koguma 0.6.6 → 2.0.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.
Files changed (44) hide show
  1. package/README.md +109 -139
  2. package/cli/auth.ts +101 -0
  3. package/cli/config.ts +149 -0
  4. package/cli/constants.ts +38 -0
  5. package/cli/content.ts +503 -0
  6. package/cli/dev-sync.ts +305 -0
  7. package/cli/exec.ts +61 -0
  8. package/cli/index.ts +779 -1545
  9. package/cli/log.ts +49 -0
  10. package/cli/preflight.ts +105 -0
  11. package/cli/scaffold.ts +680 -0
  12. package/cli/typegen.ts +190 -0
  13. package/cli/ui.ts +55 -0
  14. package/cli/wrangler.ts +367 -0
  15. package/package.json +7 -4
  16. package/src/admin/_bundle.ts +1 -1
  17. package/src/api/router.integration.test.ts +63 -80
  18. package/src/api/router.ts +85 -59
  19. package/src/config/define.ts +1 -1
  20. package/src/config/field.ts +10 -9
  21. package/src/config/index.ts +1 -13
  22. package/src/config/meta.ts +7 -7
  23. package/src/config/types.ts +1 -95
  24. package/src/db/init.ts +68 -0
  25. package/src/db/queries.ts +120 -211
  26. package/src/db/sql.ts +10 -25
  27. package/src/media/index.ts +105 -47
  28. package/src/react/Markdown.test.tsx +195 -0
  29. package/src/react/Markdown.tsx +40 -0
  30. package/src/react/index.ts +6 -22
  31. package/src/react/types.ts +3 -112
  32. package/src/db/migrate.ts +0 -182
  33. package/src/db/schema.ts +0 -122
  34. package/src/react/RichText.test.tsx +0 -535
  35. package/src/react/RichText.tsx +0 -350
  36. package/src/rich-text/index.ts +0 -4
  37. package/src/rich-text/koguma-to-lexical.ts +0 -340
  38. package/src/rich-text/lexical-compat.test.ts +0 -513
  39. package/src/rich-text/lexical-to-koguma.test.ts +0 -906
  40. package/src/rich-text/lexical-to-koguma.ts +0 -400
  41. package/src/rich-text/markdown-to-koguma.ts +0 -164
  42. package/src/rich-text/plain.test.ts +0 -208
  43. package/src/rich-text/plain.ts +0 -114
  44. package/src/rich-text/snapshots.test.ts +0 -284
