@tokenoftrust/storefront-runner 3.11.0 → 3.12.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.
@@ -2,7 +2,8 @@
2
2
  * The question/answer pairs a page visibly carries, for a declared FAQPage node
3
3
  * (lib/content/seo/structuredData.ts). Derived from the rendered content itself, so the markup can never
4
4
  * claim an answer the page does not show: a heading phrased as a question, answered by the text
5
- * up to the next heading. Fewer than two pairs is not an FAQ.
5
+ * up to the next heading, or a details/summary pair whose summary is the question and whose
6
+ * remaining text is the answer. A summary need not end in ?. Fewer than two pairs is not an FAQ.
6
7
  */
7
8
  import type { ProseSection } from "@/lib/content/prose";
8
9
  import { plainText } from "@/lib/content/richtext";
@@ -29,7 +30,17 @@ function atLeastTwo(items: FaqItem[]): FaqItem[] {
29
30
  return answered.length >= MIN_PAIRS ? answered : [];
30
31
  }
31
32
 
32
- /** FAQ pairs from sanitized body HTML: each question-phrased h2–h4 and the text after it. */
33
+ /** One details element: its summary is the question, the rest of the element is the answer. */
34
+ function detailsPair(block: string): FaqItem | null {
35
+ const summary = /<summary\b[^>]*>[\s\S]*?<\/summary>/i.exec(block);
36
+ if (!summary) return null;
37
+ const q = textOf(summary[0]);
38
+ const at = summary.index ?? 0;
39
+ const rest = block.slice(0, at) + block.slice(at + summary[0].length);
40
+ return { q, a: textOf(rest) };
41
+ }
42
+
43
+ /** FAQ pairs from sanitized body HTML: question-phrased h2–h4, plus every details/summary pair. */
33
44
  export function faqFromHtml(html: string | undefined): FaqItem[] {
34
45
  if (!html) return [];
35
46
  const parts = html.split(/(<h[2-4][^>]*>[\s\S]*?<\/h[2-4]>)/i);
@@ -38,6 +49,10 @@ export function faqFromHtml(html: string | undefined): FaqItem[] {
38
49
  const q = textOf(parts[i]!);
39
50
  if (isQuestion(q)) items.push({ q, a: textOf(parts[i + 1] ?? "") });
40
51
  }
52
+ for (const block of html.matchAll(/<details\b[^>]*>[\s\S]*?<\/details>/gi)) {
53
+ const pair = detailsPair(block[0]);
54
+ if (pair) items.push(pair);
55
+ }
41
56
  return atLeastTwo(items);
42
57
  }
43
58
 
