@gradial/aci 0.1.19 → 0.1.20-preview.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 (46) hide show
  1. package/README.md +22 -0
  2. package/bin/aci +0 -0
  3. package/dist/astro/index.d.ts +2 -2
  4. package/dist/astro/index.js +5 -1
  5. package/dist/astro/preview.d.ts +5 -0
  6. package/dist/astro/preview.js +33 -0
  7. package/dist/content/cache.test.d.ts +1 -0
  8. package/dist/content/cache.test.js +86 -0
  9. package/dist/define-data-model.d.ts +2 -0
  10. package/dist/define-data-model.js +3 -0
  11. package/dist/dev/index.d.ts +12 -0
  12. package/dist/dev/index.js +84 -9
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/next/config.d.ts +1 -1
  16. package/dist/next/config.js +13 -1
  17. package/dist/next/content-watch.d.ts +11 -0
  18. package/dist/next/content-watch.js +106 -0
  19. package/dist/next/index.d.ts +2 -2
  20. package/dist/next/index.js +2 -2
  21. package/dist/next/middleware.js +34 -50
  22. package/dist/next/page.d.ts +40 -0
  23. package/dist/next/page.js +59 -0
  24. package/dist/next/preview-mode.js +4 -8
  25. package/dist/next/server.js +0 -6
  26. package/dist/preview/core.d.ts +41 -0
  27. package/dist/preview/core.js +116 -0
  28. package/dist/sveltekit/index.d.ts +2 -1
  29. package/dist/sveltekit/index.js +5 -1
  30. package/dist/sveltekit/preview.d.ts +5 -0
  31. package/dist/sveltekit/preview.js +33 -0
  32. package/dist/types/config.d.ts +1 -0
  33. package/dist/types/data.d.ts +19 -0
  34. package/dist/types/data.js +11 -0
  35. package/dist/types/image.d.ts +1 -1
  36. package/dist/types/image.js +1 -1
  37. package/dist/types/index.d.ts +1 -0
  38. package/dist/types/index.js +1 -0
  39. package/package.json +24 -1
  40. package/src/cli/compile-registry.mjs +40 -1
  41. package/src/types/config.ts +1 -0
  42. package/src/types/data.ts +47 -0
  43. package/src/types/image.ts +2 -2
  44. package/src/types/index.ts +1 -0
  45. package/dist/render.d.ts +0 -14
  46. package/dist/render.js +0 -33
