@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.
Files changed (68) hide show
  1. package/README.md +47 -2
  2. package/bin/aci +0 -0
  3. package/bin/aci.js +157 -0
  4. package/dist/assets/index.d.ts +3 -0
  5. package/dist/assets/index.js +3 -0
  6. package/dist/astro/index.d.ts +24 -2
  7. package/dist/astro/index.js +42 -4
  8. package/dist/block-ref.d.ts +34 -0
  9. package/dist/block-ref.js +34 -0
  10. package/dist/content/index.d.ts +0 -3
  11. package/dist/content/index.js +0 -3
  12. package/dist/content/provider.d.ts +32 -8
  13. package/dist/content/provider.js +26 -16
  14. package/dist/content/routes.d.ts +6 -12
  15. package/dist/content/routes.js +9 -55
  16. package/dist/content/validation.js +1 -1
  17. package/dist/define-component.d.ts +1 -0
  18. package/dist/define-component.js +1 -0
  19. package/dist/define-layout.d.ts +1 -0
  20. package/dist/define-layout.js +6 -1
  21. package/dist/dev/browser.d.ts +1 -1
  22. package/dist/dev/browser.js +1 -1
  23. package/dist/dev/index.d.ts +9 -3
  24. package/dist/dev/index.js +74 -8
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +4 -0
  27. package/dist/next/asset-route.d.ts +9 -0
  28. package/dist/next/asset-route.js +15 -0
  29. package/dist/next/config.d.ts +6 -0
  30. package/dist/next/config.js +25 -0
  31. package/dist/next/dev-refresh.js +4 -4
  32. package/dist/next/edge-config.d.ts +1 -0
  33. package/dist/next/edge-config.js +92 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/middleware.js +4 -6
  37. package/dist/next/server.d.ts +9 -24
  38. package/dist/next/server.js +100 -152
  39. package/dist/providers/file.d.ts +11 -17
  40. package/dist/providers/file.js +44 -78
  41. package/dist/providers/s3.d.ts +26 -0
  42. package/dist/providers/s3.js +174 -0
  43. package/dist/react/GradialMedia.d.ts +24 -0
  44. package/dist/react/GradialMedia.js +31 -0
  45. package/dist/react/GradialPicture.d.ts +14 -0
  46. package/dist/react/GradialPicture.js +30 -0
  47. package/dist/react/GradialVideoPlayer.d.ts +13 -0
  48. package/dist/react/GradialVideoPlayer.js +28 -0
  49. package/dist/react/index.d.ts +3 -0
  50. package/dist/react/index.js +3 -0
  51. package/dist/sveltekit/index.d.ts +18 -2
  52. package/dist/sveltekit/index.js +40 -4
  53. package/dist/testing/index.d.ts +14 -12
  54. package/dist/testing/index.js +41 -28
  55. package/dist/types/component.d.ts +24 -2
  56. package/dist/types/config.d.ts +4 -0
  57. package/dist/types/image.d.ts +51 -0
  58. package/dist/types/image.js +58 -0
  59. package/dist/types/index.d.ts +3 -0
  60. package/dist/types/index.js +3 -0
  61. package/dist/types/layout.d.ts +12 -0
  62. package/dist/types/media.d.ts +69 -0
  63. package/dist/types/media.js +86 -0
  64. package/dist/types/video.d.ts +70 -0
  65. package/dist/types/video.js +22 -0
  66. package/package.json +30 -2
  67. package/src/cli/compile-registry.mjs +303 -0
  68. package/src/cli/validate-content.mjs +489 -0
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ import { GradialImageSchema, renderImageHTML } from './image.js';
3
+ import { GradialVideoSchema } from './video.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Zod schema — discriminated on $type
6
+ // ---------------------------------------------------------------------------
7
+ export const GradialAssetSchema = z.discriminatedUnion('$type', [
8
+ GradialImageSchema,
9
+ GradialVideoSchema,
10
+ ]);
11
+ // ---------------------------------------------------------------------------
12
+ // Type guards
13
+ // ---------------------------------------------------------------------------
14
+ export function isGradialImage(value) {
15
+ return (typeof value === 'object' &&
16
+ value !== null &&
17
+ value.$type === 'gradial.image');
18
+ }
19
+ export function isGradialVideo(value) {
20
+ return (typeof value === 'object' &&
21
+ value !== null &&
22
+ value.$type === 'gradial.video');
23
+ }
24
+ export function isGradialAsset(value) {
25
+ return isGradialImage(value) || isGradialVideo(value);
26
+ }
27
+ // ---------------------------------------------------------------------------
28
+ // HTML string renderers (framework-agnostic)
29
+ // ---------------------------------------------------------------------------
30
+ /**
31
+ * Renders a GradialVideo to an HTML string.
32
+ * Uses poster.fallback.src for the poster attribute.
33
+ * Includes <source> elements for format variants.
34
+ */
35
+ export function renderVideoHTML(video, attrs = {}) {
36
+ const posterSrc = video.poster?.fallback.src;
37
+ const attrPairs = [
38
+ ...Object.entries(attrs),
39
+ ['src', video.sources?.length ? undefined : video.src],
40
+ ['poster', posterSrc],
41
+ ['width', video.width > 0 ? video.width : undefined],
42
+ ['height', video.height > 0 ? video.height : undefined],
43
+ ];
44
+ const attrString = renderAttrs(attrPairs);
45
+ const sources = (video.sources ?? [])
46
+ .map((s) => `<source${renderAttrs([['src', s.src], ['type', s.type]])}>`)
47
+ .join('');
48
+ // If no format variants, src is on the <video> element itself
49
+ if (!sources) {
50
+ return `<video${attrString}></video>`;
51
+ }
52
+ return `<video${attrString}>${sources}</video>`;
53
+ }
54
+ /**
55
+ * Renders any GradialAsset to an HTML string.
56
+ * Delegates to renderImageHTML or renderVideoHTML based on $type.
57
+ */
58
+ export function renderAssetHTML(asset, attrs = {}) {
59
+ if (asset.$type === 'gradial.image') {
60
+ return renderImageHTML(asset, attrs);
61
+ }
62
+ return renderVideoHTML(asset, attrs);
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Internal helpers
66
+ // ---------------------------------------------------------------------------
67
+ function renderAttrs(pairs) {
68
+ const rendered = pairs
69
+ .filter(([, value]) => value !== undefined && value !== null && value !== false)
70
+ .map(([name, value]) => value === true
71
+ ? escapeName(name)
72
+ : `${escapeName(name)}="${escapeAttr(String(value))}"`);
73
+ if (!rendered.length)
74
+ return '';
75
+ return ' ' + rendered.join(' ');
76
+ }
77
+ function escapeName(name) {
78
+ return name.replace(/[^A-Za-z0-9_:-]/g, '');
79
+ }
80
+ function escapeAttr(value) {
81
+ return value
82
+ .replace(/&/g, '&amp;')
83
+ .replace(/"/g, '&quot;')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;');
86
+ }
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { type GradialImage } from './image.js';
3
+ export interface VideoSource {
4
+ src: string;
5
+ type: string;
6
+ }
7
+ export interface GradialVideo {
8
+ /** Discriminator — always 'gradial.video'. */
9
+ $type: 'gradial.video';
10
+ /** DAM asset identifier. */
11
+ assetId: string;
12
+ /** DAM version identifier. */
13
+ versionId: string;
14
+ /** Accessible description of the video content. */
15
+ alt: string;
16
+ /** Primary video URL (compiler-resolved). */
17
+ src: string;
18
+ /** Primary video MIME type (e.g. 'video/mp4'). */
19
+ type: string;
20
+ /** Intrinsic width in pixels. */
21
+ width: number;
22
+ /** Intrinsic height in pixels. */
23
+ height: number;
24
+ /**
25
+ * Poster frame — a full GradialImage with responsive srcset.
26
+ * The compiler extracts or generates this from the video asset.
27
+ */
28
+ poster?: GradialImage;
29
+ /** Format variants (e.g. mp4 + webm). Maps to <video><source> elements. */
30
+ sources?: VideoSource[];
31
+ /** Duration in seconds (if known). */
32
+ duration?: number;
33
+ }
34
+ export declare const VideoSourceSchema: z.ZodObject<{
35
+ src: z.ZodString;
36
+ type: z.ZodString;
37
+ }, z.core.$strip>;
38
+ export declare const GradialVideoSchema: z.ZodObject<{
39
+ $type: z.ZodLiteral<"gradial.video">;
40
+ assetId: z.ZodString;
41
+ versionId: z.ZodString;
42
+ alt: z.ZodString;
43
+ src: z.ZodString;
44
+ type: z.ZodString;
45
+ width: z.ZodNumber;
46
+ height: z.ZodNumber;
47
+ poster: z.ZodOptional<z.ZodObject<{
48
+ $type: z.ZodLiteral<"gradial.image">;
49
+ assetId: z.ZodString;
50
+ versionId: z.ZodString;
51
+ alt: z.ZodString;
52
+ fallback: z.ZodObject<{
53
+ src: z.ZodString;
54
+ width: z.ZodNumber;
55
+ height: z.ZodNumber;
56
+ type: z.ZodString;
57
+ }, z.core.$strip>;
58
+ sources: z.ZodDefault<z.ZodNullable<z.ZodArray<z.ZodObject<{
59
+ media: z.ZodOptional<z.ZodString>;
60
+ type: z.ZodString;
61
+ srcset: z.ZodString;
62
+ sizes: z.ZodOptional<z.ZodString>;
63
+ }, z.core.$strip>>>>;
64
+ }, z.core.$strip>>;
65
+ sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
66
+ src: z.ZodString;
67
+ type: z.ZodString;
68
+ }, z.core.$strip>>>;
69
+ duration: z.ZodOptional<z.ZodNumber>;
70
+ }, z.core.$strip>;
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { GradialImageSchema } from './image.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Zod schema
5
+ // ---------------------------------------------------------------------------
6
+ export const VideoSourceSchema = z.object({
7
+ src: z.string().min(1),
8
+ type: z.string().min(1),
9
+ });
10
+ export const GradialVideoSchema = z.object({
11
+ $type: z.literal('gradial.video'),
12
+ assetId: z.string().min(1),
13
+ versionId: z.string().min(1),
14
+ alt: z.string(),
15
+ src: z.string().min(1),
16
+ type: z.string().min(1),
17
+ width: z.number().int().nonnegative(),
18
+ height: z.number().int().nonnegative(),
19
+ poster: GradialImageSchema.optional(),
20
+ sources: z.array(VideoSourceSchema).optional(),
21
+ duration: z.number().nonnegative().optional(),
22
+ });
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "files": [
8
8
  "dist",
9
+ "bin/aci.js",
10
+ "bin/aci",
9
11
  "src/cli/*.mjs",
10
12
  "README.md"
11
13
  ],
14
+ "bin": {
15
+ "aci": "./bin/aci.js"
16
+ },
12
17
  "publishConfig": {
13
- "access": "restricted"
18
+ "access": "public"
14
19
  },
15
20
  "exports": {
16
21
  ".": {
@@ -41,14 +46,31 @@
41
46
  "types": "./dist/providers/file.d.ts",
42
47
  "import": "./dist/providers/file.js"
43
48
  },
49
+ "./providers/s3": {
50
+ "types": "./dist/providers/s3.d.ts",
51
+ "import": "./dist/providers/s3.js"
52
+ },
44
53
  "./dev": {
45
54
  "types": "./dist/dev/index.d.ts",
46
55
  "import": "./dist/dev/index.js"
47
56
  },
57
+ "./assets": {
58
+ "types": "./dist/assets/index.d.ts",
59
+ "import": "./dist/assets/index.js"
60
+ },
61
+ "./react": {
62
+ "types": "./dist/react/index.d.ts",
63
+ "import": "./dist/react/index.js"
64
+ },
48
65
  "./astro": {
49
66
  "types": "./dist/astro/index.d.ts",
50
67
  "import": "./dist/astro/index.js"
51
68
  },
69
+ "./next": {
70
+ "types": "./dist/next/index.d.ts",
71
+ "import": "./dist/next/index.js",
72
+ "default": "./dist/next/index.js"
73
+ },
52
74
  "./next/server": {
53
75
  "types": "./dist/next/server.d.ts",
54
76
  "import": "./dist/next/server.js",
@@ -63,6 +85,10 @@
63
85
  "types": "./dist/next/dev-refresh.d.ts",
64
86
  "import": "./dist/next/dev-refresh.js"
65
87
  },
88
+ "./next/asset-route": {
89
+ "types": "./dist/next/asset-route.d.ts",
90
+ "import": "./dist/next/asset-route.js"
91
+ },
66
92
  "./sveltekit": {
67
93
  "types": "./dist/sveltekit/index.d.ts",
68
94
  "import": "./dist/sveltekit/index.js"
@@ -72,10 +98,12 @@
72
98
  "import": "./dist/testing/index.js"
73
99
  },
74
100
  "./cli/compile-registry": "./src/cli/compile-registry.mjs",
101
+ "./cli/validate-content": "./src/cli/validate-content.mjs",
75
102
  "./cli/verify-renderer": "./src/cli/verify-renderer.mjs"
76
103
  },
77
104
  "scripts": {
78
105
  "build": "tsc -p tsconfig.json",
106
+ "build:cli": "cd ../.. && ./scripts/build-cli.sh",
79
107
  "compile-registry": "tsx src/cli/compile-registry.mjs",
80
108
  "prepack": "npm run build"
81
109
  },
@@ -3,6 +3,17 @@ 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';
7
+
8
+ // Shim React global so JSX-containing component files can be loaded.
9
+ // The compile-registry only extracts contract metadata (schemas, imageSlots)
10
+ // and never renders components, but importing .tsx files requires the JSX
11
+ // runtime to be resolvable. Next.js uses jsx: "preserve" which leaves React
12
+ // references in the transpiled output.
13
+ try {
14
+ const React = await import('react');
15
+ if (!globalThis.React) globalThis.React = React;
16
+ } catch { /* react not available — component files without JSX will still work */ }
6
17
 
7
18
  const args = parseArgs(process.argv.slice(2));
8
19
 
@@ -38,6 +49,7 @@ async function compileRegistry(options) {
38
49
  schemaFiles.push(schemaPath);
39
50
  const renderModes = component.renderModes ?? component.render;
40
51
  const varyDimensions = component.varyDimensions ?? component.vary;
52
+ const imageSlots = normalizeImageSlots(component.imageSlots);
41
53
  componentEntries.push({
42
54
  name: component.name,
43
55
  schemaFile,
@@ -53,12 +65,26 @@ async function compileRegistry(options) {
53
65
  },
54
66
  ...(component.defaultIslandMode ? { defaultIslandMode: component.defaultIslandMode } : {}),
55
67
  ...(varyDimensions?.length ? { vary: varyDimensions, varyDimensions } : {}),
68
+ ...(Object.keys(imageSlots).length ? { imageSlots } : {}),
56
69
  });
57
70
  }
58
71
 
59
72
  const seenLayouts = new Set();
60
73
  const layoutEntries = layouts.map((layout, index) => validateLayout(layout, index, seenLayouts));
61
74
 
75
+ // Post-compilation: detect nested block patterns in JSON schemas and
76
+ // validate that referenced component names exist in the registry.
77
+ const knownNames = new Set(componentEntries.map((c) => c.name));
78
+ for (const entry of componentEntries) {
79
+ const schemaPath = path.join(outDir, entry.schemaFile);
80
+ const schemaRaw = await import('node:fs/promises').then((fs) => fs.readFile(schemaPath, 'utf-8'));
81
+ const schema = JSON.parse(schemaRaw);
82
+ const nestedBlocks = collectNestedBlockRefs(schema, knownNames, entry.name);
83
+ if (nestedBlocks.length > 0) {
84
+ entry.nestedBlocks = nestedBlocks;
85
+ }
86
+ }
87
+
62
88
  const registry = {
63
89
  manifestVersion: '1',
64
90
  generatedAt: new Date().toISOString(),
@@ -84,6 +110,7 @@ function validateComponent(component, index, seen) {
84
110
  seen.add(component.name);
85
111
  if (!component.schema) throw new Error(`components[${index}].schema is required`);
86
112
  validateZodSubset(component.schema, `components[${index}].schema`);
113
+ validateImageSlotSchemaMatch(component, index);
87
114
  const renderModes = component.renderModes ?? component.render;
88
115
  if (!renderModes) throw new Error(`components[${index}].renderModes is required`);
89
116
  const enabled = Boolean(renderModes.canStatic ?? renderModes.static)
@@ -92,6 +119,183 @@ function validateComponent(component, index, seen) {
92
119
  if (!enabled) throw new Error(`components[${index}].renderModes must enable at least one mode`);
93
120
  }
94
121
 
122
+ function validateImageSlotSchemaMatch(component, index) {
123
+ const imageSlots = normalizeImageSlots(component.imageSlots);
124
+ const imageFields = collectGradialImageFields(component.schema);
125
+ for (const field of imageFields) {
126
+ if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
127
+ throw new Error(`components[${index}].imageSlots.${field} is required because schema field ${field} uses GradialImageSchema`);
128
+ }
129
+ }
130
+ for (const field of Object.keys(imageSlots)) {
131
+ if (!imageFields.has(field)) {
132
+ throw new Error(`components[${index}].schema.${field} must use GradialImageSchema because imageSlots.${field} is declared`);
133
+ }
134
+ }
135
+ }
136
+
137
+ function normalizeImageSlots(imageSlots) {
138
+ if (imageSlots == null) return {};
139
+ if (typeof imageSlots !== 'object' || Array.isArray(imageSlots)) {
140
+ throw new Error('imageSlots must be an object');
141
+ }
142
+ const out = {};
143
+ for (const [key, slot] of Object.entries(imageSlots)) {
144
+ if (!key) throw new Error('imageSlots keys must be non-empty');
145
+ if (!slot || typeof slot !== 'object' || Array.isArray(slot)) {
146
+ throw new Error(`imageSlots.${key} must be an object`);
147
+ }
148
+ if (!Array.isArray(slot.outputs) || !slot.outputs.length) {
149
+ throw new Error(`imageSlots.${key}.outputs must be a non-empty array`);
150
+ }
151
+ if (!Array.isArray(slot.formats) || !slot.formats.length) {
152
+ throw new Error(`imageSlots.${key}.formats must be a non-empty array`);
153
+ }
154
+ if (typeof slot.sizes !== 'string' || !slot.sizes.trim()) {
155
+ throw new Error(`imageSlots.${key}.sizes must be a non-empty string`);
156
+ }
157
+ out[key] = {
158
+ outputs: slot.outputs.map((output, outputIndex) => normalizeSlotOutput(key, output, outputIndex)),
159
+ formats: slot.formats.map((format, formatIndex) => {
160
+ if (typeof format !== 'string' || !format.trim()) {
161
+ throw new Error(`imageSlots.${key}.formats[${formatIndex}] must be a non-empty string`);
162
+ }
163
+ return format;
164
+ }),
165
+ sizes: slot.sizes,
166
+ };
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function normalizeSlotOutput(slotKey, output, outputIndex) {
172
+ if (!output || typeof output !== 'object' || Array.isArray(output)) {
173
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}] must be an object`);
174
+ }
175
+ if (typeof output.aspectRatio !== 'string' || !output.aspectRatio.trim()) {
176
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].aspectRatio must be a non-empty string`);
177
+ }
178
+ if (!Array.isArray(output.widths) || !output.widths.length) {
179
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths must be a non-empty array`);
180
+ }
181
+ const widths = output.widths.map((width, widthIndex) => {
182
+ if (!Number.isInteger(width) || width <= 0) {
183
+ throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths[${widthIndex}] must be a positive integer`);
184
+ }
185
+ return width;
186
+ });
187
+ return {
188
+ aspectRatio: output.aspectRatio,
189
+ widths,
190
+ ...(typeof output.media === 'string' && output.media.trim() ? { media: output.media } : {}),
191
+ };
192
+ }
193
+
194
+ function collectGradialImageFields(schema) {
195
+ const out = new Set();
196
+
197
+ collectGradialImageFieldsFromSchema(schema, '', out);
198
+
199
+ return out;
200
+ }
201
+
202
+ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
203
+ if (!schema) {
204
+ return;
205
+ }
206
+
207
+ if (prefix && isGradialImageSchema(schema)) {
208
+ out.add(prefix);
209
+ return;
210
+ }
211
+
212
+ const itemSchema = arrayItemSchema(schema);
213
+ if (itemSchema) {
214
+ collectGradialImageFieldsFromSchema(itemSchema, prefix, out);
215
+ return;
216
+ }
217
+
218
+ const unionOptions = unionOptionSchemas(schema);
219
+ if (unionOptions) {
220
+ for (const option of unionOptions) {
221
+ collectGradialImageFieldsFromSchema(option, prefix, out);
222
+ }
223
+ return;
224
+ }
225
+
226
+ const def = unwrapSchema(schema)?._def;
227
+ const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape;
228
+ if (!shape || typeof shape !== 'object') {
229
+ return;
230
+ }
231
+
232
+ for (const [field, fieldSchema] of Object.entries(shape)) {
233
+ const nextPrefix = prefix ? `${prefix}.${field}` : field;
234
+ collectGradialImageFieldsFromSchema(fieldSchema, nextPrefix, out);
235
+ }
236
+ }
237
+
238
+ function unionOptionSchemas(schema) {
239
+ const def = unwrapSchema(schema)?._def;
240
+ if (!def) return null;
241
+ if (Array.isArray(def.options)) return def.options;
242
+ if (def.options instanceof Map) return Array.from(def.options.values());
243
+ return null;
244
+ }
245
+
246
+ function arrayItemSchema(schema) {
247
+ let current = schema;
248
+ const seen = new Set();
249
+ while (current && !seen.has(current)) {
250
+ seen.add(current);
251
+ const def = current?._def;
252
+ if (!def) {
253
+ // Zod 4 mini: check for ~standard array type
254
+ if (current?.type === 'array' && current?.element) return current.element;
255
+ return null;
256
+ }
257
+ if (def.typeName === 'ZodArray') return def.type;
258
+ // Zod 4: array element might be in def.element
259
+ if (def.type === 'array' && def.element) return def.element;
260
+ current = def.innerType ?? def.schema;
261
+ }
262
+ return null;
263
+ }
264
+
265
+ function isGradialImageSchema(schema) {
266
+ const unwrapped = unwrapSchema(schema);
267
+ if (unwrapped === GradialImageSchema) return true;
268
+ try {
269
+ const compiled = z.toJSONSchema(unwrapped, {
270
+ target: 'draft-2020-12',
271
+ unrepresentable: 'throw',
272
+ });
273
+ const sources = compiled?.properties?.sources;
274
+ const sourcesIsArray = sources?.type === 'array'
275
+ || (Array.isArray(sources?.anyOf) && sources.anyOf.some((s) => s?.type === 'array'));
276
+ return compiled?.type === 'object'
277
+ && compiled?.properties?.$type?.const === 'gradial.image'
278
+ && compiled?.properties?.fallback?.type === 'object'
279
+ && sourcesIsArray;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ function unwrapSchema(schema) {
286
+ let current = schema;
287
+ const seen = new Set();
288
+ while (current && !seen.has(current)) {
289
+ seen.add(current);
290
+ if (current === GradialImageSchema) return current;
291
+ const def = current._def;
292
+ const next = def?.innerType ?? def?.schema;
293
+ if (!next) break;
294
+ current = next;
295
+ }
296
+ return current;
297
+ }
298
+
95
299
  function validateLayout(layout, index, seen) {
96
300
  if (!layout || typeof layout !== 'object') throw new Error(`layouts[${index}] must be an object`);
97
301
  if (!layout.name) throw new Error(`layouts[${index}].name is required`);
@@ -182,6 +386,105 @@ function childSchemas(def) {
182
386
  return children;
183
387
  }
184
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // Nested block detection — walks generated JSON schemas to find blockRef
391
+ // patterns (objects with `component` enum + `props`) and validates that
392
+ // referenced component names exist in the registry.
393
+ // ---------------------------------------------------------------------------
394
+
395
+ function collectNestedBlockRefs(schema, knownNames, componentName) {
396
+ const results = [];
397
+ _walkSchemaForBlockRefs(schema, schema, '', results, knownNames, componentName);
398
+ return results;
399
+ }
400
+
401
+ function _walkSchemaForBlockRefs(node, rootSchema, currentPath, results, knownNames, componentName) {
402
+ if (!node || typeof node !== 'object') return;
403
+
404
+ // Resolve $ref
405
+ if (node.$ref) {
406
+ const resolved = resolveJsonSchemaRef(node.$ref, rootSchema);
407
+ if (resolved) _walkSchemaForBlockRefs(resolved, rootSchema, currentPath, results, knownNames, componentName);
408
+ return;
409
+ }
410
+
411
+ // Check if this object matches the blockRef signature:
412
+ // { type: "object", properties: { id, component, props } }
413
+ if (isBlockRefSchema(node)) {
414
+ const allow = extractBlockRefAllow(node, rootSchema);
415
+ // Validate allowed names exist
416
+ for (const name of allow) {
417
+ if (!knownNames.has(name)) {
418
+ throw new Error(
419
+ `${componentName}: nested block allowlist references unknown component "${name}" at ${currentPath || '(root)'}`,
420
+ );
421
+ }
422
+ }
423
+ results.push({
424
+ path: currentPath || '(root)',
425
+ ...(allow.length > 0 ? { allow } : {}),
426
+ });
427
+ return; // Don't recurse into the block schema itself
428
+ }
429
+
430
+ // Recurse into object properties
431
+ if (node.properties) {
432
+ for (const [key, propSchema] of Object.entries(node.properties)) {
433
+ const nextPath = currentPath ? `${currentPath}.${key}` : key;
434
+ _walkSchemaForBlockRefs(propSchema, rootSchema, nextPath, results, knownNames, componentName);
435
+ }
436
+ }
437
+
438
+ // Recurse into array items
439
+ if (node.items) {
440
+ _walkSchemaForBlockRefs(node.items, rootSchema, `${currentPath}[]`, results, knownNames, componentName);
441
+ }
442
+
443
+ // Recurse into anyOf / allOf / oneOf
444
+ for (const keyword of ['anyOf', 'allOf', 'oneOf']) {
445
+ if (Array.isArray(node[keyword])) {
446
+ for (const sub of node[keyword]) {
447
+ _walkSchemaForBlockRefs(sub, rootSchema, currentPath, results, knownNames, componentName);
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ function isBlockRefSchema(node) {
454
+ if (node.type !== 'object' || !node.properties) return false;
455
+ const props = node.properties;
456
+ // Must have component and props fields
457
+ if (!props.component || !props.props) return false;
458
+ // component must be a string or enum
459
+ const comp = props.component;
460
+ if (comp.type !== 'string' && !Array.isArray(comp.enum)) return false;
461
+ // props must be an object (record)
462
+ if (props.props.type !== 'object') return false;
463
+ return true;
464
+ }
465
+
466
+ function extractBlockRefAllow(node, rootSchema) {
467
+ let comp = node.properties.component;
468
+ // Resolve $ref on the component field
469
+ if (comp.$ref) {
470
+ comp = resolveJsonSchemaRef(comp.$ref, rootSchema) ?? comp;
471
+ }
472
+ if (Array.isArray(comp.enum)) {
473
+ return comp.enum.filter((v) => typeof v === 'string');
474
+ }
475
+ return [];
476
+ }
477
+
478
+ function resolveJsonSchemaRef(ref, rootSchema) {
479
+ if (!ref || !ref.startsWith('#/')) return null;
480
+ const parts = ref.slice(2).split('/');
481
+ let current = rootSchema;
482
+ for (const part of parts) {
483
+ current = current?.[part];
484
+ }
485
+ return current ?? null;
486
+ }
487
+
185
488
  function parseArgs(argv) {
186
489
  const out = {};
187
490
  for (let i = 0; i < argv.length; i++) {