cosmolo 0.3.9 → 0.4.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
@@ -24,6 +24,7 @@ a canonical "just add Markdown and go" story without asking you to leave.
24
24
  | Component in Markdown | Yes (.svx) | Yes (.mdx) | Yes | No |
25
25
  | Config-driven categories | Yes | No | No | No |
26
26
  | Headless CMS (JSON API) | Yes | Manual | Manual | Manual |
27
+ | DB migration path | `migrate:db` | No | No | Manual |
27
28
  | Learning curve | SvelteKit only | Astro concepts | Vue + Nuxt | SvelteKit only |
28
29
 
29
30
  **Core principles:**
@@ -272,6 +273,71 @@ Prompts for key (slug), label, and description. Appends the new entry to `config
272
273
 
273
274
  ---
274
275
 
276
+ ## Database Migration
277
+
278
+ When a file-based Cosmolo site outgrows Markdown — multiple writers, mobile editing,
279
+ a growing team — `migrate:db` converts your content to a database without rewriting
280
+ your application code.
281
+
282
+ DB support is optional. File-based sites continue to work exactly as before.
283
+ Migration is a one-time operation when you're ready to scale.
284
+
285
+ ```bash
286
+ bunx cosmolo migrate:db
287
+ ```
288
+
289
+ The command is interactive and offers three paths:
290
+
291
+ | Option | Description |
292
+ |---|---|
293
+ | **1 — Export SQL files** | Generates `cosmolo-migration/*.sql` (CREATE TABLE + INSERT for all articles and categories). Works with any relational database. |
294
+ | **2 — Execute directly** | Executes the same SQL against a local SQLite database. Set `DATABASE_URL=./mysite.db` before running. |
295
+ | **3 — Drizzle + Cloudflare D1** | Full setup: generates Drizzle schema, CRUD helpers, `wrangler.toml` D1 binding, and `drizzle.config.ts`. Runs preflight checks before writing anything. |
296
+
297
+ ### What gets migrated
298
+
299
+ - **Articles** — all frontmatter fields plus the raw Markdown body. Subdirectory-organized files (e.g. `articles/2024/post.md`) are handled automatically; the slug becomes `2024/post`.
300
+ - **Categories** — from `config/categories.json`
301
+ - **Draft articles** — included in the DB with `draft = 1`; the generated `getArticles()` helper filters them out automatically
302
+
303
+ ### Option 3 — Drizzle + Cloudflare D1
304
+
305
+ After a preflight check (drizzle installed, wrangler.toml, table conflicts), the following files are generated:
306
+
307
+ ```
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
314
+ ```
315
+
316
+ The command prints step-by-step instructions after generation:
317
+ 1. `bunx wrangler d1 create <db-name>` and copy the `database_id` into `wrangler.toml`
318
+ 2. `bunx drizzle-kit generate` to create SQL migration files
319
+ 3. `bunx wrangler d1 migrations apply <db-name> --local` to apply locally
320
+ 4. Run Option 1 to export seed SQL, then `wrangler d1 execute` to import your articles
321
+ 5. `bun add -d @cloudflare/workers-types` for TypeScript support
322
+ 6. Add `interface Platform { env: { DB: D1Database } }` to `src/app.d.ts`
323
+
324
+ ### SSR requirement
325
+
326
+ DB-backed content requires a server-capable adapter. Content in D1 is resolved at
327
+ request time, so `adapter-static` (SSG) is not compatible.
328
+
329
+ Switch to `adapter-cloudflare` for Cloudflare Pages, or `adapter-node` for a
330
+ self-hosted server:
331
+
332
+ ```diff
333
+ - import adapter from '@sveltejs/adapter-static';
334
+ + import adapter from '@sveltejs/adapter-cloudflare';
335
+ ```
336
+
337
+ The upside: content edits take effect immediately — no rebuild or redeploy needed.
338
+
339
+ ---
340
+
275
341
  ## Headless CMS
276
342
 
277
343
  Cosmolo can expose your content as static JSON endpoints, making it usable as a
package/dist/cli/index.js CHANGED
@@ -7,6 +7,9 @@ switch (cmd) {
7
7
  case 'generate':
8
8
  await (await import('./generate.js')).main();
9
9
  break;
10
+ case 'migrate:db':
11
+ await (await import('./migrate.js')).main();
12
+ break;
10
13
  default: {
11
14
  const isUnknown = Boolean(cmd);
12
15
  if (isUnknown)
@@ -15,6 +18,7 @@ switch (cmd) {
15
18
  console.log('Commands:');
16
19
  console.log(' init Scaffold routes into an existing SvelteKit project');
17
20
  console.log(' generate [article|page|category] Create content files');
21
+ console.log(' migrate:db Migrate content to a database');
18
22
  process.exit(isUnknown ? 1 : 0);
19
23
  }
20
24
  }
@@ -0,0 +1,2 @@
1
+ import type { ResolvedCosmoloConfig } from '../../types.js';
2
+ export declare function drizzleSetup(_config: ResolvedCosmoloConfig): Promise<void>;
@@ -0,0 +1,263 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as readline from 'readline';
4
+ import { execSync } from 'child_process';
5
+ import { ARTICLE_COLUMNS, CATEGORY_COLUMNS } from './schema.js';
6
+ // ─── readline helpers ─────────────────────────────────────────────────────────
7
+ function ask(rl, question, fallback = '') {
8
+ const hint = fallback ? ` [${fallback}]` : '';
9
+ return new Promise((resolve) => rl.question(` ${question}${hint}: `, (ans) => resolve(ans.trim() || fallback)));
10
+ }
11
+ function confirm(rl, question) {
12
+ return new Promise((resolve) => rl.question(` ${question} [y/N] `, (ans) => resolve(ans.trim().toLowerCase() === 'y')));
13
+ }
14
+ // ─── Drizzle schema generation ────────────────────────────────────────────────
15
+ function drizzleColumnDef(col) {
16
+ const fn = col.type === 'TEXT' ? 'text' : 'integer';
17
+ let chain = ` ${col.name}: ${fn}('${col.name}')`;
18
+ if (col.primaryKey) {
19
+ chain += '.primaryKey()';
20
+ }
21
+ else {
22
+ if (col.notNull)
23
+ chain += '.notNull()';
24
+ if (col.defaultValue !== undefined) {
25
+ const val = typeof col.defaultValue === 'string' ? `'${col.defaultValue}'` : col.defaultValue;
26
+ chain += `.default(${val})`;
27
+ }
28
+ }
29
+ return chain + ',';
30
+ }
31
+ function generateDrizzleSchema() {
32
+ const catCols = CATEGORY_COLUMNS.map(drizzleColumnDef).join('\n');
33
+ const artCols = ARTICLE_COLUMNS.map(drizzleColumnDef).join('\n');
34
+ return (`import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';\n\n` +
35
+ `export const categories = sqliteTable('categories', {\n${catCols}\n});\n\n` +
36
+ `export const articles = sqliteTable('articles', {\n${artCols}\n});\n`);
37
+ }
38
+ // ─── CRUD file generation ─────────────────────────────────────────────────────
39
+ function generateArticlesCrud() {
40
+ return `import { drizzle } from 'drizzle-orm/d1';
41
+ import { desc, eq } from 'drizzle-orm';
42
+ import { articles } from '../../drizzle/schema';
43
+
44
+ export function createDb(d1: D1Database) {
45
+ return drizzle(d1);
46
+ }
47
+
48
+ export async function getArticles(d1: D1Database) {
49
+ return createDb(d1)
50
+ .select()
51
+ .from(articles)
52
+ .where(eq(articles.draft, 0))
53
+ .orderBy(desc(articles.sort));
54
+ }
55
+
56
+ export async function getArticle(d1: D1Database, slug: string) {
57
+ const [row] = await createDb(d1).select().from(articles).where(eq(articles.slug, slug));
58
+ return row ?? null;
59
+ }
60
+
61
+ export async function createArticle(d1: D1Database, data: typeof articles.$inferInsert) {
62
+ return createDb(d1).insert(articles).values(data).returning();
63
+ }
64
+
65
+ export async function updateArticle(
66
+ d1: D1Database,
67
+ slug: string,
68
+ data: Partial<typeof articles.$inferInsert>
69
+ ) {
70
+ return createDb(d1).update(articles).set(data).where(eq(articles.slug, slug)).returning();
71
+ }
72
+
73
+ export async function deleteArticle(d1: D1Database, slug: string) {
74
+ return createDb(d1).delete(articles).where(eq(articles.slug, slug));
75
+ }
76
+ `;
77
+ }
78
+ function generateCategoriesCrud() {
79
+ return `import { drizzle } from 'drizzle-orm/d1';
80
+ import { eq } from 'drizzle-orm';
81
+ import { categories } from '../../drizzle/schema';
82
+
83
+ export function createDb(d1: D1Database) {
84
+ return drizzle(d1);
85
+ }
86
+
87
+ export async function getCategories(d1: D1Database) {
88
+ return createDb(d1).select().from(categories);
89
+ }
90
+
91
+ export async function getCategory(d1: D1Database, key: string) {
92
+ const [row] = await createDb(d1).select().from(categories).where(eq(categories.key, key));
93
+ return row ?? null;
94
+ }
95
+
96
+ export async function createCategory(d1: D1Database, data: typeof categories.$inferInsert) {
97
+ return createDb(d1).insert(categories).values(data).returning();
98
+ }
99
+
100
+ export async function updateCategory(
101
+ d1: D1Database,
102
+ key: string,
103
+ data: Partial<typeof categories.$inferInsert>
104
+ ) {
105
+ return createDb(d1).update(categories).set(data).where(eq(categories.key, key)).returning();
106
+ }
107
+
108
+ export async function deleteCategory(d1: D1Database, key: string) {
109
+ return createDb(d1).delete(categories).where(eq(categories.key, key));
110
+ }
111
+ `;
112
+ }
113
+ function generateDrizzleConfig() {
114
+ return `import type { Config } from 'drizzle-kit';
115
+
116
+ export default {
117
+ schema: './drizzle/schema.ts',
118
+ out: './drizzle/migrations',
119
+ dialect: 'sqlite',
120
+ } satisfies Config;
121
+ `;
122
+ }
123
+ function generateDevVarsExample() {
124
+ return `CLOUDFLARE_ACCOUNT_ID=
125
+ CLOUDFLARE_DATABASE_ID=
126
+ CLOUDFLARE_D1_TOKEN=
127
+ `;
128
+ }
129
+ // ─── wrangler.toml helpers ────────────────────────────────────────────────────
130
+ function d1Section(dbName) {
131
+ return (`\n[[d1_databases]]\n` +
132
+ `binding = "DB"\n` +
133
+ `database_name = "${dbName}"\n` +
134
+ `database_id = "REPLACE_WITH_YOUR_DATABASE_ID"\n`);
135
+ }
136
+ function updateWranglerToml(root, dbName) {
137
+ const wranglerPath = path.join(root, 'wrangler.toml');
138
+ if (fs.existsSync(wranglerPath)) {
139
+ const existing = fs.readFileSync(wranglerPath, 'utf-8');
140
+ fs.writeFileSync(wranglerPath, existing + d1Section(dbName));
141
+ return 'appended';
142
+ }
143
+ const minimal = `name = "cosmolo-site"\n` +
144
+ `compatibility_date = "2025-01-01"\n` +
145
+ d1Section(dbName);
146
+ fs.writeFileSync(wranglerPath, minimal);
147
+ return 'created';
148
+ }
149
+ // ─── preflight checks ─────────────────────────────────────────────────────────
150
+ async function preflight(root, rl) {
151
+ console.log('\n Checking environment...\n');
152
+ // drizzle-orm installed?
153
+ const drizzleInstalled = fs.existsSync(path.join(root, 'node_modules', 'drizzle-orm'));
154
+ if (!drizzleInstalled) {
155
+ console.log(' drizzle-orm is not installed.');
156
+ const install = await confirm(rl, 'Install drizzle-orm and drizzle-kit now?');
157
+ if (install) {
158
+ console.log(' Running: bun add drizzle-orm drizzle-kit ...');
159
+ execSync('bun add drizzle-orm drizzle-kit', { stdio: 'inherit', cwd: root });
160
+ }
161
+ else {
162
+ console.log(' Install drizzle-orm and drizzle-kit manually, then re-run migrate:db.');
163
+ return null;
164
+ }
165
+ }
166
+ // wrangler.toml: existing D1 binding?
167
+ const wranglerPath = path.join(root, 'wrangler.toml');
168
+ if (fs.existsSync(wranglerPath)) {
169
+ const content = fs.readFileSync(wranglerPath, 'utf-8');
170
+ if (content.includes('[[d1_databases]]')) {
171
+ console.log(' Warning: wrangler.toml already contains [[d1_databases]].');
172
+ const proceed = await confirm(rl, 'Add another D1 binding anyway?');
173
+ if (!proceed)
174
+ return null;
175
+ }
176
+ }
177
+ // drizzle/schema.ts exists?
178
+ const schemaPath = path.join(root, 'drizzle', 'schema.ts');
179
+ if (fs.existsSync(schemaPath)) {
180
+ const ok = await confirm(rl, 'drizzle/schema.ts already exists. Overwrite?');
181
+ if (!ok)
182
+ return null;
183
+ // table name conflict check
184
+ const existing = fs.readFileSync(schemaPath, 'utf-8');
185
+ const conflicts = ['articles', 'categories'].filter((t) => existing.includes(`sqliteTable('${t}'`));
186
+ if (conflicts.length > 0) {
187
+ console.log(` Warning: table(s) already defined in schema: ${conflicts.join(', ')}`);
188
+ const ok2 = await confirm(rl, 'Continue and overwrite?');
189
+ if (!ok2)
190
+ return null;
191
+ }
192
+ }
193
+ // drizzle.config.ts exists?
194
+ const drizzleConfigPath = path.join(root, 'drizzle.config.ts');
195
+ if (fs.existsSync(drizzleConfigPath)) {
196
+ const ok = await confirm(rl, 'drizzle.config.ts already exists. Overwrite?');
197
+ if (!ok)
198
+ return null;
199
+ }
200
+ // src/lib/db/ CRUD files exist?
201
+ const dbDir = path.join(root, 'src', 'lib', 'db');
202
+ const crudFiles = ['articles.ts', 'categories.ts'].filter((f) => fs.existsSync(path.join(dbDir, f)));
203
+ if (crudFiles.length > 0) {
204
+ console.log(` Warning: src/lib/db/${crudFiles.join(', ')} already exist.`);
205
+ const ok = await confirm(rl, 'Overwrite CRUD files?');
206
+ if (!ok)
207
+ return null;
208
+ }
209
+ // D1 database name
210
+ const dbName = await ask(rl, 'D1 database name', 'cosmolo');
211
+ return { dbName };
212
+ }
213
+ // ─── main ─────────────────────────────────────────────────────────────────────
214
+ export async function drizzleSetup(_config) {
215
+ const root = process.cwd();
216
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
217
+ const result = await preflight(root, rl);
218
+ rl.close();
219
+ if (!result)
220
+ return;
221
+ const { dbName } = result;
222
+ // Generate drizzle/schema.ts
223
+ const drizzleDir = path.join(root, 'drizzle');
224
+ fs.mkdirSync(drizzleDir, { recursive: true });
225
+ fs.writeFileSync(path.join(drizzleDir, 'schema.ts'), generateDrizzleSchema());
226
+ // Generate src/lib/db/articles.ts and categories.ts
227
+ const dbDir = path.join(root, 'src', 'lib', 'db');
228
+ fs.mkdirSync(dbDir, { recursive: true });
229
+ fs.writeFileSync(path.join(dbDir, 'articles.ts'), generateArticlesCrud());
230
+ fs.writeFileSync(path.join(dbDir, 'categories.ts'), generateCategoriesCrud());
231
+ // drizzle.config.ts
232
+ fs.writeFileSync(path.join(root, 'drizzle.config.ts'), generateDrizzleConfig());
233
+ // .dev.vars.example
234
+ if (!fs.existsSync(path.join(root, '.dev.vars.example'))) {
235
+ fs.writeFileSync(path.join(root, '.dev.vars.example'), generateDevVarsExample());
236
+ }
237
+ // wrangler.toml
238
+ const wranglerAction = updateWranglerToml(root, dbName);
239
+ console.log('\n✓ Files generated:');
240
+ console.log(' drizzle/schema.ts');
241
+ console.log(' src/lib/db/articles.ts');
242
+ console.log(' src/lib/db/categories.ts');
243
+ console.log(' drizzle.config.ts');
244
+ console.log(' .dev.vars.example');
245
+ console.log(` wrangler.toml (${wranglerAction})`);
246
+ console.log('\nNext steps:\n');
247
+ console.log(` 1. Create the D1 database (if not done yet):`);
248
+ console.log(` bunx wrangler d1 create ${dbName}`);
249
+ console.log(` Copy the database_id into wrangler.toml.\n`);
250
+ console.log(` 2. Generate migration files:`);
251
+ console.log(` bunx drizzle-kit generate\n`);
252
+ console.log(` 3. Apply migrations locally:`);
253
+ console.log(` bunx wrangler d1 migrations apply ${dbName} --local\n`);
254
+ console.log(` 4. Seed content from Markdown files:`);
255
+ console.log(` bunx cosmolo migrate:db → choose Option 1 to export SQL`);
256
+ console.log(` bunx wrangler d1 execute ${dbName} --local --file=cosmolo-migration/002_seed_categories.sql`);
257
+ 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:`);
259
+ console.log(` bun add -d @cloudflare/workers-types\n`);
260
+ console.log(` 6. Add D1Database to src/app.d.ts:`);
261
+ console.log(` interface Platform { env: { DB: D1Database } }`);
262
+ console.log(`\n See docs/DB_MIGRATION.md for full details.`);
263
+ }
@@ -0,0 +1,12 @@
1
+ export interface SqlColumn {
2
+ name: string;
3
+ type: 'TEXT' | 'INTEGER';
4
+ notNull: boolean;
5
+ defaultValue?: string | number;
6
+ primaryKey?: boolean;
7
+ }
8
+ export declare const ARTICLE_COLUMNS: SqlColumn[];
9
+ export declare const CATEGORY_COLUMNS: SqlColumn[];
10
+ export declare function createTableSql(tableName: string, columns: SqlColumn[]): string;
11
+ export declare function escapeSql(val: string): string;
12
+ export declare function toSqlLiteral(val: unknown): string;
@@ -0,0 +1,56 @@
1
+ // Derived from articleFrontmatterSchema in articles.ts.
2
+ // Arrays are stored as JSON text. Booleans as INTEGER (0/1, SQLite convention).
3
+ export const ARTICLE_COLUMNS = [
4
+ { name: 'slug', type: 'TEXT', notNull: true, primaryKey: true },
5
+ { name: 'title', type: 'TEXT', notNull: true },
6
+ { name: 'category', type: 'TEXT', notNull: true },
7
+ { name: 'excerpt', type: 'TEXT', notNull: true },
8
+ { name: 'sort', type: 'INTEGER', notNull: true, defaultValue: 0 },
9
+ { name: 'date', type: 'TEXT', notNull: true, defaultValue: '' },
10
+ { name: 'tags', type: 'TEXT', notNull: true, defaultValue: '[]' },
11
+ { name: 'series', type: 'TEXT', notNull: false },
12
+ { name: 'series_order', type: 'INTEGER', notNull: false },
13
+ { name: 'draft', type: 'INTEGER', notNull: true, defaultValue: 0 },
14
+ { name: 'related', type: 'TEXT', notNull: true, defaultValue: '[]' },
15
+ { name: 'body', type: 'TEXT', notNull: true },
16
+ ];
17
+ export const CATEGORY_COLUMNS = [
18
+ { name: 'key', type: 'TEXT', notNull: true, primaryKey: true },
19
+ { name: 'label', type: 'TEXT', notNull: true },
20
+ { name: 'description', type: 'TEXT', notNull: true, defaultValue: '' },
21
+ ];
22
+ function columnDef(col) {
23
+ let def = ` ${col.name} ${col.type}`;
24
+ if (col.primaryKey) {
25
+ def += ' PRIMARY KEY';
26
+ }
27
+ else {
28
+ if (col.notNull)
29
+ def += ' NOT NULL';
30
+ if (col.defaultValue !== undefined) {
31
+ const val = typeof col.defaultValue === 'string' ? `'${col.defaultValue}'` : col.defaultValue;
32
+ def += ` DEFAULT ${val}`;
33
+ }
34
+ }
35
+ return def;
36
+ }
37
+ export function createTableSql(tableName, columns) {
38
+ const defs = columns.map(columnDef).join(',\n');
39
+ return `CREATE TABLE IF NOT EXISTS ${tableName} (\n${defs}\n);`;
40
+ }
41
+ export function escapeSql(val) {
42
+ return val.replace(/'/g, "''");
43
+ }
44
+ export function toSqlLiteral(val) {
45
+ if (val === null || val === undefined)
46
+ return 'NULL';
47
+ if (typeof val === 'boolean')
48
+ return val ? '1' : '0';
49
+ if (typeof val === 'number')
50
+ return String(val);
51
+ if (val instanceof Date)
52
+ return `'${escapeSql(val.toISOString().split('T')[0])}'`;
53
+ if (Array.isArray(val))
54
+ return `'${escapeSql(JSON.stringify(val))}'`;
55
+ return `'${escapeSql(String(val))}'`;
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { ResolvedCosmoloConfig } from '../../types.js';
2
+ export declare function executeSqlDirect(config: ResolvedCosmoloConfig): Promise<void>;
@@ -0,0 +1,67 @@
1
+ import * as path from 'path';
2
+ import * as readline from 'readline';
3
+ import { ARTICLE_COLUMNS, CATEGORY_COLUMNS, createTableSql } from './schema.js';
4
+ import { buildCategoriesInserts, buildArticlesInserts } from './sql-export.js';
5
+ function confirm(question) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => rl.question(`${question} [y/N] `, (ans) => {
8
+ rl.close();
9
+ resolve(ans.trim().toLowerCase() === 'y');
10
+ }));
11
+ }
12
+ function resolveDbPath(url) {
13
+ // Reject known non-SQLite URL schemes
14
+ if (/^(postgres|postgresql|mysql|mariadb|mongodb):\/\//i.test(url))
15
+ return null;
16
+ return url.replace(/^(sqlite:|file:)/, '');
17
+ }
18
+ export async function executeSqlDirect(config) {
19
+ const dbUrl = process.env.DATABASE_URL;
20
+ if (!dbUrl) {
21
+ console.error('\n DATABASE_URL is not set.');
22
+ console.error(' Example: DATABASE_URL=./mysite.db bunx cosmolo migrate:db');
23
+ process.exit(1);
24
+ }
25
+ const dbPath = resolveDbPath(dbUrl);
26
+ if (!dbPath) {
27
+ console.error('\n Only SQLite databases are supported for direct execution.');
28
+ console.error(' Use Option 1 to export SQL files for other database engines.');
29
+ process.exit(1);
30
+ }
31
+ const root = process.cwd();
32
+ const catInserts = buildCategoriesInserts(path.join(root, config.categoriesConfigPath));
33
+ const artInserts = buildArticlesInserts(path.join(root, config.articlesDir));
34
+ console.log(`\n Target: ${dbPath}`);
35
+ console.log(` Categories: ${catInserts.length} rows`);
36
+ console.log(` Articles: ${artInserts.length} rows`);
37
+ const ok = await confirm('\n Proceed?');
38
+ if (!ok) {
39
+ console.log(' Aborted.');
40
+ return;
41
+ }
42
+ // bun:sqlite is a bun built-in; the 'bun:' scheme is not a standard ESM URL
43
+ // so we suppress the TypeScript module resolution error here.
44
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
45
+ // @ts-ignore
46
+ const { Database } = await import('bun:sqlite');
47
+ const db = new Database(dbPath);
48
+ try {
49
+ db.run('BEGIN TRANSACTION');
50
+ db.run(createTableSql('categories', CATEGORY_COLUMNS));
51
+ db.run(createTableSql('articles', ARTICLE_COLUMNS));
52
+ for (const sql of catInserts)
53
+ db.run(sql);
54
+ for (const sql of artInserts)
55
+ db.run(sql);
56
+ db.run('COMMIT');
57
+ console.log('\n ✓ Migration complete.');
58
+ }
59
+ catch (err) {
60
+ db.run('ROLLBACK');
61
+ console.error('\n Migration failed:', err);
62
+ process.exit(1);
63
+ }
64
+ finally {
65
+ db.close();
66
+ }
67
+ }
@@ -0,0 +1,4 @@
1
+ import type { ResolvedCosmoloConfig } from '../../types.js';
2
+ export declare function buildCategoriesInserts(categoriesPath: string): string[];
3
+ export declare function buildArticlesInserts(articlesDir: string): string[];
4
+ export declare function exportSqlFiles(config: ResolvedCosmoloConfig): Promise<void>;
@@ -0,0 +1,76 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import matter from 'gray-matter';
4
+ import { ARTICLE_COLUMNS, CATEGORY_COLUMNS, createTableSql, escapeSql, toSqlLiteral, } from './schema.js';
5
+ export function buildCategoriesInserts(categoriesPath) {
6
+ if (!fs.existsSync(categoriesPath))
7
+ return [];
8
+ const raw = JSON.parse(fs.readFileSync(categoriesPath, 'utf-8'));
9
+ return Object.entries(raw).map(([key, entry]) => {
10
+ const label = escapeSql(entry.label ?? '');
11
+ const description = escapeSql(entry.description ?? '');
12
+ return `INSERT INTO categories (key, label, description) VALUES ('${escapeSql(key)}', '${label}', '${description}');`;
13
+ });
14
+ }
15
+ function collectArticleFiles(dir, baseDir) {
16
+ const results = [];
17
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
18
+ const fullPath = path.join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ results.push(...collectArticleFiles(fullPath, baseDir));
21
+ }
22
+ else if (/\.(md|svx)$/.test(entry.name)) {
23
+ const rel = path.relative(baseDir, fullPath).replace(/\.(md|svx)$/, '');
24
+ // Normalize path separators to forward slashes for slug
25
+ results.push({ filePath: fullPath, slug: rel.replace(/\\/g, '/') });
26
+ }
27
+ }
28
+ return results;
29
+ }
30
+ export function buildArticlesInserts(articlesDir) {
31
+ if (!fs.existsSync(articlesDir))
32
+ return [];
33
+ return collectArticleFiles(articlesDir, articlesDir).map(({ filePath, slug }) => {
34
+ const raw = fs.readFileSync(filePath, 'utf-8');
35
+ const { data, content } = matter(raw);
36
+ const dateVal = data.date instanceof Date
37
+ ? data.date.toISOString().split('T')[0]
38
+ : (data.date ?? '');
39
+ const values = [
40
+ toSqlLiteral(slug),
41
+ toSqlLiteral(data.title ?? ''),
42
+ toSqlLiteral(data.category ?? ''),
43
+ toSqlLiteral(data.excerpt ?? ''),
44
+ toSqlLiteral(data.sort ?? 0),
45
+ toSqlLiteral(dateVal),
46
+ toSqlLiteral(data.tags ?? []),
47
+ toSqlLiteral(data.series ?? null),
48
+ toSqlLiteral(data.seriesOrder ?? null),
49
+ toSqlLiteral(data.draft ?? false),
50
+ toSqlLiteral(data.related ?? []),
51
+ toSqlLiteral(content),
52
+ ].join(', ');
53
+ const cols = 'slug, title, category, excerpt, sort, date, tags, series, series_order, draft, related, body';
54
+ return `INSERT INTO articles (${cols}) VALUES (${values});`;
55
+ });
56
+ }
57
+ export async function exportSqlFiles(config) {
58
+ const root = process.cwd();
59
+ const outputDir = path.join(root, 'cosmolo-migration');
60
+ fs.mkdirSync(outputDir, { recursive: true });
61
+ const createSql = createTableSql('categories', CATEGORY_COLUMNS) +
62
+ '\n\n' +
63
+ createTableSql('articles', ARTICLE_COLUMNS);
64
+ fs.writeFileSync(path.join(outputDir, '001_create_tables.sql'), createSql + '\n');
65
+ const categoriesPath = path.join(root, config.categoriesConfigPath);
66
+ const catInserts = buildCategoriesInserts(categoriesPath);
67
+ fs.writeFileSync(path.join(outputDir, '002_seed_categories.sql'), catInserts.join('\n') + '\n');
68
+ const articlesDir = path.join(root, config.articlesDir);
69
+ const artInserts = buildArticlesInserts(articlesDir);
70
+ fs.writeFileSync(path.join(outputDir, '003_seed_articles.sql'), artInserts.join('\n') + '\n');
71
+ console.log(`\n✓ SQL files written to cosmolo-migration/`);
72
+ console.log(' 001_create_tables.sql');
73
+ console.log(` 002_seed_categories.sql (${catInserts.length} rows)`);
74
+ console.log(` 003_seed_articles.sql (${artInserts.length} rows)`);
75
+ console.log('\nReview the files and run them against your database.');
76
+ }
@@ -0,0 +1 @@
1
+ export declare function main(): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import * as readline from 'readline';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { DEFAULT_CONFIG } from '../config.js';
5
+ async function loadConfig() {
6
+ const root = process.cwd();
7
+ for (const name of ['cosmolo.config.ts', 'cosmolo.config.js']) {
8
+ const p = path.join(root, name);
9
+ if (fs.existsSync(p)) {
10
+ try {
11
+ const mod = await import(p);
12
+ return mod.default;
13
+ }
14
+ catch {
15
+ // fall through to defaults
16
+ }
17
+ }
18
+ }
19
+ return DEFAULT_CONFIG;
20
+ }
21
+ function ask(rl, question) {
22
+ return new Promise((resolve) => rl.question(question, (ans) => resolve(ans.trim())));
23
+ }
24
+ export async function main() {
25
+ const config = await loadConfig();
26
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27
+ console.log('\ncosmolo migrate:db\n');
28
+ console.log('Select migration type:');
29
+ console.log(' 1. Export as SQL files');
30
+ console.log(' 2. Execute SQL directly');
31
+ console.log(' 3. Full setup with Drizzle ORM + Cloudflare D1');
32
+ const choice = await ask(rl, '\n> ');
33
+ rl.close();
34
+ switch (choice) {
35
+ case '1': {
36
+ const { exportSqlFiles } = await import('./migrate/sql-export.js');
37
+ await exportSqlFiles(config);
38
+ break;
39
+ }
40
+ case '2': {
41
+ const { executeSqlDirect } = await import('./migrate/sql-execute.js');
42
+ await executeSqlDirect(config);
43
+ break;
44
+ }
45
+ case '3': {
46
+ const { drizzleSetup } = await import('./migrate/drizzle-setup.js');
47
+ await drizzleSetup(config);
48
+ break;
49
+ }
50
+ default:
51
+ console.error(`\nInvalid choice: "${choice}". Enter 1, 2, or 3.`);
52
+ process.exit(1);
53
+ }
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cosmolo",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "cosmolo": "./dist/cli/index.js"