@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
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
import { transform, transformStyleAttribute, type CustomAtRules, type Declaration, type Image, type TokenOrValue, type Url, type Visitor } from "lightningcss";
|
|
2
|
+
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provider-neutral image dependency discovery and classification.
|
|
6
|
+
*
|
|
7
|
+
* This module is deliberately pure: it turns already-read artifact text into a
|
|
8
|
+
* capture plan plus a sanitized finding batch. Network capture, persistence,
|
|
9
|
+
* and lifecycle state belong to control-plane adapters. Keeping one extractor
|
|
10
|
+
* here prevents HTML, CSS, catalog, and migrated-blog paths from silently
|
|
11
|
+
* disagreeing about which image references exist.
|
|
12
|
+
*
|
|
13
|
+
* Raw locators are present only in `dependencies`/`captureCandidates`, which are
|
|
14
|
+
* effect inputs. `findings` and `warningBatch` are the operator-visible contract
|
|
15
|
+
* and never include locators, tenant ids, artifact paths, query strings, or
|
|
16
|
+
* content digests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type ImageArtifactKind = "html" | "css" | "catalog";
|
|
20
|
+
export type ImageReferenceSurface =
|
|
21
|
+
| "html-src"
|
|
22
|
+
| "html-srcset"
|
|
23
|
+
| "html-metadata"
|
|
24
|
+
| "css-url"
|
|
25
|
+
| "catalog";
|
|
26
|
+
|
|
27
|
+
export interface ImageDependencyArtifact {
|
|
28
|
+
/** Used only for relative-path containment; never copied into a finding. */
|
|
29
|
+
path: string;
|
|
30
|
+
content: string;
|
|
31
|
+
kind?: ImageArtifactKind;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PlatformHostedImageConfig {
|
|
35
|
+
/** Exact already-hosted references. Query/fragment spelling is ignored. */
|
|
36
|
+
references?: readonly string[];
|
|
37
|
+
/** Absolute origins whose image bytes are platform-hosted. */
|
|
38
|
+
origins?: readonly string[];
|
|
39
|
+
/** Root-absolute platform namespaces in addition to the built-in ones. */
|
|
40
|
+
pathPrefixes?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ImageDependencyScanOptions {
|
|
44
|
+
tenantId: string;
|
|
45
|
+
platformHosted?: PlatformHostedImageConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ImageDependencyDisposition =
|
|
49
|
+
| "platform-hosted"
|
|
50
|
+
| "tenant-hosted"
|
|
51
|
+
| "inline"
|
|
52
|
+
| "capture-required"
|
|
53
|
+
| "security-error"
|
|
54
|
+
| "deferred";
|
|
55
|
+
|
|
56
|
+
export type ImageDependencyReason =
|
|
57
|
+
| "third-party"
|
|
58
|
+
| "local"
|
|
59
|
+
| "cross-tenant"
|
|
60
|
+
| "tenant-escape"
|
|
61
|
+
| "unsafe-scheme"
|
|
62
|
+
| "malformed"
|
|
63
|
+
| "unsupported";
|
|
64
|
+
|
|
65
|
+
/** Internal/effect-facing dependency. Its locator must never be rendered. */
|
|
66
|
+
export interface ImageDependency {
|
|
67
|
+
locator: string;
|
|
68
|
+
canonicalLocator: string;
|
|
69
|
+
artifactIndex: number;
|
|
70
|
+
occurrence: number;
|
|
71
|
+
surface: ImageReferenceSurface;
|
|
72
|
+
disposition: ImageDependencyDisposition;
|
|
73
|
+
reason?: ImageDependencyReason;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ImageDependencyFinding {
|
|
77
|
+
/** Stable within an exact ordered artifact revision; contains no content-derived data. */
|
|
78
|
+
id: string;
|
|
79
|
+
code: "image-capture-required" | "image-reference-security" | "image-reference-deferred";
|
|
80
|
+
reason: ImageDependencyReason;
|
|
81
|
+
occurrences: number;
|
|
82
|
+
authoring: "warning" | "non-overridable-security-error";
|
|
83
|
+
/** Hosted preview is the capture verb and therefore the enforcement point. */
|
|
84
|
+
hostedPreview: "overridable-quality-error" | "non-overridable-security-error";
|
|
85
|
+
overridable: boolean;
|
|
86
|
+
message: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ImageDependencyWarningBatch {
|
|
90
|
+
code: "image-dependency-batch";
|
|
91
|
+
total: number;
|
|
92
|
+
captureRequired: number;
|
|
93
|
+
securityErrors: number;
|
|
94
|
+
/** CSS image forms skipped whole. Not part of captureRequired or securityErrors. */
|
|
95
|
+
deferred: number;
|
|
96
|
+
representedFindings: number;
|
|
97
|
+
omittedFindings: number;
|
|
98
|
+
/** Bounded, sanitized copy; never one line per reference. */
|
|
99
|
+
messages: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ImageDependencyScan {
|
|
103
|
+
census: {
|
|
104
|
+
references: number;
|
|
105
|
+
platformHosted: number;
|
|
106
|
+
tenantHosted: number;
|
|
107
|
+
inline: number;
|
|
108
|
+
captureRequired: number;
|
|
109
|
+
securityErrors: number;
|
|
110
|
+
deferred: number;
|
|
111
|
+
};
|
|
112
|
+
dependencies: ImageDependency[];
|
|
113
|
+
/** De-duplicated raw effect inputs. URL provenance is not byte identity. */
|
|
114
|
+
captureCandidates: Array<{
|
|
115
|
+
locator: string;
|
|
116
|
+
canonicalLocator: string;
|
|
117
|
+
reason: "third-party" | "local";
|
|
118
|
+
/** A locator is provenance only; capture must derive identity from fetched original bytes. */
|
|
119
|
+
identityAuthority: "original-image-bytes";
|
|
120
|
+
}>;
|
|
121
|
+
findings: ImageDependencyFinding[];
|
|
122
|
+
warningBatch: ImageDependencyWarningBatch | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface ExtractedReference {
|
|
126
|
+
locator: string;
|
|
127
|
+
surface: ImageReferenceSurface;
|
|
128
|
+
classificationLocator?: string;
|
|
129
|
+
/** Set only for HTML references that appear after the document's first base href. */
|
|
130
|
+
afterBase?: boolean;
|
|
131
|
+
/** Set when the reference is a skipped CSS image function, not an authored URL. */
|
|
132
|
+
deferredFunction?: "var" | "env" | "cross-fade";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Properties whose value includes an image. Unparsed var()/env()/cross-fade() anywhere in them is deferred. */
|
|
136
|
+
const IMAGE_VALUE_PROPERTIES = new Set([
|
|
137
|
+
"background",
|
|
138
|
+
"background-image",
|
|
139
|
+
"border-image",
|
|
140
|
+
"border-image-source",
|
|
141
|
+
"content",
|
|
142
|
+
"cursor",
|
|
143
|
+
"list-style",
|
|
144
|
+
"list-style-image",
|
|
145
|
+
"mask",
|
|
146
|
+
"mask-border",
|
|
147
|
+
"mask-border-source",
|
|
148
|
+
"mask-box-image",
|
|
149
|
+
"mask-box-image-source",
|
|
150
|
+
"mask-image",
|
|
151
|
+
"shape-outside",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
const DEFERRED_IMAGE_MESSAGE = "A CSS image form was skipped because hosted preview does not resolve it yet.";
|
|
155
|
+
|
|
156
|
+
const BUILTIN_PLATFORM_PREFIXES = ["/_astro/", "/shared/"] as const;
|
|
157
|
+
const DIRECT_IMAGE_KEY_RE = /^(?:image|images|featuredimage|featured_image|thumbnail|thumbnailurl|imageurl|image_url)$/i;
|
|
158
|
+
const SCALAR_IMAGE_KEY_RE = /^(?:thumbnailurl|imageurl|image_url)$/i;
|
|
159
|
+
const HTML_VALUE_KEYS = new Set(["contenthtml", "content_html", "descriptionhtml", "description_html", "html"]);
|
|
160
|
+
const NON_IMAGE_ASSET_RE = /\.(?:css|m?js|woff2?|ttf|otf|eot|map|json|xml|txt|pdf|mp4|webm|mov)$/i;
|
|
161
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
162
|
+
const URL_TAB_OR_NEWLINE_RE = /[\t\n\r]/g;
|
|
163
|
+
const ASCII_WHITESPACE_RE = /[\t\n\f\r ]/;
|
|
164
|
+
const ASCII_WHITESPACE_RUN_RE = /[\t\n\f\r ]+/;
|
|
165
|
+
const MAX_VISIBLE_FINDINGS = 100;
|
|
166
|
+
const MAX_PERCENT_DECODE_PASSES = 8;
|
|
167
|
+
const MAX_CATALOG_WALK_ITEMS = 1_000_000;
|
|
168
|
+
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
169
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
170
|
+
const CSS_FILENAME = "artifact.css";
|
|
171
|
+
const textEncoder = new TextEncoder();
|
|
172
|
+
|
|
173
|
+
type HtmlNode = DefaultTreeAdapterMap["node"];
|
|
174
|
+
type HtmlElement = DefaultTreeAdapterMap["element"];
|
|
175
|
+
type HtmlChild = DefaultTreeAdapterMap["childNode"];
|
|
176
|
+
|
|
177
|
+
function inferKind(artifact: ImageDependencyArtifact): ImageArtifactKind {
|
|
178
|
+
if (artifact.kind) return artifact.kind;
|
|
179
|
+
const path = artifact.path.toLowerCase().split(/[?#]/, 1)[0] ?? "";
|
|
180
|
+
if (path.endsWith(".css")) return "css";
|
|
181
|
+
if (path.endsWith(".html") || path.endsWith(".htm")) return "html";
|
|
182
|
+
return "catalog";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isAsciiWhitespace(value: string | undefined): boolean {
|
|
186
|
+
return value != null && ASCII_WHITESPACE_RE.test(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function trimAsciiWhitespace(value: string): string {
|
|
190
|
+
return value.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isC0ControlOrSpace(code: number): boolean {
|
|
194
|
+
return code <= 0x1f || code === 0x20;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** WHATWG basic URL parser: drop leading and trailing C0 controls and spaces, then ASCII tab and newline anywhere. An interior form feed stays. */
|
|
198
|
+
function normalizeUrlInput(value: string): string {
|
|
199
|
+
let start = 0;
|
|
200
|
+
let end = value.length;
|
|
201
|
+
while (start < end && isC0ControlOrSpace(value.charCodeAt(start))) start += 1;
|
|
202
|
+
while (end > start && isC0ControlOrSpace(value.charCodeAt(end - 1))) end -= 1;
|
|
203
|
+
return value.slice(start, end).replace(URL_TAB_OR_NEWLINE_RE, "");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isHtmlText(node: HtmlNode): node is DefaultTreeAdapterMap["textNode"] {
|
|
207
|
+
return node.nodeName === "#text";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isHtmlElement(node: HtmlNode): node is HtmlElement {
|
|
211
|
+
return node.nodeName !== "#text"
|
|
212
|
+
&& node.nodeName !== "#comment"
|
|
213
|
+
&& node.nodeName !== "#documentType"
|
|
214
|
+
&& node.nodeName !== "#document"
|
|
215
|
+
&& node.nodeName !== "#document-fragment";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function htmlAttribute(element: HtmlElement, name: string): string | null {
|
|
219
|
+
for (const candidate of element.attrs) {
|
|
220
|
+
if (candidate.name === name || candidate.name.toLowerCase() === name) return candidate.value;
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const SRCSET_FLOAT_RE = /^-?(?:(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?|[0-9]*\.[eE][+-]?[0-9]+)$/;
|
|
226
|
+
const SRCSET_MAX_INTEGER = 0x7fffffff;
|
|
227
|
+
|
|
228
|
+
function srcsetDensityValue(value: string): number {
|
|
229
|
+
if (value.startsWith("-.")) return Number(`-0${value.slice(1)}`);
|
|
230
|
+
if (value.startsWith(".")) return Number(`0${value}`);
|
|
231
|
+
return Number(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validSrcsetDescriptors(descriptors: readonly string[]): boolean {
|
|
235
|
+
let width = false;
|
|
236
|
+
let density = false;
|
|
237
|
+
let height = false;
|
|
238
|
+
for (const descriptor of descriptors) {
|
|
239
|
+
const suffix = descriptor.at(-1);
|
|
240
|
+
const number = descriptor.slice(0, -1);
|
|
241
|
+
if (suffix === "w" && /^\d+$/.test(number)) {
|
|
242
|
+
const value = Number(number);
|
|
243
|
+
if (width || density || value === 0 || value > SRCSET_MAX_INTEGER) return false;
|
|
244
|
+
width = true;
|
|
245
|
+
} else if (suffix === "x" && SRCSET_FLOAT_RE.test(number)) {
|
|
246
|
+
const value = srcsetDensityValue(number);
|
|
247
|
+
if (width || density || height || value < 0 || !Number.isFinite(value)) return false;
|
|
248
|
+
density = true;
|
|
249
|
+
} else if (suffix === "h" && /^\d+$/.test(number)) {
|
|
250
|
+
const value = Number(number);
|
|
251
|
+
if (height || density || value === 0 || value > SRCSET_MAX_INTEGER) return false;
|
|
252
|
+
height = true;
|
|
253
|
+
} else {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return !height || width;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface SrcsetEntry {
|
|
261
|
+
locator: string;
|
|
262
|
+
descriptors: string[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function srcsetEntries(value: string): SrcsetEntry[] {
|
|
266
|
+
const entries: SrcsetEntry[] = [];
|
|
267
|
+
let cursor = 0;
|
|
268
|
+
while (cursor < value.length) {
|
|
269
|
+
while (cursor < value.length && (isAsciiWhitespace(value[cursor]) || value[cursor] === ",")) cursor++;
|
|
270
|
+
if (cursor >= value.length) break;
|
|
271
|
+
const start = cursor;
|
|
272
|
+
while (cursor < value.length && !isAsciiWhitespace(value[cursor])) cursor++;
|
|
273
|
+
let locator = value.slice(start, cursor);
|
|
274
|
+
const trailingCommas = locator.match(/,+$/)?.[0].length ?? 0;
|
|
275
|
+
if (trailingCommas) locator = locator.slice(0, -trailingCommas);
|
|
276
|
+
if (trailingCommas) {
|
|
277
|
+
if (locator) entries.push({ locator, descriptors: [] });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const descriptors: string[] = [];
|
|
281
|
+
let descriptor = "";
|
|
282
|
+
let inParens = false;
|
|
283
|
+
while (cursor < value.length) {
|
|
284
|
+
const char = value[cursor]!;
|
|
285
|
+
if (char === "(" && !inParens) inParens = true;
|
|
286
|
+
else if (char === ")" && inParens) inParens = false;
|
|
287
|
+
if ((isAsciiWhitespace(char) || char === ",") && !inParens) {
|
|
288
|
+
if (descriptor) {
|
|
289
|
+
descriptors.push(descriptor);
|
|
290
|
+
descriptor = "";
|
|
291
|
+
}
|
|
292
|
+
if (char === ",") { cursor++; break; }
|
|
293
|
+
cursor++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
descriptor += char;
|
|
297
|
+
cursor++;
|
|
298
|
+
}
|
|
299
|
+
if (descriptor) descriptors.push(descriptor);
|
|
300
|
+
if (locator && validSrcsetDescriptors(descriptors)) entries.push({ locator, descriptors });
|
|
301
|
+
}
|
|
302
|
+
return entries;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function srcsetLocators(value: string): string[] {
|
|
306
|
+
return srcsetEntries(value).map((entry) => entry.locator);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function srcsetDensity(descriptors: readonly string[]): number | null {
|
|
310
|
+
if (descriptors.length === 0) return 1;
|
|
311
|
+
for (const descriptor of descriptors) {
|
|
312
|
+
if (descriptor.endsWith("x")) return srcsetDensityValue(descriptor.slice(0, -1));
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function srcsetWidth(descriptors: readonly string[]): number | null {
|
|
318
|
+
for (const descriptor of descriptors) {
|
|
319
|
+
if (descriptor.endsWith("w")) return Number(descriptor.slice(0, -1));
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type PreloadChoice =
|
|
325
|
+
| { kind: "href" }
|
|
326
|
+
| { kind: "none" }
|
|
327
|
+
| { kind: "urls"; locators: string[] };
|
|
328
|
+
|
|
329
|
+
function choosePreloadImage(imagesrcset: string, href: string | null, _imagesizes: string | null): PreloadChoice {
|
|
330
|
+
const entries = srcsetEntries(imagesrcset).map((entry, index) => ({
|
|
331
|
+
locator: entry.locator,
|
|
332
|
+
density: srcsetDensity(entry.descriptors),
|
|
333
|
+
width: srcsetWidth(entry.descriptors),
|
|
334
|
+
index,
|
|
335
|
+
}));
|
|
336
|
+
if (entries.length === 0) return { kind: "href" };
|
|
337
|
+
if (entries.some((entry) => entry.width != null)) {
|
|
338
|
+
const locators = entries
|
|
339
|
+
.filter((entry) => !isFragmentOnlyLocator(entry.locator))
|
|
340
|
+
.map((entry) => entry.locator);
|
|
341
|
+
return locators.length === 0 ? { kind: "none" } : { kind: "urls", locators };
|
|
342
|
+
}
|
|
343
|
+
const densities = entries.filter((entry) => entry.density != null).map((entry) => ({
|
|
344
|
+
locator: entry.locator,
|
|
345
|
+
density: entry.density!,
|
|
346
|
+
index: entry.index,
|
|
347
|
+
}));
|
|
348
|
+
if (!densities.some((entry) => entry.density === 1) && href != null) {
|
|
349
|
+
densities.push({ locator: href, density: 1, index: entries.length });
|
|
350
|
+
}
|
|
351
|
+
if (densities.length === 0) return { kind: "href" };
|
|
352
|
+
const atLeastOne = densities.filter((entry) => entry.density >= 1);
|
|
353
|
+
const pool = atLeastOne.length > 0 ? atLeastOne : densities;
|
|
354
|
+
pool.sort((left, right) => atLeastOne.length > 0
|
|
355
|
+
? left.density - right.density || left.index - right.index
|
|
356
|
+
: right.density - left.density || left.index - right.index);
|
|
357
|
+
const selected = pool[0]!.locator;
|
|
358
|
+
return isFragmentOnlyLocator(selected) ? { kind: "none" } : { kind: "urls", locators: [selected] };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function isBlankLocator(locator: string): boolean {
|
|
362
|
+
return trimAsciiWhitespace(locator).length === 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isFragmentOnlyLocator(locator: string): boolean {
|
|
366
|
+
return trimAsciiWhitespace(locator).startsWith("#");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isNonImageDataUrl(locator: string): boolean {
|
|
370
|
+
return /^data:(?!image\/)/i.test(trimAsciiWhitespace(locator));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isNonImageAsset(locator: string): boolean {
|
|
374
|
+
return NON_IMAGE_ASSET_RE.test(stripQueryHash(trimAsciiWhitespace(locator)));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function rememberImageLocator(
|
|
378
|
+
refs: ExtractedReference[],
|
|
379
|
+
locator: string,
|
|
380
|
+
surface: ImageReferenceSurface,
|
|
381
|
+
afterBase = false,
|
|
382
|
+
): void {
|
|
383
|
+
const normalized = normalizeUrlInput(locator);
|
|
384
|
+
if (isBlankLocator(normalized) || isFragmentOnlyLocator(normalized)) return;
|
|
385
|
+
refs.push(afterBase ? { locator: normalized, surface, afterBase: true } : { locator: normalized, surface });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function rememberCssLocator(refs: ExtractedReference[], locator: string): boolean {
|
|
389
|
+
// WHATWG drops leading and trailing C0 controls, then tab and newline anywhere.
|
|
390
|
+
// CSS \9, \A, and \D arrive as those characters; normalize before the data-URL and extension filters.
|
|
391
|
+
const normalized = normalizeUrlInput(locator);
|
|
392
|
+
if (isBlankLocator(normalized) || isFragmentOnlyLocator(normalized) || isNonImageDataUrl(normalized) || isNonImageAsset(normalized)) return false;
|
|
393
|
+
refs.push({ locator: normalized, surface: "css-url" });
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function urlLocationKey(url: Url): string {
|
|
398
|
+
return `${url.loc.line}:${url.loc.column}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function cssFunctionName(name: string): string {
|
|
402
|
+
return name.toLowerCase();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function declarationPropertyName(declaration: Declaration): string {
|
|
406
|
+
// Custom properties keep author case. Image properties match case-insensitively.
|
|
407
|
+
const name = declaration.property === "unparsed"
|
|
408
|
+
? declaration.value.propertyId.property
|
|
409
|
+
: declaration.property === "custom"
|
|
410
|
+
? String(declaration.value.name)
|
|
411
|
+
: declaration.property;
|
|
412
|
+
return name.toLowerCase();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Quoted URL() / uRl() inside an unparsed image() is a nested function. Its source is the first string. */
|
|
416
|
+
function quotedUrlFunctionLocator(nodes: readonly TokenOrValue[]): string | null {
|
|
417
|
+
for (const argument of nodes) {
|
|
418
|
+
if (argument.type === "token" && argument.value.type === "string") return String(argument.value.value);
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function suppressImageFunctionUrls(
|
|
424
|
+
nodes: readonly TokenOrValue[],
|
|
425
|
+
locations: Set<string>,
|
|
426
|
+
walkVariableFallbacks = false,
|
|
427
|
+
): void {
|
|
428
|
+
for (const argument of nodes) {
|
|
429
|
+
if (argument.type === "url") {
|
|
430
|
+
locations.add(urlLocationKey(argument.value));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (argument.type === "function") {
|
|
434
|
+
suppressImageFunctionUrls(argument.value.arguments, locations, walkVariableFallbacks);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
// image-set suppression stays on nested functions. image() also suppresses var()/env() fallback urls.
|
|
438
|
+
if (!walkVariableFallbacks || (argument.type !== "var" && argument.type !== "env")) continue;
|
|
439
|
+
const fallback = argument.value.fallback;
|
|
440
|
+
if (fallback) suppressImageFunctionUrls(fallback, locations, true);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** url() nodes nested in an unparsed image-set option are not that option. Direct url() arguments stay visible. */
|
|
445
|
+
function suppressNestedFunctionUrls(nodes: readonly TokenOrValue[], locations: Set<string>): void {
|
|
446
|
+
for (const argument of nodes) {
|
|
447
|
+
if (argument.type === "function") suppressImageFunctionUrls(argument.value.arguments, locations);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function deferredFunctionName(name: string): "var" | "env" | "cross-fade" | null {
|
|
452
|
+
const normalized = cssFunctionName(name);
|
|
453
|
+
if (normalized === "var" || normalized === "env") return normalized;
|
|
454
|
+
// -webkit-cross-fade is cross-fade. Any vendor prefix stays unresolved.
|
|
455
|
+
if (normalized === "cross-fade" || /^-[a-z]+-cross-fade$/.test(normalized)) return "cross-fade";
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function rememberDeferred(refs: ExtractedReference[], name: "var" | "env" | "cross-fade"): void {
|
|
460
|
+
refs.push({ locator: `css-function:${name}`, surface: "css-url", deferredFunction: name });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** One kept locator. type(), format(), and a discarded non-image do not consume it. var(), env(), and cross-fade() consume it without walking children. Nested image() keeps its own one source. */
|
|
464
|
+
function recordImageFunctionSource(refs: ExtractedReference[], nodes: readonly TokenOrValue[]): boolean {
|
|
465
|
+
for (const argument of nodes) {
|
|
466
|
+
if (argument.type === "url") {
|
|
467
|
+
if (rememberCssLocator(refs, argument.value.url)) return true;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (argument.type === "token" && argument.value.type === "string") {
|
|
471
|
+
if (rememberCssLocator(refs, String(argument.value.value))) return true;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// The visitor records the deferred dependency. Children are not a source.
|
|
475
|
+
if (argument.type === "var" || argument.type === "env") return true;
|
|
476
|
+
if (argument.type !== "function") continue;
|
|
477
|
+
const name = cssFunctionName(argument.value.name);
|
|
478
|
+
if (name === "url") {
|
|
479
|
+
const locator = quotedUrlFunctionLocator(argument.value.arguments);
|
|
480
|
+
if (locator != null && rememberCssLocator(refs, locator)) return true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
// MIME tags, not image sources. Recursing would keep "image/png" and consume the one slot.
|
|
484
|
+
if (name === "type" || name === "format") continue;
|
|
485
|
+
if (deferredFunctionName(name)) return true;
|
|
486
|
+
if (recordImageFunctionSource(refs, argument.value.arguments)) return true;
|
|
487
|
+
}
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function adoptCssReferences(refs: ExtractedReference[], cssRefs: readonly ExtractedReference[], afterBase: boolean): void {
|
|
492
|
+
for (const ref of cssRefs) refs.push(afterBase ? { ...ref, afterBase: true } : ref);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function rememberCssImage(refs: ExtractedReference[], image: Image): void {
|
|
496
|
+
if (image.type === "url") {
|
|
497
|
+
rememberCssLocator(refs, image.value.url);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (image.type !== "image-set") return;
|
|
501
|
+
for (const option of image.value.options) rememberCssImage(refs, option.image);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function cssReferences(css: string, kind: "stylesheet" | "declaration"): ExtractedReference[] {
|
|
505
|
+
if (!css) return [];
|
|
506
|
+
const refs: ExtractedReference[] = [];
|
|
507
|
+
const fontFaceUrls: string[] = [];
|
|
508
|
+
// url() nodes anywhere inside image() are still walked by the Url visitor after Function.
|
|
509
|
+
// The visitor rebuilds a fresh object per callback, so identity can't dedupe — key on source
|
|
510
|
+
// position, which is stable for the same node. One image() keeps one source; the rest stay suppressed.
|
|
511
|
+
const imageFunctionUrlLocations = new Set<string>();
|
|
512
|
+
// Function enter runs before children. "image" means this image() recorded its one source;
|
|
513
|
+
// a nested image() visit must not record again. "image-set" means a direct string token is an option.
|
|
514
|
+
// "deferred" is var(), env(), or cross-fade(): their children are not resolved.
|
|
515
|
+
const frames: Array<"image" | "image-set" | "deferred" | "skip"> = [];
|
|
516
|
+
let valueDeferDepth = 0;
|
|
517
|
+
const propertyStack: string[] = [];
|
|
518
|
+
const deferredOpen = (): boolean => valueDeferDepth > 0 || frames.includes("deferred");
|
|
519
|
+
const inImageValue = (): boolean => {
|
|
520
|
+
if (frames.includes("image") || frames.includes("image-set")) return true;
|
|
521
|
+
const property = propertyStack.at(-1);
|
|
522
|
+
return property != null && IMAGE_VALUE_PROPERTIES.has(property);
|
|
523
|
+
};
|
|
524
|
+
const noteDeferred = (name: "var" | "env" | "cross-fade"): void => {
|
|
525
|
+
if (!deferredOpen() && inImageValue()) rememberDeferred(refs, name);
|
|
526
|
+
};
|
|
527
|
+
const visitor: Visitor<CustomAtRules> = {
|
|
528
|
+
Declaration(declaration) {
|
|
529
|
+
propertyStack.push(declarationPropertyName(declaration));
|
|
530
|
+
},
|
|
531
|
+
DeclarationExit() {
|
|
532
|
+
propertyStack.pop();
|
|
533
|
+
},
|
|
534
|
+
Rule: {
|
|
535
|
+
"font-face"(rule) {
|
|
536
|
+
for (const property of rule.value.properties) {
|
|
537
|
+
if (property.type !== "source") continue;
|
|
538
|
+
for (const source of property.value) {
|
|
539
|
+
if (source.type === "url") fontFaceUrls.push(source.value.url.url);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
Url(url) {
|
|
545
|
+
if (imageFunctionUrlLocations.has(urlLocationKey(url))) return;
|
|
546
|
+
if (fontFaceUrls[0] === url.url) {
|
|
547
|
+
fontFaceUrls.shift();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (deferredOpen()) return;
|
|
551
|
+
rememberCssLocator(refs, url.url);
|
|
552
|
+
},
|
|
553
|
+
Image(image) {
|
|
554
|
+
if (deferredOpen()) return;
|
|
555
|
+
if (image.type === "image-set") rememberCssImage(refs, image);
|
|
556
|
+
},
|
|
557
|
+
Token: {
|
|
558
|
+
string(token) {
|
|
559
|
+
if (deferredOpen()) return;
|
|
560
|
+
if (frames.at(-1) !== "image-set") return;
|
|
561
|
+
rememberCssLocator(refs, String(token.value));
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
Variable() {
|
|
565
|
+
noteDeferred("var");
|
|
566
|
+
valueDeferDepth++;
|
|
567
|
+
},
|
|
568
|
+
VariableExit() {
|
|
569
|
+
valueDeferDepth--;
|
|
570
|
+
},
|
|
571
|
+
EnvironmentVariable() {
|
|
572
|
+
noteDeferred("env");
|
|
573
|
+
valueDeferDepth++;
|
|
574
|
+
},
|
|
575
|
+
EnvironmentVariableExit() {
|
|
576
|
+
valueDeferDepth--;
|
|
577
|
+
},
|
|
578
|
+
// image() stays unparsed. Quoted URL()/uRl() is a nested function, not argument type "url".
|
|
579
|
+
// image-set() with a string or image() option stays unparsed too; its string options are tokens.
|
|
580
|
+
// Lowercase var() and env() are not functions; VAR()/ENV() are. cross-fade(), including a vendor prefix, is always a function.
|
|
581
|
+
Function(fn) {
|
|
582
|
+
const deferred = deferredFunctionName(fn.name);
|
|
583
|
+
if (deferred) {
|
|
584
|
+
noteDeferred(deferred);
|
|
585
|
+
frames.push("deferred");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (deferredOpen()) {
|
|
589
|
+
frames.push("skip");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const name = cssFunctionName(fn.name);
|
|
593
|
+
if (name === "image") {
|
|
594
|
+
if (frames.includes("image")) {
|
|
595
|
+
frames.push("skip");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
frames.push("image");
|
|
599
|
+
suppressImageFunctionUrls(fn.arguments, imageFunctionUrlLocations, true);
|
|
600
|
+
recordImageFunctionSource(refs, fn.arguments);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if ((name === "image-set" || name === "-webkit-image-set") && !frames.includes("image")) {
|
|
604
|
+
frames.push("image-set");
|
|
605
|
+
suppressNestedFunctionUrls(fn.arguments, imageFunctionUrlLocations);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (name === "url" && frames.at(-1) === "image-set") {
|
|
609
|
+
const locator = quotedUrlFunctionLocator(fn.arguments);
|
|
610
|
+
if (locator != null) rememberCssLocator(refs, locator);
|
|
611
|
+
}
|
|
612
|
+
frames.push("skip");
|
|
613
|
+
},
|
|
614
|
+
FunctionExit() {
|
|
615
|
+
frames.pop();
|
|
616
|
+
},
|
|
617
|
+
};
|
|
618
|
+
const code = textEncoder.encode(css);
|
|
619
|
+
try {
|
|
620
|
+
if (kind === "declaration") {
|
|
621
|
+
transformStyleAttribute({ filename: CSS_FILENAME, code, errorRecovery: true, visitor });
|
|
622
|
+
} else {
|
|
623
|
+
transform({ filename: CSS_FILENAME, code, errorRecovery: true, visitor });
|
|
624
|
+
}
|
|
625
|
+
} catch {
|
|
626
|
+
return refs;
|
|
627
|
+
}
|
|
628
|
+
return refs;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const LOCAL_REFERENCE_ORIGIN = "https://local.invalid";
|
|
632
|
+
const ALTERNATE_LOCAL_REFERENCE_ORIGIN = "https://alternate-local.invalid";
|
|
633
|
+
|
|
634
|
+
function localArtifactUrl(artifactPath: string, origin = LOCAL_REFERENCE_ORIGIN): URL | null {
|
|
635
|
+
try {
|
|
636
|
+
const safeArtifactPath = artifactPath.replace(/\\/g, "/").replace(/[?#]/g, (character) => encodeURIComponent(character));
|
|
637
|
+
return new URL(safeArtifactPath, `${origin}/`);
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
type ResolvedBase = { base: URL; alternateBase: URL | null };
|
|
644
|
+
|
|
645
|
+
type HtmlBaseResolution =
|
|
646
|
+
| { kind: "none" }
|
|
647
|
+
| { kind: "resolved"; base: URL; alternateBase: URL | null }
|
|
648
|
+
| { kind: "invalid" };
|
|
649
|
+
|
|
650
|
+
function effectiveHtmlBase(baseHref: string | null, artifactPath: string, inherited: ResolvedBase | null = null): HtmlBaseResolution {
|
|
651
|
+
const fallback = inherited?.base ?? localArtifactUrl(artifactPath);
|
|
652
|
+
const alternateFallback = inherited ? inherited.alternateBase : localArtifactUrl(artifactPath, ALTERNATE_LOCAL_REFERENCE_ORIGIN);
|
|
653
|
+
if (!fallback || (!inherited && !alternateFallback)) return { kind: "invalid" };
|
|
654
|
+
if (baseHref == null) return { kind: "none" };
|
|
655
|
+
const href = normalizeUrlInput(baseHref);
|
|
656
|
+
if (CONTROL_RE.test(href)) return { kind: "invalid" };
|
|
657
|
+
try {
|
|
658
|
+
const base = new URL(href, fallback);
|
|
659
|
+
const alternateBase = alternateFallback ? new URL(href, alternateFallback) : null;
|
|
660
|
+
new URL("x", base);
|
|
661
|
+
if (alternateBase) new URL("x", alternateBase);
|
|
662
|
+
return { kind: "resolved", base, alternateBase: alternateBase && base.href !== alternateBase.href ? alternateBase : null };
|
|
663
|
+
} catch {
|
|
664
|
+
return { kind: "invalid" };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function resolveHtmlReference(locator: string, base: URL, alternateBase: URL | null): string {
|
|
669
|
+
const href = normalizeUrlInput(locator);
|
|
670
|
+
if (!href || CONTROL_RE.test(href)) return locator;
|
|
671
|
+
try {
|
|
672
|
+
const resolved = new URL(href, base);
|
|
673
|
+
const alternateResolved = alternateBase ? new URL(href, alternateBase) : null;
|
|
674
|
+
if (resolved.origin === LOCAL_REFERENCE_ORIGIN && alternateResolved && alternateResolved.href !== resolved.href) {
|
|
675
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
676
|
+
}
|
|
677
|
+
return resolved.toString();
|
|
678
|
+
} catch {
|
|
679
|
+
return locator;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function resolvedBase(baseHref: string | null, sawBase: boolean, artifactPath: string, inherited: ResolvedBase | null): ResolvedBase | null {
|
|
684
|
+
if (!sawBase || baseHref == null) return inherited;
|
|
685
|
+
const own = effectiveHtmlBase(baseHref, artifactPath, inherited);
|
|
686
|
+
if (own.kind === "resolved") return { base: own.base, alternateBase: own.alternateBase };
|
|
687
|
+
return inherited;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function applyHtmlBase(
|
|
691
|
+
refs: ExtractedReference[],
|
|
692
|
+
baseHref: string | null,
|
|
693
|
+
artifactPath: string,
|
|
694
|
+
inherited: ResolvedBase | null,
|
|
695
|
+
): ExtractedReference[] {
|
|
696
|
+
const own = baseHref == null ? null : effectiveHtmlBase(baseHref, artifactPath, inherited);
|
|
697
|
+
const ownBase = own?.kind === "resolved" ? own : null;
|
|
698
|
+
return refs.map((reference) => {
|
|
699
|
+
if (reference.deferredFunction) return reference;
|
|
700
|
+
if (reference.afterBase && ownBase) {
|
|
701
|
+
return { ...reference, locator: resolveHtmlReference(reference.locator, ownBase.base, ownBase.alternateBase) };
|
|
702
|
+
}
|
|
703
|
+
if (inherited && (!reference.afterBase || !ownBase)) {
|
|
704
|
+
return { ...reference, locator: resolveHtmlReference(reference.locator, inherited.base, inherited.alternateBase) };
|
|
705
|
+
}
|
|
706
|
+
return reference;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
type HtmlSourceContext = "picture" | null;
|
|
711
|
+
|
|
712
|
+
interface HtmlWalkFrame {
|
|
713
|
+
node: HtmlNode;
|
|
714
|
+
inTemplate: boolean;
|
|
715
|
+
cssText: boolean;
|
|
716
|
+
sourceContext: HtmlSourceContext;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function pushHtmlChildren(
|
|
720
|
+
stack: HtmlWalkFrame[],
|
|
721
|
+
children: readonly HtmlChild[],
|
|
722
|
+
inTemplate: boolean,
|
|
723
|
+
cssText: boolean,
|
|
724
|
+
sourceContext: HtmlSourceContext,
|
|
725
|
+
): void {
|
|
726
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
727
|
+
const child = children[index]!;
|
|
728
|
+
stack.push({ node: child, inTemplate, cssText: cssText && child.nodeName === "#text", sourceContext });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function childSourceContext(tag: string, namespace: string): HtmlSourceContext {
|
|
733
|
+
if (namespace === HTML_NAMESPACE && tag === "picture") return "picture";
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function collectHtmlElement(
|
|
738
|
+
element: HtmlElement,
|
|
739
|
+
refs: ExtractedReference[],
|
|
740
|
+
inTemplate: boolean,
|
|
741
|
+
noteBase: (href: string) => void,
|
|
742
|
+
afterBase: boolean,
|
|
743
|
+
sourceContext: HtmlSourceContext,
|
|
744
|
+
artifactPath: string,
|
|
745
|
+
inheritedBase: ResolvedBase | null,
|
|
746
|
+
): void {
|
|
747
|
+
const tag = element.tagName;
|
|
748
|
+
const html = element.namespaceURI === HTML_NAMESPACE;
|
|
749
|
+
if (html && !inTemplate && tag === "base") {
|
|
750
|
+
const href = htmlAttribute(element, "href");
|
|
751
|
+
if (href != null) noteBase(href);
|
|
752
|
+
afterBase = true;
|
|
753
|
+
}
|
|
754
|
+
if (tag === "style") return;
|
|
755
|
+
const styleValue = htmlAttribute(element, "style");
|
|
756
|
+
const emitStyle = (): void => {
|
|
757
|
+
if (styleValue != null) adoptCssReferences(refs, cssReferences(styleValue, "declaration"), afterBase);
|
|
758
|
+
};
|
|
759
|
+
if (!html) {
|
|
760
|
+
if (element.namespaceURI === SVG_NAMESPACE && (tag === "image" || tag.toLowerCase() === "feimage")) {
|
|
761
|
+
for (const candidate of element.attrs) {
|
|
762
|
+
const name = candidate.name.toLowerCase();
|
|
763
|
+
if (name === "href" || name === "xlink:href") rememberImageLocator(refs, candidate.value, "html-src", afterBase);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
emitStyle();
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (tag === "iframe") {
|
|
770
|
+
const srcdoc = htmlAttribute(element, "srcdoc");
|
|
771
|
+
if (srcdoc != null && !isBlankLocator(srcdoc)) {
|
|
772
|
+
for (const nested of htmlReferences(srcdoc, artifactPath, inheritedBase)) {
|
|
773
|
+
refs.push({
|
|
774
|
+
locator: nested.locator,
|
|
775
|
+
surface: nested.surface,
|
|
776
|
+
classificationLocator: nested.classificationLocator,
|
|
777
|
+
// Dropping this treats css-function:* as an unsafe scheme and loses the warning.
|
|
778
|
+
deferredFunction: nested.deferredFunction,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
emitStyle();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (tag === "img") {
|
|
786
|
+
const src = htmlAttribute(element, "src");
|
|
787
|
+
if (src != null) rememberImageLocator(refs, src, "html-src", afterBase);
|
|
788
|
+
const srcset = htmlAttribute(element, "srcset");
|
|
789
|
+
if (srcset != null) {
|
|
790
|
+
for (const locator of srcsetLocators(srcset)) rememberImageLocator(refs, locator, "html-srcset", afterBase);
|
|
791
|
+
}
|
|
792
|
+
emitStyle();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (tag === "source") {
|
|
796
|
+
if (sourceContext === "picture") {
|
|
797
|
+
const srcset = htmlAttribute(element, "srcset");
|
|
798
|
+
if (srcset != null) {
|
|
799
|
+
for (const locator of srcsetLocators(srcset)) rememberImageLocator(refs, locator, "html-srcset", afterBase);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
emitStyle();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (tag === "video") {
|
|
806
|
+
const poster = htmlAttribute(element, "poster");
|
|
807
|
+
if (poster != null) rememberImageLocator(refs, poster, "html-src", afterBase);
|
|
808
|
+
emitStyle();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (tag === "meta") {
|
|
812
|
+
const property = (htmlAttribute(element, "property") ?? "").toLowerCase();
|
|
813
|
+
const name = (htmlAttribute(element, "name") ?? "").toLowerCase();
|
|
814
|
+
if (property === "og:image" || property === "twitter:image" || name === "og:image" || name === "twitter:image") {
|
|
815
|
+
const content = htmlAttribute(element, "content");
|
|
816
|
+
if (content != null) rememberImageLocator(refs, content, "html-metadata", afterBase);
|
|
817
|
+
}
|
|
818
|
+
emitStyle();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (tag === "input" && (htmlAttribute(element, "type") ?? "").toLowerCase() === "image") {
|
|
822
|
+
const src = htmlAttribute(element, "src");
|
|
823
|
+
if (src != null) rememberImageLocator(refs, src, "html-src", afterBase);
|
|
824
|
+
}
|
|
825
|
+
const rel = (htmlAttribute(element, "rel") ?? "").toLowerCase().split(ASCII_WHITESPACE_RUN_RE);
|
|
826
|
+
const preloadsImage = rel.includes("preload") && (htmlAttribute(element, "as") ?? "").toLowerCase() === "image";
|
|
827
|
+
const linkHref = htmlAttribute(element, "href");
|
|
828
|
+
if (tag === "link" && preloadsImage && htmlAttribute(element, "imagesrcset") != null) {
|
|
829
|
+
const choice = choosePreloadImage(htmlAttribute(element, "imagesrcset") ?? "", linkHref, htmlAttribute(element, "imagesizes"));
|
|
830
|
+
if (choice.kind === "href" && linkHref != null) rememberImageLocator(refs, linkHref, "html-metadata", afterBase);
|
|
831
|
+
else if (choice.kind === "urls") {
|
|
832
|
+
for (const locator of choice.locators) rememberImageLocator(refs, locator, "html-srcset", afterBase);
|
|
833
|
+
}
|
|
834
|
+
} else if (tag === "link" && (rel.includes("icon") || rel.includes("apple-touch-icon") || preloadsImage)) {
|
|
835
|
+
if (linkHref != null) rememberImageLocator(refs, linkHref, "html-metadata", afterBase);
|
|
836
|
+
}
|
|
837
|
+
emitStyle();
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function htmlReferences(html: string, artifactPath: string, inherited: ResolvedBase | null = null): ExtractedReference[] {
|
|
841
|
+
const refs: ExtractedReference[] = [];
|
|
842
|
+
let baseHref: string | null = null;
|
|
843
|
+
let sawBase = false;
|
|
844
|
+
const noteBase = (href: string): void => {
|
|
845
|
+
if (sawBase) return;
|
|
846
|
+
sawBase = true;
|
|
847
|
+
baseHref = href;
|
|
848
|
+
};
|
|
849
|
+
const enclosingBase = (): ResolvedBase | null => resolvedBase(baseHref, sawBase, artifactPath, inherited);
|
|
850
|
+
const stack: HtmlWalkFrame[] = [{ node: parseFragment(html), inTemplate: false, cssText: false, sourceContext: null }];
|
|
851
|
+
while (stack.length > 0) {
|
|
852
|
+
const frame = stack.pop()!;
|
|
853
|
+
const { node, inTemplate, sourceContext } = frame;
|
|
854
|
+
if (isHtmlText(node)) {
|
|
855
|
+
if (frame.cssText) adoptCssReferences(refs, cssReferences(node.value, "stylesheet"), sawBase);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (node.nodeName === "#document" || node.nodeName === "#document-fragment") {
|
|
859
|
+
pushHtmlChildren(stack, node.childNodes, inTemplate, false, sourceContext);
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (!isHtmlElement(node)) continue;
|
|
863
|
+
collectHtmlElement(node, refs, inTemplate, noteBase, sawBase, sourceContext, artifactPath, enclosingBase());
|
|
864
|
+
const childContext = childSourceContext(node.tagName, node.namespaceURI);
|
|
865
|
+
if (node.tagName === "template" && node.namespaceURI === HTML_NAMESPACE && "content" in node) {
|
|
866
|
+
pushHtmlChildren(stack, node.content.childNodes, true, false, childContext);
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (node.tagName === "style") {
|
|
870
|
+
pushHtmlChildren(stack, node.childNodes, inTemplate, true, childContext);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
pushHtmlChildren(stack, node.childNodes, inTemplate, false, childContext);
|
|
874
|
+
}
|
|
875
|
+
return applyHtmlBase(refs, baseHref, artifactPath, inherited);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function catalogReferences(content: string, artifactPath: string): ExtractedReference[] | null {
|
|
879
|
+
let root: unknown;
|
|
880
|
+
try {
|
|
881
|
+
root = JSON.parse(content);
|
|
882
|
+
} catch {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
const refs: ExtractedReference[] = [];
|
|
886
|
+
const malformed = (): void => { refs.push({ locator: "", surface: "catalog" }); };
|
|
887
|
+
type WalkItem =
|
|
888
|
+
| { kind: "visit"; value: unknown; key: string; imageContext: boolean }
|
|
889
|
+
| { kind: "reference"; locator: string }
|
|
890
|
+
| { kind: "malformed" };
|
|
891
|
+
const stack: WalkItem[] = [{ kind: "visit", value: root, key: "", imageContext: false }];
|
|
892
|
+
let processed = 0;
|
|
893
|
+
while (stack.length > 0) {
|
|
894
|
+
if (++processed > MAX_CATALOG_WALK_ITEMS) {
|
|
895
|
+
malformed();
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
const item = stack.pop()!;
|
|
899
|
+
if (item.kind === "reference") {
|
|
900
|
+
refs.push({ locator: item.locator, surface: "catalog" });
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
if (item.kind === "malformed") {
|
|
904
|
+
malformed();
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
const { value, key, imageContext } = item;
|
|
908
|
+
if (SCALAR_IMAGE_KEY_RE.test(key) && typeof value !== "string") {
|
|
909
|
+
malformed();
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (typeof value === "string") {
|
|
913
|
+
if (HTML_VALUE_KEYS.has(key.toLowerCase())) refs.push(...htmlReferences(value, artifactPath));
|
|
914
|
+
else if (DIRECT_IMAGE_KEY_RE.test(key) || (imageContext && /^(?:src|url)$/i.test(key))) {
|
|
915
|
+
if (trimAsciiWhitespace(value)) refs.push({ locator: value, surface: "catalog" });
|
|
916
|
+
else malformed();
|
|
917
|
+
}
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (Array.isArray(value)) {
|
|
921
|
+
const images = /^(?:image|images)$/i.test(key);
|
|
922
|
+
if (stack.length + value.length > MAX_CATALOG_WALK_ITEMS - processed) {
|
|
923
|
+
malformed();
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
for (let index = value.length - 1; index >= 0; index--) {
|
|
927
|
+
const child = value[index];
|
|
928
|
+
if (images && (child == null || (typeof child !== "string" && typeof child !== "object"))) {
|
|
929
|
+
stack.push({ kind: "malformed" });
|
|
930
|
+
} else {
|
|
931
|
+
stack.push({ kind: "visit", value: child, key, imageContext: imageContext || images });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
if (!value || typeof value !== "object") {
|
|
937
|
+
if (DIRECT_IMAGE_KEY_RE.test(key) || (imageContext && /^(?:src|url)$/i.test(key))) malformed();
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
const record = value as Record<string, unknown>;
|
|
941
|
+
const entries = Object.entries(record);
|
|
942
|
+
if (stack.length + entries.length > MAX_CATALOG_WALK_ITEMS - processed) {
|
|
943
|
+
malformed();
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
if (imageContext && !entries.some(([childKey, child]) => /^(?:src|url)$/i.test(childKey) && typeof child === "string" && trimAsciiWhitespace(child))) {
|
|
947
|
+
malformed();
|
|
948
|
+
}
|
|
949
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
950
|
+
const [childKey, child] = entries[index]!;
|
|
951
|
+
if (imageContext && /^(?:src|url)$/i.test(childKey)) {
|
|
952
|
+
if (typeof child === "string" && trimAsciiWhitespace(child)) stack.push({ kind: "reference", locator: child });
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const nextImageContext = /^(?:image|images|featuredImage|featured_image|thumbnail)$/i.test(childKey);
|
|
956
|
+
stack.push({ kind: "visit", value: child, key: childKey, imageContext: nextImageContext });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return refs;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function structuralPath(path: string): string {
|
|
963
|
+
return stripQueryHash(path).replace(/\\/g, "/");
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function literalPathSegments(path: string): string[] {
|
|
967
|
+
return stripQueryHash(path).replace(/\\/g, "/").split("/");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function decodePathSegment(segment: string): string | null {
|
|
971
|
+
let decoded = segment;
|
|
972
|
+
for (let pass = 0; pass < MAX_PERCENT_DECODE_PASSES; pass++) {
|
|
973
|
+
let next: string;
|
|
974
|
+
try {
|
|
975
|
+
next = decodeURIComponent(decoded);
|
|
976
|
+
} catch {
|
|
977
|
+
return decoded;
|
|
978
|
+
}
|
|
979
|
+
if (next === decoded) return decoded;
|
|
980
|
+
decoded = next;
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
return decodeURIComponent(decoded) === decoded ? decoded : null;
|
|
984
|
+
} catch {
|
|
985
|
+
return decoded;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function hasEscape(path: string): boolean {
|
|
990
|
+
let depth = 0;
|
|
991
|
+
for (const segment of literalPathSegments(path)) {
|
|
992
|
+
if (!segment) continue;
|
|
993
|
+
const decoded = decodePathSegment(segment);
|
|
994
|
+
if (decoded == null) return true;
|
|
995
|
+
if (decoded === ".") continue;
|
|
996
|
+
if (decoded === "..") {
|
|
997
|
+
if (depth === 0) return true;
|
|
998
|
+
depth--;
|
|
999
|
+
} else {
|
|
1000
|
+
depth++;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function hasDotDotSegment(path: string): boolean {
|
|
1007
|
+
return literalPathSegments(path).some((segment) => {
|
|
1008
|
+
const decoded = decodePathSegment(segment);
|
|
1009
|
+
return decoded == null || decoded === "..";
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function segmentDecodesToDotDot(segment: string): boolean {
|
|
1014
|
+
const decoded = decodePathSegment(segment);
|
|
1015
|
+
return decoded == null || decoded === "..";
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function absolutePathEscapes(pathname: string): boolean {
|
|
1019
|
+
return pathname.split("/").some((segment) => segmentDecodesToDotDot(segment));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function resolveLocalPath(artifactPath: string, locatorPath: string): string | null {
|
|
1023
|
+
if (artifactPath.startsWith("/") || hasEscape(artifactPath)) return null;
|
|
1024
|
+
const base = literalPathSegments(artifactPath).slice(0, -1);
|
|
1025
|
+
const absolute = locatorPath.replace(/\\/g, "/").startsWith("/");
|
|
1026
|
+
const parts = absolute ? [] : base;
|
|
1027
|
+
for (const segment of literalPathSegments(locatorPath)) {
|
|
1028
|
+
if (!segment) continue;
|
|
1029
|
+
const decoded = decodePathSegment(segment);
|
|
1030
|
+
if (decoded == null) return null;
|
|
1031
|
+
if (decoded === ".") continue;
|
|
1032
|
+
if (decoded === "..") {
|
|
1033
|
+
if (parts.length === 0) return null;
|
|
1034
|
+
parts.pop();
|
|
1035
|
+
} else {
|
|
1036
|
+
parts.push(segment);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return `/${parts.join("/")}`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function normalizePathPercentEncoding(path: string): string {
|
|
1043
|
+
return path.replace(/%([0-9a-f]{2})/gi, (encoded, hex: string) => {
|
|
1044
|
+
const character = String.fromCharCode(Number.parseInt(hex, 16));
|
|
1045
|
+
return /[a-z0-9_.~-]/i.test(character) ? character : encoded.toUpperCase();
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function canonicalLocalPath(artifactPath: string, locatorPath: string): string | null {
|
|
1050
|
+
try {
|
|
1051
|
+
const artifactUrl = localArtifactUrl(artifactPath);
|
|
1052
|
+
if (!artifactUrl) return null;
|
|
1053
|
+
const resolved = new URL(locatorPath.replace(/\\/g, "/"), artifactUrl);
|
|
1054
|
+
return resolved.origin === LOCAL_REFERENCE_ORIGIN ? normalizePathPercentEncoding(resolved.pathname) : null;
|
|
1055
|
+
} catch {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** A literal backslash the URL parser resolves off-origin is a scheme-relative network path. `%5C` is not. */
|
|
1061
|
+
function backslashNetworkUrl(locator: string, artifactPath: string): URL | null {
|
|
1062
|
+
if (!locator.includes("\\")) return null;
|
|
1063
|
+
const artifactUrl = localArtifactUrl(artifactPath);
|
|
1064
|
+
if (!artifactUrl) return null;
|
|
1065
|
+
try {
|
|
1066
|
+
const parsed = new URL(locator, artifactUrl);
|
|
1067
|
+
if (parsed.origin === LOCAL_REFERENCE_ORIGIN) return null;
|
|
1068
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1069
|
+
return parsed;
|
|
1070
|
+
} catch {
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function stripQueryHash(value: string): string {
|
|
1076
|
+
return value.replace(/[?#].*$/, "");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function stripHash(value: string): string {
|
|
1080
|
+
return value.replace(/#.*$/, "");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function normalizedOrigin(value: string): string | null {
|
|
1084
|
+
try {
|
|
1085
|
+
const url = new URL(value);
|
|
1086
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.origin : null;
|
|
1087
|
+
} catch {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function canonicalPlatformReference(value: string): string {
|
|
1093
|
+
const reference = trimAsciiWhitespace(value);
|
|
1094
|
+
if (reference.startsWith("//") || /^https?:/i.test(reference)) {
|
|
1095
|
+
try {
|
|
1096
|
+
const url = reference.startsWith("//") ? new URL(`https:${reference}`) : new URL(reference);
|
|
1097
|
+
if ((url.protocol === "http:" || url.protocol === "https:") && url.hostname) {
|
|
1098
|
+
url.search = "";
|
|
1099
|
+
url.hash = "";
|
|
1100
|
+
return url.toString();
|
|
1101
|
+
}
|
|
1102
|
+
} catch {
|
|
1103
|
+
// Fall through to exact path/reference handling.
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return stripQueryHash(reference);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
interface PreparedPlatformHostedImageConfig {
|
|
1110
|
+
references: Set<string>;
|
|
1111
|
+
origins: Set<string>;
|
|
1112
|
+
prefixRoot: PlatformPathPrefixNode;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
interface PlatformPathPrefixNode {
|
|
1116
|
+
terminal: boolean;
|
|
1117
|
+
children: Map<string, PlatformPathPrefixNode>;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function platformPrefixTree(prefixes: readonly string[]): PlatformPathPrefixNode {
|
|
1121
|
+
const root: PlatformPathPrefixNode = { terminal: false, children: new Map() };
|
|
1122
|
+
for (const configured of prefixes) {
|
|
1123
|
+
const normalized = (configured.startsWith("/") ? configured : `/${configured}`).replace(/\/$/, "");
|
|
1124
|
+
let node = root;
|
|
1125
|
+
for (const character of normalized) {
|
|
1126
|
+
let child = node.children.get(character);
|
|
1127
|
+
if (!child) {
|
|
1128
|
+
child = { terminal: false, children: new Map() };
|
|
1129
|
+
node.children.set(character, child);
|
|
1130
|
+
}
|
|
1131
|
+
node = child;
|
|
1132
|
+
}
|
|
1133
|
+
node.terminal = true;
|
|
1134
|
+
}
|
|
1135
|
+
return root;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function hasPlatformPrefix(path: string, root: PlatformPathPrefixNode): boolean {
|
|
1139
|
+
if (root.terminal && path.startsWith("/")) return true;
|
|
1140
|
+
let node = root;
|
|
1141
|
+
let offset = 0;
|
|
1142
|
+
for (const character of path) {
|
|
1143
|
+
const child = node.children.get(character);
|
|
1144
|
+
if (!child) return false;
|
|
1145
|
+
node = child;
|
|
1146
|
+
offset += character.length;
|
|
1147
|
+
if (node.terminal && (offset === path.length || path[offset] === "/")) return true;
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function preparePlatformHostedConfig(config: PlatformHostedImageConfig | undefined): PreparedPlatformHostedImageConfig {
|
|
1153
|
+
return {
|
|
1154
|
+
references: new Set((config?.references ?? []).map(canonicalPlatformReference)),
|
|
1155
|
+
origins: new Set((config?.origins ?? []).map(normalizedOrigin).filter((origin): origin is string => origin != null)),
|
|
1156
|
+
prefixRoot: platformPrefixTree([...BUILTIN_PLATFORM_PREFIXES, ...(config?.pathPrefixes ?? [])]),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function tenantPathOwner(path: string): string | null {
|
|
1161
|
+
return path.match(/^\/tenants\/([^/]+)(?:\/.*)?$/)?.[1] ?? null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Classify one extracted locator. The returned locator fields are effect inputs,
|
|
1166
|
+
* not user-facing copy; callers render only findings/batches from `scanImageDependencies`.
|
|
1167
|
+
*/
|
|
1168
|
+
export function classifyImageDependency(
|
|
1169
|
+
locator: string,
|
|
1170
|
+
context: {
|
|
1171
|
+
tenantId: string;
|
|
1172
|
+
artifactPath: string;
|
|
1173
|
+
platformHosted?: PlatformHostedImageConfig;
|
|
1174
|
+
},
|
|
1175
|
+
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1176
|
+
return classifyImageDependencyWithPrepared(locator, context, preparePlatformHostedConfig(context.platformHosted));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function classifyParsedHttpUrl(
|
|
1180
|
+
absolute: URL,
|
|
1181
|
+
context: { tenantId: string },
|
|
1182
|
+
platformHosted: PreparedPlatformHostedImageConfig,
|
|
1183
|
+
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1184
|
+
if (!absolute.hostname || absolute.username || absolute.password) {
|
|
1185
|
+
return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
|
|
1186
|
+
}
|
|
1187
|
+
if (absolutePathEscapes(absolute.pathname)) {
|
|
1188
|
+
return { canonicalLocator: "escape:", disposition: "security-error", reason: "tenant-escape" };
|
|
1189
|
+
}
|
|
1190
|
+
absolute.hash = "";
|
|
1191
|
+
const canonical = absolute.toString();
|
|
1192
|
+
if (platformHosted.origins.has(absolute.origin) || platformHosted.references.has(stripQueryHash(canonical))) {
|
|
1193
|
+
const absoluteTenant = tenantPathOwner(absolute.pathname);
|
|
1194
|
+
if (absoluteTenant != null && absoluteTenant !== context.tenantId) {
|
|
1195
|
+
return { canonicalLocator: "cross-tenant:", disposition: "security-error", reason: "cross-tenant" };
|
|
1196
|
+
}
|
|
1197
|
+
return { canonicalLocator: canonical, disposition: "platform-hosted" };
|
|
1198
|
+
}
|
|
1199
|
+
return { canonicalLocator: canonical, disposition: "capture-required", reason: "third-party" };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function classifyImageDependencyWithPrepared(
|
|
1203
|
+
locator: string,
|
|
1204
|
+
context: { tenantId: string; artifactPath: string },
|
|
1205
|
+
platformHosted: PreparedPlatformHostedImageConfig,
|
|
1206
|
+
): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
|
|
1207
|
+
const raw = normalizeUrlInput(locator);
|
|
1208
|
+
if (!raw || CONTROL_RE.test(raw)) {
|
|
1209
|
+
return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
|
|
1210
|
+
}
|
|
1211
|
+
if (/^data:image\//i.test(raw)) {
|
|
1212
|
+
return { canonicalLocator: "inline:", disposition: "inline" };
|
|
1213
|
+
}
|
|
1214
|
+
if (/^(?:javascript|file|blob|ftp|vbscript):/i.test(raw)) {
|
|
1215
|
+
return { canonicalLocator: "unsafe:", disposition: "security-error", reason: "unsafe-scheme" };
|
|
1216
|
+
}
|
|
1217
|
+
if (raw.startsWith("//") || /^https?:/i.test(raw)) {
|
|
1218
|
+
let absolute: URL;
|
|
1219
|
+
try {
|
|
1220
|
+
absolute = raw.startsWith("//") ? new URL(`https:${raw}`) : new URL(raw);
|
|
1221
|
+
} catch {
|
|
1222
|
+
return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
|
|
1223
|
+
}
|
|
1224
|
+
return classifyParsedHttpUrl(absolute, context, platformHosted);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
|
1228
|
+
return { canonicalLocator: "unsafe:", disposition: "security-error", reason: "unsafe-scheme" };
|
|
1229
|
+
}
|
|
1230
|
+
const networkUrl = backslashNetworkUrl(raw, context.artifactPath);
|
|
1231
|
+
if (networkUrl) return classifyParsedHttpUrl(networkUrl, context, platformHosted);
|
|
1232
|
+
const pathOnly = stripQueryHash(raw);
|
|
1233
|
+
const structural = structuralPath(pathOnly);
|
|
1234
|
+
if (structural.startsWith("/") && hasDotDotSegment(pathOnly)) {
|
|
1235
|
+
return { canonicalLocator: "escape:", disposition: "security-error", reason: "tenant-escape" };
|
|
1236
|
+
}
|
|
1237
|
+
const tenant = tenantPathOwner(structural);
|
|
1238
|
+
if (tenant != null) {
|
|
1239
|
+
if (tenant !== context.tenantId) {
|
|
1240
|
+
return { canonicalLocator: "cross-tenant:", disposition: "security-error", reason: "cross-tenant" };
|
|
1241
|
+
}
|
|
1242
|
+
return { canonicalLocator: stripHash(raw), disposition: "tenant-hosted" };
|
|
1243
|
+
}
|
|
1244
|
+
if (
|
|
1245
|
+
(!hasDotDotSegment(pathOnly) && platformHosted.references.has(pathOnly)) ||
|
|
1246
|
+
hasPlatformPrefix(structural, platformHosted.prefixRoot)
|
|
1247
|
+
) {
|
|
1248
|
+
return { canonicalLocator: stripHash(raw), disposition: "platform-hosted" };
|
|
1249
|
+
}
|
|
1250
|
+
if (/^\/_a\/[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i.test(structural)) {
|
|
1251
|
+
return { canonicalLocator: stripHash(raw), disposition: "platform-hosted" };
|
|
1252
|
+
}
|
|
1253
|
+
const resolved = resolveLocalPath(context.artifactPath, pathOnly);
|
|
1254
|
+
const canonicalPath = canonicalLocalPath(context.artifactPath, pathOnly);
|
|
1255
|
+
if (resolved == null || canonicalPath == null) {
|
|
1256
|
+
return { canonicalLocator: "escape:", disposition: "security-error", reason: "tenant-escape" };
|
|
1257
|
+
}
|
|
1258
|
+
const canonical = `${canonicalPath}${stripHash(raw.slice(pathOnly.length))}`;
|
|
1259
|
+
const resolvedTenant = tenantPathOwner(resolved);
|
|
1260
|
+
if (resolvedTenant != null) {
|
|
1261
|
+
if (resolvedTenant !== context.tenantId) {
|
|
1262
|
+
return { canonicalLocator: "cross-tenant:", disposition: "security-error", reason: "cross-tenant" };
|
|
1263
|
+
}
|
|
1264
|
+
return { canonicalLocator: canonical, disposition: "tenant-hosted" };
|
|
1265
|
+
}
|
|
1266
|
+
if (
|
|
1267
|
+
platformHosted.references.has(canonicalPath) ||
|
|
1268
|
+
hasPlatformPrefix(resolved, platformHosted.prefixRoot) ||
|
|
1269
|
+
/^\/_a\/[0-9a-f]{64}(?:\.[a-z0-9]+)?$/i.test(resolved)
|
|
1270
|
+
) {
|
|
1271
|
+
return { canonicalLocator: canonical, disposition: "platform-hosted" };
|
|
1272
|
+
}
|
|
1273
|
+
return { canonicalLocator: canonical, disposition: "capture-required", reason: "local" };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function findingMessage(reason: ImageDependencyReason): string {
|
|
1277
|
+
switch (reason) {
|
|
1278
|
+
case "third-party": return "Third-party image content must be captured before hosted preview.";
|
|
1279
|
+
case "local": return "Local image content must be captured before hosted preview.";
|
|
1280
|
+
case "cross-tenant": return "An image reference crosses the current tenant boundary.";
|
|
1281
|
+
case "tenant-escape": return "An image reference escapes the current tenant boundary.";
|
|
1282
|
+
case "unsafe-scheme": return "An image reference uses a scheme that hosted preview cannot accept.";
|
|
1283
|
+
case "malformed": return "An image reference is malformed.";
|
|
1284
|
+
case "unsupported": return DEFERRED_IMAGE_MESSAGE;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
export function buildImageDependencyWarningBatch(
|
|
1289
|
+
findings: readonly ImageDependencyFinding[],
|
|
1290
|
+
): ImageDependencyWarningBatch | null {
|
|
1291
|
+
if (findings.length === 0) return null;
|
|
1292
|
+
const captureRequired = findings
|
|
1293
|
+
.filter((finding) => finding.code === "image-capture-required")
|
|
1294
|
+
.reduce((sum, finding) => sum + finding.occurrences, 0);
|
|
1295
|
+
const securityErrors = findings
|
|
1296
|
+
.filter((finding) => finding.code === "image-reference-security")
|
|
1297
|
+
.reduce((sum, finding) => sum + finding.occurrences, 0);
|
|
1298
|
+
const deferred = findings
|
|
1299
|
+
.filter((finding) => finding.code === "image-reference-deferred")
|
|
1300
|
+
.reduce((sum, finding) => sum + finding.occurrences, 0);
|
|
1301
|
+
const unresolved = captureRequired + securityErrors;
|
|
1302
|
+
const messages: string[] = [];
|
|
1303
|
+
if (unresolved > 0) messages.push(`Image hosting found ${unresolved} unresolved reference(s) in one batch.`);
|
|
1304
|
+
if (captureRequired > 0) messages.push(`${captureRequired} reference(s) require capture before hosted preview.`);
|
|
1305
|
+
if (securityErrors > 0) messages.push(`${securityErrors} reference(s) require a security fix before hosted preview.`);
|
|
1306
|
+
if (deferred > 0) messages.push(`${deferred} reference(s) were skipped because hosted preview does not resolve them yet.`);
|
|
1307
|
+
return {
|
|
1308
|
+
code: "image-dependency-batch",
|
|
1309
|
+
total: unresolved + deferred,
|
|
1310
|
+
captureRequired,
|
|
1311
|
+
securityErrors,
|
|
1312
|
+
deferred,
|
|
1313
|
+
representedFindings: findings.length,
|
|
1314
|
+
omittedFindings: 0,
|
|
1315
|
+
messages,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Scan a revision's already-read artifacts with no network or filesystem I/O. */
|
|
1320
|
+
export function scanImageDependencies(
|
|
1321
|
+
artifacts: readonly ImageDependencyArtifact[],
|
|
1322
|
+
options: ImageDependencyScanOptions,
|
|
1323
|
+
): ImageDependencyScan {
|
|
1324
|
+
const dependencies: ImageDependency[] = [];
|
|
1325
|
+
const platformHosted = preparePlatformHostedConfig(options.platformHosted);
|
|
1326
|
+
let occurrence = 0;
|
|
1327
|
+
for (const [artifactIndex, artifact] of artifacts.entries()) {
|
|
1328
|
+
const kind = inferKind(artifact);
|
|
1329
|
+
let refs: ExtractedReference[];
|
|
1330
|
+
if (kind === "html") refs = htmlReferences(artifact.content, artifact.path);
|
|
1331
|
+
else if (kind === "css") refs = cssReferences(artifact.content, "stylesheet");
|
|
1332
|
+
else {
|
|
1333
|
+
const parsed = catalogReferences(artifact.content, artifact.path);
|
|
1334
|
+
if (parsed == null) {
|
|
1335
|
+
refs = [{ locator: "", surface: "catalog" }];
|
|
1336
|
+
} else {
|
|
1337
|
+
refs = parsed;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
for (const ref of refs) {
|
|
1341
|
+
occurrence++;
|
|
1342
|
+
if (ref.deferredFunction) {
|
|
1343
|
+
dependencies.push({
|
|
1344
|
+
locator: `css-function:${ref.deferredFunction}`,
|
|
1345
|
+
canonicalLocator: "deferred:",
|
|
1346
|
+
artifactIndex,
|
|
1347
|
+
occurrence,
|
|
1348
|
+
surface: ref.surface,
|
|
1349
|
+
disposition: "deferred",
|
|
1350
|
+
reason: "unsupported",
|
|
1351
|
+
});
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const classified = classifyImageDependencyWithPrepared(ref.classificationLocator ?? ref.locator, {
|
|
1355
|
+
tenantId: options.tenantId,
|
|
1356
|
+
artifactPath: artifact.path,
|
|
1357
|
+
}, platformHosted);
|
|
1358
|
+
dependencies.push({
|
|
1359
|
+
locator: ref.locator,
|
|
1360
|
+
artifactIndex,
|
|
1361
|
+
occurrence,
|
|
1362
|
+
surface: ref.surface,
|
|
1363
|
+
...classified,
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const unresolved = new Map<string, { dependency: ImageDependency; occurrences: number; groupOrdinal: number }>();
|
|
1369
|
+
const artifactGroupCounts = new Map<number, number>();
|
|
1370
|
+
for (const dependency of dependencies) {
|
|
1371
|
+
if (dependency.disposition !== "capture-required" && dependency.disposition !== "security-error" && dependency.disposition !== "deferred") continue;
|
|
1372
|
+
const key = `${dependency.artifactIndex}\0${dependency.surface}\0${dependency.disposition}\0${dependency.reason}\0${dependency.canonicalLocator}`;
|
|
1373
|
+
const current = unresolved.get(key);
|
|
1374
|
+
if (current) current.occurrences++;
|
|
1375
|
+
else {
|
|
1376
|
+
const groupOrdinal = (artifactGroupCounts.get(dependency.artifactIndex) ?? 0) + 1;
|
|
1377
|
+
artifactGroupCounts.set(dependency.artifactIndex, groupOrdinal);
|
|
1378
|
+
unresolved.set(key, { dependency, occurrences: 1, groupOrdinal });
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const findings: ImageDependencyFinding[] = [];
|
|
1383
|
+
for (const { dependency, occurrences, groupOrdinal } of unresolved.values()) {
|
|
1384
|
+
const reason = dependency.reason!;
|
|
1385
|
+
const security = dependency.disposition === "security-error";
|
|
1386
|
+
const deferred = dependency.disposition === "deferred";
|
|
1387
|
+
findings.push({
|
|
1388
|
+
id: `image-dependency-${String(dependency.artifactIndex + 1).padStart(6, "0")}-${String(groupOrdinal).padStart(6, "0")}`,
|
|
1389
|
+
code: security ? "image-reference-security" : deferred ? "image-reference-deferred" : "image-capture-required",
|
|
1390
|
+
reason,
|
|
1391
|
+
occurrences,
|
|
1392
|
+
authoring: security ? "non-overridable-security-error" : "warning",
|
|
1393
|
+
hostedPreview: security ? "non-overridable-security-error" : "overridable-quality-error",
|
|
1394
|
+
overridable: !security,
|
|
1395
|
+
message: findingMessage(reason),
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
const visibleFindings = findings.slice(0, MAX_VISIBLE_FINDINGS);
|
|
1399
|
+
|
|
1400
|
+
const captureByCanonicalLocator = new Map<string, ImageDependency & { reason: "third-party" | "local" }>();
|
|
1401
|
+
for (const dependency of dependencies) {
|
|
1402
|
+
if (dependency.disposition !== "capture-required" || (dependency.reason !== "third-party" && dependency.reason !== "local")) continue;
|
|
1403
|
+
const current = captureByCanonicalLocator.get(dependency.canonicalLocator);
|
|
1404
|
+
if (!current || dependency.locator < current.locator) captureByCanonicalLocator.set(dependency.canonicalLocator, dependency as ImageDependency & { reason: "third-party" | "local" });
|
|
1405
|
+
}
|
|
1406
|
+
const captureCandidates = [...captureByCanonicalLocator.values()]
|
|
1407
|
+
.sort((left, right) => left.canonicalLocator < right.canonicalLocator ? -1 : left.canonicalLocator > right.canonicalLocator ? 1 : 0)
|
|
1408
|
+
.map(({ locator, canonicalLocator, reason }) => ({
|
|
1409
|
+
locator,
|
|
1410
|
+
canonicalLocator,
|
|
1411
|
+
reason,
|
|
1412
|
+
identityAuthority: "original-image-bytes" as const,
|
|
1413
|
+
}));
|
|
1414
|
+
|
|
1415
|
+
const count = (disposition: ImageDependencyDisposition): number =>
|
|
1416
|
+
dependencies.filter((dependency) => dependency.disposition === disposition).length;
|
|
1417
|
+
return {
|
|
1418
|
+
census: {
|
|
1419
|
+
references: dependencies.length,
|
|
1420
|
+
platformHosted: count("platform-hosted"),
|
|
1421
|
+
tenantHosted: count("tenant-hosted"),
|
|
1422
|
+
inline: count("inline"),
|
|
1423
|
+
captureRequired: count("capture-required"),
|
|
1424
|
+
securityErrors: count("security-error"),
|
|
1425
|
+
deferred: count("deferred"),
|
|
1426
|
+
},
|
|
1427
|
+
dependencies,
|
|
1428
|
+
captureCandidates,
|
|
1429
|
+
findings: visibleFindings,
|
|
1430
|
+
warningBatch: (() => {
|
|
1431
|
+
const batch = buildImageDependencyWarningBatch(findings);
|
|
1432
|
+
if (!batch) return null;
|
|
1433
|
+
batch.representedFindings = visibleFindings.length;
|
|
1434
|
+
batch.omittedFindings = findings.length - visibleFindings.length;
|
|
1435
|
+
return batch;
|
|
1436
|
+
})(),
|
|
1437
|
+
};
|
|
1438
|
+
}
|