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 +101 -11
- package/dist/articles.js +5 -5
- package/dist/cli/index.js +6 -2
- package/dist/cli/init.js +174 -21
- package/dist/cli/migrate/drizzle-setup.js +163 -11
- package/dist/cli/migrate/schema.js +1 -0
- package/dist/cli/migrate/sql-export.js +13 -1
- package/dist/cli/setup/r2.d.ts +1 -0
- package/dist/cli/setup/r2.js +90 -0
- package/dist/loaders.js +1 -1
- package/dist/markdown.js +7 -3
- package/dist/plugin.js +42 -3
- package/dist/types.d.ts +2 -3
- package/dist/virtual.d.ts +3 -1
- package/package.json +2 -2
- package/templates/full/routes/+page.svelte +1 -1
- package/templates/full/routes/categories/[slug]/+page.svelte +1 -1
- package/templates/full/routes/tags/[tag]/+page.svelte +1 -1
- package/templates/shared/config/site.json +1 -2
- package/templates/shared/routes/articles/[slug]/+page.server.ts +13 -16
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 (
|
|
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
|
|
309
|
-
src/lib/db/articles.ts
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
.
|
|
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 |
|
|
531
|
+
| Adapter | Generated files |
|
|
443
532
|
|---|---|
|
|
444
|
-
| **SSG** (`adapter-static`) |
|
|
445
|
-
| **
|
|
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
|
|
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
|
|
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
|
|
86
|
-
console.log(' 2)
|
|
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
|
|
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
|
|
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 (
|
|
111
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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('
|
|
156
|
-
|
|
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)
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
397
|
+
console.log(` ${step++}. Generate migration files:`);
|
|
251
398
|
console.log(` bunx drizzle-kit generate\n`);
|
|
252
|
-
console.log(`
|
|
399
|
+
console.log(` ${step++}. Apply migrations locally:`);
|
|
253
400
|
console.log(` bunx wrangler d1 migrations apply ${dbName} --local\n`);
|
|
254
|
-
console.log(`
|
|
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
|
-
|
|
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(`
|
|
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(
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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}
|
|
78
|
+
? `import.meta.glob('${articlesDir}/**/*.md', { query: '?raw', import: 'default', eager: true })`
|
|
49
79
|
: '{}';
|
|
50
80
|
const svxModulesExpr = articlesExist
|
|
51
|
-
? `import.meta.glob('${articlesDir}
|
|
81
|
+
? `import.meta.glob('${articlesDir}/**/*.svx', { eager: true })`
|
|
52
82
|
: '{}';
|
|
53
83
|
const rawPageFilesExpr = pagesExist
|
|
54
|
-
? `import.meta.glob('${pagesDir}
|
|
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
|
-
|
|
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,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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
18
|
+
export const load = createArticleLoader(config);
|