@gradial/aci 0.1.46 → 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 (56) 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/cli/emit-error-pages.d.mts +39 -0
  7. package/dist/cli/emit-error-pages.mjs +402 -0
  8. package/dist/content/default-error-page-html.d.ts +2 -0
  9. package/dist/content/default-error-page-html.js +6 -0
  10. package/dist/content/default-error-page.d.ts +23 -0
  11. package/dist/content/default-error-page.js +34 -0
  12. package/dist/content/error-page-kind.d.ts +57 -0
  13. package/dist/content/error-page-kind.js +106 -0
  14. package/dist/content/error-pages.d.ts +88 -0
  15. package/dist/content/error-pages.js +120 -0
  16. package/dist/content/index.d.ts +2 -0
  17. package/dist/content/index.js +2 -0
  18. package/dist/content/types.d.ts +12 -0
  19. package/dist/next/config.js +17 -0
  20. package/dist/next/error-page-instrumentation.d.ts +23 -0
  21. package/dist/next/error-page-instrumentation.js +36 -0
  22. package/dist/next/error-page-marker.d.ts +11 -0
  23. package/dist/next/error-page-marker.js +20 -0
  24. package/dist/next/error-pages.d.ts +52 -0
  25. package/dist/next/error-pages.js +94 -0
  26. package/dist/next/error-seat-payload.d.ts +33 -0
  27. package/dist/next/error-seat-payload.js +143 -0
  28. package/dist/next/error-seat.d.ts +57 -0
  29. package/dist/next/error-seat.js +83 -0
  30. package/dist/next/index.d.ts +4 -0
  31. package/dist/next/index.js +8 -0
  32. package/package.json +14 -8
  33. package/src/cli/emit-error-pages.mjs +424 -0
  34. package/templates/astro/template/package.json.tmpl +1 -1
  35. package/templates/astro/template/src/aci/error-page-instrumentation.mjs +33 -0
  36. package/templates/astro/template/src/pages/404.astro +54 -0
  37. package/templates/astro/template/src/pages/500.astro +53 -0
  38. package/templates/astro/template/src/pages/[...slug].astro +15 -2
  39. package/templates/common/.agents/skills/authoring/SKILL.md +71 -0
  40. package/templates/common/.content/config/robots.txt +4 -0
  41. package/templates/common/.content/config/site.json.tmpl +4 -0
  42. package/templates/common/.content/pages/404/_index.json +31 -0
  43. package/templates/common/.content/pages/error/_index.json +31 -0
  44. package/templates/common/.content/schemas/site.schema.json +9 -0
  45. package/templates/common/starter-content/.content/config/robots.txt +2 -0
  46. package/templates/common/starter-content/.content/config/site.json +4 -0
  47. package/templates/common/starter-content/.content/pages/404/_index.json +31 -0
  48. package/templates/common/starter-content/.content/pages/about/_index.json +3 -6
  49. package/templates/common/starter-content/.content/pages/error/_index.json +31 -0
  50. package/templates/common/starter-content/.content/pages/home/_index.json +10 -19
  51. package/templates/common/starter-content/.content/schemas/site.schema.json +9 -0
  52. package/templates/nextjs/template/package.json.tmpl +1 -1
  53. package/templates/nextjs/template/src/aci/error-page-instrumentation.d.mts +4 -0
  54. package/templates/nextjs/template/src/aci/error-page-instrumentation.mjs +38 -0
  55. package/templates/nextjs/template/src/app/error.tsx +18 -0
  56. package/templates/nextjs/template/src/app/not-found.tsx +13 -0
