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
package/cli/content.ts ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * cli/content.ts — content/ directory ↔ D1 database sync
3
+ *
4
+ * The content/ directory is the git-friendly representation of CMS entries:
5
+ *
6
+ * content/
7
+ * ├── post/
8
+ * │ ├── hello-world.md # markdown body + frontmatter
9
+ * │ └── our-mission.md
10
+ * ├── siteSettings/
11
+ * │ └── index.yml # singletons use index.yml
12
+ * └── media/ # optional local images (not managed here)
13
+ * └── hero.jpg
14
+ *
15
+ * Markdown files use YAML frontmatter for structured fields.
16
+ * The file body (below the frontmatter) is mapped to the first `markdown`
17
+ * field in the content type definition. If the content type has no markdown
18
+ * field, YAML-only files (.yml) are used.
19
+ */
20
+
21
+ import matter from 'gray-matter';
22
+ import {
23
+ existsSync,
24
+ readFileSync,
25
+ writeFileSync,
26
+ mkdirSync,
27
+ readdirSync,
28
+ statSync
29
+ } from 'fs';
30
+ import { resolve, join, extname, basename } from 'path';
31
+
32
+ // ── Types ──────────────────────────────────────────────────────────
33
+
34
+ export interface ContentEntry {
35
+ /** Content type ID (derived from parent folder name) */
36
+ contentType: string;
37
+ /** Slug derived from filename (without extension) */
38
+ slug: string;
39
+ /** Whether this is a singleton (index.yml) */
40
+ singleton: boolean;
41
+ /** All frontmatter fields */
42
+ fields: Record<string, unknown>;
43
+ /** Markdown body (if present) */
44
+ body: string | null;
45
+ /** Absolute path to the source file */
46
+ filePath: string;
47
+ }
48
+
49
+ export interface ContentTypeInfo {
50
+ id: string;
51
+ singleton?: boolean;
52
+ /** Map of fieldId → { fieldType, ... } */
53
+ fieldMeta: Record<string, { fieldType: string; required: boolean }>;
54
+ }
55
+
56
+ // ── Parse a single content file ────────────────────────────────────
57
+
58
+ /**
59
+ * Parse a .md or .yml file into a ContentEntry.
60
+ */
61
+ export function parseContentFile(
62
+ filePath: string,
63
+ contentType: string
64
+ ): ContentEntry {
65
+ const raw = readFileSync(filePath, 'utf-8');
66
+ const ext = extname(filePath).toLowerCase();
67
+ const name = basename(filePath, ext);
68
+ const singleton = name === 'index' && ext === '.yml';
69
+
70
+ if (ext === '.yml' || ext === '.yaml') {
71
+ // YAML-only file (no markdown body)
72
+ const parsed = matter(raw);
73
+
74
+ // gray-matter needs `---` delimiters. If the file is plain YAML
75
+ // (no delimiters), parsed.data will be empty and the content ends
76
+ // up in parsed.content. Detect this and re-parse as raw YAML.
77
+ let fields = parsed.data;
78
+ if (Object.keys(fields).length === 0 && parsed.content.trim().length > 0) {
79
+ // Wrap in --- delimiters so gray-matter can parse it
80
+ const wrapped = `---\n${raw.trim()}\n---\n`;
81
+ fields = matter(wrapped).data;
82
+ }
83
+
84
+ return {
85
+ contentType,
86
+ slug: singleton ? contentType : name,
87
+ singleton,
88
+ fields,
89
+ body: null,
90
+ filePath
91
+ };
92
+ }
93
+
94
+ // Markdown file with frontmatter
95
+ const parsed = matter(raw);
96
+ const body = parsed.content.trim() || null;
97
+
98
+ return {
99
+ contentType,
100
+ slug: name,
101
+ singleton: false,
102
+ fields: parsed.data,
103
+ body,
104
+ filePath
105
+ };
106
+ }
107
+
108
+ // ── Read entire content/ directory ─────────────────────────────────
109
+
110
+ /**
111
+ * Scan the content/ directory and return all parsed entries.
112
+ * Skips the `media/` subdirectory and any files/directories starting with `_`.
113
+ */
114
+ export function readContentDir(contentDir: string): ContentEntry[] {
115
+ if (!existsSync(contentDir)) return [];
116
+
117
+ const entries: ContentEntry[] = [];
118
+ const subdirs = readdirSync(contentDir);
119
+
120
+ for (const subdir of subdirs) {
121
+ // Skip media/ — it's for local image storage, not content
122
+ if (subdir === 'media') continue;
123
+ // Skip _-prefixed dirs (_orphaned/, _example dirs, dev drafts, etc.)
124
+ if (subdir.startsWith('_')) continue;
125
+
126
+ const subdirPath = resolve(contentDir, subdir);
127
+ if (!statSync(subdirPath).isDirectory()) continue;
128
+
129
+ const files = readdirSync(subdirPath);
130
+ for (const file of files) {
131
+ // Skip _-prefixed files (_example.md, dev drafts, etc.)
132
+ if (file.startsWith('_')) continue;
133
+
134
+ const ext = extname(file).toLowerCase();
135
+ if (!['.md', '.yml', '.yaml'].includes(ext)) continue;
136
+
137
+ const filePath = resolve(subdirPath, file);
138
+ if (!statSync(filePath).isFile()) continue;
139
+
140
+ entries.push(parseContentFile(filePath, subdir));
141
+ }
142
+ }
143
+
144
+ return entries;
145
+ }
146
+
147
+ // ── Convert ContentEntry → D1 row data ─────────────────────────────
148
+
149
+ /**
150
+ * Find the first markdown field ID in a content type's field metadata.
151
+ * Returns null if the content type has no markdown field.
152
+ */
153
+ export function findMarkdownField(
154
+ fieldMeta: Record<string, { fieldType: string }>
155
+ ): string | null {
156
+ for (const [id, meta] of Object.entries(fieldMeta)) {
157
+ if (meta.fieldType === 'markdown') return id;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Convert a ContentEntry into the flat data object suitable for D1 insertion.
164
+ * The markdown body (if present) is assigned to the first markdown field.
165
+ */
166
+ export function contentEntryToDbRow(
167
+ entry: ContentEntry,
168
+ ctInfo: ContentTypeInfo
169
+ ): Record<string, unknown> {
170
+ const data: Record<string, unknown> = { ...entry.fields };
171
+
172
+ // Assign markdown body to the first markdown field
173
+ if (entry.body) {
174
+ const mdField = findMarkdownField(ctInfo.fieldMeta);
175
+ if (mdField) {
176
+ data[mdField] = entry.body;
177
+ }
178
+ }
179
+
180
+ // Set slug
181
+ if (!data.slug && !entry.singleton) {
182
+ data.slug = entry.slug;
183
+ }
184
+
185
+ // Ensure an ID exists
186
+ if (!data.id) {
187
+ // Use a deterministic ID based on content type + slug for idempotent sync
188
+ data.id = `${entry.contentType}-${entry.slug}`;
189
+ }
190
+
191
+ // Map common frontmatter conventions
192
+ if ('published' in data) {
193
+ data.status = data.published ? 'published' : 'draft';
194
+ delete data.published;
195
+ }
196
+ if (!data.status) {
197
+ data.status = 'published';
198
+ }
199
+ if ('date' in data && data.date instanceof Date) {
200
+ data.date = (data.date as Date).toISOString();
201
+ }
202
+ if ('publish_at' in data && data.publish_at instanceof Date) {
203
+ data.publish_at = (data.publish_at as Date).toISOString();
204
+ }
205
+
206
+ return data;
207
+ }
208
+
209
+ // ── Convert D1 row → content file ──────────────────────────────────
210
+
211
+ /**
212
+ * Convert a D1 entry row into a markdown/yml file content string.
213
+ * System fields (id, content_type, created_at, updated_at) are stripped
214
+ * from frontmatter. The markdown field (if any) becomes the file body.
215
+ */
216
+ export function dbRowToContentFile(
217
+ row: Record<string, unknown>,
218
+ ctInfo: ContentTypeInfo
219
+ ): { content: string; extension: string } {
220
+ // Parse the data blob if it's still a JSON string
221
+ let fields: Record<string, unknown>;
222
+ if (typeof row.data === 'string') {
223
+ fields = { ...JSON.parse(row.data) };
224
+ } else {
225
+ // Already unpacked — strip system fields
226
+ const { id, content_type, created_at, updated_at, data, ...rest } = row;
227
+ fields = { ...rest };
228
+ }
229
+
230
+ // Map status back to published boolean for friendlier frontmatter
231
+ if (row.status === 'published') {
232
+ fields.published = true;
233
+ delete fields.status;
234
+ } else if (row.status === 'draft') {
235
+ fields.published = false;
236
+ delete fields.status;
237
+ }
238
+
239
+ // Strip system-only fields from frontmatter
240
+ delete fields.slug;
241
+
242
+ const mdFieldId = findMarkdownField(ctInfo.fieldMeta);
243
+
244
+ if (mdFieldId && fields[mdFieldId]) {
245
+ // Markdown file: body goes below frontmatter
246
+ const body = String(fields[mdFieldId]);
247
+ delete fields[mdFieldId];
248
+ const fm = matter.stringify('', fields).trim();
249
+ return {
250
+ content: `${fm}\n\n${body}\n`,
251
+ extension: '.md'
252
+ };
253
+ }
254
+
255
+ // YAML-only file (no markdown field or empty body)
256
+ const fm = matter.stringify('', fields).trim();
257
+ return { content: fm + '\n', extension: '.yml' };
258
+ }
259
+
260
+ // ── Write content/ directory from D1 entries ───────────────────────
261
+
262
+ /**
263
+ * Write D1 entries to the content/ directory as .md/.yml files.
264
+ * Each content type gets its own subdirectory.
265
+ *
266
+ * @param contentDir - Absolute path to the content/ directory
267
+ * @param entries - Array of D1 entry rows (with content_type, slug, data, status)
268
+ * @param contentTypes - Content type definitions for field metadata
269
+ * @returns Number of files written
270
+ */
271
+ export function writeContentDir(
272
+ contentDir: string,
273
+ entries: Record<string, unknown>[],
274
+ contentTypes: ContentTypeInfo[]
275
+ ): number {
276
+ const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
277
+ let count = 0;
278
+
279
+ for (const entry of entries) {
280
+ const typeId = entry.content_type as string;
281
+ const ctInfo = ctMap.get(typeId);
282
+ if (!ctInfo) continue;
283
+
284
+ const typeDir = resolve(contentDir, typeId);
285
+ mkdirSync(typeDir, { recursive: true });
286
+
287
+ const { content, extension } = dbRowToContentFile(entry, ctInfo);
288
+
289
+ // Determine filename
290
+ const slug = (entry.slug as string) || (entry.id as string);
291
+ const isSingleton = ctInfo.singleton;
292
+ const filename = isSingleton ? `index${extension}` : `${slug}${extension}`;
293
+
294
+ writeFileSync(resolve(typeDir, filename), content);
295
+ count++;
296
+ }
297
+
298
+ return count;
299
+ }
300
+
301
+ // ── Sync helpers ───────────────────────────────────────────────────
302
+
303
+ /**
304
+ * Read content/ files and produce D1 row data for each.
305
+ * Returns data ready for INSERT OR REPLACE into the entries table.
306
+ *
307
+ * @param contentDir - Absolute path to the content/ directory
308
+ * @param contentTypes - Content type definitions for field metadata
309
+ * @returns Array of { contentType, rowData } ready for SQL generation
310
+ */
311
+ export function prepareContentForSync(
312
+ contentDir: string,
313
+ contentTypes: ContentTypeInfo[]
314
+ ): { contentType: string; rowData: Record<string, unknown> }[] {
315
+ const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
316
+ const entries = readContentDir(contentDir);
317
+ const results: { contentType: string; rowData: Record<string, unknown> }[] =
318
+ [];
319
+
320
+ for (const entry of entries) {
321
+ const ctInfo = ctMap.get(entry.contentType);
322
+ if (!ctInfo) continue;
323
+ results.push({
324
+ contentType: entry.contentType,
325
+ rowData: contentEntryToDbRow(entry, ctInfo)
326
+ });
327
+ }
328
+
329
+ return results;
330
+ }
331
+
332
+ // ── Content validation ─────────────────────────────────────────────
333
+
334
+ export interface ValidationWarning {
335
+ level: 'warn';
336
+ file: string;
337
+ message: string;
338
+ }
339
+
340
+ /**
341
+ * Levenshtein distance between two strings.
342
+ * Used for "did you mean?" suggestions.
343
+ */
344
+ export function levenshtein(a: string, b: string): number {
345
+ const m = a.length;
346
+ const n = b.length;
347
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
348
+ Array(n + 1).fill(0)
349
+ );
350
+ for (let i = 0; i <= m; i++) dp[i]![0] = i;
351
+ for (let j = 0; j <= n; j++) dp[0]![j] = j;
352
+ for (let i = 1; i <= m; i++) {
353
+ for (let j = 1; j <= n; j++) {
354
+ dp[i]![j] =
355
+ a[i - 1] === b[j - 1]
356
+ ? dp[i - 1]![j - 1]!
357
+ : 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
358
+ }
359
+ }
360
+ return dp[m]![n]!;
361
+ }
362
+
363
+ /**
364
+ * Find the closest match for a string from a list of candidates.
365
+ * Returns null if no candidate is within maxDistance.
366
+ */
367
+ export function closestMatch(
368
+ input: string,
369
+ candidates: string[],
370
+ maxDistance = 3
371
+ ): string | null {
372
+ let best: string | null = null;
373
+ let bestDist = maxDistance + 1;
374
+ for (const c of candidates) {
375
+ const d = levenshtein(input.toLowerCase(), c.toLowerCase());
376
+ if (d < bestDist) {
377
+ bestDist = d;
378
+ best = c;
379
+ }
380
+ }
381
+ return best;
382
+ }
383
+
384
+ /**
385
+ * Validate the content/ directory against content type definitions.
386
+ * Returns warnings for:
387
+ * 1. Unknown directories (typos like content/posts/ when type is "post")
388
+ * 2. Unknown frontmatter keys not defined in the schema
389
+ * 3. Missing required fields
390
+ *
391
+ * Never errors — only warns. Skips media/ and _-prefixed files/dirs.
392
+ */
393
+ export function validateContent(
394
+ contentDir: string,
395
+ contentTypes: ContentTypeInfo[]
396
+ ): ValidationWarning[] {
397
+ if (!existsSync(contentDir)) return [];
398
+
399
+ const warnings: ValidationWarning[] = [];
400
+ const ctIds = contentTypes.map(ct => ct.id);
401
+ const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
402
+
403
+ const subdirs = readdirSync(contentDir);
404
+
405
+ for (const subdir of subdirs) {
406
+ // Skip reserved/internal dirs
407
+ if (subdir === 'media') continue;
408
+ if (subdir.startsWith('_')) continue;
409
+
410
+ const subdirPath = resolve(contentDir, subdir);
411
+ if (!statSync(subdirPath).isDirectory()) continue;
412
+
413
+ // Check 1: Unknown directory
414
+ if (!ctMap.has(subdir)) {
415
+ const suggestion = closestMatch(subdir, ctIds);
416
+ const hint = suggestion ? ` Did you mean "${suggestion}"?` : '';
417
+ warnings.push({
418
+ level: 'warn',
419
+ file: `content/${subdir}/`,
420
+ message: `does not match any content type.${hint}`
421
+ });
422
+ continue;
423
+ }
424
+
425
+ // Warn if content type ID is 'media' (reserved)
426
+ // (This would only fire if someone had media in both config and as dir)
427
+
428
+ const ctInfo = ctMap.get(subdir)!;
429
+ const fieldIds = Object.keys(ctInfo.fieldMeta);
430
+ // Include conventional keys that are valid but not in fieldMeta
431
+ const knownKeys = new Set([
432
+ ...fieldIds,
433
+ 'id',
434
+ 'slug',
435
+ 'status',
436
+ 'published',
437
+ 'publish_at',
438
+ 'created_at',
439
+ 'updated_at'
440
+ ]);
441
+
442
+ const files = readdirSync(subdirPath);
443
+ for (const file of files) {
444
+ if (file.startsWith('_')) continue;
445
+ const ext = extname(file).toLowerCase();
446
+ if (!['.md', '.yml', '.yaml'].includes(ext)) continue;
447
+
448
+ const filePath = resolve(subdirPath, file);
449
+ if (!statSync(filePath).isFile()) continue;
450
+
451
+ const entry = parseContentFile(filePath, subdir);
452
+ const relPath = `${subdir}/${file}`;
453
+
454
+ // Check 2: Unknown frontmatter keys
455
+ for (const key of Object.keys(entry.fields)) {
456
+ if (!knownKeys.has(key)) {
457
+ warnings.push({
458
+ level: 'warn',
459
+ file: relPath,
460
+ message: `unknown field "${key}" — not defined in site.config.ts`
461
+ });
462
+ }
463
+ }
464
+
465
+ // Check 3: Missing required fields
466
+ for (const [fieldId, meta] of Object.entries(ctInfo.fieldMeta)) {
467
+ if (!meta.required) continue;
468
+ // Markdown fields get their value from the body
469
+ if (meta.fieldType === 'markdown') {
470
+ if (!entry.body) {
471
+ warnings.push({
472
+ level: 'warn',
473
+ file: relPath,
474
+ message: `missing required field "${fieldId}" (markdown body is empty)`
475
+ });
476
+ }
477
+ continue;
478
+ }
479
+ const val = entry.fields[fieldId];
480
+ if (val === undefined || val === null || val === '') {
481
+ warnings.push({
482
+ level: 'warn',
483
+ file: relPath,
484
+ message: `missing required field "${fieldId}"`
485
+ });
486
+ }
487
+ }
488
+ }
489
+ }
490
+
491
+ // Check for reserved name conflict
492
+ for (const ct of contentTypes) {
493
+ if (ct.id === 'media') {
494
+ warnings.push({
495
+ level: 'warn',
496
+ file: 'site.config.ts',
497
+ message: `content type "media" conflicts with reserved directory content/media/`
498
+ });
499
+ }
500
+ }
501
+
502
+ return warnings;
503
+ }