cosmolo 0.4.0 → 0.5.0

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/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,104 @@ 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
+ `compatibility_date = "${new Date().toISOString().slice(0, 10)}"`,
224
+ `compatibility_flags = ["nodejs_compat"]`,
225
+ ``,
226
+ `# Uncomment to add Cloudflare D1 (run: bunx cosmolo migrate:db)`,
227
+ `# [[d1_databases]]`,
228
+ `# binding = "DB"`,
229
+ `# database_name = "${projectName}-db"`,
230
+ `# database_id = "" # fill in after: bunx wrangler d1 create ${projectName}-db`,
231
+ ].join('\n') + '\n';
232
+ writeFile(wranglerTomlPath, wranglerToml);
233
+ console.log(` created wrangler.toml`);
234
+ const appDts = [
235
+ `// See https://svelte.dev/docs/kit/types#app.d.ts`,
236
+ `// Install @cloudflare/workers-types for full type support:`,
237
+ `// bun add -D @cloudflare/workers-types`,
238
+ `declare global {`,
239
+ `\tnamespace App {`,
240
+ `\t\tinterface Platform {`,
241
+ `\t\t\tenv: Env;`,
242
+ `\t\t\tcf: CfProperties;`,
243
+ `\t\t\tctx: ExecutionContext;`,
244
+ `\t\t}`,
245
+ `\t}`,
246
+ `}`,
247
+ ``,
248
+ `export {};`,
249
+ ].join('\n') + '\n';
250
+ writeFile(appDtsPath, appDts);
251
+ console.log(` created src/app.d.ts`);
252
+ if (generateGha) {
253
+ writeFile(ghaWorkflowPath, githubActionsContent(projectName));
254
+ console.log(` created .github/workflows/deploy.yml`);
255
+ }
256
+ }
257
+ injectPackageScripts(PROJECT_ROOT, adapter);
141
258
  // ── Next steps ──────────────────────────────────────────────────────────
142
259
  console.log('\nDone! Next steps:\n');
143
260
  console.log(' 1. Run: bun install');
144
- if (isSSG) {
261
+ if (adapter === 'ssg') {
145
262
  console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-static');
263
+ if (mode === 'full') {
264
+ console.log(' 3. Install sass: bun add -D sass (SCSS used in Svelte templates)');
265
+ console.log(' 4. Run: bun dev');
266
+ }
267
+ else {
268
+ console.log(' 3. Add your own +page.svelte files for each route.');
269
+ console.log(' 4. Run: bun dev');
270
+ }
146
271
  }
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');
272
+ else if (adapter === 'cloudflare') {
273
+ console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-cloudflare');
274
+ console.log(' 3. Install types: bun add -D @cloudflare/workers-types');
275
+ if (generateGha) {
276
+ console.log(' 4. Add secret to GitHub repo: CF_API_TOKEN (Cloudflare API token)');
277
+ if (mode === 'full') {
278
+ console.log(' 5. Install sass: bun add -D sass (SCSS used in Svelte templates)');
279
+ console.log(' 6. Push to main — GitHub Actions will build and deploy automatically.');
280
+ }
281
+ else {
282
+ console.log(' 5. Add your own +page.svelte files for each route.');
283
+ console.log(' 6. Push to main — GitHub Actions will build and deploy automatically.');
284
+ }
285
+ }
286
+ else {
287
+ if (mode === 'full') {
288
+ console.log(' 4. Install sass: bun add -D sass (SCSS used in Svelte templates)');
289
+ console.log(' 5. Run: bunx wrangler dev (or bun dev for local Vite)');
290
+ console.log(' 6. Deploy: bun run deploy');
291
+ }
292
+ else {
293
+ console.log(' 4. Add your own +page.svelte files for each route.');
294
+ console.log(' 5. Run: bunx wrangler dev (or bun dev for local Vite)');
295
+ console.log(' 6. Deploy: bun run deploy');
296
+ }
297
+ }
153
298
  }
154
299
  else {
155
- console.log(' 3. Add your own +page.svelte files for each route.');
156
- console.log(' 4. Run: bun dev');
300
+ console.log(' 2. Install adapter: bun add -D @sveltejs/adapter-vercel (or your adapter)');
301
+ if (mode === 'full') {
302
+ console.log(' 3. Install sass: bun add -D sass (SCSS used in Svelte templates)');
303
+ console.log(' 4. Run: bun dev');
304
+ }
305
+ else {
306
+ console.log(' 3. Add your own +page.svelte files for each route.');
307
+ console.log(' 4. Run: bun dev');
308
+ }
157
309
  }
158
310
  console.log('\n See https://github.com/alcogy/cosmolo for full documentation.\n');
159
311
  }
