@gradial/aci 0.1.1 → 0.1.3

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 (57) hide show
  1. package/README.md +65 -1
  2. package/bin/aci +0 -0
  3. package/bin/aci.js +139 -27
  4. package/dist/assets/index.d.ts +2 -0
  5. package/dist/assets/index.js +2 -0
  6. package/dist/astro/index.js +4 -4
  7. package/dist/block-ref.d.ts +34 -0
  8. package/dist/block-ref.js +34 -0
  9. package/dist/define-component.d.ts +1 -0
  10. package/dist/define-component.js +1 -0
  11. package/dist/define-layout.d.ts +1 -0
  12. package/dist/define-layout.js +1 -0
  13. package/dist/dev/index.d.ts +6 -0
  14. package/dist/dev/index.js +66 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +4 -1
  17. package/dist/next/asset-route.d.ts +9 -0
  18. package/dist/next/asset-route.js +15 -0
  19. package/dist/next/config.d.ts +2 -10
  20. package/dist/next/config.js +5 -2
  21. package/dist/next/server.d.ts +4 -0
  22. package/dist/next/server.js +53 -0
  23. package/dist/providers/s3.d.ts +2 -0
  24. package/dist/providers/s3.js +13 -1
  25. package/dist/react/GradialMedia.d.ts +24 -0
  26. package/dist/react/GradialMedia.js +31 -0
  27. package/dist/react/GradialPicture.d.ts +14 -0
  28. package/dist/react/GradialPicture.js +30 -0
  29. package/dist/react/GradialVideoPlayer.d.ts +13 -0
  30. package/dist/react/GradialVideoPlayer.js +28 -0
  31. package/dist/react/index.d.ts +3 -0
  32. package/dist/react/index.js +3 -0
  33. package/dist/sveltekit/index.js +6 -1
  34. package/dist/types/component.d.ts +8 -3
  35. package/dist/types/image.d.ts +2 -2
  36. package/dist/types/image.js +1 -1
  37. package/dist/types/index.d.ts +2 -0
  38. package/dist/types/index.js +2 -0
  39. package/dist/types/media.d.ts +69 -0
  40. package/dist/types/media.js +86 -0
  41. package/dist/types/video.d.ts +70 -0
  42. package/dist/types/video.js +22 -0
  43. package/package.json +14 -9
  44. package/src/cli/compile-registry.mjs +142 -1
  45. package/src/cli/validate-content.mjs +98 -0
  46. package/src/types/component.ts +59 -0
  47. package/src/types/config.ts +36 -0
  48. package/src/types/image.ts +100 -0
  49. package/src/types/index.ts +9 -0
  50. package/src/types/layout.ts +29 -0
  51. package/src/types/media.ts +125 -0
  52. package/src/types/page.ts +48 -0
  53. package/src/types/render-mode.ts +18 -0
  54. package/src/types/renderer.ts +83 -0
  55. package/src/types/video.ts +66 -0
  56. package/src/cli/css-stub-loader.mjs +0 -27
  57. package/src/cli/generate-registry.mjs +0 -72
@@ -5,6 +5,16 @@ import { pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
6
  import { GradialImageSchema } from '../types/image.ts';
7
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 */ }
17
+
8
18
  const args = parseArgs(process.argv.slice(2));
9
19
 
