@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.
- package/README.md +1 -1
- package/bin/aci.js +45 -0
- package/dist/assets/index.d.ts +1 -0
- package/dist/assets/index.js +1 -0
- package/dist/astro/index.d.ts +24 -2
- package/dist/astro/index.js +42 -4
- package/dist/content/index.d.ts +0 -3
- package/dist/content/index.js +0 -3
- package/dist/content/provider.d.ts +32 -8
- package/dist/content/provider.js +26 -16
- package/dist/content/routes.d.ts +6 -12
- package/dist/content/routes.js +9 -55
- package/dist/content/validation.js +1 -1
- package/dist/define-layout.js +5 -1
- package/dist/dev/browser.d.ts +1 -1
- package/dist/dev/browser.js +1 -1
- package/dist/dev/index.d.ts +3 -3
- package/dist/dev/index.js +8 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/next/config.d.ts +14 -0
- package/dist/next/config.js +22 -0
- package/dist/next/dev-refresh.js +4 -4
- package/dist/next/edge-config.d.ts +1 -0
- package/dist/next/edge-config.js +92 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/middleware.js +4 -6
- package/dist/next/server.d.ts +5 -24
- package/dist/next/server.js +47 -152
- package/dist/providers/file.d.ts +11 -17
- package/dist/providers/file.js +44 -78
- package/dist/providers/s3.d.ts +24 -0
- package/dist/providers/s3.js +162 -0
- package/dist/sveltekit/index.d.ts +18 -2
- package/dist/sveltekit/index.js +35 -4
- package/dist/testing/index.d.ts +14 -12
- package/dist/testing/index.js +41 -28
- package/dist/types/component.d.ts +19 -2
- package/dist/types/config.d.ts +4 -0
- package/dist/types/image.d.ts +51 -0
- package/dist/types/image.js +58 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/layout.d.ts +12 -0
- package/package.json +26 -2
- package/src/cli/compile-registry.mjs +162 -0
- package/src/cli/css-stub-loader.mjs +27 -0
- package/src/cli/generate-registry.mjs +72 -0
- 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
|
+
}
|