@@ -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;
@@ -110,6 +139,81 @@ export async function deleteCategory(d1: D1Database, key: string) {
110
139
  }
111
140
  `;
112
141
  }
142
+ // ─── Route file generation ────────────────────────────────────────────────────
143
+ function generateHomeRoute() {
144
+ return `import type { PageServerLoad } from './$types';
145
+ import { getArticles, parseArticle } from '$lib/db/articles';
146
+ import { getCategories } from '$lib/db/categories';
147
+ import siteConfig from '../../config/site.json';
148
+
149
+ export const load: PageServerLoad = async ({ platform }) => {
150
+ const db = platform!.env.DB;
151
+ const [rawArticles, categories] = await Promise.all([getArticles(db), getCategories(db)]);
152
+ return {
153
+ articles: rawArticles.map(parseArticle),
154
+ categories,
155
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
156
+ };
157
+ };
158
+ `;
159
+ }
160
+ function generateArticleRoute() {
161
+ return `import type { PageServerLoad } from './$types';
162
+ import { error } from '@sveltejs/kit';
163
+ import { marked } from 'marked';
164
+ import { getArticle, parseArticle } from '$lib/db/articles';
165
+ import { getCategories } from '$lib/db/categories';
166
+
167
+ export const load: PageServerLoad = async ({ params, platform }) => {
168
+ const db = platform!.env.DB;
169
+ const [raw, categories] = await Promise.all([getArticle(db, params.slug), getCategories(db)]);
170
+ if (!raw) error(404, 'Article not found');
171
+ const article = {
172
+ ...parseArticle(raw),
173
+ body: await marked(raw.body ?? ''),
174
+ };
175
+ return { article, categories };
176
+ };
177
+ `;
178
+ }
179
+ function generateCategoryRoute() {
180
+ return `import type { PageServerLoad } from './$types';
181
+ import { error } from '@sveltejs/kit';
182
+ import { getArticlesByCategory, parseArticle } from '$lib/db/articles';
183
+ import { getCategory } from '$lib/db/categories';
184
+ import siteConfig from '../../../../config/site.json';
185
+
186
+ export const load: PageServerLoad = async ({ params, platform }) => {
187
+ const db = platform!.env.DB;
188
+ const [rawArticles, category] = await Promise.all([
189
+ getArticlesByCategory(db, params.slug),
190
+ getCategory(db, params.slug),
191
+ ]);
192
+ if (!category) error(404, 'Category not found');
193
+ return {
194
+ articles: rawArticles.map(parseArticle),
195
+ category,
196
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
197
+ };
198
+ };
199
+ `;
200
+ }
201
+ function generateTagRoute() {
202
+ return `import type { PageServerLoad } from './$types';
203
+ import { getArticlesByTag, parseArticle } from '$lib/db/articles';
204
+ import siteConfig from '../../../../config/site.json';
205
+
206
+ export const load: PageServerLoad = async ({ params, platform }) => {
207
+ const db = platform!.env.DB;
208
+ const rawArticles = await getArticlesByTag(db, params.tag);
209
+ return {
210
+ articles: rawArticles.map(parseArticle),
211
+ tag: params.tag,
212
+ articlesPerPage: siteConfig.articlesPerPage ?? 10,
213
+ };
214
+ };
215
+ `;
216
+ }
113
217
  function generateDrizzleConfig() {
114
218
  return `import type { Config } from 'drizzle-kit';
115
219
 
@@ -208,7 +312,25 @@ async function preflight(root, rl) {
208
312
  }
209
313
  // D1 database name
210
314
  const dbName = await ask(rl, 'D1 database name', 'cosmolo');
211
- return { dbName };
315
+ // Generate D1-backed routes?
316
+ const routePaths = [
317
+ path.join('src', 'routes', '+page.server.ts'),
318
+ path.join('src', 'routes', 'articles', '[slug]', '+page.server.ts'),
319
+ path.join('src', 'routes', 'categories', '[slug]', '+page.server.ts'),
320
+ path.join('src', 'routes', 'tags', '[tag]', '+page.server.ts'),
321
+ ];
322
+ const existingRoutes = routePaths.filter((p) => fs.existsSync(path.join(root, p)));
323
+ let generateRoutes = true;
324
+ if (existingRoutes.length > 0) {
325
+ console.log(`\n The following route files will be replaced with D1-backed versions:`);
326
+ for (const p of existingRoutes)
327
+ console.log(` ${p}`);
328
+ generateRoutes = await confirm(rl, 'Replace with D1-backed route files?');
329
+ }
330
+ else {
331
+ generateRoutes = await confirm(rl, 'Generate D1-backed route files?');
332
+ }
333
+ return { dbName, generateRoutes };
212
334
  }