10
20
  try {
@@ -62,6 +72,19 @@ async function compileRegistry(options) {
62
72
  const seenLayouts = new Set();
63
73
  const layoutEntries = layouts.map((layout, index) => validateLayout(layout, index, seenLayouts));
64
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
+
65
88
  const registry = {
66
89
  manifestVersion: '1',
67
90
  generatedAt: new Date().toISOString(),
@@ -192,6 +215,14 @@ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
192
215
  return;
193
216
  }
194
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
+
195
226
  const def = unwrapSchema(schema)?._def;
196
227
  const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape;
197
228
  if (!shape || typeof shape !== 'object') {
@@ -204,6 +235,14 @@ function collectGradialImageFieldsFromSchema(schema, prefix, out) {
204
235
  }
205
236
  }
206
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
+
207
246
  function arrayItemSchema(schema) {
208
247
  let current = schema;
209
248
  const seen = new Set();
@@ -231,10 +270,13 @@ function isGradialImageSchema(schema) {
231
270
  target: 'draft-2020-12',
232
271
  unrepresentable: 'throw',
233
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'));
234
276
  return compiled?.type === 'object'
235
277
  && compiled?.properties?.$type?.const === 'gradial.image'
236
278
  && compiled?.properties?.fallback?.type === 'object'
237
- && compiled?.properties?.sources?.type === 'array';
279
+ && sourcesIsArray;
238
280
  } catch {
239
281
  return false;
240
282
  }
@@ -344,6 +386,105 @@ function childSchemas(def) {
344
386
  return children;
345
387
  }
346
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
+
347
488
  function parseArgs(argv) {
348
489
  const out = {};
349
490
  for (let i = 0; i < argv.length; i++) {
@@ -53,8 +53,10 @@ async function validateContent(options) {
53
53
  const registry = JSON.parse(registryRaw);
54
54
 
55
55
  // Build schema map: component name → JSON Schema object
56
+ // Build nested blocks map: component name → array of { path, allow? }
56
57
  const schemaMap = new Map();
57
58
  const knownComponents = new Set();
59
+ const nestedBlocksMap = new Map();
58
60
  for (const component of registry.components) {
59
61
  knownComponents.add(component.name);
60
62
  if (component.schemaFile) {
@@ -62,6 +64,9 @@ async function validateContent(options) {
62
64
  const schemaRaw = await readFile(schemaPath, 'utf-8');
63
65
  schemaMap.set(component.name, JSON.parse(schemaRaw));
64
66
  }
67
+ if (Array.isArray(component.nestedBlocks) && component.nestedBlocks.length > 0) {
68
+ nestedBlocksMap.set(component.name, component.nestedBlocks);
69
+ }
65
70
  }
66
71
 
67
72
  // Find all compiled route files
@@ -128,6 +133,19 @@ async function validateContent(options) {
128
133
  message: issue.message,
129
134
  });
130
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
+ }
131
149
  }
132
150
  }
133
151
 
@@ -341,6 +359,86 @@ function resolveRef(ref, rootSchema) {
341
359
  // Content helpers
342
360
  // ---------------------------------------------------------------------------
343
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
+
344
442
  function collectBlocks(pageData) {
345
443
  const blocks = [];
346
444
  if (pageData.regions && typeof pageData.regions === 'object') {
@@ -0,0 +1,59 @@
1
+ import type { IslandMode, VaryDimension } from './render-mode.js';
2
+ import type { ImageSlotContract } from './image.js';
3
+ import type { z } from 'zod';
4
+
5
+ export interface ComponentRenderModes {
6
+ canStatic: boolean;
7
+ canSSR: boolean;
8
+ canClientIsland: boolean;
9
+ }
10
+
11
+ /**
12
+ * A CMS-registered component — sync or async (server components).
13
+ * Accepts content props derived from the Zod schema.
14
+ */
15
+ type CmsComponentFn<TProps = any> =
16
+ | ((props: TProps) => import('react').ReactElement | null)
17
+ | ((props: TProps) => Promise<import('react').ReactElement | null>);
18
+
19
+ /**
20
+ * Infers the props type from a schema.
21
+ * If TSchema is a Zod type, returns z.infer<TSchema>.
22
+ * Otherwise returns Record<string, unknown>.
23
+ */
24
+ type InferContentProps<TSchema> = TSchema extends z.ZodType<infer T> ? T : Record<string, unknown>;
25
+
26
+ export interface ComponentDefinition<TSchema = unknown> {
27
+ name: string;
28
+ /**
29
+ * The React component that renders this CMS block.
30
+ * Accepts at least the schema-derived props, but may accept additional
31
+ * composition params (className, etc.) passed by parent sections.
32
+ */
33
+ component?: CmsComponentFn<any>;
34
+ schema: TSchema;
35
+ renderModes?: ComponentRenderModes;
36
+ render?: ComponentRenderModes;
37
+ defaultIslandMode?: IslandMode;
38
+ varyDimensions?: VaryDimension[];
39
+ vary?: VaryDimension[];
40
+ imageSlots?: Record<string, ImageSlotContract>;
41
+ }
42
+
43
+ export interface ComponentContract<TSchema = unknown> {
44
+ name: string;
45
+ component?: CmsComponentFn<any>;
46
+ schema: TSchema;
47
+ renderModes: ComponentRenderModes;
48
+ render: ComponentRenderModes;
49
+ defaultIslandMode?: IslandMode;
50
+ varyDimensions?: VaryDimension[];
51
+ vary?: VaryDimension[];
52
+ imageSlots?: Record<string, ImageSlotContract>;
53
+ }
54
+
55
+ export type InferComponentProps<TContract> = TContract extends ComponentContract<infer TSchema>
56
+ ? InferContentProps<TSchema>
57
+ : Record<string, unknown>;
58
+
59
+ export type { CmsComponentFn, InferContentProps };
@@ -0,0 +1,36 @@
1
+ import type { RenderCapabilities } from './renderer.js';
2
+
3
+ export interface BareMetalConfig {
4
+ version: '1';
5
+ siteId: string;
6
+ framework: 'astro' | 'next' | 'sveltekit' | 'custom';
7
+ source: {
8
+ root: string;
9
+ outDir?: string;
10
+ };
11
+ componentRegistry: string;
12
+ layoutRegistry: string;
13
+ rendererEntry: string;
14
+ capabilities: RenderCapabilities;
15
+ routes: RouteConfig;
16
+ dam?: DAMConfig;
17
+ externalDependencies?: ExternalDependency[];
18
+ rendererProtocol: 'http' | 'stdio-json';
19
+ }
20
+
21
+ export interface RouteConfig {
22
+ cmsManaged: string;
23
+ frameworkOwned?: string[];
24
+ }
25
+
26
+ export interface ExternalDependency {
27
+ name: string;
28
+ host: string;
29
+ kind?: 'api' | 'client-script';
30
+ description?: string;
31
+ cacheTTL?: number;
32
+ }
33
+
34
+ export interface DAMConfig {
35
+ autoApproveUploads?: boolean;
36
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod';
2
+
3
+ export interface ImageSource {
4
+ src: string;
5
+ width: number;
6
+ height: number;
7
+ type: string;
8
+ }
9
+
10
+ export interface PictureSource {
11
+ media?: string;
12
+ type: string;
13
+ srcset: string;
14
+ sizes?: string;
15
+ }
16
+
17
+ export interface GradialImage {
18
+ $type: 'gradial.image';
19
+ assetId: string;
20
+ versionId: string;
21
+ alt: string;
22
+ fallback: ImageSource;
23
+ sources: PictureSource[];
24
+ }
25
+
26
+ export interface ImageSlotContract {
27
+ outputs: SlotOutput[];
28
+ formats: string[];
29
+ sizes: string;
30
+ }
31
+
32
+ export interface SlotOutput {
33
+ aspectRatio: string;
34
+ widths: number[];
35
+ media?: string;
36
+ }
37
+
38
+ export const GradialImageSchema = z.object({
39
+ $type: z.literal('gradial.image'),
40
+ assetId: z.string().min(1),
41
+ versionId: z.string().min(1),
42
+ alt: z.string(),
43
+ fallback: z.object({
44
+ src: z.string().min(1),
45
+ width: z.number().int().nonnegative(),
46
+ height: z.number().int().nonnegative(),
47
+ type: z.string().min(1),
48
+ }),
49
+ sources: z.array(z.object({
50
+ media: z.string().optional(),
51
+ type: z.string().min(1),
52
+ srcset: z.string().min(1),
53
+ sizes: z.string().optional(),
54
+ })).nullable().default([]),
55
+ });
56
+
57
+ export type ImageHTMLAttributes = Record<string, string | number | boolean | undefined | null>;
58
+
59
+ export function renderImageHTML(image: GradialImage, attrs: ImageHTMLAttributes = {}): string {
60
+ const imgAttrs = renderAttrs({
61
+ ...attrs,
62
+ src: image.fallback.src,
63
+ alt: image.alt,
64
+ width: image.fallback.width > 0 ? image.fallback.width : undefined,
65
+ height: image.fallback.height > 0 ? image.fallback.height : undefined,
66
+ });
67
+ const img = `<img${imgAttrs}>`;
68
+ if (!image.sources.length) {
69
+ return img;
70
+ }
71
+ const sources = image.sources.map((source) => `<source${renderAttrs({
72
+ media: source.media,
73
+ type: source.type,
74
+ srcset: source.srcset,
75
+ sizes: source.sizes,
76
+ })}>`).join('');
77
+ return `<picture>${sources}${img}</picture>`;
78
+ }
79
+
80
+ function renderAttrs(attrs: ImageHTMLAttributes): string {
81
+ const rendered = Object.entries(attrs)
82
+ .filter(([, value]) => value !== undefined && value !== null && value !== false)
83
+ .map(([name, value]) => value === true ? escapeName(name) : `${escapeName(name)}="${escapeAttr(String(value))}"`);
84
+ if (!rendered.length) {
85
+ return '';
86
+ }
87
+ return ' ' + rendered.join(' ');
88
+ }
89
+
90
+ function escapeName(name: string): string {
91
+ return name.replace(/[^A-Za-z0-9_:-]/g, '');
92
+ }
93
+
94
+ function escapeAttr(value: string): string {
95
+ return value
96
+ .replace(/&/g, '&amp;')
97
+ .replace(/"/g, '&quot;')
98
+ .replace(/</g, '&lt;')
99
+ .replace(/>/g, '&gt;');
100
+ }
@@ -0,0 +1,9 @@
1
+ export * from './config.js';
2
+ export * from './component.js';
3
+ export * from './layout.js';
4
+ export * from './page.js';
5
+ export * from './renderer.js';
6
+ export * from './render-mode.js';
7
+ export * from './image.js';
8
+ export * from './video.js';
9
+ export * from './media.js';
@@ -0,0 +1,29 @@
1
+ import type { FragmentRefNode } from './page.js';
2
+
3
+ export interface LayoutSlot {
4
+ name: string;
5
+ required: boolean;
6
+ }
7
+
8
+ /** Default content for non-required layout slots, keyed by slot name. */
9
+ export type LayoutDefaults = Record<string, FragmentRefNode[]>;
10
+
11
+ export interface LayoutDefinition {
12
+ name: string;
13
+ slots: LayoutSlot[];
14
+ /** Default fragment references for slots not provided by the page. */
15
+ defaults?: LayoutDefaults;
16
+ }
17
+
18
+ export interface LayoutContract {
19
+ name: string;
20
+ slots: LayoutSlot[];
21
+ defaults?: LayoutDefaults;
22
+ }
23
+
24
+ /** A compiled layout file as stored on disk. */
25
+ export interface CompiledLayout {
26
+ name: string;
27
+ slots: LayoutSlot[];
28
+ defaults: LayoutDefaults;
29
+ }
@@ -0,0 +1,125 @@
1
+ import { z } from 'zod';
2
+ import { GradialImageSchema, renderImageHTML, type GradialImage } from './image.js';
3
+ import { GradialVideoSchema, type GradialVideo } from './video.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // GradialAsset — union of all DAM-backed asset types
7
+ //
8
+ // Content authors write: { "$type": "dam.assetRef", "assetId": "..." }
9
+ // The compiler resolves this to a GradialImage or GradialVideo based on
10
+ // the asset's media type in the DAM.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Discriminated union of all compiler-resolved asset types. */
14
+ export type GradialAsset = GradialImage | GradialVideo;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Zod schema — discriminated on $type
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const GradialAssetSchema = z.discriminatedUnion('$type', [
21
+ GradialImageSchema,
22
+ GradialVideoSchema,
23
+ ]);
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Type guards
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export function isGradialImage(value: unknown): value is GradialImage {
30
+ return (
31
+ typeof value === 'object' &&
32
+ value !== null &&
33
+ (value as Record<string, unknown>).$type === 'gradial.image'
34
+ );
35
+ }
36
+
37
+ export function isGradialVideo(value: unknown): value is GradialVideo {
38
+ return (
39
+ typeof value === 'object' &&
40
+ value !== null &&
41
+ (value as Record<string, unknown>).$type === 'gradial.video'
42
+ );
43
+ }
44
+
45
+ export function isGradialAsset(value: unknown): value is GradialAsset {
46
+ return isGradialImage(value) || isGradialVideo(value);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // HTML string renderers (framework-agnostic)
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Renders a GradialVideo to an HTML string.
55
+ * Uses poster.fallback.src for the poster attribute.
56
+ * Includes <source> elements for format variants.
57
+ */
58
+ export function renderVideoHTML(
59
+ video: GradialVideo,
60
+ attrs: Record<string, string | number | boolean | undefined | null> = {},
61
+ ): string {
62
+ const posterSrc = video.poster?.fallback.src;
63
+ const attrPairs: Array<[string, string | number | boolean | undefined | null]> = [
64
+ ...Object.entries(attrs),
65
+ ['src', video.sources?.length ? undefined : video.src],
66
+ ['poster', posterSrc],
67
+ ['width', video.width > 0 ? video.width : undefined],
68
+ ['height', video.height > 0 ? video.height : undefined],
69
+ ];
70
+ const attrString = renderAttrs(attrPairs);
71
+
72
+ const sources = (video.sources ?? [])
73
+ .map((s) => `<source${renderAttrs([['src', s.src], ['type', s.type]])}>`)
74
+ .join('');
75
+
76
+ // If no format variants, src is on the <video> element itself
77
+ if (!sources) {
78
+ return `<video${attrString}></video>`;
79
+ }
80
+ return `<video${attrString}>${sources}</video>`;
81
+ }
82
+
83
+ /**
84
+ * Renders any GradialAsset to an HTML string.
85
+ * Delegates to renderImageHTML or renderVideoHTML based on $type.
86
+ */
87
+ export function renderAssetHTML(
88
+ asset: GradialAsset,
89
+ attrs: Record<string, string | number | boolean | undefined | null> = {},
90
+ ): string {
91
+ if (asset.$type === 'gradial.image') {
92
+ return renderImageHTML(asset, attrs);
93
+ }
94
+ return renderVideoHTML(asset, attrs);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Internal helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function renderAttrs(
102
+ pairs: Array<[string, string | number | boolean | undefined | null]>,
103
+ ): string {
104
+ const rendered = pairs
105
+ .filter(([, value]) => value !== undefined && value !== null && value !== false)
106
+ .map(([name, value]) =>
107
+ value === true
108
+ ? escapeName(name)
109
+ : `${escapeName(name)}="${escapeAttr(String(value))}"`,
110
+ );
111
+ if (!rendered.length) return '';
112
+ return ' ' + rendered.join(' ');
113
+ }
114
+
115
+ function escapeName(name: string): string {
116
+ return name.replace(/[^A-Za-z0-9_:-]/g, '');
117
+ }
118
+
119
+ function escapeAttr(value: string): string {
120
+ return value
121
+ .replace(/&/g, '&amp;')
122
+ .replace(/"/g, '&quot;')
123
+ .replace(/</g, '&lt;')
124
+ .replace(/>/g, '&gt;');
125
+ }