@llm-cms/core 0.0.1 → 0.0.2

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/src/validate.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // packages/core/src/validate.ts
2
+ import { modelFormat, parseDoc } from "./doc-format";
2
3
  import type { Frontmatter } from "./mdx";
3
4
  import { parseMdx } from "./mdx";
4
5
  import type { ModelSnapshot } from "./model";
6
+ import type { BlockInstance, BlockSnapshot } from "./block";
5
7
  import { matchAnyModelPath } from "./path";
6
8
  import { expectedTypeFor, frontmatterZod, normalizeFrontmatter } from "./zod";
7
9
 
@@ -43,6 +45,42 @@ export function validateFrontmatter(
43
45
  return { ok: false, errors };
44
46
  }
45
47
 
48
+ function asBlockList(value: unknown): BlockInstance[] {
49
+ if (!Array.isArray(value)) return [];
50
+ return value.filter((item): item is BlockInstance => {
51
+ if (!item || typeof item !== "object") return false;
52
+ const row = item as Record<string, unknown>;
53
+ return typeof row.id === "string" && typeof row.type === "string";
54
+ });
55
+ }
56
+
57
+ /** Validate each block instance against the blocks catalog. */
58
+ export function validateBlocks(
59
+ blocks: unknown,
60
+ catalog: BlockSnapshot[],
61
+ prefix = "blocks",
62
+ ): ValidationResult {
63
+ if (!Array.isArray(blocks)) return { ok: true };
64
+ const byName = new Map(catalog.map((b) => [b.name, b]));
65
+ const errors: string[] = [];
66
+ asBlockList(blocks).forEach((block, i) => {
67
+ const def = byName.get(block.type);
68
+ if (!def) {
69
+ errors.push(`${prefix}[${i}]: unknown type "${block.type}"`);
70
+ return;
71
+ }
72
+ const props = block.props && typeof block.props === "object" ? block.props : {};
73
+ const result = validateFrontmatter({ fields: def.fields }, props);
74
+ if (!result.ok) {
75
+ for (const err of result.errors) {
76
+ errors.push(`${prefix}[${i}].${err}`);
77
+ }
78
+ }
79
+ });
80
+ if (errors.length > 0) return { ok: false, errors };
81
+ return { ok: true };
82
+ }
83
+
46
84
  /**
47
85
  * Parse a raw MDX string and validate its frontmatter. Never throws on
48
86
  * malformed frontmatter (an unknown field kind in `model` still throws).
@@ -61,6 +99,32 @@ export function validateMdxFile(
61
99
  return validateFrontmatter(model, frontmatter);
62
100
  }
63
101
 
102
+ export function validateDocFile(
103
+ model: Pick<ModelSnapshot, "fields" | "format">,
104
+ raw: string,
105
+ catalog: BlockSnapshot[] = [],
106
+ ): ValidationResult {
107
+ const format = modelFormat(model);
108
+ let frontmatter: Frontmatter;
109
+ try {
110
+ frontmatter = parseDoc(raw, format).frontmatter;
111
+ } catch (err) {
112
+ const msg = err instanceof Error ? err.message : "invalid";
113
+ const label = format === "page" ? "JSON" : "frontmatter";
114
+ return { ok: false, errors: [msg.startsWith("JSON:") ? msg : `${label}: ${msg}`] };
115
+ }
116
+ const base = validateFrontmatter(model, frontmatter);
117
+ if (format !== "page") return base;
118
+ const blockField = Object.entries(model.fields).find(([, f]) => f.kind === "blocks");
119
+ if (!blockField) return base;
120
+ const blockResult = validateBlocks(frontmatter[blockField[0]], catalog, blockField[0]);
121
+ if (base.ok && blockResult.ok) return { ok: true };
122
+ return {
123
+ ok: false,
124
+ errors: [...(base.ok ? [] : base.errors), ...(blockResult.ok ? [] : blockResult.errors)],
125
+ };
126
+ }
127
+
64
128
  export type ContentFileResult =
65
129
  | { path: string; type: string; ok: true }
66
130
  | { path: string; type: string; ok: false; errors: string[] }
@@ -70,13 +134,15 @@ export type ContentFileResult =
70
134
  export function validateContentTree(input: {
71
135
  models: ModelSnapshot[];
72
136
  files: Array<{ path: string; raw: string }>;
137
+ catalog?: BlockSnapshot[];
73
138
  }): ContentFileResult[] {
139
+ const catalog = input.catalog ?? [];
74
140
  return input.files.map((file) => {
75
141
  const hit = matchAnyModelPath(input.models, file.path);
76
142
  if (!hit) return { path: file.path, type: null, ok: true, skipped: true };
77
143
  const model = input.models.find((m) => m.type === hit.type);
78
144
  if (!model) return { path: file.path, type: null, ok: true, skipped: true };
79
- const result = validateMdxFile(model, file.raw);
145
+ const result = validateDocFile(model, file.raw, catalog);
80
146
  if (result.ok) return { path: file.path, type: model.type, ok: true };
81
147
  return { path: file.path, type: model.type, ok: false, errors: result.errors };
82
148
  });
package/src/zod.ts CHANGED
@@ -5,12 +5,14 @@ import type { Frontmatter } from "./mdx";
5
5
  import type { ModelSnapshot } from "./model";
6
6
 
7
7
  /** Primitive a field kind is stored as in frontmatter; used in error messages. */
8
- export function expectedTypeFor(kind: FieldKind): "string" | "number" | "boolean" {
8
+ export function expectedTypeFor(kind: FieldKind): "string" | "number" | "boolean" | "array" {
9
9
  switch (kind) {
10
10
  case "number":
11
11
  return "number";
12
12
  case "boolean":
13
13
  return "boolean";
14
+ case "blocks":
15
+ return "array";
14
16
  case "text":
15
17
  case "slug":
16
18
  case "locale":
@@ -25,6 +27,15 @@ export function expectedTypeFor(kind: FieldKind): "string" | "number" | "boolean
25
27
  }
26
28
 
27
29
  function fieldZod(field: FieldSnapshot): z.ZodType {
30
+ if (field.kind === "blocks") {
31
+ const item = z.object({
32
+ id: z.string().min(1),
33
+ type: z.string().min(1),
34
+ props: z.record(z.string(), z.unknown()).optional().default({}),
35
+ });
36
+ const arr = field.required ? z.array(item).min(1) : z.array(item).optional();
37
+ return arr;
38
+ }
28
39
  let base: z.ZodType;
29
40
  switch (expectedTypeFor(field.kind)) {
30
41
  case "number":