cosmolo 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -54,7 +54,7 @@ bun generate:article
54
54
  bun dev
55
55
  ```
56
56
 
57
- `cosmolo init` asks two questions — which mode (full UI or server-only) and which adapter (SSG or serverless) — then copies the appropriate route files into your project.
57
+ `cosmolo init` asks two questions — which mode (Full or Slim) and which adapter (SSG, Cloudflare, or Serverless) — then copies the appropriate route files into your project.
58
58
 
59
59
  ---
60
60
 
@@ -273,6 +273,49 @@ Prompts for key (slug), label, and description. Appends the new entry to `config
273
273
 
274
274
  ---
275
275
 
276
+ ## Cloudflare
277
+
278
+ Cosmolo works with any SvelteKit-compatible deployment platform, but it is purpose-built
279
+ around the Cloudflare stack. SvelteKit and Cloudflare Workers are an unusually good fit —
280
+ edge-native rendering, zero cold starts, globally distributed infrastructure, and a generous
281
+ free tier. Cosmolo's CLI removes the usual setup friction so you can go from `init` to
282
+ deployed in minutes.
283
+
284
+ ### One-command Cloudflare setup
285
+
286
+ ```bash
287
+ bunx cosmolo init # choose "Cloudflare" when prompted for adapter
288
+ ```
289
+
290
+ This single command generates everything needed to deploy:
291
+
292
+ | Generated file | Purpose |
293
+ |---|---|
294
+ | `svelte.config.js` | Pre-configured with `adapter-cloudflare` |
295
+ | `wrangler.toml` | Project name, `nodejs_compat`, D1 template commented out |
296
+ | `src/app.d.ts` | `App.Platform` with `Env`, `CfProperties`, `ExecutionContext` |
297
+ | `.github/workflows/deploy.yml` | Optional — push-to-`main` deploy via `wrangler-action` |
298
+
299
+ After init, two commands to go live:
300
+
301
+ ```bash
302
+ bun install && bun add -D @sveltejs/adapter-cloudflare @cloudflare/workers-types
303
+ bun run deploy # bun run build + wrangler pages deploy
304
+ ```
305
+
306
+ If you opted in to GitHub Actions during init, pushing to `main` triggers the deploy automatically.
307
+
308
+ ### Cloudflare services
309
+
310
+ | Command | What it sets up |
311
+ |---|---|
312
+ | `cosmolo migrate:db` → option 3 | **D1** — Drizzle schema, CRUD helpers (`getArticlesByCategory`, `getArticlesByTag`, …), D1-backed `+page.server.ts` route files |
313
+ | `cosmolo setup:r2` | **R2** — `wrangler.toml` binding, `src/lib/r2.ts` helper, `/assets/[...key]` edge serving route |
314
+
315
+ Each command is self-contained — run only the ones you need.
316
+
317
+ ---
318
+
276
319
  ## Database Migration
277
320
 
278
321
  When a file-based Cosmolo site outgrows Markdown — multiple writers, mobile editing,
@@ -305,12 +348,22 @@ The command is interactive and offers three paths:
305
348
  After a preflight check (drizzle installed, wrangler.toml, table conflicts), the following files are generated:
306
349
 
307
350
  ```
308
- drizzle/schema.ts ← Drizzle schema for articles and categories tables
309
- src/lib/db/articles.ts ← getArticles, getArticle, createArticle, updateArticle, deleteArticle
310
- src/lib/db/categories.ts ← getCategories, getCategory, createCategory, updateCategory, deleteCategory
311
- wrangler.toml [[d1_databases]] binding added (merged if file exists)
312
- drizzle.config.ts drizzle-kit config (dialect: sqlite)
313
- .dev.vars.example Cloudflare environment variable reference
351
+ drizzle/schema.ts ← Drizzle schema for articles and categories tables
352
+ src/lib/db/articles.ts ← getArticles, getArticlesByCategory, getArticlesByTag,
353
+ getArticle, parseArticle, createArticle, updateArticle, deleteArticle
354
+ src/lib/db/categories.ts getCategories, getCategory, createCategory, updateCategory, deleteCategory
355
+ wrangler.toml [[d1_databases]] binding added (merged if file exists)
356
+ drizzle.config.ts drizzle-kit config (dialect: sqlite)
357
+ .dev.vars.example ← Cloudflare environment variable reference
358
+ ```
359
+
360
+ Optionally (prompted during setup), the existing `+page.server.ts` route files are replaced with D1-backed versions that read from `platform.env.DB` instead of the Cosmolo virtual module:
361
+
362
+ ```
363
+ src/routes/+page.server.ts ← Home page — getArticles + getCategories from D1
364
+ src/routes/articles/[slug]/+page.server.ts ← Article — getArticle from D1, Markdown rendered with marked
365
+ src/routes/categories/[slug]/+page.server.ts ← Category — getArticlesByCategory from D1
366
+ src/routes/tags/[tag]/+page.server.ts ← Tag — getArticlesByTag from D1 (json_each query)
314
367
  ```
315
368
 
316
369
  The command prints step-by-step instructions after generation:
@@ -338,6 +391,42 @@ The upside: content edits take effect immediately — no rebuild or redeploy nee
338
391
 
339
392
  ---
340
393
 
394
+ ## R2 Asset Storage
395
+
396
+ Add Cloudflare R2 object storage for article images and other binary assets:
397
+
398
+ ```bash
399
+ bunx cosmolo setup:r2
400
+ ```
401
+
402
+ The command asks for a bucket name and binding name, then generates:
403
+
404
+ ```
405
+ src/lib/r2.ts ← getR2Asset(bucket, key) helper
406
+ src/routes/assets/[...key]/+server.ts ← Edge route — serves files directly from R2
407
+ wrangler.toml ← [[r2_buckets]] binding appended
408
+ ```
409
+
410
+ After setup:
411
+
412
+ ```bash
413
+ # 1. Create the bucket
414
+ bunx wrangler r2 bucket create <bucket-name>
415
+
416
+ # 2. Upload an asset
417
+ bunx wrangler r2 object put <bucket-name>/images/photo.jpg --file ./static/images/photo.jpg
418
+
419
+ # 3. Reference it in templates as /assets/images/photo.jpg
420
+ ```
421
+
422
+ Add the binding type to `src/app.d.ts` (one line):
423
+
424
+ ```ts
425
+ interface Platform { env: { ASSETS: R2Bucket } }
426
+ ```
427
+
428
+ ---
429
+
341
430
  ## Headless CMS
342
431
 
343
432
  Cosmolo can expose your content as static JSON endpoints, making it usable as a
@@ -391,7 +480,7 @@ bun add -D vite @sveltejs/kit
391
480
  **1. Create `cosmolo.config.ts`** in your project root
392
481
 