@@ -0,0 +1,40 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { Metadata } from 'next';
3
+ import { type KernelPage, type KernelSiteConfig } from '../content/index.js';
4
+ export interface AciPageProps {
5
+ page: KernelPage;
6
+ siteConfig: KernelSiteConfig;
7
+ }
8
+ export interface CreateAciPageOptions {
9
+ /** The site's CmsPage component that renders chrome + content blocks. */
10
+ CmsPage: ComponentType<AciPageProps>;
11
+ }
12
+ type NextPageProps = {
13
+ params: Promise<{
14
+ slug?: string[];
15
+ }>;
16
+ };
17
+ /**
18
+ * Creates the catch-all route handler for a Next.js ACI site.
19
+ *
20
+ * Usage in `src/app/[[...slug]]/page.tsx`:
21
+ *
22
+ * ```tsx
23
+ * import { createAciPage } from '@gradial/aci/next/page';
24
+ * import { CmsPage } from '@/components/CmsPage';
25
+ *
26
+ * const page = createAciPage({ CmsPage });
27
+ * export const dynamic = page.dynamic;
28
+ * export const generateMetadata = page.generateMetadata;
29
+ * export default page.default;
30
+ * ```
31
+ *
32
+ * The SDK owns param normalization, content loading, metadata generation,
33
+ * and notFound handling. The site only provides its CmsPage component.
34
+ */
35
+ export declare function createAciPage({ CmsPage }: CreateAciPageOptions): {
36
+ dynamic: "force-dynamic";
37
+ generateMetadata: ({ params }: NextPageProps) => Promise<Metadata>;
38
+ default: ({ params }: NextPageProps) => Promise<import("react").JSX.Element>;
39
+ };
40
+ export {};
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { notFound } from 'next/navigation.js';
3
+ import { PageNotFoundError, routeFromNextParams, getRenderInput, } from './server.js';
4
+ import { routeMetadataForContent, } from '../content/index.js';
5
+ /**
6
+ * Creates the catch-all route handler for a Next.js ACI site.
7
+ *
8
+ * Usage in `src/app/[[...slug]]/page.tsx`:
9
+ *
10
+ * ```tsx
11
+ * import { createAciPage } from '@gradial/aci/next/page';
12
+ * import { CmsPage } from '@/components/CmsPage';
13
+ *
14
+ * const page = createAciPage({ CmsPage });
15
+ * export const dynamic = page.dynamic;
16
+ * export const generateMetadata = page.generateMetadata;
17
+ * export default page.default;
18
+ * ```
19
+ *
20
+ * The SDK owns param normalization, content loading, metadata generation,
21
+ * and notFound handling. The site only provides its CmsPage component.
22
+ */
23
+ export function createAciPage({ CmsPage }) {
24
+ const dynamic = 'force-dynamic';
25
+ async function generateMetadata({ params }) {
26
+ const route = await routeFromNextParams(params);
27
+ const input = await getRenderInput(route);
28
+ const meta = routeMetadataForContent(input.siteConfig, input.page);
29
+ return {
30
+ title: meta.title,
31
+ description: meta.description,
32
+ alternates: meta.canonical ? { canonical: meta.canonical } : undefined,
33
+ openGraph: {
34
+ title: meta.title,
35
+ description: meta.description,
36
+ siteName: meta.siteName,
37
+ },
38
+ };
39
+ }
40
+ async function CatchAllPage({ params }) {
41
+ const route = await routeFromNextParams(params);
42
+ try {
43
+ const input = await getRenderInput(route);
44
+ if (!input.page || input.page.status !== 'published')
45
+ notFound();
46
+ return _jsx(CmsPage, { page: input.page, siteConfig: input.siteConfig });
47
+ }
48
+ catch (error) {
49
+ if (error instanceof PageNotFoundError)
50
+ notFound();
51
+ throw error;
52
+ }
53
+ }
54
+ return {
55
+ dynamic,
56
+ generateMetadata,
57
+ default: CatchAllPage,
58
+ };
59
+ }
@@ -1,13 +1,9 @@
1
1
  import { headers } from 'next/headers.js';
