@tokenoftrust/storefront-runner 2.0.41 → 2.0.43
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/apps/storefront/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +7 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/image-dependencies.ts +1438 -0
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +30 -2
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +9 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +27 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +13 -0
- package/packages/public-runtime/src/integration/native-ga4.ts +11 -0
- package/packages/public-runtime/src/integration/platform-adapters.ts +5 -1
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +19 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- package/tenants/home/theme.json +0 -6
|
@@ -159,6 +159,7 @@ export function renderIntegrationPhases(
|
|
|
159
159
|
assertRenderablePlan(plan);
|
|
160
160
|
|
|
161
161
|
const head =
|
|
162
|
+
integrationEntryPreloadLinks(plan) +
|
|
162
163
|
phaseBoundary("platform.environment") +
|
|
163
164
|
renderBrowserEnvironmentScript(plan.environment, nonce) +
|
|
164
165
|
phaseBoundary("consent.defaults") +
|
|
@@ -197,6 +198,31 @@ export function renderIntegrationPhases(
|
|
|
197
198
|
* under the raw mount, which stamps nonces onto the tenant's own inline tags BEFORE
|
|
198
199
|
* splicing engine bytes.
|
|
199
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* `modulepreload` hints for every declared integration's own same-origin entry
|
|
203
|
+
* asset (e.g. `/platform/google-measurement/v2/entry.mjs`,
|
|
204
|
+
* `/platform/amplitude-http/v1/entry.mjs`) — never a vendor/third-party URL, since
|
|
205
|
+
* `entry.url` is always the plan's own resolved, same-origin, exact-version asset.
|
|
206
|
+
*
|
|
207
|
+
* This is a pure network-timing hint: it lets the browser start fetching an
|
|
208
|
+
* integration's entry module WHILE the document.deferred lifecycle host
|
|
209
|
+
* (`boot.mjs`) is still being fetched/parsed, instead of only starting that fetch
|
|
210
|
+
* after the host finishes executing its sequential per-integration load loop. It
|
|
211
|
+
* changes nothing about WHEN or WHETHER an integration actually initializes: the
|
|
212
|
+
* consent gate and the lifecycle runtime's own dynamic `import()` are unchanged,
|
|
213
|
+
* so a preloaded-but-consent-gated module still never runs, and a preload can
|
|
214
|
+
* only ever fetch bytes this document would fetch anyway once eligible.
|
|
215
|
+
*/
|
|
216
|
+
function integrationEntryPreloadLinks(plan: EffectivePlan): string {
|
|
217
|
+
const urls = new Set<string>();
|
|
218
|
+
for (const integration of plan.integrations) {
|
|
219
|
+
if (integration.entry.format === "module") urls.add(integration.entry.url);
|
|
220
|
+
}
|
|
221
|
+
let out = "";
|
|
222
|
+
for (const url of urls) out += `<link rel="modulepreload" href="${url}">`;
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
200
226
|
function lifecycleHostScript(): string {
|
|
201
227
|
return `<script type="module" src="${LIFECYCLE_HOST_ASSET_URL}"></script>`;
|
|
202
228
|
}
|
|
@@ -208,12 +234,12 @@ function inlineScript(source: string, nonce: string): string {
|
|
|
208
234
|
|
|
209
235
|
/** Renderer-owned, provider-neutral viewer control: identical in both document mounts. */
|
|
210
236
|
function viewerAnalyticsConsentStyle(nonce: string): string {
|
|
211
|
-
const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}`;
|
|
237
|
+
const style = `.tot-analytics-consent{position:fixed;z-index:900;right:1rem;bottom:1rem;display:flex;max-width:min(30rem,calc(100vw - 2rem));align-items:center;gap:.65rem;padding:.7rem .8rem;border:1px solid var(--color-border,#cbd5e1);border-radius:.75rem;background:var(--color-bg,#fff);color:var(--color-text,#111827);box-shadow:0 8px 30px rgba(15,23,42,.18);font:500 .75rem/1.25 system-ui,sans-serif}.tot-analytics-consent__copy{display:grid;gap:.15rem;min-width:8rem}.tot-analytics-consent__detail{color:var(--color-muted,#475569);font-size:.68rem}.tot-analytics-consent__actions{display:flex;gap:.4rem}.tot-analytics-consent button{border:1px solid currentColor;border-radius:999px;background:transparent;color:inherit;padding:.4rem .65rem;font:inherit;cursor:pointer}.tot-analytics-consent button:disabled{cursor:default;opacity:.45}@media(max-width:34rem){.tot-analytics-consent{left:1rem;right:1rem;align-items:flex-start;flex-direction:column}.tot-analytics-consent__actions{width:100%}.tot-analytics-consent button{flex:1}}.tot-analytics-consent[hidden]{display:none}`;
|
|
212
238
|
return `<style${nonce ? ` nonce="${nonce}"` : ""}>${style}</style>`;
|
|
213
239
|
}
|
|
214
240
|
|
|
215
241
|
function viewerAnalyticsConsentControl(): string {
|
|
216
|
-
return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings">` +
|
|
242
|
+
return `<aside class="tot-analytics-consent" data-tot-analytics-consent-control aria-label="Analytics privacy settings" hidden>` +
|
|
217
243
|
`<span class="tot-analytics-consent__copy"><strong data-tot-analytics-consent-status aria-live="polite">Analytics off</strong>` +
|
|
218
244
|
`<span class="tot-analytics-consent__detail">Choose whether anonymous analytics may run.</span></span>` +
|
|
219
245
|
`<span class="tot-analytics-consent__actions">` +
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Types for the dependency-free redirect decision core (publication-redirects.mjs). */
|
|
2
|
+
|
|
3
|
+
export const REDIRECT_STATUS: 308;
|
|
4
|
+
export const EDGE_FUNCTION_CODE_LIMIT_BYTES: number;
|
|
5
|
+
export const EDGE_FUNCTION_RESERVED_BYTES: number;
|
|
6
|
+
export const REDIRECT_MAP_BUDGET_BYTES: number;
|
|
7
|
+
export const COLLECTION_REDIRECTS_PATH: RegExp;
|
|
8
|
+
export const SITE_REDIRECTS_PATH: "content/redirects.json";
|
|
9
|
+
export const WHOLE_SITE_DELIVERY_CONTRACT: string;
|
|
10
|
+
|
|
11
|
+
export interface DeclaredRedirect {
|
|
12
|
+
from: string;
|
|
13
|
+
to: string;
|
|
14
|
+
/** Which declaration produced it, named in any refusal. */
|
|
15
|
+
source: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RedirectSet {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
redirects: { from: string; to: string }[];
|
|
21
|
+
violations: string[];
|
|
22
|
+
bytes: number;
|
|
23
|
+
budget: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeRedirectPath(raw: string): string | null;
|
|
27
|
+
export function redirectsFromCollection(
|
|
28
|
+
collection: string,
|
|
29
|
+
rows: Record<string, unknown>,
|
|
30
|
+
): DeclaredRedirect[];
|
|
31
|
+
export function redirectsFromSiteMap(rows: Record<string, unknown>): DeclaredRedirect[];
|
|
32
|
+
export function isRedirectDeclarationPath(path: string): boolean;
|
|
33
|
+
export function declaredRedirectsFromContent(
|
|
34
|
+
entries: { path: string; json: unknown }[],
|
|
35
|
+
): DeclaredRedirect[];
|
|
36
|
+
export function redirectsFromAliases(
|
|
37
|
+
aliases: readonly { from?: string; behavior?: string; to?: string }[],
|
|
38
|
+
source?: string,
|
|
39
|
+
): DeclaredRedirect[];
|
|
40
|
+
export function buildRedirectSet(declared: DeclaredRedirect[], budget?: number): RedirectSet;
|
|
41
|
+
export function serializeRedirectMap(redirects: { from: string; to: string }[]): string;
|
|
42
|
+
export function aliasesFromRedirectSet(
|
|
43
|
+
set: RedirectSet,
|
|
44
|
+
): { from: string; behavior: "redirect-308"; to: string }[];
|
|
45
|
+
export function formatRedirectSetReport(set: RedirectSet): string;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// The pure core that turns a tenant's DECLARED redirects into a substrate-neutral redirect set
|
|
3
|
+
// the publish pipeline can emit.
|
|
4
|
+
//
|
|
5
|
+
// A tenant's public site is served CDN -> static object (ADR 0013), so a redirect cannot be a
|
|
6
|
+
// request-time decision in the Worker: it must exist in the published artifact. Both substrates
|
|
7
|
+
// express it the same way — a precomputed map consulted by the single permitted edge function —
|
|
8
|
+
// so the set below is substrate-neutral and the adapters only serialize it.
|
|
9
|
+
//
|
|
10
|
+
// Redirects are DECLARED, never derived from a tenant id. Three declaration sources normalize into
|
|
11
|
+
// the one `redirect-308` contract `PublicationSpaceAliasV1` already defines:
|
|
12
|
+
// - publication-space `routes.aliases` entries whose behavior is `redirect-308`;
|
|
13
|
+
// - a content collection's `redirects.json` (old slug -> new slug), which becomes
|
|
14
|
+
// `/<collection>/<old>/` -> `/<collection>/<new>/`;
|
|
15
|
+
// - the tenant-wide `content/redirects.json` (relative path -> relative path or an explicit
|
|
16
|
+
// HTTPS destination), for legacy URLs that belong to no single collection.
|
|
17
|
+
// Adding a tenant is a declaration, not a code change.
|
|
18
|
+
//
|
|
19
|
+
// 308 rather than 301 throughout: both pass ranking signal, and 308 additionally preserves the
|
|
20
|
+
// request method, so a redirected non-GET is not silently rewritten. The declared alias contract
|
|
21
|
+
// already specifies 308; collection renames join it rather than introducing a second status.
|
|
22
|
+
//
|
|
23
|
+
// Fails closed. An ambiguous declaration (one `from`, two targets), a cycle, or a set that exceeds
|
|
24
|
+
// the substrate's ceiling REFUSES rather than emitting a partial map — a redirect that silently
|
|
25
|
+
// does not exist is the failure this mechanism exists to prevent.
|
|
26
|
+
|
|
27
|
+
/** The status every materialized redirect carries. */
|
|
28
|
+
export const REDIRECT_STATUS = 308;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CloudFront Functions cap total code size at 10 KB, and the map is baked into the one
|
|
32
|
+
* viewer-request function the public path already carries. The emitter refuses above the budget
|
|
33
|
+
* left for it rather than shipping a function that cannot publish.
|
|
34
|
+
*/
|
|
35
|
+
export const EDGE_FUNCTION_CODE_LIMIT_BYTES = 10240;
|
|
36
|
+
/** Bump only when the meaning of a whole-site delivery manifest changes without changing its values. */
|
|
37
|
+
export const WHOLE_SITE_DELIVERY_CONTRACT = "whole-site-delivery-v1";
|
|
38
|
+
/** Reserved for the function's own logic, so the map gets the rest. */
|
|
39
|
+
export const EDGE_FUNCTION_RESERVED_BYTES = 2048;
|
|
40
|
+
/** Bytes available to the serialized map. */
|
|
41
|
+
export const REDIRECT_MAP_BUDGET_BYTES =
|
|
42
|
+
EDGE_FUNCTION_CODE_LIMIT_BYTES - EDGE_FUNCTION_RESERVED_BYTES;
|
|
43
|
+
|
|
44
|
+
/** JSON is not quite JS: these are legal in a JSON string but terminate a line to a JS parser. */
|
|
45
|
+
const LINE_SEPARATOR = "
";
|
|
46
|
+
const PARAGRAPH_SEPARATOR = "
";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} DeclaredRedirect
|
|
50
|
+
* @property {string} from root-absolute request path
|
|
51
|
+
* @property {string} to root-absolute destination path
|
|
52
|
+
* @property {string} source which declaration produced it, for the report
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {Object} RedirectSet
|
|
57
|
+
* @property {boolean} ok
|
|
58
|
+
* @property {{from: string, to: string}[]} redirects normalized, deduped, chain-collapsed
|
|
59
|
+
* @property {string[]} violations why it refused; empty when ok
|
|
60
|
+
* @property {number} bytes serialized size of the map
|
|
61
|
+
* @property {number} budget bytes available to the map
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize a declared path to the form the edge function compares against: root-absolute, no
|
|
66
|
+
* query or fragment, with a trailing slash unless the last segment names a file.
|
|
67
|
+
* Returns null when the value cannot be a same-origin request path.
|
|
68
|
+
* @param {string} raw
|
|
69
|
+
* @returns {string | null}
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeRedirectPath(raw) {
|
|
72
|
+
if (typeof raw !== "string") return null;
|
|
73
|
+
const trimmed = raw.trim();
|
|
74
|
+
if (!trimmed.startsWith("/")) return null;
|
|
75
|
+
// Protocol-relative (`//host/...`) would leave the origin.
|
|
76
|
+
if (trimmed.startsWith("//")) return null;
|
|
77
|
+
const withoutSuffix = trimmed.split("?")[0].split("#")[0];
|
|
78
|
+
if (withoutSuffix.includes("..")) return null;
|
|
79
|
+
const collapsed = withoutSuffix.replace(/\/{2,}/g, "/");
|
|
80
|
+
if (collapsed === "/") return "/";
|
|
81
|
+
// The last NON-EMPTY segment: a trailing slash otherwise hides the filename from this test.
|
|
82
|
+
const segments = collapsed.split("/").filter(Boolean);
|
|
83
|
+
const last = segments[segments.length - 1] ?? "";
|
|
84
|
+
const namesFile = last.includes(".");
|
|
85
|
+
if (namesFile) return collapsed.replace(/\/$/, "");
|
|
86
|
+
return collapsed.endsWith("/") ? collapsed : `${collapsed}/`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Turn a content collection's `redirects.json` (old slug -> new slug) into declared redirects
|
|
91
|
+
* under that collection's route prefix.
|
|
92
|
+
* @param {string} collection e.g. "blog"
|
|
93
|
+
* @param {Record<string, unknown>} rows
|
|
94
|
+
* @returns {DeclaredRedirect[]}
|
|
95
|
+
*/
|
|
96
|
+
export function redirectsFromCollection(collection, rows) {
|
|
97
|
+
if (!rows || typeof rows !== "object") return [];
|
|
98
|
+
/** @type {DeclaredRedirect[]} */
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const [oldSlug, newSlug] of Object.entries(rows)) {
|
|
101
|
+
if (typeof newSlug !== "string" || !oldSlug || !newSlug) continue;
|
|
102
|
+
out.push({
|
|
103
|
+
from: `/${collection}/${oldSlug}/`,
|
|
104
|
+
to: `/${collection}/${newSlug}/`,
|
|
105
|
+
source: `content/${collection}/redirects.json`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Turn the tenant-wide `content/redirects.json` map into root-relative declarations.
|
|
113
|
+
* Its keys and same-origin values are repository paths without a leading slash; an
|
|
114
|
+
* explicit HTTPS destination remains absolute and is validated by `buildRedirectSet`.
|
|
115
|
+
* @param {Record<string, unknown>} rows
|
|
116
|
+
* @returns {DeclaredRedirect[]}
|
|
117
|
+
*/
|
|
118
|
+
export function redirectsFromSiteMap(rows) {
|
|
119
|
+
if (!rows || typeof rows !== "object" || Array.isArray(rows)) {
|
|
120
|
+
throw new TypeError(
|
|
121
|
+
"content/redirects.json must be an object mapping relative paths to destinations",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
/** @type {DeclaredRedirect[]} */
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [from, rawTo] of Object.entries(rows)) {
|
|
127
|
+
if (
|
|
128
|
+
!from || from.startsWith("/") || typeof rawTo !== "string" || !rawTo || rawTo.startsWith("/")
|
|
129
|
+
) {
|
|
130
|
+
throw new TypeError(
|
|
131
|
+
"content/redirects.json keys and same-origin destinations must be non-empty paths without a leading slash",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const rootedFrom = `/${from}`;
|
|
135
|
+
const isExternal = /^[A-Za-z][A-Za-z0-9+.-]*:/.test(rawTo);
|
|
136
|
+
const rootedTo = isExternal ? rawTo : `/${rawTo}`;
|
|
137
|
+
out.push({
|
|
138
|
+
from: normalizeRedirectPath(rootedFrom) ?? rootedFrom,
|
|
139
|
+
to: isExternal ? rawTo : normalizeRedirectPath(rootedTo) ?? rootedTo,
|
|
140
|
+
source: SITE_REDIRECTS_PATH,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A content collection's redirect declaration, as it appears in a composed bundle's file list.
|
|
148
|
+
* `content/blog/redirects.json` declares redirects for the `blog` collection.
|
|
149
|
+
*/
|
|
150
|
+
export const COLLECTION_REDIRECTS_PATH = /^content\/([A-Za-z0-9][A-Za-z0-9_-]*)\/redirects\.json$/;
|
|
151
|
+
|
|
152
|
+
/** The tenant-wide redirect declaration, for legacy URLs that belong to no single collection. */
|
|
153
|
+
export const SITE_REDIRECTS_PATH = "content/redirects.json";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Does this composed file declare redirects the composition lifts into `routes.aliases`?
|
|
157
|
+
* @param {string} path
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
export function isRedirectDeclarationPath(path) {
|
|
161
|
+
return path === SITE_REDIRECTS_PATH || COLLECTION_REDIRECTS_PATH.test(path ?? "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Declared redirects for every content collection present in a composed revision.
|
|
166
|
+
*
|
|
167
|
+
* Reads the COMPOSED files rather than a working tree, so the redirects are bound to the same
|
|
168
|
+
* revision as the pages they point at — a rename and its redirect can never publish apart.
|
|
169
|
+
*
|
|
170
|
+
* @param {{path: string, json: unknown}[]} entries composed files, already parsed
|
|
171
|
+
* @returns {DeclaredRedirect[]}
|
|
172
|
+
*/
|
|
173
|
+
export function declaredRedirectsFromContent(entries) {
|
|
174
|
+
/** @type {DeclaredRedirect[]} */
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const entry of entries ?? []) {
|
|
177
|
+
if (entry?.path === SITE_REDIRECTS_PATH) {
|
|
178
|
+
out.push(...redirectsFromSiteMap(/** @type {Record<string, unknown>} */ (entry.json)));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const match = COLLECTION_REDIRECTS_PATH.exec(entry?.path ?? "");
|
|
182
|
+
if (!match) continue;
|
|
183
|
+
out.push(
|
|
184
|
+
...redirectsFromCollection(
|
|
185
|
+
/** @type {string} */ (match[1]),
|
|
186
|
+
/** @type {Record<string, unknown>} */ (entry.json),
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Normalize a same-origin path or a deliberately external HTTPS destination.
|
|
195
|
+
*
|
|
196
|
+
* A source is always same-origin; a TARGET may leave the origin, but only when the declaration
|
|
197
|
+
* says so in full: `https:` only, no credentials, no query, no fragment. Anything else — including
|
|
198
|
+
* every shape that merely looks absolute — is not a usable destination and refuses.
|
|
199
|
+
* @param {string} raw
|
|
200
|
+
* @returns {string | null}
|
|
201
|
+
*/
|
|
202
|
+
function normalizeRedirectDestination(/** @type {string} */ raw) {
|
|
203
|
+
const sameOrigin = normalizeRedirectPath(raw);
|
|
204
|
+
if (sameOrigin !== null) return sameOrigin;
|
|
205
|
+
try {
|
|
206
|
+
const url = new URL(raw);
|
|
207
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "");
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Turn publication-space aliases into declared redirects. Only `redirect-308` aliases produce a
|
|
218
|
+
* redirect; a `render` alias is served, not redirected.
|
|
219
|
+
* @param {{from?: string, behavior?: string, to?: string}[]} aliases
|
|
220
|
+
* @param {string} [source]
|
|
221
|
+
* @returns {DeclaredRedirect[]}
|
|
222
|
+
*/
|
|
223
|
+
export function redirectsFromAliases(aliases, source = "publication-space routes.aliases") {
|
|
224
|
+
if (!Array.isArray(aliases)) return [];
|
|
225
|
+
/** @type {DeclaredRedirect[]} */
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const alias of aliases) {
|
|
228
|
+
if (!alias || alias.behavior !== "redirect-308") continue;
|
|
229
|
+
if (typeof alias.from !== "string" || typeof alias.to !== "string") continue;
|
|
230
|
+
out.push({ from: alias.from, to: alias.to, source });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Follow `from -> to` through the declared map so a rename chain (a -> b, b -> c) resolves to a
|
|
237
|
+
* single hop (a -> c). A chain costs an extra round trip on every visit and dilutes the signal the
|
|
238
|
+
* redirect exists to preserve. A cycle is unresolvable and refuses.
|
|
239
|
+
* @param {Map<string, string>} direct
|
|
240
|
+
* @param {string} from
|
|
241
|
+
* @returns {{to: string} | {cycle: string[]}}
|
|
242
|
+
*/
|
|
243
|
+
function collapseChain(direct, from) {
|
|
244
|
+
const seen = [from];
|
|
245
|
+
let to = /** @type {string} */ (direct.get(from));
|
|
246
|
+
while (direct.has(to)) {
|
|
247
|
+
if (seen.includes(to)) return { cycle: [...seen, to] };
|
|
248
|
+
seen.push(to);
|
|
249
|
+
to = /** @type {string} */ (direct.get(to));
|
|
250
|
+
}
|
|
251
|
+
return { to };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build the substrate-neutral redirect set from every declared source.
|
|
256
|
+
* @param {DeclaredRedirect[]} declared
|
|
257
|
+
* @param {number} [budget]
|
|
258
|
+
* @returns {RedirectSet}
|
|
259
|
+
*/
|
|
260
|
+
export function buildRedirectSet(declared, budget = REDIRECT_MAP_BUDGET_BYTES) {
|
|
261
|
+
/** @type {string[]} */
|
|
262
|
+
const violations = [];
|
|
263
|
+
/** @type {Map<string, string>} */
|
|
264
|
+
const direct = new Map();
|
|
265
|
+
/** @type {Map<string, string>} */
|
|
266
|
+
const origin = new Map();
|
|
267
|
+
|
|
268
|
+
for (const entry of declared) {
|
|
269
|
+
const from = normalizeRedirectPath(entry.from);
|
|
270
|
+
const to = normalizeRedirectDestination(entry.to);
|
|
271
|
+
if (from === null) {
|
|
272
|
+
violations.push(`${entry.source}: redirect source is not a usable request path`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (to === null) {
|
|
276
|
+
violations.push(`${entry.source}: redirect target is not a usable destination path`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (from === to) {
|
|
280
|
+
violations.push(`${entry.source}: "${from}" redirects to itself`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const existing = direct.get(from);
|
|
284
|
+
if (existing !== undefined && existing !== to) {
|
|
285
|
+
violations.push(
|
|
286
|
+
`"${from}" is declared twice with different targets ("${existing}" in ${origin.get(from)}, "${to}" in ${entry.source})`,
|
|
287
|
+
);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
direct.set(from, to);
|
|
291
|
+
origin.set(from, entry.source);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @type {{from: string, to: string}[]} */
|
|
295
|
+
const redirects = [];
|
|
296
|
+
for (const from of [...direct.keys()].sort()) {
|
|
297
|
+
const resolved = collapseChain(direct, from);
|
|
298
|
+
if ("cycle" in resolved) {
|
|
299
|
+
violations.push(`redirect cycle: ${resolved.cycle.join(" -> ")}`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
redirects.push({ from, to: resolved.to });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const bytes = serializeRedirectMap(redirects).length;
|
|
306
|
+
if (bytes > budget) {
|
|
307
|
+
violations.push(
|
|
308
|
+
`redirect map is ${bytes} bytes, over the ${budget}-byte edge budget (${redirects.length} redirects) — move the map to a key-value store before declaring more`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { ok: violations.length === 0, redirects, violations, bytes, budget };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The map exactly as an edge function embeds it: compact JSON, keys sorted, so the same declared
|
|
317
|
+
* input always produces byte-identical output and the published artifact stays deterministic.
|
|
318
|
+
*
|
|
319
|
+
* The output is embedded as a JS literal in generated edge-function source, and its keys and values
|
|
320
|
+
* are tenant-declared strings. `JSON.stringify` escapes quotes and backslashes, which closes the
|
|
321
|
+
* obvious break-out; the escapes below close the rest of the JSON-is-not-quite-JS gap — the two
|
|
322
|
+
* separator code points are legal in JSON strings but are line terminators to a JS parser, and `<`
|
|
323
|
+
* is neutralized so the same bytes stay inert if the map is ever inlined into markup.
|
|
324
|
+
* @param {{from: string, to: string}[]} redirects
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
export function serializeRedirectMap(redirects) {
|
|
328
|
+
/** @type {Record<string, string>} */
|
|
329
|
+
const map = {};
|
|
330
|
+
for (const { from, to } of [...redirects].sort((a, b) => a.from.localeCompare(b.from))) {
|
|
331
|
+
map[from] = to;
|
|
332
|
+
}
|
|
333
|
+
return JSON.stringify(map)
|
|
334
|
+
.split(LINE_SEPARATOR)
|
|
335
|
+
.join("\\u2028")
|
|
336
|
+
.split(PARAGRAPH_SEPARATOR)
|
|
337
|
+
.join("\\u2029")
|
|
338
|
+
.split("<")
|
|
339
|
+
.join("\\u003c");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The declared set as publication-space aliases — the shape `routes.aliases` already carries and
|
|
344
|
+
* `publicationRedirects()` (scripts/publish/lib/publication-space-release.mjs) already consumes on
|
|
345
|
+
* its way into the live pointer. Emitting this rather than a parallel structure is what keeps
|
|
346
|
+
* redirects ONE contract: the edge function, the pointer value, and the declaration all agree
|
|
347
|
+
* because there is only one of them.
|
|
348
|
+
*
|
|
349
|
+
* Returns an empty array when the set refused, so a caller that ignores `ok` cannot publish a
|
|
350
|
+
* half-built topology.
|
|
351
|
+
* @param {RedirectSet} set
|
|
352
|
+
* @returns {{from: string, behavior: "redirect-308", to: string}[]}
|
|
353
|
+
*/
|
|
354
|
+
export function aliasesFromRedirectSet(set) {
|
|
355
|
+
if (!set.ok) return [];
|
|
356
|
+
return set.redirects.map(({ from, to }) => ({
|
|
357
|
+
from,
|
|
358
|
+
behavior: /** @type {"redirect-308"} */ ("redirect-308"),
|
|
359
|
+
to,
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {RedirectSet} set
|
|
365
|
+
* @returns {string}
|
|
366
|
+
*/
|
|
367
|
+
export function formatRedirectSetReport(set) {
|
|
368
|
+
const lines = [];
|
|
369
|
+
lines.push(
|
|
370
|
+
`== Redirect materialization == ${set.redirects.length} redirect(s), ${set.bytes}/${set.budget} bytes`,
|
|
371
|
+
);
|
|
372
|
+
for (const violation of set.violations) lines.push(` REFUSE: ${violation}`);
|
|
373
|
+
lines.push(
|
|
374
|
+
set.ok
|
|
375
|
+
? `OK — every declared redirect materializes at ${REDIRECT_STATUS}.`
|
|
376
|
+
: `REFUSED — ${set.violations.length} violation(s); nothing emitted.`,
|
|
377
|
+
);
|
|
378
|
+
return lines.join("\n");
|
|
379
|
+
}
|
|
@@ -27,11 +27,32 @@ export const PUBLICATION_MOUNT_ENTRY_VERSION = 1 as const;
|
|
|
27
27
|
export const PUBLICATION_SPACE_PATH_CASE_POLICY = "sensitive" as const;
|
|
28
28
|
export const PUBLICATION_SPACE_UNICODE_POLICY = "nfc" as const;
|
|
29
29
|
|
|
30
|
+
/** Static public-discovery outputs generated from exact live publication versions. */
|
|
31
|
+
export const PUBLICATION_DISCOVERY_OUTPUT_PATHS_V1 = [
|
|
32
|
+
"/feed.json",
|
|
33
|
+
"/llms.txt",
|
|
34
|
+
"/publication-discovery/manifest.json",
|
|
35
|
+
"/publication-discovery/redirects.json",
|
|
36
|
+
"/publication-discovery/seo.json",
|
|
37
|
+
"/search-index.json",
|
|
38
|
+
"/sitemap.xml",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/** Platform route roots that reserve every generated discovery output. */
|
|
42
|
+
export const PUBLICATION_DISCOVERY_RESERVED_MOUNTS_V1 = [
|
|
43
|
+
"/feed.json",
|
|
44
|
+
"/llms.txt",
|
|
45
|
+
"/publication-discovery",
|
|
46
|
+
"/search-index.json",
|
|
47
|
+
"/sitemap.xml",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
30
50
|
/** Platform-owned path segments that tenant publication mounts may not claim. */
|
|
31
51
|
export const PUBLICATION_SPACE_RESERVED_MOUNTS = [
|
|
32
52
|
"/.well-known",
|
|
33
53
|
"/__evidence",
|
|
34
54
|
"/__preview",
|
|
55
|
+
"/_a",
|
|
35
56
|
"/_astro",
|
|
36
57
|
"/_style-guide",
|
|
37
58
|
"/admin",
|
|
@@ -47,7 +68,7 @@ export const PUBLICATION_SPACE_RESERVED_MOUNTS = [
|
|
|
47
68
|
"/favicon.svg",
|
|
48
69
|
"/health",
|
|
49
70
|
"/js",
|
|
50
|
-
|
|
71
|
+
...PUBLICATION_DISCOVERY_RESERVED_MOUNTS_V1,
|
|
51
72
|
"/platform",
|
|
52
73
|
"/preview",
|
|
53
74
|
"/products",
|
|
@@ -56,7 +77,6 @@ export const PUBLICATION_SPACE_RESERVED_MOUNTS = [
|
|
|
56
77
|
"/saved",
|
|
57
78
|
"/robots.txt",
|
|
58
79
|
"/shared",
|
|
59
|
-
"/sitemap.xml",
|
|
60
80
|
"/style-guide",
|
|
61
81
|
"/tenants",
|
|
62
82
|
] as const;
|
|
@@ -280,7 +300,8 @@ function validateIdentifier(value: string | undefined, v: Violations, path: stri
|
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
function isReservedMount(mount: string): boolean {
|
|
283
|
-
|
|
303
|
+
const caseFolded = mount.toLowerCase();
|
|
304
|
+
return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) => publicationMountMatchesPath(reserved, caseFolded));
|
|
284
305
|
}
|
|
285
306
|
|
|
286
307
|
function validateSource(raw: unknown, v: Violations, path: string): PublicationSpaceSourceV1 | undefined {
|