@@ -0,0 +1,680 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ writeFileSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync
10
+ } from 'fs';
11
+ import { resolve } from 'path';
12
+ import { ok, warn } from './log.ts';
13
+ import { generateKogumaToml } from './config.ts';
14
+ import { findMarkdownField, type ContentTypeInfo } from './content.ts';
15
+ import matter from 'gray-matter';
16
+
17
+ // ── Template types ─────────────────────────────────────────────────
18
+
19
+ export interface Template {
20
+ name: string;
21
+ description: string;
22
+ contentTypes: {
23
+ id: string;
24
+ name: string;
25
+ displayField: string;
26
+ singleton?: boolean;
27
+ fields: Record<string, string>; // fieldId → builder expression
28
+ }[];
29
+ }
30
+
31
+ // ── Template registry ──────────────────────────────────────────────
32
+
33
+ export const TEMPLATES: Template[] = [
34
+ {
35
+ name: 'Blog',
36
+ description: 'Blog with posts and site settings',
37
+ contentTypes: [
38
+ {
39
+ id: 'siteSettings',
40
+ name: 'Site Settings',
41
+ displayField: 'siteName',
42
+ singleton: true,
43
+ fields: {
44
+ siteName: 'field.text("Site Name").required()',
45
+ tagline: 'field.text("Tagline")',
46
+ footerText: 'field.text("Footer Text")'
47
+ }
48
+ },
49
+ {
50
+ id: 'post',
51
+ name: 'Posts',
52
+ displayField: 'title',
53
+ fields: {
54
+ title: 'field.text("Title").required()',
55
+ slug: 'field.text("Slug").required().max(120)',
56
+ excerpt: 'field.longText("Excerpt")',
57
+ body: 'field.markdown("Body")',
58
+ coverImage: 'field.image("Cover Image")',
59
+ published: 'field.boolean("Published").default(false)',
60
+ date: 'field.date("Date")'
61
+ }
62
+ }
63
+ ]
64
+ },
65
+ {
66
+ name: 'Portfolio',
67
+ description: 'Portfolio with projects and about page',
68
+ contentTypes: [
69
+ {
70
+ id: 'about',
71
+ name: 'About',
72
+ displayField: 'name',
73
+ singleton: true,
74
+ fields: {
75
+ name: 'field.text("Name").required()',
76
+ bio: 'field.markdown("Bio")',
77
+ avatar: 'field.image("Avatar")',
78
+ email: 'field.text("Email")'
79
+ }
80
+ },
81
+ {
82
+ id: 'project',
83
+ name: 'Projects',
84
+ displayField: 'title',
85
+ fields: {
86
+ title: 'field.text("Title").required()',
87
+ description: 'field.longText("Description")',
88
+ heroImage: 'field.image("Hero Image")',
89
+ url: 'field.url("Live URL")',
90
+ year: 'field.number("Year")'
91
+ }
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ name: 'Docs',
97
+ description: 'Documentation site with pages and categories',
98
+ contentTypes: [
99
+ {
100
+ id: 'page',
101
+ name: 'Pages',
102
+ displayField: 'title',
103
+ fields: {
104
+ title: 'field.text("Title").required()',
105
+ slug: 'field.text("Slug").required()',
106
+ body: 'field.markdown("Body")',
107
+ sortOrder: 'field.number("Sort Order").default(0)'
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ {
113
+ name: 'Blank',
114
+ description: 'Empty project — start from scratch',
115
+ contentTypes: [
116
+ {
117
+ id: 'page',
118
+ name: 'Pages',
119
+ displayField: 'title',
120
+ fields: {
121
+ title: 'field.text("Title").required()',
122
+ body: 'field.markdown("Body")'
123
+ }
124
+ }
125
+ ]
126
+ }
127
+ ];
128
+
129
+ // ── File generators ────────────────────────────────────────────────
130
+
131
+ export function generateSiteConfig(
132
+ template: Template,
133
+ siteName: string
134
+ ): string {
135
+ const lines: string[] = [
136
+ `import { defineConfig, contentType, field } from "koguma";`,
137
+ ``
138
+ ];
139
+
140
+ for (const ct of template.contentTypes) {
141
+ lines.push(`const ${ct.id} = contentType({`);
142
+ lines.push(` id: "${ct.id}",`);
143
+ lines.push(` name: "${ct.name}",`);
144
+ lines.push(` displayField: "${ct.displayField}",`);
145
+ if (ct.singleton) lines.push(` singleton: true,`);
146
+ lines.push(` fields: {`);
147
+ for (const [fid, expr] of Object.entries(ct.fields)) {
148
+ lines.push(` ${fid}: ${expr},`);
149
+ }
150
+ lines.push(` },`);
151
+ lines.push(`});`);
152
+ lines.push(``);
153
+ }
154
+
155
+ const ctIds = template.contentTypes.map(ct => ct.id).join(', ');
156
+ lines.push(`export default defineConfig({`);
157
+ lines.push(` siteName: "${siteName}",`);
158
+ lines.push(` contentTypes: [${ctIds}],`);
159
+ lines.push(`});`);
160
+ lines.push(``);
161
+
162
+ return lines.join('\n');
163
+ }
164
+
165
+ export function generateWorkerTs(): string {
166
+ return `import { createWorker } from "koguma/worker";
167
+ import config from "./site.config";
168
+ export default createWorker(config);
169
+ `;
170
+ }
171
+
172
+ export function generatePackageJson(projectName: string): string {
173
+ return (
174
+ JSON.stringify(
175
+ {
176
+ name: projectName,
177
+ private: true,
178
+ scripts: {
179
+ dev: 'koguma dev',
180
+ deploy: 'koguma push --remote https://YOUR-SITE.workers.dev'
181
+ },
182
+ dependencies: {
183
+ koguma: 'latest'
184
+ },
185
+ devDependencies: {
186
+ wrangler: 'latest'
187
+ }
188
+ },
189
+ null,
190
+ 2
191
+ ) + '\n'
192
+ );
193
+ }
194
+
195
+ export function generateTsconfig(): string {
196
+ return (
197
+ JSON.stringify(
198
+ {
199
+ compilerOptions: {
200
+ target: 'ESNext',
201
+ module: 'ESNext',
202
+ moduleResolution: 'Bundler',
203
+ strict: true,
204
+ esModuleInterop: true,
205
+ skipLibCheck: true,
206
+ types: ['@cloudflare/workers-types']
207
+ },
208
+ include: ['*.ts']
209
+ },
210
+ null,
211
+ 2
212
+ ) + '\n'
213
+ );
214
+ }
215
+
216
+ export function generateGitignore(): string {
217
+ return `node_modules/
218
+ .koguma/
219
+ .dev.vars
220
+ koguma.d.ts
221
+ `;
222
+ }
223
+
224
+ // ── Scaffold orchestrator ──────────────────────────────────────────
225
+
226
+ /**
227
+ * Scaffold a new Koguma project in the current directory.
228
+ *
229
+ * @param projectName — Name for the project (used in package.json, koguma.toml)
230
+ * @param template — The selected Template to scaffold from
231
+ * @returns The project root path and list of created file names
232
+ */
233
+ export function scaffoldNewProject(
234
+ projectName: string,
235
+ template: Template
236
+ ): { root: string; createdFiles: string[]; skippedFiles: string[] } {
237
+ const root = process.cwd();
238
+ const createdFiles: string[] = [];
239
+ const skippedFiles: string[] = [];
240
+
241
+ const files: [string, string][] = [
242
+ ['site.config.ts', generateSiteConfig(template, projectName)],
243
+ ['koguma.toml', generateKogumaToml(projectName)],
244
+ ['package.json', generatePackageJson(projectName)],
245
+ ['tsconfig.json', generateTsconfig()],
246
+ ['.gitignore', generateGitignore()]
247
+ ];
248
+
249
+ for (const [name, content] of files) {
250
+ const path = resolve(root, name);
251
+ if (existsSync(path)) {
252
+ skippedFiles.push(name);
253
+ } else {
254
+ writeFileSync(path, content);
255
+ createdFiles.push(name);
256
+ }
257
+ }
258
+
259
+ // Scaffold content/ directories from template
260
+ scaffoldContentDirFromTemplate(root, template);
261
+
262
+ return { root, createdFiles, skippedFiles };
263
+ }
264
+
265
+ // ── Content directory scaffolding ──────────────────────────────────
266
+
267
+ /**
268
+ * Map a field type string to a sensible placeholder value for _example files.
269
+ */
270
+ export function placeholderForFieldType(fieldType: string): unknown {
271
+ switch (fieldType) {
272
+ case 'text':
273
+ case 'longText':
274
+ case 'url':
275
+ case 'email':
276
+ case 'phone':
277
+ case 'color':
278
+ case 'instagram':
279
+ case 'youtube':
280
+ case 'date':
281
+ case 'select':
282
+ case 'image':
283
+ return '';
284
+ case 'images':
285
+ case 'refs':
286
+ return [];
287
+ case 'ref':
288
+ return '';
289
+ case 'number':
290
+ return 0;
291
+ case 'boolean':
292
+ return false;
293
+ case 'markdown':
294
+ return null; // handled as body, not frontmatter
295
+ default:
296
+ return '';
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Extract the field type from a template builder expression like:
302
+ * 'field.text("Title").required()' → 'text'
303
+ * 'field.markdown("Body")' → 'markdown'
304
+ */
305
+ export function fieldTypeFromExpression(expr: string): string {
306
+ const match = expr.match(/^field\.(\w+)\(/);
307
+ return match ? match[1]! : 'text';
308
+ }
309
+
310
+ /**
311
+ * Generate the content of an _example file for a content type.
312
+ * Returns { content, extension }.
313
+ */
314
+ export function generateExampleFile(
315
+ ctId: string,
316
+ fields: Record<string, { fieldType: string }>,
317
+ singleton?: boolean
318
+ ): { content: string; extension: string } {
319
+ const frontmatter: Record<string, unknown> = {};
320
+ let hasMarkdown = false;
321
+
322
+ for (const [fieldId, meta] of Object.entries(fields)) {
323
+ if (meta.fieldType === 'markdown') {
324
+ hasMarkdown = true;
325
+ continue; // markdown goes in body, not frontmatter
326
+ }
327
+ frontmatter[fieldId] = placeholderForFieldType(meta.fieldType);
328
+ }
329
+
330
+ if (hasMarkdown) {
331
+ const fm = matter.stringify('', frontmatter).trim();
332
+ const bodyHint = singleton ? '' : `\nWrite your ${ctId} content here.\n`;
333
+ return {
334
+ content: `${fm}\n${bodyHint}`,
335
+ extension: '.md'
336
+ };
337
+ }
338
+
339
+ const fm = matter.stringify('', frontmatter).trim();
340
+ return { content: fm + '\n', extension: '.yml' };
341
+ }
342
+
343
+ /**
344
+ * Scaffold content/ directories from a Template (used by `koguma init` for new projects).
345
+ */
346
+ export function scaffoldContentDirFromTemplate(
347
+ root: string,
348
+ template: Template
349
+ ): void {
350
+ const contentDir = resolve(root, 'content');
351
+
352
+ for (const ct of template.contentTypes) {
353
+ const typeDir = resolve(contentDir, ct.id);
354
+ const dirExisted = existsSync(typeDir);
355
+
356
+ if (!dirExisted) {
357
+ mkdirSync(typeDir, { recursive: true });
358
+ }
359
+
360
+ // Create _example file if dir is new or empty
361
+ const isEmpty = dirExisted ? readdirSync(typeDir).length === 0 : true;
362
+
363
+ if (isEmpty) {
364
+ // Convert template fields to { fieldType } format
365
+ const fields: Record<string, { fieldType: string }> = {};
366
+ for (const [fid, expr] of Object.entries(ct.fields)) {
367
+ fields[fid] = { fieldType: fieldTypeFromExpression(expr) };
368
+ }
369
+
370
+ const { content, extension } = generateExampleFile(
371
+ ct.id,
372
+ fields,
373
+ ct.singleton
374
+ );
375
+ const filename = `_example${extension}`;
376
+ writeFileSync(resolve(typeDir, filename), content);
377
+ ok(`Created content/${ct.id}/${filename}`);
378
+ } else if (!dirExisted) {
379
+ ok(`Created content/${ct.id}/`);
380
+ }
381
+ }
382
+
383
+ // Ensure content/media/ exists
384
+ const mediaDir = resolve(contentDir, 'media');
385
+ if (!existsSync(mediaDir)) {
386
+ mkdirSync(mediaDir, { recursive: true });
387
+ ok('Created content/media/');
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Scaffold content/ directories from ContentTypeInfo[] (used when site.config.ts exists).
393
+ */
394
+ export function scaffoldContentDir(
395
+ root: string,
396
+ contentTypes: ContentTypeInfo[]
397
+ ): void {
398
+ const contentDir = resolve(root, 'content');
399
+
400
+ for (const ct of contentTypes) {
401
+ const typeDir = resolve(contentDir, ct.id);
402
+ const dirExisted = existsSync(typeDir);
403
+
404
+ if (!dirExisted) {
405
+ mkdirSync(typeDir, { recursive: true });
406
+ }
407
+
408
+ const isEmpty = dirExisted ? readdirSync(typeDir).length === 0 : true;
409
+
410
+ if (isEmpty) {
411
+ const { content, extension } = generateExampleFile(
412
+ ct.id,
413
+ ct.fieldMeta,
414
+ ct.singleton
415
+ );
416
+ const filename = `_example${extension}`;
417
+ writeFileSync(resolve(typeDir, filename), content);
418
+ ok(`Created content/${ct.id}/${filename}`);
419
+ } else if (!dirExisted) {
420
+ ok(`Created content/${ct.id}/`);
421
+ }
422
+ }
423
+
424
+ // Ensure content/media/ exists
425
+ const mediaDir = resolve(contentDir, 'media');
426
+ if (!existsSync(mediaDir)) {
427
+ mkdirSync(mediaDir, { recursive: true });
428
+ ok('Created content/media/');
429
+ }
430
+ }
431
+
432
+ // ── Config-drift sync ──────────────────────────────────────────────
433
+
434
+ export interface DriftAction {
435
+ type:
436
+ | 'create_dir'
437
+ | 'create_example'
438
+ | 'update_example'
439
+ | 'delete_dir'
440
+ | 'orphan_dir'
441
+ | 'restore_dir';
442
+ path: string;
443
+ detail?: string;
444
+ }
445
+
446
+ /**
447
+ * Check if a directory contains only _example files (and nothing else).
448
+ */
449
+ function dirHasOnlyExamples(dirPath: string): boolean {
450
+ const files = readdirSync(dirPath);
451
+ if (files.length === 0) return false; // empty != only examples
452
+ return files.every(f => f.startsWith('_'));
453
+ }
454
+
455
+ /**
456
+ * Sync content/ directories with the current config.
457
+ *
458
+ * - Creates missing dirs + _example files
459
+ * - Regenerates _example files when fields change
460
+ * - Orphans dirs for removed content types (moves to _orphaned/)
461
+ * - Restores dirs from _orphaned/ when content type is re-added
462
+ *
463
+ * When dryRun=true, returns actions without executing them.
464
+ */
465
+ export function syncContentDirsWithConfig(
466
+ root: string,
467
+ contentTypes: ContentTypeInfo[],
468
+ dryRun = false
469
+ ): DriftAction[] {
470
+ const contentDir = resolve(root, 'content');
471
+ const orphanedDir = resolve(contentDir, '_orphaned');
472
+ const actions: DriftAction[] = [];
473
+ const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
474
+ const ctIds = new Set(contentTypes.map(ct => ct.id));
475
+
476
+ // Ensure content/ exists
477
+ if (!existsSync(contentDir)) {
478
+ if (!dryRun) mkdirSync(contentDir, { recursive: true });
479
+ }
480
+
481
+ // ── Step 1: Restore from _orphaned/ if matching config type exists ──
482
+
483
+ if (existsSync(orphanedDir)) {
484
+ for (const orphan of readdirSync(orphanedDir)) {
485
+ if (!ctIds.has(orphan)) continue;
486
+
487
+ const orphanPath = resolve(orphanedDir, orphan);
488
+ const targetPath = resolve(contentDir, orphan);
489
+
490
+ if (existsSync(targetPath)) {
491
+ // Both exist — warn for manual merge
492
+ actions.push({
493
+ type: 'restore_dir',
494
+ path: `content/${orphan}/`,
495
+ detail: `Both content/${orphan}/ and _orphaned/${orphan}/ exist — manual merge needed`
496
+ });
497
+ if (!dryRun) {
498
+ warn(
499
+ `Both content/${orphan}/ and _orphaned/${orphan}/ exist — manual merge needed`
500
+ );
501
+ }
502
+ } else {
503
+ actions.push({
504
+ type: 'restore_dir',
505
+ path: `content/${orphan}/`,
506
+ detail: `Restored from _orphaned/${orphan}/`
507
+ });
508
+ if (!dryRun) {
509
+ renameSync(orphanPath, targetPath);
510
+ ok(`Restored content/${orphan}/ from _orphaned/`);
511
+ }
512
+ }
513
+ }
514
+
515
+ // Clean up _orphaned/ if it's now empty
516
+ if (
517
+ !dryRun &&
518
+ existsSync(orphanedDir) &&
519
+ readdirSync(orphanedDir).length === 0
520
+ ) {
521
+ rmSync(orphanedDir, { recursive: true });
522
+ }
523
+ }
524
+
525
+ // ── Step 2: Create/update dirs for current config types ──
526
+
527
+ for (const ct of contentTypes) {
528
+ const typeDir = resolve(contentDir, ct.id);
529
+ const dirExists = existsSync(typeDir);
530
+
531
+ if (!dirExists) {
532
+ // New content type, no dir — create + _example
533
+ actions.push({
534
+ type: 'create_dir',
535
+ path: `content/${ct.id}/`
536
+ });
537
+ if (!dryRun) {
538
+ mkdirSync(typeDir, { recursive: true });
539
+ }
540
+
541
+ const { content, extension } = generateExampleFile(
542
+ ct.id,
543
+ ct.fieldMeta,
544
+ ct.singleton
545
+ );
546
+ const filename = `_example${extension}`;
547
+ actions.push({
548
+ type: 'create_example',
549
+ path: `content/${ct.id}/${filename}`
550
+ });
551
+ if (!dryRun) {
552
+ writeFileSync(resolve(typeDir, filename), content);
553
+ ok(`Created content/${ct.id}/${filename}`);
554
+ }
555
+ continue;
556
+ }
557
+
558
+ const files = readdirSync(typeDir);
559
+
560
+ // If dir is empty, create _example
561
+ if (files.length === 0) {
562
+ const { content, extension } = generateExampleFile(
563
+ ct.id,
564
+ ct.fieldMeta,
565
+ ct.singleton
566
+ );
567
+ const filename = `_example${extension}`;
568
+ actions.push({
569
+ type: 'create_example',
570
+ path: `content/${ct.id}/${filename}`
571
+ });
572
+ if (!dryRun) {
573
+ writeFileSync(resolve(typeDir, filename), content);
574
+ ok(`Created content/${ct.id}/${filename}`);
575
+ }
576
+ continue;
577
+ }
578
+
579
+ // Check if _example file exists and needs updating
580
+ const exampleFile = files.find(
581
+ f => f.startsWith('_example') && /\.(md|yml|yaml)$/.test(f)
582
+ );
583
+ if (exampleFile) {
584
+ // Regenerate and compare
585
+ const { content: newContent, extension: newExt } = generateExampleFile(
586
+ ct.id,
587
+ ct.fieldMeta,
588
+ ct.singleton
589
+ );
590
+ const newFilename = `_example${newExt}`;
591
+ const existingPath = resolve(typeDir, exampleFile);
592
+ const existingContent = readFileSync(existingPath, 'utf-8');
593
+
594
+ // Check if file type changed (singleton ↔ collection) or content changed
595
+ if (exampleFile !== newFilename || existingContent !== newContent) {
596
+ actions.push({
597
+ type: 'update_example',
598
+ path: `content/${ct.id}/${newFilename}`
599
+ });
600
+ if (!dryRun) {
601
+ // Remove old if filename changed
602
+ if (exampleFile !== newFilename) {
603
+ rmSync(existingPath);
604
+ }
605
+ writeFileSync(resolve(typeDir, newFilename), newContent);
606
+ ok(`Updated content/${ct.id}/${newFilename}`);
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ // ── Step 3: Orphan/delete dirs not in config ──
613
+
614
+ if (existsSync(contentDir)) {
615
+ for (const subdir of readdirSync(contentDir)) {
616
+ if (subdir === 'media') continue;
617
+ if (subdir.startsWith('_')) continue;
618
+
619
+ const subdirPath = resolve(contentDir, subdir);
620
+ if (!statSync(subdirPath).isDirectory()) continue;
621
+
622
+ if (!ctIds.has(subdir)) {
623
+ const files = readdirSync(subdirPath);
624
+
625
+ if (files.length === 0) {
626
+ // Empty dir — just delete
627
+ actions.push({
628
+ type: 'delete_dir',
629
+ path: `content/${subdir}/`,
630
+ detail: 'empty directory'
631
+ });
632
+ if (!dryRun) {
633
+ rmSync(subdirPath, { recursive: true });
634
+ ok(`Deleted empty content/${subdir}/`);
635
+ }
636
+ } else if (dirHasOnlyExamples(subdirPath)) {
637
+ // Only _example files — delete (nothing of value)
638
+ actions.push({
639
+ type: 'delete_dir',
640
+ path: `content/${subdir}/`,
641
+ detail: 'contained only _example files'
642
+ });
643
+ if (!dryRun) {
644
+ rmSync(subdirPath, { recursive: true });
645
+ ok(`Deleted content/${subdir}/ (only contained _example files)`);
646
+ }
647
+ } else {
648
+ // Has real files — move to _orphaned/
649
+ const realFileCount = files.filter(f => !f.startsWith('_')).length;
650
+ actions.push({
651
+ type: 'orphan_dir',
652
+ path: `content/${subdir}/`,
653
+ detail: `${realFileCount} content file${realFileCount === 1 ? '' : 's'}`
654
+ });
655
+ if (!dryRun) {
656
+ if (!existsSync(orphanedDir)) {
657
+ mkdirSync(orphanedDir, { recursive: true });
658
+ }
659
+ renameSync(subdirPath, resolve(orphanedDir, subdir));
660
+ warn(
661
+ `Moved content/${subdir}/ → _orphaned/${subdir}/ (${realFileCount} file${realFileCount === 1 ? '' : 's'} — no matching content type)`
662
+ );
663
+ }
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ // Ensure content/media/ exists
670
+ const mediaDir = resolve(contentDir, 'media');
671
+ if (!existsSync(mediaDir)) {
672
+ if (!dryRun) {
673
+ mkdirSync(mediaDir, { recursive: true });
674
+ ok('Created content/media/');
675
+ }
676
+ actions.push({ type: 'create_dir', path: 'content/media/' });
677
+ }
678
+
679
+ return actions;
680
+ }