@@ -0,0 +1,424 @@
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
+
58
+ import { execFileSync, spawn } from 'node:child_process';
59
+ import fs from 'node:fs';
60
+ import net from 'node:net';
61
+ import path from 'node:path';
62
+ import { fileURLToPath, pathToFileURL } from 'node:url';
63
+
64
+ const MARKER = '<!-- aci:error-pages generated -->';
65
+ const SIZE_BUDGET = 150 * 1024;
66
+ const BOOT_DEADLINE_MS = 60_000;
67
+
68
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
69
+ const siteRoot = process.cwd();
70
+ const contentRoot = path.resolve(siteRoot, process.env.ACI_CONTENT_ROOT || '.aci/compiled');
71
+
72
+ function log(message) {
73
+ console.log(`[aci error-pages] ${message}`);
74
+ }
75
+ function warn(message) {
76
+ console.warn(`\n[aci error-pages] WARNING: ${message}\n`);
77
+ }
78
+ function fail(message) {
79
+ console.error(`\n[aci error-pages] ERROR: ${message}\n`);
80
+ process.exit(1);
81
+ }
82
+
83
+ async function sdkContent() {
84
+ return await import(pathToFileURL(path.join(packageRoot, 'dist', 'content', 'index.js')).href);
85
+ }
86
+
87
+ function hasCompiledContent() {
88
+ return fs.existsSync(path.join(contentRoot, 'manifest.json'));
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // prepare
93
+ // ---------------------------------------------------------------------------
94
+
95
+ async function prepare() {
96
+ if (hasCompiledContent()) {
97
+ log(`compiled content present at ${contentRoot}`);
98
+ } else {
99
+ const authored = path.join(siteRoot, '.content');
100
+ if (!fs.existsSync(authored)) {
101
+ warn(
102
+ `neither compiled content (${contentRoot}) nor authored content (./.content) exists; `
103
+ + 'the error seat and 500.html will serve the shipped default page (REQ-4)',
104
+ );
105
+ } else {
106
+ log('compiling ./.content for the error-page build steps');
107
+ execFileSync(
108
+ 'node',
109
+ [path.join(packageRoot, 'bin', 'aci.js'), 'content', 'compile', '--content', authored, '--out', contentRoot],
110
+ { cwd: siteRoot, stdio: 'inherit' },
111
+ );
112
+ }
113
+ }
114
+ // D-16: the instrumentation artifact rides the compiled content root into
115
+ // the AWS-lane code capsule (source/.aci/compiled/**). Written even when
116
+ // content is absent — the tenant policy module is independent of authored
117
+ // content, and an artifact the Go materializer never receives silently
118
+ // degrades to the default tags (the exact gap D-16 closes).
119
+ try {
120
+ await writeInstrumentationArtifact();
121
+ } catch (error) {
122
+ fail(error.message);
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // emit
128
+ // ---------------------------------------------------------------------------
129
+
130
+ async function resolveCatchAllRoute() {
131
+ const { resolveErrorPageBindings, defaultErrorPageHtml, errorPagePostureFromEnv } = await sdkContent();
132
+ // D-5 posture: the one shared config surface (ACI_ERROR_PAGES_POSTURE);
133
+ // unknown values fail closed here just like the Go stack.
134
+ let posture;
135
+ try {
136
+ posture = errorPagePostureFromEnv();
137
+ } catch (error) {
138
+ fail(error.message);
139
+ }
140
+ if (!hasCompiledContent()) {
141
+ if (posture === 'enforce') {
142
+ fail(`no compiled content at ${contentRoot}; the enforce posture fails closed (D-5)`);
143
+ }
144
+ warn(`no compiled content at ${contentRoot}; emitting the shipped default 500.html (REQ-4)`);
145
+ return { route: null, defaultHtml: defaultErrorPageHtml() };
146
+ }
147
+ const { FileContentProvider } = await import(
148
+ pathToFileURL(path.join(packageRoot, 'dist', 'providers', 'file.js')).href
149
+ );
150
+ const provider = new FileContentProvider(contentRoot);
151
+ const siteConfig = await provider.getSiteConfig();
152
+ const resolution = await resolveErrorPageBindings(siteConfig, { source: provider, posture });
153
+ if (resolution.errors.length > 0) {
154
+ fail(`errorPages bindings are invalid (REQ-5, fail closed):\n- ${resolution.errors.join('\n- ')}`);
155
+ }
156
+ for (const warning of resolution.warnings) warn(warning);
157
+ if (resolution.defaulted || !resolution.bindings) {
158
+ return { route: null, defaultHtml: defaultErrorPageHtml() };
159
+ }
160
+ return { route: resolution.bindings.catchAll, defaultHtml: defaultErrorPageHtml() };
161
+ }
162
+
163
+ async function freePort() {
164
+ return new Promise((resolve, reject) => {
165
+ const server = net.createServer();
166
+ server.unref();
167
+ server.on('error', reject);
168
+ server.listen(0, '127.0.0.1', () => {
169
+ const { port } = server.address();
170
+ server.close(() => resolve(port));
171
+ });
172
+ });
173
+ }
174
+
175
+ async function fetchRenderedRoute(route) {
176
+ const serverPath = path.join(siteRoot, '.next', 'standalone', 'server.js');
177
+ if (!fs.existsSync(serverPath)) {
178
+ fail(`${serverPath} missing — the emit phase must run after \`next build\` (withAci uses standalone output)`);
179
+ }
180
+ const port = await freePort();
181
+ const logChunks = [];
182
+ const child = spawn('node', [serverPath], {
183
+ cwd: siteRoot,
184
+ detached: true,
185
+ stdio: ['ignore', 'pipe', 'pipe'],
186
+ env: {
187
+ ...process.env,
188
+ NODE_ENV: 'production',
189
+ PORT: String(port),
190
+ HOSTNAME: '127.0.0.1',
191
+ ACI_CONTENT_PROVIDER: 'file',
192
+ ACI_CONTENT_ROOT: contentRoot,
193
+ },
194
+ });
195
+ child.stdout.on('data', (c) => logChunks.push(String(c)));
196
+ child.stderr.on('data', (c) => logChunks.push(String(c)));
197
+ const exited = new Promise((resolve) => {
198
+ child.on('exit', resolve);
199
+ child.on('error', (error) => {
200
+ logChunks.push(`spawn failed: ${error.message}\n`);
201
+ resolve(null);
202
+ });
203
+ });
204
+
205
+ try {
206
+ const url = `http://127.0.0.1:${port}${route}`;
207
+ const start = Date.now();
208
+ let last = 'no attempt made';
209
+ while (Date.now() - start < BOOT_DEADLINE_MS) {
210
+ try {
211
+ const response = await fetch(url, { redirect: 'follow' });
212
+ if (response.status === 200) return await response.text();
213
+ last = `status ${response.status}`;
214
+ } catch (error) {
215
+ last = error.message;
216
+ }
217
+ await new Promise((r) => setTimeout(r, 500));
218
+ }
219
+ fail(`GET ${url} did not render within ${BOOT_DEADLINE_MS}ms (last: ${last})\n--- server output ---\n${logChunks.join('').slice(-4000)}`);
220
+ return '';
221
+ } finally {
222
+ const killGroup = (signal) => {
223
+ if (!child.pid) return;
224
+ try { process.kill(-child.pid, signal); } catch { /* gone */ }
225
+ };
226
+ killGroup('SIGTERM');
227
+ const killTimer = setTimeout(() => killGroup('SIGKILL'), 10_000);
228
+ await exited;
229
+ clearTimeout(killTimer);
230
+ killGroup('SIGKILL');
231
+ }
232
+ }
233
+
234
+ function inlineStylesheets(html) {
235
+ return html.replace(/<link\b[^>]*rel="stylesheet"[^>]*>/gi, (tag) => {
236
+ const href = tag.match(/href="([^"]+)"/i)?.[1];
237
+ if (!href || !href.startsWith('/_next/')) {
238
+ warn(`dropping non-inlinable stylesheet ${href ?? '<unknown>'} from 500.html (self-containment, REQ-14)`);
239
+ return '';
240
+ }
241
+ const cssPath = path.join(siteRoot, '.next', href.replace(/^\/_next\//, ''));
242
+ if (!fs.existsSync(cssPath)) {
243
+ warn(`stylesheet ${href} not found in .next output; dropping it from 500.html`);
244
+ return '';
245
+ }
246
+ return `<style>${fs.readFileSync(cssPath, 'utf8')}</style>`;
247
+ });
248
+ }
249
+
250
+ // Relative convention path of the tenant-owned instrumentation policy
251
+ // module (D-15, code-organization R-14).
252
+ const INSTRUMENTATION_MODULE = path.join('src', 'aci', 'error-page-instrumentation.mjs');
253
+
254
+ /**
255
+ * D-15 policy resolution for the 500.html instrumentation tag (page_type
256
+ * "error"; self-contained inline delivery — this copy is a real static
257
+ * document where inline scripts execute). Convention path: the tenant-owned
258
+ * `src/aci/error-page-instrumentation.mjs` exporting `pushErrorPageView` is
259
+ * serialized via the SDK mechanism; absent → the SDK default tag. A module
260
+ * that EXISTS but fails to import or lacks the export fails the emit loudly:
261
+ * a typo'd policy module silently reverting to the default is the failure
262
+ * mode D-15 guards against. The source used is logged either way.
263
+ *
264
+ * Exported for unit tests; `content` injects the SDK content functions so
265
+ * tests stay dist-free (at runtime they arrive through the same lazy dist
266
+ * import every other SDK internal uses here, sdkContent).
267
+ */
268
+ export async function resolveInstrumentationTag(root = siteRoot, content) {
269
+ const sdk = content ?? (await sdkContent());
270
+ const push = await resolveInstrumentationPush(root);
271
+ if (!push) return sdk.errorPageInstrumentationTag('catchAll');
272
+ return sdk.serializeErrorPageInstrumentation(push, 'catchAll');
273
+ }
274
+
275
+ /**
276
+ * Resolves the tenant policy module's push function by the D-15 convention
277
+ * path. Absent module → null (callers fall back to the SDK default); a
278
+ * module that exists but fails to import or lacks the export throws — a
279
+ * typo'd policy module silently reverting to the default is the failure
280
+ * mode D-15 guards against. The source used is logged either way.
281
+ */
282
+ async function resolveInstrumentationPush(root = siteRoot) {
283
+ const modulePath = path.join(root, INSTRUMENTATION_MODULE);
284
+ if (!fs.existsSync(modulePath)) {
285
+ log(`instrumentation source: SDK default (no ${INSTRUMENTATION_MODULE})`);
286
+ return null;
287
+ }
288
+ let policy;
289
+ try {
290
+ policy = await import(pathToFileURL(modulePath).href);
291
+ } catch (error) {
292
+ throw new Error(
293
+ `${INSTRUMENTATION_MODULE} exists but failed to import: ${error.message}\n`
294
+ + 'A broken policy module must not silently revert to the SDK default (D-15); '
295
+ + 'fix the module or delete it to use the default.',
296
+ );
297
+ }
298
+ if (typeof policy.pushErrorPageView !== 'function') {
299
+ throw new Error(
300
+ `${INSTRUMENTATION_MODULE} does not export a pushErrorPageView function (D-15 policy contract); `
301
+ + 'export one or delete the file to use the SDK default.',
302
+ );
303
+ }
304
+ log(`instrumentation source: site module ${INSTRUMENTATION_MODULE}`);
305
+ return policy.pushErrorPageView;
306
+ }
307
+
308
+ /**
309
+ * D-16 prepare-phase artifact: serializes BOTH kinds' instrumentation tags
310
+ * (tenant module when present, SDK default otherwise) into
311
+ * error-pages/instrumentation.json under the compiled content root. The
312
+ * AWS-lane code capsule packs source/.aci/compiled/**, so the Go publish
313
+ * materializer reads this file from the capsule and stamps the kind-matching
314
+ * tag into the CDN error artifacts — the tag crosses the JS→Go boundary as
315
+ * data. Serialization failures (bind/method-shorthand exports, FIND-006)
316
+ * throw before anything is written.
317
+ *
318
+ * Exported for unit tests; `content` and `outRoot` are injected there like
319
+ * resolveInstrumentationTag's `content`.
320
+ */
321
+ export async function writeInstrumentationArtifact(root = siteRoot, content, outRoot = contentRoot) {
322
+ const sdk = content ?? (await sdkContent());
323
+ const push = await resolveInstrumentationPush(root);
324
+ const tagFor = (kind) => (push
325
+ ? sdk.serializeErrorPageInstrumentation(push, kind)
326
+ : sdk.errorPageInstrumentationTag(kind));
327
+ const artifact = {
328
+ version: 1,
329
+ notFound: tagFor('notFound'),
330
+ error: tagFor('catchAll'),
331
+ };
332
+ const dir = path.join(outRoot, 'error-pages');
333
+ fs.mkdirSync(dir, { recursive: true });
334
+ const target = path.join(dir, 'instrumentation.json');
335
+ fs.writeFileSync(target, `${JSON.stringify(artifact)}\n`);
336
+ log(`wrote ${path.relative(root, target) || target} (D-16 instrumentation transport)`);
337
+ return target;
338
+ }
339
+
340
+ /**
341
+ * Appends the D-14 instrumentation script tag and the generation marker
342
+ * before </body>. Exported for unit tests (the tag is a parameter so the
343
+ * function stays synchronous and dist-free there). Throws when the document
344
+ * has no </body> (quorum BUG-003): writing an UNMARKED 500.html would poison
345
+ * every later build via the skip-if-present contract, so the emit fails
346
+ * loudly instead.
347
+ */
348
+ export function stampInstrumentation(html, instrumentationTag) {
349
+ const stamped = html.replace(/<\/body>/i, `${instrumentationTag}\n${MARKER}\n</body>`);
350
+ if (!stamped.includes(MARKER)) {
351
+ throw new Error(
352
+ 'rendered HTML has no </body> tag, so the instrumentation script and generation marker '
353
+ + 'could not be appended; refusing to write an unmarked public/500.html (skip-if-present contract)',
354
+ );
355
+ }
356
+ return stamped;
357
+ }
358
+
359
+ function postProcess(html, instrumentationTag) {
360
+ let out = html;
361
+ // Strip every script tag: framework runtime chunks are dead weight in a
362
+ // statically served copy, and this removes the collect script and the
363
+ // data-aci-assignments tag (REQ-11) in one sweep.
364
+ out = out.replace(/<script\b[\s\S]*?<\/script>/gi, '');
365
+ out = out.replace(/<link\b[^>]*rel="(?:preload|modulepreload|prefetch)"[^>]*>/gi, '');
366
+ out = out.replace(/<meta\b[^>]*name="aci-render-proof"[^>]*>/gi, '');
367
+ out = inlineStylesheets(out);
368
+ return stampInstrumentation(out, instrumentationTag);
369
+ }
370
+
371
+ async function emit() {
372
+ const publicDir = path.join(siteRoot, 'public');
373
+ const target = path.join(publicDir, '500.html');
374
+ if (fs.existsSync(target) && !fs.readFileSync(target, 'utf8').includes(MARKER)) {
375
+ log('public/500.html already provided by the site; leaving it untouched (skip-if-present)');
376
+ return;
377
+ }
378
+
379
+ const { route, defaultHtml } = await resolveCatchAllRoute();
380
+ let instrumentationTag;
381
+ try {
382
+ instrumentationTag = await resolveInstrumentationTag();
383
+ } catch (error) {
384
+ fail(error.message);
385
+ }
386
+ let html;
387
+ try {
388
+ if (!route) {
389
+ // The shipped default is already self-contained; it gets the same
390
+ // instrumentation-plus-marker append used for rendered pages.
391
+ html = stampInstrumentation(defaultHtml, instrumentationTag);
392
+ } else {
393
+ log(`rendering bound catchAll page ${route} for public/500.html`);
394
+ html = postProcess(await fetchRenderedRoute(route), instrumentationTag);
395
+ }
396
+ } catch (error) {
397
+ fail(error.message);
398
+ }
399
+
400
+ const size = Buffer.byteLength(html);
401
+ if (size > SIZE_BUDGET) {
402
+ fail(
403
+ `public/500.html is ${size} bytes, over the ${SIZE_BUDGET}-byte budget (REQ-14). `
404
+ + 'Trim the bound page/design-system CSS or fall back to system fonts (the sanctioned escape hatch).',
405
+ );
406
+ }
407
+
408
+ fs.mkdirSync(publicDir, { recursive: true });
409
+ fs.writeFileSync(target, html);
410
+ log(`wrote public/500.html (${size} bytes${route ? `, from ${route}` : ', shipped default'})`);
411
+ }
412
+
413
+ // Run only when executed as a CLI — the module is also imported by unit
414
+ // tests for stampInstrumentation.
415
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
416
+ if (isMain) {
417
+ const phase = process.argv[2];
418
+ if (phase !== 'prepare' && phase !== 'emit') {
419
+ console.error('usage: emit-error-pages.mjs <prepare|emit>');
420
+ process.exit(2);
421
+ }
422
+ if (phase === 'prepare') await prepare();
423
+ else await emit();
424
+ }
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "astro dev --host 0.0.0.0",
8
- "build": "astro build",
8
+ "build": "node ./node_modules/@gradial/aci/src/cli/emit-error-pages.mjs prepare && astro build",
9
9
  "preview": "ACI_CONTENT_ROOT=./.aci/compiled astro preview --host 0.0.0.0",
10
10
  "typecheck": "ACI_CONTENT_ROOT=./.aci/compiled astro check --tsconfig ./tsconfig.json",
11
11
  "test": "node --import tsx --test tests/**/*.test.ts",
@@ -0,0 +1,33 @@
1
+ // Error-page analytics policy — THIS FILE IS YOURS (customer-owned;
2
+ // code-organization R-14 / authorable-error-pages D-15). The ACI SDK owns
3
+ // the delivery mechanism (when this runs, serialization, the
4
+ // data-aci-error-page marker); this file owns WHAT is pushed.
5
+ //
6
+ // Contract:
7
+ // - pushErrorPageView MUST stay self-contained: browser globals only — no
8
+ // imports, no closed-over identifiers. The Astro seats and the 500.html
9
+ // emitter execute it STRINGIFIED via toString(), so anything outside the
10
+ // function body is lost.
11
+ // - Emptying the body to a no-op disables error-page analytics on every
12
+ // surface (404.astro, 500.astro, emitted static copies) — the sanctioned
13
+ // alternative to GTM-container-side suppression.
14
+ // - Plain .mjs on purpose: it is consumed verbatim, never transpiled.
15
+
16
+ /**
17
+ * Pushes one `page_view` event per error-surface view (D-14 default
18
+ * payload). `page_path` is the visitor's requested path, so 404 reports
19
+ * double as a broken-link inventory.
20
+ *
21
+ * @param {'not_found' | 'error'} pageType
22
+ */
23
+ export function pushErrorPageView(pageType) {
24
+ window.dataLayer = window.dataLayer || [];
25
+ window.dataLayer.push({
26
+ event: 'page_view',
27
+ page_type: pageType,
28
+ page_path: window.location.pathname,
29
+ page_location: window.location.href,
30
+ page_referrer: document.referrer || '(direct)',
31
+ page_title: document.title,
32
+ });
33
+ }
@@ -0,0 +1,54 @@
1
+ ---
2
+ // Astro's native notFound error seat (authorable-error-pages REQ-7).
3
+ // Renders the page bound by site.json#errorPages.notFound through the normal
4
+ // SiteLayout path; static builds emit dist/404.html, which doubles as the
5
+ // direct /404/ route and the error surface (D-11 — the instrumentation on a
6
+ // direct visit is the accepted consequence). If the bound page cannot be
7
+ // loaded, the SDK degrades to its shipped default fragment — this seat never
8
+ // throws.
9
+ import { getErrorPageData, createRenderChildren } from '@gradial/aci/astro';
10
+ import { registry } from '../cms/registry';
11
+ import SiteLayout from '../layouts/SiteLayout.astro';
12
+ import { pushErrorPageView } from '../aci/error-page-instrumentation.mjs';
13
+
14
+ const errorPage = await getErrorPageData(Astro, 'notFound', { pushErrorPageView });
15
+ const data = errorPage.data;
16
+ const renderChildren = createRenderChildren(registry);
17
+ ---
18
+
19
+ <SiteLayout
20
+ title={errorPage.meta.title}
21
+ description={errorPage.meta.description}
22
+ canonical={errorPage.meta.canonical}
23
+ siteName={errorPage.meta.siteName}
24
+ >
25
+ {data ? (
26
+ data.layout.slots.map((slot) => {
27
+ const blocks = data.slots[slot.name];
28
+ if (!blocks?.length) return null;
29
+ const rendered = renderChildren(blocks);
30
+ if (slot.name === 'main') {
31
+ return (
32
+ <main id="content-main">
33
+ {rendered.map((child) => {
34
+ const Comp = child.Component as any;
35
+ return Comp ? <Comp {...child.props} /> : null;
36
+ })}
37
+ </main>
38
+ );
39
+ }
40
+ return rendered.map((child) => {
41
+ const Comp = child.Component as any;
42
+ return Comp ? <Comp {...child.props} /> : null;
43
+ });
44
+ })
45
+ ) : (
46
+ <Fragment set:html={errorPage.fallbackHtml} />
47
+ )}
48
+ <!-- D-14 error-surface analytics: ONE page_view push with the visitor's
49
+ requested path. The push body is site-owned (D-15): the SDK serializes
50
+ src/aci/error-page-instrumentation.mjs into this tag, injected like
51
+ SiteLayout's collectTag; inline scripts execute here — Astro serves
52
+ real HTML documents (unlike the Next flight-rendered seat). -->
53
+ <Fragment set:html={errorPage.instrumentationTag} />
54
+ </SiteLayout>
@@ -0,0 +1,53 @@
1
+ ---
2
+ // Astro's native catchAll (500) error seat (authorable-error-pages REQ-7).
3
+ // Renders the page bound by site.json#errorPages.catchAll through the normal
4
+ // SiteLayout path. Static builds emit dist/500.html for the hosting layer;
5
+ // server output serves this seat when a route throws during on-demand
6
+ // rendering. If the bound page cannot be loaded, the SDK degrades to its
7
+ // shipped default fragment — this seat never throws.
8
+ import { getErrorPageData, createRenderChildren } from '@gradial/aci/astro';
9
+ import { registry } from '../cms/registry';
10
+ import SiteLayout from '../layouts/SiteLayout.astro';
11
+ import { pushErrorPageView } from '../aci/error-page-instrumentation.mjs';
12
+
13
+ const errorPage = await getErrorPageData(Astro, 'catchAll', { pushErrorPageView });
14
+ const data = errorPage.data;
15
+ const renderChildren = createRenderChildren(registry);
16
+ ---
17
+
18
+ <SiteLayout
19
+ title={errorPage.meta.title}
20
+ description={errorPage.meta.description}
21
+ canonical={errorPage.meta.canonical}
22
+ siteName={errorPage.meta.siteName}
23
+ >
24
+ {data ? (
25
+ data.layout.slots.map((slot) => {
26
+ const blocks = data.slots[slot.name];
27
+ if (!blocks?.length) return null;
28
+ const rendered = renderChildren(blocks);
29
+ if (slot.name === 'main') {
30
+ return (
31
+ <main id="content-main">
32
+ {rendered.map((child) => {
33
+ const Comp = child.Component as any;
34
+ return Comp ? <Comp {...child.props} /> : null;
35
+ })}
36
+ </main>
37
+ );
38
+ }
39
+ return rendered.map((child) => {
40
+ const Comp = child.Component as any;
41
+ return Comp ? <Comp {...child.props} /> : null;
42
+ });
43
+ })
44
+ ) : (
45
+ <Fragment set:html={errorPage.fallbackHtml} />
46
+ )}
47
+ <!-- D-14 error-surface analytics: ONE page_view push with the visitor's
48
+ requested path. The push body is site-owned (D-15): the SDK serializes
49
+ src/aci/error-page-instrumentation.mjs into this tag, injected like
50
+ SiteLayout's collectTag; inline scripts execute here — Astro serves
51
+ real HTML documents (unlike the Next flight-rendered seat). -->
52
+ <Fragment set:html={errorPage.instrumentationTag} />
53
+ </SiteLayout>
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { getGradialStaticPaths, getPageData, createRenderChildren } from '@gradial/aci/astro';
2
+ import { getGradialStaticPaths, getPageData, createRenderChildren, PageNotFoundError, type PageData } from '@gradial/aci/astro';
3
3
  import { registry } from '../cms/registry';
4
4
  import SiteLayout from '../layouts/SiteLayout.astro';
5
5
 
@@ -7,7 +7,20 @@ export const getStaticPaths = getGradialStaticPaths;
7
7
 
8
8
  // SDK owns route normalization, content loading, metadata, layout/slot
9
9
  // resolution, and collect-tag emission. No FileContentProvider imports.
10
- const data = await getPageData(Astro);
10
+ //
11
+ // Server output: unknown routes raise PageNotFoundError here; rewrite to the
12
+ // 404 seat so misses answer HTTP 404, not 500 (authorable-error-pages D-10).
13
+ // src/pages/404.astro is load-bearing for this rewrite — deleting it turns
14
+ // every miss into a 500.
15
+ // Static output never takes this branch — getStaticPaths only emits known
16
+ // routes.
17
+ let data: PageData;
18
+ try {
19
+ data = await getPageData(Astro);
20
+ } catch (error) {
21
+ if (error instanceof PageNotFoundError) return Astro.rewrite('/404/');
22
+ throw error;
23
+ }
11
24
  const renderChildren = createRenderChildren(registry);
12
25
  ---
13
26
 
@@ -238,6 +238,77 @@ Global elements (navigation, footer, branding, SEO) live in
238
238
  }
239
239
  ```
240
240
 
241
+ ### Error Pages
242
+
243
+ Sites bind their error pages in `site.json` via `errorPages`:
244
+
245
+ ```json
246
+ {
247
+ "errorPages": {
248
+ "notFound": "/404/",
249
+ "catchAll": "/error/"
250
+ }
251
+ }
252
+ ```
253
+
254
+ - Each binding is a route path resolving to an **ordinary content page**
255
+ (`.content/pages/404/_index.json`, `.content/pages/error/_index.json`) —
256
+ authored with normal layouts and components like any other page.
257
+ `notFound` serves unknown routes with HTTP 404; `catchAll` serves
258
+ application errors (500-class).
259
+ - Error pages stay out of discovery: set `metadata.sitemap.visible: false`
260
+ on both pages, and the template `robots.txt` disallows both routes.
261
+ - If `errorPages` is absent, the build warns loudly and ships a
262
+ branded-neutral default page (warn-then-enforce migration: absent
263
+ bindings will eventually fail the build, so author them). Present but
264
+ broken bindings — malformed paths, no matching page, unpublished page —
265
+ fail the build immediately.
266
+ - Error pages are base-audience only: no personalization, audience, or
267
+ experiment overlays (validation rejects them), and collect/assignment
268
+ markup is stripped from materialized copies.
269
+ - Analytics: error-surface renders push one `page_view` event to
270
+ `window.dataLayer` with `page_type` (`not_found` | `error`),
271
+ `page_path` (the visitor's requested path), `page_location`,
272
+ `page_referrer`, and `page_title`. The push body is site-owned
273
+ (`src/aci/error-page-instrumentation.mjs` — see the GTM/GA4 section
274
+ below); the SDK wrappers handle delivery.
275
+
276
+ ### Error-page analytics (GTM/GA4)
277
+
278
+ The push body is YOURS: it lives in the site-owned
279
+ `src/aci/error-page-instrumentation.mjs`, whose `pushErrorPageView`
280
+ function every error surface (Next seats, Astro seats, the emitted static
281
+ `500.html`) executes. The scaffolded default pushes one event to
282
+ `window.dataLayer`:
283
+
284
+ ```js
285
+ { event: 'page_view', page_type: 'not_found' | 'error',
286
+ page_path, page_location, page_referrer, page_title }
287
+ ```
288
+
289
+ - **Customize** — edit the function in place. It must stay self-contained
290
+ (browser globals only, no imports, no closed-over identifiers): some
291
+ surfaces execute it stringified.
292
+ - **Disable** — make the function body a no-op. That turns off error-page
293
+ analytics on every surface, and no GTM-side suppression is needed.
294
+
295
+ Container-side dedup is only needed when you KEEP the default push
296
+ alongside automatic GA4 page views: because the event name is `page_view`,
297
+ either disable `send_page_view` and drive all page views from the
298
+ dataLayer, or add a trigger exception on the config tag for error
299
+ surfaces. The stable suppression signal is the `data-aci-error-page`
300
+ attribute (values `not_found` | `error`; the attribute is SDK-owned and
301
+ stable regardless of what your push does): present on the instrumentation
302
+ script tag (Astro, parse-time) and on a server-rendered marker element
303
+ (Next, in the DOM before container load), so container-load triggers can
304
+ gate on it in both frameworks. If you leave automatic page views on and
305
+ also keep the default push, error pages will double-count — that is a
306
+ container configuration choice, not an SDK default.
307
+
308
+ Direct visits to `/404/` and `/error/` do not carry error context and do
309
+ not push the event (Astro static output is the one exception: its
310
+ `404.html` doubles as the direct route).
311
+
241
312
  ### ID Conventions
242
313
 
243
314
  - Every block must have a unique `id` within the page.