@stacksjs/cms 0.70.55 → 0.70.57

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.
Files changed (68) hide show
  1. package/package.json +10 -7
  2. package/src/authors/destroy.ts +0 -55
  3. package/src/authors/fetch.ts +0 -70
  4. package/src/authors/index.ts +0 -21
  5. package/src/authors/store.ts +0 -150
  6. package/src/authors/update.ts +0 -42
  7. package/src/build.ts +0 -1382
  8. package/src/categorizables/destroy.ts +0 -64
  9. package/src/categorizables/fetch.ts +0 -121
  10. package/src/categorizables/index.ts +0 -22
  11. package/src/categorizables/store.ts +0 -140
  12. package/src/categorizables/update.ts +0 -57
  13. package/src/commentables/destroy.ts +0 -64
  14. package/src/commentables/fetch.ts +0 -319
  15. package/src/commentables/index.ts +0 -25
  16. package/src/commentables/store.ts +0 -156
  17. package/src/commentables/update.ts +0 -75
  18. package/src/database.ts +0 -9
  19. package/src/index.ts +0 -42
  20. package/src/pages/destroy.ts +0 -64
  21. package/src/pages/fetch.ts +0 -82
  22. package/src/pages/index.ts +0 -21
  23. package/src/pages/store.ts +0 -42
  24. package/src/pages/update.ts +0 -39
  25. package/src/posts/destroy.ts +0 -90
  26. package/src/posts/fetch.ts +0 -82
  27. package/src/posts/index.ts +0 -25
  28. package/src/posts/store.ts +0 -213
  29. package/src/posts/update.ts +0 -46
  30. package/src/taggables/destroy.ts +0 -49
  31. package/src/taggables/fetch.ts +0 -282
  32. package/src/taggables/index.ts +0 -17
  33. package/src/taggables/store.ts +0 -122
  34. package/src/taggables/update.ts +0 -74
  35. package/src/tests/categorizables.test.ts +0 -66
  36. package/src/tests/setup.ts +0 -120
  37. /package/dist/{src/authors → authors}/destroy.d.ts +0 -0
  38. /package/dist/{src/authors → authors}/fetch.d.ts +0 -0
  39. /package/dist/{src/authors → authors}/index.d.ts +0 -0
  40. /package/dist/{src/authors → authors}/store.d.ts +0 -0
  41. /package/dist/{src/authors → authors}/update.d.ts +0 -0
  42. /package/dist/{src/categorizables → categorizables}/destroy.d.ts +0 -0
  43. /package/dist/{src/categorizables → categorizables}/fetch.d.ts +0 -0
  44. /package/dist/{src/categorizables → categorizables}/index.d.ts +0 -0
  45. /package/dist/{src/categorizables → categorizables}/store.d.ts +0 -0
  46. /package/dist/{src/categorizables → categorizables}/update.d.ts +0 -0
  47. /package/dist/{src/commentables → commentables}/destroy.d.ts +0 -0
  48. /package/dist/{src/commentables → commentables}/fetch.d.ts +0 -0
  49. /package/dist/{src/commentables → commentables}/index.d.ts +0 -0
  50. /package/dist/{src/commentables → commentables}/store.d.ts +0 -0
  51. /package/dist/{src/commentables → commentables}/update.d.ts +0 -0
  52. /package/dist/{src/database.d.ts → database.d.ts} +0 -0
  53. /package/dist/{src/index.d.ts → index.d.ts} +0 -0
  54. /package/dist/{src/pages → pages}/destroy.d.ts +0 -0
  55. /package/dist/{src/pages → pages}/fetch.d.ts +0 -0
  56. /package/dist/{src/pages → pages}/index.d.ts +0 -0
  57. /package/dist/{src/pages → pages}/store.d.ts +0 -0
  58. /package/dist/{src/pages → pages}/update.d.ts +0 -0
  59. /package/dist/{src/posts → posts}/destroy.d.ts +0 -0
  60. /package/dist/{src/posts → posts}/fetch.d.ts +0 -0
  61. /package/dist/{src/posts → posts}/index.d.ts +0 -0
  62. /package/dist/{src/posts → posts}/store.d.ts +0 -0
  63. /package/dist/{src/posts → posts}/update.d.ts +0 -0
  64. /package/dist/{src/taggables → taggables}/destroy.d.ts +0 -0
  65. /package/dist/{src/taggables → taggables}/fetch.d.ts +0 -0
  66. /package/dist/{src/taggables → taggables}/index.d.ts +0 -0
  67. /package/dist/{src/taggables → taggables}/store.d.ts +0 -0
  68. /package/dist/{src/taggables → taggables}/update.d.ts +0 -0
package/src/build.ts DELETED
@@ -1,1382 +0,0 @@
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, '&lt;')
48
- .replace(/>/g, '&gt;')
49
- .replace(/"/g, '&quot;')
50
- .replace(/'/g, '&#039;')
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>&copy; ${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">&middot;</span>
794
- <span>${date}</span>
795
- <span class="sep">&middot;</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 &rarr;</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">&larr; 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">&middot;</span>
843
- <span>${date}</span>
844
- <span class="sep">&middot;</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}/`}">&larr; Newer</a>` : ''
867
- const next = page < totalPages ? `<a href="/page/${page + 1}/">Older &rarr;</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
- }