@civitai/app-sdk 0.56.1 → 0.58.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.
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Embedding one of your own bundled documents inside a block — the `srcdoc`
3
+ * pattern, and why a plain `<iframe src>` cannot work.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ===============
7
+ * Two platform policies combine so that a block can never frame its own
8
+ * bundled content by URL. Neither is a bug, and neither has a manifest-side
9
+ * workaround:
10
+ *
11
+ * 1. A block's own frame is sandboxed with the manifest's `iframe.sandbox`
12
+ * tokens intersected by a trust-tier allowlist. An unverified block keeps
13
+ * `allow-scripts allow-forms` and nothing else — `allow-same-origin` is
14
+ * refused at `civitai app validate` time ("forbidden outside the internal
15
+ * trust tier") and re-stripped host-side even if it reached the manifest.
16
+ * So the block document's origin is OPAQUE: `window.origin === "null"`.
17
+ *
18
+ * 2. Every path served from `https://<blockId>.civit.ai/` — `.html`, `.js`,
19
+ * `.wasm`, archive files, all of it — carries
20
+ * `Content-Security-Policy: frame-ancestors …` plus
21
+ * `X-Frame-Options: SAMEORIGIN`. Those headers are stamped at the platform
22
+ * edge, not by the block's own server, so a block cannot override them.
23
+ *
24
+ * `frame-ancestors` is checked against EVERY ancestor of the framed document.
25
+ * An opaque origin matches no source list — measured, including the wide-open
26
+ * `frame-ancestors *` — because CSP3 ASCII-serializes the ancestor origin and
27
+ * then URL-parses it, and the string `"null"` fails that parse, returning
28
+ * Blocked before any source matching happens. So widening the allowlist cannot
29
+ * fix this, and `X-Frame-Options: SAMEORIGIN` blocks it a second time on its
30
+ * own.
31
+ *
32
+ * 🔴 EXTERNAL PLATFORM MEASUREMENT — NOTHING IN THIS REPO VERIFIES THE MATRIX
33
+ * BELOW. It was taken on 2026-10-03 against a local two-origin Chromium replica
34
+ * of the platform's frame topology (sandbox `allow-scripts allow-forms`, nested
35
+ * document framed from the opaque-origin parent). That replica is deliberately
36
+ * NOT committed here — it would be a second, drifting model of a platform this
37
+ * package does not own — so no test in this package exercises any row, and a
38
+ * green suite says nothing about them. Treat the table as a dated observation of
39
+ * the platform, not as a pinned property of this code: RE-MEASURE IT if the
40
+ * platform changes its sandbox trust tiers or the response headers its static
41
+ * host stamps.
42
+ *
43
+ * headers on the nested document result
44
+ * ---------------------------------------- ---------
45
+ * (none) ← positive control LOADED
46
+ * frame-ancestors * BLOCKED
47
+ * frame-ancestors <app-host> <top-host> BLOCKED
48
+ * frame-ancestors <unrelated> ← control BLOCKED
49
+ * X-Frame-Options: SAMEORIGIN only BLOCKED
50
+ *
51
+ * Within that replica the positive control is what makes the four BLOCKEDs mean
52
+ * something: the harness CAN load a nested document, so the blocks are the
53
+ * headers and not the replica. That is a claim about the replica's internal
54
+ * consistency; it is not a claim that the replica matches production.
55
+ *
56
+ * WHAT THIS MODULE DOES
57
+ * =====================
58
+ * The one thing that works: fetch the nested document's HTML (the static host
59
+ * sends `Access-Control-Allow-Origin: *`, so a null-origin `fetch` succeeds
60
+ * where a frame load does not), rewrite its base URL to the absolute location
61
+ * it was fetched from, and hand the string to an iframe's `srcdoc`. A `srcdoc`
62
+ * frame inherits the parent's opaque origin and carries NO response headers of
63
+ * its own, so neither policy applies to it.
64
+ *
65
+ * 🔴 PREFER A SINGLE-DOCUMENT DESIGN. An engine build (Defold, Unity, Phaser,
66
+ * …) is a `<canvas>` plus a JS loader and normally mounts straight into the
67
+ * block's own document — no nested frame, no `<base>` rewrite, none of this
68
+ * module. Reach for the helper only when a separate document is genuinely
69
+ * required (a third-party viewer that insists on owning `document`, a build you
70
+ * cannot alter).
71
+ *
72
+ * 🔴 THE RETURNED STRING IS NOT SANITIZED, ON PURPOSE. It is your own bundled
73
+ * markup and scripts; stripping them would remove the thing the nested document
74
+ * exists to run. The consequence is the contract: pass a `src` you control.
75
+ * `fetchNestedDocument` is not a way to inline a document from somewhere else,
76
+ * and the returned string carries whatever the fetched bytes said. Put your own
77
+ * `sandbox` attribute on the iframe you hand it to.
78
+ *
79
+ * 🔴 KNOWN LIMITS. {@link injectBaseHref} is a SINGLE-PASS TOKENIZER, not an
80
+ * HTML parse (see `scanDocument`): it walks the document left to right once,
81
+ * tracking comments, `<script>`/`<style>` raw-text bodies and quoted attribute
82
+ * values, and locates the opening `<head>` and the first base-setting `<base>`
83
+ * only in real markup. It builds no tree, so it does not know element nesting,
84
+ * implied tags, or which `<head>` a stray tag would really have landed in.
85
+ * The cases it still gets wrong, none of which it previously got right either:
86
+ *
87
+ * - `<title>` and `<textarea>` hold ESCAPABLE raw text, which this scanner
88
+ * does not model — a literal `<base href=…>` written inside one is read as
89
+ * markup. (`<script>`/`<style>`, the two bodies a build tool actually emits
90
+ * markup-shaped strings into, ARE skipped.)
91
+ * - A `<base>` inside a conditional comment, a `<template>`, or markup a
92
+ * script writes at runtime is invisible or treated at face value, because
93
+ * there is no DOM here to ask.
94
+ * - It does nothing about assets a script resolves by hand from `location.*`:
95
+ * `<base>` moves `document.baseURI` and relative URLs in markup, not a
96
+ * string a loader built from `location.pathname`.
97
+ *
98
+ * A real parse would mean a DOM dependency on a zero-dependency, worker-safe
99
+ * surface, which is the trade this scanner exists to avoid.
100
+ */
101
+ /**
102
+ * Thrown by {@link fetchNestedDocument} and {@link injectBaseHref}. Carries a
103
+ * {@link NestedDocumentErrorCode} so a caller can tell "you gave me a bad URL"
104
+ * from "the host said 404" from "the network refused", which are three
105
+ * different things to show a user.
106
+ */
107
+ export class NestedDocumentError extends Error {
108
+ name = 'NestedDocumentError';
109
+ /** Machine-readable reason. */
110
+ code;
111
+ /** The absolute URL involved, when one was resolved before the failure. */
112
+ url;
113
+ /** HTTP status, set only for `code: 'http-error'`. */
114
+ status;
115
+ constructor(message, options) {
116
+ super(message, { cause: options.cause });
117
+ this.code = options.code;
118
+ this.url = options.url;
119
+ this.status = options.status;
120
+ }
121
+ }
122
+ /**
123
+ * Give a document an absolute `<base href>` so its relative asset URLs still
124
+ * resolve once it is running from a `srcdoc` frame.
125
+ *
126
+ * This is the whole mechanism, and the reason it is a separate function with
127
+ * tests of its own: a `srcdoc` document's own base URL is `about:srcdoc`, which
128
+ * inherits the embedder's — an opaque origin — so every `src="loader.js"` and
129
+ * `url(font.woff2)` in the fetched markup resolves to nothing. A wrong or
130
+ * missing `<base>` breaks every relative load in the document **with no error
131
+ * naming the base**, which is why it is pinned by its own test.
132
+ *
133
+ * 🔴 MODULE-INTERNAL — NOT part of the public `./blocks` surface. It is exported
134
+ * from this FILE so the unit suite can import it by path; `index.ts`
135
+ * deliberately does not re-export it, because nothing in or out of the tree
136
+ * calls the rewrite on its own and an export is far cheaper to add later than to
137
+ * remove once published. Same for {@link InjectBaseHrefResult}. See the note
138
+ * beside the `./nestedDocument.js` re-export in `index.ts`.
139
+ *
140
+ * ONE RULE, in both directions:
141
+ *
142
+ * - no `<base href>` in the input → the base becomes the DIRECTORY of
143
+ * `documentUrl` (`…/game/index.html` → `…/game/`), which is what the
144
+ * document's relative URLs were written against.
145
+ * - a `<base href>` already present → it is RESOLVED against `documentUrl`
146
+ * and REPLACED by the absolute result. The author's intent is kept; only
147
+ * its form changes. A root-relative `<base href="/">`, which engine builds
148
+ * ship routinely, would otherwise resolve against the opaque origin and
149
+ * take every asset with it.
150
+ *
151
+ * Insertion point, in order: replacing the existing `<base href>`; immediately
152
+ * after the opening `<head>` tag; immediately after a leading `<!doctype>`;
153
+ * otherwise at the very start. A `<base>` in any of those positions is parsed
154
+ * into the head and precedes every asset reference, which is the property that
155
+ * matters.
156
+ *
157
+ * A `<base>` that declares only `target` and no `href` is left alone — per the
158
+ * HTML spec it sets no document base URL — and the new absolute `<base href>` is
159
+ * inserted as if there were none, so the output then holds two `<base>` tags.
160
+ * See {@link InjectBaseHrefResult.html} for what is and is not guaranteed about
161
+ * the count.
162
+ *
163
+ * 🔴 BUT `href=""` IS AN `href`, so it is base-setting and is REWRITTEN. The
164
+ * spec keys on the `href` CONTENT ATTRIBUTE being present, not on its value
165
+ * resolving to something new: `""` parses successfully against the document's
166
+ * own URL, so `<base href="">` sets the base to `documentUrl` itself (not its
167
+ * directory), and a whitespace-only value does the same because the URL parser
168
+ * strips leading and trailing spaces. The distinction is load-bearing, not
169
+ * pedantic — see the comment at the `wantHref` test in `scanDocument` for what
170
+ * treating it as absent did to a document declaring `<base href=""><base
171
+ * href="/real/">`.
172
+ *
173
+ * Only REAL MARKUP is considered: a `<base href>` or a `<head>` written inside
174
+ * an HTML comment, or inside a `<script>`/`<style>` body, is ignored, and tag
175
+ * names match case-insensitively. A quoted attribute value may contain `>`.
176
+ *
177
+ * @param html The document's markup, as fetched.
178
+ * @param documentUrl The absolute URL `html` was fetched from.
179
+ * @throws {NestedDocumentError} `code: 'invalid-url'` if `documentUrl` is not absolute.
180
+ */
181
+ export function injectBaseHref(html, documentUrl) {
182
+ let docUrl;
183
+ try {
184
+ docUrl = new URL(documentUrl);
185
+ }
186
+ catch (cause) {
187
+ throw new NestedDocumentError(`injectBaseHref: documentUrl is not an absolute URL: ${documentUrl}`, { code: 'invalid-url', cause });
188
+ }
189
+ const scan = scanDocument(html);
190
+ const existing = scan.base;
191
+ let baseHref;
192
+ try {
193
+ baseHref =
194
+ existing === undefined ? new URL('.', docUrl).href : new URL(existing.href, docUrl).href;
195
+ }
196
+ catch (cause) {
197
+ throw new NestedDocumentError(`injectBaseHref: the document's own <base href="${existing?.href ?? ''}"> does not resolve ` +
198
+ `against ${docUrl.href}`, { code: 'invalid-url', url: docUrl.href, cause });
199
+ }
200
+ const tag = `<base href="${escapeAttr(baseHref)}">`;
201
+ if (existing !== undefined) {
202
+ const before = html.slice(0, existing.index);
203
+ const after = html.slice(existing.index + existing.length);
204
+ return { html: `${before}${tag}${after}`, baseHref, hadExistingBase: true };
205
+ }
206
+ return { html: insertInto(html, tag, scan), baseHref, hadExistingBase: false };
207
+ }
208
+ /**
209
+ * Fetch one of your own bundled documents and return it as a `srcdoc` string
210
+ * with an absolute `<base href>` injected.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * const { srcDoc } = await fetchNestedDocument({ src: '/game/index.html' });
215
+ * iframe.setAttribute('sandbox', 'allow-scripts');
216
+ * iframe.srcdoc = srcDoc;
217
+ * ```
218
+ *
219
+ * @throws {NestedDocumentError} `'invalid-url'`, `'http-error'` (with `.status`)
220
+ * or `'network-error'`. An abort via `options.signal` rejects with the
221
+ * platform's own `AbortError` instead, so `signal.aborted` stays the way to tell
222
+ * a cancellation from a failure.
223
+ */
224
+ export async function fetchNestedDocument(options) {
225
+ const base = options.baseUrl ?? currentDocumentBaseUrl();
226
+ let url;
227
+ try {
228
+ url = new URL(options.src, base).href;
229
+ }
230
+ catch (cause) {
231
+ throw new NestedDocumentError(`fetchNestedDocument: cannot resolve src "${options.src}"` +
232
+ (base === undefined
233
+ ? ' — it is relative and this runtime has no document base URL. Pass an absolute src, ' +
234
+ 'or options.baseUrl.'
235
+ : ` against base "${base}".`), { code: 'invalid-url', cause });
236
+ }
237
+ const doFetch = globalThis.fetch;
238
+ if (typeof doFetch !== 'function') {
239
+ throw new NestedDocumentError('fetchNestedDocument: this runtime has no global fetch.', { code: 'network-error', url });
240
+ }
241
+ let response;
242
+ try {
243
+ response = await doFetch(url, { signal: options.signal });
244
+ }
245
+ catch (cause) {
246
+ // An abort is the caller's own doing, not a failure to report as one: pass
247
+ // the platform AbortError straight through so `signal.aborted` keeps
248
+ // meaning what it means.
249
+ if (options.signal?.aborted)
250
+ throw cause;
251
+ throw new NestedDocumentError(`fetchNestedDocument: request to ${url} failed. A block runs at an opaque origin, so the ` +
252
+ 'host must answer with Access-Control-Allow-Origin.', { code: 'network-error', url, cause });
253
+ }
254
+ if (!response.ok) {
255
+ throw new NestedDocumentError(`fetchNestedDocument: ${url} returned HTTP ${response.status}.`, { code: 'http-error', url, status: response.status });
256
+ }
257
+ let html;
258
+ try {
259
+ html = await response.text();
260
+ }
261
+ catch (cause) {
262
+ if (options.signal?.aborted)
263
+ throw cause;
264
+ throw new NestedDocumentError(`fetchNestedDocument: reading the body of ${url} failed.`, { code: 'network-error', url, cause });
265
+ }
266
+ // 🔴 THE BASE IS RESOLVED AGAINST THE RESPONSE'S FINAL URL, NOT THE REQUESTED
267
+ // ONE. `fetch` follows redirects by default, and a `src` of `/game` answered
268
+ // with a 301 to `/game/index.html` lands the document one directory deeper
269
+ // than the request names. Basing on the requested URL then resolves every
270
+ // relative asset one directory TOO HIGH — silently, because the only symptom
271
+ // is a pile of 404s with nothing naming the base. `Response.url` is the
272
+ // serialized final URL and is what `document.baseURI` would have been had the
273
+ // document loaded normally. A `Response` stand-in without one (a stub, an
274
+ // exotic runtime) falls back to the requested URL, i.e. the previous
275
+ // behaviour.
276
+ const responseUrl = response.url;
277
+ const documentUrl = typeof responseUrl === 'string' && responseUrl !== '' ? responseUrl : url;
278
+ const { html: srcDoc, baseHref, hadExistingBase } = injectBaseHref(html, documentUrl);
279
+ return { srcDoc, url: documentUrl, baseHref, hadExistingBase };
280
+ }
281
+ /**
282
+ * Elements whose content is RAW TEXT: `<` inside them starts no tag, so markup
283
+ * written in a JS string literal or a CSS value is content, not structure.
284
+ *
285
+ * `title`/`textarea` hold *escapable* raw text and are deliberately NOT here —
286
+ * modelling them needs entity handling this scanner does not do, and neither is
287
+ * a place a build tool emits markup-shaped strings. Stated as a known limit in
288
+ * the module header.
289
+ */
290
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style']);
291
+ /** The five characters HTML counts as whitespace. */
292
+ function isHtmlSpace(ch) {
293
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f';
294
+ }
295
+ /** A tag name must START with an ASCII letter, or the `<` is literal text. */
296
+ function isAsciiAlpha(ch) {
297
+ if (ch === undefined)
298
+ return false;
299
+ const c = ch.charCodeAt(0);
300
+ return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
301
+ }
302
+ /**
303
+ * ONE LEFT-TO-RIGHT PASS over the document, locating the opening `<head>`, a
304
+ * leading doctype and the first base-setting `<base>` — in REAL MARKUP only.
305
+ *
306
+ * 🔴 WHY THIS IS A SCANNER AND NOT TWO REGEXES, measured. The regex version
307
+ * found a `<base href>` or a `<head>` wherever the characters appeared, which is
308
+ * not where the parser finds them, and three of those disagreements were
309
+ * actively harmful rather than merely over-eager:
310
+ *
311
+ * - a `<base href>` inside an HTML COMMENT was "found", and the replacement
312
+ * was spliced INSIDE the comment — leaving the document with ZERO base
313
+ * elements, where ignoring the comment inserts after `<head>` and works.
314
+ * Detection turned a working document into a broken one.
315
+ * - a `<base` inside an inline SCRIPT STRING was rewritten, corrupting the JS
316
+ * (`var s = "<base href="https://…">"`) so the script did not run at all.
317
+ * - a `<base>` whose attribute value contained `>` (`<base data-x="a>b"
318
+ * href="/pkg/">`) was not recognised as having an `href`, so the author's
319
+ * declared base was silently discarded.
320
+ *
321
+ * 🔴 AND IT IS LINEAR, WHICH THE REGEX VERSION WAS NOT — including the
322
+ * "two linear regexes" revision that answered CodeQL's `js/polynomial-redos`
323
+ * alert. `/<base\b[^>]*>/g` still retries `[^>]*` from every `<base` start
324
+ * position when the document holds a long run with no `>`, so a document of
325
+ * `'<base '.repeat(n)` cost quadratic time: 4x per doubling. Fixing the
326
+ * ALERT is not the same as fixing the ASYMPTOTICS.
327
+ * Every loop below advances its cursor by at least one character per iteration
328
+ * and never restarts, and the only allocations are bounded-length tag/attribute
329
+ * names — so the whole pass is O(document length). Pinned by the pathological
330
+ * input guard in `test/blocks/nestedDocument.test.ts`.
331
+ */
332
+ function scanDocument(html) {
333
+ const n = html.length;
334
+ let headEndsAt;
335
+ let doctypeEndsAt;
336
+ let base;
337
+ // A doctype only counts when it is the document's FIRST node. Tracked as a
338
+ // flag rather than re-inspecting the prefix, which would itself be quadratic.
339
+ let atFirstNode = true;
340
+ let i = 0;
341
+ while (i < n) {
342
+ const lt = html.indexOf('<', i);
343
+ if (lt < 0)
344
+ break;
345
+ // Text before this `<`. Non-whitespace text is a node, so a doctype after
346
+ // it is not leading. Cheap: the scan stops at the first such character and
347
+ // the flag is never re-raised.
348
+ if (atFirstNode) {
349
+ for (let p = i; p < lt; p++) {
350
+ if (!isHtmlSpace(html[p])) {
351
+ atFirstNode = false;
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ // ── A comment. Everything inside it is invisible. ──────────────────────
357
+ if (html.startsWith('<!--', lt)) {
358
+ const end = html.indexOf('-->', lt + 4);
359
+ i = end < 0 ? n : end + 3;
360
+ atFirstNode = false;
361
+ continue;
362
+ }
363
+ // ── A markup declaration (`<!doctype …>`) or a bogus comment (`<?…>`). ──
364
+ if (html[lt + 1] === '!' || html[lt + 1] === '?') {
365
+ const gt = html.indexOf('>', lt + 2);
366
+ const end = gt < 0 ? n : gt + 1;
367
+ if (atFirstNode &&
368
+ doctypeEndsAt === undefined &&
369
+ html.slice(lt + 2, lt + 9).toLowerCase() === 'doctype' &&
370
+ !isAsciiAlpha(html[lt + 9])) {
371
+ doctypeEndsAt = end;
372
+ }
373
+ i = end;
374
+ atFirstNode = false;
375
+ continue;
376
+ }
377
+ // ── A start or end tag. ────────────────────────────────────────────────
378
+ let j = lt + 1;
379
+ const closing = html[j] === '/';
380
+ if (closing)
381
+ j++;
382
+ if (!isAsciiAlpha(html[j])) {
383
+ // Not a tag: the `<` is literal text content.
384
+ i = lt + 1;
385
+ atFirstNode = false;
386
+ continue;
387
+ }
388
+ const nameStart = j;
389
+ while (j < n && !isHtmlSpace(html[j]) && html[j] !== '/' && html[j] !== '>')
390
+ j++;
391
+ // Lowercased so `<HEAD`, `<Base` and `<BASE` all match, while `<header`
392
+ // and `<basefont` do not — the tag name is compared WHOLE, which is what
393
+ // the old `\b` in the pattern was standing in for.
394
+ const name = html.slice(nameStart, j).toLowerCase();
395
+ const wantHref = !closing && name === 'base' && base === undefined;
396
+ let href;
397
+ // Walk the attributes to find where this tag really ends. A QUOTED VALUE
398
+ // MAY CONTAIN `>` — which is the whole reason this cannot be `indexOf('>')`.
399
+ let k = j;
400
+ while (k < n && html[k] !== '>') {
401
+ if (isHtmlSpace(html[k]) || html[k] === '/') {
402
+ k++;
403
+ continue;
404
+ }
405
+ const attrNameStart = k;
406
+ while (k < n &&
407
+ !isHtmlSpace(html[k]) &&
408
+ html[k] !== '/' &&
409
+ html[k] !== '>' &&
410
+ html[k] !== '=') {
411
+ k++;
412
+ }
413
+ if (k === attrNameStart) {
414
+ // A stray `=` with no name before it. Consume it so the loop advances.
415
+ k++;
416
+ continue;
417
+ }
418
+ const attrName = html.slice(attrNameStart, k).toLowerCase();
419
+ let p = k;
420
+ while (p < n && isHtmlSpace(html[p]))
421
+ p++;
422
+ let value = '';
423
+ if (html[p] === '=') {
424
+ p++;
425
+ while (p < n && isHtmlSpace(html[p]))
426
+ p++;
427
+ const quote = html[p];
428
+ if (quote === '"' || quote === "'") {
429
+ p++;
430
+ const from = p;
431
+ while (p < n && html[p] !== quote)
432
+ p++;
433
+ value = html.slice(from, p);
434
+ if (p < n)
435
+ p++; // past the closing quote
436
+ }
437
+ else {
438
+ const from = p;
439
+ while (p < n && !isHtmlSpace(html[p]) && html[p] !== '>')
440
+ p++;
441
+ value = html.slice(from, p);
442
+ }
443
+ k = p;
444
+ }
445
+ // The FIRST `href` wins, which is what an HTML parser does with a
446
+ // duplicate attribute.
447
+ if (wantHref && href === undefined && attrName === 'href')
448
+ href = value;
449
+ }
450
+ const tagEnd = k < n ? k + 1 : n;
451
+ if (!closing && name === 'head' && headEndsAt === undefined)
452
+ headEndsAt = tagEnd;
453
+ // 🔴 AN EMPTY `href` IS BASE-SETTING — this line read `href !== ''` and
454
+ // that was wrong. Per HTML the document base URL comes from the first
455
+ // `<base>` that HAS an `href` CONTENT ATTRIBUTE, and `""` parses
456
+ // successfully against the document's own URL, so `<base href="">` sets
457
+ // the base to the document URL rather than setting none. Reading it as
458
+ // absent is harmful rather than merely imprecise: for `<base href=""><base
459
+ // href="/real/">` the scan skipped past the empty tag to the second one, so
460
+ // the helper rewrote the DECOY and left the empty tag first — and the first
461
+ // base-setting tag is the one the parser honours, so `document.baseURI`
462
+ // becomes the EMBEDDER's URL, the opaque-origin failure this whole module
463
+ // exists to prevent, with a `baseHref` the caller is told is in use when it
464
+ // is not. Measured in `useNestedDocument.browser.test.tsx`. A whitespace-only
465
+ // `href` is the same case for the same reason: the URL parser strips
466
+ // leading and trailing spaces, so `href=" "` also resolves to the
467
+ // document URL. Only a `<base>` with NO `href` attribute at all sets no
468
+ // base URL, and that case is `href === undefined` here.
469
+ if (wantHref && href !== undefined) {
470
+ base = { index: lt, length: tagEnd - lt, href };
471
+ }
472
+ atFirstNode = false;
473
+ i = tagEnd;
474
+ // A raw-text element's body is content, not markup. Resume at its end tag
475
+ // so the end tag itself is still tokenized normally.
476
+ if (!closing && RAW_TEXT_ELEMENTS.has(name)) {
477
+ i = findRawTextEnd(html, name, tagEnd);
478
+ }
479
+ }
480
+ return { headEndsAt, doctypeEndsAt, base };
481
+ }
482
+ /**
483
+ * Index of the `<` that opens `</name…>`, or the end of the document if the
484
+ * element is never closed — which is also what a real parser does with an
485
+ * unterminated `<script>`.
486
+ */
487
+ function findRawTextEnd(html, name, from) {
488
+ const needle = `</${name}`;
489
+ let at = from;
490
+ while (at < html.length) {
491
+ const lt = html.indexOf('<', at);
492
+ if (lt < 0)
493
+ return html.length;
494
+ // `slice` of a bounded length, so this allocation is O(1) per `<`.
495
+ if (html.slice(lt, lt + needle.length).toLowerCase() === needle) {
496
+ const after = html[lt + needle.length];
497
+ if (after === undefined || isHtmlSpace(after) || after === '>' || after === '/')
498
+ return lt;
499
+ }
500
+ at = lt + 1;
501
+ }
502
+ return html.length;
503
+ }
504
+ /**
505
+ * Escape a value for a double-quoted HTML attribute. `URL#href` percent-encodes
506
+ * `"`, `<` and `>` already, so `&` is the character that actually occurs here —
507
+ * the other three are covered because this must not depend on that.
508
+ */
509
+ function escapeAttr(value) {
510
+ return value
511
+ .replace(/&/g, '&amp;')
512
+ .replace(/"/g, '&quot;')
513
+ .replace(/</g, '&lt;')
514
+ .replace(/>/g, '&gt;');
515
+ }
516
+ /** Insert `tag` after the opening `<head>`, else after a leading doctype, else at the start. */
517
+ function insertInto(html, tag, scan) {
518
+ const at = scan.headEndsAt ?? scan.doctypeEndsAt;
519
+ if (at === undefined)
520
+ return `${tag}${html}`;
521
+ return `${html.slice(0, at)}${tag}${html.slice(at)}`;
522
+ }
523
+ /**
524
+ * The current document's base URL, or `undefined` in a runtime without one
525
+ * (Node, a worker, SSR). `document.baseURI` is the right reading rather than
526
+ * `location.href`: it already accounts for a `<base>` the block itself declares.
527
+ */
528
+ function currentDocumentBaseUrl() {
529
+ const scope = globalThis;
530
+ const fromDocument = scope.document?.baseURI;
531
+ if (typeof fromDocument === 'string' && fromDocument !== '')
532
+ return fromDocument;
533
+ const fromLocation = scope.location?.href;
534
+ if (typeof fromLocation === 'string' && fromLocation !== '')
535
+ return fromLocation;
536
+ return undefined;
537
+ }
538
+ //# sourceMappingURL=nestedDocument.js.map