213
335
  // ─── main ─────────────────────────────────────────────────────────────────────
214
336
  export async function drizzleSetup(_config) {
@@ -218,7 +340,7 @@ export async function drizzleSetup(_config) {
218
340
  rl.close();
219
341
  if (!result)
220
342
  return;
221
- const { dbName } = result;
343
+ const { dbName, generateRoutes } = result;
222
344
  // Generate drizzle/schema.ts
223
345
  const drizzleDir = path.join(root, 'drizzle');
224
346
  fs.mkdirSync(drizzleDir, { recursive: true });
@@ -236,6 +358,20 @@ export async function drizzleSetup(_config) {
236
358
  }
237
359
  // wrangler.toml
238
360
  const wranglerAction = updateWranglerToml(root, dbName);
361
+ // D1-backed route files
362
+ if (generateRoutes) {
363
+ const routesDir = path.join(root, 'src', 'routes');
364
+ const routeFiles = [
365
+ [path.join(routesDir, '+page.server.ts'), generateHomeRoute()],
366
+ [path.join(routesDir, 'articles', '[slug]', '+page.server.ts'), generateArticleRoute()],
367
+ [path.join(routesDir, 'categories', '[slug]', '+page.server.ts'), generateCategoryRoute()],
368
+ [path.join(routesDir, 'tags', '[tag]', '+page.server.ts'), generateTagRoute()],
369
+ ];
370
+ for (const [filePath, content] of routeFiles) {
371
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
372
+ fs.writeFileSync(filePath, content);
373
+ }
374
+ }
239
375
  console.log('\n✓ Files generated:');
240
376
  console.log(' drizzle/schema.ts');
241
377
  console.log(' src/lib/db/articles.ts');
@@ -243,21 +379,33 @@ export async function drizzleSetup(_config) {
243
379
  console.log(' drizzle.config.ts');
244
380
  console.log(' .dev.vars.example');
245
381
  console.log(` wrangler.toml (${wranglerAction})`);
382
+ if (generateRoutes) {
383
+ console.log(' src/routes/+page.server.ts');
384
+ console.log(' src/routes/articles/[slug]/+page.server.ts');
385
+ console.log(' src/routes/categories/[slug]/+page.server.ts');
386
+ console.log(' src/routes/tags/[tag]/+page.server.ts');
387
+ }
388
+ let step = 1;
246
389
  console.log('\nNext steps:\n');
247
- console.log(` 1. Create the D1 database (if not done yet):`);
390
+ console.log(` ${step++}. Create the D1 database (if not done yet):`);
248
391
  console.log(` bunx wrangler d1 create ${dbName}`);
249
392
  console.log(` Copy the database_id into wrangler.toml.\n`);
250
- console.log(` 2. Generate migration files:`);
393
+ console.log(` ${step++}. Generate migration files:`);
251
394
  console.log(` bunx drizzle-kit generate\n`);
252
- console.log(` 3. Apply migrations locally:`);
395
+ console.log(` ${step++}. Apply migrations locally:`);
253
396
  console.log(` bunx wrangler d1 migrations apply ${dbName} --local\n`);
254
- console.log(` 4. Seed content from Markdown files:`);
397
+ console.log(` ${step++}. Seed content from Markdown files:`);
255
398
  console.log(` bunx cosmolo migrate:db → choose Option 1 to export SQL`);
256
399
  console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/002_seed_categories.sql`);
257
400
  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:`);
401
+ if (generateRoutes) {
402
+ console.log(` ${step++}. Install marked for Markdown rendering in routes:`);
403
+ console.log(` bun add marked\n`);
404
+ }
405
+ console.log(` ${step++}. Install Cloudflare Workers types for TypeScript:`);
259
406
  console.log(` bun add -d @cloudflare/workers-types\n`);
260
- console.log(` 6. Add D1Database to src/app.d.ts:`);
407
+ console.log(` ${step++}. Ensure src/app.d.ts declares the DB binding:`);
261
408
  console.log(` interface Platform { env: { DB: D1Database } }`);
262
- console.log(`\n See docs/DB_MIGRATION.md for full details.`);
409
+ console.log(` (cosmolo init --cloudflare does this automatically)\n`);
410
+ console.log(` See docs/DB_MIGRATION.md for full details.`);
263
411
  }
@@ -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/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/types.d.ts CHANGED
@@ -49,7 +49,4 @@ export interface SiteConfig {
49
49
  twitterHandle: string;
50
50
  fallbackCategoryLabel: string;
51
51
  articlesPerPage: number;
52
- ogImage: {
53
- mode: 'static' | 'generated';
54
- };
55
52
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "cosmolo",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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
  ".": {
@@ -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
  }