@gradial/aci 0.1.45 → 0.1.47

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 (90) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/error-page.d.ts +56 -0
  3. package/dist/astro/error-page.js +79 -0
  4. package/dist/astro/index.d.ts +4 -0
  5. package/dist/astro/index.js +9 -0
  6. package/dist/audience-data-models.d.ts +142 -2
  7. package/dist/audience-data-models.js +2 -0
  8. package/dist/cli/compile-registry.d.mts +1 -5
  9. package/dist/cli/compile-registry.mjs +10 -25
  10. package/dist/cli/emit-error-pages.d.mts +39 -0
  11. package/dist/cli/emit-error-pages.mjs +402 -0
  12. package/dist/cli/validate-content.d.mts +1 -31
  13. package/dist/cli/validate-content.mjs +12 -50
  14. package/dist/cli/verify-next-cdn-routes.mjs +1 -0
  15. package/dist/content/default-error-page-html.d.ts +2 -0
  16. package/dist/content/default-error-page-html.js +6 -0
  17. package/dist/content/default-error-page.d.ts +23 -0
  18. package/dist/content/default-error-page.js +34 -0
  19. package/dist/content/error-page-kind.d.ts +57 -0
  20. package/dist/content/error-page-kind.js +106 -0
  21. package/dist/content/error-pages.d.ts +88 -0
  22. package/dist/content/error-pages.js +120 -0
  23. package/dist/content/index.d.ts +2 -0
  24. package/dist/content/index.js +2 -0
  25. package/dist/content/provider.d.ts +1 -1
  26. package/dist/content/resolve-slots.js +0 -1
  27. package/dist/content/types.d.ts +12 -2
  28. package/dist/internal/render-proof.d.ts +1 -0
  29. package/dist/internal/render-proof.js +197 -0
  30. package/dist/next/config.js +17 -0
  31. package/dist/next/error-page-instrumentation.d.ts +23 -0
  32. package/dist/next/error-page-instrumentation.js +36 -0
  33. package/dist/next/error-page-marker.d.ts +11 -0
  34. package/dist/next/error-page-marker.js +20 -0
  35. package/dist/next/error-pages.d.ts +52 -0
  36. package/dist/next/error-pages.js +94 -0
  37. package/dist/next/error-seat-payload.d.ts +33 -0
  38. package/dist/next/error-seat-payload.js +143 -0
  39. package/dist/next/error-seat.d.ts +57 -0
  40. package/dist/next/error-seat.js +83 -0
  41. package/dist/next/index.d.ts +4 -0
  42. package/dist/next/index.js +8 -0
  43. package/dist/next/middleware.js +9 -54
  44. package/dist/next/page.js +9 -4
  45. package/dist/next/personalization-host.d.ts +5 -0
  46. package/dist/next/server.js +88 -75
  47. package/dist/next/vercel-collect-sdk-route.js +6 -30
  48. package/dist/next/vercel-edge-projection-v1.d.ts +24 -0
  49. package/dist/next/vercel-edge-projection-v1.js +880 -0
  50. package/dist/next/vercel-s3-personalization.js +7 -11
  51. package/dist/providers/s3.d.ts +2 -4
  52. package/dist/providers/s3.js +173 -19
  53. package/dist/types/block-ref.d.ts +0 -2
  54. package/dist/types/block-ref.js +0 -1
  55. package/package.json +15 -9
  56. package/src/cli/compile-registry.mjs +10 -24
  57. package/src/cli/emit-error-pages.mjs +424 -0
  58. package/src/cli/validate-content.mjs +13 -52
  59. package/src/cli/verify-next-cdn-routes.mjs +6 -0
  60. package/src/types/block-ref.ts +0 -2
  61. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +6 -7
  62. package/templates/astro/starter-content/src/fixtures/composition.ts +2 -3
  63. package/templates/astro/starter-content/src/fixtures/sections.ts +1 -2
  64. package/templates/astro/template/package.json.tmpl +1 -1
  65. package/templates/astro/template/src/aci/error-page-instrumentation.mjs +33 -0
  66. package/templates/astro/template/src/pages/404.astro +54 -0
  67. package/templates/astro/template/src/pages/500.astro +53 -0
  68. package/templates/astro/template/src/pages/[...slug].astro +15 -2
  69. package/templates/common/.agents/skills/authoring/SKILL.md +74 -11
  70. package/templates/common/.agents/skills/authoring/references/block-slots.md +0 -3
  71. package/templates/common/.content/config/robots.txt +4 -0
  72. package/templates/common/.content/config/site.json.tmpl +4 -0
  73. package/templates/common/.content/pages/404/_index.json +31 -0
  74. package/templates/common/.content/pages/error/_index.json +31 -0
  75. package/templates/common/.content/pages/home/_index.json +0 -10
  76. package/templates/common/.content/schemas/site.schema.json +9 -0
  77. package/templates/common/starter-content/.content/config/robots.txt +2 -0
  78. package/templates/common/starter-content/.content/config/site.json +4 -0
  79. package/templates/common/starter-content/.content/pages/404/_index.json +31 -0
  80. package/templates/common/starter-content/.content/pages/about/_index.json +0 -3
  81. package/templates/common/starter-content/.content/pages/error/_index.json +31 -0
  82. package/templates/common/starter-content/.content/pages/home/_index.json +21 -14
  83. package/templates/common/starter-content/.content/schemas/site.schema.json +9 -0
  84. package/templates/nextjs/template/package.json.tmpl +1 -1
  85. package/templates/nextjs/template/src/aci/error-page-instrumentation.d.mts +4 -0
  86. package/templates/nextjs/template/src/aci/error-page-instrumentation.mjs +38 -0
  87. package/templates/nextjs/template/src/app/error.tsx +18 -0
  88. package/templates/nextjs/template/src/app/not-found.tsx +13 -0
  89. package/src/types/block-ref.test.ts +0 -45
  90. package/templates/nextjs/template/src/app/collect/route.ts +0 -18
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env node
2
+ // Authorable error pages — build integration (spec:
3
+ // specs/features/authorable-error-pages, T3/T11; REQ-10/REQ-11/REQ-14, D-9,
4
+ // D-16).
5
+ //
6
+ // Two phases. The Next template wires both around `next build`; the Astro
7
+ // template runs only `prepare` before `astro build` (its 404/500 seats
8
+ // render through the framework, but the D-16 instrumentation artifact must
9
+ // still reach the compiled content root for the AWS lane):
10
+ //
11
+ // prepare (before `next build`)
12
+ // Ensures compiled content exists at .aci/compiled so withAci() can
13
+ // serialize the bound catchAll page for the client-only error seat
14
+ // (D-9). Compiles ./.content via the platform CLI when needed; when
15
+ // neither compiled content nor authored content exists, it warns and
16
+ // leaves the shipped default in play (REQ-4, warn posture). Then writes
17
+ // error-pages/instrumentation.json (both kinds' serialized tags) into
18
+ // the compiled content root so the tag crosses the JS→Go boundary as
19
+ // data: the AWS-lane code capsule packs source/.aci/compiled/** and the
20
+ // Go publish materializer stamps the kind-matching tag into the CDN
21
+ // error artifacts (D-16). An unserializable tenant push fails the build
22
+ // loudly here (delta FIND-006).
23
+ //
24
+ // emit (after `next build`)
25
+ // Materializes `public/500.html` from the page bound to
26
+ // site.json#errorPages.catchAll by booting the standalone build,
27
+ // fetching the bound route, and post-processing the document. Boot-and-
28
+ // fetch beats re-rendering the page here because the standalone server
29
+ // is the only renderer that produces the document exactly as served
30
+ // (layout shell, resolved fragments, RSC-injected head and styles).
31
+ // Post-processing:
32
+ // - all script tags and preload/prefetch links stripped — this
33
+ // removes the collect and assignment tags (REQ-11) along with the
34
+ // framework runtime, which is useless in a statically served copy;
35
+ // - same-origin stylesheets inlined from .next/static (REQ-14 spirit:
36
+ // inline styles; note @font-face/image URLs inside the CSS keep
37
+ // their same-origin paths — this copy is served by the Vercel
38
+ // platform on the deployment that owns those assets);
39
+ // - one self-contained inline script appended pushing the D-14
40
+ // `page_view` (page_type "error") — a static document executes
41
+ // inline scripts, unlike the flight-rendered not-found seat. The
42
+ // push body is site policy (D-15): the tenant-owned
43
+ // src/aci/error-page-instrumentation.mjs when present, the SDK
44
+ // default otherwise;
45
+ // - hard size budget: ≤150 KB raw (REQ-14) — exceeding it fails the
46
+ // build; trimming the design system or falling back to system
47
+ // fonts is the sanctioned escape hatch.
48
+ // Skips when the site already provides its own public/500.html (a file
49
+ // without the generation marker); regenerates its own prior output.
50
+ // Absent bindings emit the shipped branded-neutral default (REQ-4).
51
+ // Vercel picks static per-status files out of the build output
52
+ // ("file presence = configuration"); on the current Pro plan the file
53
+ // serves app-level 500s only and lights up for platform 5xx on a plan
54
+ // upgrade (REQ-10, P-1).
55
+ //
56
+ // Usage: node ./node_modules/@gradial/aci/src/cli/emit-error-pages.mjs <prepare|emit>
57
+ import { execFileSync, spawn } from 'node:child_process';
58
+ import fs from 'node:fs';
59
+ import net from 'node:net';
60
+ import path from 'node:path';
61
+ import { fileURLToPath, pathToFileURL } from 'node:url';
62
+ const MARKER = '<!-- aci:error-pages generated -->';
63
+ const SIZE_BUDGET = 150 * 1024;
64
+ const BOOT_DEADLINE_MS = 60_000;
65
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
66
+ const siteRoot = process.cwd();
67
+ const contentRoot = path.resolve(siteRoot, process.env.ACI_CONTENT_ROOT || '.aci/compiled');
68
+ function log(message) {
69
+ console.log(`[aci error-pages] ${message}`);
70
+ }
71
+ function warn(message) {
72
+ console.warn(`\n[aci error-pages] WARNING: ${message}\n`);
73
+ }
74
+ function fail(message) {
75
+ console.error(`\n[aci error-pages] ERROR: ${message}\n`);
76
+ process.exit(1);
77
+ }
78
+ async function sdkContent() {
79
+ return await import(pathToFileURL(path.join(packageRoot, 'dist', 'content', 'index.js')).href);
80
+ }
81
+ function hasCompiledContent() {
82
+ return fs.existsSync(path.join(contentRoot, 'manifest.json'));
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // prepare
86
+ // ---------------------------------------------------------------------------
87
+ async function prepare() {
88
+ if (hasCompiledContent()) {
89
+ log(`compiled content present at ${contentRoot}`);
90
+ }
91
+ else {
92
+ const authored = path.join(siteRoot, '.content');
93
+ if (!fs.existsSync(authored)) {
94
+ warn(`neither compiled content (${contentRoot}) nor authored content (./.content) exists; `
95
+ + 'the error seat and 500.html will serve the shipped default page (REQ-4)');
96
+ }
97
+ else {
98
+ log('compiling ./.content for the error-page build steps');
99
+ execFileSync('node', [path.join(packageRoot, 'bin', 'aci.js'), 'content', 'compile', '--content', authored, '--out', contentRoot], { cwd: siteRoot, stdio: 'inherit' });
100
+ }
101
+ }
102
+ // D-16: the instrumentation artifact rides the compiled content root into
103
+ // the AWS-lane code capsule (source/.aci/compiled/**). Written even when
104
+ // content is absent — the tenant policy module is independent of authored
105
+ // content, and an artifact the Go materializer never receives silently
106
+ // degrades to the default tags (the exact gap D-16 closes).
107
+ try {
108
+ await writeInstrumentationArtifact();
109
+ }
110
+ catch (error) {
111
+ fail(error.message);
112
+ }
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // emit
116
+ // ---------------------------------------------------------------------------
117
+ async function resolveCatchAllRoute() {
118
+ const { resolveErrorPageBindings, defaultErrorPageHtml, errorPagePostureFromEnv } = await sdkContent();
119
+ // D-5 posture: the one shared config surface (ACI_ERROR_PAGES_POSTURE);
120
+ // unknown values fail closed here just like the Go stack.
121
+ let posture;
122
+ try {
123
+ posture = errorPagePostureFromEnv();
124
+ }
125
+ catch (error) {
126
+ fail(error.message);
127
+ }
128
+ if (!hasCompiledContent()) {
129
+ if (posture === 'enforce') {
130
+ fail(`no compiled content at ${contentRoot}; the enforce posture fails closed (D-5)`);
131
+ }
132
+ warn(`no compiled content at ${contentRoot}; emitting the shipped default 500.html (REQ-4)`);
133
+ return { route: null, defaultHtml: defaultErrorPageHtml() };
134
+ }
135
+ const { FileContentProvider } = await import(pathToFileURL(path.join(packageRoot, 'dist', 'providers', 'file.js')).href);
136
+ const provider = new FileContentProvider(contentRoot);
137
+ const siteConfig = await provider.getSiteConfig();
138
+ const resolution = await resolveErrorPageBindings(siteConfig, { source: provider, posture });
139
+ if (resolution.errors.length > 0) {
140
+ fail(`errorPages bindings are invalid (REQ-5, fail closed):\n- ${resolution.errors.join('\n- ')}`);
141
+ }
142
+ for (const warning of resolution.warnings)
143
+ warn(warning);
144
+ if (resolution.defaulted || !resolution.bindings) {
145
+ return { route: null, defaultHtml: defaultErrorPageHtml() };
146
+ }
147
+ return { route: resolution.bindings.catchAll, defaultHtml: defaultErrorPageHtml() };
148
+ }
149
+ async function freePort() {
150
+ return new Promise((resolve, reject) => {
151
+ const server = net.createServer();
152
+ server.unref();
153
+ server.on('error', reject);
154
+ server.listen(0, '127.0.0.1', () => {
155
+ const { port } = server.address();
156
+ server.close(() => resolve(port));
157
+ });
158
+ });
159
+ }
160
+ async function fetchRenderedRoute(route) {
161
+ const serverPath = path.join(siteRoot, '.next', 'standalone', 'server.js');
162
+ if (!fs.existsSync(serverPath)) {
163
+ fail(`${serverPath} missing — the emit phase must run after \`next build\` (withAci uses standalone output)`);
164
+ }
165
+ const port = await freePort();
166
+ const logChunks = [];
167
+ const child = spawn('node', [serverPath], {
168
+ cwd: siteRoot,
169
+ detached: true,
170
+ stdio: ['ignore', 'pipe', 'pipe'],
171
+ env: {
172
+ ...process.env,
173
+ NODE_ENV: 'production',
174
+ PORT: String(port),
175
+ HOSTNAME: '127.0.0.1',
176
+ ACI_CONTENT_PROVIDER: 'file',
177
+ ACI_CONTENT_ROOT: contentRoot,
178
+ },
179
+ });
180
+ child.stdout.on('data', (c) => logChunks.push(String(c)));
181
+ child.stderr.on('data', (c) => logChunks.push(String(c)));
182
+ const exited = new Promise((resolve) => {
183
+ child.on('exit', resolve);
184
+ child.on('error', (error) => {
185
+ logChunks.push(`spawn failed: ${error.message}\n`);
186
+ resolve(null);
187
+ });
188
+ });
189
+ try {
190
+ const url = `http://127.0.0.1:${port}${route}`;
191
+ const start = Date.now();
192
+ let last = 'no attempt made';
193
+ while (Date.now() - start < BOOT_DEADLINE_MS) {
194
+ try {
195
+ const response = await fetch(url, { redirect: 'follow' });
196
+ if (response.status === 200)
197
+ return await response.text();
198
+ last = `status ${response.status}`;
199
+ }
200
+ catch (error) {
201
+ last = error.message;
202
+ }
203
+ await new Promise((r) => setTimeout(r, 500));
204
+ }
205
+ fail(`GET ${url} did not render within ${BOOT_DEADLINE_MS}ms (last: ${last})\n--- server output ---\n${logChunks.join('').slice(-4000)}`);
206
+ return '';
207
+ }
208
+ finally {
209
+ const killGroup = (signal) => {
210
+ if (!child.pid)
211
+ return;
212
+ try {
213
+ process.kill(-child.pid, signal);
214
+ }
215
+ catch { /* gone */ }
216
+ };
217
+ killGroup('SIGTERM');
218
+ const killTimer = setTimeout(() => killGroup('SIGKILL'), 10_000);
219
+ await exited;
220
+ clearTimeout(killTimer);
221
+ killGroup('SIGKILL');
222
+ }
223
+ }
224
+ function inlineStylesheets(html) {
225
+ return html.replace(/<link\b[^>]*rel="stylesheet"[^>]*>/gi, (tag) => {
226
+ const href = tag.match(/href="([^"]+)"/i)?.[1];
227
+ if (!href || !href.startsWith('/_next/')) {
228
+ warn(`dropping non-inlinable stylesheet ${href ?? '<unknown>'} from 500.html (self-containment, REQ-14)`);
229
+ return '';
230
+ }
231
+ const cssPath = path.join(siteRoot, '.next', href.replace(/^\/_next\//, ''));
232
+ if (!fs.existsSync(cssPath)) {
233
+ warn(`stylesheet ${href} not found in .next output; dropping it from 500.html`);
234
+ return '';
235
+ }
236
+ return `<style>${fs.readFileSync(cssPath, 'utf8')}</style>`;
237
+ });
238
+ }
239
+ // Relative convention path of the tenant-owned instrumentation policy
240
+ // module (D-15, code-organization R-14).
241
+ const INSTRUMENTATION_MODULE = path.join('src', 'aci', 'error-page-instrumentation.mjs');
242
+ /**
243
+ * D-15 policy resolution for the 500.html instrumentation tag (page_type
244
+ * "error"; self-contained inline delivery — this copy is a real static
245
+ * document where inline scripts execute). Convention path: the tenant-owned
246
+ * `src/aci/error-page-instrumentation.mjs` exporting `pushErrorPageView` is
247
+ * serialized via the SDK mechanism; absent → the SDK default tag. A module
248
+ * that EXISTS but fails to import or lacks the export fails the emit loudly:
249
+ * a typo'd policy module silently reverting to the default is the failure
250
+ * mode D-15 guards against. The source used is logged either way.
251
+ *
252
+ * Exported for unit tests; `content` injects the SDK content functions so
253
+ * tests stay dist-free (at runtime they arrive through the same lazy dist
254
+ * import every other SDK internal uses here, sdkContent).
255
+ */
256
+ export async function resolveInstrumentationTag(root = siteRoot, content) {
257
+ const sdk = content ?? (await sdkContent());
258
+ const push = await resolveInstrumentationPush(root);
259
+ if (!push)
260
+ return sdk.errorPageInstrumentationTag('catchAll');
261
+ return sdk.serializeErrorPageInstrumentation(push, 'catchAll');
262
+ }
263
+ /**
264
+ * Resolves the tenant policy module's push function by the D-15 convention
265
+ * path. Absent module → null (callers fall back to the SDK default); a
266
+ * module that exists but fails to import or lacks the export throws — a
267
+ * typo'd policy module silently reverting to the default is the failure
268
+ * mode D-15 guards against. The source used is logged either way.
269
+ */
270
+ async function resolveInstrumentationPush(root = siteRoot) {
271
+ const modulePath = path.join(root, INSTRUMENTATION_MODULE);
272
+ if (!fs.existsSync(modulePath)) {
273
+ log(`instrumentation source: SDK default (no ${INSTRUMENTATION_MODULE})`);
274
+ return null;
275
+ }
276
+ let policy;
277
+ try {
278
+ policy = await import(pathToFileURL(modulePath).href);
279
+ }
280
+ catch (error) {
281
+ throw new Error(`${INSTRUMENTATION_MODULE} exists but failed to import: ${error.message}\n`
282
+ + 'A broken policy module must not silently revert to the SDK default (D-15); '
283
+ + 'fix the module or delete it to use the default.');
284
+ }
285
+ if (typeof policy.pushErrorPageView !== 'function') {
286
+ throw new Error(`${INSTRUMENTATION_MODULE} does not export a pushErrorPageView function (D-15 policy contract); `
287
+ + 'export one or delete the file to use the SDK default.');
288
+ }
289
+ log(`instrumentation source: site module ${INSTRUMENTATION_MODULE}`);
290
+ return policy.pushErrorPageView;
291
+ }
292
+ /**
293
+ * D-16 prepare-phase artifact: serializes BOTH kinds' instrumentation tags
294
+ * (tenant module when present, SDK default otherwise) into
295
+ * error-pages/instrumentation.json under the compiled content root. The
296
+ * AWS-lane code capsule packs source/.aci/compiled/**, so the Go publish
297
+ * materializer reads this file from the capsule and stamps the kind-matching
298
+ * tag into the CDN error artifacts — the tag crosses the JS→Go boundary as
299
+ * data. Serialization failures (bind/method-shorthand exports, FIND-006)
300
+ * throw before anything is written.
301
+ *
302
+ * Exported for unit tests; `content` and `outRoot` are injected there like
303
+ * resolveInstrumentationTag's `content`.
304
+ */
305
+ export async function writeInstrumentationArtifact(root = siteRoot, content, outRoot = contentRoot) {
306
+ const sdk = content ?? (await sdkContent());
307
+ const push = await resolveInstrumentationPush(root);
308
+ const tagFor = (kind) => (push
309
+ ? sdk.serializeErrorPageInstrumentation(push, kind)
310
+ : sdk.errorPageInstrumentationTag(kind));
311
+ const artifact = {
312
+ version: 1,
313
+ notFound: tagFor('notFound'),
314
+ error: tagFor('catchAll'),
315
+ };
316
+ const dir = path.join(outRoot, 'error-pages');
317
+ fs.mkdirSync(dir, { recursive: true });
318
+ const target = path.join(dir, 'instrumentation.json');
319
+ fs.writeFileSync(target, `${JSON.stringify(artifact)}\n`);
320
+ log(`wrote ${path.relative(root, target) || target} (D-16 instrumentation transport)`);
321
+ return target;
322
+ }
323
+ /**
324
+ * Appends the D-14 instrumentation script tag and the generation marker
325
+ * before </body>. Exported for unit tests (the tag is a parameter so the
326
+ * function stays synchronous and dist-free there). Throws when the document
327
+ * has no </body> (quorum BUG-003): writing an UNMARKED 500.html would poison
328
+ * every later build via the skip-if-present contract, so the emit fails
329
+ * loudly instead.
330
+ */
331
+ export function stampInstrumentation(html, instrumentationTag) {
332
+ const stamped = html.replace(/<\/body>/i, `${instrumentationTag}\n${MARKER}\n</body>`);
333
+ if (!stamped.includes(MARKER)) {
334
+ throw new Error('rendered HTML has no </body> tag, so the instrumentation script and generation marker '
335
+ + 'could not be appended; refusing to write an unmarked public/500.html (skip-if-present contract)');
336
+ }
337
+ return stamped;
338
+ }
339
+ function postProcess(html, instrumentationTag) {
340
+ let out = html;
341
+ // Strip every script tag: framework runtime chunks are dead weight in a
342
+ // statically served copy, and this removes the collect script and the
343
+ // data-aci-assignments tag (REQ-11) in one sweep.
344
+ out = out.replace(/<script\b[\s\S]*?<\/script>/gi, '');
345
+ out = out.replace(/<link\b[^>]*rel="(?:preload|modulepreload|prefetch)"[^>]*>/gi, '');
346
+ out = out.replace(/<meta\b[^>]*name="aci-render-proof"[^>]*>/gi, '');
347
+ out = inlineStylesheets(out);
348
+ return stampInstrumentation(out, instrumentationTag);
349
+ }
350
+ async function emit() {
351
+ const publicDir = path.join(siteRoot, 'public');
352
+ const target = path.join(publicDir, '500.html');
353
+ if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
354
+ log('public/500.html already provided by the site; leaving it untouched (skip-if-present)');
355
+ return;
356
+ }
357
+ const { route, defaultHtml } = await resolveCatchAllRoute();
358
+ let instrumentationTag;
359
+ try {
360
+ instrumentationTag = await resolveInstrumentationTag();
361
+ }
362
+ catch (error) {
363
+ fail(error.message);
364
+ }
365
+ let html;
366
+ try {
367
+ if (!route) {
368
+ // The shipped default is already self-contained; it gets the same
369
+ // instrumentation-plus-marker append used for rendered pages.
370
+ html = stampInstrumentation(defaultHtml, instrumentationTag);
371
+ }
372
+ else {
373
+ log(`rendering bound catchAll page ${route} for public/500.html`);
374
+ html = postProcess(await fetchRenderedRoute(route), instrumentationTag);
375
+ }
376
+ }
377
+ catch (error) {
378
+ fail(error.message);
379
+ }
380
+ const size = Buffer.byteLength(html);
381
+ if (size > SIZE_BUDGET) {
382
+ fail(`public/500.html is ${size} bytes, over the ${SIZE_BUDGET}-byte budget (REQ-14). `
383
+ + 'Trim the bound page/design-system CSS or fall back to system fonts (the sanctioned escape hatch).');
384
+ }
385
+ fs.mkdirSync(publicDir, { recursive: true });
386
+ fs.writeFileSync(target, html);
387
+ log(`wrote public/500.html (${size} bytes${route ? `, from ${route}` : ', shipped default'})`);
388
+ }
389
+ // Run only when executed as a CLI — the module is also imported by unit
390
+ // tests for stampInstrumentation.
391
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
392
+ if (isMain) {
393
+ const phase = process.argv[2];
394
+ if (phase !== 'prepare' && phase !== 'emit') {
395
+ console.error('usage: emit-error-pages.mjs <prepare|emit>');
396
+ process.exit(2);
397
+ }
398
+ if (phase === 'prepare')
399
+ await prepare();
400
+ else
401
+ await emit();
402
+ }
@@ -1,32 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export function validateContent(options: any): Promise<{
3
- warnings?: any[] | undefined;
4
- success: boolean;
5
- pagesValidated: number;
6
- blocksValidated: number;
7
- errors: ({
8
- page: string;
9
- message: string;
10
- fragment?: undefined;
11
- component?: undefined;
12
- path?: undefined;
13
- } | {
14
- fragment: string;
15
- message: string;
16
- page?: undefined;
17
- component?: undefined;
18
- path?: undefined;
19
- } | {
20
- fragment: string;
21
- component: any;
22
- message: string;
23
- page?: undefined;
24
- path?: undefined;
25
- } | {
26
- fragment: string;
27
- component: any;
28
- path: any;
29
- message: any;
30
- page?: undefined;
31
- })[];
32
- }>;
2
+ export {};
@@ -19,25 +19,21 @@
19
19
  * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
20
20
  * imports needed, same schemas usable by Go backend or other tools.
21
21
  */
22
- import { realpathSync } from 'node:fs';
23
22
  import { readdir, readFile } from 'node:fs/promises';
24
23
  import path from 'node:path';
25
- import { fileURLToPath } from 'node:url';
26
- if (isMainModule()) {
27
- const args = parseArgs(process.argv.slice(2));
28
- try {
29
- const result = await validateContent(args);
30
- console.log(JSON.stringify(result, null, 2));
31
- if (!result.success)
32
- process.exitCode = 1;
33
- }
34
- catch (error) {
35
- console.error(JSON.stringify({
36
- success: false,
37
- errors: [{ message: String(error?.message ?? error) }],
38
- }, null, 2));
24
+ const args = parseArgs(process.argv.slice(2));
25
+ try {
26
+ const result = await validateContent(args);
27
+ console.log(JSON.stringify(result, null, 2));
28
+ if (!result.success)
39
29
  process.exitCode = 1;
40
- }
30
+ }
31
+ catch (error) {
32
+ console.error(JSON.stringify({
33
+ success: false,
34
+ errors: [{ message: String(error?.message ?? error) }],
35
+ }, null, 2));
36
+ process.exitCode = 1;
41
37
  }
42
38
  // ---------------------------------------------------------------------------
43
39
  // Main
@@ -90,22 +86,6 @@ async function validateContent(options) {
90
86
  const blocks = collectBlocks(pageData);
91
87
  for (const block of blocks) {
92
88
  blocksValidated++;
93
- if (!block || typeof block !== 'object') {
94
- collector.push({
95
- page: relativePath,
96
- blockId: '<unknown>',
97
- message: 'page region entry must be a component block',
98
- });
99
- continue;
100
- }
101
- if (block.$type !== 'component') {
102
- collector.push({
103
- page: relativePath,
104
- blockId: block.id ?? '<unknown>',
105
- path: '$type',
106
- message: 'block "$type" must be "component"',
107
- });
108
- }
109
89
  if (!block.component) {
110
90
  collector.push({
111
91
  page: relativePath,
@@ -368,13 +348,6 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
368
348
  issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
369
349
  continue;
370
350
  }
371
- if (nestedBlock.$type !== 'component') {
372
- issues.push({
373
- path: `${nestedPath}.$type`,
374
- message: `${parentBlock.component}: block ref child "$type" must be "component"`,
375
- });
376
- continue;
377
- }
378
351
  if (!componentMap.has(nestedBlock.component)) {
379
352
  issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
380
353
  continue;
@@ -451,14 +424,3 @@ function parseArgs(argv) {
451
424
  }
452
425
  return out;
453
426
  }
454
- function isMainModule() {
455
- if (!process.argv[1])
456
- return false;
457
- try {
458
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
459
- }
460
- catch {
461
- return false;
462
- }
463
- }
464
- export { validateContent };
@@ -32,6 +32,7 @@ checkMissing('legacy required catch-all is removed', 'src/app/[...slug]/page.tsx
32
32
  checkFile('canonical asset route exists', 'src/app/gradial/assets/[...path]/route.ts', (content) => content.includes('@gradial/aci/next/asset-route'), 'Expected the canonical asset route to use @gradial/aci/next/asset-route.');
33
33
  checkMissing('legacy release-scoped asset route is removed', 'src/app/gradial/assets/[releaseId]/[...path]/route.ts', 'Release-scoped asset URLs are produced by withAci() rewrites into the canonical src/app/gradial/assets/[...path]/route.ts; do not add a [releaseId] route.');
34
34
  checkMissing('no separate __r route folder', 'src/app/__r/[releaseId]/routes/[[...slug]]/page.tsx', 'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.');
35
+ checkMissing('platform-owned collect route is absent', 'src/app/collect/route.ts', 'ACI injects the reserved /collect function during Vercel Build Output composition; customer source must not own it.');
35
36
  checkFile('next.config uses withAci', 'next.config.mjs', (content) => content.includes('withAci'), 'Expected next.config.mjs to use withAci() from @gradial/aci/next.');
36
37
  for (const line of checks) {
37
38
  console.log(line);
@@ -0,0 +1,2 @@
1
+ /** Shipped branded-neutral default error page, embedded at build time. */
2
+ export declare const DEFAULT_ERROR_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Page unavailable</title>\n</head>\n<body style=\"margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#1a1a1a;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center\">\n<main style=\"max-width:26rem;padding:2rem\">\n<p style=\"margin:0 0 0.75rem;font-size:0.8rem;letter-spacing:0.12em;text-transform:uppercase;color:#6b6b6b\">Page unavailable</p>\n<h1 style=\"margin:0 0 1rem;font-size:1.5rem;font-weight:600;line-height:1.3\">This page can&rsquo;t be shown right now.</h1>\n<p style=\"margin:0 0 1.5rem;font-size:1rem;line-height:1.6;color:#4a4a4a\">The page you requested doesn&rsquo;t exist or couldn&rsquo;t be served. Check the address, try again in a moment, or return to the homepage.</p>\n<a href=\"/\" style=\"display:inline-block;padding:0.6rem 1.4rem;border:1px solid #1a1a1a;border-radius:999px;color:#1a1a1a;text-decoration:none;font-size:0.9rem\">Back to homepage</a>\n</main>\n</body>\n</html>\n";
@@ -0,0 +1,6 @@
1
+ // GENERATED FILE — DO NOT EDIT.
2
+ // Source of truth: assets/error-pages/default.html
3
+ // Regenerate with: node scripts/generate-default-error-page.mjs
4
+ // (default-error-page.test.ts fails if this file drifts from the asset.)
5
+ /** Shipped branded-neutral default error page, embedded at build time. */
6
+ export const DEFAULT_ERROR_PAGE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>Page unavailable</title>\n</head>\n<body style=\"margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#1a1a1a;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;text-align:center\">\n<main style=\"max-width:26rem;padding:2rem\">\n<p style=\"margin:0 0 0.75rem;font-size:0.8rem;letter-spacing:0.12em;text-transform:uppercase;color:#6b6b6b\">Page unavailable</p>\n<h1 style=\"margin:0 0 1rem;font-size:1.5rem;font-weight:600;line-height:1.3\">This page can&rsquo;t be shown right now.</h1>\n<p style=\"margin:0 0 1.5rem;font-size:1rem;line-height:1.6;color:#4a4a4a\">The page you requested doesn&rsquo;t exist or couldn&rsquo;t be served. Check the address, try again in a moment, or return to the homepage.</p>\n<a href=\"/\" style=\"display:inline-block;padding:0.6rem 1.4rem;border:1px solid #1a1a1a;border-radius:999px;color:#1a1a1a;text-decoration:none;font-size:0.9rem\">Back to homepage</a>\n</main>\n</body>\n</html>\n";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shipped branded-neutral default error page (authorable-error-pages
3
+ * REQ-4/D-5). Materialized with a loud warning when a site has no
4
+ * `errorPages` binding under the warn-then-enforce posture. Self-contained
5
+ * by construction: inline styles only, no external references (REQ-14).
6
+ *
7
+ * The human-editable source of truth is `assets/error-pages/default.html`;
8
+ * it is embedded here as a generated constant (default-error-page-html.ts)
9
+ * because bundled serverless functions cannot read package assets at
10
+ * runtime — webpack rewrites module-URL resolution and output file tracing
11
+ * does not carry the .html into the function bundle, so a runtime file read
12
+ * crashes the very error surface this page exists to protect.
13
+ */
14
+ /** Returns the shipped branded-neutral default error page HTML. */
15
+ export declare function defaultErrorPageHtml(): string;
16
+ /**
17
+ * The shipped default page's `<body>` content as an embeddable fragment: the
18
+ * body element renamed to a `<div>` (its inline styles preserved) so
19
+ * framework error seats can inline the default INSIDE an existing document —
20
+ * the not-found seat and the client error seat render within the app's root
21
+ * layout, where a nested `<body>`/`<html>` would be invalid.
22
+ */
23
+ export declare function defaultErrorPageFragmentHtml(): string;
@@ -0,0 +1,34 @@
1
+ import { DEFAULT_ERROR_PAGE_HTML } from './default-error-page-html.js';
2
+ /**
3
+ * Shipped branded-neutral default error page (authorable-error-pages
4
+ * REQ-4/D-5). Materialized with a loud warning when a site has no
5
+ * `errorPages` binding under the warn-then-enforce posture. Self-contained
6
+ * by construction: inline styles only, no external references (REQ-14).
7
+ *
8
+ * The human-editable source of truth is `assets/error-pages/default.html`;
9
+ * it is embedded here as a generated constant (default-error-page-html.ts)
10
+ * because bundled serverless functions cannot read package assets at
11
+ * runtime — webpack rewrites module-URL resolution and output file tracing
12
+ * does not carry the .html into the function bundle, so a runtime file read
13
+ * crashes the very error surface this page exists to protect.
14
+ */
15
+ /** Returns the shipped branded-neutral default error page HTML. */
16
+ export function defaultErrorPageHtml() {
17
+ return DEFAULT_ERROR_PAGE_HTML;
18
+ }
19
+ /**
20
+ * The shipped default page's `<body>` content as an embeddable fragment: the
21
+ * body element renamed to a `<div>` (its inline styles preserved) so
22
+ * framework error seats can inline the default INSIDE an existing document —
23
+ * the not-found seat and the client error seat render within the app's root
24
+ * layout, where a nested `<body>`/`<html>` would be invalid.
25
+ */
26
+ export function defaultErrorPageFragmentHtml() {
27
+ const match = defaultErrorPageHtml().match(/<body([^>]*)>([\s\S]*?)<\/body>/i);
28
+ if (!match) {
29
+ // The shipped asset is package-controlled; this is a packaging bug, but
30
+ // error surfaces must never crash — fall back to the raw document body.
31
+ return defaultErrorPageHtml();
32
+ }
33
+ return `<div${match[1]}>${match[2]}</div>`;
34
+ }
@@ -0,0 +1,57 @@
1
+ export type ErrorPageKind = 'notFound' | 'catchAll';
2
+ /** D-14 page_type values for the error-surface `page_view` event. */
3
+ export type ErrorPageType = 'not_found' | 'error';
4
+ /** Maps a binding kind to its D-14 page_type. */
5
+ export declare function errorPageType(kind: ErrorPageKind): ErrorPageType;
6
+ declare global {
7
+ interface Window {
8
+ dataLayer?: Array<Record<string, unknown>>;
9
+ }
10
+ }
11
+ /**
12
+ * The D-15 policy shape: the dataLayer push for one error-surface view.
13
+ * Site policy lives in the tenant-owned scaffolded module
14
+ * `src/aci/error-page-instrumentation.mjs` (`pushErrorPageView`); the SDK
15
+ * ships `defaultPushErrorPageView` as the scaffolded default's twin. A push
16
+ * MUST be self-contained (browser globals only — no imports, no closed-over
17
+ * identifiers): the Astro seats and the 500.html emitter execute it
18
+ * STRINGIFIED via `serializeErrorPageInstrumentation`.
19
+ */
20
+ export type ErrorPagePush = (pageType: ErrorPageType) => void;
21
+ /**
22
+ * The SDK's copy of the D-14 default payload push — the same body the
23
+ * scaffolded policy module ships with. Used when no tenant module is in
24
+ * play: the Next `ErrorPageInstrumentation` component's default, the Astro
25
+ * seats' fallback, and the emitter's fallback tag. Self-contained by the
26
+ * ErrorPagePush contract (it serializes via toString()).
27
+ */
28
+ export declare function defaultPushErrorPageView(pageType: ErrorPageType): void;
29
+ /**
30
+ * SDK MECHANISM (D-15, code-organization R-14): serializes a self-contained
31
+ * push function into the inline analytics `<script>` tag the Astro seats
32
+ * inject via `<Fragment set:html>` and the Next emit-error-pages CLI stamps
33
+ * into `public/500.html`. The marker attribute and IIFE wrapper are
34
+ * SDK-owned platform contract (D-14: `data-aci-error-page` is the STABLE
35
+ * GTM suppression signal); the function body is site policy. `</script`,
36
+ * `<script` and `<!--` occurrences in the body are escaped (the script-data
37
+ * escaping table) so a push can neither break out of the tag nor walk the
38
+ * HTML parser into the escaped-script-data states (FIND-007).
39
+ *
40
+ * A non-function push degrades to the default tag with a warning
41
+ * (FIND-002): this is the runtime mechanism boundary and error surfaces
42
+ * must not crash over a broken policy value — the emitter keeps its own
43
+ * fail-loud stance at build time.
44
+ */
45
+ export declare function serializeErrorPageInstrumentation(push: ErrorPagePush, kind: ErrorPageKind): string;
46
+ /**
47
+ * The SDK DEFAULT analytics tag (D-15 amended D-14): the serialized
48
+ * `defaultPushErrorPageView`. Consumers fall back to it when no tenant
49
+ * policy module is provided — the Astro seats without
50
+ * `options.pushErrorPageView` and the emitter when
51
+ * `src/aci/error-page-instrumentation.mjs` is absent. The Next 'use client'
52
+ * component (`next/error-page-instrumentation.tsx`) cannot consume a script
53
+ * tag (RSC skips inline scripts); it calls the same default function
54
+ * directly, and the parity test beside it holds both deliveries in
55
+ * lockstep.
56
+ */
57
+ export declare function errorPageInstrumentationTag(kind: ErrorPageKind): string;