@gradial/aci 0.1.0 → 0.1.1

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 (50) hide show
  1. package/README.md +1 -1
  2. package/bin/aci.js +45 -0
  3. package/dist/assets/index.d.ts +1 -0
  4. package/dist/assets/index.js +1 -0
  5. package/dist/astro/index.d.ts +24 -2
  6. package/dist/astro/index.js +42 -4
  7. package/dist/content/index.d.ts +0 -3
  8. package/dist/content/index.js +0 -3
  9. package/dist/content/provider.d.ts +32 -8
  10. package/dist/content/provider.js +26 -16
  11. package/dist/content/routes.d.ts +6 -12
  12. package/dist/content/routes.js +9 -55
  13. package/dist/content/validation.js +1 -1
  14. package/dist/define-layout.js +5 -1
  15. package/dist/dev/browser.d.ts +1 -1
  16. package/dist/dev/browser.js +1 -1
  17. package/dist/dev/index.d.ts +3 -3
  18. package/dist/dev/index.js +8 -8
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +1 -0
  21. package/dist/next/config.d.ts +14 -0
  22. package/dist/next/config.js +22 -0
  23. package/dist/next/dev-refresh.js +4 -4
  24. package/dist/next/edge-config.d.ts +1 -0
  25. package/dist/next/edge-config.js +92 -0
  26. package/dist/next/index.d.ts +2 -0
  27. package/dist/next/index.js +2 -0
  28. package/dist/next/middleware.js +4 -6
  29. package/dist/next/server.d.ts +5 -24
  30. package/dist/next/server.js +47 -152
  31. package/dist/providers/file.d.ts +11 -17
  32. package/dist/providers/file.js +44 -78
  33. package/dist/providers/s3.d.ts +24 -0
  34. package/dist/providers/s3.js +162 -0
  35. package/dist/sveltekit/index.d.ts +18 -2
  36. package/dist/sveltekit/index.js +35 -4
  37. package/dist/testing/index.d.ts +14 -12
  38. package/dist/testing/index.js +41 -28
  39. package/dist/types/component.d.ts +19 -2
  40. package/dist/types/config.d.ts +4 -0
  41. package/dist/types/image.d.ts +51 -0
  42. package/dist/types/image.js +58 -0
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/index.js +1 -0
  45. package/dist/types/layout.d.ts +12 -0
  46. package/package.json +26 -2
  47. package/src/cli/compile-registry.mjs +162 -0
  48. package/src/cli/css-stub-loader.mjs +27 -0
  49. package/src/cli/generate-registry.mjs +72 -0
  50. package/src/cli/validate-content.mjs +391 -0
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
+ import { GradialImageSchema } from '../types/image.ts';
6
7
 
7
8
  const args = parseArgs(process.argv.slice(2));
8
9
 
@@ -38,6 +39,7 @@ async function compileRegistry(options) {
38
39
  schemaFiles.push(schemaPath);
39
40
  const renderModes = component.renderModes ?? component.render;
40
41
  const varyDimensions = component.varyDimensions ?? component.vary;
42
+ const imageSlots = normalizeImageSlots(component.imageSlots);
41
43
  componentEntries.push({
42
44
  name: component.name,
43
45
  schemaFile,
@@ -53,6 +55,7 @@ async function compileRegistry(options) {
53
55
  },
54
56
  ...(component.defaultIslandMode ? { defaultIslandMode: component.defaultIslandMode } : {}),
55
57
  ...(varyDimensions?.length ? { vary: varyDimensions, varyDimensions } : {}),
58
+ ...(Object.keys(imageSlots).length ? { imageSlots } : {}),
56
59
  });
57
60
  }
58
61
 
