@gradial/aci 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -4
  2. package/bin/aci-darwin-arm64 +0 -0
  3. package/dist/audience-data-models.d.ts +406 -0
  4. package/dist/audience-data-models.js +105 -0
  5. package/dist/block-ref.d.ts +2 -2
  6. package/dist/content/assets.js +1 -1
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +6 -0
  9. package/dist/next/asset-route.d.ts +0 -1
  10. package/dist/next/asset-route.js +5 -6
  11. package/dist/next/config.js +53 -3
  12. package/dist/next/content-watch.js +1 -1
  13. package/dist/next/middleware.d.ts +2 -2
  14. package/dist/next/middleware.js +47 -26
  15. package/dist/next/page.d.ts +6 -0
  16. package/dist/next/page.js +34 -4
  17. package/dist/next/preview-mode.js +6 -7
  18. package/dist/next/server.js +4 -20
  19. package/dist/preview/core.d.ts +0 -2
  20. package/dist/preview/core.js +1 -3
  21. package/dist/react/Media.d.ts +1 -1
  22. package/dist/react/Media.js +1 -1
  23. package/dist/react/VideoPlayer.d.ts +307 -6
  24. package/dist/react/VideoPlayer.js +19 -5
  25. package/dist/react/index.d.ts +3 -3
  26. package/dist/types/image.d.ts +2 -2
  27. package/dist/types/media.d.ts +3 -0
  28. package/dist/types/video.d.ts +14 -8
  29. package/dist/types/video.js +3 -0
  30. package/package.json +2 -8
  31. package/src/cli/compile-registry.mjs +68 -43
  32. package/src/cli/verify-next-cdn-routes.mjs +101 -0
  33. package/src/types/component.ts +55 -0
  34. package/src/types/config.ts +37 -0
  35. package/src/types/data.ts +47 -0
  36. package/src/types/image.ts +4 -3
  37. package/src/types/index.ts +11 -0
  38. package/src/types/layout.ts +29 -0
  39. package/src/types/link.ts +100 -0
  40. package/src/types/media.ts +34 -0
  41. package/src/types/page.ts +47 -0
  42. package/src/types/render-mode.ts +10 -0
  43. package/src/types/renderer.ts +83 -0
  44. package/src/types/rich-text.ts +64 -0
  45. package/src/types/video.ts +76 -0
  46. package/templates/astro/template/package.json.tmpl +3 -6
  47. package/templates/nextjs/template/{next.config.ts → next.config.mjs} +2 -2
  48. package/templates/nextjs/template/package.json.tmpl +3 -5
  49. package/templates/nextjs/template/src/app/gradial/assets/[...path]/route.ts +4 -0
  50. package/templates/nextjs/template/src/app/gradial/assets/[releaseId]/[...path]/route.ts +0 -1
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
6
  import { ImageSchema } from '../types/image.ts';
7
+ import { VideoSchema } from '../types/video.ts';
7
8
  import { getBlockRefMeta } from '../types/block-ref.ts';
8
9
 
9
10
  const DATA_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
@@ -147,15 +148,17 @@ function validateComponent(component, index, seen) {
147
148
 
148
149
  function validateImageSlotSchemaMatch(component, index) {
149
150
  const imageSlots = normalizeImageSlots(component.imageSlots);
150
- const imageFields = collectImageFields(component.props);
151
+ const mediaFields = collectMediaFields(component.props);
152
+ const imageFields = new Set(mediaFields.filter(([, k]) => k === 'image').map(([p]) => p));
153
+ const allMediaFields = new Set(mediaFields.map(([p]) => p));
151
154
  for (const field of imageFields) {
152
155
  if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
153
156
  throw new Error(`components[${index}].imageSlots.${field} is required because props field ${field} uses an image schema`);
154
157
  }
155
158
  }
156
159
  for (const field of Object.keys(imageSlots)) {
157
- if (!imageFields.has(field)) {
158
- throw new Error(`components[${index}].props.${field} must use an image schema because imageSlots.${field} is declared`);
160
+ if (!allMediaFields.has(field)) {
161
+ throw new Error(`components[${index}].props.${field} must use an image or asset schema because imageSlots.${field} is declared`);
159
162
  }
160
163
  }
161
164
  }
@@ -272,48 +275,35 @@ function normalizeSlotOutput(slotKey, output, outputIndex) {
272
275
  };
273
276
  }
