@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
package/README.md CHANGED
@@ -5,6 +5,28 @@ installing `@gradial/aci`, Zod 4, and TypeScript tooling. The
5
5
  SDK exposes component and layout helpers, renderer types, compiler tooling,
6
6
  content provider helpers, and framework-specific subpaths from one package.
7
7
 
8
+ ## Getting Started
9
+
10
+ The fastest way to create a new ACI site is via the scaffolding script:
11
+
12
+ ```bash
13
+ npx tsx scripts/create-site.ts --framework astro --name my-site --dir ./my-site
14
+ cd my-site
15
+ npm run dev
16
+ ```
17
+
18
+ Available frameworks: `astro`, `nextjs`, `sveltekit`.
19
+
20
+ Generated starters include:
21
+ - `.aci/docs/` — Understanding ACI, running, and testing guides
22
+ - `.aci/guides/` — Component development, design system, content authoring
23
+ - `.aci/migration/` — Migrating existing sites to ACI
24
+ - Design system tokens, ESLint rules, and demo content
25
+
26
+ See the [templates directory](templates/) for the canonical starter source.
27
+
28
+ ## Installation
29
+
8
30
  ```bash
9
31
  npm install @gradial/aci zod@^4
10
32
  npm install -D typescript tsx
package/bin/aci CHANGED
Binary file
@@ -33,9 +33,9 @@ export declare function getGradialStaticPaths(): Promise<Array<{
33
33
  };
34
34
  }>>;
35
35
  export declare function getPageRuntimeRenderInput(request: Request): RenderInput | null;
36
+ export { getPreviewMode, createPreviewBannerHTML, createPreviewCookies, type PreviewContext, type PreviewCookie, } from './preview.js';
36
37
  export interface GradialAstroOptions extends GradialContentWatchOptions {
37
38
  /** Path to compiled content root. Defaults to ACI_CONTENT_ROOT env or '.aci/compiled' */
38
39
  compiledRoot?: string;
39
40
  }
40
- export declare function gradialAstro(options?: GradialAstroOptions): AstroIntegrationLike;
41
- export {};
41
+ export declare function withGradialAci(options?: GradialAstroOptions): AstroIntegrationLike;
@@ -31,7 +31,11 @@ export async function getGradialStaticPaths() {
31
31
  export function getPageRuntimeRenderInput(request) {
32
32
  return getPendingRenderInputFromHeaders(request.headers);
33
33
  }
34
- export function gradialAstro(options = {}) {
34
+ // ---------------------------------------------------------------------------
35
+ // Preview support
36
+ // ---------------------------------------------------------------------------
37
+ export { getPreviewMode, createPreviewBannerHTML, createPreviewCookies, } from './preview.js';
38
+ export function withGradialAci(options = {}) {
35
39
  return {
36
40
  name: 'gradial-astro',
37
41
  hooks: {
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach } from '@jest/globals';
2
+ import { getCachedSiteConfig, getCachedManifest, clearProviderCache, getCacheStats, resetCacheStats } from './cache.js';
3
+ describe('Content Cache', () => {
4
+ let mockProvider;
5
+ let getSiteConfigCallCount = 0;
6
+ let manifestCallCount = 0;
7
+ beforeEach(() => {
8
+ getSiteConfigCallCount = 0;
9
+ manifestCallCount = 0;
10
+ resetCacheStats();
11
+ mockProvider = {
12
+ getSiteConfig: async () => {
13
+ getSiteConfigCallCount++;
14
+ return { title: 'Test Site' };
15
+ },
16
+ manifest: async () => {
17
+ manifestCallCount++;
18
+ return {
19
+ routes: {},
20
+ fragments: {},
21
+ siteConfigRef: 'site.json'
22
+ };
23
+ },
24
+ getPage: async () => ({}),
25
+ getFragment: async () => ({}),
26
+ listRoutes: async () => []
27
+ };
28
+ });
29
+ it('should cache siteConfig across multiple calls', async () => {
30
+ const result1 = await getCachedSiteConfig(mockProvider);
31
+ const result2 = await getCachedSiteConfig(mockProvider);
32
+ expect(getSiteConfigCallCount).toBe(1); // Only called once
33
+ expect(result1).toEqual(result2);
34
+ const stats = getCacheStats();
35
+ expect(stats.siteConfig.hits).toBe(1);
36
+ expect(stats.siteConfig.misses).toBe(1);
37
+ });
38
+ it('should cache manifest across multiple calls', async () => {
39
+ const result1 = await getCachedManifest(mockProvider);
40
+ const result2 = await getCachedManifest(mockProvider);
41
+ expect(manifestCallCount).toBe(1); // Only called once
42
+ expect(result1).toEqual(result2);
43
+ const stats = getCacheStats();
44
+ expect(stats.manifest.hits).toBe(1);
45
+ expect(stats.manifest.misses).toBe(1);
46
+ });
47
+ it('should clear cache for a provider', async () => {
48
+ await getCachedSiteConfig(mockProvider);
49
+ expect(getSiteConfigCallCount).toBe(1);
50
+ clearProviderCache(mockProvider);
51
+ await getCachedSiteConfig(mockProvider);
52
+ expect(getSiteConfigCallCount).toBe(2); // Called again after clear
53
+ const stats = getCacheStats();
54
+ expect(stats.siteConfig.misses).toBe(2);
55
+ });
56
+ it('should not cache rejected promises', async () => {
57
+ const errorProvider = {
58
+ getSiteConfig: async () => {
59
+ getSiteConfigCallCount++;
60
+ throw new Error('S3 timeout');
61
+ },
62
+ getPage: async () => ({}),
63
+ getFragment: async () => ({}),
64
+ listRoutes: async () => []
65
+ };
66
+ await expect(getCachedSiteConfig(errorProvider)).rejects.toThrow('S3 timeout');
67
+ expect(getSiteConfigCallCount).toBe(1);
68
+ // Second call should retry (not cached)
69
+ await expect(getCachedSiteConfig(errorProvider)).rejects.toThrow('S3 timeout');
70
+ expect(getSiteConfigCallCount).toBe(2);
71
+ });
72
+ it('should cache separately per provider instance', async () => {
73
+ const provider2 = {
74
+ getSiteConfig: async () => ({ title: 'Site 2' }),
75
+ getPage: async () => ({}),
76
+ getFragment: async () => ({}),
77
+ listRoutes: async () => []
78
+ };
79
+ const result1 = await getCachedSiteConfig(mockProvider);
80
+ const result2 = await getCachedSiteConfig(provider2);
81
+ expect(result1).toEqual({ title: 'Test Site' });
82
+ expect(result2).toEqual({ title: 'Site 2' });
83
+ const stats = getCacheStats();
84
+ expect(stats.siteConfig.misses).toBe(2); // Both are cache misses
85
+ });
86
+ });
@@ -0,0 +1,2 @@
1
+ import type { DataModelContract, DataModelDefinition } from './types/data.js';
2
+ export declare function defineDataModel<TSchema = unknown, TType extends string = string>(definition: DataModelDefinition<TSchema, TType>): DataModelContract<TSchema, TType>;
@@ -0,0 +1,3 @@
1
+ export function defineDataModel(definition) {
2
+ return { ...definition };
3
+ }
@@ -7,10 +7,21 @@ declare global {
7
7
  }
8
8
  }
9
9
  export interface GradialContentWatchOptions extends DevRefreshOptions {
10
+ /** Source content root to watch. Defaults to ACI_CONTENT_SOURCE env or '.content' */
10
11
  contentRoot?: string;
12
+ /** Compiled content output root. Defaults to ACI_CONTENT_ROOT env or '.aci/compiled' */
11
13
  compiledRoot?: string;
14
+ /** Path to the ACI CLI binary. Defaults to ACI_BIN env or 'aci' */
15
+ aciBin?: string;
16
+ /** Disable content watching entirely. Defaults to ACI_DISABLE_CONTENT_WATCH env */
12
17
  enabled?: boolean;
13
18
  }
19
+ interface ViteModuleGraph {
20
+ idToModuleMap: Map<unknown, {
21
+ id: string | null;
22
+ }>;
23
+ invalidateModule(mod: unknown): void;
24
+ }
14
25
  interface ViteDevServerLike {
15
26
  middlewares?: {
16
27
  use(path: string, handler: (req: IncomingMessage, res: ServerResponse, next: () => void) => void): void;
@@ -22,6 +33,7 @@ interface ViteDevServerLike {
22
33
  ws: {
23
34
  send(payload: unknown): void;
24
35
  };
36
+ moduleGraph?: ViteModuleGraph;
25
37
  }
26
38
  export interface VitePluginLike {
27
39
  name: string;
package/dist/dev/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import path from 'node:path';
3
4
  import { compiledContentRoot } from '../content/routes.js';
4
5
  import { DEFAULT_DEV_REFRESH_PATH, DEFAULT_DEV_REFRESH_PORT, } from './browser.js';
@@ -48,23 +49,97 @@ export function devRefreshScript(options = {}) {
48
49
  export function devRefreshScriptTag(options = {}) {
49
50
  return `<script>${devRefreshScript(options)}</script>`;
50
51
  }
52
+ function sourceContentRoot(options) {
53
+ return path.resolve(options.contentRoot ||
54
+ process.env.ACI_CONTENT_SOURCE ||
55
+ './.content');
56
+ }
57
+ function resolveAciBin(options) {
58
+ return options.aciBin || process.env.ACI_BIN || 'aci';
59
+ }
60
+ function recompileContent(options, reason) {
61
+ const siteDir = process.cwd();
62
+ const src = sourceContentRoot(options);
63
+ const out = path.resolve(options.compiledRoot ||
64
+ process.env.ACI_CONTENT_ROOT ||
65
+ '.aci/compiled');
66
+ const bin = resolveAciBin(options);
67
+ console.log(`[aci] compiling content (${reason})`);
68
+ const result = spawnSync(bin, [
69
+ '-s', siteDir,
70
+ 'build',
71
+ '--content', src,
72
+ '--out', out,
73
+ '--skip-code',
74
+ ], {
75
+ cwd: siteDir,
76
+ stdio: 'inherit',
77
+ env: process.env,
78
+ });
79
+ if (result.status !== 0) {
80
+ console.error(`[aci] content compile failed with exit code ${result.status ?? 'unknown'}`);
81
+ return false;
82
+ }
83
+ return true;
84
+ }
51
85
  export function gradialContentWatchPlugin(options = {}) {
52
86
  const enabled = options.enabled ?? process.env.ACI_DISABLE_CONTENT_WATCH !== '1';
87
+ if (!enabled) {
88
+ return {
89
+ name: 'gradial-content-watch-disabled',
90
+ apply: 'serve',
91
+ };
92
+ }
93
+ let timer;
94
+ let running = false;
95
+ let queued = false;
96
+ function rebuild(server, reason) {
97
+ if (running) {
98
+ queued = true;
99
+ return;
100
+ }
101
+ running = true;
102
+ const success = recompileContent(options, reason);
103
+ running = false;
104
+ if (success) {
105
+ if (server.moduleGraph) {
106
+ for (const mod of server.moduleGraph.idToModuleMap.values()) {
107
+ server.moduleGraph.invalidateModule(mod);
108
+ }
109
+ }
110
+ server.ws.send({ type: 'full-reload' });
111
+ }
112
+ if (queued) {
113
+ queued = false;
114
+ rebuild(server, 'queued change');
115
+ }
116
+ }
53
117
  return {
54
118
  name: 'gradial-content-watch',
55
119
  apply: 'serve',
56
120
  configureServer(server) {
57
- if (!enabled) {
58
- return;
59
- }
60
- const root = path.resolve(options.contentRoot || compiledContentRoot());
61
- server.watcher.add(root);
62
- server.watcher.on('all', (_eventName, filePath) => {
121
+ const src = sourceContentRoot(options);
122
+ const compiled = path.resolve(options.compiledRoot ||
123
+ process.env.ACI_CONTENT_ROOT ||
124
+ compiledContentRoot());
125
+ server.watcher.add(src);
126
+ server.watcher.add(compiled);
127
+ rebuild(server, 'startup');
128
+ server.watcher.on('all', (_event, filePath) => {
63
129
  const changed = path.resolve(filePath);
64
- const relative = path.relative(root, changed);
65
- if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
66
- server.ws.send({ type: 'full-reload', path: '*' });
130
+ const isSource = changed.startsWith(src + path.sep);
131
+ const isCompiled = changed.startsWith(compiled + path.sep);
132
+ if (!isSource && !isCompiled)
133
+ return;
134
+ if (isCompiled) {
135
+ server.ws.send({ type: 'full-reload' });
136
+ return;
67
137
  }
138
+ clearTimeout(timer);
139
+ timer = setTimeout(() => {
140
+ const relative = path.relative(src, changed);
141
+ rebuild(server, relative);
142
+ }, 100);
68
143
  });
69
144
  },
70
145
  };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './block-ref.js';
2
2
  export * from './define-component.js';
3
+ export * from './define-data-model.js';
3
4
  export * from './define-layout.js';
4
5
  export * from './types/index.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './block-ref.js';
2
2
  export * from './define-component.js';
3
+ export * from './define-data-model.js';
3
4
  export * from './define-layout.js';
4
5
  export * from './types/index.js';
5
6
  // Note: content/ is NOT re-exported here because it imports node:path,
@@ -3,4 +3,4 @@ export interface WithGradialOptions {
3
3
  /** Asset rewrite prefix. Defaults to '/.gradial-dam' */
4
4
  assetPrefix?: string;
5
5
  }
6
- export declare function withGradial(nextConfig?: NextConfig, options?: WithGradialOptions): NextConfig;
6
+ export declare function withGradialAci(nextConfig?: NextConfig, options?: WithGradialOptions): NextConfig;
@@ -1,6 +1,8 @@
1
- export function withGradial(nextConfig = {}, options = {}) {
1
+ import { createContentWatchWebpackPlugin } from './content-watch.js';
2
+ export function withGradialAci(nextConfig = {}, options = {}) {
2
3
  const assetPrefix = options.assetPrefix || '/.gradial-dam';
3
4
  const userRewrites = nextConfig.rewrites;
5
+ const userWebpack = nextConfig.webpack;
4
6
  return {
5
7
  ...nextConfig,
6
8
  rewrites: async () => {
@@ -21,5 +23,15 @@ export function withGradial(nextConfig = {}, options = {}) {
21
23
  beforeFiles: [...gradialRewrites, ...(userResult.beforeFiles ?? [])],
22
24
  };
23
25
  },
26
+ webpack: (config, context) => {
27
+ if (context?.isServer && process.env.NODE_ENV !== 'production') {
28
+ if (!config.plugins)
29
+ config.plugins = [];
30
+ config.plugins.push(createContentWatchWebpackPlugin());
31
+ }
32
+ return typeof userWebpack === 'function'
33
+ ? userWebpack(config, context)
34
+ : config;
35
+ },
24
36
  };
25
37
  }
@@ -0,0 +1,11 @@
1
+ interface ContentWatchWebpackPlugin {
2
+ apply(compiler: {
3
+ hooks: {
4
+ [key: string]: {
5
+ tap: (name: string, fn: () => void) => void;
6
+ };
7
+ };
8
+ }): void;
9
+ }
10
+ export declare function createContentWatchWebpackPlugin(): ContentWatchWebpackPlugin;
11
+ export {};
@@ -0,0 +1,106 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import { WebSocketServer } from 'ws';
6
+ import { DEFAULT_DEV_REFRESH_PATH, DEFAULT_DEV_REFRESH_PORT, } from '../dev/browser.js';
7
+ function sourceContentRoot() {
8
+ return path.resolve(process.env.ACI_CONTENT_SOURCE || './.content');
9
+ }
10
+ function compiledContentRoot() {
11
+ return path.resolve(process.env.ACI_CONTENT_ROOT || '.aci/compiled');
12
+ }
13
+ function resolveAciBin() {
14
+ return process.env.ACI_BIN || 'aci';
15
+ }
16
+ function recompileContent(reason) {
17
+ const siteDir = process.cwd();
18
+ const src = sourceContentRoot();
19
+ const out = compiledContentRoot();
20
+ const bin = resolveAciBin();
21
+ console.log(`[aci] compiling content (${reason})`);
22
+ const result = spawnSync(bin, [
23
+ '-s', siteDir,
24
+ 'build',
25
+ '--content', src,
26
+ '--out', out,
27
+ '--skip-code',
28
+ ], {
29
+ cwd: siteDir,
30
+ stdio: 'inherit',
31
+ env: process.env,
32
+ });
33
+ if (result.status !== 0) {
34
+ console.error(`[aci] content compile failed with exit code ${result.status ?? 'unknown'}`);
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+ let watcherStarted = false;
40
+ function startContentWatcher() {
41
+ if (watcherStarted)
42
+ return;
43
+ watcherStarted = true;
44
+ const enabled = process.env.ACI_DISABLE_CONTENT_WATCH !== '1';
45
+ if (!enabled)
46
+ return;
47
+ const src = sourceContentRoot();
48
+ const wsPort = Number(process.env.ACI_DEV_WS_PORT || process.env.NEXT_PUBLIC_ACI_DEV_WS_PORT || DEFAULT_DEV_REFRESH_PORT);
49
+ const wsPath = process.env.ACI_DEV_WS_PATH || DEFAULT_DEV_REFRESH_PATH;
50
+ let timer;
51
+ let running = false;
52
+ let queued = false;
53
+ const server = http.createServer();
54
+ const wss = new WebSocketServer({ server, path: wsPath });
55
+ server.listen(wsPort, () => {
56
+ console.log(`[aci] dev refresh WebSocket on port ${wsPort}`);
57
+ });
58
+ function notifyClients() {
59
+ for (const client of wss.clients) {
60
+ if (client.readyState === 1) {
61
+ client.send(JSON.stringify({ type: 'content-updated' }));
62
+ }
63
+ }
64
+ }
65
+ function rebuild(reason) {
66
+ if (running) {
67
+ queued = true;
68
+ return;
69
+ }
70
+ running = true;
71
+ const success = recompileContent(reason);
72
+ running = false;
73
+ if (success) {
74
+ notifyClients();
75
+ }
76
+ if (queued) {
77
+ queued = false;
78
+ rebuild('queued change');
79
+ }
80
+ }
81
+ rebuild('startup');
82
+ try {
83
+ fs.watch(src, { recursive: true }, (_event, filename) => {
84
+ if (!filename)
85
+ return;
86
+ clearTimeout(timer);
87
+ timer = setTimeout(() => {
88
+ rebuild(path.relative(src, path.resolve(src, filename)));
89
+ }, 100);
90
+ });
91
+ }
92
+ catch {
93
+ console.warn('[aci] fs.watch not supported on this platform, content watch disabled');
94
+ }
95
+ }
96
+ export function createContentWatchWebpackPlugin() {
97
+ return {
98
+ apply(compiler) {
99
+ compiler.hooks['beforeCompile']?.tap('gradial-content-watch', () => {
100
+ if (process.env.NODE_ENV !== 'production') {
101
+ startContentWatcher();
102
+ }
103
+ });
104
+ },
105
+ };
106
+ }
@@ -1,4 +1,4 @@
1
1
  export { DevRefresh, type DevRefreshProps } from './dev-refresh.js';
2
- export { withGradial, type WithGradialOptions } from './config.js';
3
- export { PreviewBanner, type PreviewBannerProps } from './preview-banner.js';
2
+ export { withGradialAci, type WithGradialOptions } from './config.js';
4
3
  export { isPreviewMode } from './preview-mode.js';
4
+ export { createPreviewBannerHTML, createPreviewCookies, extractPreviewToken, resolvePreviewContext, PREVIEW_HEADER, RELEASE_HEADER, PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, type PreviewContext, type PreviewClaims, type PreviewCookie, } from '../preview/core.js';
@@ -1,4 +1,4 @@
1
1
  export { DevRefresh } from './dev-refresh.js';
2
- export { withGradial } from './config.js';
3
- export { PreviewBanner } from './preview-banner.js';
2
+ export { withGradialAci } from './config.js';
4
3
  export { isPreviewMode } from './preview-mode.js';
4
+ export { createPreviewBannerHTML, createPreviewCookies, extractPreviewToken, resolvePreviewContext, PREVIEW_HEADER, RELEASE_HEADER, PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, } from '../preview/core.js';
@@ -1,7 +1,6 @@
1
1
  import { NextResponse } from 'next/server.js';
2
2
  import { getUncachedEdgeConfigValue } from './edge-config.js';
3
- import { validatePreviewToken } from './preview.js';
4
- const RELEASE_HEADER = 'x-gradial-release-id';
3
+ import { PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, PREVIEW_HEADER, RELEASE_HEADER, extractPreviewToken, resolvePreviewContext, createPreviewCookies, previewSignKey, } from '../preview/core.js';
5
4
  const ROUTE_HEADER = 'x-gradial-route';
6
5
  const DISPATCH_HEADER = 'x-gradial-dispatch';
7
6
  export function createGradialMiddleware(config) {
@@ -11,40 +10,30 @@ export function createGradialMiddleware(config) {
11
10
  if (url.searchParams.get('leave_preview') === '1') {
12
11
  const cleanUrl = new URL(url.pathname, url.origin);
13
12
  const response = NextResponse.redirect(cleanUrl);
14
- response.cookies.delete('aci_preview');
15
- response.cookies.delete('aci_preview_release');
13
+ response.cookies.delete(PREVIEW_TOKEN_COOKIE);
14
+ response.cookies.delete(PREVIEW_RELEASE_COOKIE);
16
15
  return response;
17
16
  }
18
17
  const requestHeaders = gradialHeaders(request.headers);
19
18
  // Try preview token first; if valid, use that release.
20
19
  // If no token or invalid, fall through to active release from edge config.
21
- const previewReleaseId = await releaseIdFromPreviewToken(request, config);
22
- const releaseId = previewReleaseId || await activeReleaseFromEdgeConfig(config);
20
+ const preview = await resolvePreviewFromRequest(request, config);
21
+ const releaseId = preview?.releaseId || await activeReleaseFromEdgeConfig(config);
23
22
  if (!releaseId) {
24
23
  return NextResponse.next({ request: { headers: requestHeaders } });
25
24
  }
26
25
  requestHeaders.set(RELEASE_HEADER, releaseId);
27
26
  requestHeaders.set(ROUTE_HEADER, url.pathname);
28
27
  requestHeaders.set(DISPATCH_HEADER, 'ssr-page');
29
- if (previewReleaseId) {
30
- requestHeaders.set('x-gradial-preview', '1');
28
+ if (preview?.isPreview) {
29
+ requestHeaders.set(PREVIEW_HEADER, '1');
31
30
  }
32
31
  const response = NextResponse.next({ request: { headers: requestHeaders } });
33
32
  // Persist preview token as cookie so navigation stays in preview
34
- const queryToken = url.searchParams.get('preview_token');
35
- if (queryToken && previewReleaseId) {
36
- response.cookies.set('aci_preview', queryToken, {
37
- httpOnly: true,
38
- sameSite: 'lax',
39
- maxAge: 60 * 60,
40
- path: '/',
41
- });
42
- response.cookies.set('aci_preview_release', previewReleaseId, {
43
- httpOnly: false,
44
- sameSite: 'lax',
45
- maxAge: 60 * 60,
46
- path: '/',
47
- });
33
+ if (preview?.token && preview.isPreview) {
34
+ for (const cookie of createPreviewCookies(preview.token, releaseId)) {
35
+ response.cookies.set(cookie.name, cookie.value, cookie.options);
36
+ }
48
37
  }
49
38
  return response;
50
39
  };
@@ -54,8 +43,31 @@ function gradialHeaders(headers) {
54
43
  next.delete(RELEASE_HEADER);
55
44
  next.delete(ROUTE_HEADER);
56
45
  next.delete(DISPATCH_HEADER);
46
+ next.delete(PREVIEW_HEADER);
57
47
  return next;
58
48
  }
49
+ async function resolvePreviewFromRequest(request, config) {
50
+ const token = extractPreviewToken({
51
+ query: request.nextUrl.searchParams,
52
+ cookies: cookieMap(request.cookies),
53
+ authorization: request.headers.get('authorization') || undefined,
54
+ });
55
+ if (!token)
56
+ return null;
57
+ const signKey = previewSignKey();
58
+ if (!signKey) {
59
+ console.warn('[gradial/mw] GRADIAL_PREVIEW_SIGN_KEY not set — preview tokens will be ignored');
60
+ return null;
61
+ }
62
+ return await resolvePreviewContext({ token, siteId: config.siteId, signKey });
63
+ }
64
+ function cookieMap(cookies) {
65
+ const map = new Map();
66
+ for (const cookie of cookies.getAll()) {
67
+ map.set(cookie.name, cookie.value);
68
+ }
69
+ return map;
70
+ }
59
71
  async function activeReleaseFromEdgeConfig(config) {
60
72
  const edgeConfig = config.edgeConfig || process.env.EDGE_CONFIG || '';
61
73
  if (!edgeConfig || !config.siteId)
@@ -126,31 +138,3 @@ function clampRelease(activeReleaseId, activeSequence, compatibility) {
126
138
  function edgeKeySegment(value) {
127
139
  return value.replace(/[^A-Za-z0-9_-]/g, '_');
128
140
  }
129
- async function releaseIdFromPreviewToken(request, config) {
130
- const token = previewTokenFromRequest(request);
131
- if (!token)
132
- return '';
133
- const signKey = process.env.GRADIAL_PREVIEW_SIGN_KEY || '';
134
- if (!signKey) {
135
- console.warn('[gradial/mw] GRADIAL_PREVIEW_SIGN_KEY not set — preview tokens will be ignored');
136
- return '';
137
- }
138
- const claims = await validatePreviewToken(token, signKey);
139
- if (!claims || claims.scope !== 'preview' || !claims.releaseId)
140
- return '';
141
- if (claims.siteId && config.siteId && claims.siteId !== config.siteId)
142
- return '';
143
- if (!claims.expiresAt || Date.parse(claims.expiresAt) <= Date.now())
144
- return '';
145
- return claims.releaseId;
146
- }
147
- function previewTokenFromRequest(request) {
148
- const queryToken = request.nextUrl.searchParams.get('preview_token');
149
- if (queryToken)
150
- return queryToken;
151
- const cookieToken = request.cookies.get('aci_preview')?.value || '';
152
- if (cookieToken)
153
- return cookieToken;
154
- const auth = request.headers.get('authorization') || '';
155
- return auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : '';
156
- }