2
- const RELEASE_HEADER = 'x-gradial-release-id';
3
- const PREVIEW_HEADER = 'x-gradial-preview';
2
+ import { PREVIEW_HEADER, RELEASE_HEADER } from '../preview/core.js';
4
3
  export async function isPreviewMode() {
5
- try {
6
- const current = await headers();
7
- if (current.get(PREVIEW_HEADER) === '1') {
8
- return current.get(RELEASE_HEADER) || '';
9
- }
4
+ const current = await headers();
5
+ if (current.get(PREVIEW_HEADER) === '1') {
6
+ return current.get(RELEASE_HEADER) || '';
10
7
  }
11
- catch { }
12
8
  return '';
13
9
  }
@@ -37,23 +37,17 @@ export async function getRoutes(config = {}) {
37
37
  return routes.map((route) => route.path).sort();
38
38
  }
39
39
  export async function getRenderInput(route = '/', config = {}) {
40
- const startTime = Date.now();
41
40
  const runtimeInput = await getPageRuntimeRenderInput();
42
41
  if (runtimeInput) {
43
42
  return runtimeInput;
44
43
  }
45
- const providerStart = Date.now();
46
44
  const provider = await resolveProviderWithFallback(config);
47
- console.log(`[ACI SSR] Provider resolved in ${Date.now() - providerStart}ms`);
48
45
  // Cache shared resources (manifest, siteConfig) but not per-page content
49
46
  // Page-level caching is handled by CDN
50
- const fetchStart = Date.now();
51
47
  const [page, siteConfig] = await Promise.all([
52
48
  provider.getPage(route),
53
49
  getCachedSiteConfig(provider),
54
50
  ]);
55
- console.log(`[ACI SSR] Content fetched in ${Date.now() - fetchStart}ms`);
56
- console.log(`[ACI SSR] Total getRenderInput for ${route}: ${Date.now() - startTime}ms`);
57
51
  return {
58
52
  route,
59
53
  domain: siteConfig.domain || 'www.aci.local',
@@ -0,0 +1,41 @@
1
+ export interface PreviewClaims {
2
+ scope?: string;
3
+ releaseId?: string;
4
+ siteId?: string;
5
+ expiresAt?: string;
6
+ }
7
+ export interface PreviewTokenSource {
8
+ query?: URLSearchParams;
9
+ cookies?: Map<string, string> | Record<string, string>;
10
+ authorization?: string;
11
+ }
12
+ export interface PreviewResolveOptions {
13
+ siteId: string;
14
+ signKey?: string;
15
+ token?: string;
16
+ }
17
+ export interface PreviewContext {
18
+ isPreview: boolean;
19
+ releaseId?: string;
20
+ token?: string;
21
+ }
22
+ export interface PreviewCookie {
23
+ name: string;
24
+ value: string;
25
+ options: {
26
+ httpOnly: boolean;
27
+ sameSite: 'lax' | 'strict' | 'none';
28
+ maxAge: number;
29
+ path: string;
30
+ };
31
+ }
32
+ export declare const PREVIEW_HEADER = "x-gradial-preview";
33
+ export declare const RELEASE_HEADER = "x-gradial-release-id";
34
+ export declare const PREVIEW_TOKEN_COOKIE = "aci_preview";
35
+ export declare const PREVIEW_RELEASE_COOKIE = "aci_preview_release";
36
+ export declare function validatePreviewToken(token: string, signKey: string): Promise<PreviewClaims | null>;
37
+ export declare function extractPreviewToken(source: PreviewTokenSource): string | undefined;
38
+ export declare function previewSignKey(): string | undefined;
39
+ export declare function resolvePreviewContext(options: PreviewResolveOptions): Promise<PreviewContext>;
40
+ export declare function createPreviewCookies(token: string, releaseId: string): PreviewCookie[];
41
+ export declare function createPreviewBannerHTML(releaseId: string): string;
@@ -0,0 +1,116 @@
1
+ export const PREVIEW_HEADER = 'x-gradial-preview';
2
+ export const RELEASE_HEADER = 'x-gradial-release-id';
3
+ export const PREVIEW_TOKEN_COOKIE = 'aci_preview';
4
+ export const PREVIEW_RELEASE_COOKIE = 'aci_preview_release';
5
+ export async function validatePreviewToken(token, signKey) {
6
+ try {
7
+ const parts = token.split('.');
8
+ if (parts.length !== 3)
9
+ return null;
10
+ const signingInput = `${parts[0]}.${parts[1]}`;
11
+ const signature = base64URLToBytes(parts[2]);
12
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(signKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
13
+ const expected = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)));
14
+ if (!constantTimeEqual(signature, expected))
15
+ return null;
16
+ const payload = new TextDecoder().decode(base64URLToBytes(parts[1]));
17
+ return JSON.parse(payload);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function extractPreviewToken(source) {
24
+ const query = source.query?.get('preview_token');
25
+ if (query)
26
+ return query;
27
+ const cookies = source.cookies;
28
+ if (cookies) {
29
+ if (cookies instanceof Map) {
30
+ const cookie = cookies.get(PREVIEW_TOKEN_COOKIE);
31
+ if (cookie)
32
+ return cookie;
33
+ }
34
+ else {
35
+ const cookie = cookies[PREVIEW_TOKEN_COOKIE];
36
+ if (cookie)
37
+ return cookie;
38
+ }
39
+ }
40
+ const auth = source.authorization || '';
41
+ if (auth.startsWith('Bearer '))
42
+ return auth.slice('Bearer '.length);
43
+ return undefined;
44
+ }
45
+ export function previewSignKey() {
46
+ return process.env.GRADIAL_PREVIEW_SIGN_KEY || undefined;
47
+ }
48
+ export async function resolvePreviewContext(options) {
49
+ const token = options.token;
50
+ if (!token)
51
+ return { isPreview: false };
52
+ const signKey = options.signKey || previewSignKey();
53
+ if (!signKey) {
54
+ throw new Error('GRADIAL_PREVIEW_SIGN_KEY is not set; preview tokens cannot be validated.');
55
+ }
56
+ const claims = await validatePreviewToken(token, signKey);
57
+ if (!claims || claims.scope !== 'preview' || !claims.releaseId) {
58
+ return { isPreview: false };
59
+ }
60
+ if (claims.siteId && options.siteId && claims.siteId !== options.siteId) {
61
+ return { isPreview: false };
62
+ }
63
+ if (!claims.expiresAt || Date.parse(claims.expiresAt) <= Date.now()) {
64
+ return { isPreview: false };
65
+ }
66
+ return { isPreview: true, releaseId: claims.releaseId, token };
67
+ }
68
+ export function createPreviewCookies(token, releaseId) {
69
+ const common = {
70
+ sameSite: 'lax',
71
+ maxAge: 60 * 60,
72
+ path: '/',
73
+ };
74
+ return [
75
+ {
76
+ name: PREVIEW_TOKEN_COOKIE,
77
+ value: token,
78
+ options: { ...common, httpOnly: true },
79
+ },
80
+ {
81
+ name: PREVIEW_RELEASE_COOKIE,
82
+ value: releaseId,
83
+ options: { ...common, httpOnly: false },
84
+ },
85
+ ];
86
+ }
87
+ export function createPreviewBannerHTML(releaseId) {
88
+ const shortId = releaseId.length > 20
89
+ ? `${releaseId.slice(0, 8)}\u2026${releaseId.slice(-6)}`
90
+ : releaseId;
91
+ return `<div role="alert" style="position:fixed;top:0;left:0;right:0;z-index:9999;display:flex;align-items:center;justify-content:center;gap:12px;padding:8px 16px;background-color:#1a1a2e;color:#fff;font-size:13px;font-family:system-ui,-apple-system,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,0.3);">
92
+ <span aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background-color:#f59e0b;color:#1a1a2e;font-weight:700;font-size:11px;flex-shrink:0;">P</span>
93
+ <span style="font-weight:500;">Release Preview</span>
94
+ <span style="font-family:monospace;opacity:0.7;font-size:12px;" title="${releaseId}">${shortId}</span>
95
+ <a href="?leave_preview=1" style="margin-left:auto;padding:4px 12px;border-radius:6px;background-color:#dc2626;color:#fff;text-decoration:none;font-size:12px;font-weight:600;white-space:nowrap;">Exit Preview</a>
96
+ </div>`;
97
+ }
98
+ function base64URLToBytes(value) {
99
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
100
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
101
+ const binary = atob(padded);
102
+ const bytes = new Uint8Array(binary.length);
103
+ for (let i = 0; i < binary.length; i++) {
104
+ bytes[i] = binary.charCodeAt(i);
105
+ }
106
+ return bytes;
107
+ }
108
+ function constantTimeEqual(left, right) {
109
+ if (left.length !== right.length)
110
+ return false;
111
+ let diff = 0;
112
+ for (let i = 0; i < left.length; i++) {
113
+ diff |= left[i] ^ right[i];
114
+ }
115
+ return diff === 0;
116
+ }
@@ -19,4 +19,5 @@ export declare function gradialEntries(): Promise<string[]>;
19
19
  export declare function getPageRuntimeRenderInput(event: {
20
20
  request: Request;
21
21
  }): RenderInput | null;
22
- export declare function gradialSvelteKit(options?: GradialContentWatchOptions): VitePluginLike;
22
+ export { getPreviewMode, createPreviewBannerHTML, createPreviewCookies, type PreviewContext, type PreviewCookie, } from './preview.js';
23
+ export declare function withGradialAci(options?: GradialContentWatchOptions): VitePluginLike;
@@ -32,9 +32,13 @@ export function getPageRuntimeRenderInput(event) {
32
32
  return getPendingRenderInputFromHeaders(event.request.headers);
33
33
  }
34
34
  // ---------------------------------------------------------------------------
35
+ // Preview support
36
+ // ---------------------------------------------------------------------------
37
+ export { getPreviewMode, createPreviewBannerHTML, createPreviewCookies, } from './preview.js';
38
+ // ---------------------------------------------------------------------------
35
39
  // SvelteKit Vite plugin
36
40
  // ---------------------------------------------------------------------------
37
- export function gradialSvelteKit(options = {}) {
41
+ export function withGradialAci(options = {}) {
38
42
  const watchPlugin = gradialContentWatchPlugin(options);
39
43
  const damAssetPlugin = gradialDamAssetPlugin(options);
40
44
  return {
@@ -0,0 +1,5 @@
1
+ import { type PreviewContext, type PreviewCookie } from '../preview/core.js';
2
+ export declare function getPreviewMode(request: Request, siteId: string): Promise<PreviewContext>;
3
+ export declare function createPreviewBannerHTML(releaseId: string): string;
4
+ export declare function createPreviewCookies(token: string, releaseId: string): PreviewCookie[];
5
+ export type { PreviewContext, PreviewCookie };
@@ -0,0 +1,33 @@
1
+ import { extractPreviewToken, resolvePreviewContext, createPreviewBannerHTML as coreCreatePreviewBannerHTML, createPreviewCookies as coreCreatePreviewCookies, previewSignKey, } from '../preview/core.js';
2
+ export async function getPreviewMode(request, siteId) {
3
+ const url = new URL(request.url);
4
+ const token = extractPreviewToken({
5
+ query: url.searchParams,
6
+ cookies: parseCookieHeader(request.headers.get('cookie') || ''),
7
+ authorization: request.headers.get('authorization') || undefined,
8
+ });
9
+ if (!token)
10
+ return { isPreview: false };
11
+ return resolvePreviewContext({ token, siteId, signKey: previewSignKey() });
12
+ }
13
+ export function createPreviewBannerHTML(releaseId) {
14
+ return coreCreatePreviewBannerHTML(releaseId);
15
+ }
16
+ export function createPreviewCookies(token, releaseId) {
17
+ return coreCreatePreviewCookies(token, releaseId);
18
+ }
19
+ function parseCookieHeader(header) {
20
+ const cookies = new Map();
21
+ for (const part of header.split(';')) {
22
+ const trimmed = part.trim();
23
+ if (!trimmed)
24
+ continue;
25
+ const idx = trimmed.indexOf('=');
26
+ if (idx < 0)
27
+ continue;
28
+ const name = trimmed.slice(0, idx).trim();
29
+ const value = trimmed.slice(idx + 1).trim();
30
+ cookies.set(name, decodeURIComponent(value));
31
+ }
32
+ return cookies;
33
+ }
@@ -9,6 +9,7 @@ export interface BareMetalConfig {
9
9
  publicDir?: string;
10
10
  };
11
11
  componentRegistry: string;
12
+ dataRegistry?: string;
12
13
  layoutRegistry: string;
13
14
  rendererEntry: string;
14
15
  capabilities: RenderCapabilities;
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ type InferDataProps<TSchema> = TSchema extends z.ZodType<infer T> ? T : Record<string, unknown>;
3
+ export interface DataModelDefinition<TSchema = unknown, TType extends string = string> {
4
+ type: TType;
5
+ schema: TSchema;
6
+ }
7
+ export interface DataModelContract<TSchema = unknown, TType extends string = string> {
8
+ type: TType;
9
+ schema: TSchema;
10
+ }
11
+ export interface DataEntry<TProps = Record<string, unknown>, TType extends string = string> {
12
+ $type: TType;
13
+ props: TProps;
14
+ }
15
+ export type InferDataModelProps<TContract> = TContract extends DataModelContract<infer TSchema, string> ? InferDataProps<TSchema> : Record<string, unknown>;
16
+ export type InferDataModelType<TContract> = TContract extends DataModelContract<unknown, infer TType> ? TType : string;
17
+ export type DataModelRef<TContract extends DataModelContract> = DataEntry<InferDataModelProps<TContract>, InferDataModelType<TContract>>;
18
+ export declare function dataModelRef<TContract extends DataModelContract<z.ZodTypeAny>>(contract: TContract): z.ZodType<DataModelRef<TContract>>;
19
+ export {};
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ const dataRefPattern = /^\$ref:data\/[A-Za-z0-9][A-Za-z0-9_./-]*$/;
3
+ export function dataModelRef(contract) {
4
+ return z.union([
5
+ z.string().regex(dataRefPattern, `expected a $ref:data/... pointer for ${contract.type}`),
6
+ z.object({
7
+ $type: z.literal(contract.type),
8
+ props: contract.schema,
9
+ }),
10
+ ]).describe(`data model reference: ${contract.type}`);
11
+ }
@@ -17,7 +17,7 @@ export interface GradialImage {
17
17
  versionId: string;
18
18
  alt: string;
19
19
  fallback: ImageSource;
20
- sources: PictureSource[];
20
+ sources: PictureSource[] | null;
21
21
  }
22
22
  export interface ImageSlotContract {
23
23
  outputs: SlotOutput[];
@@ -26,7 +26,7 @@ export function renderImageHTML(image, attrs = {}) {
26
26
  height: image.fallback.height > 0 ? image.fallback.height : undefined,
27
27
  });
28
28
  const img = `<img${imgAttrs}>`;
29
- if (!image.sources.length) {
29
+ if (!image.sources?.length) {
30
30
  return img;
31
31
  }
32
32
  const sources = image.sources.map((source) => `<source${renderAttrs({
@@ -1,5 +1,6 @@
1
1
  export * from './config.js';
2
2
  export * from './component.js';
3
+ export * from './data.js';
3
4
  export * from './layout.js';
4
5
  export * from './page.js';
5
6
  export * from './renderer.js';
@@ -1,5 +1,6 @@
1
1
  export * from './config.js';
2
2
  export * from './component.js';
3
+ export * from './data.js';
3
4
  export * from './layout.js';
4
5
  export * from './page.js';
5
6
  export * from './renderer.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gradial/aci",
3
- "version": "0.1.19",
3
+ "version": "0.1.20-preview.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -35,10 +35,18 @@
35
35
  "types": "./dist/content/contract.d.ts",
36
36
  "import": "./dist/content/contract.js"
37
37
  },
38
+ "./content/cache": {
39
+ "types": "./dist/content/cache.d.ts",
40
+ "import": "./dist/content/cache.js"
41
+ },
38
42
  "./content/validation": {
39
43
  "types": "./dist/content/validation.d.ts",
40
44
  "import": "./dist/content/validation.js"
41
45
  },
46
+ "./content/cache": {
47
+ "types": "./dist/content/cache.d.ts",
48
+ "import": "./dist/content/cache.js"
49
+ },
42
50
  "./content/tailwind-validator": {
43
51
  "types": "./dist/content/tailwind-validator.d.ts",
44
52
  "import": "./dist/content/tailwind-validator.js"
@@ -71,11 +79,21 @@
71
79
  "types": "./dist/astro/index.d.ts",
72
80
  "import": "./dist/astro/index.js"
73
81
  },
82
+ "./preview": {
83
+ "types": "./dist/preview/core.d.ts",
84
+ "import": "./dist/preview/core.js",
85
+ "default": "./dist/preview/core.js"
86
+ },
74
87
  "./next": {
75
88
  "types": "./dist/next/index.d.ts",
76
89
  "import": "./dist/next/index.js",
77
90
  "default": "./dist/next/index.js"
78
91
  },
92
+ "./next/page": {
93
+ "types": "./dist/next/page.d.ts",
94
+ "import": "./dist/next/page.js",
95
+ "default": "./dist/next/page.js"
96
+ },
79
97
  "./next/server": {
80
98
  "types": "./dist/next/server.d.ts",
81
99
  "import": "./dist/next/server.js",
@@ -115,6 +133,9 @@
115
133
  "overrides": {
116
134
  "postcss": "^8.5.10"
117
135
  },
136
+ "dependencies": {
137
+ "ws": "^8.18.0"
138
+ },
118
139
  "peerDependencies": {
119
140
  "@vercel/edge-config": "^1.4.3",
120
141
  "next": "^15.5.0",
@@ -135,11 +156,13 @@
135
156
  "devDependencies": {
136
157
  "@types/node": "^24.10.1",
137
158
  "@types/react": "^19.0.0",
159
+ "@types/ws": "^8.18.0",
138
160
  "@vercel/edge-config": "^1.4.3",
139
161
  "next": "^15.5.6",
140
162
  "react": "^19.0.0",
141
163
  "tsx": "^4.20.0",
142
164
  "typescript": "^5.9.3",
165
+ "ws": "^8.18.0",
143
166
  "zod": "^4.0.0"
144
167
  }
145
168
  }
@@ -5,6 +5,8 @@ import { pathToFileURL } from 'node:url';
5
5
  import { z } from 'zod';
6
6
  import { GradialImageSchema } from '../types/image.ts';
7
7
 
8
+ const DATA_TYPE_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
9
+
8
10
  // Shim React global so JSX-containing component files can be loaded.
9
11
  // The compile-registry only extracts contract metadata (schemas, imageSlots)
10
12
  // and never renders components, but importing .tsx files requires the JSX
@@ -33,6 +35,9 @@ async function compileRegistry(options) {
33
35
 
34
36
  const components = await loadDefaultArray(options.components, 'component registry');
35
37
  const layouts = await loadDefaultArray(options.layouts, 'layout registry');
38
+ const dataModels = options.data
39
+ ? await loadDefaultArray(options.data, 'data model registry')
40
+ : [];
36
41
  const outDir = path.resolve(options.output);
37
42
  const schemaDir = path.join(outDir, 'schemas');
38
43
  await mkdir(schemaDir, { recursive: true });
@@ -72,6 +77,24 @@ async function compileRegistry(options) {
72
77
  const seenLayouts = new Set();
73
78
  const layoutEntries = layouts.map((layout, index) => validateLayout(layout, index, seenLayouts));
74
79
 
80
+ const seenDataModels = new Set();
81
+ const dataModelEntries = [];
82
+ if (dataModels.length > 0) {
83
+ await mkdir(path.join(schemaDir, 'data'), { recursive: true });
84
+ }
85
+ for (const [index, dataModel] of dataModels.entries()) {
86
+ validateDataModel(dataModel, index, seenDataModels);
87
+ const schema = compileSchema(dataModel.schema, dataModel.type);
88
+ const schemaFile = `schemas/data/${dataModel.type}.schema.json`;
89
+ const schemaPath = path.join(outDir, schemaFile);
90
+ await writeFile(schemaPath, JSON.stringify(schema, null, 2) + '\n');
91
+ schemaFiles.push(schemaPath);
92
+ dataModelEntries.push({
93
+ type: dataModel.type,
94
+ schemaFile,
95
+ });
96
+ }
97
+
75
98
  // Post-compilation: detect nested block patterns in JSON schemas and
76
99
  // validate that referenced component names exist in the registry.
77
100
  const knownNames = new Set(componentEntries.map((c) => c.name));
@@ -90,6 +113,7 @@ async function compileRegistry(options) {
90
113
  generatedAt: new Date().toISOString(),
91
114
  components: componentEntries,
92
115
  layouts: layoutEntries,
116
+ dataModels: dataModelEntries,
93
117
  };
94
118
  const registryPath = path.join(outDir, 'registry.json');
95
119
  await writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
@@ -98,11 +122,26 @@ async function compileRegistry(options) {
98
122
 
99
123
  async function loadDefaultArray(modulePath, label) {
100
124
  const mod = await import(pathToFileURL(path.resolve(modulePath)).href);
101
- const value = mod.default ?? mod.registry ?? mod.components ?? mod.layouts;
125
+ const value = mod.default ?? mod.registry ?? mod.components ?? mod.layouts ?? mod.dataModels;
102
126
  if (!Array.isArray(value)) throw new Error(`${label} must export an array`);
103
127
  return value;
104
128
  }
105
129
 
130
+ function validateDataModel(dataModel, index, seen) {
131
+ if (!dataModel || typeof dataModel !== 'object') {
132
+ throw new Error(`dataModels[${index}] must be an object`);
133
+ }
134
+ if (typeof dataModel.type !== 'string' || !DATA_TYPE_PATTERN.test(dataModel.type)) {
135
+ throw new Error(`dataModels[${index}].type must match ${DATA_TYPE_PATTERN}`);
136
+ }
137
+ if (seen.has(dataModel.type)) {
138
+ throw new Error(`duplicate data model type ${dataModel.type}`);
139
+ }
140
+ seen.add(dataModel.type);
141
+ if (!dataModel.schema) throw new Error(`dataModels[${index}].schema is required`);
142
+ validateZodSubset(dataModel.schema, `dataModels[${index}].schema`);
143
+ }
144
+
106
145
  function validateComponent(component, index, seen) {
107
146
  if (!component || typeof component !== 'object') throw new Error(`components[${index}] must be an object`);
108
147
  if (!component.name) throw new Error(`components[${index}].name is required`);
@@ -10,6 +10,7 @@ export interface BareMetalConfig {
10
10
  publicDir?: string;
11
11
  };
12
12
  componentRegistry: string;
13
+ dataRegistry?: string;
13
14
  layoutRegistry: string;
14
15
  rendererEntry: string;
15
16
  capabilities: RenderCapabilities;
@@ -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
+ }