274
277
 
275
- function collectImageFields(schema) {
276
- const out = new Set();
278
+ /**
279
+ * Walks a Zod schema and returns [dotPath, kind] pairs for every field
280
+ * that contains an image or asset schema.
281
+ * kind = 'image' for ImageSchema-only fields, 'asset' for unions (image | video).
282
+ */
283
+ function collectMediaFields(schema, prefix = '') {
284
+ if (!schema) return [];
277
285
 
278
- collectImageFieldsFromSchema(schema, '', out);
279
-
280
- return out;
281
- }
282
-
283
- function collectImageFieldsFromSchema(schema, prefix, out) {
284
- if (!schema) {
285
- return;
286
- }
287
-
288
- if (prefix && isImageSchema(schema)) {
289
- out.add(prefix);
290
- return;
291
- }
286
+ if (prefix && isImageSchema(schema)) return [[prefix, 'image']];
287
+ if (prefix && isVideoSchema(schema)) return [];
292
288
 
293
289
  const itemSchema = arrayItemSchema(schema);
294
- if (itemSchema) {
295
- collectImageFieldsFromSchema(itemSchema, prefix, out);
296
- return;
297
- }
290
+ if (itemSchema) return collectMediaFields(itemSchema, prefix);
298
291
 
299
292
  const unionOptions = unionOptionSchemas(schema);
300
293
  if (unionOptions) {
301
- for (const option of unionOptions) {
302
- collectImageFieldsFromSchema(option, prefix, out);
303
- }
304
- return;
294
+ const hasImage = unionOptions.some((opt) => isImageSchema(opt));
295
+ const hasVideo = unionOptions.some((opt) => isVideoSchema(opt));
296
+ if (prefix && hasImage && hasVideo) return [[prefix, 'asset']];
297
+ if (prefix && hasVideo) return [];
298
+ return unionOptions.flatMap((opt) => collectMediaFields(opt, prefix));
305
299
  }
306
300
 
307
- const def = unwrapSchema(schema)?._def;
308
- const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape;
309
- if (!shape || typeof shape !== 'object') {
310
- return;
311
- }
301
+ const shape = getSchemaShape(unwrapSchema(schema));
302
+ if (!shape) return [];
312
303
 
313
- for (const [field, fieldSchema] of Object.entries(shape)) {
314
- const nextPrefix = prefix ? `${prefix}.${field}` : field;
315
- collectImageFieldsFromSchema(fieldSchema, nextPrefix, out);
316
- }
304
+ return Object.entries(shape).flatMap(([field, fieldSchema]) =>
305
+ collectMediaFields(fieldSchema, prefix ? `${prefix}.${field}` : field),
306
+ );
317
307
  }
318
308
 
319
309
  function unionOptionSchemas(schema) {
@@ -369,6 +359,21 @@ function isImageSchema(schema) {
369
359
  }
370
360
  }
371
361
 
362
+ function isVideoSchema(schema) {
363
+ const unwrapped = unwrapSchema(schema);
364
+ if (unwrapped === VideoSchema) return true;
365
+ try {
366
+ const compiled = z.toJSONSchema(unwrapped, {
367
+ target: 'draft-2020-12',
368
+ unrepresentable: 'throw',
369
+ });
370
+ return compiled?.type === 'object'
371
+ && compiled?.properties?.$type?.const === 'gradial.video';
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+
372
377
  function unwrapSchema(schema) {
373
378
  let current = schema;
374
379
  const seen = new Set();
@@ -451,6 +456,25 @@ async function generatePageSchema(schemaDir, componentEntries, outDir) {
451
456
  alt: { type: 'string' },
452
457
  },
453
458
  },
459
+ staticAssetRef: {
460
+ type: 'object',
461
+ required: ['$type', 'src'],
462
+ additionalProperties: true,
463
+ properties: {
464
+ $type: { const: 'static.assetRef' },
465
+ src: { type: 'string', minLength: 1 },
466
+ alt: { type: 'string' },
467
+ mediaType: { type: 'string' },
468
+ width: { type: 'number' },
469
+ height: { type: 'number' },
470
+ },
471
+ },
472
+ assetRef: {
473
+ oneOf: [
474
+ { $ref: '#/$defs/damAssetRef' },
475
+ { $ref: '#/$defs/staticAssetRef' },
476
+ ],
477
+ },
454
478
  };
455
479
  const oneOf = [];
456
480
  for (const entry of componentEntries) {
@@ -524,25 +548,26 @@ function replaceImagesWithDamAssetRef(schema, damAssetRef) {
524
548
  }
525
549
  if (schema.properties) {
526
550
  for (const [key, value] of Object.entries(schema.properties)) {
527
- if (isJsonImage(value)) {
528
- schema.properties[key] = { $ref: '#/$defs/damAssetRef' };
551
+ if (isJsonAssetRef(value)) {
552
+ schema.properties[key] = { $ref: '#/$defs/assetRef' };
529
553
  } else {
530
554
  replaceImagesWithDamAssetRef(value, damAssetRef);
531
555
  }
532
556
  }
533
557
  }
534
558
  if (schema.items) {
535
- if (isJsonImage(schema.items)) {
536
- schema.items = { $ref: '#/$defs/damAssetRef' };
559
+ if (isJsonAssetRef(schema.items)) {
560
+ schema.items = { $ref: '#/$defs/assetRef' };
537
561
  } else {
538
562
  replaceImagesWithDamAssetRef(schema.items, damAssetRef);
539
563
  }
540
564
  }
541
565
  }
542
566
 
543
- function isJsonImage(node) {
544
- return node?.type === 'object'
545
- && node?.properties?.$type?.const === 'gradial.image';
567
+ function isJsonAssetRef(node) {
568
+ if (node?.type !== 'object' || !node?.properties?.$type) return false;
569
+ const constVal = node.properties.$type.const;
570
+ return constVal === 'gradial.image' || constVal === 'gradial.video';
546
571
  }
547
572
 
548
573
  function validateZodSubset(schema, pathLabel, seen = new Set()) {
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const projectRoot = process.argv[2] || '.';
7
+
8
+ const checks = [];
9
+ const missing = [];
10
+
11
+ function checkFile(label, relativePath, validate, message) {
12
+ const fullPath = path.join(projectRoot, relativePath);
13
+ if (!fs.existsSync(fullPath)) {
14
+ missing.push(`✗ ${label}: ${relativePath} not found`);
15
+ return;
16
+ }
17
+ const content = fs.readFileSync(fullPath, 'utf-8');
18
+ if (!validate(content)) {
19
+ checks.push(`✗ ${label}: ${message}`);
20
+ return;
21
+ }
22
+ checks.push(`✓ ${label}`);
23
+ }
24
+
25
+ function checkMissing(label, relativePath, message) {
26
+ const fullPath = path.join(projectRoot, relativePath);
27
+ if (fs.existsSync(fullPath)) {
28
+ checks.push(`✗ ${label}: ${relativePath} should be removed. ${message}`);
29
+ return;
30
+ }
31
+ checks.push(`✓ ${label}`);
32
+ }
33
+
34
+ function checkOptionalFile(label, relativePath, validate, message) {
35
+ const fullPath = path.join(projectRoot, relativePath);
36
+ if (!fs.existsSync(fullPath)) {
37
+ checks.push(`- ${label}: ${relativePath} not found (optional)`);
38
+ return;
39
+ }
40
+ const content = fs.readFileSync(fullPath, 'utf-8');
41
+ if (!validate(content)) {
42
+ checks.push(`✗ ${label}: ${message}`);
43
+ return;
44
+ }
45
+ checks.push(`✓ ${label}`);
46
+ }
47
+
48
+ checkFile(
49
+ 'middleware is configured',
50
+ 'src/middleware.ts',
51
+ (content) => (content.includes('@gradial/aci/next/middleware') || content.includes('createGradialMiddleware'))
52
+ && !content.includes('|__r'),
53
+ 'Expected src/middleware.ts to use @gradial/aci/next/middleware and not exclude __r; middleware blocks direct /__r requests.',
54
+ );
55
+
56
+ checkFile(
57
+ 'catch-all page route exists',
58
+ 'src/app/[[...slug]]/page.tsx',
59
+ (content) => content.includes('createPage') || content.includes('getRenderInput'),
60
+ 'Expected catch-all page to use createPage() from the SDK.',
61
+ );
62
+
63
+ checkFile(
64
+ 'public asset route exists',
65
+ 'src/app/gradial/assets/[...path]/route.ts',
66
+ (content) => content.includes('@gradial/aci/next/asset-route'),
67
+ 'Expected public asset route to re-export @gradial/aci/next/asset-route.',
68
+ );
69
+
70
+ checkMissing(
71
+ 'legacy release-scoped asset route is removed',
72
+ 'src/app/gradial/assets/[releaseId]/[...path]/route.ts',
73
+ 'Use src/app/gradial/assets/[...path]/route.ts instead. Assets are now public, not release-scoped.',
74
+ );
75
+
76
+ checkMissing(
77
+ 'no separate __r route folder',
78
+ 'src/app/__r/[releaseId]/routes/[[...slug]]/page.tsx',
79
+ 'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.',
80
+ );
81
+
82
+ checkFile(
83
+ 'next.config uses withAci',
84
+ 'next.config.mjs',
85
+ (content) => content.includes('withAci'),
86
+ 'Expected next.config.mjs to use withAci() from @gradial/aci/next.',
87
+ );
88
+
89
+ for (const line of checks) {
90
+ console.log(line);
91
+ }
92
+ for (const line of missing) {
93
+ console.log(line);
94
+ }
95
+
96
+ const failed = [...checks.filter((line) => line.startsWith('✗')), ...missing];
97
+ if (failed.length > 0) {
98
+ console.error(`\n${failed.length} check(s) failed`);
99
+ process.exit(1);
100
+ }
101
+ console.log('\nAll route checks passed');
@@ -0,0 +1,55 @@
1
+ import type { ImageSlotContract } from './image.js';
2
+ import type { BlockRefMeta } from './block-ref.js';
3
+ import type { z } from 'zod';
4
+
5
+ type InferContentProps<TSchema> = TSchema extends z.ZodType<infer T> ? T : Record<string, unknown>;
6
+
7
+ export interface ComponentContractValidator<TProps = Record<string, unknown>> {
8
+ (context: ComponentValidationContext<TProps>): void | Promise<void>;
9
+ }
10
+
11
+ export interface ComponentValidationContext<TProps = Record<string, unknown>> {
12
+ component: string;
13
+ props: TProps;
14
+ blockId?: string;
15
+ path: readonly string[];
16
+ report(issue: ComponentValidationIssue): void;
17
+ }
18
+
19
+ export interface ComponentValidationIssue {
20
+ path?: readonly string[];
21
+ code?: string;
22
+ message: string;
23
+ severity?: 'error' | 'warning';
24
+ }
25
+
26
+ export interface ComponentDefinition<TId extends string = string, TSchema = unknown> {
27
+ id: TId;
28
+ displayName?: string;
29
+ props: TSchema;
30
+ imageSlots?: Record<string, ImageSlotContract>;
31
+ validate?: readonly ComponentContractValidator<InferContentProps<TSchema>>[];
32
+ }
33
+
34
+ export interface ComponentContract<TId extends string = string, TSchema = unknown> {
35
+ readonly id: TId;
36
+ readonly displayName?: string;
37
+ props: TSchema;
38
+ blockSlots?: Record<string, BlockRefMeta>;
39
+ imageSlots?: Record<string, ImageSlotContract>;
40
+ validate: readonly ComponentContractValidator<InferContentProps<TSchema>>[];
41
+ }
42
+
43
+ export type InferComponentProps<TContract> = TContract extends ComponentContract<string, infer TSchema>
44
+ ? InferContentProps<TSchema>
45
+ : Record<string, unknown>;
46
+
47
+ export type ComponentContractID<TContract> = TContract extends ComponentContract<infer TId, unknown>
48
+ ? TId
49
+ : never;
50
+
51
+ export type ComponentRegistry<TContracts extends Record<string, ComponentContract>> = {
52
+ readonly [K in keyof TContracts]: TContracts[K] & { readonly id: K & string };
53
+ };
54
+
55
+ export type { InferContentProps };
@@ -0,0 +1,37 @@
1
+ import type { RenderCapabilities } from './renderer.js';
2
+
3
+ export interface Config {
4
+ version: '1';
5
+ siteId: string;
6
+ framework: 'astro' | 'next' | 'sveltekit' | 'custom';
7
+ source: {
8
+ root: string;
9
+ outDir?: string;
10
+ publicDir?: string;
11
+ };
12
+ componentRegistry: string;
13
+ dataRegistry?: string;
14
+ layoutRegistry: string;
15
+ capabilities: RenderCapabilities;
16
+ routes: RouteConfig;
17
+ dam?: DAMConfig;
18
+ externalDependencies?: ExternalDependency[];
19
+ rendererProtocol: 'http' | 'stdio-json';
20
+ }
21
+
22
+ export interface RouteConfig {
23
+ cmsManaged: string;
24
+ frameworkOwned?: string[];
25
+ }
26
+
27
+ export interface ExternalDependency {
28
+ name: string;
29
+ host: string;
30
+ kind?: 'api' | 'client-script';
31
+ description?: string;
32
+ cacheTTL?: number;
33
+ }
34
+
35
+ export interface DAMConfig {
36
+ autoApproveUploads?: boolean;
37
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+
3
+ type InferDataProps<TSchema> = TSchema extends z.ZodType<infer T>
4
+ ? T
5
+ : Record<string, unknown>;
6
+
7
+ const dataRefPattern = /^\$ref:data\/[A-Za-z0-9][A-Za-z0-9_./-]*$/;
8
+
9
+ export interface DataModelDefinition<TSchema = unknown, TType extends string = string> {
10
+ type: TType;
11
+ schema: TSchema;
12
+ }
13
+
14
+ export interface DataModelContract<TSchema = unknown, TType extends string = string> {
15
+ type: TType;
16
+ schema: TSchema;
17
+ }
18
+
19
+ export interface DataEntry<TProps = Record<string, unknown>, TType extends string = string> {
20
+ $type: TType;
21
+ props: TProps;
22
+ }
23
+
24
+ export type InferDataModelProps<TContract> = TContract extends DataModelContract<infer TSchema, string>
25
+ ? InferDataProps<TSchema>
26
+ : Record<string, unknown>;
27
+
28
+ export type InferDataModelType<TContract> = TContract extends DataModelContract<unknown, infer TType>
29
+ ? TType
30
+ : string;
31
+
32
+ export type DataModelRef<TContract extends DataModelContract> = DataEntry<
33
+ InferDataModelProps<TContract>,
34
+ InferDataModelType<TContract>
35
+ >;
36
+
37
+ export function dataModelRef<TContract extends DataModelContract<z.ZodTypeAny>>(
38
+ contract: TContract,
39
+ ): z.ZodType<DataModelRef<TContract>> {
40
+ return z.union([
41
+ z.string().regex(dataRefPattern, `expected a $ref:data/... pointer for ${contract.type}`),
42
+ z.object({
43
+ $type: z.literal(contract.type),
44
+ props: contract.schema,
45
+ }),
46
+ ]).describe(`data model reference: ${contract.type}`) as unknown as z.ZodType<DataModelRef<TContract>>;
47
+ }
@@ -22,15 +22,16 @@ export interface ImageSource {
22
22
  // Image — DAM-backed image asset with compiler-resolved derivatives
23
23
  //
24
24
  // Content authors write an asset reference: { "$type": "dam.assetRef", ... }
25
- // The compiler resolves it to this Image value based on the asset's media type.
25
+ // or for static files: { "$type": "static.assetRef", "src": "/path" }
26
+ // The compiler resolves both to this Image value.
26
27
  // ---------------------------------------------------------------------------
27
28
 
28
29
  export interface Image {
29
30
  /** Discriminator — always 'gradial.image'. */
30
31
  $type: 'gradial.image';
31
- /** DAM asset identifier. */
32
+ /** DAM asset identifier (or "static:<path>" for non-DAM assets). */
32
33
  assetId: string;
33
- /** DAM version identifier. */
34
+ /** DAM version identifier (or "static" for non-DAM assets). */
34
35
  versionId: string;
35
36
  /** Accessible description of the image content. */
36
37
  alt: string;
@@ -0,0 +1,11 @@
1
+ export * from './config.js';
2
+ export * from './component.js';
3
+ export * from './data.js';
4
+ export * from './layout.js';
5
+ export * from './page.js';
6
+ export * from './rich-text.js';
7
+ export * from './renderer.js';
8
+ export * from './render-mode.js';
9
+ export * from './image.js';
10
+ export * from './video.js';
11
+ 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,100 @@
1
+ import { z } from 'zod';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Link — internal or external hyperlink target
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type LinkTarget = '_self' | '_blank' | '_parent' | '_top';
8
+
9
+ export interface Link {
10
+ href: string;
11
+ target?: LinkTarget;
12
+ title?: string;
13
+ rel?: string;
14
+ }
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Zod schema
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export const LinkSchema = z.object({
21
+ href: z.string().min(1),
22
+ target: z.enum(['_self', '_blank', '_parent', '_top']).optional(),
23
+ title: z.string().optional(),
24
+ rel: z.string().optional(),
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Link resolver — turns a reference string into a Link object
29
+ //
30
+ // Supported reference schemes:
31
+ // aci:page:{slug} → resolved by the consumer (e.g. Next.js/Astro router)
32
+ // aci:external:{url} → external URL
33
+ // {url} → literal URL
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface LinkResolverContext {
37
+ /** Resolved path for an aci:page:{slug} reference, if found. */
38
+ resolvePageSlug?: (slug: string) => string | undefined;
39
+ }
40
+
41
+ export function resolveLink(
42
+ reference: string | Link,
43
+ ctx: LinkResolverContext = {},
44
+ ): Link {
45
+ if (typeof reference !== 'string') return reference;
46
+
47
+ if (reference.startsWith('aci:external:')) {
48
+ return { href: reference.slice('aci:external:'.length), target: '_blank' };
49
+ }
50
+
51
+ if (reference.startsWith('aci:page:')) {
52
+ const slug = reference.slice('aci:page:'.length);
53
+ const path = ctx.resolvePageSlug?.(slug);
54
+ return { href: path ?? `/${slug}` };
55
+ }
56
+
57
+ return { href: reference };
58
+ }
59
+
60
+ /**
61
+ * Resolve a Link object to its final href string.
62
+ * An optional resolver can perform async or domain-specific resolution.
63
+ */
64
+ export function resolveHref(
65
+ link: Link,
66
+ resolver?: (link: Link) => string | Promise<string>,
67
+ ): string | Promise<string> {
68
+ if (resolver) {
69
+ return resolver(link);
70
+ }
71
+ return link.href;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Shared helpers — consumed by React, Astro, and Svelte components
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Throw if the href uses an unsafe protocol (javascript:, data:, vbscript:).
80
+ */
81
+ export function assertSafeHref(href: string): void {
82
+ const protocol = href.split(':')[0].toLowerCase();
83
+ if (protocol === 'javascript' || protocol === 'data' || protocol === 'vbscript') {
84
+ throw new Error(`unsafe Link protocol: ${protocol}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Normalize the rel attribute for a Link.
90
+ * Adds noopener noreferrer for target="_blank" automatically.
91
+ */
92
+ export function normalizeLinkRel(link: Link): string | undefined {
93
+ if (link.target !== '_blank') {
94
+ return link.rel;
95
+ }
96
+ const rel = new Set((link.rel ?? '').split(/\s+/).filter(Boolean));
97
+ rel.add('noopener');
98
+ rel.add('noreferrer');
99
+ return [...rel].join(' ');
100
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { ImageSchema, type Image } from './image.js';
3
+ import { VideoSchema, type Video } from './video.js';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Asset — union of all compiler-resolved asset types
7
+ //
8
+ // Content authors write either:
9
+ // { "$type": "dam.assetRef", "assetId": "..." } — resolved via DAM
10
+ // { "$type": "static.assetRef", "src": "/path" } — resolved as-is
11
+ //
12
+ // The compiler normalizes both into Image or Video values.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Discriminated union of all compiler-resolved asset types. */
16
+ export type Asset = Image | Video;
17
+
18
+ /** Zod schema for a resolved asset value. */
19
+ export const AssetSchema = z.union([ImageSchema, VideoSchema]);
20
+
21
+ /** Runtime guard: value is a resolved asset. */
22
+ export function isAsset(value: unknown): value is Asset {
23
+ return ImageSchema.safeParse(value).success || VideoSchema.safeParse(value).success;
24
+ }
25
+
26
+ /** Runtime guard: value is a resolved image asset. */
27
+ export function isImage(value: unknown): value is Image {
28
+ return ImageSchema.safeParse(value).success;
29
+ }
30
+
31
+ /** Runtime guard: value is a resolved video asset. */
32
+ export function isVideo(value: unknown): value is Video {
33
+ return VideoSchema.safeParse(value).success;
34
+ }
@@ -0,0 +1,47 @@
1
+ import type { VaryDimension } from './render-mode.js';
2
+
3
+ export interface PageDocument {
4
+ path: string;
5
+ layout: string;
6
+ locale: string;
7
+ metadata: PageMetadata;
8
+ regions: Record<string, RegionNode>;
9
+ }
10
+
11
+ export interface PageMetadata {
12
+ title: string;
13
+ description?: string;
14
+ openGraph?: Record<string, string>;
15
+ canonicalUrl?: string;
16
+ alternateLocales?: { locale: string; path: string }[];
17
+ customHead?: string[];
18
+ }
19
+
20
+ export interface RegionNode {
21
+ kind: 'region';
22
+ name: string;
23
+ children: Array<BlockNode | FragmentRefNode>;
24
+ }
25
+
26
+ export interface BlockNode {
27
+ kind: 'block';
28
+ id: string;
29
+ component: string;
30
+ props: Record<string, unknown>;
31
+ renderHints: BlockRenderHints;
32
+ contentRef?: string;
33
+ }
34
+
35
+ export interface BlockRenderHints {
36
+ canStatic: boolean;
37
+ needsSSR: boolean;
38
+ isIsland: boolean;
39
+ islandMode?: 'ssr' | 'client';
40
+ varyDimensions?: VaryDimension[];
41
+ }
42
+
43
+ export interface FragmentRefNode {
44
+ kind: 'fragment-ref';
45
+ fragmentId: string;
46
+ inline: boolean;
47
+ }
@@ -0,0 +1,10 @@
1
+ export type IslandMode = 'ssr' | 'client';
2
+
3
+ export type VaryDimension =
4
+ | 'geo:country'
5
+ | 'geo:region'
6
+ | 'geo:city'
7
+ | `cookie:${string}`
8
+ | `header:${string}`
9
+ | 'locale'
10
+ | `query:${string}`;