@tokenoftrust/storefront-runner 2.5.5 → 3.0.0

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 (80) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +95 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/ImageGallery.tsx +20 -3
  7. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  8. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  9. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  10. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  11. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  12. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  13. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  14. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  15. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  16. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  17. package/apps/storefront/src/layouts/Layout.astro +1 -1
  18. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  19. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  20. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  22. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  23. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  24. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  25. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  26. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  28. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  29. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  30. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  31. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  32. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  33. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  34. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  35. package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
  36. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  37. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  38. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  39. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  40. package/apps/storefront/src/middleware/index.ts +33 -12
  41. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  42. package/apps/storefront/src/pages/[...slug].astro +3 -0
  43. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  44. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  45. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  46. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  47. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  48. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  49. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  50. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  51. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  52. package/apps/storefront/src/pages/collections/index.astro +1 -1
  53. package/apps/storefront/src/pages/products/[handle].astro +18 -8
  54. package/apps/storefront/src/pages/search.astro +33 -33
  55. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  56. package/apps/storefront/vitest.config.cts +3 -0
  57. package/package.json +1 -1
  58. package/packages/cli/src/declared-config.mjs +318 -32
  59. package/packages/cli/src/publication-spaces.mjs +1 -1
  60. package/packages/public-runtime/package.json +1 -0
  61. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  62. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  63. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  64. package/packages/public-runtime/src/candidate-index.ts +5 -0
  65. package/packages/public-runtime/src/capabilities.ts +12 -9
  66. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  67. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  68. package/packages/public-runtime/src/checkout.ts +190 -6
  69. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  70. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  71. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  72. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  73. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  74. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  75. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  76. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  77. package/packages/public-runtime/src/membership.ts +3 -0
  78. package/packages/public-runtime/src/publication-space.ts +1 -0
  79. package/packages/public-runtime/src/tenant.ts +28 -4
  80. package/scripts/tenant/validate.mjs +17 -8
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Capture rewrites: replace each captured image reference in artifact text with its platform-owned
3
+ * `/_a/<sha256><ext>` URL, leaving every reference that was not captured exactly as written.
4
+ *
5
+ * One engine for every place a captured revision is rendered: the hosted preview overlay
6
+ * (`hosted-preview-image-capture.ts`) and the static publish, which rewrites the checked-out tenant
7
+ * tree from the same sealed manifest before it builds (`scripts/publish/apply-captured-images.mjs`),
8
+ * so both CDNs serve the bytes the preview showed. Pure string work with no runtime imports, so the
9
+ * publish scripts load it directly.
10
+ */
11
+ import type { ImageDependency, ImageDependencyArtifact } from "./image-dependencies.js";
12
+
13
+ export type CaptureRewrite = {
14
+ /** Effect-only; never log or render. */
15
+ canonicalLocator: string;
16
+ /** Additional spellings present in artifact text (raw locator, sealed path). */
17
+ aliases: string[];
18
+ /**
19
+ * Remote spellings (also listed in `aliases`) that match only as written: a following `?query`
20
+ * is a different URL, and a different image, so it is never absorbed into the match.
21
+ */
22
+ exactAliases?: string[];
23
+ platformUrl: string;
24
+ address: string;
25
+ mediaType: string;
26
+ };
27
+
28
+ /** A sealed capture-manifest entry, as `original-image-capture.ts` writes it. */
29
+ export interface CapturedManifestEntry {
30
+ /** Remote `locator` is query-stripped for display; remote entries are matched by `locatorDigest`. */
31
+ provenance: { kind: "local" | "remote"; locator: string };
32
+ address: string;
33
+ mediaType: string;
34
+ /** Remote entries: digest of the exact fetched locator, query included (`captureLocatorDigest`). */
35
+ locatorDigest?: string;
36
+ }
37
+
38
+ export function stripQueryFragment(value: string): string {
39
+ return value.split(/[?#]/, 1)[0] ?? value;
40
+ }
41
+
42
+ export function isAbsoluteHttpLocator(value: string): boolean {
43
+ return /^https?:\/\//i.test(value) || value.startsWith("//");
44
+ }
45
+
46
+ /** Bare local path with a leading slash; absolute http(s) locators stay absolute. */
47
+ export function normalizeRewriteLocator(value: string): string {
48
+ const bare = stripQueryFragment(value);
49
+ if (isAbsoluteHttpLocator(bare)) return bare;
50
+ return bare.startsWith("/") ? bare : `/${bare}`;
51
+ }
52
+
53
+ export function localPathKey(value: string): string {
54
+ return stripQueryFragment(value).replace(/^\//, "");
55
+ }
56
+
57
+ export function addAlias(aliases: string[], value: string | undefined, canonical: string): void {
58
+ if (!value || value === canonical || aliases.includes(value)) return;
59
+ aliases.push(value);
60
+ }
61
+
62
+ export function locatorWasCaptured(captured: ReadonlySet<string>, locator: string): boolean {
63
+ if (captured.has(locator)) return true;
64
+ // A remote query names a different image, so only that exact spelling (fragment aside) counts:
65
+ // `render?id=1` captured says nothing about `render?id=2`.
66
+ if (isAbsoluteHttpLocator(locator)) return captured.has(locator.split("#", 1)[0]!);
67
+ const bare = stripQueryFragment(locator);
68
+ if (captured.has(bare) || captured.has(bare.replace(/^\//, "")) || captured.has(`/${bare.replace(/^\//, "")}`)) {
69
+ return true;
70
+ }
71
+ const key = localPathKey(locator);
72
+ for (const item of captured) {
73
+ if (localPathKey(item) === key) return true;
74
+ if (stripQueryFragment(item) === bare) return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ /** Characters that may continue a longer absolute URL around a locator token. */
80
+ const URL_TOKEN_CONTINUE = /[A-Za-z0-9._~:@%+\-]/;
81
+ /**
82
+ * Path/query/fragment glue inside an absolute URL token.
83
+ * Includes `;` so HTML `&amp;`-joined query embeds stay one enclosing token.
84
+ */
85
+ const URL_TOKEN_INNER = /[A-Za-z0-9._~:@%+\-/?#&=;]/;
86
+
87
+ function isLocatorLeftEdge(prev: string | undefined, haystack?: string, start?: number): boolean {
88
+ if (prev == null) return true;
89
+ // Attribute/CSS value starts — not URL query/fragment/assignment glue.
90
+ // `(` and `,` are NOT standalone edges: enclosingUrlToken cannot span them, so
91
+ // a captured alias after `(` / `,` inside a longer absolute/root-relative URL
92
+ // would otherwise rewrite in place. `(` is allowed only for flush `url(`;
93
+ // spaced srcset candidates use whitespace as the edge.
94
+ if (prev === '"' || prev === "'" || prev === "=" || /\s/.test(prev)) {
95
+ return true;
96
+ }
97
+ if (prev === "(" && haystack != null && start != null) {
98
+ // `url(/path)` or `url( /path)` — optional whitespace already handled when
99
+ // prev is whitespace; here prev is the opening paren flush against the locator.
100
+ const before = haystack.slice(Math.max(0, start - 4), start);
101
+ if (before.toLowerCase() === "url(") return true;
102
+ return false;
103
+ }
104
+ // `,` is never a standalone left edge. Spaced srcset/imagesrcset candidates
105
+ // (`1x, /path`) already have whitespace as prev. A comma inside an absolute
106
+ // URL query list (`?list=/u.png,/path`) must not rewrite the post-comma alias.
107
+ if (prev === ",") return false;
108
+ // HTML entity-quoted CSS url(&quot;/path&quot;)
109
+ if (
110
+ haystack != null
111
+ && start != null
112
+ && start >= 6
113
+ && haystack.slice(start - 6, start).toLowerCase() === "&quot;"
114
+ ) {
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ function isLocatorRightEdge(next: string | undefined): boolean {
121
+ if (next == null) return true;
122
+ return next === '"' || next === "'" || next === ")" || next === "?" || next === "#" || next === ","
123
+ || /\s/.test(next);
124
+ }
125
+
126
+ /**
127
+ * Start of the current tag region before `index`. Quotes are only meaningful
128
+ * inside tags, so text apostrophes (don't) cannot poison later markup.
129
+ */
130
+ function currentTagStart(haystack: string, index: number): number {
131
+ let quote: string | null = null;
132
+ let inTag = false;
133
+ let last = 0;
134
+ for (let i = 0; i < index; i++) {
135
+ const ch = haystack[i]!;
136
+ if (!inTag) {
137
+ if (ch === "<") {
138
+ inTag = true;
139
+ quote = null;
140
+ last = i;
141
+ }
142
+ continue;
143
+ }
144
+ if (quote) {
145
+ if (ch === quote) quote = null;
146
+ continue;
147
+ }
148
+ if (ch === '"' || ch === "'") {
149
+ quote = ch;
150
+ continue;
151
+ }
152
+ if (ch === ">") {
153
+ inTag = false;
154
+ last = i + 1;
155
+ }
156
+ }
157
+ return last;
158
+ }
159
+
160
+ /** True when index sits inside attribute quotes within the current tag. */
161
+ function isInsideQuotes(haystack: string, index: number): boolean {
162
+ let quote: string | null = null;
163
+ let inTag = false;
164
+ for (let i = 0; i < index; i++) {
165
+ const ch = haystack[i]!;
166
+ if (!inTag) {
167
+ if (ch === "<") {
168
+ inTag = true;
169
+ quote = null;
170
+ }
171
+ continue;
172
+ }
173
+ if (quote) {
174
+ if (ch === quote) quote = null;
175
+ continue;
176
+ }
177
+ if (ch === '"' || ch === "'") {
178
+ quote = ch;
179
+ continue;
180
+ }
181
+ if (ch === ">") {
182
+ inTag = false;
183
+ quote = null;
184
+ }
185
+ }
186
+ return inTag && quote != null;
187
+ }
188
+
189
+ /**
190
+ * HTML/SVG void self-close (`…png/>`) outside quoted attribute values.
191
+ * Inside quotes, `/>` is path text.
192
+ */
193
+ function isHtmlSelfCloseSlash(haystack: string, slashIndex: number): boolean {
194
+ if (haystack[slashIndex] !== "/" || haystack[slashIndex + 1] !== ">") return false;
195
+ return !isInsideQuotes(haystack, slashIndex);
196
+ }
197
+
198
+ /**
199
+ * Strip an unquoted `attr=` glue prefix that enclosingUrlToken may attach
200
+ * (`src=https://…`, `href=//cdn…`).
201
+ */
202
+ function stripAttrAssignmentPrefix(value: string): string {
203
+ return value.replace(/^[A-Za-z_:][\w:.-]*=/, "");
204
+ }
205
+
206
+ /**
207
+ * Expand [start, end) through URL-significant characters to the enclosing
208
+ * absolute-or-path token (stops at quotes, whitespace, parens).
209
+ */
210
+ function enclosingUrlToken(haystack: string, start: number, end: number): { start: number; end: number; value: string } {
211
+ let left = start;
212
+ while (left > 0 && URL_TOKEN_INNER.test(haystack[left - 1]!)) {
213
+ // Do not absorb a wrapping `&quot;` entity into the URL token.
214
+ if (
215
+ haystack[left - 1] === ";"
216
+ && left >= 6
217
+ && haystack.slice(left - 6, left).toLowerCase() === "&quot;"
218
+ ) {
219
+ break;
220
+ }
221
+ left -= 1;
222
+ }
223
+ let right = end;
224
+ while (right < haystack.length && URL_TOKEN_INNER.test(haystack[right]!)) {
225
+ // Markup self-close (`…png/>`) is not URL path — stop before absorbing `/`.
226
+ if (isHtmlSelfCloseSlash(haystack, right)) break;
227
+ // Closing `&quot;` wrapper around a CSS url() argument.
228
+ if (haystack.slice(right, right + 6).toLowerCase() === "&quot;") break;
229
+ right += 1;
230
+ }
231
+ return { start: left, end: right, value: haystack.slice(left, right) };
232
+ }
233
+
234
+ /**
235
+ * Start of the HTML/CSS attribute (or url()) value that contains `index`.
236
+ * Returns -1 when no value opener is found in the current tag.
237
+ */
238
+ function attributeValueStart(haystack: string, index: number): number {
239
+ const from = currentTagStart(haystack, index);
240
+ let valueStart = -1;
241
+ let inQuote: string | null = null;
242
+ let inUnquotedValue = false;
243
+ for (let j = from; j < index; j++) {
244
+ const ch = haystack[j]!;
245
+ if (inQuote) {
246
+ if (ch === inQuote) {
247
+ inQuote = null;
248
+ valueStart = -1;
249
+ }
250
+ continue;
251
+ }
252
+ if (inUnquotedValue) {
253
+ // `=` inside an unquoted value (mailto?body=…) is not a new attribute.
254
+ if (/\s/.test(ch)) {
255
+ inUnquotedValue = false;
256
+ valueStart = -1;
257
+ }
258
+ continue;
259
+ }
260
+ if (ch === '"' || ch === "'") {
261
+ inQuote = ch;
262
+ valueStart = j + 1;
263
+ continue;
264
+ }
265
+ if (ch === "=") {
266
+ let k = j + 1;
267
+ while (k < index && /\s/.test(haystack[k]!)) k += 1;
268
+ // k may equal index when the locator begins the unquoted value.
269
+ if (k > index) continue;
270
+ if (k < haystack.length && (haystack[k] === '"' || haystack[k] === "'")) continue;
271
+ valueStart = k;
272
+ inUnquotedValue = true;
273
+ continue;
274
+ }
275
+ if (ch === "(") {
276
+ valueStart = j + 1;
277
+ }
278
+ }
279
+ return valueStart;
280
+ }
281
+
282
+ /**
283
+ * True when the match sits inside a mailto:/sms:/tel: attribute value.
284
+ * Only the current attribute value is inspected (not earlier attrs on the tag).
285
+ */
286
+ function isInsideContactScheme(haystack: string, start: number): boolean {
287
+ const valueStart = attributeValueStart(haystack, start);
288
+ if (valueStart < 0 || valueStart > start) return false;
289
+ return /^(?:mailto|sms|tel):/i.test(haystack.slice(valueStart, start).trimStart());
290
+ }
291
+
292
+ /** Attribute name for the value containing `index`, or null. */
293
+ function currentAttributeName(haystack: string, index: number): string | null {
294
+ const valueStart = attributeValueStart(haystack, index);
295
+ if (valueStart < 0) return null;
296
+ let i = valueStart - 1;
297
+ while (i >= 0 && (haystack[i] === '"' || haystack[i] === "'" || haystack[i] === "=" || /\s/.test(haystack[i]!))) {
298
+ i -= 1;
299
+ }
300
+ const end = i + 1;
301
+ while (i >= 0 && /[\w:-]/.test(haystack[i]!)) i -= 1;
302
+ const name = haystack.slice(i + 1, end);
303
+ return name ? name.toLowerCase() : null;
304
+ }
305
+
306
+ /**
307
+ * If `index` is inside a CSS `url(…)`, return the optional quote that opened
308
+ * the url argument. Null when not inside url(.
309
+ */
310
+ function cssUrlArgContext(haystack: string, index: number): { quote: string | null } | null {
311
+ let from = attributeValueStart(haystack, index);
312
+ if (from < 0) {
313
+ from = 0;
314
+ for (let i = index - 1; i >= 0; i--) {
315
+ const ch = haystack[i]!;
316
+ if (ch === "{" || ch === ";" || ch === "<") {
317
+ from = i + 1;
318
+ break;
319
+ }
320
+ }
321
+ } else {
322
+ // valueStart may sit just after `url(` or inside `url("…")` / `url('…')`,
323
+ // including arbitrary whitespace: `url( "…" )`. Walk back over an
324
+ // optional quote then whitespace and require a flush `url(` — no fixed
325
+ // suffix window (a 12-char cap breaks at ≥8 spaces before the quote).
326
+ let i = from;
327
+ if (i > 0 && (haystack[i - 1] === '"' || haystack[i - 1] === "'")) {
328
+ i -= 1;
329
+ }
330
+ while (i > 0 && /\s/.test(haystack[i - 1]!)) i -= 1;
331
+ if (i >= 4 && haystack.slice(i - 4, i).toLowerCase() === "url(") {
332
+ from = i - 4;
333
+ }
334
+ }
335
+ // Bound the scan so a prior attribute's `url(` cannot poison this value.
336
+ const before = haystack.slice(from, index);
337
+ let lastUrl = -1;
338
+ let quote: string | null = null;
339
+ for (let i = 0; i < before.length; i++) {
340
+ const ch = before[i]!;
341
+ // CSS escapes: \X is literal; keep quote state across \" inside url("…").
342
+ if (ch === "\\" && i + 1 < before.length) {
343
+ i += 1;
344
+ continue;
345
+ }
346
+ if (quote) {
347
+ if (ch === quote) quote = null;
348
+ continue;
349
+ }
350
+ if (ch === '"' || ch === "'") {
351
+ quote = ch;
352
+ continue;
353
+ }
354
+ if (before.slice(i, i + 4).toLowerCase() === "url(") {
355
+ lastUrl = i;
356
+ i += 3;
357
+ }
358
+ }
359
+ if (lastUrl < 0) return null;
360
+ const afterUrl = before.slice(lastUrl + 4);
361
+ let q: string | null = null;
362
+ let depth = 1;
363
+ for (let i = 0; i < afterUrl.length; i++) {
364
+ const ch = afterUrl[i]!;
365
+ if (ch === "\\" && i + 1 < afterUrl.length) {
366
+ i += 1;
367
+ continue;
368
+ }
369
+ if (q) {
370
+ if (ch === q) q = null;
371
+ continue;
372
+ }
373
+ if (ch === '"' || ch === "'") {
374
+ q = ch;
375
+ continue;
376
+ }
377
+ if (ch === "(") depth += 1;
378
+ if (ch === ")") {
379
+ depth -= 1;
380
+ if (depth === 0) return null;
381
+ }
382
+ }
383
+ const argOpen = afterUrl.match(/^\s*(['"]|&quot;)/i);
384
+ if (!argOpen) return { quote: null };
385
+ const opener = argOpen[1]!;
386
+ return { quote: opener.toLowerCase() === "&quot;" ? "&quot;" : opener };
387
+ }
388
+
389
+ /**
390
+ * Extend through a locator's own query/fragment until a context delimiter.
391
+ * - CSS `url(`: stop at the argument's quote or unquoted `)`
392
+ * - srcset: stop before descriptor whitespace / quotes (spaced candidate lists)
393
+ * - other quoted attrs: through the value (query may contain `,` `>` `)`)
394
+ * - unquoted attrs: through URL-safe query chars plus `,` / parentheses
395
+ */
396
+ function extendOwnQueryEnd(haystack: string, queryStart: number): number {
397
+ const valueStart = attributeValueStart(haystack, queryStart);
398
+ const opener = valueStart > 0
399
+ && (haystack[valueStart - 1] === '"' || haystack[valueStart - 1] === "'")
400
+ ? haystack[valueStart - 1]!
401
+ : null;
402
+ const cssUrl = cssUrlArgContext(haystack, queryStart);
403
+ const attrName = currentAttributeName(haystack, queryStart);
404
+ const inSrcset = attrName === "srcset" || attrName === "imagesrcset";
405
+ let i = queryStart;
406
+ let unquotedUrlDepth = cssUrl && !cssUrl.quote ? 1 : 0;
407
+ while (i < haystack.length) {
408
+ const ch = haystack[i]!;
409
+ if (cssUrl) {
410
+ // CSS escapes: \X is a literal X inside url(), not a delimiter.
411
+ if (ch === "\\" && i + 1 < haystack.length) {
412
+ i += 2;
413
+ continue;
414
+ }
415
+ if (cssUrl.quote === "&quot;") {
416
+ if (haystack.slice(i, i + 6).toLowerCase() === "&quot;") break;
417
+ } else if (cssUrl.quote) {
418
+ if (ch === cssUrl.quote) break;
419
+ } else {
420
+ if (ch === "(") unquotedUrlDepth += 1;
421
+ if (ch === ")") {
422
+ unquotedUrlDepth -= 1;
423
+ if (unquotedUrlDepth <= 0) break;
424
+ }
425
+ }
426
+ i += 1;
427
+ continue;
428
+ }
429
+ if (inSrcset) {
430
+ // Stop at descriptor whitespace / quotes only. Comma heuristics fight
431
+ // mid-query path commas vs dense next-candidates; prefer full query
432
+ // strip (PII) and require spaced srcset forms (`?v=1 1x, other.png`).
433
+ if (/\s/.test(ch)) break;
434
+ if (opener && ch === opener) break;
435
+ if (!opener && (ch === '"' || ch === "'" || ch === "<" || ch === ">" )) break;
436
+ if (isHtmlSelfCloseSlash(haystack, i)) break;
437
+ if (!URL_TOKEN_INNER.test(ch) && ch !== "," && ch !== "(" && ch !== ")") break;
438
+ i += 1;
439
+ continue;
440
+ }
441
+ if (opener) {
442
+ if (ch === opener) break;
443
+ i += 1;
444
+ continue;
445
+ }
446
+ // HTML entity closer for url(&quot;…&quot;)
447
+ if (
448
+ (ch === "&")
449
+ && haystack.slice(i, i + 6).toLowerCase() === "&quot;"
450
+ ) {
451
+ break;
452
+ }
453
+ if (ch === '"' || ch === "'" || ch === "<" || ch === ">" || /\s/.test(ch)) break;
454
+ if (isHtmlSelfCloseSlash(haystack, i)) break;
455
+ // Unquoted src/href queries may include parentheses and commas.
456
+ if (!URL_TOKEN_INNER.test(ch) && ch !== "," && ch !== "(" && ch !== ")") break;
457
+ i += 1;
458
+ }
459
+ return i;
460
+ }
461
+
462
+ /**
463
+ * When needle is a standalone locator, return the end index to replace
464
+ * (may extend through the locator's own ?query/#fragment). Otherwise null.
465
+ */
466
+ function standaloneReplaceEnd(haystack: string, start: number, end: number, needle: string, exact = false): number | null {
467
+ const prev = start > 0 ? haystack[start - 1] : undefined;
468
+ const next = end < haystack.length ? haystack[end] : undefined;
469
+ // An exact (remote) spelling followed by its own query is a different URL.
470
+ if (exact && next === "?") return null;
471
+ if (!isLocatorLeftEdge(prev, haystack, start)) return null;
472
+ if (isInsideContactScheme(haystack, start)) return null;
473
+ // Bare path may be followed by its own query when a longer alias was absent.
474
+ // A trailing `/` before `>` is HTML/SVG self-close, not path continuation.
475
+ if (!isLocatorRightEdge(next) && next !== "?" && next !== "#" && !isHtmlSelfCloseSlash(haystack, end)) {
476
+ if (URL_TOKEN_CONTINUE.test(next!) || next === "/") return null;
477
+ }
478
+ const enclosing = enclosingUrlToken(haystack, start, end);
479
+ const payload = stripAttrAssignmentPrefix(enclosing.value);
480
+ const payloadOrigin = enclosing.start + (enclosing.value.length - payload.length);
481
+ // Longer enclosing token ⇒ needle is embedded (absolute/relative/mailto/sms
482
+ // query or path). attr= glue alone is stripped first so unquoted absolute
483
+ // capture targets still match. A locator's own ?query/#fragment is standalone
484
+ // and replaced in full so query PII never rides onto `/_a/…`.
485
+ if (payload !== needle && payload.length > needle.length) {
486
+ const ownSuffix = payload[needle.length];
487
+ if (
488
+ start === payloadOrigin
489
+ && payload.startsWith(needle)
490
+ && (ownSuffix === "?" || ownSuffix === "#")
491
+ ) {
492
+ return extendOwnQueryEnd(haystack, start + needle.length);
493
+ }
494
+ return null;
495
+ }
496
+ // Bare needle at value start with an immediate own query/fragment.
497
+ if (next === "?" || next === "#") {
498
+ return extendOwnQueryEnd(haystack, end);
499
+ }
500
+ return end;
501
+ }
502
+
503
+ /** HTML source may keep `&amp;` while scan aliases decode to `&`. */
504
+ function htmlAmpVariants(value: string): string[] {
505
+ if (!value.includes("&") || value.includes("&amp;")) return [];
506
+ return [value.replace(/&/g, "&amp;")];
507
+ }
508
+
509
+ /**
510
+ * Replace only full locator / URL tokens (src/url values), never a bare path
511
+ * substring inside a longer absolute URL.
512
+ */
513
+ function replaceAllLocatorTokens(haystack: string, needle: string, replacement: string, exact = false): string {
514
+ if (!needle || needle === replacement) return haystack;
515
+ let out = "";
516
+ let cursor = 0;
517
+ while (cursor < haystack.length) {
518
+ const idx = haystack.indexOf(needle, cursor);
519
+ if (idx === -1) {
520
+ out += haystack.slice(cursor);
521
+ break;
522
+ }
523
+ out += haystack.slice(cursor, idx);
524
+ const end = idx + needle.length;
525
+ const replaceEnd = standaloneReplaceEnd(haystack, idx, end, needle, exact);
526
+ if (replaceEnd != null) {
527
+ out += replacement;
528
+ cursor = replaceEnd;
529
+ } else {
530
+ out += haystack[idx]!;
531
+ cursor = idx + 1;
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+
537
+ /** Replace captured locators with platform-owned URLs. Leaves unresolved refs intact. */
538
+ export function applyCaptureRewrites(
539
+ artifacts: readonly ImageDependencyArtifact[],
540
+ rewrites: readonly CaptureRewrite[],
541
+ ): ImageDependencyArtifact[] {
542
+ if (rewrites.length === 0) return artifacts.map((a) => ({ ...a }));
543
+ const prepared = rewrites.map((rewrite) => {
544
+ // Longer needles first so `/path?v=1` wins over slash-stripped `path`.
545
+ // Include `&amp;` spellings so HTML-escaped query joins still match.
546
+ const baseNeedles = [rewrite.canonicalLocator, ...rewrite.aliases].filter(Boolean);
547
+ const needles = [...new Set(baseNeedles.flatMap((n) => [n, ...htmlAmpVariants(n)]))]
548
+ .sort((a, b) => b.length - a.length);
549
+ // Every needle normally contains the shortest one, so an artifact without it holds none of
550
+ // them: one native search skips the rewrite. A catalog is thousands of rewrites over thousands
551
+ // of files, and most files name a handful of images.
552
+ const shortest = needles[needles.length - 1] ?? "";
553
+ const key = shortest && needles.every((needle) => needle.includes(shortest)) ? shortest : null;
554
+ const exact = new Set((rewrite.exactAliases ?? []).flatMap((n) => [n, ...htmlAmpVariants(n)]));
555
+ return { rewrite, needles, key, exact };
556
+ });
557
+ return artifacts.map((artifact) => {
558
+ let content = artifact.content;
559
+ for (const { rewrite, needles, key, exact } of prepared) {
560
+ if (key !== null && !content.includes(key)) continue;
561
+ for (const needle of needles) {
562
+ content = replaceAllLocatorTokens(content, needle, rewrite.platformUrl, exact.has(needle));
563
+ }
564
+ }
565
+ // Guard: slash-stripped local needles must not leave a protocol-relative URL.
566
+ content = content.replace(/\/{2,}_a\//g, "/_a/");
567
+ return { ...artifact, content };
568
+ });
569
+ }
570
+
571
+ /**
572
+ * One rewrite per sealed address, carrying every spelling of it the scan saw.
573
+ *
574
+ * A local entry names a repo file, so its query is irrelevant: it matches by path and carries the
575
+ * sealed path with and without its leading slash, plus every query-bearing spelling of that path.
576
+ * A remote entry names exactly the URL that was fetched, query included: it matches only a
577
+ * reference whose canonical locator has its `locatorDigest`, and carries only that reference's
578
+ * own spellings, never the query-stripped path (which would rewrite a query-distinct sibling, a
579
+ * different image, to this one). A key that names more than one address is never resolved.
580
+ */
581
+ export function buildCaptureRewrites(input: {
582
+ entries: readonly CapturedManifestEntry[];
583
+ dependencies: readonly Pick<ImageDependency, "disposition" | "reason" | "canonicalLocator" | "locator">[];
584
+ platformUrl(address: string, mediaType: string): string;
585
+ /** `captureLocatorDigest`: sha256 hex of the canonical locator, fragment dropped. */
586
+ locatorDigest(canonicalLocator: string): string;
587
+ }): CaptureRewrite[] {
588
+ const rewrites: CaptureRewrite[] = [];
589
+ const byAddress = new Map<string, CaptureRewrite>();
590
+ const rewriteFor = (entry: CapturedManifestEntry, canonicalLocator: string): CaptureRewrite => {
591
+ const existing = byAddress.get(entry.address);
592
+ if (existing) return existing;
593
+ const rewrite: CaptureRewrite = {
594
+ canonicalLocator,
595
+ aliases: [],
596
+ platformUrl: input.platformUrl(entry.address, entry.mediaType),
597
+ address: entry.address,
598
+ mediaType: entry.mediaType,
599
+ };
600
+ byAddress.set(entry.address, rewrite);
601
+ rewrites.push(rewrite);
602
+ return rewrite;
603
+ };
604
+
605
+ // Indexed rather than scanned: a catalog seals thousands of entries for thousands of references.
606
+ // `null` marks a key that names more than one address.
607
+ const localByKey = new Map<string, CapturedManifestEntry | null>();
608
+ const remoteByDigest = new Map<string, CapturedManifestEntry | null>();
609
+ const index = (table: Map<string, CapturedManifestEntry | null>, key: string, entry: CapturedManifestEntry) => {
610
+ const current = table.get(key);
611
+ if (current === undefined) table.set(key, entry);
612
+ else if (current !== null && current.address !== entry.address) table.set(key, null);
613
+ };
614
+ for (const entry of input.entries) {
615
+ if (entry.provenance.kind === "local") index(localByKey, localPathKey(entry.provenance.locator), entry);
616
+ else if (entry.locatorDigest) index(remoteByDigest, entry.locatorDigest, entry);
617
+ }
618
+
619
+ for (const entry of input.entries) {
620
+ if (entry.provenance.kind !== "local" || !localByKey.get(localPathKey(entry.provenance.locator))) continue;
621
+ const sealed = entry.provenance.locator;
622
+ const rewrite = rewriteFor(entry, normalizeRewriteLocator(sealed));
623
+ addAlias(rewrite.aliases, sealed, rewrite.canonicalLocator);
624
+ addAlias(rewrite.aliases, sealed.replace(/^\//, ""), rewrite.canonicalLocator);
625
+ addAlias(rewrite.aliases, `/${sealed.replace(/^\//, "")}`, rewrite.canonicalLocator);
626
+ }
627
+
628
+ for (const dep of input.dependencies) {
629
+ if (dep.disposition !== "capture-required") continue;
630
+ if (dep.reason === "local") {
631
+ const keys = new Set([localPathKey(dep.canonicalLocator), localPathKey(dep.locator)]);
632
+ const hits = [...keys].map((key) => localByKey.get(key));
633
+ if (hits.some((hit) => hit === null)) continue;
634
+ const entry = hits.find((hit): hit is CapturedManifestEntry => hit !== undefined);
635
+ if (!entry) continue;
636
+ const rewrite = rewriteFor(entry, normalizeRewriteLocator(dep.canonicalLocator));
637
+ rewrite.canonicalLocator = normalizeRewriteLocator(dep.canonicalLocator);
638
+ addAlias(rewrite.aliases, dep.locator, rewrite.canonicalLocator);
639
+ addAlias(rewrite.aliases, dep.canonicalLocator, rewrite.canonicalLocator);
640
+ addAlias(rewrite.aliases, stripQueryFragment(dep.locator), rewrite.canonicalLocator);
641
+ addAlias(rewrite.aliases, stripQueryFragment(dep.canonicalLocator), rewrite.canonicalLocator);
642
+ addAlias(rewrite.aliases, entry.provenance.locator, rewrite.canonicalLocator);
643
+ } else if (dep.reason === "third-party") {
644
+ const entry = remoteByDigest.get(input.locatorDigest(dep.canonicalLocator));
645
+ if (!entry) continue;
646
+ const exact = dep.canonicalLocator.split("#", 1)[0]!;
647
+ const rewrite = rewriteFor(entry, exact);
648
+ const exactAliases = (rewrite.exactAliases ??= []);
649
+ for (const spelling of [exact, dep.locator, dep.locator.split("#", 1)[0]!]) {
650
+ addAlias(rewrite.aliases, spelling, rewrite.canonicalLocator);
651
+ if (spelling && !exactAliases.includes(spelling)) exactAliases.push(spelling);
652
+ }
653
+ }
654
+ }
655
+ return rewrites;
656
+ }
657
+
658
+ /**
659
+ * Rewrite a JSON document whose image references are whole string values, such as the catalog
660
+ * listing projection: every string equal to a captured spelling becomes its platform URL, and the
661
+ * document is re-serialized. Linear in the document, where the text engine above is linear in the
662
+ * document per rewrite — a listing is one multi-megabyte document naming every catalog image.
663
+ * Returns null for text that is not JSON.
664
+ */
665
+ export function rewriteJsonImageValues(content: string, rewrites: readonly CaptureRewrite[]): string | null {
666
+ let root: unknown;
667
+ try {
668
+ root = JSON.parse(content);
669
+ } catch {
670
+ return null;
671
+ }
672
+ const byNeedle = new Map<string, string>();
673
+ for (const rewrite of rewrites) {
674
+ for (const needle of [rewrite.canonicalLocator, ...rewrite.aliases]) {
675
+ if (needle && !byNeedle.has(needle)) byNeedle.set(needle, rewrite.platformUrl);
676
+ }
677
+ }
678
+ const visit = (value: unknown): unknown => {
679
+ if (typeof value === "string") return byNeedle.get(value.trim()) ?? value;
680
+ if (Array.isArray(value)) return value.map(visit);
681
+ if (value && typeof value === "object") {
682
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, child]) => [key, visit(child)]));
683
+ }
684
+ return value;
685
+ };
686
+ return JSON.stringify(visit(root));
687
+ }