@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.
- package/README.md +4 -4
- package/dist/astro/error-page.d.ts +56 -0
- package/dist/astro/error-page.js +79 -0
- package/dist/astro/index.d.ts +4 -0
- package/dist/astro/index.js +9 -0
- package/dist/audience-data-models.d.ts +142 -2
- package/dist/audience-data-models.js +2 -0
- package/dist/cli/compile-registry.d.mts +1 -5
- package/dist/cli/compile-registry.mjs +10 -25
- package/dist/cli/emit-error-pages.d.mts +39 -0
- package/dist/cli/emit-error-pages.mjs +402 -0
- package/dist/cli/validate-content.d.mts +1 -31
- package/dist/cli/validate-content.mjs +12 -50
- package/dist/cli/verify-next-cdn-routes.mjs +1 -0
- package/dist/content/default-error-page-html.d.ts +2 -0
- package/dist/content/default-error-page-html.js +6 -0
- package/dist/content/default-error-page.d.ts +23 -0
- package/dist/content/default-error-page.js +34 -0
- package/dist/content/error-page-kind.d.ts +57 -0
- package/dist/content/error-page-kind.js +106 -0
- package/dist/content/error-pages.d.ts +88 -0
- package/dist/content/error-pages.js +120 -0
- package/dist/content/index.d.ts +2 -0
- package/dist/content/index.js +2 -0
- package/dist/content/provider.d.ts +1 -1
- package/dist/content/resolve-slots.js +0 -1
- package/dist/content/types.d.ts +12 -2
- package/dist/internal/render-proof.d.ts +1 -0
- package/dist/internal/render-proof.js +197 -0
- package/dist/next/config.js +17 -0
- package/dist/next/error-page-instrumentation.d.ts +23 -0
- package/dist/next/error-page-instrumentation.js +36 -0
- package/dist/next/error-page-marker.d.ts +11 -0
- package/dist/next/error-page-marker.js +20 -0
- package/dist/next/error-pages.d.ts +52 -0
- package/dist/next/error-pages.js +94 -0
- package/dist/next/error-seat-payload.d.ts +33 -0
- package/dist/next/error-seat-payload.js +143 -0
- package/dist/next/error-seat.d.ts +57 -0
- package/dist/next/error-seat.js +83 -0
- package/dist/next/index.d.ts +4 -0
- package/dist/next/index.js +8 -0
- package/dist/next/middleware.js +9 -54
- package/dist/next/page.js +9 -4
- package/dist/next/personalization-host.d.ts +5 -0
- package/dist/next/server.js +88 -75
- package/dist/next/vercel-collect-sdk-route.js +6 -30
- package/dist/next/vercel-edge-projection-v1.d.ts +24 -0
- package/dist/next/vercel-edge-projection-v1.js +880 -0
- package/dist/next/vercel-s3-personalization.js +7 -11
- package/dist/providers/s3.d.ts +2 -4
- package/dist/providers/s3.js +173 -19
- package/dist/types/block-ref.d.ts +0 -2
- package/dist/types/block-ref.js +0 -1
- package/package.json +15 -9
- package/src/cli/compile-registry.mjs +10 -24
- package/src/cli/emit-error-pages.mjs +424 -0
- package/src/cli/validate-content.mjs +13 -52
- package/src/cli/verify-next-cdn-routes.mjs +6 -0
- package/src/types/block-ref.ts +0 -2
- package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +6 -7
- package/templates/astro/starter-content/src/fixtures/composition.ts +2 -3
- package/templates/astro/starter-content/src/fixtures/sections.ts +1 -2
- package/templates/astro/template/package.json.tmpl +1 -1
- package/templates/astro/template/src/aci/error-page-instrumentation.mjs +33 -0
- package/templates/astro/template/src/pages/404.astro +54 -0
- package/templates/astro/template/src/pages/500.astro +53 -0
- package/templates/astro/template/src/pages/[...slug].astro +15 -2
- package/templates/common/.agents/skills/authoring/SKILL.md +74 -11
- package/templates/common/.agents/skills/authoring/references/block-slots.md +0 -3
- package/templates/common/.content/config/robots.txt +4 -0
- package/templates/common/.content/config/site.json.tmpl +4 -0
- package/templates/common/.content/pages/404/_index.json +31 -0
- package/templates/common/.content/pages/error/_index.json +31 -0
- package/templates/common/.content/pages/home/_index.json +0 -10
- package/templates/common/.content/schemas/site.schema.json +9 -0
- package/templates/common/starter-content/.content/config/robots.txt +2 -0
- package/templates/common/starter-content/.content/config/site.json +4 -0
- package/templates/common/starter-content/.content/pages/404/_index.json +31 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +0 -3
- package/templates/common/starter-content/.content/pages/error/_index.json +31 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +21 -14
- package/templates/common/starter-content/.content/schemas/site.schema.json +9 -0
- package/templates/nextjs/template/package.json.tmpl +1 -1
- package/templates/nextjs/template/src/aci/error-page-instrumentation.d.mts +4 -0
- package/templates/nextjs/template/src/aci/error-page-instrumentation.mjs +38 -0
- package/templates/nextjs/template/src/app/error.tsx +18 -0
- package/templates/nextjs/template/src/app/not-found.tsx +13 -0
- package/src/types/block-ref.test.ts +0 -45
- package/templates/nextjs/template/src/app/collect/route.ts +0 -18
|
@@ -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
|
+
}
|
|
@@ -19,24 +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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
process.exitCode = 1;
|
|
39
|
-
}
|
|
24
|
+
|
|
25
|
+
const args = parseArgs(process.argv.slice(2));
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = await validateContent(args);
|
|
29
|
+
console.log(JSON.stringify(result, null, 2));
|
|
30
|
+
if (!result.success) process.exitCode = 1;
|
|
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;
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
// ---------------------------------------------------------------------------
|
|
@@ -98,24 +95,6 @@ async function validateContent(options) {
|
|
|
98
95
|
for (const block of blocks) {
|
|
99
96
|
blocksValidated++;
|
|
100
97
|
|
|
101
|
-
if (!block || typeof block !== 'object') {
|
|
102
|
-
collector.push({
|
|
103
|
-
page: relativePath,
|
|
104
|
-
blockId: '<unknown>',
|
|
105
|
-
message: 'page region entry must be a component block',
|
|
106
|
-
});
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (block.$type !== 'component') {
|
|
111
|
-
collector.push({
|
|
112
|
-
page: relativePath,
|
|
113
|
-
blockId: block.id ?? '<unknown>',
|
|
114
|
-
path: '$type',
|
|
115
|
-
message: 'block "$type" must be "component"',
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
98
|
if (!block.component) {
|
|
120
99
|
collector.push({
|
|
121
100
|
page: relativePath,
|
|
@@ -401,13 +380,6 @@ function validateNestedSlots(parentBlock, componentMap, schemaMap, path = 'props
|
|
|
401
380
|
issues.push({ path: nestedPath, message: `${parentBlock.component}: block ref child must be a block` });
|
|
402
381
|
continue;
|
|
403
382
|
}
|
|
404
|
-
if (nestedBlock.$type !== 'component') {
|
|
405
|
-
issues.push({
|
|
406
|
-
path: `${nestedPath}.$type`,
|
|
407
|
-
message: `${parentBlock.component}: block ref child "$type" must be "component"`,
|
|
408
|
-
});
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
383
|
|
|
412
384
|
if (!componentMap.has(nestedBlock.component)) {
|
|
413
385
|
issues.push({ path: `${nestedPath}.component`, message: `${parentBlock.component}: unknown nested component "${nestedBlock.component}"` });
|
|
@@ -478,14 +450,3 @@ function parseArgs(argv) {
|
|
|
478
450
|
}
|
|
479
451
|
return out;
|
|
480
452
|
}
|
|
481
|
-
|
|
482
|
-
function isMainModule() {
|
|
483
|
-
if (!process.argv[1]) return false;
|
|
484
|
-
try {
|
|
485
|
-
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(path.resolve(process.argv[1]));
|
|
486
|
-
} catch {
|
|
487
|
-
return false;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export { validateContent };
|
|
@@ -71,6 +71,12 @@ checkMissing(
|
|
|
71
71
|
'The root catch-all handles release-scoped rewrites. Do not create a separate __r route folder.',
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
checkMissing(
|
|
75
|
+
'platform-owned collect route is absent',
|
|
76
|
+
'src/app/collect/route.ts',
|
|
77
|
+
'ACI injects the reserved /collect function during Vercel Build Output composition; customer source must not own it.',
|
|
78
|
+
);
|
|
79
|
+
|
|
74
80
|
checkFile(
|
|
75
81
|
'next.config uses withAci',
|
|
76
82
|
'next.config.mjs',
|
package/src/types/block-ref.ts
CHANGED
|
@@ -9,7 +9,6 @@ export interface BlockRefMeta {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const blockRefBase = z.object({
|
|
12
|
-
$type: z.literal('component'),
|
|
13
12
|
id: z.string().min(1),
|
|
14
13
|
component: z.string().min(1),
|
|
15
14
|
props: z.record(z.string(), z.unknown()),
|
|
@@ -22,7 +21,6 @@ type InferContractProps<T> = T extends ComponentContract<string, infer Schema>
|
|
|
22
21
|
type InferContractId<T> = T extends ComponentContract<infer Id, unknown> ? Id : never;
|
|
23
22
|
|
|
24
23
|
export type BlockRefEntry<T extends ComponentContract> = {
|
|
25
|
-
$type: 'component';
|
|
26
24
|
id: string;
|
|
27
25
|
component: InferContractId<T>;
|
|
28
26
|
props: InferContractProps<T>;
|
|
@@ -13,18 +13,17 @@ export const atomicBlockStoryFixtures: AtomicBlockStoryFixture[] = [
|
|
|
13
13
|
storybookId: 'core-atomic-blocks-button--variants',
|
|
14
14
|
title: 'Button / All Variants',
|
|
15
15
|
block: {
|
|
16
|
-
$type: 'component',
|
|
17
16
|
id: 'fixture-button-variants',
|
|
18
17
|
component: 'container',
|
|
19
18
|
props: {
|
|
20
19
|
theme: 'auto',
|
|
21
20
|
blocks: [
|
|
22
|
-
{
|
|
23
|
-
{
|
|
24
|
-
{
|
|
25
|
-
{
|
|
26
|
-
{
|
|
27
|
-
{
|
|
21
|
+
{ id: 'btn-primary', component: 'button', props: { label: 'Primary button', href: '#', variant: 'primary' } },
|
|
22
|
+
{ id: 'btn-secondary', component: 'button', props: { label: 'Secondary button', href: '#', variant: 'secondary' } },
|
|
23
|
+
{ id: 'btn-tertiary', component: 'button', props: { label: 'Tertiary button', href: '#', variant: 'tertiary' } },
|
|
24
|
+
{ id: 'btn-icon-before', component: 'button', props: { label: 'Icon before', href: '#', variant: 'primary', icon: 'arrow-right', iconPosition: 'before' } },
|
|
25
|
+
{ id: 'btn-icon-after', component: 'button', props: { label: 'Icon after', href: '#', variant: 'secondary', icon: 'arrow-right', iconPosition: 'after' } },
|
|
26
|
+
{ id: 'btn-new-window', component: 'button', props: { label: 'Opens in new tab', href: 'https://example.com', variant: 'primary', newWindow: true, icon: 'arrow-right', iconPosition: 'after' } }
|
|
28
27
|
]
|
|
29
28
|
}
|
|
30
29
|
}
|
|
@@ -14,15 +14,14 @@ export const compositionStoryFixtures: CompositionStoryFixture[] = [
|
|
|
14
14
|
storybookId: 'composition-container--buttons',
|
|
15
15
|
title: 'Container / Buttons',
|
|
16
16
|
block: {
|
|
17
|
-
$type: 'component',
|
|
18
17
|
id: 'fixture-container-buttons',
|
|
19
18
|
component: 'container',
|
|
20
19
|
props: {
|
|
21
20
|
theme: 'auto',
|
|
22
21
|
className: 'space-y-4',
|
|
23
22
|
blocks: [
|
|
24
|
-
{
|
|
25
|
-
{
|
|
23
|
+
{ id: 'btn-1', component: 'button', props: { label: 'Primary action', href: '#', variant: 'primary', icon: 'arrow_forward', iconPosition: 'after' } },
|
|
24
|
+
{ id: 'btn-2', component: 'button', props: { label: 'Secondary action', href: '#', variant: 'secondary' } }
|
|
26
25
|
]
|
|
27
26
|
}
|
|
28
27
|
}
|
|
@@ -14,7 +14,6 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
|
|
|
14
14
|
storybookId: 'sections-home-hero--default',
|
|
15
15
|
title: 'Home Hero',
|
|
16
16
|
block: {
|
|
17
|
-
$type: 'component',
|
|
18
17
|
id: 'fixture-home-hero',
|
|
19
18
|
component: 'home_hero',
|
|
20
19
|
props: {
|
|
@@ -23,7 +22,7 @@ export const sectionStoryFixtures: SectionStoryFixture[] = [
|
|
|
23
22
|
description: 'One content model, three renderers. Build once in Next.js, Astro, and SvelteKit—then prove parity with shared fixtures.',
|
|
24
23
|
ctaLabel: 'Get started',
|
|
25
24
|
ctaHref: '/docs',
|
|
26
|
-
|
|
25
|
+
image: {
|
|
27
26
|
$type: 'gradial.image',
|
|
28
27
|
assetId: 'fixture_seattle',
|
|
29
28
|
versionId: 'v0001',
|
|
@@ -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
|
+
}
|