@stacksjs/cms 0.70.53 → 0.70.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/authors/destroy.ts +55 -0
- package/src/authors/fetch.ts +70 -0
- package/src/authors/index.ts +21 -0
- package/src/authors/store.ts +150 -0
- package/src/authors/update.ts +42 -0
- package/src/build.ts +1382 -0
- package/src/categorizables/destroy.ts +64 -0
- package/src/categorizables/fetch.ts +121 -0
- package/src/categorizables/index.ts +22 -0
- package/src/categorizables/store.ts +140 -0
- package/src/categorizables/update.ts +57 -0
- package/src/commentables/destroy.ts +64 -0
- package/src/commentables/fetch.ts +319 -0
- package/src/commentables/index.ts +25 -0
- package/src/commentables/store.ts +156 -0
- package/src/commentables/update.ts +75 -0
- package/src/database.ts +9 -0
- package/src/index.ts +42 -0
- package/src/pages/destroy.ts +64 -0
- package/src/pages/fetch.ts +82 -0
- package/src/pages/index.ts +21 -0
- package/src/pages/store.ts +42 -0
- package/src/pages/update.ts +39 -0
- package/src/posts/destroy.ts +90 -0
- package/src/posts/fetch.ts +82 -0
- package/src/posts/index.ts +25 -0
- package/src/posts/store.ts +213 -0
- package/src/posts/update.ts +46 -0
- package/src/taggables/destroy.ts +49 -0
- package/src/taggables/fetch.ts +282 -0
- package/src/taggables/index.ts +17 -0
- package/src/taggables/store.ts +122 -0
- package/src/taggables/update.ts +74 -0
- package/src/tests/categorizables.test.ts +66 -0
- package/src/tests/setup.ts +120 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,1382 @@
|
|
|
1
|
+
import type { BlogConfig } from '../../../../config/blog'
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { path as p } from '@stacksjs/path'
|
|
5
|
+
|
|
6
|
+
export interface BuildBlogOptions {
|
|
7
|
+
config: BlogConfig
|
|
8
|
+
outDir: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PostRow {
|
|
12
|
+
id: number
|
|
13
|
+
title: string
|
|
14
|
+
slug?: string | null
|
|
15
|
+
content: string
|
|
16
|
+
body?: string | null
|
|
17
|
+
excerpt?: string | null
|
|
18
|
+
poster?: string | null
|
|
19
|
+
status: string
|
|
20
|
+
published_at?: string | null
|
|
21
|
+
views?: number | null
|
|
22
|
+
is_featured?: number | null
|
|
23
|
+
author_id?: number | null
|
|
24
|
+
created_at?: string | null
|
|
25
|
+
updated_at?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface AuthorRow {
|
|
29
|
+
id: number
|
|
30
|
+
name: string
|
|
31
|
+
email: string
|
|
32
|
+
bio?: string | null
|
|
33
|
+
avatar?: string | null
|
|
34
|
+
social_links?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getSlug(post: PostRow): string {
|
|
38
|
+
if (post.slug && typeof post.slug === 'string' && post.slug !== 'null') {
|
|
39
|
+
return post.slug
|
|
40
|
+
}
|
|
41
|
+
return `post-${post.id}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeHtml(str: string): string {
|
|
45
|
+
return str
|
|
46
|
+
.replace(/&/g, '&')
|
|
47
|
+
.replace(/</g, '<')
|
|
48
|
+
.replace(/>/g, '>')
|
|
49
|
+
.replace(/"/g, '"')
|
|
50
|
+
.replace(/'/g, ''')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeXml(str: string): string {
|
|
54
|
+
return escapeHtml(str)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatDate(dateStr: string): string {
|
|
58
|
+
const date = new Date(dateStr)
|
|
59
|
+
return date.toLocaleDateString('en-US', {
|
|
60
|
+
year: 'numeric',
|
|
61
|
+
month: 'long',
|
|
62
|
+
day: 'numeric',
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatRssDate(dateStr: string): string {
|
|
67
|
+
return new Date(dateStr).toUTCString()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function estimateReadingTime(text: string): number {
|
|
71
|
+
const words = text.split(/\s+/).length
|
|
72
|
+
return Math.max(1, Math.ceil(words / 200))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderMarkdownish(text: string): string {
|
|
76
|
+
// Simple markdown-like rendering for paragraphs, headings, code blocks, bold, links
|
|
77
|
+
const lines = text.split('\n')
|
|
78
|
+
const result: string[] = []
|
|
79
|
+
let inCodeBlock = false
|
|
80
|
+
let codeBuffer: string[] = []
|
|
81
|
+
let paragraphBuffer: string[] = []
|
|
82
|
+
|
|
83
|
+
function flushParagraph() {
|
|
84
|
+
if (paragraphBuffer.length > 0) {
|
|
85
|
+
const text = paragraphBuffer.join(' ')
|
|
86
|
+
if (text.trim()) {
|
|
87
|
+
result.push(`<p>${inlineFormat(text.trim())}</p>`)
|
|
88
|
+
}
|
|
89
|
+
paragraphBuffer = []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function inlineFormat(str: string): string {
|
|
94
|
+
// Bold: **text**
|
|
95
|
+
str = str.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
96
|
+
// Inline code: `text`
|
|
97
|
+
str = str.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
98
|
+
// Links: [text](url)
|
|
99
|
+
str = str.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
100
|
+
return str
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (line.startsWith('```')) {
|
|
105
|
+
if (inCodeBlock) {
|
|
106
|
+
result.push(`<pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre>`)
|
|
107
|
+
codeBuffer = []
|
|
108
|
+
inCodeBlock = false
|
|
109
|
+
} else {
|
|
110
|
+
flushParagraph()
|
|
111
|
+
inCodeBlock = true
|
|
112
|
+
}
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (inCodeBlock) {
|
|
117
|
+
codeBuffer.push(line)
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (line.trim() === '') {
|
|
122
|
+
flushParagraph()
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (line.startsWith('## ')) {
|
|
127
|
+
flushParagraph()
|
|
128
|
+
result.push(`<h2>${inlineFormat(escapeHtml(line.slice(3)))}</h2>`)
|
|
129
|
+
} else if (line.startsWith('### ')) {
|
|
130
|
+
flushParagraph()
|
|
131
|
+
result.push(`<h3>${inlineFormat(escapeHtml(line.slice(4)))}</h3>`)
|
|
132
|
+
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
133
|
+
flushParagraph()
|
|
134
|
+
result.push(`<ul><li>${inlineFormat(escapeHtml(line.slice(2)))}</li></ul>`)
|
|
135
|
+
} else {
|
|
136
|
+
paragraphBuffer.push(escapeHtml(line))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (inCodeBlock && codeBuffer.length) {
|
|
141
|
+
result.push(`<pre><code>${escapeHtml(codeBuffer.join('\n'))}</code></pre>`)
|
|
142
|
+
}
|
|
143
|
+
flushParagraph()
|
|
144
|
+
|
|
145
|
+
// Merge consecutive <ul> elements
|
|
146
|
+
return result.join('\n').replace(/<\/ul>\n<ul>/g, '\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function blogFontFaces(fontPath = '/assets/fonts/nps'): string {
|
|
150
|
+
return `@font-face {
|
|
151
|
+
font-family: 'Campmate Script';
|
|
152
|
+
src: url('${fontPath}/CampmateScript-Regular.woff2') format('woff2');
|
|
153
|
+
font-weight: 400;
|
|
154
|
+
font-style: normal;
|
|
155
|
+
font-display: swap;
|
|
156
|
+
}
|
|
157
|
+
@font-face {
|
|
158
|
+
font-family: 'Sequoia Sans';
|
|
159
|
+
src: url('${fontPath}/SequoiaSans-Regular.woff2') format('woff2');
|
|
160
|
+
font-weight: 400;
|
|
161
|
+
font-style: normal;
|
|
162
|
+
font-display: swap;
|
|
163
|
+
}
|
|
164
|
+
@font-face {
|
|
165
|
+
font-family: 'Switchback';
|
|
166
|
+
src: url('${fontPath}/Switchback-Regular.woff2') format('woff2');
|
|
167
|
+
font-weight: 400;
|
|
168
|
+
font-style: normal;
|
|
169
|
+
font-display: swap;
|
|
170
|
+
}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function copyBlogFonts(outDir: string): void {
|
|
174
|
+
const sourceDir = p.frameworkPath('defaults/resources/assets/fonts/nps')
|
|
175
|
+
if (!existsSync(sourceDir))
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
const targetDir = join(outDir, 'assets', 'fonts', 'nps')
|
|
179
|
+
ensureDir(targetDir)
|
|
180
|
+
|
|
181
|
+
for (const file of readdirSync(sourceDir)) {
|
|
182
|
+
if (file.endsWith('.woff2'))
|
|
183
|
+
copyFileSync(join(sourceDir, file), join(targetDir, file))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function copyBlogImages(outDir: string): void {
|
|
188
|
+
const sourceDir = p.frameworkPath('defaults/resources/assets/images')
|
|
189
|
+
if (!existsSync(sourceDir))
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
const targetDir = join(outDir, 'assets', 'images')
|
|
193
|
+
ensureDir(targetDir)
|
|
194
|
+
|
|
195
|
+
for (const file of readdirSync(sourceDir)) {
|
|
196
|
+
if (file.endsWith('.svg'))
|
|
197
|
+
copyFileSync(join(sourceDir, file), join(targetDir, file))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sqliteDatabasePath(): string {
|
|
202
|
+
const configuredPath = process.env.DB_DATABASE_PATH || 'database/stacks.sqlite'
|
|
203
|
+
return configuredPath.startsWith('/') ? configuredPath : p.projectPath(configuredPath)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function shouldUseLocalSqlite(): boolean {
|
|
207
|
+
return (process.env.DB_CONNECTION || 'sqlite') === 'sqlite' && existsSync(sqliteDatabasePath())
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function queryLocalSqlite<T>(sql: string): Promise<T[] | null> {
|
|
211
|
+
if (!shouldUseLocalSqlite())
|
|
212
|
+
return null
|
|
213
|
+
|
|
214
|
+
const { Database } = await import('bun:sqlite')
|
|
215
|
+
const sqlite = new Database(sqliteDatabasePath(), { readonly: true })
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
return sqlite.query(sql).all() as T[]
|
|
219
|
+
} finally {
|
|
220
|
+
sqlite.close()
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
225
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
226
|
+
try {
|
|
227
|
+
return await Promise.race([
|
|
228
|
+
promise,
|
|
229
|
+
new Promise<T>((_, reject) => {
|
|
230
|
+
timeout = setTimeout(() => reject(new Error(`CMS database query timed out after ${ms}ms`)), ms)
|
|
231
|
+
}),
|
|
232
|
+
])
|
|
233
|
+
} finally {
|
|
234
|
+
if (timeout)
|
|
235
|
+
clearTimeout(timeout)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function fetchPublishedPosts(): Promise<PostRow[]> {
|
|
240
|
+
const sqlitePosts = await queryLocalSqlite<PostRow>(
|
|
241
|
+
'select * from posts where status = \'published\' order by published_at desc',
|
|
242
|
+
)
|
|
243
|
+
if (sqlitePosts)
|
|
244
|
+
return sqlitePosts
|
|
245
|
+
|
|
246
|
+
const { db } = await import('@stacksjs/database')
|
|
247
|
+
return await withTimeout(
|
|
248
|
+
db
|
|
249
|
+
.selectFrom('posts')
|
|
250
|
+
.where('status', '=', 'published')
|
|
251
|
+
.orderBy('published_at', 'desc')
|
|
252
|
+
.selectAll()
|
|
253
|
+
.execute() as Promise<PostRow[]>,
|
|
254
|
+
3000,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function fetchAuthors(): Promise<AuthorRow[]> {
|
|
259
|
+
const sqliteAuthors = await queryLocalSqlite<AuthorRow>('select * from authors order by created_at desc')
|
|
260
|
+
if (sqliteAuthors)
|
|
261
|
+
return sqliteAuthors
|
|
262
|
+
|
|
263
|
+
const { db } = await import('@stacksjs/database')
|
|
264
|
+
return await withTimeout(
|
|
265
|
+
db
|
|
266
|
+
.selectFrom('authors')
|
|
267
|
+
.selectAll()
|
|
268
|
+
.execute() as Promise<AuthorRow[]>,
|
|
269
|
+
3000,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function generateLayout(config: BlogConfig, title: string, content: string, _options?: { isPost?: boolean }): string {
|
|
274
|
+
const pageTitle = title === config.title ? config.title : `${escapeHtml(title)} | ${escapeHtml(config.title)}`
|
|
275
|
+
|
|
276
|
+
return `<!DOCTYPE html>
|
|
277
|
+
<html lang="en">
|
|
278
|
+
<head>
|
|
279
|
+
<meta charset="UTF-8">
|
|
280
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
281
|
+
<title>${pageTitle}</title>
|
|
282
|
+
<meta name="description" content="${escapeHtml(config.description)}">
|
|
283
|
+
<link rel="alternate" type="application/rss+xml" title="${escapeHtml(config.title)}" href="/feed.xml">
|
|
284
|
+
<style>
|
|
285
|
+
${blogFontFaces()}
|
|
286
|
+
|
|
287
|
+
:root {
|
|
288
|
+
--primary: ${config.theme.primaryColor};
|
|
289
|
+
--primary-hover: #24472d;
|
|
290
|
+
--primary-soft: #e4ead8;
|
|
291
|
+
--primary-deep: #18351f;
|
|
292
|
+
--on-primary: #fff7e7;
|
|
293
|
+
--on-primary-muted: #f3dfbd;
|
|
294
|
+
--accent: #b7792d;
|
|
295
|
+
--accent-hover: #96621f;
|
|
296
|
+
--accent-strong: #8b5316;
|
|
297
|
+
--accent-soft: #f5dfb8;
|
|
298
|
+
--river: #4e8f88;
|
|
299
|
+
--river-soft: #cfe8db;
|
|
300
|
+
--canopy: #326d43;
|
|
301
|
+
--post-hover-border: #b7792d;
|
|
302
|
+
--newsletter-border: #6e8f5f;
|
|
303
|
+
--newsletter-bg: #2d5938;
|
|
304
|
+
--newsletter-bg-end: #18351f;
|
|
305
|
+
--bg: #f7f0e3;
|
|
306
|
+
--paper: #fffaf0;
|
|
307
|
+
--bg-soft: #efe4d1;
|
|
308
|
+
--bg-muted: #eadbc1;
|
|
309
|
+
--text: #273128;
|
|
310
|
+
--text-light: #56624f;
|
|
311
|
+
--text-lighter: #7d806f;
|
|
312
|
+
--border: #d5c5a6;
|
|
313
|
+
--shadow: 0 14px 32px -24px rgba(53, 39, 19, 0.55);
|
|
314
|
+
--max-width: 820px;
|
|
315
|
+
--radius: 8px;
|
|
316
|
+
--focus-ring: 0 0 0 3px rgba(245, 223, 184, 0.45);
|
|
317
|
+
--font-display: 'Campmate Script', 'Sequoia Sans', system-ui, sans-serif;
|
|
318
|
+
--font-sans: 'Sequoia Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
319
|
+
--font-serif: 'Switchback', Georgia, serif;
|
|
320
|
+
}
|
|
321
|
+
@media (prefers-color-scheme: dark) {
|
|
322
|
+
:root {
|
|
323
|
+
--primary: #326d43;
|
|
324
|
+
--primary-hover: #5d9a66;
|
|
325
|
+
--primary-soft: #d9ead4;
|
|
326
|
+
--primary-deep: #142419;
|
|
327
|
+
--on-primary: #fff6e6;
|
|
328
|
+
--on-primary-muted: #eadcc0;
|
|
329
|
+
--accent: #d58a2e;
|
|
330
|
+
--accent-hover: #f0a846;
|
|
331
|
+
--accent-strong: #f2b04f;
|
|
332
|
+
--accent-soft: #ffe3ad;
|
|
333
|
+
--river: #6fb1a6;
|
|
334
|
+
--river-soft: #d9fff0;
|
|
335
|
+
--canopy: #4f8e55;
|
|
336
|
+
--post-hover-border: #d58a2e;
|
|
337
|
+
--newsletter-border: #6f955f;
|
|
338
|
+
--newsletter-bg: #2f6840;
|
|
339
|
+
--newsletter-bg-end: #142419;
|
|
340
|
+
--bg: #101811;
|
|
341
|
+
--paper: #19251b;
|
|
342
|
+
--bg-soft: #213321;
|
|
343
|
+
--bg-muted: #2a3d28;
|
|
344
|
+
--text: #fff2dc;
|
|
345
|
+
--text-light: #e8dcc3;
|
|
346
|
+
--text-lighter: #c9bda3;
|
|
347
|
+
--border: #4f7650;
|
|
348
|
+
--shadow: 0 18px 46px -26px rgba(0, 0, 0, 0.95);
|
|
349
|
+
--focus-ring: 0 0 0 3px rgba(255, 227, 173, 0.42);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
353
|
+
body {
|
|
354
|
+
position: relative;
|
|
355
|
+
font-family: var(--font-sans);
|
|
356
|
+
background: var(--bg);
|
|
357
|
+
background-image:
|
|
358
|
+
radial-gradient(circle at top left, rgba(183, 121, 45, 0.09), transparent 24rem),
|
|
359
|
+
linear-gradient(180deg, rgba(255, 250, 240, 0.7), rgba(239, 228, 209, 0.48));
|
|
360
|
+
color: var(--text);
|
|
361
|
+
line-height: 1.7;
|
|
362
|
+
-webkit-font-smoothing: antialiased;
|
|
363
|
+
}
|
|
364
|
+
body::before {
|
|
365
|
+
content: '';
|
|
366
|
+
position: fixed;
|
|
367
|
+
inset: 0;
|
|
368
|
+
pointer-events: none;
|
|
369
|
+
background-image: url('/assets/images/topography.svg');
|
|
370
|
+
background-size: 520px 520px;
|
|
371
|
+
background-repeat: repeat;
|
|
372
|
+
opacity: 0.025;
|
|
373
|
+
z-index: 0;
|
|
374
|
+
}
|
|
375
|
+
body::after {
|
|
376
|
+
content: '';
|
|
377
|
+
position: fixed;
|
|
378
|
+
left: 0;
|
|
379
|
+
right: 0;
|
|
380
|
+
bottom: 0;
|
|
381
|
+
height: min(42vh, 26rem);
|
|
382
|
+
pointer-events: none;
|
|
383
|
+
background-image: url('/assets/images/park-ridge.svg');
|
|
384
|
+
background-position: center bottom;
|
|
385
|
+
background-repeat: no-repeat;
|
|
386
|
+
background-size: cover;
|
|
387
|
+
opacity: 0.12;
|
|
388
|
+
z-index: 0;
|
|
389
|
+
}
|
|
390
|
+
body > * {
|
|
391
|
+
position: relative;
|
|
392
|
+
z-index: 1;
|
|
393
|
+
}
|
|
394
|
+
@media (prefers-color-scheme: dark) {
|
|
395
|
+
body {
|
|
396
|
+
background-image:
|
|
397
|
+
radial-gradient(circle at 50% -10%, rgba(82, 126, 69, 0.18), transparent 32rem),
|
|
398
|
+
radial-gradient(circle at 15% 18%, rgba(213, 138, 46, 0.08), transparent 24rem),
|
|
399
|
+
linear-gradient(180deg, #142018 0%, #101811 56%, #0d150f 100%);
|
|
400
|
+
}
|
|
401
|
+
body::before { opacity: 0.045; }
|
|
402
|
+
body::after { opacity: 0.2; }
|
|
403
|
+
}
|
|
404
|
+
a { color: var(--primary); text-decoration: none; transition: color 0.15s, opacity 0.15s, border-color 0.15s; }
|
|
405
|
+
a:hover { color: var(--primary-hover); opacity: 1; }
|
|
406
|
+
a:focus-visible,
|
|
407
|
+
button:focus-visible,
|
|
408
|
+
input:focus-visible {
|
|
409
|
+
outline: none;
|
|
410
|
+
box-shadow: var(--focus-ring);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* Header */
|
|
414
|
+
.header {
|
|
415
|
+
border-bottom: 3px solid var(--accent);
|
|
416
|
+
padding: 0.875rem 2rem;
|
|
417
|
+
display: flex;
|
|
418
|
+
align-items: center;
|
|
419
|
+
justify-content: space-between;
|
|
420
|
+
background: var(--primary);
|
|
421
|
+
position: sticky;
|
|
422
|
+
top: 0;
|
|
423
|
+
z-index: 10;
|
|
424
|
+
box-shadow: 0 12px 30px -20px rgba(0, 0, 0, 0.85);
|
|
425
|
+
}
|
|
426
|
+
.header .site-title {
|
|
427
|
+
font-family: var(--font-display);
|
|
428
|
+
font-size: 1.125rem;
|
|
429
|
+
font-weight: 750;
|
|
430
|
+
color: var(--on-primary);
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: center;
|
|
433
|
+
gap: 0.5rem;
|
|
434
|
+
}
|
|
435
|
+
.header .site-title svg { height: 28px; width: 28px; color: var(--accent-soft); }
|
|
436
|
+
.header nav { display: flex; align-items: center; gap: 1.25rem; }
|
|
437
|
+
.header nav a { color: var(--on-primary-muted); font-size: 0.875rem; font-weight: 700; }
|
|
438
|
+
.header nav a:hover { color: var(--on-primary); opacity: 1; }
|
|
439
|
+
|
|
440
|
+
/* Container */
|
|
441
|
+
.container { max-width: var(--max-width); margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
|
|
442
|
+
|
|
443
|
+
/* Hero */
|
|
444
|
+
.hero {
|
|
445
|
+
position: relative;
|
|
446
|
+
overflow: hidden;
|
|
447
|
+
text-align: center;
|
|
448
|
+
padding: 2.75rem 2rem 2.5rem;
|
|
449
|
+
border: 1px solid var(--border);
|
|
450
|
+
border-top: 4px solid var(--accent);
|
|
451
|
+
border-radius: var(--radius);
|
|
452
|
+
background: var(--paper);
|
|
453
|
+
box-shadow: var(--shadow);
|
|
454
|
+
margin-bottom: 1.5rem;
|
|
455
|
+
}
|
|
456
|
+
.hero::before {
|
|
457
|
+
content: '';
|
|
458
|
+
position: absolute;
|
|
459
|
+
inset: 0;
|
|
460
|
+
background-image: url('/assets/images/topography.svg');
|
|
461
|
+
background-size: 420px 420px;
|
|
462
|
+
opacity: 0.045;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
}
|
|
465
|
+
.hero::after {
|
|
466
|
+
content: '';
|
|
467
|
+
position: absolute;
|
|
468
|
+
left: 0;
|
|
469
|
+
right: 0;
|
|
470
|
+
bottom: -1px;
|
|
471
|
+
height: 46%;
|
|
472
|
+
background-image: url('/assets/images/park-ridge.svg');
|
|
473
|
+
background-position: center bottom;
|
|
474
|
+
background-repeat: no-repeat;
|
|
475
|
+
background-size: 110% auto;
|
|
476
|
+
opacity: 0.16;
|
|
477
|
+
pointer-events: none;
|
|
478
|
+
}
|
|
479
|
+
.hero > * {
|
|
480
|
+
position: relative;
|
|
481
|
+
z-index: 1;
|
|
482
|
+
}
|
|
483
|
+
.park-emblem {
|
|
484
|
+
width: 92px;
|
|
485
|
+
height: 92px;
|
|
486
|
+
margin: 0 auto 0.85rem;
|
|
487
|
+
color: var(--primary);
|
|
488
|
+
filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.18));
|
|
489
|
+
}
|
|
490
|
+
.park-emblem svg {
|
|
491
|
+
width: 100%;
|
|
492
|
+
height: 100%;
|
|
493
|
+
display: block;
|
|
494
|
+
}
|
|
495
|
+
.hero h1 {
|
|
496
|
+
font-family: var(--font-display);
|
|
497
|
+
font-size: 2rem;
|
|
498
|
+
font-weight: 850;
|
|
499
|
+
margin-bottom: 0.5rem;
|
|
500
|
+
color: var(--text);
|
|
501
|
+
}
|
|
502
|
+
.hero p { color: var(--text-light); font-size: 1.05rem; max-width: 540px; margin: 0 auto; font-weight: 700; }
|
|
503
|
+
|
|
504
|
+
/* Post list */
|
|
505
|
+
.post-list { list-style: none; }
|
|
506
|
+
.post-item {
|
|
507
|
+
position: relative;
|
|
508
|
+
overflow: hidden;
|
|
509
|
+
padding: 1.5rem;
|
|
510
|
+
border: 1px solid var(--border);
|
|
511
|
+
border-left: 4px solid var(--primary);
|
|
512
|
+
border-radius: var(--radius);
|
|
513
|
+
background: var(--paper);
|
|
514
|
+
box-shadow: var(--shadow);
|
|
515
|
+
margin-bottom: 1rem;
|
|
516
|
+
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
|
517
|
+
}
|
|
518
|
+
.post-item:hover {
|
|
519
|
+
border-color: var(--post-hover-border);
|
|
520
|
+
box-shadow: 0 20px 48px -26px rgba(0, 0, 0, 0.72);
|
|
521
|
+
transform: translateY(-1px);
|
|
522
|
+
}
|
|
523
|
+
.post-item:last-child { border-bottom: 1px solid var(--border); }
|
|
524
|
+
.post-tag {
|
|
525
|
+
display: inline-block;
|
|
526
|
+
font-size: 0.7rem;
|
|
527
|
+
font-weight: 600;
|
|
528
|
+
text-transform: uppercase;
|
|
529
|
+
letter-spacing: 0.05em;
|
|
530
|
+
color: var(--primary);
|
|
531
|
+
background: var(--primary-soft);
|
|
532
|
+
padding: 0.15rem 0.5rem;
|
|
533
|
+
border-radius: 4px;
|
|
534
|
+
margin-bottom: 0.5rem;
|
|
535
|
+
}
|
|
536
|
+
.post-title { font-family: var(--font-display); font-size: 1.375rem; font-weight: 750; margin-bottom: 0.4rem; }
|
|
537
|
+
.post-title a { color: var(--text); }
|
|
538
|
+
.post-title a:hover { color: var(--accent-strong); opacity: 1; }
|
|
539
|
+
.post-meta {
|
|
540
|
+
color: var(--text-lighter);
|
|
541
|
+
font-size: 0.8125rem;
|
|
542
|
+
margin-bottom: 0.6rem;
|
|
543
|
+
display: flex;
|
|
544
|
+
align-items: center;
|
|
545
|
+
gap: 0.5rem;
|
|
546
|
+
flex-wrap: wrap;
|
|
547
|
+
font-weight: 800;
|
|
548
|
+
text-transform: uppercase;
|
|
549
|
+
}
|
|
550
|
+
.post-meta .sep { color: var(--border); }
|
|
551
|
+
.post-excerpt { font-family: var(--font-serif); color: var(--text-light); line-height: 1.72; font-size: 0.975rem; }
|
|
552
|
+
.read-more { display: inline-block; margin-top: 0.75rem; font-size: 0.875rem; font-weight: 800; color: var(--accent-strong); }
|
|
553
|
+
.read-more:hover { color: var(--primary-hover); }
|
|
554
|
+
|
|
555
|
+
/* Newsletter */
|
|
556
|
+
.newsletter-card {
|
|
557
|
+
position: relative;
|
|
558
|
+
overflow: hidden;
|
|
559
|
+
display: grid;
|
|
560
|
+
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr);
|
|
561
|
+
gap: 1.5rem;
|
|
562
|
+
align-items: center;
|
|
563
|
+
padding: 1.5rem;
|
|
564
|
+
margin: 0 0 1rem;
|
|
565
|
+
border: 1px solid var(--newsletter-border);
|
|
566
|
+
border-top: 4px solid var(--accent);
|
|
567
|
+
border-radius: var(--radius);
|
|
568
|
+
background:
|
|
569
|
+
linear-gradient(135deg, var(--newsletter-bg), var(--newsletter-bg-end));
|
|
570
|
+
color: var(--on-primary);
|
|
571
|
+
box-shadow: var(--shadow);
|
|
572
|
+
}
|
|
573
|
+
.newsletter-card::before {
|
|
574
|
+
content: '';
|
|
575
|
+
position: absolute;
|
|
576
|
+
inset: 0;
|
|
577
|
+
background-image: url('/assets/images/topography.svg');
|
|
578
|
+
background-size: 360px 360px;
|
|
579
|
+
opacity: 0.09;
|
|
580
|
+
pointer-events: none;
|
|
581
|
+
}
|
|
582
|
+
.newsletter-content,
|
|
583
|
+
.newsletter-form {
|
|
584
|
+
position: relative;
|
|
585
|
+
z-index: 1;
|
|
586
|
+
}
|
|
587
|
+
.newsletter-eyebrow {
|
|
588
|
+
color: var(--accent-soft);
|
|
589
|
+
font-size: 0.72rem;
|
|
590
|
+
font-weight: 700;
|
|
591
|
+
letter-spacing: 0.08em;
|
|
592
|
+
text-transform: uppercase;
|
|
593
|
+
margin-bottom: 0.35rem;
|
|
594
|
+
}
|
|
595
|
+
.newsletter-card h2 {
|
|
596
|
+
font-family: var(--font-display);
|
|
597
|
+
font-size: 1.35rem;
|
|
598
|
+
line-height: 1.2;
|
|
599
|
+
margin-bottom: 0.4rem;
|
|
600
|
+
color: var(--on-primary);
|
|
601
|
+
}
|
|
602
|
+
.newsletter-card p {
|
|
603
|
+
color: var(--on-primary-muted);
|
|
604
|
+
font-family: var(--font-serif);
|
|
605
|
+
font-size: 0.95rem;
|
|
606
|
+
line-height: 1.55;
|
|
607
|
+
}
|
|
608
|
+
.newsletter-form {
|
|
609
|
+
display: flex;
|
|
610
|
+
gap: 0.5rem;
|
|
611
|
+
align-items: center;
|
|
612
|
+
}
|
|
613
|
+
.newsletter-form input {
|
|
614
|
+
min-width: 13rem;
|
|
615
|
+
flex: 1;
|
|
616
|
+
height: 3rem;
|
|
617
|
+
border: 1px solid rgba(255, 247, 231, 0.65);
|
|
618
|
+
border-radius: 6px;
|
|
619
|
+
background: #fff8ea;
|
|
620
|
+
color: #263126;
|
|
621
|
+
padding: 0 0.85rem;
|
|
622
|
+
font: inherit;
|
|
623
|
+
line-height: normal;
|
|
624
|
+
font-weight: 700;
|
|
625
|
+
}
|
|
626
|
+
.newsletter-form input::placeholder { color: #6b6f65; opacity: 1; }
|
|
627
|
+
.newsletter-form button {
|
|
628
|
+
height: 3rem;
|
|
629
|
+
border: 0;
|
|
630
|
+
border-radius: 6px;
|
|
631
|
+
background: var(--accent);
|
|
632
|
+
color: #fff8ea;
|
|
633
|
+
cursor: pointer;
|
|
634
|
+
font: inherit;
|
|
635
|
+
line-height: normal;
|
|
636
|
+
font-weight: 800;
|
|
637
|
+
padding: 0 1rem;
|
|
638
|
+
white-space: nowrap;
|
|
639
|
+
}
|
|
640
|
+
.newsletter-form button:hover {
|
|
641
|
+
background: var(--accent-hover);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Featured badge */
|
|
645
|
+
.featured-badge {
|
|
646
|
+
display: inline-block;
|
|
647
|
+
background: var(--accent);
|
|
648
|
+
color: #fff8ea;
|
|
649
|
+
font-size: 0.6875rem;
|
|
650
|
+
font-weight: 600;
|
|
651
|
+
padding: 0.125rem 0.5rem;
|
|
652
|
+
border-radius: 4px;
|
|
653
|
+
margin-left: 0.5rem;
|
|
654
|
+
vertical-align: middle;
|
|
655
|
+
text-transform: uppercase;
|
|
656
|
+
letter-spacing: 0.03em;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/* Post page */
|
|
660
|
+
.back-link { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text-light); font-size: 0.875rem; font-weight: 500; margin-bottom: 1.5rem; }
|
|
661
|
+
.back-link:hover { color: var(--accent-strong); }
|
|
662
|
+
.post-header {
|
|
663
|
+
margin-bottom: 2rem;
|
|
664
|
+
padding-bottom: 1.5rem;
|
|
665
|
+
border-bottom: 1px solid var(--border);
|
|
666
|
+
}
|
|
667
|
+
.post-header h1 { font-family: var(--font-display); font-size: 2.25rem; font-weight: 850; margin-bottom: 0.75rem; line-height: 1.25; }
|
|
668
|
+
.post-poster { width: 100%; border-radius: 12px; margin-bottom: 2rem; aspect-ratio: 16/9; object-fit: cover; }
|
|
669
|
+
.author-info { display: flex; align-items: center; gap: 0.75rem; margin-top: 1rem; }
|
|
670
|
+
.author-avatar { width: 36px; height: 36px; border-radius: 50%; object-fit: cover; }
|
|
671
|
+
.author-name { font-weight: 600; font-size: 0.9rem; }
|
|
672
|
+
.author-bio { color: var(--text-light); font-size: 0.8125rem; }
|
|
673
|
+
|
|
674
|
+
/* Post content */
|
|
675
|
+
.post-content { font-family: var(--font-serif); line-height: 1.8; font-size: 1.0625rem; }
|
|
676
|
+
.post-content p { margin-bottom: 1.5rem; }
|
|
677
|
+
.post-content h2 { font-family: var(--font-sans); margin-top: 2.5rem; margin-bottom: 0.75rem; font-size: 1.5rem; font-weight: 700; }
|
|
678
|
+
.post-content h3 { font-family: var(--font-sans); margin-top: 2rem; margin-bottom: 0.5rem; font-size: 1.25rem; font-weight: 600; }
|
|
679
|
+
.post-content img { max-width: 100%; border-radius: var(--radius); margin: 1.5rem 0; }
|
|
680
|
+
.post-content pre { background: var(--bg-soft); color: var(--text); padding: 1.25rem; border-radius: var(--radius); overflow-x: auto; margin: 1.5rem 0; border: 1px solid var(--border); font-size: 0.875rem; }
|
|
681
|
+
.post-content code { font-family: 'Fira Code', 'JetBrains Mono', monospace; font-size: 0.875em; }
|
|
682
|
+
.post-content p code { background: var(--bg-muted); padding: 0.15em 0.4em; border-radius: 4px; font-size: 0.85em; }
|
|
683
|
+
.post-content ul { margin-bottom: 1.5rem; padding-left: 1.5rem; }
|
|
684
|
+
.post-content li { margin-bottom: 0.35rem; }
|
|
685
|
+
.post-content a { text-decoration: underline; text-underline-offset: 2px; }
|
|
686
|
+
.post-content strong { font-weight: 600; }
|
|
687
|
+
|
|
688
|
+
/* Post footer */
|
|
689
|
+
.post-footer { margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--border); }
|
|
690
|
+
.post-footer .author-card { display: flex; gap: 1rem; align-items: flex-start; padding: 1.25rem; background: var(--paper); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
691
|
+
.post-footer .author-card img { width: 48px; height: 48px; border-radius: 50%; }
|
|
692
|
+
|
|
693
|
+
/* Footer */
|
|
694
|
+
.footer {
|
|
695
|
+
border-top: 3px solid var(--accent);
|
|
696
|
+
padding: 2rem;
|
|
697
|
+
text-align: center;
|
|
698
|
+
color: var(--on-primary-muted);
|
|
699
|
+
font-size: 0.8125rem;
|
|
700
|
+
margin-top: 2rem;
|
|
701
|
+
background: var(--primary);
|
|
702
|
+
}
|
|
703
|
+
.footer a { color: var(--accent-soft); font-weight: 800; }
|
|
704
|
+
|
|
705
|
+
/* Pagination */
|
|
706
|
+
.pagination { display: flex; justify-content: center; gap: 0.75rem; margin-top: 2.5rem; }
|
|
707
|
+
.pagination a {
|
|
708
|
+
padding: 0.5rem 1.25rem;
|
|
709
|
+
border: 1px solid var(--border);
|
|
710
|
+
border-radius: 6px;
|
|
711
|
+
font-size: 0.875rem;
|
|
712
|
+
font-weight: 500;
|
|
713
|
+
color: var(--text);
|
|
714
|
+
background: var(--paper);
|
|
715
|
+
}
|
|
716
|
+
.pagination a:hover { border-color: var(--accent); color: var(--accent-strong); opacity: 1; }
|
|
717
|
+
|
|
718
|
+
@media (max-width: 640px) {
|
|
719
|
+
.header { padding: 0.75rem 1rem; gap: 0.75rem; }
|
|
720
|
+
.header .site-title { font-size: 0.95rem; white-space: nowrap; }
|
|
721
|
+
.header .site-title svg { height: 18px; width: 18px; }
|
|
722
|
+
.header nav { gap: 0.75rem; }
|
|
723
|
+
.header nav a { font-size: 0.75rem; }
|
|
724
|
+
.park-emblem { width: 76px; height: 76px; }
|
|
725
|
+
.hero h1 { font-size: 1.5rem; }
|
|
726
|
+
.hero::after { height: 38%; background-size: 180% auto; }
|
|
727
|
+
.newsletter-card { grid-template-columns: 1fr; padding: 1.25rem; }
|
|
728
|
+
.newsletter-form { flex-direction: column; align-items: stretch; }
|
|
729
|
+
.newsletter-form input,
|
|
730
|
+
.newsletter-form button { width: 100%; }
|
|
731
|
+
.post-title { font-size: 1.15rem; }
|
|
732
|
+
.post-header h1 { font-size: 1.75rem; }
|
|
733
|
+
.container { padding: 1.5rem 1rem 3rem; }
|
|
734
|
+
}
|
|
735
|
+
</style>
|
|
736
|
+
</head>
|
|
737
|
+
<body>
|
|
738
|
+
<header class="header">
|
|
739
|
+
<a href="/" class="site-title">
|
|
740
|
+
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
|
741
|
+
<path d="M16 2.5 27.7 8v10.3c0 4.8-3.1 8.7-11.7 11.2C7.4 27 4.3 23.1 4.3 18.3V8L16 2.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
|
|
742
|
+
<path d="m8.7 19.2 5.2-6.6 3 3.8 2.3-2.8 4.2 5.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
743
|
+
<path d="M11.4 21.8c2.4-1 4.3-.9 6.2 0 1.5.7 2.8.8 4.7 0" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
744
|
+
<path d="M16 6.6v3.8M12.9 9.7h6.2" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
745
|
+
</svg>
|
|
746
|
+
${escapeHtml(config.title)}
|
|
747
|
+
</a>
|
|
748
|
+
<nav>
|
|
749
|
+
<a href="/">Posts</a>
|
|
750
|
+
<a href="https://stacksjs.com/docs">Docs</a>
|
|
751
|
+
${config.enableRss ? '<a href="/feed.xml">RSS</a>' : ''}
|
|
752
|
+
${config.social.github ? `<a href="https://github.com/${config.social.github}" target="_blank" rel="noopener">GitHub</a>` : ''}
|
|
753
|
+
</nav>
|
|
754
|
+
</header>
|
|
755
|
+
<main class="container">
|
|
756
|
+
${content}
|
|
757
|
+
</main>
|
|
758
|
+
<footer class="footer">
|
|
759
|
+
<p>© ${new Date().getFullYear()} ${escapeHtml(config.title)}. Built with <a href="https://stacksjs.org">Stacks</a>.</p>
|
|
760
|
+
</footer>
|
|
761
|
+
</body>
|
|
762
|
+
</html>`
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function generateNewsletterCapture(source = 'blog-static'): string {
|
|
766
|
+
return `<section id="newsletter" class="newsletter-card" aria-label="Subscribe to Stacks updates">
|
|
767
|
+
<div class="newsletter-content">
|
|
768
|
+
<div class="newsletter-eyebrow">Trail dispatch</div>
|
|
769
|
+
<h2>Get new Stacks notes by email</h2>
|
|
770
|
+
<p>Short framework updates, release notes, and field guides for building with Stacks.</p>
|
|
771
|
+
</div>
|
|
772
|
+
<form class="newsletter-form" action="https://stacksjs.com/api/email/subscribe" method="POST">
|
|
773
|
+
<input type="hidden" name="source" value="${escapeHtml(source)}">
|
|
774
|
+
<input type="email" name="email" placeholder="you@example.com" autocomplete="email" required>
|
|
775
|
+
<button type="submit">Subscribe</button>
|
|
776
|
+
</form>
|
|
777
|
+
</section>`
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function generatePostCard(post: PostRow, author?: AuthorRow): string {
|
|
781
|
+
const slug = getSlug(post)
|
|
782
|
+
const date = post.published_at ? formatDate(post.published_at) : ''
|
|
783
|
+
const authorName = author?.name || 'Stacks Team'
|
|
784
|
+
const rawExcerpt = post.excerpt || (post.body || post.content || '').slice(0, 220)
|
|
785
|
+
const excerpt = rawExcerpt.replace(/[#*`\[\]]/g, '').trim()
|
|
786
|
+
const featured = post.is_featured ? '<span class="featured-badge">Featured</span>' : ''
|
|
787
|
+
const readTime = estimateReadingTime(post.body || post.content || '')
|
|
788
|
+
|
|
789
|
+
return `<li class="post-item">
|
|
790
|
+
<h2 class="post-title"><a href="/posts/${escapeHtml(slug)}/">${escapeHtml(post.title)}${featured}</a></h2>
|
|
791
|
+
<div class="post-meta">
|
|
792
|
+
<span>${escapeHtml(authorName)}</span>
|
|
793
|
+
<span class="sep">·</span>
|
|
794
|
+
<span>${date}</span>
|
|
795
|
+
<span class="sep">·</span>
|
|
796
|
+
<span>${readTime} min read</span>
|
|
797
|
+
</div>
|
|
798
|
+
<p class="post-excerpt">${escapeHtml(excerpt)}${excerpt.length >= 200 ? '...' : ''}</p>
|
|
799
|
+
<a href="/posts/${escapeHtml(slug)}/" class="read-more">Read more →</a>
|
|
800
|
+
</li>`
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function generatePostPage(post: PostRow, config: BlogConfig, author?: AuthorRow): string {
|
|
804
|
+
const date = post.published_at ? formatDate(post.published_at) : ''
|
|
805
|
+
const bodyContent = post.body || post.content || ''
|
|
806
|
+
const authorName = author?.name || 'Stacks Team'
|
|
807
|
+
const readTime = estimateReadingTime(bodyContent)
|
|
808
|
+
|
|
809
|
+
const authorHtml = author
|
|
810
|
+
? `<div class="author-info">
|
|
811
|
+
${author.avatar ? `<img src="${escapeHtml(author.avatar)}" alt="${escapeHtml(author.name)}" class="author-avatar" />` : ''}
|
|
812
|
+
<div>
|
|
813
|
+
<div class="author-name">${escapeHtml(author.name)}</div>
|
|
814
|
+
${author.bio ? `<div class="author-bio">${escapeHtml(author.bio.slice(0, 100))}</div>` : ''}
|
|
815
|
+
</div>
|
|
816
|
+
</div>`
|
|
817
|
+
: `<div class="author-info">
|
|
818
|
+
<div>
|
|
819
|
+
<div class="author-name">${escapeHtml(authorName)}</div>
|
|
820
|
+
</div>
|
|
821
|
+
</div>`
|
|
822
|
+
|
|
823
|
+
const authorFooter = author
|
|
824
|
+
? `<div class="post-footer">
|
|
825
|
+
<div class="author-card">
|
|
826
|
+
${author.avatar ? `<img src="${escapeHtml(author.avatar)}" alt="${escapeHtml(author.name)}" />` : ''}
|
|
827
|
+
<div>
|
|
828
|
+
<div class="author-name">${escapeHtml(author.name)}</div>
|
|
829
|
+
${author.bio ? `<p style="color: var(--text-light); font-size: 0.875rem; margin-top: 0.25rem;">${escapeHtml(author.bio)}</p>` : ''}
|
|
830
|
+
</div>
|
|
831
|
+
</div>
|
|
832
|
+
</div>`
|
|
833
|
+
: ''
|
|
834
|
+
|
|
835
|
+
const content = `
|
|
836
|
+
<a href="/" class="back-link">← All posts</a>
|
|
837
|
+
<article>
|
|
838
|
+
<div class="post-header">
|
|
839
|
+
<h1>${escapeHtml(post.title)}</h1>
|
|
840
|
+
<div class="post-meta">
|
|
841
|
+
<span>${escapeHtml(authorName)}</span>
|
|
842
|
+
<span class="sep">·</span>
|
|
843
|
+
<span>${date}</span>
|
|
844
|
+
<span class="sep">·</span>
|
|
845
|
+
<span>${readTime} min read</span>
|
|
846
|
+
</div>
|
|
847
|
+
${authorHtml}
|
|
848
|
+
</div>
|
|
849
|
+
<div class="post-content">
|
|
850
|
+
${renderMarkdownish(bodyContent)}
|
|
851
|
+
</div>
|
|
852
|
+
${authorFooter}
|
|
853
|
+
</article>`
|
|
854
|
+
|
|
855
|
+
return generateLayout(config, post.title, content, { isPost: true })
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function generateIndexPage(posts: PostRow[], config: BlogConfig, authors: Map<number, AuthorRow>, page: number, totalPages: number): string {
|
|
859
|
+
const postCards = posts.map(post => {
|
|
860
|
+
const author = post.author_id ? authors.get(post.author_id) : undefined
|
|
861
|
+
return generatePostCard(post, author)
|
|
862
|
+
}).join('\n')
|
|
863
|
+
|
|
864
|
+
let pagination = ''
|
|
865
|
+
if (totalPages > 1) {
|
|
866
|
+
const prev = page > 1 ? `<a href="${page === 2 ? '/' : `/page/${page - 1}/`}">← Newer</a>` : ''
|
|
867
|
+
const next = page < totalPages ? `<a href="/page/${page + 1}/">Older →</a>` : ''
|
|
868
|
+
pagination = `<div class="pagination">${prev} ${next}</div>`
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const hero = page === 1 ? `
|
|
872
|
+
<div class="hero">
|
|
873
|
+
<div class="park-emblem" aria-hidden="true">
|
|
874
|
+
<svg viewBox="0 0 96 96" fill="none">
|
|
875
|
+
<path d="M48 6 82 22v30c0 18-11 30-34 38C25 82 14 70 14 52V22L48 6Z" fill="var(--primary)" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round"/>
|
|
876
|
+
<path d="M24 58 41 36l10 13 8-10 14 19" stroke="var(--accent-soft)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
877
|
+
<path d="M29 69c8-5 15-5 23-1 6 3 12 3 20-1" stroke="var(--river-soft)" stroke-width="5" stroke-linecap="round"/>
|
|
878
|
+
<path d="M48 18v13M38 31h20" stroke="var(--accent-soft)" stroke-width="5" stroke-linecap="round"/>
|
|
879
|
+
<path d="m28 64 8-13 8 13H28ZM59 65l7-12 7 12H59Z" fill="var(--canopy)"/>
|
|
880
|
+
</svg>
|
|
881
|
+
</div>
|
|
882
|
+
<h1>${escapeHtml(config.title)}</h1>
|
|
883
|
+
<p>${escapeHtml(config.description)}</p>
|
|
884
|
+
</div>` : ''
|
|
885
|
+
|
|
886
|
+
const content = `
|
|
887
|
+
${hero}
|
|
888
|
+
${page === 1 ? generateNewsletterCapture() : ''}
|
|
889
|
+
<ul class="post-list">
|
|
890
|
+
${postCards}
|
|
891
|
+
</ul>
|
|
892
|
+
${pagination}`
|
|
893
|
+
|
|
894
|
+
return generateLayout(config, config.title, content)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function generateRssFeed(posts: PostRow[], config: BlogConfig, domain: string): string {
|
|
898
|
+
const items = posts.map(post => {
|
|
899
|
+
const slug = getSlug(post)
|
|
900
|
+
const pubDate = post.published_at ? formatRssDate(post.published_at) : ''
|
|
901
|
+
const description = post.excerpt || (post.body || post.content || '').slice(0, 300)
|
|
902
|
+
return ` <item>
|
|
903
|
+
<title>${escapeXml(post.title)}</title>
|
|
904
|
+
<link>https://${domain}/posts/${escapeXml(slug)}/</link>
|
|
905
|
+
<guid>https://${domain}/posts/${escapeXml(slug)}/</guid>
|
|
906
|
+
<description>${escapeXml(description)}</description>
|
|
907
|
+
${pubDate ? `<pubDate>${pubDate}</pubDate>` : ''}
|
|
908
|
+
</item>`
|
|
909
|
+
}).join('\n')
|
|
910
|
+
|
|
911
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
912
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
913
|
+
<channel>
|
|
914
|
+
<title>${escapeXml(config.title)}</title>
|
|
915
|
+
<link>https://${domain}/</link>
|
|
916
|
+
<description>${escapeXml(config.description)}</description>
|
|
917
|
+
<atom:link href="https://${domain}/feed.xml" rel="self" type="application/rss+xml"/>
|
|
918
|
+
<language>en-us</language>
|
|
919
|
+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
|
920
|
+
${items}
|
|
921
|
+
</channel>
|
|
922
|
+
</rss>`
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function generateSitemap(posts: PostRow[], _config: BlogConfig, domain: string): string {
|
|
926
|
+
const urls = posts.map((post) => {
|
|
927
|
+
const slug = getSlug(post)
|
|
928
|
+
const lastmod = post.updated_at || post.published_at || ''
|
|
929
|
+
return ` <url>
|
|
930
|
+
<loc>https://${domain}/posts/${escapeXml(slug)}/</loc>
|
|
931
|
+
${lastmod ? `<lastmod>${new Date(lastmod).toISOString().split('T')[0]}</lastmod>` : ''}
|
|
932
|
+
<changefreq>weekly</changefreq>
|
|
933
|
+
</url>`
|
|
934
|
+
}).join('\n')
|
|
935
|
+
|
|
936
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
937
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
938
|
+
<url>
|
|
939
|
+
<loc>https://${domain}/</loc>
|
|
940
|
+
<changefreq>daily</changefreq>
|
|
941
|
+
<priority>1.0</priority>
|
|
942
|
+
</url>
|
|
943
|
+
${urls}
|
|
944
|
+
</urlset>`
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function ensureDir(dir: string): void {
|
|
948
|
+
mkdirSync(dir, { recursive: true })
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function getDefaultBlogPosts(): PostRow[] {
|
|
952
|
+
return [
|
|
953
|
+
{
|
|
954
|
+
id: 1,
|
|
955
|
+
title: 'Introducing Stacks: A Full-Stack Framework for the Modern Web',
|
|
956
|
+
slug: 'introducing-stacks',
|
|
957
|
+
content: 'We are thrilled to announce the official launch of Stacks, a full-stack framework.',
|
|
958
|
+
body: `We are thrilled to announce the official launch of **Stacks**, a full-stack framework designed to make building web applications, APIs, cloud infrastructure, and libraries a delightful experience.
|
|
959
|
+
|
|
960
|
+
## Why Stacks?
|
|
961
|
+
|
|
962
|
+
The JavaScript ecosystem is incredibly rich, but building a production-ready application still requires gluing together dozens of tools, configurations, and deployment pipelines. Stacks changes that.
|
|
963
|
+
|
|
964
|
+
With Stacks, you get a **unified, batteries-included framework** that handles everything from your database models and API routes to cloud infrastructure and documentation sites — all from a single, cohesive project.
|
|
965
|
+
|
|
966
|
+
## What Makes Stacks Different
|
|
967
|
+
|
|
968
|
+
- **Model-View-Action (MVA)**: A fresh take on MVC that emphasizes clarity and simplicity
|
|
969
|
+
- **Type-Safe by Default**: Built on TypeScript with deep type inference throughout
|
|
970
|
+
- **Cloud-Native**: Define your AWS infrastructure in \`config/cloud.ts\` and deploy with \`./buddy deploy\`
|
|
971
|
+
- **Zero-Config DX**: Linting, testing, CI/CD pipelines, and documentation generation — all preconfigured
|
|
972
|
+
- **Library Extraction**: Build your app, then extract reusable Vue components and TypeScript functions as publishable packages
|
|
973
|
+
|
|
974
|
+
## Getting Started
|
|
975
|
+
|
|
976
|
+
Getting started with Stacks is simple:
|
|
977
|
+
|
|
978
|
+
\`\`\`bash
|
|
979
|
+
bunx stacks new my-app
|
|
980
|
+
cd my-app
|
|
981
|
+
./buddy dev
|
|
982
|
+
\`\`\`
|
|
983
|
+
|
|
984
|
+
This gives you a fully configured project with a dev server, database, API routes, and more — all ready to go.
|
|
985
|
+
|
|
986
|
+
## What's Next
|
|
987
|
+
|
|
988
|
+
We are actively working on expanding Stacks with more features, better documentation, and a growing ecosystem of plugins. Follow us on [GitHub](https://github.com/stacksjs/stacks) and join the conversation.
|
|
989
|
+
|
|
990
|
+
The future of full-stack development is here. Let's build something great together.`,
|
|
991
|
+
excerpt: 'We are thrilled to announce the official launch of Stacks, a full-stack framework designed to make building web applications, APIs, and cloud infrastructure a delightful experience.',
|
|
992
|
+
status: 'published',
|
|
993
|
+
published_at: '2026-02-20T10:00:00.000Z',
|
|
994
|
+
views: 1250,
|
|
995
|
+
is_featured: 1,
|
|
996
|
+
created_at: '2026-02-20T10:00:00.000Z',
|
|
997
|
+
updated_at: '2026-02-20T10:00:00.000Z',
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
id: 2,
|
|
1001
|
+
title: 'Deploying to AWS with a Single Command',
|
|
1002
|
+
slug: 'deploying-to-aws',
|
|
1003
|
+
content: 'Learn how Stacks makes cloud deployment as simple as running ./buddy deploy.',
|
|
1004
|
+
body: `One of the most powerful features of Stacks is its built-in cloud deployment pipeline. Instead of wrestling with Terraform, CDK, or manual AWS console clicks, you can deploy your entire application — API, frontend, docs, and blog — with a single command.
|
|
1005
|
+
|
|
1006
|
+
## The Problem with Cloud Deployment
|
|
1007
|
+
|
|
1008
|
+
Most teams spend weeks setting up their deployment pipelines. You need to configure CloudFormation or Terraform templates, set up CI/CD, manage SSL certificates, configure CloudFront distributions, and handle database migrations. It's tedious and error-prone.
|
|
1009
|
+
|
|
1010
|
+
## How Stacks Solves It
|
|
1011
|
+
|
|
1012
|
+
With Stacks, your cloud infrastructure is defined in a simple TypeScript configuration file:
|
|
1013
|
+
|
|
1014
|
+
\`\`\`typescript
|
|
1015
|
+
// config/cloud.ts
|
|
1016
|
+
export const tsCloud = {
|
|
1017
|
+
project: { name: 'my-app', region: 'us-east-1' },
|
|
1018
|
+
infrastructure: {
|
|
1019
|
+
compute: { instances: 1, size: 'small' },
|
|
1020
|
+
storage: {
|
|
1021
|
+
public: { website: { indexDocument: 'index.html' } },
|
|
1022
|
+
docs: { website: { indexDocument: 'index.html' } },
|
|
1023
|
+
},
|
|
1024
|
+
ssl: { enabled: true, domains: ['myapp.com', 'docs.myapp.com'] },
|
|
1025
|
+
dns: { domain: 'myapp.com' },
|
|
1026
|
+
},
|
|
1027
|
+
}
|
|
1028
|
+
\`\`\`
|
|
1029
|
+
|
|
1030
|
+
Then deploy everything:
|
|
1031
|
+
|
|
1032
|
+
\`\`\`bash
|
|
1033
|
+
./buddy deploy --yes
|
|
1034
|
+
\`\`\`
|
|
1035
|
+
|
|
1036
|
+
## What Happens Under the Hood
|
|
1037
|
+
|
|
1038
|
+
When you run \`./buddy deploy\`, Stacks:
|
|
1039
|
+
|
|
1040
|
+
- **Generates a CloudFormation template** from your config
|
|
1041
|
+
- **Provisions EC2 instances** with your Bun application
|
|
1042
|
+
- **Creates S3 buckets** for static sites (frontend, docs, blog)
|
|
1043
|
+
- **Sets up CloudFront** distributions with SSL certificates
|
|
1044
|
+
- **Configures Route53** DNS records
|
|
1045
|
+
- **Runs database migrations** on the remote server
|
|
1046
|
+
- **Uploads static assets** to S3 with proper cache headers
|
|
1047
|
+
- **Invalidates CloudFront** caches for instant updates
|
|
1048
|
+
|
|
1049
|
+
All of this happens automatically, with progress output so you know exactly what's happening.
|
|
1050
|
+
|
|
1051
|
+
## Zero-Downtime Updates
|
|
1052
|
+
|
|
1053
|
+
Subsequent deployments are incremental. Stacks detects what changed and only updates the necessary resources. Your users never experience downtime.
|
|
1054
|
+
|
|
1055
|
+
## Try It Yourself
|
|
1056
|
+
|
|
1057
|
+
If you have an AWS account, you can deploy a Stacks app in under 10 minutes. Check out our [deployment guide](https://stacksjs.com/docs/bootcamp/deploy) to get started.`,
|
|
1058
|
+
excerpt: 'Learn how Stacks makes cloud deployment as simple as running a single command. No Terraform, no CDK — just ./buddy deploy.',
|
|
1059
|
+
status: 'published',
|
|
1060
|
+
published_at: '2026-02-18T14:00:00.000Z',
|
|
1061
|
+
views: 840,
|
|
1062
|
+
is_featured: 0,
|
|
1063
|
+
created_at: '2026-02-18T14:00:00.000Z',
|
|
1064
|
+
updated_at: '2026-02-18T14:00:00.000Z',
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
id: 3,
|
|
1068
|
+
title: 'Building Type-Safe APIs with the Stacks ORM',
|
|
1069
|
+
slug: 'type-safe-apis',
|
|
1070
|
+
content: 'Discover how Stacks models auto-generate fully typed API endpoints.',
|
|
1071
|
+
body: `One of the most tedious parts of building a web application is writing CRUD boilerplate. With Stacks, your models automatically generate fully typed API endpoints, database migrations, factories, and seeders.
|
|
1072
|
+
|
|
1073
|
+
## Define a Model, Get an API
|
|
1074
|
+
|
|
1075
|
+
Here's what a typical Stacks model looks like:
|
|
1076
|
+
|
|
1077
|
+
\`\`\`typescript
|
|
1078
|
+
// app/Models/Post.ts
|
|
1079
|
+
export default defineModel({
|
|
1080
|
+
name: 'Post',
|
|
1081
|
+
table: 'posts',
|
|
1082
|
+
traits: {
|
|
1083
|
+
useTimestamps: true,
|
|
1084
|
+
useApi: {
|
|
1085
|
+
uri: 'posts',
|
|
1086
|
+
routes: ['index', 'store', 'show', 'update', 'destroy'],
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
attributes: {
|
|
1090
|
+
title: {
|
|
1091
|
+
validation: { rule: schema.string().min(3).max(255) },
|
|
1092
|
+
factory: faker => faker.lorem.sentence(),
|
|
1093
|
+
},
|
|
1094
|
+
slug: {
|
|
1095
|
+
unique: true,
|
|
1096
|
+
validation: { rule: schema.string().min(3).max(255) },
|
|
1097
|
+
},
|
|
1098
|
+
body: {
|
|
1099
|
+
validation: { rule: schema.string() },
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
})
|
|
1103
|
+
\`\`\`
|
|
1104
|
+
|
|
1105
|
+
From this single file, Stacks generates:
|
|
1106
|
+
|
|
1107
|
+
- **API routes**: \`GET /api/posts\`, \`POST /api/posts\`, \`GET /api/posts/:id\`, \`PATCH /api/posts/:id\`, \`DELETE /api/posts/:id\`
|
|
1108
|
+
- **Database migration**: Creates the \`posts\` table with all columns
|
|
1109
|
+
- **Factory**: Generates realistic test data using Faker
|
|
1110
|
+
- **Seeder**: Populates your database with sample data
|
|
1111
|
+
- **TypeScript types**: Full type inference for queries and responses
|
|
1112
|
+
|
|
1113
|
+
## Type-Safe Queries
|
|
1114
|
+
|
|
1115
|
+
The Stacks ORM is built on Kysely, giving you fully type-safe database queries:
|
|
1116
|
+
|
|
1117
|
+
\`\`\`typescript
|
|
1118
|
+
const posts = await db
|
|
1119
|
+
.selectFrom('posts')
|
|
1120
|
+
.where('status', '=', 'published')
|
|
1121
|
+
.orderBy('published_at', 'desc')
|
|
1122
|
+
.selectAll()
|
|
1123
|
+
.execute()
|
|
1124
|
+
\`\`\`
|
|
1125
|
+
|
|
1126
|
+
Every column name, operator, and value is type-checked at compile time. Typos become compile errors, not runtime bugs.
|
|
1127
|
+
|
|
1128
|
+
## Relationships
|
|
1129
|
+
|
|
1130
|
+
Stacks supports all common relationship types with a clean, declarative syntax:
|
|
1131
|
+
|
|
1132
|
+
\`\`\`typescript
|
|
1133
|
+
export default defineModel({
|
|
1134
|
+
name: 'Post',
|
|
1135
|
+
belongsTo: ['Author'],
|
|
1136
|
+
traits: { taggable: true, categorizable: true, commentables: true },
|
|
1137
|
+
})
|
|
1138
|
+
\`\`\`
|
|
1139
|
+
|
|
1140
|
+
## What's Next
|
|
1141
|
+
|
|
1142
|
+
We are working on even more ORM features — real-time subscriptions, full-text search integration, and automatic OpenAPI documentation generation. Stay tuned.`,
|
|
1143
|
+
excerpt: 'Discover how Stacks models auto-generate fully typed API endpoints, database migrations, and more from a single model definition.',
|
|
1144
|
+
status: 'published',
|
|
1145
|
+
published_at: '2026-02-15T09:00:00.000Z',
|
|
1146
|
+
views: 620,
|
|
1147
|
+
is_featured: 0,
|
|
1148
|
+
created_at: '2026-02-15T09:00:00.000Z',
|
|
1149
|
+
updated_at: '2026-02-15T09:00:00.000Z',
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
id: 4,
|
|
1153
|
+
title: 'Meet Buddy: Your CLI Companion for Stacks Development',
|
|
1154
|
+
slug: 'meet-buddy-cli',
|
|
1155
|
+
content: 'Buddy is the CLI tool that powers your entire Stacks development workflow.',
|
|
1156
|
+
body: `Every great framework needs a great CLI. In Stacks, that CLI is called **Buddy** — your companion for development, testing, deployment, and everything in between.
|
|
1157
|
+
|
|
1158
|
+
## What Can Buddy Do?
|
|
1159
|
+
|
|
1160
|
+
Buddy is the single entry point for every task in your Stacks project:
|
|
1161
|
+
|
|
1162
|
+
\`\`\`bash
|
|
1163
|
+
./buddy dev # Start the development server
|
|
1164
|
+
./buddy build # Build for production
|
|
1165
|
+
./buddy test # Run your test suite
|
|
1166
|
+
./buddy deploy # Deploy to the cloud
|
|
1167
|
+
./buddy generate # Generate models, migrations, and more
|
|
1168
|
+
./buddy lint # Lint and format your code
|
|
1169
|
+
./buddy key:generate # Generate a new application key
|
|
1170
|
+
\`\`\`
|
|
1171
|
+
|
|
1172
|
+
## Developer Experience First
|
|
1173
|
+
|
|
1174
|
+
Buddy is designed to feel fast and intuitive. Some highlights:
|
|
1175
|
+
|
|
1176
|
+
- **Lazy-loaded commands**: Only the command you run is loaded, keeping startup under 100ms
|
|
1177
|
+
- **Interactive mode**: Run \`./buddy\` with no arguments for a guided menu
|
|
1178
|
+
- **Verbose mode**: Add \`--verbose\` to any command for detailed output
|
|
1179
|
+
- **Tab completion**: Full shell completion support for bash and zsh
|
|
1180
|
+
|
|
1181
|
+
## Extensible
|
|
1182
|
+
|
|
1183
|
+
You can add your own commands by creating files in \`app/Commands/\`:
|
|
1184
|
+
|
|
1185
|
+
\`\`\`typescript
|
|
1186
|
+
// app/Commands/Greet.ts
|
|
1187
|
+
export default function (buddy) {
|
|
1188
|
+
buddy
|
|
1189
|
+
.command('greet <name>', 'Greet someone')
|
|
1190
|
+
.action((name) => {
|
|
1191
|
+
console.log('Hello, ' + name + '!')
|
|
1192
|
+
})
|
|
1193
|
+
}
|
|
1194
|
+
\`\`\`
|
|
1195
|
+
|
|
1196
|
+
Then run it:
|
|
1197
|
+
|
|
1198
|
+
\`\`\`bash
|
|
1199
|
+
./buddy greet World
|
|
1200
|
+
# Hello, World!
|
|
1201
|
+
\`\`\`
|
|
1202
|
+
|
|
1203
|
+
## Built on Bun
|
|
1204
|
+
|
|
1205
|
+
Buddy runs on [Bun](https://bun.sh), giving it near-instant startup times and excellent TypeScript support without a build step. Your commands are executed directly from TypeScript source.
|
|
1206
|
+
|
|
1207
|
+
## Try It
|
|
1208
|
+
|
|
1209
|
+
Start a new Stacks project and explore what Buddy can do. You might be surprised how much a good CLI can improve your workflow.`,
|
|
1210
|
+
excerpt: 'Buddy is the CLI tool that powers your entire Stacks development workflow — from dev server to deployment, all in one place.',
|
|
1211
|
+
status: 'published',
|
|
1212
|
+
published_at: '2026-02-12T11:30:00.000Z',
|
|
1213
|
+
views: 450,
|
|
1214
|
+
is_featured: 0,
|
|
1215
|
+
created_at: '2026-02-12T11:30:00.000Z',
|
|
1216
|
+
updated_at: '2026-02-12T11:30:00.000Z',
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
id: 5,
|
|
1220
|
+
title: 'Documentation as a First-Class Citizen',
|
|
1221
|
+
slug: 'documentation-first-class',
|
|
1222
|
+
content: 'How Stacks makes writing and deploying documentation effortless.',
|
|
1223
|
+
body: `Good documentation is the difference between a framework people try and a framework people adopt. That's why Stacks treats documentation as a **first-class feature**, not an afterthought.
|
|
1224
|
+
|
|
1225
|
+
## Write Docs, Deploy Docs
|
|
1226
|
+
|
|
1227
|
+
Every Stacks project comes with a \`docs/\` directory preconfigured. Write your documentation in Markdown, and Stacks builds it into a beautiful static site using [BunPress](https://github.com/stacksjs/bunpress).
|
|
1228
|
+
|
|
1229
|
+
\`\`\`bash
|
|
1230
|
+
./buddy dev:docs # Preview docs locally
|
|
1231
|
+
./buddy deploy # Docs deploy automatically to docs.yoursite.com
|
|
1232
|
+
\`\`\`
|
|
1233
|
+
|
|
1234
|
+
## What You Get
|
|
1235
|
+
|
|
1236
|
+
- **Beautiful defaults**: Clean, responsive design with dark mode support
|
|
1237
|
+
- **Sidebar navigation**: Automatically generated from your file structure
|
|
1238
|
+
- **Syntax highlighting**: Code blocks with proper language highlighting
|
|
1239
|
+
- **Search**: Built-in search functionality
|
|
1240
|
+
- **Automatic deployment**: Docs deploy to a dedicated S3 bucket + CloudFront CDN
|
|
1241
|
+
|
|
1242
|
+
## Powered by BunPress
|
|
1243
|
+
|
|
1244
|
+
Under the hood, Stacks uses BunPress — a fast, minimal static site generator built on Bun. It takes your Markdown files and produces optimized HTML with:
|
|
1245
|
+
|
|
1246
|
+
- Table of contents generation
|
|
1247
|
+
- Anchor links for headings
|
|
1248
|
+
- Responsive images
|
|
1249
|
+
- SEO-friendly output with sitemaps and meta tags
|
|
1250
|
+
|
|
1251
|
+
## Documentation is Part of Your Deploy
|
|
1252
|
+
|
|
1253
|
+
When you run \`./buddy deploy\`, your documentation is automatically:
|
|
1254
|
+
|
|
1255
|
+
- Built from the \`docs/\` directory
|
|
1256
|
+
- Uploaded to an S3 bucket
|
|
1257
|
+
- Served via CloudFront at \`docs.yourdomain.com\`
|
|
1258
|
+
- Cache-invalidated for instant updates
|
|
1259
|
+
|
|
1260
|
+
No separate CI/CD pipeline needed. No extra configuration. It just works.
|
|
1261
|
+
|
|
1262
|
+
## Start Documenting
|
|
1263
|
+
|
|
1264
|
+
Great software deserves great documentation. With Stacks, there's no excuse not to write it.`,
|
|
1265
|
+
excerpt: 'How Stacks makes writing and deploying documentation effortless — from Markdown to a deployed docs site in seconds.',
|
|
1266
|
+
status: 'published',
|
|
1267
|
+
published_at: '2026-02-10T08:00:00.000Z',
|
|
1268
|
+
views: 380,
|
|
1269
|
+
is_featured: 0,
|
|
1270
|
+
created_at: '2026-02-10T08:00:00.000Z',
|
|
1271
|
+
updated_at: '2026-02-10T08:00:00.000Z',
|
|
1272
|
+
},
|
|
1273
|
+
]
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export async function buildBlogSite(options: BuildBlogOptions): Promise<void> {
|
|
1277
|
+
const { config, outDir } = options
|
|
1278
|
+
const domain = `${config.subdomain}.stacksjs.com`
|
|
1279
|
+
|
|
1280
|
+
// Ensure output directory
|
|
1281
|
+
ensureDir(outDir)
|
|
1282
|
+
ensureDir(join(outDir, 'posts'))
|
|
1283
|
+
copyBlogFonts(outDir)
|
|
1284
|
+
copyBlogImages(outDir)
|
|
1285
|
+
|
|
1286
|
+
// Fetch published posts from database
|
|
1287
|
+
let posts: PostRow[]
|
|
1288
|
+
let usedDefaults = false
|
|
1289
|
+
try {
|
|
1290
|
+
const dbPosts = await fetchPublishedPosts()
|
|
1291
|
+
|
|
1292
|
+
// If DB posts have no slugs or are faker data, merge with defaults
|
|
1293
|
+
const hasRealContent = dbPosts.some(p => p.slug && p.slug !== 'null' && !p.title.endsWith('.'))
|
|
1294
|
+
if (dbPosts.length === 0 || !hasRealContent) {
|
|
1295
|
+
posts = getDefaultBlogPosts()
|
|
1296
|
+
usedDefaults = true
|
|
1297
|
+
} else {
|
|
1298
|
+
posts = dbPosts
|
|
1299
|
+
}
|
|
1300
|
+
} catch {
|
|
1301
|
+
console.log(' Database not available, using default blog posts')
|
|
1302
|
+
posts = getDefaultBlogPosts()
|
|
1303
|
+
usedDefaults = true
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (usedDefaults) {
|
|
1307
|
+
console.log(' Using built-in blog posts (seed your database for custom content)')
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Fetch authors
|
|
1311
|
+
const authors = new Map<number, AuthorRow>()
|
|
1312
|
+
try {
|
|
1313
|
+
const authorRows = await fetchAuthors()
|
|
1314
|
+
for (const author of authorRows) {
|
|
1315
|
+
authors.set(author.id, author)
|
|
1316
|
+
}
|
|
1317
|
+
} catch {
|
|
1318
|
+
// Authors table may not exist yet
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Generate paginated index pages
|
|
1322
|
+
const postsPerPage = config.postsPerPage || 10
|
|
1323
|
+
const totalPages = Math.max(1, Math.ceil(posts.length / postsPerPage))
|
|
1324
|
+
|
|
1325
|
+
for (let page = 1; page <= totalPages; page++) {
|
|
1326
|
+
const start = (page - 1) * postsPerPage
|
|
1327
|
+
const pagePosts = posts.slice(start, start + postsPerPage)
|
|
1328
|
+
const html = generateIndexPage(pagePosts, config, authors, page, totalPages)
|
|
1329
|
+
|
|
1330
|
+
if (page === 1) {
|
|
1331
|
+
writeFileSync(join(outDir, 'index.html'), html)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (totalPages > 1) {
|
|
1335
|
+
const pageDir = join(outDir, 'page', String(page))
|
|
1336
|
+
ensureDir(pageDir)
|
|
1337
|
+
writeFileSync(join(pageDir, 'index.html'), html)
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// Generate individual post pages
|
|
1342
|
+
const generatedSlugs = new Set<string>()
|
|
1343
|
+
for (const post of posts) {
|
|
1344
|
+
const slug = getSlug(post)
|
|
1345
|
+
|
|
1346
|
+
// Skip duplicate slugs
|
|
1347
|
+
if (generatedSlugs.has(slug)) continue
|
|
1348
|
+
generatedSlugs.add(slug)
|
|
1349
|
+
|
|
1350
|
+
const postDir = join(outDir, 'posts', slug)
|
|
1351
|
+
ensureDir(postDir)
|
|
1352
|
+
|
|
1353
|
+
const author = post.author_id ? authors.get(post.author_id) : undefined
|
|
1354
|
+
const html = generatePostPage(post, config, author)
|
|
1355
|
+
writeFileSync(join(postDir, 'index.html'), html)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Generate RSS feed
|
|
1359
|
+
if (config.enableRss) {
|
|
1360
|
+
const rss = generateRssFeed(posts.slice(0, 20), config, domain)
|
|
1361
|
+
writeFileSync(join(outDir, 'feed.xml'), rss)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Generate sitemap
|
|
1365
|
+
if (config.enableSitemap) {
|
|
1366
|
+
const sitemap = generateSitemap(posts, config, domain)
|
|
1367
|
+
writeFileSync(join(outDir, 'sitemap.xml'), sitemap)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Generate 404 page
|
|
1371
|
+
const notFoundContent = `
|
|
1372
|
+
<div style="text-align: center; padding: 6rem 0;">
|
|
1373
|
+
<p style="font-size: 5rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.5rem;">404</p>
|
|
1374
|
+
<p style="color: var(--text-light); margin-bottom: 1.5rem; font-size: 1.125rem;">This page could not be found.</p>
|
|
1375
|
+
<a href="/" style="display: inline-block; padding: 0.6rem 1.5rem; background: var(--primary); color: white; border-radius: 6px; font-weight: 500; font-size: 0.9rem;">Back to blog</a>
|
|
1376
|
+
</div>`
|
|
1377
|
+
writeFileSync(join(outDir, '404.html'), generateLayout(config, '404 - Not Found', notFoundContent))
|
|
1378
|
+
|
|
1379
|
+
console.log(` Generated ${generatedSlugs.size} post pages, ${totalPages} index page(s)`)
|
|
1380
|
+
if (config.enableRss) console.log(' Generated feed.xml')
|
|
1381
|
+
if (config.enableSitemap) console.log(' Generated sitemap.xml')
|
|
1382
|
+
}
|