Band coverage for never-visible occupying items, page-height trust guard, font materialization integrity
- Items never visible at the canonical viewport (carousel slides off-screen at every captured width) now fall through to per-viewport geometry bands when they occupy layout, instead of freezing canonical geometry - The explicit-height probe trust excludes body/html/main and any wrapper whose height matches the page scrollHeight - page-height nodes keep flowing, and the layout height gate stays meaningful - @font-face and style-rule url() harvesting resolves against the OWNING stylesheet's href (walking @import nesting), not document.baseURI; font bytes are magic-byte validated at the storage choke point (HTML bodies from catch-all 200 routes are rejected); font-face dedup prefers the variant whose source actually resolved, deterministically 415 tests pass (16 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1d68f16f96
commit
994d2bc21e
@@ -182,6 +182,28 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Container-magic check for accepted font bytes, mirroring `looksLikeVideoFile`. A router that
|
||||
* answers 200+HTML for an unknown `/media/*` path (common on SPA hosts) otherwise gets stored as
|
||||
* a `.woff2`; the browser rejects it as a font and the whole page falls through to a system
|
||||
* fallback. Accept only the real font-container signatures — woff2 (`wOF2`), woff (`wOFF`),
|
||||
* OpenType/CFF (`OTTO`), TrueType (`\x00\x01\x00\x00` or `true`/`ttcf`), and EOT (the version
|
||||
* header at bytes 8..11 is `\x01\x00\x01\x00`/`\x02\x00\x01\x00`, so key EOT off its unique
|
||||
* 0x504C signature at offset 34) — and explicitly reject an HTML/text body. */
|
||||
export function looksLikeFontFile(bytes: Buffer): boolean {
|
||||
if (bytes.length < 4) return false;
|
||||
const b0 = bytes[0]!, b1 = bytes[1]!, b2 = bytes[2]!, b3 = bytes[3]!;
|
||||
// A leading `<` (`<!DOCTYPE`, `<html`, `<?xml`, `<svg`) is never a binary font container.
|
||||
if (b0 === 0x3c) return false;
|
||||
const tag = bytes.subarray(0, 4).toString("latin1");
|
||||
if (tag === "wOF2" || tag === "wOFF") return true; // woff2 / woff
|
||||
if (tag === "OTTO" || tag === "true" || tag === "ttcf") return true; // CFF OpenType / TrueType / TrueType collection
|
||||
if (b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00) return true; // TrueType sfnt (\x00\x01\x00\x00)
|
||||
// EOT: a little-endian byte count precedes a version/flags header; its stable marker is the
|
||||
// 0x504C ("LP") magic at byte offset 34.
|
||||
if (bytes.length >= 36 && bytes[34] === 0x4c && bytes[35] === 0x50) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
|
||||
* raw attribute strings (null/undefined when absent, matching a missing attribute).
|
||||
@@ -1047,6 +1069,10 @@ export async function captureSite(opts: {
|
||||
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
|
||||
// asset surfaces in visual_assets_missing instead.
|
||||
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
||||
// Same guard for fonts: a 200+HTML body (SPA router answering an unknown /media/* path) must not
|
||||
// be stored as a .woff2. Left unstored, the face surfaces as failed, so the font graph can fall
|
||||
// back to a correctly-resolved duplicate url instead of shipping an impostor the browser rejects.
|
||||
if (type === "font" && !looksLikeFontFile(bytes)) return;
|
||||
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
||||
if (a.storedAs) return;
|
||||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
|
||||
|
||||
@@ -80,6 +80,10 @@ export type FontFace = {
|
||||
display?: string;
|
||||
unicodeRange?: string;
|
||||
stretch?: string;
|
||||
// Url of the stylesheet this face was declared in (undefined for an inline <style>, or for faces
|
||||
// parsed out-of-band where the base is already baked into `src`). The src descriptor's relative
|
||||
// url()s resolve against this, not the document — see readRules and parseSrcUrls.
|
||||
baseHref?: string;
|
||||
};
|
||||
|
||||
export type PageSnapshot = {
|
||||
@@ -600,17 +604,34 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
// dropped, collapsing the box in the clone.
|
||||
const explicitWidthSelectors: string[] = [];
|
||||
|
||||
const absUrl = (u: string): string => {
|
||||
try { return new URL(u, document.baseURI).href; } catch { return u; }
|
||||
const absUrl = (u: string, base?: string): string => {
|
||||
try { return new URL(u, base || document.baseURI).href; } catch { return u; }
|
||||
};
|
||||
|
||||
const harvestUrlsFromText = (text: string): void => {
|
||||
// A relative url() inside a stylesheet resolves against THAT STYLESHEET'S url, not the document —
|
||||
// `url("../media/x.woff2")` in `/a/b/css/sheet.css` points at `/a/b/media/x.woff2`, which is a
|
||||
// different file from the document-relative `/media/x.woff2`. Resolving font/image srcs against
|
||||
// `document.baseURI` fetches the wrong path (often the SPA router's 200+HTML shell), so a harvested
|
||||
// face src must carry its owning sheet's base. Walk up through `@import` nesting (parentStyleSheet
|
||||
// via ownerRule) to the nearest sheet that actually has an href; an inline `<style>` has none, so
|
||||
// it correctly falls back to the document base.
|
||||
const sheetBaseHref = (sheet: CSSStyleSheet | null | undefined): string | undefined => {
|
||||
let s: CSSStyleSheet | null | undefined = sheet;
|
||||
// Bound the walk defensively; @import chains are shallow in practice.
|
||||
for (let i = 0; s && i < 32; i++) {
|
||||
if (s.href) return s.href;
|
||||
s = s.ownerRule?.parentStyleSheet ?? null;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const harvestUrlsFromText = (text: string, base?: string): void => {
|
||||
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const raw = m[2];
|
||||
if (!raw || raw.startsWith("data:")) continue;
|
||||
cssUrlSet.add(absUrl(raw));
|
||||
cssUrlSet.add(absUrl(raw, base));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -663,7 +684,12 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
return false;
|
||||
};
|
||||
|
||||
const readRules = (rules: CSSRuleList): void => {
|
||||
// `base` is the url of the stylesheet these rules live in (undefined for an inline <style>, which
|
||||
// resolves against the document). It is threaded so every harvested url() — @font-face src and
|
||||
// ordinary style-rule url() alike — resolves relative to its OWNING sheet, not the page. The src
|
||||
// string is left verbatim on the FontFace (parseSrcUrls re-absolutizes it downstream against the
|
||||
// same base), but the base still drives the cssUrlSet entry that triggers the download.
|
||||
const readRules = (rules: CSSRuleList, base?: string): void => {
|
||||
for (const rule of Array.from(rules)) {
|
||||
const type = rule.constructor.name;
|
||||
if (type === "CSSFontFaceRule") {
|
||||
@@ -680,14 +706,15 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
display: s.getPropertyValue("font-display") || undefined,
|
||||
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
|
||||
stretch: s.getPropertyValue("font-stretch") || undefined,
|
||||
baseHref: base,
|
||||
});
|
||||
harvestUrlsFromText(src);
|
||||
harvestUrlsFromText(src, base);
|
||||
}
|
||||
} else if (type === "CSSKeyframesRule") {
|
||||
keyframes.push((rule as CSSKeyframesRule).cssText);
|
||||
} else if (type === "CSSStyleRule") {
|
||||
const r = rule as CSSStyleRule;
|
||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
|
||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText, base);
|
||||
if (r.selectorText && r.style &&
|
||||
(isExplicitHeight(r.style.getPropertyValue("height")) ||
|
||||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
||||
@@ -699,17 +726,20 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
explicitWidthSelectors.push(r.selectorText);
|
||||
}
|
||||
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
||||
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
||||
// Nested grouping rules live in the same sheet — keep the base.
|
||||
try { readRules((rule as CSSGroupingRule).cssRules, base); } catch { /* ignore */ }
|
||||
} else if (type === "CSSImportRule") {
|
||||
// The imported sheet is a separate file: its rules resolve against ITS url, falling back to
|
||||
// the importing sheet's base only if the imported sheet reports none.
|
||||
const imp = rule as CSSImportRule;
|
||||
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules); } catch { /* cross-origin */ }
|
||||
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules, sheetBaseHref(imp.styleSheet) ?? base); } catch { /* cross-origin */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
try {
|
||||
readRules(sheet.cssRules);
|
||||
readRules(sheet.cssRules, sheetBaseHref(sheet));
|
||||
} catch {
|
||||
// Cross-origin sheet — record its href so the capture layer can fetch the
|
||||
// raw text out-of-band and parse font-faces/urls from it.
|
||||
@@ -746,7 +776,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
|
||||
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
|
||||
for (const sheet of sheets) {
|
||||
try { readRules(sheet.cssRules); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
|
||||
try { readRules(sheet.cssRules, sheetBaseHref(sheet)); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user