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.
- package/README.md +109 -139
- package/cli/auth.ts +101 -0
- package/cli/config.ts +149 -0
- package/cli/constants.ts +38 -0
- package/cli/content.ts +503 -0
- package/cli/dev-sync.ts +305 -0
- package/cli/exec.ts +61 -0
- package/cli/index.ts +779 -1545
- package/cli/log.ts +49 -0
- package/cli/preflight.ts +105 -0
- package/cli/scaffold.ts +680 -0
- package/cli/typegen.ts +190 -0
- package/cli/ui.ts +55 -0
- package/cli/wrangler.ts +367 -0
- package/package.json +7 -4
- package/src/admin/_bundle.ts +1 -1
- package/src/api/router.integration.test.ts +63 -80
- package/src/api/router.ts +85 -59
- package/src/config/define.ts +1 -1
- package/src/config/field.ts +10 -9
- package/src/config/index.ts +1 -13
- package/src/config/meta.ts +7 -7
- package/src/config/types.ts +1 -95
- package/src/db/init.ts +68 -0
- package/src/db/queries.ts +120 -211
- package/src/db/sql.ts +10 -25
- package/src/media/index.ts +105 -47
- package/src/react/Markdown.test.tsx +195 -0
- package/src/react/Markdown.tsx +40 -0
- package/src/react/index.ts +6 -22
- package/src/react/types.ts +3 -112
- package/src/db/migrate.ts +0 -182
- package/src/db/schema.ts +0 -122
- package/src/react/RichText.test.tsx +0 -535
- package/src/react/RichText.tsx +0 -350
- package/src/rich-text/index.ts +0 -4
- package/src/rich-text/koguma-to-lexical.ts +0 -340
- package/src/rich-text/lexical-compat.test.ts +0 -513
- package/src/rich-text/lexical-to-koguma.test.ts +0 -906
- package/src/rich-text/lexical-to-koguma.ts +0 -400
- package/src/rich-text/markdown-to-koguma.ts +0 -164
- package/src/rich-text/plain.test.ts +0 -208
- package/src/rich-text/plain.ts +0 -114
- package/src/rich-text/snapshots.test.ts +0 -284
package/cli/scaffold.ts
ADDED
|
@@ -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
|
+
}
|