393
482
  ```typescript
394
- import { resolveConfig } from 'cosmolo';
483
+ import { resolveConfig } from 'cosmolo/plugin';
395
484
 
396
485
  export default resolveConfig({
397
486
  articlesDir: 'src/content/articles', // default
@@ -439,10 +528,11 @@ The command asks two questions:
439
528
 
440
529
  **Adapter**
441
530
 
442
- | Adapter | Effect |
531
+ | Adapter | Generated files |
443
532
  |---|---|
444
- | **SSG** (`adapter-static`) | Also creates `src/routes/+layout.ts` with `export const prerender = true` |
445
- | **Serverless / SSR** | No layout file routes render on demand |
533
+ | **SSG** (`adapter-static`) | `svelte.config.js` (adapter-static) + `src/routes/+layout.ts` (`prerender = true`) |
534
+ | **Cloudflare** (`adapter-cloudflare`) | `svelte.config.js` (adapter-cloudflare) + `wrangler.toml` + `src/app.d.ts` (`Platform` type). Optionally `.github/workflows/deploy.yml`. |
535
+ | **Serverless** | No extra files — bring your own adapter (Vercel, Node, etc.) |
446
536
 
447
537
  If any target file already exists, the command lists every conflict and exits without
448
538
  writing anything.
package/dist/articles.js CHANGED
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import matter from 'gray-matter';
3
3
  import { renderMarkdown, generateToc } from './markdown.js';
4
4
  import { isKnownCategory } from './categories.js';
5
- import { rawMdFiles, svxModules } from 'cosmolo:content';
5
+ import { rawMdFiles, svxModules, updatedAtMap } from 'cosmolo:content';
6
6
  export const articleFrontmatterSchema = z.object({
7
7
  title: z.string(),
8
8
  category: z.string(),
@@ -30,14 +30,14 @@ export function getArticles(config) {
30
30
  const frontmatter = articleFrontmatterSchema.parse(data);
31
31
  if (frontmatter.draft)
32
32
  continue;
33
- articles.push({ ...frontmatter, slug, html: '', markdown: '', toc: [] });
33
+ articles.push({ ...frontmatter, slug, html: '', markdown: '', toc: [], updatedAt: updatedAtMap[slug] ?? '' });
34
34
  }
35
35
  for (const [filePath, mod] of Object.entries(svxModules)) {
36
36
  const slug = slugFromPath(filePath, config.articlesDir);
37
37
  const frontmatter = articleFrontmatterSchema.parse(mod.metadata);
38
38
  if (frontmatter.draft)
39
39
  continue;
40
- articles.push({ ...frontmatter, slug, html: '', markdown: '', toc: [] });
40
+ articles.push({ ...frontmatter, slug, html: '', markdown: '', toc: [], updatedAt: updatedAtMap[slug] ?? '' });
41
41
  }
42
42
  return articles.sort((a, b) => b.sort - a.sort);
43
43
  }
@@ -56,11 +56,11 @@ export async function getArticle(config, slug) {
56
56
  const frontmatter = articleFrontmatterSchema.parse(data);
57
57
  const html = await renderMarkdown(content);
58
58
  const toc = generateToc(content);
59
- return { ...frontmatter, slug, html, markdown: content, toc };
59
+ return { ...frontmatter, slug, html, markdown: content, toc, updatedAt: updatedAtMap[slug] ?? '' };
60
60
  }
61
61
  if (svxModules[svxPath] !== undefined) {
62
62
  const frontmatter = articleFrontmatterSchema.parse(svxModules[svxPath].metadata);
63
- return { ...frontmatter, slug, html: '', markdown: '', toc: [] };
63
+ return { ...frontmatter, slug, html: '', markdown: '', toc: [], updatedAt: updatedAtMap[slug] ?? '' };
64
64
  }
65
65
  throw new Error(`Article not found: ${slug}`);
66
66
  }
package/dist/cli/index.js CHANGED
@@ -10,15 +10,19 @@ switch (cmd) {
10
10
  case 'migrate:db':
11
11
  await (await import('./migrate.js')).main();
12
12
  break;
13
+ case 'setup:r2':
14
+ await (await import('./setup/r2.js')).main();
15
+ break;
13
16
  default: {
14
17
  const isUnknown = Boolean(cmd);
15
18
  if (isUnknown)
16
19
  console.error(`Unknown command: ${cmd}\n`);
17
20
  console.log('Usage: cosmolo <command>\n');
18
21
  console.log('Commands:');
19
- console.log(' init Scaffold routes into an existing SvelteKit project');
22
+ console.log(' init Scaffold routes and config into a SvelteKit project');
20
23
  console.log(' generate [article|page|category] Create content files');
21
- console.log(' migrate:db Migrate content to a database');
24
+ console.log(' migrate:db Migrate file-based content to a database (D1)');
25
+ console.log(' setup:r2 Add Cloudflare R2 bucket for asset storage');
22
26
  process.exit(isUnknown ? 1 : 0);
23
27
  }
24
28
  }
package/dist/cli/init.js CHANGED
@@ -36,7 +36,21 @@ function destPath(relativePath, projectRoot) {
36
36
  .replace(/^lib\//, 'src/lib/');
37
37
  return path.join(projectRoot, mapped);
38
38
  }
39
- function injectPackageScripts(projectRoot) {
39
+ function readProjectName(projectRoot) {
40
+ const pkgPath = path.join(projectRoot, 'package.json');
41
+ if (fs.existsSync(pkgPath)) {
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
44
+ if (typeof pkg.name === 'string' && pkg.name)
45
+ return pkg.name;
46
+ }
47
+ catch {
48
+ // fall through
49
+ }
50
+ }
51
+ return 'my-cosmolo-site';
52
+ }
53
+ function injectPackageScripts(projectRoot, adapter) {
40
54
  const pkgPath = path.join(projectRoot, 'package.json');
41
55
  if (!fs.existsSync(pkgPath))
42
56
  return;
@@ -49,6 +63,9 @@ function injectPackageScripts(projectRoot) {
49
63
  'generate:page': 'cosmolo generate page',
50
64
  'generate:category': 'cosmolo generate category',
51
65
  };
66
+ if (adapter === 'cloudflare') {
67
+ scripts['deploy'] = 'bun run build && bunx wrangler pages deploy .svelte-kit/cloudflare';
68
+ }
52
69
  for (const [key, val] of Object.entries(scripts)) {
53
70
  if (!pkg.scripts[key]) {
54
71
  pkg.scripts[key] = val;
@@ -64,6 +81,40 @@ function injectPackageScripts(projectRoot) {
64
81
  console.log(' updated package.json (added cosmolo dependency + generate:* scripts)');
65
82
  }
66
83
  }
84
+ function svelteConfigContent(adapter) {
85
+ const pkg = adapter === 'ssg' ? '@sveltejs/adapter-static' : '@sveltejs/adapter-cloudflare';
86
+ return (`import adapter from '${pkg}';\n` +
87
+ `import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\n` +
88
+ `/** @type {import('@sveltejs/kit').Config} */\n` +
89
+ `const config = {\n` +
90
+ `\tpreprocess: vitePreprocess(),\n` +
91
+ `\tkit: {\n` +
92
+ `\t\tadapter: adapter(),\n` +
93
+ `\t},\n` +
94
+ `};\n\n` +
95
+ `export default config;\n`);
96
+ }
97
+ function githubActionsContent(projectName) {
98
+ return (`name: Deploy to Cloudflare Pages\n\n` +
99
+ `on:\n` +
100
+ ` push:\n` +
101
+ ` branches: [main]\n\n` +
102
+ `jobs:\n` +
103
+ ` deploy:\n` +
104
+ ` runs-on: ubuntu-latest\n` +
105
+ ` permissions:\n` +
106
+ ` contents: read\n` +
107
+ ` deployments: write\n` +
108
+ ` steps:\n` +
109
+ ` - uses: actions/checkout@v4\n` +
110
+ ` - uses: oven-sh/setup-bun@v2\n` +
111
+ ` - run: bun install --frozen-lockfile\n` +
112
+ ` - run: bun run build\n` +
113
+ ` - uses: cloudflare/wrangler-action@v3\n` +
114
+ ` with:\n` +
115
+ ` apiToken: \${{ secrets.CF_API_TOKEN }}\n` +
116
+ ` command: pages deploy .svelte-kit/cloudflare --project-name=${projectName}\n`);
117
+ }
67
118
  // ─── main ─────────────────────────────────────────────────────────────────────
68
119
  export async function main() {
69
120
  const PROJECT_ROOT = process.cwd();
@@ -82,15 +133,16 @@ export async function main() {
82
133
  const mode = modeRaw === '1' ? 'full' : 'slim';
83
134
  // ── Adapter selection ───────────────────────────────────────────────────
84
135
  console.log('\nChoose your deployment adapter:\n');
85
- console.log(' 1) SSG — @sveltejs/adapter-static (Cloudflare Pages static, GitHub Pages, etc.)');
86
- console.log(' 2) Serverless/SSR Cloudflare Workers, Vercel, Node, etc.\n');
136
+ console.log(' 1) SSG — @sveltejs/adapter-static (GitHub Pages, Cloudflare Pages static, etc.)');
137
+ console.log(' 2) Cloudflare @sveltejs/adapter-cloudflare (Workers / Pages SSR)');
138
+ console.log(' 3) Serverless — Vercel, Node, etc.\n');
87
139
  let adapterRaw = '';
88
- while (!['1', '2'].includes(adapterRaw)) {
89
- adapterRaw = (await ask(rl, 'Adapter [1/2]: ')).trim();
90
- if (!['1', '2'].includes(adapterRaw))
91
- console.log(' Please enter 1 or 2.');
140
+ while (!['1', '2', '3'].includes(adapterRaw)) {
141
+ adapterRaw = (await ask(rl, 'Adapter [1/2/3]: ')).trim();
142
+ if (!['1', '2', '3'].includes(adapterRaw))
143
+ console.log(' Please enter 1, 2, or 3.');
92
144
  }
93
- const isSSG = adapterRaw === '1';
145
+ const adapter = adapterRaw === '1' ? 'ssg' : adapterRaw === '2' ? 'cloudflare' : 'serverless';
94
146
  // ── Collect files ───────────────────────────────────────────────────────
95
147
  const sharedFiles = collectFiles(path.join(TEMPLATE_DIR, 'shared'));
96
148
  const fullFiles = mode === 'full' ? collectFiles(path.join(TEMPLATE_DIR, 'full')) : [];
@@ -100,6 +152,16 @@ export async function main() {
100
152
  ];
101
153
  const layoutTsPath = path.join(PROJECT_ROOT, 'src/routes/+layout.ts');
102
154
  const layoutTsContent = 'export const prerender = true;\n';
155
+ const wranglerTomlPath = path.join(PROJECT_ROOT, 'wrangler.toml');
156
+ const appDtsPath = path.join(PROJECT_ROOT, 'src/app.d.ts');
157
+ const svelteConfigPath = path.join(PROJECT_ROOT, 'svelte.config.js');
158
+ const ghaWorkflowPath = path.join(PROJECT_ROOT, '.github', 'workflows', 'deploy.yml');
159
+ // ── GitHub Actions prompt (Cloudflare only, before conflict detection) ──
160
+ let generateGha = false;
161
+ if (adapter === 'cloudflare') {
162
+ const ans = (await ask(rl, '\nGenerate GitHub Actions deploy workflow? [y/N]: ')).toLowerCase();
163
+ generateGha = ans === 'y';
164
+ }
103
165
  // ── Conflict detection ──────────────────────────────────────────────────
104
166
  const conflicts = [];
105
167
  for (const [, rel] of allFiles) {
@@ -107,8 +169,21 @@ export async function main() {
107
169
  if (fs.existsSync(dest))
108
170
  conflicts.push(path.relative(PROJECT_ROOT, dest));
109
171
  }
110
- if (isSSG && fs.existsSync(layoutTsPath)) {
111
- conflicts.push(path.relative(PROJECT_ROOT, layoutTsPath));
172
+ if (adapter === 'ssg') {
173
+ if (fs.existsSync(layoutTsPath))
174
+ conflicts.push(path.relative(PROJECT_ROOT, layoutTsPath));
175
+ if (fs.existsSync(svelteConfigPath))
176
+ conflicts.push('svelte.config.js');
177
+ }
178
+ if (adapter === 'cloudflare') {
179
+ if (fs.existsSync(wranglerTomlPath))
180
+ conflicts.push('wrangler.toml');
181
+ if (fs.existsSync(appDtsPath))
182
+ conflicts.push('src/app.d.ts');
183
+ if (fs.existsSync(svelteConfigPath))
184
+ conflicts.push('svelte.config.js');
185
+ if (generateGha && fs.existsSync(ghaWorkflowPath))
186
+ conflicts.push('.github/workflows/deploy.yml');
112
187
  }
113
188
  if (conflicts.length > 0) {
114
189
  console.log('\nThe following files already exist:\n');
@@ -133,27 +208,105 @@ export async function main() {
133
208
  copyFile(src, dest);
134
209
  console.log(` created ${path.relative(PROJECT_ROOT, dest)}`);
135
210
  }
136
- if (isSSG) {
211
+ if (adapter === 'ssg') {
137
212
  writeFile(layoutTsPath, layoutTsContent);
138
213
  console.log(` created src/routes/+layout.ts`);
214
+ writeFile(svelteConfigPath, svelteConfigContent('ssg'));
215
+ console.log(` created svelte.config.js`);
139
216
  }
140
- injectPackageScripts(PROJECT_ROOT);
217
+ if (adapter === 'cloudflare') {
218
+ writeFile(svelteConfigPath, svelteConfigContent('cloudflare'));
219
+ console.log(` created svelte.config.js`);
220
+ const projectName = readProjectName(PROJECT_ROOT);
221
+ const wranglerToml = [
222
+ `name = "${projectName}"`,
223
+ `pages_build_output_dir = ".svelte-kit/cloudflare"`,
224
+ `compatibility_date = "${new Date().toISOString().slice(0, 10)}"`,
225
+ `compatibility_flags = ["nodejs_compat"]`,
226
+ ``,
227
+ `# Uncomment to add Cloudflare D1 (run: bunx cosmolo migrate:db)`,
228
+ `# [[d1_databases]]`,
229
+ `# binding = "DB"`,
230
+ `# database_name = "${projectName}-db"`,
231
+ `# database_id = "" # fill in after: bunx wrangler d1 create ${projectName}-db`,
232
+ ].join('\n') + '\n';
233
+ writeFile(wranglerTomlPath, wranglerToml);
234
+ console.log(` created wrangler.toml`);
235
+ const appDts = [
236
+ `// See https://svelte.dev/docs/kit/types#app.d.ts`,
237
+ `// Install @cloudflare/workers-types for full type support:`,
238
+ `// bun add -D @cloudflare/workers-types`,
239
+ `declare global {`,
240
+ `\tnamespace App {`,
241
+ `\t\tinterface Platform {`,
242
+ `\t\t\tenv: Env;`,
243
+ `\t\t\tcf: CfProperties;`,
244
+ `\t\t\tctx: ExecutionContext;`,
245
+ `\t\t}`,
246
+ `\t}`,
247
+ `}`,
248
+ ``,
249
+ `export {};`,
250
+ ].join('\n') + '\n';
251
+ writeFile(appDtsPath, appDts);
252
+ console.log(` created src/app.d.ts`);
253
+ if (generateGha) {
254
+ writeFile(ghaWorkflowPath, githubActionsContent(projectName));
255
+ console.log(` created .github/workflows/deploy.yml`);
256
+ }
257
+ }
258
+ injectPackageScripts(PROJECT_ROOT, adapter);
141
259
  // ── Next steps ──────────────────────────────────────────────────────────
