@gradial/aci 0.1.0 → 0.1.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/README.md +47 -2
- package/bin/aci +0 -0
- package/bin/aci.js +157 -0
- package/dist/assets/index.d.ts +3 -0
- package/dist/assets/index.js +3 -0
- package/dist/astro/index.d.ts +24 -2
- package/dist/astro/index.js +42 -4
- package/dist/block-ref.d.ts +34 -0
- package/dist/block-ref.js +34 -0
- 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-component.d.ts +1 -0
- package/dist/define-component.js +1 -0
- package/dist/define-layout.d.ts +1 -0
- package/dist/define-layout.js +6 -1
- package/dist/dev/browser.d.ts +1 -1
- package/dist/dev/browser.js +1 -1
- package/dist/dev/index.d.ts +9 -3
- package/dist/dev/index.js +74 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/next/asset-route.d.ts +9 -0
- package/dist/next/asset-route.js +15 -0
- package/dist/next/config.d.ts +6 -0
- package/dist/next/config.js +25 -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 +9 -24
- package/dist/next/server.js +100 -152
- package/dist/providers/file.d.ts +11 -17
- package/dist/providers/file.js +44 -78
- package/dist/providers/s3.d.ts +26 -0
- package/dist/providers/s3.js +174 -0
- package/dist/react/GradialMedia.d.ts +24 -0
- package/dist/react/GradialMedia.js +31 -0
- package/dist/react/GradialPicture.d.ts +14 -0
- package/dist/react/GradialPicture.js +30 -0
- package/dist/react/GradialVideoPlayer.d.ts +13 -0
- package/dist/react/GradialVideoPlayer.js +28 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.js +3 -0
- package/dist/sveltekit/index.d.ts +18 -2
- package/dist/sveltekit/index.js +40 -4
- package/dist/testing/index.d.ts +14 -12
- package/dist/testing/index.js +41 -28
- package/dist/types/component.d.ts +24 -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 +3 -0
- package/dist/types/index.js +3 -0
- package/dist/types/layout.d.ts +12 -0
- package/dist/types/media.d.ts +69 -0
- package/dist/types/media.js +86 -0
- package/dist/types/video.d.ts +70 -0
- package/dist/types/video.js +22 -0
- package/package.json +30 -2
- package/src/cli/compile-registry.mjs +303 -0
- package/src/cli/validate-content.mjs +489 -0
|
@@ -0,0 +1,489 @@
|
|
|
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
|
+
// Build nested blocks map: component name → array of { path, allow? }
|
|
57
|
+
const schemaMap = new Map();
|
|
58
|
+
const knownComponents = new Set();
|
|
59
|
+
const nestedBlocksMap = new Map();
|
|
60
|
+
for (const component of registry.components) {
|
|
61
|
+
knownComponents.add(component.name);
|
|
62
|
+
if (component.schemaFile) {
|
|
63
|
+
const schemaPath = path.join(registryDir, component.schemaFile);
|
|
64
|
+
const schemaRaw = await readFile(schemaPath, 'utf-8');
|
|
65
|
+
schemaMap.set(component.name, JSON.parse(schemaRaw));
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(component.nestedBlocks) && component.nestedBlocks.length > 0) {
|
|
68
|
+
nestedBlocksMap.set(component.name, component.nestedBlocks);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Find all compiled route files
|
|
73
|
+
const routesDir = path.join(contentRoot, 'routes');
|
|
74
|
+
const pageFiles = await findJsonFiles(routesDir);
|
|
75
|
+
|
|
76
|
+
const errors = [];
|
|
77
|
+
const warnings = [];
|
|
78
|
+
let pagesValidated = 0;
|
|
79
|
+
let blocksValidated = 0;
|
|
80
|
+
|
|
81
|
+
for (const pageFile of pageFiles) {
|
|
82
|
+
const relativePath = path.relative(contentRoot, pageFile);
|
|
83
|
+
let pageData;
|
|
84
|
+
try {
|
|
85
|
+
const raw = await readFile(pageFile, 'utf-8');
|
|
86
|
+
pageData = JSON.parse(raw);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
errors.push({ page: relativePath, message: `failed to parse JSON: ${err.message}` });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pagesValidated++;
|
|
93
|
+
const isPublished = pageData.status === 'published';
|
|
94
|
+
const collector = isPublished ? errors : warnings;
|
|
95
|
+
|
|
96
|
+
const blocks = collectBlocks(pageData);
|
|
97
|
+
|
|
98
|
+
for (const block of blocks) {
|
|
99
|
+
blocksValidated++;
|
|
100
|
+
|
|
101
|
+
if (!block.component) {
|
|
102
|
+
collector.push({
|
|
103
|
+
page: relativePath,
|
|
104
|
+
blockId: block.id ?? '<unknown>',
|
|
105
|
+
message: 'block is missing "component" field',
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check component exists in registry
|
|
111
|
+
if (!knownComponents.has(block.component)) {
|
|
112
|
+
collector.push({
|
|
113
|
+
page: relativePath,
|
|
114
|
+
blockId: block.id ?? '<unknown>',
|
|
115
|
+
component: block.component,
|
|
116
|
+
message: `unknown component: "${block.component}"`,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Validate props against JSON Schema
|
|
122
|
+
const schema = schemaMap.get(block.component);
|
|
123
|
+
if (!schema) continue; // no schema file — component registered but no schema to check
|
|
124
|
+
|
|
125
|
+
const props = block.props ?? {};
|
|
126
|
+
const issues = validateJsonSchema(props, schema, 'props');
|
|
127
|
+
for (const issue of issues) {
|
|
128
|
+
collector.push({
|
|
129
|
+
page: relativePath,
|
|
130
|
+
blockId: block.id ?? '<unknown>',
|
|
131
|
+
component: block.component,
|
|
132
|
+
path: issue.path,
|
|
133
|
+
message: issue.message,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Recursively validate nested blocks (blockRef patterns)
|
|
138
|
+
const nestedIssues = validateNestedBlocks(
|
|
139
|
+
block.component, props, knownComponents, schemaMap, nestedBlocksMap,
|
|
140
|
+
);
|
|
141
|
+
for (const issue of nestedIssues) {
|
|
142
|
+
collector.push({
|
|
143
|
+
page: relativePath,
|
|
144
|
+
blockId: block.id ?? '<unknown>',
|
|
145
|
+
component: block.component,
|
|
146
|
+
...issue,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Also validate fragments
|
|
153
|
+
const fragmentsDir = path.join(contentRoot, 'fragments');
|
|
154
|
+
const fragmentFiles = await findJsonFiles(fragmentsDir);
|
|
155
|
+
|
|
156
|
+
for (const fragmentFile of fragmentFiles) {
|
|
157
|
+
const relativePath = path.relative(contentRoot, fragmentFile);
|
|
158
|
+
let fragmentData;
|
|
159
|
+
try {
|
|
160
|
+
const raw = await readFile(fragmentFile, 'utf-8');
|
|
161
|
+
fragmentData = JSON.parse(raw);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
errors.push({ fragment: relativePath, message: `failed to parse JSON: ${err.message}` });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!fragmentData.component) continue;
|
|
168
|
+
blocksValidated++;
|
|
169
|
+
|
|
170
|
+
if (!knownComponents.has(fragmentData.component)) {
|
|
171
|
+
errors.push({
|
|
172
|
+
fragment: relativePath,
|
|
173
|
+
component: fragmentData.component,
|
|
174
|
+
message: `unknown component: "${fragmentData.component}"`,
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const schema = schemaMap.get(fragmentData.component);
|
|
180
|
+
if (!schema) continue;
|
|
181
|
+
|
|
182
|
+
const props = fragmentData.props ?? {};
|
|
183
|
+
const issues = validateJsonSchema(props, schema, 'props');
|
|
184
|
+
for (const issue of issues) {
|
|
185
|
+
errors.push({
|
|
186
|
+
fragment: relativePath,
|
|
187
|
+
component: fragmentData.component,
|
|
188
|
+
path: issue.path,
|
|
189
|
+
message: issue.message,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const success = errors.length === 0;
|
|
195
|
+
return {
|
|
196
|
+
success,
|
|
197
|
+
pagesValidated,
|
|
198
|
+
blocksValidated,
|
|
199
|
+
errors,
|
|
200
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Lightweight JSON Schema draft-2020-12 validator
|
|
206
|
+
//
|
|
207
|
+
// Covers the subset that Zod generates via z.toJSONSchema:
|
|
208
|
+
// type, required, properties, additionalProperties,
|
|
209
|
+
// minLength, minimum, maximum, enum, const,
|
|
210
|
+
// items, minItems, maxItems, anyOf, allOf, $ref (local)
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function validateJsonSchema(value, schema, pathPrefix = '') {
|
|
214
|
+
const issues = [];
|
|
215
|
+
_validate(value, schema, pathPrefix, issues, schema);
|
|
216
|
+
return issues;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _validate(value, schema, currentPath, issues, rootSchema) {
|
|
220
|
+
if (!schema || typeof schema !== 'object') return;
|
|
221
|
+
|
|
222
|
+
// $ref — resolve local references
|
|
223
|
+
if (schema.$ref) {
|
|
224
|
+
const resolved = resolveRef(schema.$ref, rootSchema);
|
|
225
|
+
if (resolved) {
|
|
226
|
+
_validate(value, resolved, currentPath, issues, rootSchema);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// anyOf — value must match at least one
|
|
232
|
+
if (Array.isArray(schema.anyOf)) {
|
|
233
|
+
const anyMatches = schema.anyOf.some((sub) => {
|
|
234
|
+
const subIssues = [];
|
|
235
|
+
_validate(value, sub, currentPath, subIssues, rootSchema);
|
|
236
|
+
return subIssues.length === 0;
|
|
237
|
+
});
|
|
238
|
+
if (!anyMatches) {
|
|
239
|
+
issues.push({ path: currentPath, message: `${currentPath}: does not match any allowed schema` });
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// allOf — value must match all
|
|
245
|
+
if (Array.isArray(schema.allOf)) {
|
|
246
|
+
for (const sub of schema.allOf) {
|
|
247
|
+
_validate(value, sub, currentPath, issues, rootSchema);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// const
|
|
253
|
+
if ('const' in schema) {
|
|
254
|
+
if (value !== schema.const) {
|
|
255
|
+
issues.push({ path: currentPath, message: `${currentPath}: expected constant "${schema.const}", got ${JSON.stringify(value)}` });
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// enum
|
|
261
|
+
if (Array.isArray(schema.enum)) {
|
|
262
|
+
if (!schema.enum.includes(value)) {
|
|
263
|
+
issues.push({ path: currentPath, message: `${currentPath}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}` });
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// type checking
|
|
269
|
+
const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null;
|
|
270
|
+
if (types) {
|
|
271
|
+
const actualType = getJsonType(value);
|
|
272
|
+
const typeMatch = types.some((t) =>
|
|
273
|
+
t === actualType || (t === 'integer' && actualType === 'number' && Number.isInteger(value))
|
|
274
|
+
);
|
|
275
|
+
if (!typeMatch) {
|
|
276
|
+
// Allow null for nullable schemas
|
|
277
|
+
if (value !== null || !types.includes('null')) {
|
|
278
|
+
issues.push({ path: currentPath, message: `${currentPath}: expected type ${types.join('|')}, got ${actualType}` });
|
|
279
|
+
return; // Stop further checks if type is wrong
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (value === null || value === undefined) return;
|
|
285
|
+
|
|
286
|
+
// String checks
|
|
287
|
+
if (typeof value === 'string') {
|
|
288
|
+
if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
|
|
289
|
+
issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} is less than minLength ${schema.minLength}` });
|
|
290
|
+
}
|
|
291
|
+
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
|
|
292
|
+
issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} exceeds maxLength ${schema.maxLength}` });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Number checks
|
|
297
|
+
if (typeof value === 'number') {
|
|
298
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
299
|
+
issues.push({ path: currentPath, message: `${currentPath}: ${value} is less than minimum ${schema.minimum}` });
|
|
300
|
+
}
|
|
301
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
302
|
+
issues.push({ path: currentPath, message: `${currentPath}: ${value} exceeds maximum ${schema.maximum}` });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Array checks
|
|
307
|
+
if (Array.isArray(value)) {
|
|
308
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
309
|
+
issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, minimum is ${schema.minItems}` });
|
|
310
|
+
}
|
|
311
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
312
|
+
issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, maximum is ${schema.maxItems}` });
|
|
313
|
+
}
|
|
314
|
+
if (schema.items) {
|
|
315
|
+
for (let i = 0; i < value.length; i++) {
|
|
316
|
+
_validate(value[i], schema.items, `${currentPath}[${i}]`, issues, rootSchema);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Object checks
|
|
322
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
323
|
+
// Required fields
|
|
324
|
+
if (Array.isArray(schema.required)) {
|
|
325
|
+
for (const key of schema.required) {
|
|
326
|
+
if (!(key in value) || value[key] === undefined) {
|
|
327
|
+
issues.push({ path: `${currentPath}.${key}`, message: `${currentPath}.${key}: required field is missing` });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Properties
|
|
332
|
+
if (schema.properties) {
|
|
333
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
334
|
+
if (key in value) {
|
|
335
|
+
_validate(value[key], propSchema, `${currentPath}.${key}`, issues, rootSchema);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getJsonType(value) {
|
|
343
|
+
if (value === null) return 'null';
|
|
344
|
+
if (Array.isArray(value)) return 'array';
|
|
345
|
+
return typeof value;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function resolveRef(ref, rootSchema) {
|
|
349
|
+
if (!ref.startsWith('#/')) return null;
|
|
350
|
+
const parts = ref.slice(2).split('/');
|
|
351
|
+
let current = rootSchema;
|
|
352
|
+
for (const part of parts) {
|
|
353
|
+
current = current?.[part];
|
|
354
|
+
}
|
|
355
|
+
return current ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Content helpers
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Nested block validation — recursively validates blocks inside blockRef slots
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function validateNestedBlocks(parentComponent, props, knownComponents, schemaMap, nestedBlocksMap) {
|
|
367
|
+
const issues = [];
|
|
368
|
+
// Find all block-shaped arrays in props by walking the data
|
|
369
|
+
const nestedBlocks = findNestedBlockArrays(props);
|
|
370
|
+
for (const { path: blockPath, blocks } of nestedBlocks) {
|
|
371
|
+
for (const [index, nestedBlock] of blocks.entries()) {
|
|
372
|
+
if (!nestedBlock || typeof nestedBlock !== 'object') continue;
|
|
373
|
+
const childComponent = nestedBlock.component;
|
|
374
|
+
if (!childComponent || typeof childComponent !== 'string') continue;
|
|
375
|
+
|
|
376
|
+
const nestedPath = `${blockPath}[${index}]`;
|
|
377
|
+
|
|
378
|
+
// Check nested component exists in registry
|
|
379
|
+
if (!knownComponents.has(childComponent)) {
|
|
380
|
+
issues.push({
|
|
381
|
+
path: `${nestedPath}.component`,
|
|
382
|
+
message: `${parentComponent} → ${nestedPath}: unknown nested component "${childComponent}"`,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Validate nested block's props against its own schema
|
|
388
|
+
const childSchema = schemaMap.get(childComponent);
|
|
389
|
+
if (!childSchema) continue;
|
|
390
|
+
|
|
391
|
+
const childProps = nestedBlock.props ?? {};
|
|
392
|
+
const childIssues = validateJsonSchema(childProps, childSchema, `${nestedPath}.props`);
|
|
393
|
+
for (const issue of childIssues) {
|
|
394
|
+
issues.push({
|
|
395
|
+
nestedComponent: childComponent,
|
|
396
|
+
path: issue.path,
|
|
397
|
+
message: `${parentComponent} → ${childComponent}: ${issue.message}`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Recurse further if the nested component itself has nested blocks
|
|
402
|
+
const deeperIssues = validateNestedBlocks(
|
|
403
|
+
childComponent, childProps, knownComponents, schemaMap, nestedBlocksMap,
|
|
404
|
+
);
|
|
405
|
+
issues.push(...deeperIssues);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return issues;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Walks a props object looking for arrays of block-shaped objects
|
|
413
|
+
* (objects with `component` + `props` fields).
|
|
414
|
+
*/
|
|
415
|
+
function findNestedBlockArrays(obj, prefix = 'props') {
|
|
416
|
+
const results = [];
|
|
417
|
+
if (!obj || typeof obj !== 'object') return results;
|
|
418
|
+
|
|
419
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
420
|
+
const currentPath = `${prefix}.${key}`;
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
// Check if array contains block-shaped objects
|
|
423
|
+
const hasBlocks = value.some(
|
|
424
|
+
(item) => item && typeof item === 'object' && typeof item.component === 'string' && 'props' in item,
|
|
425
|
+
);
|
|
426
|
+
if (hasBlocks) {
|
|
427
|
+
results.push({ path: currentPath, blocks: value });
|
|
428
|
+
}
|
|
429
|
+
// Also recurse into array items that are objects (for nested structures like tabs[].blocks[])
|
|
430
|
+
for (const item of value) {
|
|
431
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
432
|
+
results.push(...findNestedBlockArrays(item, currentPath + '[]'));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
436
|
+
results.push(...findNestedBlockArrays(value, currentPath));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return results;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function collectBlocks(pageData) {
|
|
443
|
+
const blocks = [];
|
|
444
|
+
if (pageData.regions && typeof pageData.regions === 'object') {
|
|
445
|
+
for (const region of Object.values(pageData.regions)) {
|
|
446
|
+
if (Array.isArray(region)) blocks.push(...region);
|
|
447
|
+
else if (region && typeof region === 'object' && Array.isArray(region.children)) blocks.push(...region.children);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (Array.isArray(pageData.blocks)) blocks.push(...pageData.blocks);
|
|
451
|
+
return blocks;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function findPageFiles(dir) {
|
|
455
|
+
return findFilesMatching(dir, (name) => name === '_index.json');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function findJsonFiles(dir) {
|
|
459
|
+
return findFilesMatching(dir, (name) => name.endsWith('.json'));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function findFilesMatching(dir, predicate) {
|
|
463
|
+
const results = [];
|
|
464
|
+
try { await collectFiles(dir, predicate, results); }
|
|
465
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
466
|
+
return results;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function collectFiles(dir, predicate, results) {
|
|
470
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
471
|
+
for (const entry of entries) {
|
|
472
|
+
const full = path.join(dir, entry.name);
|
|
473
|
+
if (entry.isDirectory()) await collectFiles(full, predicate, results);
|
|
474
|
+
else if (predicate(entry.name)) results.push(full);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseArgs(argv) {
|
|
479
|
+
const out = {};
|
|
480
|
+
for (let i = 0; i < argv.length; i++) {
|
|
481
|
+
const arg = argv[i];
|
|
482
|
+
if (!arg.startsWith('--')) continue;
|
|
483
|
+
const body = arg.slice(2);
|
|
484
|
+
const equals = body.indexOf('=');
|
|
485
|
+
if (equals >= 0) { out[body.slice(0, equals)] = body.slice(equals + 1); continue; }
|
|
486
|
+
out[body] = argv[++i];
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
}
|