@@ -84,6 +87,7 @@ function validateComponent(component, index, seen) {
84
87
  seen.add(component.name);
85
88
  if (!component.schema) throw new Error(`components[${index}].schema is required`);
86
89
  validateZodSubset(component.schema, `components[${index}].schema`);
90
+ validateImageSlotSchemaMatch(component, index);
87
91
  const renderModes = component.renderModes ?? component.render;
88
92
  if (!renderModes) throw new Error(`components[${index}].renderModes is required`);
89
93
  const enabled = Boolean(renderModes.canStatic ?? renderModes.static)
@@ -92,6 +96,164 @@ function validateComponent(component, index, seen) {
92
96
  if (!enabled) throw new Error(`components[${index}].renderModes must enable at least one mode`);
93
97
  }
94
98
 
99
+ function validateImageSlotSchemaMatch(component, index) {
100
+ const imageSlots = normalizeImageSlots(component.imageSlots);
101
+ const imageFields = collectGradialImageFields(component.schema);
102
+ for (const field of imageFields) {
103
+ if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
104
+ throw new Error(`components[${index}].imageSlots.${field} is required because schema field ${field} uses GradialImageSchema`);
105
+ }
106
+ }
107
+ for (const field of Object.keys(imageSlots)) {
108
+ if (!imageFields.has(field)) {
109
+ throw new Error(`components[${index}].schema.${field} must use GradialImageSchema because imageSlots.${field} is declared`);
110
+ }
111
+ }
112
+ }
113
+
114
+ function normalizeImageSlots(imageSlots) {
115
+ if (imageSlots == null) return {};
116
+ if (typeof imageSlots !== 'object' || Array.isArray(imageSlots)) {
117
+ throw new Error('imageSlots must be an object');
118
+ }
119
+ const out = {};
120
+ for (const [key, slot] of Object.entries(imageSlots)) {
121
+ if (!key) throw new Error('imageSlots keys must be non-empty');
122
+ if (!slot || typeof slot !== 'object' || Array.isArray(slot)) {
123
+ throw new Error(`imageSlots.${key} must be an object`);
124
+ }
125
+ if (!Array.isArray(slot.outputs) || !slot.outputs.length) {
126
+ throw new Error(`imageSlots.${key}.outputs must be a non-empty array`);
127
+ }
128
+ if (!Array.isArray(slot.formats) || !slot.formats.length) {
129
+ throw new Error(`imageSlots.${key}.formats must be a non-empty array`);
130
+ }
131
+ if (typeof slot.sizes !== 'string' || !slot.sizes.trim()) {
132
+ throw new Error(`imageSlots.${key}.sizes must be a non-empty string`);
133
+ }
134
+ out[key] = {
135
+ outputs: slot.outputs.map((output, outputIndex) => normalizeSlotOutput(key, output, outputIndex)),
136
+ formats: slot.formats.map((format, formatIndex) => {
137
+ if (typeof format !== 'string' || !format.trim()) {
138
+ throw new Error(`imageSlots.${key}.formats[${formatIndex}] must be a non-empty string`);
139
+ }
140
+ return format;
141
+ }),
142
+ sizes: slot.sizes,
143
+ };
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function normalizeSlotOutput(slotKey, output, outputIndex) {
149
+ if (!output || typeof output !== 'object' || Array.isArray(output)) {
150
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}] must be an object`);
151
+ }
152
+ if (typeof output.aspectRatio !== 'string' || !output.aspectRatio.trim()) {
153
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].aspectRatio must be a non-empty string`);
154
+ }
155
+ if (!Array.isArray(output.widths) || !output.widths.length) {
156
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths must be a non-empty array`);
157
+ }
158
+ const widths = output.widths.map((width, widthIndex) => {
159
+ if (!Number.isInteger(width) || width <= 0) {
160
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths[${widthIndex}] must be a positive integer`);
161
+ }
162
+ return width;
163
+ });
164
+ return {
165
+ aspectRatio: output.aspectRatio,
166
+ widths,
167
+ ...(typeof output.media === 'string' && output.media.trim() ? { media: output.media } : {}),
168
+ };
169
+ }
170
+
171
+ function collectGradialImageFields(schema) {
172
+ const out = new Set();
173
+
174
+ collectGradialImageFieldsFromSchema(schema, '', out);
175
+
176
+ return out;
177
+ }
178
+
179
+ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
180
+ if (!schema) {
181
+ return;
182
+ }
183
+
184
+ if (prefix && isGradialImageSchema(schema)) {
185
+ out.add(prefix);
186
+ return;
187
+ }
188
+
189
+ const itemSchema = arrayItemSchema(schema);
190
+ if (itemSchema) {
191
+ collectGradialImageFieldsFromSchema(itemSchema, prefix, out);
192
+ return;
193
+ }
194
+
195
+ const def = unwrapSchema(schema)?._def;
196
+ const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape;
197
+ if (!shape || typeof shape !== 'object') {
198
+ return;
199
+ }
200
+
201
+ for (const [field, fieldSchema] of Object.entries(shape)) {
202
+ const nextPrefix = prefix ? `${prefix}.${field}` : field;
203
+ collectGradialImageFieldsFromSchema(fieldSchema, nextPrefix, out);
204
+ }
205
+ }
206
+
207
+ function arrayItemSchema(schema) {
208
+ let current = schema;
209
+ const seen = new Set();
210
+ while (current && !seen.has(current)) {
211
+ seen.add(current);
212
+ const def = current?._def;
213
+ if (!def) {
214
+ // Zod 4 mini: check for ~standard array type
215
+ if (current?.type === 'array' && current?.element) return current.element;
216
+ return null;
217
+ }
218
+ if (def.typeName === 'ZodArray') return def.type;
219
+ // Zod 4: array element might be in def.element
220
+ if (def.type === 'array' && def.element) return def.element;
221
+ current = def.innerType ?? def.schema;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function isGradialImageSchema(schema) {
227
+ const unwrapped = unwrapSchema(schema);
228
+ if (unwrapped === GradialImageSchema) return true;
229
+ try {
230
+ const compiled = z.toJSONSchema(unwrapped, {
231
+ target: 'draft-2020-12',
232
+ unrepresentable: 'throw',
233
+ });
234
+ return compiled?.type === 'object'
235
+ && compiled?.properties?.$type?.const === 'gradial.image'
236
+ && compiled?.properties?.fallback?.type === 'object'
237
+ && compiled?.properties?.sources?.type === 'array';
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ function unwrapSchema(schema) {
244
+ let current = schema;
245
+ const seen = new Set();
246
+ while (current && !seen.has(current)) {
247
+ seen.add(current);
248
+ if (current === GradialImageSchema) return current;
249
+ const def = current._def;
250
+ const next = def?.innerType ?? def?.schema;
251
+ if (!next) break;
252
+ current = next;
253
+ }
254
+ return current;
255
+ }
256
+
95
257
  function validateLayout(layout, index, seen) {
96
258
  if (!layout || typeof layout !== 'object') throw new Error(`layouts[${index}] must be an object`);
97
259
  if (!layout.name) throw new Error(`layouts[${index}].name is required`);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Node.js custom loader that stubs CSS module imports.
3
+ * Used by CLI tools (compile-registry, validate-content) that need to import
4
+ * component files without actually loading CSS.
5
+ *
6
+ * Usage: node --import tsx --loader ./css-stub-loader.mjs <script>
7
+ */
8
+
9
+ const CSS_EXTENSIONS = ['.css', '.module.css', '.scss', '.module.scss', '.less'];
10
+
11
+ export function resolve(specifier, context, nextResolve) {
12
+ if (CSS_EXTENSIONS.some((ext) => specifier.endsWith(ext))) {
13
+ return { url: `css-stub:${specifier}`, shortCircuit: true };
14
+ }
15
+ return nextResolve(specifier, context);
16
+ }
17
+
18
+ export function load(url, context, nextLoad) {
19
+ if (url.startsWith('css-stub:')) {
20
+ return {
21
+ format: 'module',
22
+ source: 'export default {};',
23
+ shortCircuit: true,
24
+ };
25
+ }
26
+ return nextLoad(url, context);
27
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-registry — Derives a runtime registry from co-located component contracts.
4
+ *
5
+ * Usage:
6
+ * baremetal generate-registry --contracts <path> --output <path>
7
+ *
8
+ * --contracts Path to the aggregation module (default-exports an array of ComponentContract).
9
+ * --output Path to the generated TypeScript file.
10
+ *
11
+ * The generated file imports the contracts module and derives:
12
+ * - `registry`: a flat map of component name -> component function (for the renderer)
13
+ * - `contractMap`: a flat map of component name -> full contract (for tooling/validation)
14
+ */
15
+ import { writeFile, mkdir } from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ const args = parseArgs(process.argv.slice(2));
19
+
20
+ if (!args.contracts) {
21
+ console.error('Error: --contracts <path> is required');
22
+ process.exitCode = 1;
23
+ } else if (!args.output) {
24
+ console.error('Error: --output <path> is required');
25
+ process.exitCode = 1;
26
+ } else {
27
+ try {
28
+ await generateRegistry(args);
29
+ console.log(`Registry generated at ${args.output}`);
30
+ } catch (error) {
31
+ console.error(`Error: ${error?.message ?? error}`);
32
+ process.exitCode = 1;
33
+ }
34
+ }
35
+
36
+ async function generateRegistry({ contracts, output }) {
37
+ const contractsPath = contracts.replace(/\.(ts|tsx|js|mjs)$/, '');
38
+ const outputPath = path.resolve(output);
39
+
40
+ await mkdir(path.dirname(outputPath), { recursive: true });
41
+
42
+ const content = `// AUTO-GENERATED — do not edit. Run \`baremetal generate-registry\` to regenerate.
43
+ import type { ComponentContract } from '@baremetal/sdk';
44
+ import contracts from '${contractsPath}';
45
+
46
+ export const registry = Object.fromEntries(
47
+ contracts.filter((c) => c.component != null).map((c) => [c.name, c.component]),
48
+ );
49
+
50
+ export const contractMap: Record<string, ComponentContract> = Object.fromEntries(
51
+ contracts.map((c) => [c.name, c]),
52
+ );
53
+ `;
54
+
55
+ await writeFile(outputPath, content, 'utf-8');
56
+ }
57
+
58
+ function parseArgs(argv) {
59
+ const out = {};
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const arg = argv[i];
62
+ if (!arg.startsWith('--')) continue;
63
+ const body = arg.slice(2);
64
+ const equals = body.indexOf('=');
65
+ if (equals >= 0) {
66
+ out[body.slice(0, equals)] = body.slice(equals + 1);
67
+ continue;
68
+ }
69
+ out[body] = argv[++i];
70
+ }
71
+ return out;
72
+ }
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-content — validate authored page content against compiled JSON schemas.
4
+ *
5
+ * Usage:
6
+ * node validate-content.mjs \
7
+ * --registry <path-to-compiled-registry-dir> \
8
+ * --compiled <path-to-compiled-content-dir>
9
+ *
10
+ * Prerequisites:
11
+ * 1. Run compile-registry to generate registry.json + schemas/*.schema.json
12
+ * 2. Run content build to generate compiled routes + fragments
13
+ *
14
+ * Validates every block in the COMPILED output against JSON schemas.
15
+ * This validates the build result — after DAM resolution, fragment inlining, etc.
16
+ * Fails with exit code 1 if any published page has validation errors.
17
+ * Warns (but does not fail) for draft pages.
18
+ *
19
+ * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
+ * imports needed, same schemas usable by Go backend or other tools.
21
+ */
22
+ import { readdir, readFile } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+
25
+ const args = parseArgs(process.argv.slice(2));
26
+
27
+ try {
28
+ const result = await validateContent(args);
29
+ console.log(JSON.stringify(result, null, 2));
30
+ if (!result.success) process.exitCode = 1;
31
+ } catch (error) {
32
+ console.error(JSON.stringify({
33
+ success: false,
34
+ errors: [{ message: String(error?.message ?? error) }],
35
+ }, null, 2));
36
+ process.exitCode = 1;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Main
41
+ // ---------------------------------------------------------------------------
42
+
43
+ async function validateContent(options) {
44
+ if (!options.registry) throw new Error('missing --registry (path to compiled registry directory)');
45
+ if (!options.compiled) throw new Error('missing --compiled (path to compiled content output directory)');
46
+
47
+ const registryDir = path.resolve(options.registry);
48
+ const contentRoot = path.resolve(options.compiled);
49
+
50
+ // Load compiled registry
51
+ const registryPath = path.join(registryDir, 'registry.json');
52
+ const registryRaw = await readFile(registryPath, 'utf-8');
53
+ const registry = JSON.parse(registryRaw);
54
+
55
+ // Build schema map: component name → JSON Schema object
56
+ const schemaMap = new Map();
57
+ const knownComponents = new Set();
58
+ for (const component of registry.components) {
59
+ knownComponents.add(component.name);
60
+ if (component.schemaFile) {
61
+ const schemaPath = path.join(registryDir, component.schemaFile);
62
+ const schemaRaw = await readFile(schemaPath, 'utf-8');
63
+ schemaMap.set(component.name, JSON.parse(schemaRaw));
64
+ }
65
+ }
66
+
67
+ // Find all compiled route files
68
+ const routesDir = path.join(contentRoot, 'routes');
69
+ const pageFiles = await findJsonFiles(routesDir);
70
+
71
+ const errors = [];
72
+ const warnings = [];
73
+ let pagesValidated = 0;
74
+ let blocksValidated = 0;
75
+
76
+ for (const pageFile of pageFiles) {
77
+ const relativePath = path.relative(contentRoot, pageFile);
78
+ let pageData;
79
+ try {
80
+ const raw = await readFile(pageFile, 'utf-8');
81
+ pageData = JSON.parse(raw);
82
+ } catch (err) {
83
+ errors.push({ page: relativePath, message: `failed to parse JSON: ${err.message}` });
84
+ continue;
85
+ }
86
+
87
+ pagesValidated++;
88
+ const isPublished = pageData.status === 'published';
89
+ const collector = isPublished ? errors : warnings;
90
+
91
+ const blocks = collectBlocks(pageData);
92
+
93
+ for (const block of blocks) {
94
+ blocksValidated++;
95
+
96
+ if (!block.component) {
97
+ collector.push({
98
+ page: relativePath,
99
+ blockId: block.id ?? '<unknown>',
100
+ message: 'block is missing "component" field',
101
+ });
102
+ continue;
103
+ }
104
+
105
+ // Check component exists in registry
106
+ if (!knownComponents.has(block.component)) {
107
+ collector.push({
108
+ page: relativePath,
109
+ blockId: block.id ?? '<unknown>',
110
+ component: block.component,
111
+ message: `unknown component: "${block.component}"`,
112
+ });
113
+ continue;
114
+ }
115
+
116
+ // Validate props against JSON Schema
117
+ const schema = schemaMap.get(block.component);
118
+ if (!schema) continue; // no schema file — component registered but no schema to check
119
+
120
+ const props = block.props ?? {};
121
+ const issues = validateJsonSchema(props, schema, 'props');
122
+ for (const issue of issues) {
123
+ collector.push({
124
+ page: relativePath,
125
+ blockId: block.id ?? '<unknown>',
126
+ component: block.component,
127
+ path: issue.path,
128
+ message: issue.message,
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // Also validate fragments
135
+ const fragmentsDir = path.join(contentRoot, 'fragments');
136
+ const fragmentFiles = await findJsonFiles(fragmentsDir);
137
+
138
+ for (const fragmentFile of fragmentFiles) {
139
+ const relativePath = path.relative(contentRoot, fragmentFile);
140
+ let fragmentData;
141
+ try {
142
+ const raw = await readFile(fragmentFile, 'utf-8');
143
+ fragmentData = JSON.parse(raw);
144
+ } catch (err) {
145
+ errors.push({ fragment: relativePath, message: `failed to parse JSON: ${err.message}` });
146
+ continue;
147
+ }
148
+
149
+ if (!fragmentData.component) continue;
150
+ blocksValidated++;
151
+
152
+ if (!knownComponents.has(fragmentData.component)) {
153
+ errors.push({
154
+ fragment: relativePath,
155
+ component: fragmentData.component,
156
+ message: `unknown component: "${fragmentData.component}"`,
157
+ });
158
+ continue;
159
+ }
160
+
161
+ const schema = schemaMap.get(fragmentData.component);
162
+ if (!schema) continue;
163
+
164
+ const props = fragmentData.props ?? {};
165
+ const issues = validateJsonSchema(props, schema, 'props');
166
+ for (const issue of issues) {
167
+ errors.push({
168
+ fragment: relativePath,
169
+ component: fragmentData.component,
170
+ path: issue.path,
171
+ message: issue.message,
172
+ });
173
+ }
174
+ }
175
+
176
+ const success = errors.length === 0;
177
+ return {
178
+ success,
179
+ pagesValidated,
180
+ blocksValidated,
181
+ errors,
182
+ ...(warnings.length > 0 ? { warnings } : {}),
183
+ };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Lightweight JSON Schema draft-2020-12 validator
188
+ //
189
+ // Covers the subset that Zod generates via z.toJSONSchema:
190
+ // type, required, properties, additionalProperties,
191
+ // minLength, minimum, maximum, enum, const,
192
+ // items, minItems, maxItems, anyOf, allOf, $ref (local)
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function validateJsonSchema(value, schema, pathPrefix = '') {
196
+ const issues = [];
197
+ _validate(value, schema, pathPrefix, issues, schema);
198
+ return issues;
199
+ }
200
+
201
+ function _validate(value, schema, currentPath, issues, rootSchema) {
202
+ if (!schema || typeof schema !== 'object') return;
203
+
204
+ // $ref — resolve local references
205
+ if (schema.$ref) {
206
+ const resolved = resolveRef(schema.$ref, rootSchema);
207
+ if (resolved) {
208
+ _validate(value, resolved, currentPath, issues, rootSchema);
209
+ }
210
+ return;
211
+ }
212
+
213
+ // anyOf — value must match at least one
214
+ if (Array.isArray(schema.anyOf)) {
215
+ const anyMatches = schema.anyOf.some((sub) => {
216
+ const subIssues = [];
217
+ _validate(value, sub, currentPath, subIssues, rootSchema);
218
+ return subIssues.length === 0;
219
+ });
220
+ if (!anyMatches) {
221
+ issues.push({ path: currentPath, message: `${currentPath}: does not match any allowed schema` });
222
+ }
223
+ return;
224
+ }
225
+
226
+ // allOf — value must match all
227
+ if (Array.isArray(schema.allOf)) {
228
+ for (const sub of schema.allOf) {
229
+ _validate(value, sub, currentPath, issues, rootSchema);
230
+ }
231
+ return;
232
+ }
233
+
234
+ // const
235
+ if ('const' in schema) {
236
+ if (value !== schema.const) {
237
+ issues.push({ path: currentPath, message: `${currentPath}: expected constant "${schema.const}", got ${JSON.stringify(value)}` });
238
+ }
239
+ return;
240
+ }
241
+
242
+ // enum
243
+ if (Array.isArray(schema.enum)) {
244
+ if (!schema.enum.includes(value)) {
245
+ issues.push({ path: currentPath, message: `${currentPath}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}` });
246
+ }
247
+ return;
248
+ }
249
+
250
+ // type checking
251
+ const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null;
252
+ if (types) {
253
+ const actualType = getJsonType(value);
254
+ const typeMatch = types.some((t) =>
255
+ t === actualType || (t === 'integer' && actualType === 'number' && Number.isInteger(value))
256
+ );
257
+ if (!typeMatch) {
258
+ // Allow null for nullable schemas
259
+ if (value !== null || !types.includes('null')) {
260
+ issues.push({ path: currentPath, message: `${currentPath}: expected type ${types.join('|')}, got ${actualType}` });
261
+ return; // Stop further checks if type is wrong
262
+ }
263
+ }
264
+ }
265
+
266
+ if (value === null || value === undefined) return;
267
+
268
+ // String checks
269
+ if (typeof value === 'string') {
270
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
271
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} is less than minLength ${schema.minLength}` });
272
+ }
273
+ if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
274
+ issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} exceeds maxLength ${schema.maxLength}` });
275
+ }
276
+ }
277
+
278
+ // Number checks
279
+ if (typeof value === 'number') {
280
+ if (typeof schema.minimum === 'number' && value < schema.minimum) {
281
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} is less than minimum ${schema.minimum}` });
282
+ }
283
+ if (typeof schema.maximum === 'number' && value > schema.maximum) {
284
+ issues.push({ path: currentPath, message: `${currentPath}: ${value} exceeds maximum ${schema.maximum}` });
285
+ }
286
+ }
287
+
288
+ // Array checks
289
+ if (Array.isArray(value)) {
290
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
291
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, minimum is ${schema.minItems}` });
292
+ }
293
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
294
+ issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, maximum is ${schema.maxItems}` });
295
+ }
296
+ if (schema.items) {
297
+ for (let i = 0; i < value.length; i++) {
298
+ _validate(value[i], schema.items, `${currentPath}[${i}]`, issues, rootSchema);
299
+ }
300
+ }
301
+ }
302
+
303
+ // Object checks
304
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
305
+ // Required fields
306
+ if (Array.isArray(schema.required)) {
307
+ for (const key of schema.required) {
308
+ if (!(key in value) || value[key] === undefined) {
309
+ issues.push({ path: `${currentPath}.${key}`, message: `${currentPath}.${key}: required field is missing` });
310
+ }
311
+ }
312
+ }
313
+ // Properties
314
+ if (schema.properties) {
315
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
316
+ if (key in value) {
317
+ _validate(value[key], propSchema, `${currentPath}.${key}`, issues, rootSchema);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ }
323
+
324
+ function getJsonType(value) {
325
+ if (value === null) return 'null';
326
+ if (Array.isArray(value)) return 'array';
327
+ return typeof value;
328
+ }
329
+
330
+ function resolveRef(ref, rootSchema) {
331
+ if (!ref.startsWith('#/')) return null;
332
+ const parts = ref.slice(2).split('/');
333
+ let current = rootSchema;
334
+ for (const part of parts) {
335
+ current = current?.[part];
336
+ }
337
+ return current ?? null;
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Content helpers
342
+ // ---------------------------------------------------------------------------
343
+
344
+ function collectBlocks(pageData) {
345
+ const blocks = [];
346
+ if (pageData.regions && typeof pageData.regions === 'object') {
347
+ for (const region of Object.values(pageData.regions)) {
348
+ if (Array.isArray(region)) blocks.push(...region);
349
+ else if (region && typeof region === 'object' && Array.isArray(region.children)) blocks.push(...region.children);
350
+ }
351
+ }
352
+ if (Array.isArray(pageData.blocks)) blocks.push(...pageData.blocks);
353
+ return blocks;
354
+ }
355
+
356
+ async function findPageFiles(dir) {
357
+ return findFilesMatching(dir, (name) => name === '_index.json');
358
+ }
359
+
360
+ async function findJsonFiles(dir) {
361
+ return findFilesMatching(dir, (name) => name.endsWith('.json'));
362
+ }
363
+
364
+ async function findFilesMatching(dir, predicate) {
365
+ const results = [];
366
+ try { await collectFiles(dir, predicate, results); }
367
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
368
+ return results;
369
+ }
370
+
371
+ async function collectFiles(dir, predicate, results) {
372
+ const entries = await readdir(dir, { withFileTypes: true });
373
+ for (const entry of entries) {
374
+ const full = path.join(dir, entry.name);
375
+ if (entry.isDirectory()) await collectFiles(full, predicate, results);
376
+ else if (predicate(entry.name)) results.push(full);
377
+ }
378
+ }
379
+
380
+ function parseArgs(argv) {
381
+ const out = {};
382
+ for (let i = 0; i < argv.length; i++) {
383
+ const arg = argv[i];
384
+ if (!arg.startsWith('--')) continue;
385
+ const body = arg.slice(2);
386
+ const equals = body.indexOf('=');
387
+ if (equals >= 0) { out[body.slice(0, equals)] = body.slice(equals + 1); continue; }
388
+ out[body] = argv[++i];
389
+ }
390
+ return out;
391
+ }