142
260
  console.log('\nDone! Next steps:\n');
143
261
  console.log(' 1. Run: bun install');
144
- if (isSSG) {
262
+ if (adapter === 'ssg') {
145
263
  console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-static');
264
+ if (mode === 'full') {
265
+ console.log(' 3. Install sass: bun add -D sass (SCSS used in Svelte templates)');
266
+ console.log(' 4. Run: bun dev');
267
+ }
268
+ else {
269
+ console.log(' 3. Add your own +page.svelte files for each route.');
270
+ console.log(' 4. Run: bun dev');
271
+ }
146
272
  }
147
- else {
148
- console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-cloudflare (or your adapter)');
149
- }
150
- if (mode === 'full') {
151
- console.log(' 3. Install sass: bun add -D sass (SCSS used in Svelte templates)');
152
- console.log(' 4. Run: bun dev');
273
+ else if (adapter === 'cloudflare') {
274
+ console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-cloudflare');
275
+ console.log(' 3. Install types: bun add -D @cloudflare/workers-types');
276
+ if (generateGha) {
277
+ console.log(' 4. Add secret to GitHub repo: CF_API_TOKEN (Cloudflare API token)');
278
+ if (mode === 'full') {
279
+ console.log(' 5. Install sass: bun add -D sass (SCSS used in Svelte templates)');
280
+ console.log(' 6. Push to main — GitHub Actions will build and deploy automatically.');
281
+ }
282
+ else {
283
+ console.log(' 5. Add your own +page.svelte files for each route.');
284
+ console.log(' 6. Push to main — GitHub Actions will build and deploy automatically.');
285
+ }
286
+ }
287
+ else {
288
+ if (mode === 'full') {
289
+ console.log(' 4. Install sass: bun add -D sass (SCSS used in Svelte templates)');
290
+ console.log(' 5. Run: bunx wrangler dev (or bun dev for local Vite)');
291
+ console.log(' 6. Deploy: bun run deploy');
292
+ }
293
+ else {
294
+ console.log(' 4. Add your own +page.svelte files for each route.');
295
+ console.log(' 5. Run: bunx wrangler dev (or bun dev for local Vite)');
296
+ console.log(' 6. Deploy: bun run deploy');
297
+ }
298
+ }
153
299
  }
154
300
  else {
155
- console.log(' 3. Add your own +page.svelte files for each route.');
156
- console.log(' 4. Run: bun dev');
301
+ console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-vercel (or your adapter)');
302
+ if (mode === 'full') {
303
+ console.log(' 3. Install sass: bun add -D sass (SCSS used in Svelte templates)');
304
+ console.log(' 4. Run: bun dev');
305
+ }
306
+ else {
307
+ console.log(' 3. Add your own +page.svelte files for each route.');
308
+ console.log(' 4. Run: bun dev');
309
+ }
157
310
  }
158
311
  console.log('\n See https://github.com/alcogy/cosmolo for full documentation.\n');
159
312
  }
@@ -38,13 +38,21 @@ function generateDrizzleSchema() {
38
38
  // ─── CRUD file generation ─────────────────────────────────────────────────────
39
39
  function generateArticlesCrud() {
40
40
  return `import { drizzle } from 'drizzle-orm/d1';
41
- import { desc, eq } from 'drizzle-orm';
41
+ import { and, desc, eq, sql } from 'drizzle-orm';
42
42
  import { articles } from '../../drizzle/schema';
43
43
 
44
44
  export function createDb(d1: D1Database) {
45
45
  return drizzle(d1);
46
46
  }
47
47
 
48
+ export function parseArticle<T extends { tags: string | null; related: string | null }>(row: T) {
49
+ return {
50
+ ...row,
51
+ tags: JSON.parse(row.tags ?? '[]') as string[],
52
+ related: JSON.parse(row.related ?? '[]') as string[],
53
+ };
54
+ }
55
+
48
56
  export async function getArticles(d1: D1Database) {
49
57
  return createDb(d1)
50
58
  .select()
@@ -53,6 +61,27 @@ export async function getArticles(d1: D1Database) {
53
61
  .orderBy(desc(articles.sort));
54
62
  }
55
63
 
64
+ export async function getArticlesByCategory(d1: D1Database, category: string) {
65
+ return createDb(d1)
66
+ .select()
67
+ .from(articles)
68
+ .where(and(eq(articles.draft, 0), eq(articles.category, category)))
69
+ .orderBy(desc(articles.sort));
70
+ }
71
+
72
+ export async function getArticlesByTag(d1: D1Database, tag: string) {
73
+ return createDb(d1)
74
+ .select()
75
+ .from(articles)
76
+ .where(
77
+ and(
78
+ eq(articles.draft, 0),
79
+ sql\`EXISTS (SELECT 1 FROM json_each(\${articles.tags}) WHERE value = \${tag})\`
80
+ )
81
+ )
82
+ .orderBy(desc(articles.sort));
83
+ }
84
+
56
85
  export async function getArticle(d1: D1Database, slug: string) {
57
86
  const [row] = await createDb(d1).select().from(articles).where(eq(articles.slug, slug));
58
87
  return row ?? null;
@@ -67,7 +96,11 @@ export async function updateArticle(
67
96
  slug: string,
68
97
  data: Partial<typeof articles.$inferInsert>
69
98
  ) {
70
- return createDb(d1).update(articles).set(data).where(eq(articles.slug, slug)).returning();
99
+ return createDb(d1)
100
+ .update(articles)
101
+ .set({ ...data, updated_at: new Date().toISOString().split('T')[0] })
102
+ .where(eq(articles.slug, slug))
103
+ .returning();
71
104
  }
72
105
 
73
106
  export async function deleteArticle(d1: D1Database, slug: string) {
@@ -110,6 +143,81 @@ export async function deleteCategory(d1: D1Database, key: string) {
110
143
  }
111
144
  `;
112
145
  }
146
+ // ─── Route file generation ────────────────────────────────────────────────────
147
+ function generateHomeRoute() {
148
+ return `import type { PageServerLoad } from './$types';
149
+ import { getArticles, parseArticle } from '$lib/db/articles';
150
+ import { getCategories } from '$lib/db/categories';
151
+ import siteConfig from '../../config/site.json';
152
+
153
+ export const load: PageServerLoad = async ({ platform }) => {
154
+ const db = platform!.env.DB;
155
+ const [rawArticles, categories] = await Promise.all([getArticles(db), getCategories(db)]);
156
+ return {
157
+ articles: rawArticles.map(parseArticle),
158
+ categories,
159
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
160
+ };
161
+ };
162
+ `;
163
+ }
164
+ function generateArticleRoute() {
165
+ return `import type { PageServerLoad } from './$types';
166
+ import { error } from '@sveltejs/kit';
167
+ import { marked } from 'marked';
168
+ import { getArticle, parseArticle } from '$lib/db/articles';
169
+ import { getCategories } from '$lib/db/categories';
170
+
171
+ export const load: PageServerLoad = async ({ params, platform }) => {
172
+ const db = platform!.env.DB;
173
+ const [raw, categories] = await Promise.all([getArticle(db, params.slug), getCategories(db)]);
174
+ if (!raw) error(404, 'Article not found');
175
+ const article = {
176
+ ...parseArticle(raw),
177
+ body: await marked(raw.body ?? ''),
178
+ };
179
+ return { article, categories };
180
+ };
181
+ `;
182
+ }
183
+ function generateCategoryRoute() {
184
+ return `import type { PageServerLoad } from './$types';
185
+ import { error } from '@sveltejs/kit';
186
+ import { getArticlesByCategory, parseArticle } from '$lib/db/articles';
187
+ import { getCategory } from '$lib/db/categories';
188
+ import siteConfig from '../../../../config/site.json';
189
+
190
+ export const load: PageServerLoad = async ({ params, platform }) => {
191
+ const db = platform!.env.DB;
192
+ const [rawArticles, category] = await Promise.all([
193
+ getArticlesByCategory(db, params.slug),
194
+ getCategory(db, params.slug),
195
+ ]);
196
+ if (!category) error(404, 'Category not found');
197
+ return {
198
+ articles: rawArticles.map(parseArticle),
199
+ category,
200
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
201
+ };
202
+ };
203
+ `;
204
+ }
205
+ function generateTagRoute() {
206
+ return `import type { PageServerLoad } from './$types';
207
+ import { getArticlesByTag, parseArticle } from '$lib/db/articles';
208
+ import siteConfig from '../../../../config/site.json';
209
+
210
+ export const load: PageServerLoad = async ({ params, platform }) => {
211
+ const db = platform!.env.DB;
212
+ const rawArticles = await getArticlesByTag(db, params.tag);
213
+ return {
214
+ articles: rawArticles.map(parseArticle),
215
+ tag: params.tag,
216
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
217
+ };
218
+ };
219
+ `;
220
+ }
113
221
  function generateDrizzleConfig() {
114
222
  return `import type { Config } from 'drizzle-kit';
115
223
 
@@ -208,7 +316,25 @@ async function preflight(root, rl) {
208
316
  }
209
317
  // D1 database name
210
318
  const dbName = await ask(rl, 'D1 database name', 'cosmolo');
211
- return { dbName };
319
+ // Generate D1-backed routes?
320
+ const routePaths = [
321
+ path.join('src', 'routes', '+page.server.ts'),
322
+ path.join('src', 'routes', 'articles', '[slug]', '+page.server.ts'),
323
+ path.join('src', 'routes', 'categories', '[slug]', '+page.server.ts'),
324
+ path.join('src', 'routes', 'tags', '[tag]', '+page.server.ts'),
325
+ ];
326
+ const existingRoutes = routePaths.filter((p) => fs.existsSync(path.join(root, p)));
327
+ let generateRoutes = true;
328
+ if (existingRoutes.length > 0) {
329
+ console.log(`\n The following route files will be replaced with D1-backed versions:`);
330
+ for (const p of existingRoutes)
331
+ console.log(` ${p}`);
332
+ generateRoutes = await confirm(rl, 'Replace with D1-backed route files?');
333
+ }
334
+ else {
335
+ generateRoutes = await confirm(rl, 'Generate D1-backed route files?');
336
+ }
337
+ return { dbName, generateRoutes };
212
338
  }
213
339
  // ─── main ─────────────────────────────────────────────────────────────────────
214
340
  export async function drizzleSetup(_config) {
@@ -218,7 +344,7 @@ export async function drizzleSetup(_config) {
218
344
  rl.close();
219
345
  if (!result)
220
346
  return;
221
- const { dbName } = result;
347
+ const { dbName, generateRoutes } = result;
222
348
  // Generate drizzle/schema.ts
223
349
  const drizzleDir = path.join(root, 'drizzle');
224
350
  fs.mkdirSync(drizzleDir, { recursive: true });
@@ -236,6 +362,20 @@ export async function drizzleSetup(_config) {
236
362
  }
237
363
  // wrangler.toml
238
364
  const wranglerAction = updateWranglerToml(root, dbName);
365
+ // D1-backed route files
366
+ if (generateRoutes) {
367
+ const routesDir = path.join(root, 'src', 'routes');
368
+ const routeFiles = [
369
+ [path.join(routesDir, '+page.server.ts'), generateHomeRoute()],
370
+ [path.join(routesDir, 'articles', '[slug]', '+page.server.ts'), generateArticleRoute()],
371
+ [path.join(routesDir, 'categories', '[slug]', '+page.server.ts'), generateCategoryRoute()],
372
+ [path.join(routesDir, 'tags', '[tag]', '+page.server.ts'), generateTagRoute()],
373
+ ];
374
+ for (const [filePath, content] of routeFiles) {
375
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
376
+ fs.writeFileSync(filePath, content);
377
+ }
378
+ }
239
379
  console.log('\n✓ Files generated:');
240
380
  console.log(' drizzle/schema.ts');
241
381
  console.log(' src/lib/db/articles.ts');
@@ -243,21 +383,33 @@ export async function drizzleSetup(_config) {
243
383
  console.log(' drizzle.config.ts');
244
384
  console.log(' .dev.vars.example');
245
385
  console.log(` wrangler.toml (${wranglerAction})`);
386
+ if (generateRoutes) {
387
+ console.log(' src/routes/+page.server.ts');
388
+ console.log(' src/routes/articles/[slug]/+page.server.ts');
389
+ console.log(' src/routes/categories/[slug]/+page.server.ts');
390
+ console.log(' src/routes/tags/[tag]/+page.server.ts');
391
+ }
392
+ let step = 1;
246
393
  console.log('\nNext steps:\n');
247
- console.log(` 1. Create the D1 database (if not done yet):`);
394
+ console.log(` ${step++}. Create the D1 database (if not done yet):`);
248
395
  console.log(` bunx wrangler d1 create ${dbName}`);
249
396
  console.log(` Copy the database_id into wrangler.toml.\n`);
250
- console.log(` 2. Generate migration files:`);
397
+ console.log(` ${step++}. Generate migration files:`);
251
398
  console.log(` bunx drizzle-kit generate\n`);
252
- console.log(` 3. Apply migrations locally:`);
399
+ console.log(` ${step++}. Apply migrations locally:`);
253
400
  console.log(` bunx wrangler d1 migrations apply ${dbName} --local\n`);
254
- console.log(` 4. Seed content from Markdown files:`);
401
+ console.log(` ${step++}. Seed content from Markdown files:`);
255
402
  console.log(` bunx cosmolo migrate:db → choose Option 1 to export SQL`);
256
403
  console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/002_seed_categories.sql`);
257
404
  console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/003_seed_articles.sql\n`);
258
- console.log(` 5. Install Cloudflare Workers types for TypeScript:`);
405
+ if (generateRoutes) {
406
+ console.log(` ${step++}. Install marked for Markdown rendering in routes:`);
407
+ console.log(` bun add marked\n`);
408
+ }
409
+ console.log(` ${step++}. Install Cloudflare Workers types for TypeScript:`);
259
410
  console.log(` bun add -d @cloudflare/workers-types\n`);
260
- console.log(` 6. Add D1Database to src/app.d.ts:`);
411
+ console.log(` ${step++}. Ensure src/app.d.ts declares the DB binding:`);
261
412
  console.log(` interface Platform { env: { DB: D1Database } }`);
262
- console.log(`\n See docs/DB_MIGRATION.md for full details.`);
413
+ console.log(` (cosmolo init --cloudflare does this automatically)\n`);
414
+ console.log(` See docs/DB_MIGRATION.md for full details.`);
263
415
  }
@@ -13,6 +13,7 @@ export const ARTICLE_COLUMNS = [
13
13
  { name: 'draft', type: 'INTEGER', notNull: true, defaultValue: 0 },
14
14
  { name: 'related', type: 'TEXT', notNull: true, defaultValue: '[]' },
15
15
  { name: 'body', type: 'TEXT', notNull: true },
16
+ { name: 'updated_at', type: 'TEXT', notNull: true, defaultValue: '' },
16
17
  ];
17
18
  export const CATEGORY_COLUMNS = [
18
19
  { name: 'key', type: 'TEXT', notNull: true, primaryKey: true },
@@ -1,7 +1,17 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { execSync } from 'child_process';
3
4
  import matter from 'gray-matter';
4
5
  import { ARTICLE_COLUMNS, CATEGORY_COLUMNS, createTableSql, escapeSql, toSqlLiteral, } from './schema.js';
6
+ function gitUpdatedAt(filePath) {
7
+ try {
8
+ const result = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
9
+ return result ? result.split('T')[0] : '';
10
+ }
11
+ catch {
12
+ return '';
13
+ }
14
+ }
5
15
  export function buildCategoriesInserts(categoriesPath) {
6
16
  if (!fs.existsSync(categoriesPath))
7
17
  return [];
@@ -36,6 +46,7 @@ export function buildArticlesInserts(articlesDir) {
36
46
  const dateVal = data.date instanceof Date
37
47
  ? data.date.toISOString().split('T')[0]
38
48
  : (data.date ?? '');
49
+ const updatedAt = gitUpdatedAt(filePath);
39
50
  const values = [
40
51
  toSqlLiteral(slug),
41
52
  toSqlLiteral(data.title ?? ''),
@@ -49,8 +60,9 @@ export function buildArticlesInserts(articlesDir) {
49
60
  toSqlLiteral(data.draft ?? false),
50
61
  toSqlLiteral(data.related ?? []),
51
62
  toSqlLiteral(content),
63
+ toSqlLiteral(updatedAt),
52
64
  ].join(', ');
53
- const cols = 'slug, title, category, excerpt, sort, date, tags, series, series_order, draft, related, body';
65
+ const cols = 'slug, title, category, excerpt, sort, date, tags, series, series_order, draft, related, body, updated_at';
54
66
  return `INSERT INTO articles (${cols}) VALUES (${values});`;
55
67
  });
56
68
  }
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as readline from 'readline';
4
+ function ask(rl, question, fallback = '') {
5
+ const hint = fallback ? ` [${fallback}]` : '';
6
+ return new Promise((resolve) => rl.question(` ${question}${hint}: `, (ans) => resolve(ans.trim() || fallback)));
7
+ }
8
+ function r2HelperContent() {
9
+ return (`// Helper for serving assets stored in Cloudflare R2.\n` +
10
+ `// Usage: import in a +server.ts route and call getR2Asset.\n\n` +
11
+ `export async function getR2Asset(bucket: R2Bucket, key: string): Promise<Response> {\n` +
12
+ ` const obj = await bucket.get(key);\n` +
13
+ ` if (!obj) return new Response('Not found', { status: 404 });\n` +
14
+ ` const headers = new Headers();\n` +
15
+ ` obj.writeHttpMetadata(headers);\n` +
16
+ ` headers.set('etag', obj.httpEtag);\n` +
17
+ ` headers.set('cache-control', 'public, max-age=31536000, immutable');\n` +
18
+ ` return new Response(obj.body as ReadableStream, { headers });\n` +
19
+ `}\n`);
20
+ }
21
+ function r2RouteContent(binding) {
22
+ return (`import type { RequestHandler } from './$types';\n` +
23
+ `import { getR2Asset } from '$lib/r2';\n\n` +
24
+ `export const GET: RequestHandler = async ({ params, platform }) => {\n` +
25
+ ` return getR2Asset(platform!.env.${binding}, params.key);\n` +
26
+ `};\n`);
27
+ }
28
+ function appendR2Binding(wranglerPath, binding, bucketName) {
29
+ const existing = fs.readFileSync(wranglerPath, 'utf-8');
30
+ const section = `\n[[r2_buckets]]\n` +
31
+ `binding = "${binding}"\n` +
32
+ `bucket_name = "${bucketName}"\n`;
33
+ fs.writeFileSync(wranglerPath, existing + section);
34
+ }
35
+ export async function main() {
36
+ const root = process.cwd();
37
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
38
+ console.log('\ncosmolo setup:r2\n');
39
+ // wrangler.toml check
40
+ const wranglerPath = path.join(root, 'wrangler.toml');
41
+ if (!fs.existsSync(wranglerPath)) {
42
+ console.error(' wrangler.toml not found.\n' +
43
+ ' Run `cosmolo init` with the Cloudflare adapter first.\n');
44
+ rl.close();
45
+ process.exit(1);
46
+ }
47
+ const bucketName = await ask(rl, 'R2 bucket name', 'assets');
48
+ const binding = await ask(rl, 'Binding name (used in platform.env.*)', 'ASSETS');
49
+ // Conflict checks
50
+ const helperPath = path.join(root, 'src', 'lib', 'r2.ts');
51
+ const routePath = path.join(root, 'src', 'routes', 'assets', '[...key]', '+server.ts');
52
+ const conflicts = [];
53
+ if (fs.existsSync(helperPath))
54
+ conflicts.push('src/lib/r2.ts');
55
+ if (fs.existsSync(routePath))
56
+ conflicts.push('src/routes/assets/[...key]/+server.ts');
57
+ if (conflicts.length > 0) {
58
+ console.log('\n The following files already exist:');
59
+ for (const f of conflicts)
60
+ console.log(` ${f}`);
61
+ const ans = await new Promise((resolve) => rl.question('\n Overwrite? [y/N]: ', (a) => resolve(a.trim().toLowerCase() || 'n')));
62
+ if (ans !== 'y') {
63
+ console.log('\n Aborted. No files were written.\n');
64
+ rl.close();
65
+ process.exit(0);
66
+ }
67
+ }
68
+ rl.close();
69
+ // Write helper
70
+ fs.mkdirSync(path.dirname(helperPath), { recursive: true });
71
+ fs.writeFileSync(helperPath, r2HelperContent());
72
+ // Write route
73
+ fs.mkdirSync(path.dirname(routePath), { recursive: true });
74
+ fs.writeFileSync(routePath, r2RouteContent(binding));
75
+ // Update wrangler.toml
76
+ appendR2Binding(wranglerPath, binding, bucketName);
77
+ console.log('\n✓ Files generated:');
78
+ console.log(' src/lib/r2.ts');
79
+ console.log(' src/routes/assets/[...key]/+server.ts');
80
+ console.log(' wrangler.toml (r2_buckets appended)');
81
+ console.log('\nNext steps:\n');
82
+ console.log(` 1. Create the R2 bucket (if not done yet):`);
83
+ console.log(` bunx wrangler r2 bucket create ${bucketName}\n`);
84
+ console.log(` 2. Add the binding to src/app.d.ts:`);
85
+ console.log(` interface Platform { env: { ${binding}: R2Bucket } }\n`);
86
+ console.log(` 3. Upload assets (e.g. from static/images/):`);
87
+ console.log(` bunx wrangler r2 object put ${bucketName}/<key> --file <path>\n`);
88
+ console.log(` 4. Reference assets via /assets/<key> in your templates.\n`);
89
+ console.log(` See https://developers.cloudflare.com/r2/ for full R2 docs.`);
90
+ }
package/dist/loaders.js CHANGED
@@ -30,7 +30,7 @@ export function createArticlesLoader(config) {
30
30
  export function createArticleLoader(config, options = {}) {
31
31
  return async ({ params }) => {
32
32
  const article = await getArticle(config, params.slug);
33
- const updatedAt = options.getUpdatedAt?.(params.slug) ?? '';
33
+ const updatedAt = options.getUpdatedAt?.(params.slug) ?? article.updatedAt;
34
34
  let related;
35
35
  if (article.related.length > 0) {
36
36
  const all = getArticles(config);
package/dist/markdown.js CHANGED
@@ -7,6 +7,9 @@ function slugifyHeading(text) {
7
7
  .trim()
8
8
  .replace(/\s+/g, '-');
9
9
  }
10
+ function escapeAttr(val) {
11
+ return val.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
12
+ }
10
13
  marked.use({
11
14
  extensions: [
12
15
  {
@@ -22,7 +25,8 @@ marked.use({
22
25
  },
23
26
  renderer(token) {
24
27
  const { videoId } = token;
25
- return `<div class="youtube-embed"><iframe src="https://www.youtube.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>\n`;
28
+ const safeId = videoId.replace(/[^a-zA-Z0-9_-]/g, '');
29
+ return `<div class="youtube-embed"><iframe src="https://www.youtube.com/embed/${safeId}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>\n`;
26
30
  },
27
31
  },
28
32
  ],
@@ -32,8 +36,8 @@ marked.use({
32
36
  link({ href, title, text }) {
33
37
  const isExternal = /^https?:\/\//.test(href ?? '');
34
38
  const rel = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
35
- const titleAttr = title ? ` title="${title}"` : '';
36
- return `<a href="${href}"${titleAttr}${rel}>${text}</a>`;
39
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
40
+ return `<a href="${escapeAttr(href ?? '')}"${titleAttr}${rel}>${text}</a>`;
37
41
  },
38
42
  heading({ text, depth }) {
39
43
  const id = slugifyHeading(text);
package/dist/plugin.js CHANGED
@@ -1,7 +1,33 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { execSync } from 'child_process';
3
4
  import { resolveConfig } from './config.js';
4
5
  export { resolveConfig } from './config.js';
6
+ function collectArticleSlugs(dir) {
7
+ const results = [];
8
+ function walk(current) {
9
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
10
+ const full = path.join(current, entry.name);
11
+ if (entry.isDirectory())
12
+ walk(full);
13
+ else if (/\.(md|svx)$/.test(entry.name)) {
14
+ const slug = path.relative(dir, full).replace(/\.(md|svx)$/, '').replace(/\\/g, '/');
15
+ results.push({ filePath: full, slug });
16
+ }
17
+ }
18
+ }
19
+ walk(dir);
20
+ return results;
21
+ }
22
+ function gitUpdatedAt(filePath, cwd) {
23
+ try {
24
+ const result = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf-8', cwd, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
25
+ return result ? result.split('T')[0] : '';
26
+ }
27
+ catch {
28
+ return '';
29
+ }
30
+ }
5
31
  const VIRTUAL_ID = 'cosmolo:content';
6
32
  const RESOLVED_ID = '\0cosmolo:content';
7
33
  /**
@@ -22,8 +48,12 @@ export function cosmoloPlugin(userConfig = {}) {
22
48
  const pagesDir = '/' + config.pagesDir.replace(/^\/|\/$/g, '');
23
49
  const categoriesAbsPath = path.resolve(process.cwd(), config.categoriesConfigPath);
24
50
  const siteConfigAbsPath = path.resolve(process.cwd(), config.siteConfigPath);
51
+ let isBuild = false;
25
52
  return {
26
53
  name: 'cosmolo',
54
+ configResolved(resolved) {
55
+ isBuild = resolved.command === 'build';
56
+ },
27
57
  config() {
28
58
  return {
29
59
  ssr: { noExternal: ['cosmolo'] },
@@ -45,20 +75,29 @@ export function cosmoloPlugin(userConfig = {}) {
45
75
  const articlesExist = fs.existsSync(articlesDirAbs);
46
76
  const pagesExist = fs.existsSync(pagesDirAbs);
47
77
  const rawMdFilesExpr = articlesExist
48
- ? `import.meta.glob('${articlesDir}/*.md', { query: '?raw', import: 'default', eager: true })`
78
+ ? `import.meta.glob('${articlesDir}/**/*.md', { query: '?raw', import: 'default', eager: true })`
49
79
  : '{}';
50
80
  const svxModulesExpr = articlesExist
51
- ? `import.meta.glob('${articlesDir}/*.svx', { eager: true })`
81
+ ? `import.meta.glob('${articlesDir}/**/*.svx', { eager: true })`
52
82
  : '{}';
53
83
  const rawPageFilesExpr = pagesExist
54
- ? `import.meta.glob('${pagesDir}/*.md', { query: '?raw', import: 'default', eager: true })`
84
+ ? `import.meta.glob('${pagesDir}/**/*.md', { query: '?raw', import: 'default', eager: true })`
55
85
  : '{}';
86
+ // Compute git updated-at dates at build time only (skipped in dev for speed)
87
+ const updatedAtMap = {};
88
+ if (isBuild && articlesExist) {
89
+ const cwd = process.cwd();
90
+ for (const { filePath, slug } of collectArticleSlugs(articlesDirAbs)) {
91
+ updatedAtMap[slug] = gitUpdatedAt(filePath, cwd);
92
+ }
93
+ }
56
94
  return `
57
95
  export const rawMdFiles = ${rawMdFilesExpr};
58
96
  export const svxModules = ${svxModulesExpr};
59
97
  export const rawPageFiles = ${rawPageFilesExpr};
60
98
  export const categoriesData = ${JSON.stringify(categoriesData)};
61
99
  export const siteConfigData = ${JSON.stringify(siteConfigData)};
100
+ export const updatedAtMap = ${JSON.stringify(updatedAtMap)};
62
101
  `.trim();
63
102
  },
64
103
  };
package/dist/types.d.ts CHANGED
@@ -31,6 +31,8 @@ export interface Article extends ArticleFrontmatter {
31
31
  html: string;
32
32
  markdown: string;
33
33
  toc: TocEntry[];
34
+ /** Last git commit date (YYYY-MM-DD). Empty string when unavailable or in dev mode. */
35
+ updatedAt: string;
34
36
  }
35
37
  export interface Page {
36
38
  slug: string;
@@ -49,7 +51,4 @@ export interface SiteConfig {
49
51
  twitterHandle: string;
50
52
  fallbackCategoryLabel: string;
51
53
  articlesPerPage: number;
52
- ogImage: {
53
- mode: 'static' | 'generated';
54
- };
55
54
  }
package/dist/virtual.d.ts CHANGED
@@ -4,5 +4,7 @@ declare module 'cosmolo:content' {
4
4
  const rawPageFiles: Record<string, string>;
5
5
  const categoriesData: Record<string, { label: string; description: string }>;
6
6
  const siteConfigData: import('./types.js').SiteConfig;
7
- export { rawMdFiles, svxModules, rawPageFiles, categoriesData, siteConfigData };
7
+ /** slug ISO date string (YYYY-MM-DD). Computed from git at build time; empty string in dev mode or when git is unavailable. */
8
+ const updatedAtMap: Record<string, string>;
9
+ export { rawMdFiles, svxModules, rawPageFiles, categoriesData, siteConfigData, updatedAtMap };
8
10
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "cosmolo",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
- "cosmolo": "./dist/cli/index.js"
6
+ "cosmolo": "dist/cli/index.js"
7
7
  },
8
8
  "exports": {
9
9
  ".": {
@@ -6,7 +6,7 @@
6
6
 
7
7
  const { data }: { data: PageData } = $props();
8
8
 
9
- const perPage = data.articlesPerPage;
9
+ const perPage = $derived(data.articlesPerPage);
10
10
 
11
11
  let query = $state('');
12
12
  let currentPage = $state(1);
@@ -4,7 +4,7 @@
4
4
 
5
5
  const { data }: { data: PageData } = $props();
6
6
 
7
- const perPage = data.articlesPerPage;
7
+ const perPage = $derived(data.articlesPerPage);
8
8
  let currentPage = $state(1);
9
9
 
10
10
  $effect(() => {
@@ -6,7 +6,7 @@
6
6
 
7
7
  const { data }: { data: PageData } = $props();
8
8
 
9
- const perPage = data.articlesPerPage;
9
+ const perPage = $derived(data.articlesPerPage);
10
10
  let currentPage = $state(1);
11
11
 
12
12
  $effect(() => {
@@ -4,6 +4,5 @@
4
4
  "description": "A content site built with Cosmolo.",
5
5
  "twitterHandle": "@yourhandle",
6
6
  "fallbackCategoryLabel": "Other",
7
- "articlesPerPage": 10,
8
- "ogImage": { "mode": "static" }
7
+ "articlesPerPage": 10
9
8
  }
@@ -1,21 +1,18 @@
1
1
  import { createArticleLoader, createArticleEntries } from 'cosmolo';
2
- import { execSync } from 'child_process';
3
2
  import config from '../../../../cosmolo.config';
4
3
 
5
- function getUpdatedAt(slug: string): string {
6
- for (const ext of ['md', 'svx']) {
7
- try {
8
- const result = execSync(
9
- `git log -1 --format=%cI -- "src/content/articles/${slug}.${ext}"`,
10
- { encoding: 'utf-8' }
11
- ).trim();
12
- if (result) return result.split('T')[0];
13
- } catch {
14
- // git not available or file not tracked
15
- }
16
- }
17
- return '';
18
- }
4
+ // To show a git-based "updated" date (SSG / Node.js only — not Cloudflare Workers):
5
+ // import { execSync } from 'child_process';
6
+ // export const load = createArticleLoader(config, {
7
+ // getUpdatedAt(slug) {
8
+ // try {
9
+ // return execSync(
10
+ // `git log -1 --format=%cI -- "src/content/articles/${slug}.md"`,
11
+ // { encoding: 'utf-8' }
12
+ // ).trim().split('T')[0];
13
+ // } catch { return ''; }
14
+ // },
15
+ // });
19
16
 
20
17
  export const entries = createArticleEntries(config);
21
- export const load = createArticleLoader(config, { getUpdatedAt });
18
+ export const load = createArticleLoader(config);