@bobbykim/manguito-cms-core 0.1.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/dist/index.mjs ADDED
@@ -0,0 +1,1119 @@
1
+ // src/config/defineConfig.ts
2
+ function defineConfig(config) {
3
+ return {
4
+ name: config.name ?? "Manguito CMS",
5
+ schema: resolveSchemaConfig(config.schema),
6
+ db: config.db,
7
+ migrations: resolveMigrationsConfig(config.migrations, config.db),
8
+ storage: config.storage,
9
+ server: config.server,
10
+ api: config.api,
11
+ admin: config.admin
12
+ };
13
+ }
14
+ function resolveSchemaConfig(config) {
15
+ return {
16
+ base_path: config?.base_path ?? "./schemas",
17
+ folders: {
18
+ content_types: config?.folders?.content_types ?? "content-types",
19
+ paragraph_types: config?.folders?.paragraph_types ?? "paragraph-types",
20
+ taxonomy_types: config?.folders?.taxonomy_types ?? "taxonomy-types",
21
+ enum_types: config?.folders?.enum_types ?? "enum-types"
22
+ }
23
+ };
24
+ }
25
+ function resolveMigrationsConfig(config, db) {
26
+ if (db?.type === "mongodb") return null;
27
+ return {
28
+ table: config?.table ?? "__manguito_migrations",
29
+ folder: config?.folder ?? "./migrations"
30
+ };
31
+ }
32
+
33
+ // src/parser/validators.ts
34
+ import { z } from "zod";
35
+ var snakeCaseName = z.string().regex(/^[a-z][a-z0-9_]*$/, 'Must be snake_case (e.g. "blog_post")');
36
+ var maxSizeString = z.string().regex(
37
+ /^\d+(\.\d+)?\s*(B|KB|MB|GB)$/i,
38
+ 'Must be a file size string like "512KB" or "2MB"'
39
+ );
40
+ var contentMachineName = z.string().regex(
41
+ /^content--[a-z][a-z0-9_]*$/,
42
+ 'Must match "content--<snake_case_name>" (e.g. "content--blog_post")'
43
+ );
44
+ var paragraphMachineName = z.string().regex(
45
+ /^paragraph--[a-z][a-z0-9_]*$/,
46
+ 'Must match "paragraph--<snake_case_name>" (e.g. "paragraph--photo_card")'
47
+ );
48
+ var taxonomyMachineName = z.string().regex(
49
+ /^taxonomy--[a-z][a-z0-9_]*$/,
50
+ 'Must match "taxonomy--<snake_case_name>" (e.g. "taxonomy--daily_post")'
51
+ );
52
+ var enumMachineName = z.string().regex(
53
+ /^enum--[a-z][a-z0-9_]*$/,
54
+ 'Must match "enum--<snake_case_name>" (e.g. "enum--link_target")'
55
+ );
56
+ var refTargetMachineName = z.string().regex(
57
+ /^(content|taxonomy)--[a-z][a-z0-9_]*$/,
58
+ "Target must be a content-type or taxonomy-type machine name"
59
+ );
60
+ var RawFieldBase = z.object({
61
+ name: snakeCaseName,
62
+ label: z.string().min(1),
63
+ required: z.boolean()
64
+ });
65
+ var RawTextPlainFieldSchema = RawFieldBase.extend({
66
+ type: z.literal("text/plain"),
67
+ limit: z.number().int().positive().optional(),
68
+ pattern: z.string().optional()
69
+ });
70
+ var RawTextRichFieldSchema = RawFieldBase.extend({
71
+ type: z.literal("text/rich")
72
+ });
73
+ var RawIntegerFieldSchema = RawFieldBase.extend({
74
+ type: z.literal("integer"),
75
+ // min/max are value bounds — can be any integer, including negative.
76
+ min: z.number().int().optional(),
77
+ max: z.number().int().optional()
78
+ });
79
+ var RawFloatFieldSchema = RawFieldBase.extend({
80
+ type: z.literal("float"),
81
+ min: z.number().optional(),
82
+ max: z.number().optional()
83
+ });
84
+ var RawBooleanFieldSchema = RawFieldBase.extend({
85
+ type: z.literal("boolean")
86
+ });
87
+ var RawDateFieldSchema = RawFieldBase.extend({
88
+ type: z.literal("date")
89
+ });
90
+ var RawImageFieldSchema = RawFieldBase.extend({
91
+ type: z.literal("image"),
92
+ max_size: maxSizeString.optional(),
93
+ alt: z.boolean().optional()
94
+ });
95
+ var RawVideoFieldSchema = RawFieldBase.extend({
96
+ type: z.literal("video"),
97
+ max_size: maxSizeString.optional(),
98
+ alt: z.boolean().optional()
99
+ });
100
+ var RawFileFieldSchema = RawFieldBase.extend({
101
+ type: z.literal("file"),
102
+ max_size: maxSizeString.optional(),
103
+ alt: z.boolean().optional()
104
+ });
105
+ var RawEnumFieldSchema = RawFieldBase.extend({
106
+ type: z.literal("enum"),
107
+ ref: enumMachineName.optional(),
108
+ values: z.array(z.string().min(1)).min(1).optional()
109
+ }).refine(
110
+ (d) => d.ref !== void 0 !== (d.values !== void 0),
111
+ { message: "Enum field must have either ref or values \u2014 not both, not neither" }
112
+ );
113
+ var RawParagraphFieldSchema = RawFieldBase.extend({
114
+ type: z.literal("paragraph"),
115
+ ref: paragraphMachineName,
116
+ rel: z.enum(["one-to-one", "one-to-many"]),
117
+ max: z.number().int().positive().optional()
118
+ });
119
+ var RawReferenceFieldSchema = RawFieldBase.extend({
120
+ type: z.literal("reference"),
121
+ target: refTargetMachineName,
122
+ rel: z.enum(["one-to-one", "one-to-many", "many-to-many"]),
123
+ max: z.number().int().positive().optional()
124
+ });
125
+ var RawNonEnumFieldSchema = z.discriminatedUnion("type", [
126
+ RawTextPlainFieldSchema,
127
+ RawTextRichFieldSchema,
128
+ RawIntegerFieldSchema,
129
+ RawFloatFieldSchema,
130
+ RawBooleanFieldSchema,
131
+ RawDateFieldSchema,
132
+ RawImageFieldSchema,
133
+ RawVideoFieldSchema,
134
+ RawFileFieldSchema,
135
+ RawParagraphFieldSchema,
136
+ RawReferenceFieldSchema
137
+ ]);
138
+ var RawFieldSchema = z.union([RawNonEnumFieldSchema, RawEnumFieldSchema]);
139
+ var RawTabSchema = z.object({
140
+ tab: z.object({
141
+ name: snakeCaseName,
142
+ label: z.string().min(1),
143
+ fields: z.array(RawFieldSchema).min(1)
144
+ })
145
+ });
146
+ var ContentTypeRawSchema = z.object({
147
+ name: contentMachineName,
148
+ label: z.string().min(1),
149
+ type: z.literal("content-type"),
150
+ default_base_path: z.string().min(1),
151
+ only_one: z.boolean(),
152
+ fields: z.array(RawTabSchema).min(1)
153
+ });
154
+ var ParagraphTypeRawSchema = z.object({
155
+ name: paragraphMachineName,
156
+ label: z.string().min(1),
157
+ type: z.literal("paragraph-type"),
158
+ fields: z.array(RawFieldSchema)
159
+ });
160
+ var TaxonomyTypeRawSchema = z.object({
161
+ name: taxonomyMachineName,
162
+ label: z.string().min(1),
163
+ type: z.literal("taxonomy-type"),
164
+ fields: z.array(RawFieldSchema)
165
+ });
166
+ var EnumTypeRawSchema = z.object({
167
+ name: enumMachineName,
168
+ label: z.string().min(1),
169
+ type: z.literal("enum-type"),
170
+ values: z.array(z.string().min(1)).min(1)
171
+ });
172
+ var RoutesFileSchema = z.object({
173
+ base_paths: z.array(
174
+ z.object({
175
+ name: snakeCaseName,
176
+ path: z.string().regex(/^\//, "Path must start with /")
177
+ })
178
+ ).min(1)
179
+ });
180
+
181
+ // src/parser/validate.ts
182
+ function buildSchemaRegistry(parsedSchemas, parsedRoutes, parsedRoles) {
183
+ const schemas = {};
184
+ const content_types = {};
185
+ const paragraph_types = {};
186
+ const taxonomy_types = {};
187
+ const enum_types = {};
188
+ for (const schema of parsedSchemas) {
189
+ schemas[schema.name] = schema;
190
+ switch (schema.schema_type) {
191
+ case "content-type":
192
+ content_types[schema.name] = schema;
193
+ break;
194
+ case "paragraph-type":
195
+ paragraph_types[schema.name] = schema;
196
+ break;
197
+ case "taxonomy-type":
198
+ taxonomy_types[schema.name] = schema;
199
+ break;
200
+ case "enum-type":
201
+ enum_types[schema.name] = schema;
202
+ break;
203
+ }
204
+ }
205
+ for (const schema of Object.values(schemas)) {
206
+ if (schema.schema_type === "enum-type") continue;
207
+ for (const field of schema.fields) {
208
+ if (field.field_type !== "enum") continue;
209
+ const ui = field.ui_component;
210
+ if (ui.enum_ref === void 0) continue;
211
+ const enumSchema = enum_types[ui.enum_ref];
212
+ if (enumSchema === void 0) continue;
213
+ const values = enumSchema.values;
214
+ field.validation.allowed_values = values;
215
+ if (field.db_column !== null) {
216
+ field.db_column.check_constraint = values;
217
+ }
218
+ ui.options = values;
219
+ }
220
+ }
221
+ return {
222
+ routes: parsedRoutes,
223
+ roles: parsedRoles,
224
+ schemas,
225
+ content_types,
226
+ paragraph_types,
227
+ taxonomy_types,
228
+ enum_types,
229
+ all_schemas: parsedSchemas
230
+ };
231
+ }
232
+ function validateCrossReferences(registry, globalMaxFileSize) {
233
+ const errors = [];
234
+ errors.push(...checkDuplicateSchemaNames(registry.all_schemas));
235
+ errors.push(...checkFieldRefs(registry));
236
+ errors.push(...checkCircularParagraphRefs(registry));
237
+ if (globalMaxFileSize !== void 0) {
238
+ errors.push(...checkMaxSizeLimit(registry, globalMaxFileSize));
239
+ }
240
+ return errors;
241
+ }
242
+ function checkDuplicateSchemaNames(allSchemas) {
243
+ const errors = [];
244
+ const seen = /* @__PURE__ */ new Map();
245
+ for (const schema of allSchemas) {
246
+ const firstFile = seen.get(schema.name);
247
+ if (firstFile !== void 0) {
248
+ errors.push({
249
+ file: schema.source_file,
250
+ code: "DUPLICATE_SCHEMA_NAME",
251
+ message: `Duplicate schema name "${schema.name}": already defined in "${firstFile}"`
252
+ });
253
+ } else {
254
+ seen.set(schema.name, schema.source_file);
255
+ }
256
+ }
257
+ return errors;
258
+ }
259
+ function checkFieldRefs(registry) {
260
+ const errors = [];
261
+ for (const schema of Object.values(registry.schemas)) {
262
+ if (schema.schema_type === "enum-type") continue;
263
+ for (const field of schema.fields) {
264
+ errors.push(...checkSingleFieldRef(field, schema.source_file, registry));
265
+ }
266
+ }
267
+ return errors;
268
+ }
269
+ function checkSingleFieldRef(field, sourceFile, registry) {
270
+ const errors = [];
271
+ const fieldPath = (prop) => `fields[${field.order}].${prop}`;
272
+ switch (field.field_type) {
273
+ case "paragraph": {
274
+ const ref = field.ui_component.ref;
275
+ if (ref in registry.paragraph_types) break;
276
+ errors.push({
277
+ file: sourceFile,
278
+ code: ref in registry.schemas ? "INVALID_REF_TARGET" : "UNKNOWN_REF",
279
+ message: ref in registry.schemas ? `Field "${field.name}": paragraph ref "${ref}" exists but is not a paragraph-type` : `Field "${field.name}": paragraph ref "${ref}" does not exist`,
280
+ path: fieldPath("ref")
281
+ });
282
+ break;
283
+ }
284
+ case "reference": {
285
+ const target = field.ui_component.ref;
286
+ if (target in registry.content_types || target in registry.taxonomy_types) break;
287
+ errors.push({
288
+ file: sourceFile,
289
+ code: target in registry.schemas ? "INVALID_REF_TARGET" : "UNKNOWN_REF",
290
+ message: target in registry.schemas ? `Field "${field.name}": reference target "${target}" exists but is not a content-type or taxonomy-type` : `Field "${field.name}": reference target "${target}" does not exist`,
291
+ path: fieldPath("target")
292
+ });
293
+ break;
294
+ }
295
+ case "enum": {
296
+ const enumRef = field.ui_component.enum_ref;
297
+ if (enumRef === void 0) break;
298
+ if (enumRef in registry.enum_types) break;
299
+ errors.push({
300
+ file: sourceFile,
301
+ code: enumRef in registry.schemas ? "INVALID_REF_TARGET" : "UNKNOWN_REF",
302
+ message: enumRef in registry.schemas ? `Field "${field.name}": enum ref "${enumRef}" exists but is not an enum-type` : `Field "${field.name}": enum ref "${enumRef}" does not exist`,
303
+ path: fieldPath("ref")
304
+ });
305
+ break;
306
+ }
307
+ }
308
+ return errors;
309
+ }
310
+ function checkCircularParagraphRefs(registry) {
311
+ const errors = [];
312
+ const adjList = /* @__PURE__ */ new Map();
313
+ for (const [name, para] of Object.entries(registry.paragraph_types)) {
314
+ const refs = [];
315
+ for (const field of para.fields) {
316
+ if (field.field_type !== "paragraph") continue;
317
+ const ref = field.ui_component.ref;
318
+ if (ref in registry.paragraph_types) {
319
+ refs.push(ref);
320
+ }
321
+ }
322
+ adjList.set(name, refs);
323
+ }
324
+ const visited = /* @__PURE__ */ new Set();
325
+ const inStack = /* @__PURE__ */ new Set();
326
+ const stack = [];
327
+ function dfs(node) {
328
+ if (inStack.has(node)) {
329
+ const cycleStart = stack.indexOf(node);
330
+ const cyclePath = stack.slice(cycleStart);
331
+ const cycleStr = [...cyclePath, node].join(" \u2192 ");
332
+ const lastNode = stack[stack.length - 1];
333
+ const sourceFile = registry.paragraph_types[lastNode]?.source_file ?? "";
334
+ errors.push({
335
+ file: sourceFile,
336
+ code: "CIRCULAR_REFERENCE",
337
+ message: `Circular paragraph reference detected: ${cycleStr}`
338
+ });
339
+ return;
340
+ }
341
+ if (visited.has(node)) return;
342
+ visited.add(node);
343
+ inStack.add(node);
344
+ stack.push(node);
345
+ for (const neighbor of adjList.get(node) ?? []) {
346
+ dfs(neighbor);
347
+ }
348
+ stack.pop();
349
+ inStack.delete(node);
350
+ }
351
+ for (const name of adjList.keys()) {
352
+ dfs(name);
353
+ }
354
+ return errors;
355
+ }
356
+ function checkMaxSizeLimit(registry, globalMaxFileSize) {
357
+ const errors = [];
358
+ for (const schema of Object.values(registry.schemas)) {
359
+ if (schema.schema_type === "enum-type") continue;
360
+ for (const field of schema.fields) {
361
+ if (field.field_type !== "image" && field.field_type !== "video" && field.field_type !== "file") {
362
+ continue;
363
+ }
364
+ const fieldMaxSize = field.validation.max_size;
365
+ if (fieldMaxSize === void 0 || fieldMaxSize <= globalMaxFileSize) continue;
366
+ errors.push({
367
+ file: schema.source_file,
368
+ code: "MAX_SIZE_EXCEEDS_GLOBAL_LIMIT",
369
+ message: `Field "${field.name}": max_size ${fieldMaxSize} bytes exceeds global limit of ${globalMaxFileSize} bytes`,
370
+ path: `fields[${field.order}].max_size`
371
+ });
372
+ }
373
+ }
374
+ return errors;
375
+ }
376
+ function parseRoutes(raw, sourceFile = "routes.json") {
377
+ const result = RoutesFileSchema.safeParse(raw);
378
+ if (!result.success) {
379
+ return {
380
+ ok: false,
381
+ errors: result.error.issues.map((issue) => ({
382
+ file: sourceFile,
383
+ code: "MISSING_REQUIRED_FIELD",
384
+ message: issue.message,
385
+ ...issue.path.length > 0 ? { path: issue.path.map(String).join(".") } : {}
386
+ }))
387
+ };
388
+ }
389
+ return { ok: true, value: result.data };
390
+ }
391
+
392
+ // src/parser/loader.ts
393
+ import * as fs from "fs";
394
+ import * as path from "path";
395
+ import { parse as parseYaml } from "yaml";
396
+ var FOLDER_KEY_TO_SCHEMA_TYPE = {
397
+ content_types: "content-type",
398
+ paragraph_types: "paragraph-type",
399
+ taxonomy_types: "taxonomy-type",
400
+ enum_types: "enum-type"
401
+ };
402
+ var SCHEMA_TYPE_TO_PREFIX = {
403
+ "content-type": "content",
404
+ "paragraph-type": "paragraph",
405
+ "taxonomy-type": "taxonomy",
406
+ "enum-type": "enum"
407
+ };
408
+ function loadSchemaFile(filePath) {
409
+ let raw;
410
+ try {
411
+ raw = fs.readFileSync(filePath, "utf-8");
412
+ } catch (err) {
413
+ return {
414
+ ok: false,
415
+ errors: [
416
+ {
417
+ file: filePath,
418
+ code: "FILE_READ_ERROR",
419
+ message: `Could not read file: ${err instanceof Error ? err.message : String(err)}`
420
+ }
421
+ ]
422
+ };
423
+ }
424
+ const ext = path.extname(filePath).toLowerCase();
425
+ if (ext === ".json") {
426
+ try {
427
+ return { ok: true, value: JSON.parse(raw) };
428
+ } catch (err) {
429
+ return {
430
+ ok: false,
431
+ errors: [
432
+ {
433
+ file: filePath,
434
+ code: "FILE_PARSE_ERROR",
435
+ message: `JSON parse error: ${err instanceof Error ? err.message : String(err)}`
436
+ }
437
+ ]
438
+ };
439
+ }
440
+ }
441
+ if (ext === ".yaml" || ext === ".yml") {
442
+ try {
443
+ return { ok: true, value: parseYaml(raw) };
444
+ } catch (err) {
445
+ return {
446
+ ok: false,
447
+ errors: [
448
+ {
449
+ file: filePath,
450
+ code: "FILE_PARSE_ERROR",
451
+ message: `YAML parse error: ${err instanceof Error ? err.message : String(err)}`
452
+ }
453
+ ]
454
+ };
455
+ }
456
+ }
457
+ return {
458
+ ok: false,
459
+ errors: [
460
+ {
461
+ file: filePath,
462
+ code: "FILE_PARSE_ERROR",
463
+ message: `Unsupported file extension "${ext}" \u2014 expected .json, .yaml, or .yml`
464
+ }
465
+ ]
466
+ };
467
+ }
468
+ function walkSchemaDirectory(config) {
469
+ const errors = [];
470
+ const files = [];
471
+ if (!directoryExists(config.base_path)) {
472
+ return {
473
+ ok: false,
474
+ errors: [
475
+ {
476
+ file: config.base_path,
477
+ code: "SCHEMA_DIR_NOT_FOUND",
478
+ message: `Schema base directory does not exist: ${config.base_path}`
479
+ }
480
+ ]
481
+ };
482
+ }
483
+ const resolvedFolderPaths = /* @__PURE__ */ new Map();
484
+ for (const folderKey of Object.keys(FOLDER_KEY_TO_SCHEMA_TYPE)) {
485
+ const folderName = config.folders[folderKey];
486
+ const absFolder = path.resolve(config.base_path, folderName);
487
+ const existing = resolvedFolderPaths.get(absFolder);
488
+ if (existing !== void 0) {
489
+ errors.push({
490
+ file: absFolder,
491
+ code: "DUPLICATE_SCHEMA_FOLDER",
492
+ message: `Folders "${existing}" and "${folderKey}" resolve to the same path: ${absFolder}`
493
+ });
494
+ } else {
495
+ resolvedFolderPaths.set(absFolder, folderKey);
496
+ }
497
+ }
498
+ if (errors.length > 0) return { ok: false, errors };
499
+ for (const [folderKey, schemaType] of Object.entries(
500
+ FOLDER_KEY_TO_SCHEMA_TYPE
501
+ )) {
502
+ const folderName = config.folders[folderKey];
503
+ const folderPath = path.resolve(config.base_path, folderName);
504
+ if (!directoryExists(folderPath)) {
505
+ errors.push({
506
+ file: folderPath,
507
+ code: "SCHEMA_FOLDER_NOT_FOUND",
508
+ message: `Schema folder does not exist: ${folderPath} (configured as "${folderName}")`
509
+ });
510
+ continue;
511
+ }
512
+ const entries = readDirEntries(folderPath);
513
+ const expectedPrefix = SCHEMA_TYPE_TO_PREFIX[schemaType];
514
+ for (const entry of entries) {
515
+ if (!isSupportedExtension(entry)) continue;
516
+ const filePath = path.join(folderPath, entry);
517
+ const baseName = path.basename(entry, path.extname(entry));
518
+ if (!hasCorrectPrefix(baseName, expectedPrefix)) {
519
+ errors.push({
520
+ file: filePath,
521
+ code: "INVALID_MACHINE_NAME",
522
+ message: `File "${entry}" in folder "${folderName}" does not match the expected "${expectedPrefix}--<name>" prefix for ${schemaType} schemas. Did you put this file in the wrong folder?`
523
+ });
524
+ continue;
525
+ }
526
+ const result = loadSchemaFile(filePath);
527
+ if (!result.ok) {
528
+ errors.push(...result.errors);
529
+ continue;
530
+ }
531
+ files.push({ path: filePath, raw: result.value, schema_type: schemaType });
532
+ }
533
+ }
534
+ if (errors.length > 0) return { ok: false, errors };
535
+ return { ok: true, value: files };
536
+ }
537
+ function directoryExists(dirPath) {
538
+ try {
539
+ return fs.statSync(dirPath).isDirectory();
540
+ } catch {
541
+ return false;
542
+ }
543
+ }
544
+ function readDirEntries(dirPath) {
545
+ try {
546
+ return fs.readdirSync(dirPath);
547
+ } catch {
548
+ return [];
549
+ }
550
+ }
551
+ function isSupportedExtension(filename) {
552
+ const ext = path.extname(filename).toLowerCase();
553
+ return ext === ".json" || ext === ".yaml" || ext === ".yml";
554
+ }
555
+ function hasCorrectPrefix(baseName, expectedPrefix) {
556
+ return baseName.startsWith(`${expectedPrefix}--`);
557
+ }
558
+
559
+ // src/registry/fieldTypeRegistry.ts
560
+ function machineNameToTableName(machineName) {
561
+ return machineName.replace("--", "_");
562
+ }
563
+ var MEDIA_ALLOWED_MIME = {
564
+ image: ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"],
565
+ video: ["video/mp4", "video/webm", "video/quicktime"],
566
+ file: ["application/pdf"]
567
+ };
568
+ var MEDIA_UI_MIME = {
569
+ image: ["image/*"],
570
+ video: ["video/*"],
571
+ file: ["application/pdf"]
572
+ };
573
+ function parseMaxSize(str) {
574
+ const m = /^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i.exec(str);
575
+ if (!m) return 0;
576
+ const value = parseFloat(m[1]);
577
+ switch (m[2].toUpperCase()) {
578
+ case "B":
579
+ return Math.round(value);
580
+ case "KB":
581
+ return Math.round(value * 1024);
582
+ case "MB":
583
+ return Math.round(value * 1048576);
584
+ case "GB":
585
+ return Math.round(value * 1073741824);
586
+ default:
587
+ return 0;
588
+ }
589
+ }
590
+ function buildMediaField(raw) {
591
+ const mediaType = raw.type;
592
+ const maxSizeBytes = raw.max_size ? parseMaxSize(raw.max_size) : void 0;
593
+ return {
594
+ validation: {
595
+ required: raw.required,
596
+ ...maxSizeBytes !== void 0 && { max_size: maxSizeBytes },
597
+ allowed_mime_types: MEDIA_ALLOWED_MIME[mediaType]
598
+ },
599
+ db_column: {
600
+ column_name: raw.name,
601
+ column_type: "uuid",
602
+ nullable: !raw.required,
603
+ foreign_key: { table: "media", column: "id", on_delete: "SET NULL" }
604
+ },
605
+ ui_component: {
606
+ component: "file-upload",
607
+ accepted_mime_types: MEDIA_UI_MIME[mediaType]
608
+ }
609
+ };
610
+ }
611
+ var fieldTypeRegistry = {
612
+ // ── Primitives ──────────────────────────────────────────────────────────────
613
+ "text/plain": (raw) => ({
614
+ validation: {
615
+ required: raw.required,
616
+ ...raw.limit !== void 0 && { limit: raw.limit },
617
+ ...raw.pattern !== void 0 && { pattern: raw.pattern }
618
+ },
619
+ db_column: { column_name: raw.name, column_type: "varchar", nullable: !raw.required },
620
+ ui_component: { component: "text-input" }
621
+ }),
622
+ "text/rich": (raw) => ({
623
+ validation: { required: raw.required },
624
+ db_column: { column_name: raw.name, column_type: "text", nullable: !raw.required },
625
+ ui_component: { component: "rich-text-editor" }
626
+ }),
627
+ integer: (raw) => ({
628
+ validation: {
629
+ required: raw.required,
630
+ ...raw.min !== void 0 && { min: raw.min },
631
+ ...raw.max !== void 0 && { max: raw.max }
632
+ },
633
+ db_column: { column_name: raw.name, column_type: "integer", nullable: !raw.required },
634
+ ui_component: { component: "number-input", step: 1 }
635
+ }),
636
+ float: (raw) => ({
637
+ validation: {
638
+ required: raw.required,
639
+ ...raw.min !== void 0 && { min: raw.min },
640
+ ...raw.max !== void 0 && { max: raw.max }
641
+ },
642
+ db_column: { column_name: raw.name, column_type: "decimal", nullable: !raw.required },
643
+ ui_component: { component: "number-input", step: 0.01 }
644
+ }),
645
+ // Boolean columns are always NOT NULL — false is the natural empty value, not NULL.
646
+ boolean: (raw) => ({
647
+ validation: { required: raw.required },
648
+ db_column: { column_name: raw.name, column_type: "boolean", nullable: false },
649
+ ui_component: { component: "checkbox" }
650
+ }),
651
+ date: (raw) => ({
652
+ validation: { required: raw.required },
653
+ db_column: { column_name: raw.name, column_type: "timestamp", nullable: !raw.required },
654
+ ui_component: { component: "date-picker" }
655
+ }),
656
+ // ── Media — FK → media.id, SET NULL on delete ───────────────────────────────
657
+ image: (raw) => buildMediaField(raw),
658
+ video: (raw) => buildMediaField(raw),
659
+ file: (raw) => buildMediaField(raw),
660
+ // ── Enum — varchar + check constraint ───────────────────────────────────────
661
+ //
662
+ // Inline enum: values are present on the field and fully resolved here.
663
+ // Ref enum: values live in another schema not yet available, so allowed_values
664
+ // and check_constraint stay empty and enum_ref is stashed in ui_component for
665
+ // resolveEnumReferences() to fill once the full registry is assembled.
666
+ enum: (raw) => {
667
+ const allowedValues = raw.values ?? [];
668
+ return {
669
+ validation: { required: raw.required, allowed_values: allowedValues },
670
+ db_column: {
671
+ column_name: raw.name,
672
+ column_type: "varchar",
673
+ nullable: !raw.required,
674
+ check_constraint: allowedValues
675
+ },
676
+ ui_component: {
677
+ component: "select",
678
+ options: allowedValues,
679
+ ...raw.ref !== void 0 && { enum_ref: raw.ref }
680
+ }
681
+ };
682
+ },
683
+ // ── Paragraph — no column on the owning table ───────────────────────────────
684
+ // The association lives on the paragraph table via parent_id/parent_type/
685
+ // parent_field/order system fields.
686
+ paragraph: (raw) => ({
687
+ validation: {
688
+ required: raw.required,
689
+ ...raw.max !== void 0 && { max_items: raw.max }
690
+ },
691
+ db_column: null,
692
+ ui_component: {
693
+ component: "paragraph-embed",
694
+ ref: raw.ref,
695
+ rel: raw.rel,
696
+ ...raw.max !== void 0 && { max: raw.max }
697
+ }
698
+ }),
699
+ // ── Reference — FK column, or a junction table for many-to-many ─────────────
700
+ reference: (raw, ctx) => {
701
+ const targetTableName = machineNameToTableName(raw.target);
702
+ const validation = {
703
+ required: raw.required,
704
+ ...raw.max !== void 0 && { max_items: raw.max }
705
+ };
706
+ const db_column = raw.rel === "many-to-many" ? {
707
+ // No FK column — the junction table owns the association.
708
+ column_name: "",
709
+ column_type: "uuid",
710
+ nullable: !raw.required,
711
+ junction: {
712
+ table_name: `junction_${ctx.ownerTableName}_${raw.name}`,
713
+ left_column: "left_id",
714
+ right_column: "right_id",
715
+ right_table: targetTableName,
716
+ order_column: false
717
+ }
718
+ } : {
719
+ // FK column on the owning table. References are independent → SET NULL.
720
+ column_name: raw.name,
721
+ column_type: "uuid",
722
+ nullable: !raw.required,
723
+ foreign_key: { table: targetTableName, column: "id", on_delete: "SET NULL" }
724
+ };
725
+ return {
726
+ validation,
727
+ db_column,
728
+ ui_component: { component: "typeahead-select", ref: raw.target, rel: raw.rel }
729
+ };
730
+ }
731
+ };
732
+
733
+ // src/parser/parseSchema.ts
734
+ var CONTENT_SYSTEM_FIELDS = [
735
+ { name: "id", db_type: "uuid", primary_key: true, default: "gen_random_uuid()", nullable: false },
736
+ { name: "slug", db_type: "varchar", nullable: false },
737
+ { name: "base_path_id", db_type: "uuid", nullable: false },
738
+ { name: "published", db_type: "boolean", default: "false", nullable: false },
739
+ { name: "created_at", db_type: "timestamp", default: "now()", nullable: false },
740
+ { name: "updated_at", db_type: "timestamp", default: "now()", nullable: false }
741
+ ];
742
+ var PARAGRAPH_SYSTEM_FIELDS = [
743
+ { name: "id", db_type: "uuid", primary_key: true, default: "gen_random_uuid()", nullable: false },
744
+ { name: "parent_id", db_type: "uuid", nullable: false },
745
+ { name: "parent_type", db_type: "varchar", nullable: false },
746
+ { name: "parent_field", db_type: "varchar", nullable: false },
747
+ { name: "order", db_type: "integer", default: "0", nullable: false },
748
+ { name: "created_at", db_type: "timestamp", default: "now()", nullable: false },
749
+ { name: "updated_at", db_type: "timestamp", default: "now()", nullable: false }
750
+ ];
751
+ var TAXONOMY_SYSTEM_FIELDS = [
752
+ { name: "id", db_type: "uuid", primary_key: true, default: "gen_random_uuid()", nullable: false },
753
+ { name: "published", db_type: "boolean", default: "false", nullable: false },
754
+ { name: "created_at", db_type: "timestamp", default: "now()", nullable: false },
755
+ { name: "updated_at", db_type: "timestamp", default: "now()", nullable: false }
756
+ ];
757
+ function nameSegmentToKebab(segment) {
758
+ return segment.replace(/_/g, "-");
759
+ }
760
+ function getNameSegment(machineName) {
761
+ const idx = machineName.indexOf("--");
762
+ return idx === -1 ? machineName : machineName.slice(idx + 2);
763
+ }
764
+ function zodPathToString(path2) {
765
+ return path2.reduce((acc, seg) => {
766
+ const s = typeof seg === "symbol" ? String(seg) : seg;
767
+ return typeof s === "number" ? `${acc}[${s}]` : acc ? `${acc}.${s}` : s;
768
+ }, "");
769
+ }
770
+ function zodErrorsToParseErrors(error, sourceFile) {
771
+ return error.issues.map((issue) => {
772
+ const pathStr = zodPathToString(issue.path);
773
+ const lastSeg = issue.path[issue.path.length - 1];
774
+ let code = "MISSING_REQUIRED_FIELD";
775
+ if (lastSeg === "name") code = "INVALID_MACHINE_NAME";
776
+ else if (lastSeg === "type" && issue.path.length <= 2) code = "INVALID_SCHEMA_TYPE";
777
+ else if (lastSeg === "type") code = "INVALID_FIELD_TYPE";
778
+ return {
779
+ file: sourceFile,
780
+ code,
781
+ message: issue.message,
782
+ ...pathStr ? { path: pathStr } : {}
783
+ };
784
+ });
785
+ }
786
+ function checkDuplicateFieldNames(fields, sourceFile, schemaName) {
787
+ const seen = /* @__PURE__ */ new Set();
788
+ const errors = [];
789
+ for (const f of fields) {
790
+ if (seen.has(f.name)) {
791
+ errors.push({
792
+ file: sourceFile,
793
+ code: "DUPLICATE_FIELD_NAME",
794
+ message: `Duplicate field name "${f.name}" in schema "${schemaName}"`
795
+ });
796
+ }
797
+ seen.add(f.name);
798
+ }
799
+ return errors;
800
+ }
801
+ function buildParsedField(rawField, order, sourceFile, ownerTableName) {
802
+ const build = fieldTypeRegistry[rawField.type];
803
+ if (!build) {
804
+ return {
805
+ ok: false,
806
+ errors: [{
807
+ file: sourceFile,
808
+ code: "INVALID_FIELD_TYPE",
809
+ message: `Unknown field type: ${String(rawField.type)}`
810
+ }]
811
+ };
812
+ }
813
+ const { name, label, required } = rawField;
814
+ const { validation, db_column, ui_component } = build(rawField, { ownerTableName });
815
+ return {
816
+ ok: true,
817
+ value: {
818
+ name,
819
+ label,
820
+ field_type: rawField.type,
821
+ required,
822
+ nullable: !required,
823
+ order,
824
+ validation,
825
+ db_column,
826
+ ui_component
827
+ }
828
+ };
829
+ }
830
+ function buildFields(rawFields, sourceFile, ownerTableName) {
831
+ const fields = [];
832
+ const errors = [];
833
+ for (let i = 0; i < rawFields.length; i++) {
834
+ const result = buildParsedField(rawFields[i], i, sourceFile, ownerTableName);
835
+ if (result.ok) {
836
+ fields.push(result.value);
837
+ } else {
838
+ errors.push(...result.errors);
839
+ }
840
+ }
841
+ return { fields, errors };
842
+ }
843
+ function parseContentType(raw, sourceFile) {
844
+ const result = ContentTypeRawSchema.safeParse(raw);
845
+ if (!result.success) {
846
+ return { ok: false, errors: zodErrorsToParseErrors(result.error, sourceFile) };
847
+ }
848
+ const v = result.data;
849
+ const flatRawFields = [];
850
+ const tabs = [];
851
+ for (const tabWrapper of v.fields) {
852
+ const tab = tabWrapper.tab;
853
+ const tabFieldNames = [];
854
+ for (const f of tab.fields) {
855
+ flatRawFields.push(f);
856
+ tabFieldNames.push(f.name);
857
+ }
858
+ tabs.push({ name: tab.name, label: tab.label, fields: tabFieldNames });
859
+ }
860
+ const dupErrors = checkDuplicateFieldNames(flatRawFields, sourceFile, v.name);
861
+ if (dupErrors.length > 0) return { ok: false, errors: dupErrors };
862
+ const tableName = machineNameToTableName(v.name);
863
+ const { fields, errors } = buildFields(flatRawFields, sourceFile, tableName);
864
+ if (errors.length > 0) return { ok: false, errors };
865
+ const junctionTables = [];
866
+ for (const field of fields) {
867
+ if (field.field_type === "reference" && field.db_column?.junction) {
868
+ const j = field.db_column.junction;
869
+ junctionTables.push({
870
+ table_name: j.table_name,
871
+ left_column: j.left_column,
872
+ right_column: j.right_column,
873
+ right_table: j.right_table,
874
+ order_column: j.order_column
875
+ });
876
+ }
877
+ }
878
+ const nameKebab = nameSegmentToKebab(getNameSegment(v.name));
879
+ const api = v.only_one ? {
880
+ default_base_path: v.default_base_path,
881
+ http_methods: ["GET", "PUT", "PATCH"],
882
+ item_path: `/api/${v.default_base_path}/${nameKebab}`
883
+ } : {
884
+ default_base_path: v.default_base_path,
885
+ http_methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
886
+ collection_path: `/api/${nameKebab}`,
887
+ item_path: `/api/${nameKebab}/:slug`
888
+ };
889
+ const schema = {
890
+ schema_type: "content-type",
891
+ name: v.name,
892
+ label: v.label,
893
+ source_file: sourceFile,
894
+ only_one: v.only_one,
895
+ default_base_path: v.default_base_path,
896
+ system_fields: CONTENT_SYSTEM_FIELDS,
897
+ fields,
898
+ ui: { tabs },
899
+ db: { table_name: tableName, junction_tables: junctionTables },
900
+ api
901
+ };
902
+ return { ok: true, schema };
903
+ }
904
+ function parseParagraphType(raw, sourceFile) {
905
+ const result = ParagraphTypeRawSchema.safeParse(raw);
906
+ if (!result.success) {
907
+ return { ok: false, errors: zodErrorsToParseErrors(result.error, sourceFile) };
908
+ }
909
+ const v = result.data;
910
+ const dupErrors = checkDuplicateFieldNames(v.fields, sourceFile, v.name);
911
+ if (dupErrors.length > 0) return { ok: false, errors: dupErrors };
912
+ const tableName = machineNameToTableName(v.name);
913
+ const { fields, errors } = buildFields(v.fields, sourceFile, tableName);
914
+ if (errors.length > 0) return { ok: false, errors };
915
+ const schema = {
916
+ schema_type: "paragraph-type",
917
+ name: v.name,
918
+ label: v.label,
919
+ source_file: sourceFile,
920
+ system_fields: PARAGRAPH_SYSTEM_FIELDS,
921
+ fields,
922
+ db: { table_name: tableName }
923
+ };
924
+ return { ok: true, schema };
925
+ }
926
+ function parseTaxonomyType(raw, sourceFile) {
927
+ const result = TaxonomyTypeRawSchema.safeParse(raw);
928
+ if (!result.success) {
929
+ return { ok: false, errors: zodErrorsToParseErrors(result.error, sourceFile) };
930
+ }
931
+ const v = result.data;
932
+ const dupErrors = checkDuplicateFieldNames(v.fields, sourceFile, v.name);
933
+ if (dupErrors.length > 0) return { ok: false, errors: dupErrors };
934
+ const tableName = machineNameToTableName(v.name);
935
+ const { fields, errors } = buildFields(v.fields, sourceFile, tableName);
936
+ if (errors.length > 0) return { ok: false, errors };
937
+ const nameKebab = nameSegmentToKebab(getNameSegment(v.name));
938
+ const basePath = `/api/taxonomy/${nameKebab}`;
939
+ const schema = {
940
+ schema_type: "taxonomy-type",
941
+ name: v.name,
942
+ label: v.label,
943
+ source_file: sourceFile,
944
+ system_fields: TAXONOMY_SYSTEM_FIELDS,
945
+ fields,
946
+ db: { table_name: tableName },
947
+ api: {
948
+ collection_path: basePath,
949
+ item_path: `${basePath}/:id`
950
+ }
951
+ };
952
+ return { ok: true, schema };
953
+ }
954
+ function parseEnumType(raw, sourceFile) {
955
+ const result = EnumTypeRawSchema.safeParse(raw);
956
+ if (!result.success) {
957
+ return { ok: false, errors: zodErrorsToParseErrors(result.error, sourceFile) };
958
+ }
959
+ const v = result.data;
960
+ const schema = {
961
+ schema_type: "enum-type",
962
+ name: v.name,
963
+ label: v.label,
964
+ source_file: sourceFile,
965
+ values: v.values
966
+ };
967
+ return { ok: true, schema };
968
+ }
969
+ function parseSchema(raw, schemaType, sourceFile = "") {
970
+ switch (schemaType) {
971
+ case "content-type":
972
+ return parseContentType(raw, sourceFile);
973
+ case "paragraph-type":
974
+ return parseParagraphType(raw, sourceFile);
975
+ case "taxonomy-type":
976
+ return parseTaxonomyType(raw, sourceFile);
977
+ case "enum-type":
978
+ return parseEnumType(raw, sourceFile);
979
+ }
980
+ }
981
+
982
+ // src/parser/parseRoles.ts
983
+ import { z as z2 } from "zod";
984
+ var VALID_PERMISSIONS = /* @__PURE__ */ new Set([
985
+ "content:read",
986
+ "content:create",
987
+ "content:edit",
988
+ "content:delete",
989
+ "media:read",
990
+ "media:create",
991
+ "media:edit",
992
+ "media:delete",
993
+ "taxonomy:read",
994
+ "taxonomy:create",
995
+ "taxonomy:edit",
996
+ "taxonomy:delete",
997
+ "users:read",
998
+ "users:create",
999
+ "users:edit",
1000
+ "users:delete",
1001
+ "roles:read"
1002
+ ]);
1003
+ var INVALID_ROLE_PERMISSIONS = /* @__PURE__ */ new Set(["roles:create", "roles:edit", "roles:delete"]);
1004
+ var VALID_PERMISSIONS_LIST = [
1005
+ "content:read",
1006
+ "content:create",
1007
+ "content:edit",
1008
+ "content:delete",
1009
+ "media:read",
1010
+ "media:create",
1011
+ "media:edit",
1012
+ "media:delete",
1013
+ "taxonomy:read",
1014
+ "taxonomy:create",
1015
+ "taxonomy:edit",
1016
+ "taxonomy:delete",
1017
+ "users:read",
1018
+ "users:create",
1019
+ "users:edit",
1020
+ "users:delete",
1021
+ "roles:read"
1022
+ ];
1023
+ var RawRoleSchema = z2.object({
1024
+ name: z2.string().min(1),
1025
+ label: z2.string().min(1),
1026
+ is_system: z2.boolean().default(false),
1027
+ hierarchy_level: z2.number().int().min(0),
1028
+ permissions: z2.array(z2.string())
1029
+ });
1030
+ var RawRolesFileSchema = z2.object({
1031
+ roles: z2.array(RawRoleSchema).min(1)
1032
+ });
1033
+ function zodRolesErrorsToParseErrors(error, sourceFile) {
1034
+ return error.issues.map((issue) => ({
1035
+ file: sourceFile,
1036
+ code: "MISSING_REQUIRED_FIELD",
1037
+ message: issue.message,
1038
+ ...issue.path.length > 0 ? { path: issue.path.join(".") } : {}
1039
+ }));
1040
+ }
1041
+ function parseRoles(raw, sourceFile = "roles/roles.json") {
1042
+ const result = RawRolesFileSchema.safeParse(raw);
1043
+ if (!result.success) {
1044
+ return { ok: false, errors: zodRolesErrorsToParseErrors(result.error, sourceFile) };
1045
+ }
1046
+ const { roles } = result.data;
1047
+ const errors = [];
1048
+ for (const role of roles) {
1049
+ for (let i = 0; i < role.permissions.length; i++) {
1050
+ const perm = role.permissions[i];
1051
+ if (INVALID_ROLE_PERMISSIONS.has(perm)) {
1052
+ errors.push({
1053
+ file: sourceFile,
1054
+ code: "INVALID_PERMISSION",
1055
+ message: `Role "${role.name}" contains invalid permission "${perm}" \u2014 roles:create, roles:edit, and roles:delete are not valid permissions; roles are managed through schema files and CLI only`,
1056
+ path: `roles[${roles.indexOf(role)}].permissions[${i}]`
1057
+ });
1058
+ } else if (!VALID_PERMISSIONS.has(perm)) {
1059
+ errors.push({
1060
+ file: sourceFile,
1061
+ code: "UNKNOWN_PERMISSION",
1062
+ message: `Role "${role.name}" contains unknown permission "${perm}"`,
1063
+ path: `roles[${roles.indexOf(role)}].permissions[${i}]`
1064
+ });
1065
+ }
1066
+ }
1067
+ }
1068
+ const levelsSeen = /* @__PURE__ */ new Map();
1069
+ for (let i = 0; i < roles.length; i++) {
1070
+ const role = roles[i];
1071
+ const existing = levelsSeen.get(role.hierarchy_level);
1072
+ if (existing !== void 0) {
1073
+ errors.push({
1074
+ file: sourceFile,
1075
+ code: "DUPLICATE_HIERARCHY_LEVEL",
1076
+ message: `Roles "${existing}" and "${role.name}" both have hierarchy_level ${role.hierarchy_level} \u2014 each role must have a unique hierarchy_level`,
1077
+ path: `roles[${i}].hierarchy_level`
1078
+ });
1079
+ } else {
1080
+ levelsSeen.set(role.hierarchy_level, role.name);
1081
+ }
1082
+ }
1083
+ if (errors.length > 0) return { ok: false, errors };
1084
+ const parsedRoles = roles.map((r) => ({
1085
+ name: r.name,
1086
+ label: r.label,
1087
+ is_system: r.is_system,
1088
+ hierarchy_level: r.hierarchy_level,
1089
+ permissions: r.permissions
1090
+ })).sort((a, b) => a.hierarchy_level - b.hierarchy_level);
1091
+ const parsed = {
1092
+ roles: parsedRoles,
1093
+ valid_permissions: VALID_PERMISSIONS_LIST
1094
+ };
1095
+ return { ok: true, value: parsed };
1096
+ }
1097
+
1098
+ // src/auth.ts
1099
+ import bcrypt from "bcryptjs";
1100
+ var SALT_ROUNDS = 12;
1101
+ async function hashPassword(password) {
1102
+ return bcrypt.hash(password, SALT_ROUNDS);
1103
+ }
1104
+ async function verifyPassword(password, stored) {
1105
+ return bcrypt.compare(password, stored);
1106
+ }
1107
+ export {
1108
+ buildSchemaRegistry,
1109
+ defineConfig,
1110
+ hashPassword,
1111
+ loadSchemaFile,
1112
+ parseRoles,
1113
+ parseRoutes,
1114
+ parseSchema,
1115
+ validateCrossReferences,
1116
+ verifyPassword,
1117
+ walkSchemaDirectory
1118
+ };
1119
+ //# sourceMappingURL=index.mjs.map