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/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
|
+
}
|