@@ -49,6 +49,13 @@ import {
49
49
  resolveCapabilities,
50
50
  capabilitiesArtifactPath,
51
51
  resolveServableChannelVersion,
52
+ resolveChannelVersion,
53
+ serveTenantAsset,
54
+ parseTenantAssetPath,
55
+ compileRouteTierIndexV1,
56
+ parsePublicationVersionStateV1Json,
57
+ publicationVersionStateKey,
58
+ readPublicationRootAssetV1,
52
59
  channelFromEnv,
53
60
  resolveEnvironmentOnlyPlan,
54
61
  INTEGRATION_NONCE_PLACEHOLDER,
@@ -66,6 +73,17 @@ import {
66
73
  } from "@tot/public-runtime";
67
74
  import { readBakedManagedInstalls } from "@/lib/integrations/integration/bakedManagedInstalls";
68
75
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
76
+ import { publicationRecoveryActiveV1, publicationRecoveryKeyV1 } from "@tot/public-runtime/publication-recovery";
77
+ import {
78
+ distinctTenantThemeGroups,
79
+ inlineDistinctTenantThemeGroups,
80
+ previewThemeInlineApplies,
81
+ selectServedThemeCss,
82
+ } from "@tot/public-runtime/theme-stylesheets";
83
+ import {
84
+ resolvePreviewVersionFromCookies,
85
+ resolveSharedPreviewAssetVersion,
86
+ } from "@/lib/assets/preview-version-cookie";
69
87
  import type {
70
88
  CapabilityConfig,
71
89
  ConsentSnapshot,
@@ -75,7 +93,10 @@ import type {
75
93
  ThemeTokens,
76
94
  BrowserEnvironment,
77
95
  PublicationArtifactBucketV1,
96
+ PublicationArtifactReadBucketV1,
97
+ PublicationChannelV1,
78
98
  PublicationRoutePlanV1,
99
+ R2BucketLike,
79
100
  RouteTierIndexV1,
80
101
  } from "@tot/public-runtime";
81
102
  import {
@@ -359,7 +380,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
359
380
  // Digest-addressed static-bundle serving. Deliberately before the edge page
360
381
  // cache/catalog/theme machinery. A selected publication bypasses this legacy
361
382
  // whole-site bundle until B1 can compose mounted spaces into it reproducibly.
362
- const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
363
383
  const cookieRevision = await resolvePreviewVersionFromCookies({
364
384
  tenantId: tenant.tenant_id,
365
385
  previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
@@ -787,6 +807,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
787
807
  nonce: cspNonce,
788
808
  });
789
809
 
810
+ // Distinct tenant theme sheets are render-blocking `/tenants/` fetches. Inline
811
+ // them into this preview document. Publication capture keeps the source links
812
+ // so the asset pipeline concatenates the same run for both CDNs.
813
+ response = await inlinePreviewTenantThemes(response, {
814
+ request,
815
+ customizationEnv: customization.env,
816
+ tenant,
817
+ kvGet,
818
+ isPreviewHost,
819
+ previewToken,
820
+ previewSecret,
821
+ nowSeconds,
822
+ pinnedVersionId: cookieRevision,
823
+ });
824
+
790
825
  // Baseline security headers on every response — set before the page-cache
791
826
  // store below so cached copies carry them too.
792
827
  applySecurityHeaders(response.headers);
@@ -919,3 +954,169 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
919
954
  // matching nonce/CSP pair, and no nonce is reused across responses (ADR 0018).
920
955
  return await stampResponseNonce(response, freshNonce());
921
956
  }
957
+
958
+ /**
959
+ * Inline a run of distinct tenant theme stylesheets into a preview document.
960
+ * Bytes follow the `/tenants/<id>/` route: publication root, then the preview
961
+ * overlay, then the asset store. A miss leaves that run as links.
962
+ */
963
+ async function inlinePreviewTenantThemes(response: Response, input: {
964
+ request: Request;
965
+ customizationEnv: "live" | "test";
966
+ tenant: TenantConfig;
967
+ kvGet: (key: string) => Promise<string | null>;
968
+ isPreviewHost: boolean;
969
+ previewToken: string | null;
970
+ previewSecret: string | null;
971
+ nowSeconds: number;
972
+ pinnedVersionId: string | null;
973
+ }): Promise<Response> {
974
+ if (!previewThemeInlineApplies({
975
+ method: input.request.method,
976
+ customizationEnv: input.customizationEnv,
977
+ captureProbe: input.request.headers.get(HTML_CACHE_POLICY_CAPTURE_PROBE),
978
+ contentType: response.headers.get("content-type") ?? "",
979
+ }) || response.status !== 200) return response;
980
+
981
+ const html = await response.text();
982
+ if (distinctTenantThemeGroups(html).length === 0) return htmlResponse(response, html);
983
+
984
+ try {
985
+ const shared = input.pinnedVersionId
986
+ ? { preview: false, versionId: null }
987
+ : await resolveSharedPreviewAssetVersion({
988
+ tenantId: input.tenant.tenant_id,
989
+ previewHost: input.isPreviewHost,
990
+ previewToken: input.previewToken,
991
+ secret: input.previewSecret,
992
+ nowSeconds: input.nowSeconds,
993
+ readPreviewChannelVersion: () =>
994
+ resolveChannelVersion(input.kvGet, input.tenant.tenant_id, "preview"),
995
+ });
996
+ const previewVersionId = input.pinnedVersionId ?? shared.versionId;
997
+ const publicationChannel: PublicationChannelV1 =
998
+ shared.preview || input.customizationEnv === "test" ? "preview" : "live";
999
+ const assetChannel = shared.preview ? "test" as const : input.customizationEnv;
1000
+ const bucket = await readR2("TENANT_ASSETS");
1001
+ const paths = [...new Set(
1002
+ distinctTenantThemeGroups(html).flatMap((group) => group.sheets.map((sheet) => sheet.path)),
1003
+ )];
1004
+ const css = new Map<string, string | null>();
1005
+ await Promise.all(paths.map(async (pathname) => {
1006
+ try {
1007
+ css.set(pathname, bucket
1008
+ ? await readPreviewThemeCss({
1009
+ tenant: input.tenant,
1010
+ pathname,
1011
+ bucket: bucket as unknown as R2BucketLike,
1012
+ kvGet: input.kvGet,
1013
+ publicationChannel,
1014
+ assetChannel,
1015
+ previewVersionId,
1016
+ })
1017
+ : null);
1018
+ } catch {
1019
+ css.set(pathname, null);
1020
+ }
1021
+ }));
1022
+ const next = inlineDistinctTenantThemeGroups(html, (pathname) => css.get(pathname) ?? null);
1023
+ return htmlResponse(response, next);
1024
+ } catch {
1025
+ return htmlResponse(response, html);
1026
+ }
1027
+ }
1028
+
1029
+ function htmlResponse(response: Response, html: string): Response {
1030
+ const headers = new Headers(response.headers);
1031
+ headers.delete("content-length");
1032
+ headers.delete("content-encoding");
1033
+ return new Response(html, { status: response.status, statusText: response.statusText, headers });
1034
+ }
1035
+
1036
+ async function readPreviewThemeCss(input: {
1037
+ tenant: TenantConfig;
1038
+ pathname: string;
1039
+ bucket: R2BucketLike;
1040
+ kvGet: (key: string) => Promise<string | null>;
1041
+ publicationChannel: PublicationChannelV1;
1042
+ assetChannel: "live" | "test";
1043
+ previewVersionId: string | null;
1044
+ }): Promise<string | null> {
1045
+ return selectServedThemeCss({
1046
+ previewVersionId: input.previewVersionId,
1047
+ publicationRoot: () => publicationRootThemeCss(input),
1048
+ overlay: () => overlayThemeCss(input),
1049
+ asset: () => assetThemeCss(input),
1050
+ });
1051
+ }
1052
+
1053
+ /** Same order as GET `/tenants/<id>/<path>`: a selected root file answers, including its 404. */
1054
+ async function publicationRootThemeCss(input: {
1055
+ tenant: TenantConfig;
1056
+ pathname: string;
1057
+ bucket: R2BucketLike;
1058
+ kvGet: (key: string) => Promise<string | null>;
1059
+ publicationChannel: PublicationChannelV1;
1060
+ }): Promise<"legacy" | "absent" | string> {
1061
+ const parsed = parseTenantAssetPath(input.pathname);
1062
+ if (!parsed || parsed.tenantId !== input.tenant.tenant_id) return "legacy";
1063
+ if (!input.tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return "legacy";
1064
+ const [stateJson, recoveryJson] = await Promise.all([
1065
+ input.kvGet(publicationVersionStateKey(input.tenant.tenant_id)),
1066
+ input.publicationChannel === "live"
1067
+ ? input.kvGet(publicationRecoveryKeyV1(input.tenant.tenant_id))
1068
+ : Promise.resolve(null),
1069
+ ]);
1070
+ if (stateJson === null || publicationRecoveryActiveV1(recoveryJson)) return "legacy";
1071
+ const state = parsePublicationVersionStateV1Json(stateJson);
1072
+ if (!state.ok) return "legacy";
1073
+ const read = await readPublicationRootAssetV1({
1074
+ index: compileRouteTierIndexV1(input.tenant),
1075
+ state: state.value,
1076
+ channel: input.publicationChannel,
1077
+ bucket: input.bucket as unknown as PublicationArtifactReadBucketV1,
1078
+ relativePath: parsed.rel,
1079
+ });
1080
+ if (read.status === "legacy-root") return "legacy";
1081
+ if (read.status === "absent") return "absent";
1082
+ try {
1083
+ return new TextDecoder("utf-8", { fatal: true }).decode(read.bytes);
1084
+ } catch {
1085
+ return "absent";
1086
+ }
1087
+ }
1088
+
1089
+ async function overlayThemeCss(input: {
1090
+ tenant: TenantConfig;
1091
+ pathname: string;
1092
+ kvGet: (key: string) => Promise<string | null>;
1093
+ previewVersionId: string | null;
1094
+ }): Promise<string | null> {
1095
+ if (!input.previewVersionId) return null;
1096
+ const parsed = parseTenantAssetPath(input.pathname);
1097
+ if (!parsed || parsed.tenantId !== input.tenant.tenant_id) return null;
1098
+ return readWithImagePreviewRewriteOverlay(
1099
+ input.kvGet,
1100
+ input.tenant.tenant_id,
1101
+ input.previewVersionId,
1102
+ `tenants/${input.tenant.tenant_id}/public/${parsed.rel}`,
1103
+ async () => null,
1104
+ );
1105
+ }
1106
+
1107
+ async function assetThemeCss(input: {
1108
+ pathname: string;
1109
+ bucket: R2BucketLike;
1110
+ kvGet: (key: string) => Promise<string | null>;
1111
+ assetChannel: "live" | "test";
1112
+ previewVersionId: string | null;
1113
+ }): Promise<string | null> {
1114
+ const res = await serveTenantAsset({
1115
+ bucket: input.bucket,
1116
+ kvGet: input.kvGet,
1117
+ channel: input.assetChannel,
1118
+ ...(input.previewVersionId ? { versionId: input.previewVersionId } : {}),
1119
+ }, input.pathname);
1120
+ if (!res || res.status !== 200) return null;
1121
+ return res.text();
1122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -31,6 +31,7 @@
31
31
  "./publication-recovery": "./src/publication-recovery.ts",
32
32
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
33
33
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
34
+ "./theme-stylesheets": "./src/theme-stylesheets.mjs",
34
35
  "./compliance-coherence": "./src/compliance-coherence.mjs",
35
36
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
36
37
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
@@ -0,0 +1,321 @@
1
+ // @ts-check
2
+ /**
3
+ * Distinct tenant theme stylesheets.
4
+ *
5
+ * A run of two or more different `/tenants/<id>/themes/*.css` links is one
6
+ * theme sheet. `/shared/` and `/_astro/` stay their own links and split a run.
7
+ * The same path repeated is not this case. Print, disabled, alternate, and
8
+ * commented links are not members. Integrity and an unusual media query split
9
+ * a run and are left in place.
10
+ *
11
+ * Preview inlines the run into the document already being rendered. Publication
12
+ * concatenates the same run in the asset pipeline. Neither path branches on a
13
+ * tenant id, and neither adds a Worker in front of public HTML.
14
+ */
15
+
16
+ const TENANT_THEME_STYLESHEET = /^\/tenants\/[^/]+\/themes\/[^/?#]+\.css$/i;
17
+ const SIMPLE_MEDIA = new Set(["", "all", "screen"]);
18
+ const CSS_REF_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)|@import\s+(['"])([^'"]+)\3/g;
19
+
20
+ /**
21
+ * @param {string} pathname
22
+ */
23
+ export function isTenantThemeStylesheetPath(pathname) {
24
+ return TENANT_THEME_STYLESHEET.test(pathname);
25
+ }
26
+
27
+ /**
28
+ * Preview inlines only a dynamic HTML GET that is not the publication capture
29
+ * and not the live document. The capture must still see the source links so
30
+ * the asset pipeline can copy each file.
31
+ * @param {{ method: string, customizationEnv: string, captureProbe: string | null, contentType: string }} input
32
+ */
33
+ export function previewThemeInlineApplies(input) {
34
+ if (input.method !== "GET") return false;
35
+ if (input.customizationEnv === "live") return false;
36
+ if (input.captureProbe != null) return false;
37
+ return input.contentType.toLowerCase().includes("text/html");
38
+ }
39
+
40
+ /**
41
+ * Publication-root, then the preview rewrite overlay, then the asset store.
42
+ * `"absent"` stops the cascade: that is a selected publication file the
43
+ * browser would 404, not a cue to serve a different copy.
44
+ * @param {{
45
+ * previewVersionId: string | null,
46
+ * publicationRoot: () => Promise<"legacy" | "absent" | string>,
47
+ * overlay: () => Promise<string | null>,
48
+ * asset: () => Promise<string | null>,
49
+ * }} input
50
+ * @returns {Promise<string | null>}
51
+ */
52
+ export async function selectServedThemeCss(input) {
53
+ const root = await input.publicationRoot();
54
+ if (root === "absent") return null;
55
+ if (root !== "legacy") return root;
56
+ if (input.previewVersionId) {
57
+ const overlay = await input.overlay();
58
+ if (overlay != null) return overlay;
59
+ }
60
+ return input.asset();
61
+ }
62
+
63
+ /**
64
+ * @param {string} html
65
+ * @returns {{ sheets: { start: number, end: number, path: string }[] }[]}
66
+ */
67
+ export function distinctTenantThemeGroups(html) {
68
+ /** @type {{ start: number, end: number, path: string }[]} */
69
+ let run = [];
70
+ /** @type {{ sheets: { start: number, end: number, path: string }[] }[]} */
71
+ const groups = [];
72
+ const flush = () => {
73
+ if (run.length >= 2 && new Set(run.map((sheet) => sheet.path)).size >= 2) {
74
+ groups.push({ sheets: run });
75
+ }
76
+ run = [];
77
+ };
78
+ for (const sheet of blockingStylesheetLinks(html)) {
79
+ if (sheet.member) run.push(sheet);
80
+ else flush();
81
+ }
82
+ flush();
83
+ return groups;
84
+ }
85
+
86
+ /**
87
+ * Replace each distinct theme run with one inline style. A run whose bytes
88
+ * are missing, not utf-8, or whose relative url escapes the site is left as links.
89
+ * @param {string} html
90
+ * @param {(pathname: string) => string | null} cssFor
91
+ */
92
+ export function inlineDistinctTenantThemeGroups(html, cssFor) {
93
+ const groups = distinctTenantThemeGroups(html);
94
+ if (groups.length === 0) return html;
95
+ /** @type {{ start: number, end: number, replacement: string }[]} */
96
+ const edits = [];
97
+ for (const group of groups) {
98
+ /** @type {string[]} */
99
+ const parts = [];
100
+ let skip = false;
101
+ for (const sheet of group.sheets) {
102
+ const css = cssFor(sheet.path);
103
+ const prepared = css == null ? null : prepareInlineSheet(css, sheet.path);
104
+ if (prepared == null) {
105
+ skip = true;
106
+ break;
107
+ }
108
+ parts.push(prepared);
109
+ }
110
+ const first = group.sheets[0];
111
+ if (skip || !first) continue;
112
+ const style = `<style data-tot-theme>${escapeStyleBody(parts.join("\n"))}</style>`;
113
+ edits.push({ start: first.start, end: first.end, replacement: style });
114
+ for (const sheet of group.sheets.slice(1)) {
115
+ edits.push({ start: sheet.start, end: sheet.end, replacement: "" });
116
+ }
117
+ }
118
+ return applyStylesheetEdits(html, edits);
119
+ }
120
+
121
+ /**
122
+ * @param {string} html
123
+ * @param {{ start: number, end: number, replacement: string }[]} edits
124
+ */
125
+ export function applyStylesheetEdits(html, edits) {
126
+ const ordered = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
127
+ let out = html;
128
+ for (const edit of ordered) {
129
+ out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * @param {string} css
136
+ * @param {string} stylesheetPath
137
+ * @returns {string | null}
138
+ */
139
+ function prepareInlineSheet(css, stylesheetPath) {
140
+ const charset = /^\uFEFF?@charset\s+["']([^"']+)["']\s*;/.exec(css);
141
+ const charsetLabel = charset?.[1];
142
+ if (charset && charsetLabel) {
143
+ if (charsetLabel.toLowerCase() !== "utf-8") return null;
144
+ css = css.slice(charset[0].length).replace(/^\s+/, "");
145
+ }
146
+ return rewriteRelativeCssUrls(css, stylesheetPath);
147
+ }
148
+
149
+ /**
150
+ * Root-absolute urls, so an inlined sheet resolves the same files the link did.
151
+ * A relative reference that escapes the site cannot be rewritten; the caller
152
+ * leaves the links in place.
153
+ * @param {string} css
154
+ * @param {string} stylesheetPath
155
+ * @returns {string | null}
156
+ */
157
+ function rewriteRelativeCssUrls(css, stylesheetPath) {
158
+ const cssRel = stylesheetPath.replace(/^\/+/, "");
159
+ let escaped = false;
160
+ const out = css.replace(CSS_REF_RE, (whole, q1, u1, q2, u2) => {
161
+ const raw = (u1 ?? u2 ?? "").trim();
162
+ if (!raw || raw.startsWith("#") || raw.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) return whole;
163
+ const cut = raw.search(/[?#]/);
164
+ const path = cut === -1 ? raw : raw.slice(0, cut);
165
+ const suffix = cut === -1 ? "" : raw.slice(cut);
166
+ if (path.startsWith("/")) return whole;
167
+ let rel = null;
168
+ try {
169
+ rel = resolveRelative(cssRel, decodeURIComponent(path));
170
+ } catch {
171
+ rel = null;
172
+ }
173
+ if (!rel) {
174
+ escaped = true;
175
+ return whole;
176
+ }
177
+ const target = `/${rel}${suffix}`;
178
+ return u1 !== undefined ? `url(${q1}${target}${q1})` : `@import ${q2}${target}${q2}`;
179
+ });
180
+ return escaped ? null : out;
181
+ }
182
+
183
+ /**
184
+ * @param {string} fromRel
185
+ * @param {string} ref
186
+ * @returns {string | null}
187
+ */
188
+ function resolveRelative(fromRel, ref) {
189
+ const parts = fromRel.split("/").slice(0, -1);
190
+ for (const seg of ref.split("/")) {
191
+ if (seg === "" || seg === ".") continue;
192
+ if (seg === "..") {
193
+ if (!parts.length) return null;
194
+ parts.pop();
195
+ } else parts.push(seg);
196
+ }
197
+ return parts.length ? parts.join("/") : null;
198
+ }
199
+
200
+ /**
201
+ * @param {string} css
202
+ */
203
+ function escapeStyleBody(css) {
204
+ return css.replace(/<\/style/gi, "<\\/style");
205
+ }
206
+
207
+ /**
208
+ * @param {string} html
209
+ * @returns {{ start: number, end: number, path: string, member: boolean }[]}
210
+ */
211
+ function blockingStylesheetLinks(html) {
212
+ const head = headSpan(html);
213
+ const comments = commentRanges(html);
214
+ /** @type {{ start: number, end: number, path: string, member: boolean }[]} */
215
+ const sheets = [];
216
+ for (const match of html.slice(head.start, head.end).matchAll(/<link\b([^>]*)>/gi)) {
217
+ const start = head.start + (match.index ?? 0);
218
+ if (inside(comments, start)) continue;
219
+ const attrs = match[1] ?? "";
220
+ const parsed = parseTagAttrs(attrs);
221
+ const rel = relTokens(parsed.rel);
222
+ if (!rel.includes("stylesheet") || rel.includes("alternate")) continue;
223
+ if (hasFlag(attrs, "disabled")) continue;
224
+ const media = (parsed.media || "").trim().toLowerCase();
225
+ if (media === "print") continue;
226
+ const href = (parsed.href || "").trim();
227
+ const path = pathnameOf(href);
228
+ const local = href.startsWith("/") && !href.startsWith("//");
229
+ const integrity = Object.prototype.hasOwnProperty.call(parsed, "integrity");
230
+ const odd = !SIMPLE_MEDIA.has(media);
231
+ if (!path) continue;
232
+ const member = local && !integrity && !odd && isTenantThemeStylesheetPath(path);
233
+ sheets.push({
234
+ start,
235
+ end: start + match[0].length,
236
+ path,
237
+ member,
238
+ });
239
+ }
240
+ return sheets;
241
+ }
242
+
243
+ /**
244
+ * @param {string} html
245
+ */
246
+ function headSpan(html) {
247
+ const explicit = /<head[\s>][\s\S]*?<\/head>/i.exec(html);
248
+ if (explicit && explicit.index !== undefined) {
249
+ return { start: explicit.index, end: explicit.index + explicit[0].length };
250
+ }
251
+ const bodyAt = html.search(/<body[\s>]/i);
252
+ return { start: 0, end: bodyAt >= 0 ? bodyAt : html.length };
253
+ }
254
+
255
+ /**
256
+ * @param {string} html
257
+ * @returns {[number, number][]}
258
+ */
259
+ function commentRanges(html) {
260
+ /** @type {[number, number][]} */
261
+ const ranges = [];
262
+ for (const match of html.matchAll(/<!--[\s\S]*?-->/g)) {
263
+ const start = match.index ?? 0;
264
+ ranges.push([start, start + match[0].length]);
265
+ }
266
+ return ranges;
267
+ }
268
+
269
+ /**
270
+ * @param {[number, number][]} ranges
271
+ * @param {number} index
272
+ */
273
+ function inside(ranges, index) {
274
+ return ranges.some(([start, end]) => index >= start && index < end);
275
+ }
276
+
277
+ /**
278
+ * @param {string} href
279
+ * @returns {string}
280
+ */
281
+ function pathnameOf(href) {
282
+ const bare = href.split(/[?#]/)[0] ?? "";
283
+ if (/^https?:/i.test(bare)) {
284
+ try {
285
+ return new URL(bare).pathname;
286
+ } catch {
287
+ return bare;
288
+ }
289
+ }
290
+ return bare;
291
+ }
292
+
293
+ /**
294
+ * @param {string} attrs
295
+ * @returns {Record<string, string>}
296
+ */
297
+ function parseTagAttrs(attrs) {
298
+ /** @type {Record<string, string>} */
299
+ const out = {};
300
+ for (const match of attrs.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
301
+ const name = match[1];
302
+ if (!name) continue;
303
+ out[name.toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? "";
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * @param {string} attrs
310
+ * @param {string} name
311
+ */
312
+ function hasFlag(attrs, name) {
313
+ return new RegExp(`(?:^|\\s)${name}(?=\\s|/|=|$)`, "i").test(attrs);
314
+ }
315
+
316
+ /**
317
+ * @param {string | undefined} rel
318
+ */
319
+ function relTokens(rel) {
320
+ return String(rel || "").trim().toLowerCase().split(/\s+/).filter(Boolean);
321
+ }