|
| 1 | +import { parseHTML } from "linkedom"; |
| 2 | + |
| 3 | +export interface HtmlParityComposition { |
| 4 | + id: string; |
| 5 | + originalId: string | null; |
| 6 | + start: number; |
| 7 | + duration: number | null; |
| 8 | + trackIndex: number | null; |
| 9 | + width: number | null; |
| 10 | + height: number | null; |
| 11 | + variableValues: string | null; |
| 12 | +} |
| 13 | + |
| 14 | +export interface HtmlParityTimedElement { |
| 15 | + identity: string; |
| 16 | + start: number; |
| 17 | + duration: number | null; |
| 18 | + trackIndex: number | null; |
| 19 | +} |
| 20 | + |
| 21 | +export interface HtmlParityResource { |
| 22 | + identity: string; |
| 23 | + attribute: "src" | "href" | "poster"; |
| 24 | + locality: "local" | "remote"; |
| 25 | +} |
| 26 | + |
| 27 | +export interface CompiledHtmlParityContract { |
| 28 | + compositions: HtmlParityComposition[]; |
| 29 | + timedElements: HtmlParityTimedElement[]; |
| 30 | + authoredStyleSignatures: string[]; |
| 31 | + parityFontFamilies: string[]; |
| 32 | + resources: HtmlParityResource[]; |
| 33 | + runtimeBootstrap: boolean; |
| 34 | + variableBootstrap: boolean; |
| 35 | +} |
| 36 | + |
| 37 | +function finiteAttribute(element: Element, name: string): number | null { |
| 38 | + const raw = element.getAttribute(name); |
| 39 | + if (raw === null || raw.trim() === "") return null; |
| 40 | + const value = Number(raw); |
| 41 | + return Number.isFinite(value) ? value : null; |
| 42 | +} |
| 43 | + |
| 44 | +function timing(element: Element): { |
| 45 | + start: number; |
| 46 | + duration: number | null; |
| 47 | + trackIndex: number | null; |
| 48 | +} { |
| 49 | + const start = finiteAttribute(element, "data-start") ?? 0; |
| 50 | + const duration = |
| 51 | + finiteAttribute(element, "data-duration") ?? |
| 52 | + (() => { |
| 53 | + const end = finiteAttribute(element, "data-end"); |
| 54 | + return end === null ? null : Math.max(0, end - start); |
| 55 | + })(); |
| 56 | + return { |
| 57 | + start, |
| 58 | + duration, |
| 59 | + trackIndex: |
| 60 | + finiteAttribute(element, "data-track-index") ?? finiteAttribute(element, "data-layer"), |
| 61 | + }; |
| 62 | +} |
| 63 | + |
| 64 | +function normalizeJson(value: string | null): string | null { |
| 65 | + if (value === null) return null; |
| 66 | + try { |
| 67 | + return JSON.stringify(canonicalizeJson(JSON.parse(value) as unknown)); |
| 68 | + } catch { |
| 69 | + return value.trim(); |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +function canonicalizeJson(value: unknown): unknown { |
| 74 | + if (Array.isArray(value)) return value.map(canonicalizeJson); |
| 75 | + if (value === null || typeof value !== "object") return value; |
| 76 | + return Object.fromEntries( |
| 77 | + Object.entries(value) |
| 78 | + .sort(([left], [right]) => left.localeCompare(right)) |
| 79 | + .map(([key, nested]) => [key, canonicalizeJson(nested)]), |
| 80 | + ); |
| 81 | +} |
| 82 | + |
| 83 | +function styleSignatures(document: Document): string[] { |
| 84 | + return [...document.querySelectorAll("style")] |
| 85 | + .map((style) => style.textContent ?? "") |
| 86 | + .flatMap((css) => css.match(/[^{}]+\{[^{}]*--parity-contract\s*:[^{}]+\}/g) ?? []) |
| 87 | + .map((rule) => |
| 88 | + rule |
| 89 | + .replace(/\s+/g, " ") |
| 90 | + .replace(/\s*([:;{},])\s*/g, "$1") |
| 91 | + .trim(), |
| 92 | + ) |
| 93 | + .sort(); |
| 94 | +} |
| 95 | + |
| 96 | +function parityFontFamilies(html: string): string[] { |
| 97 | + const families = new Set<string>(); |
| 98 | + for (const match of html.matchAll(/font-family\s*:\s*([^;}]+)/gi)) { |
| 99 | + for (const family of match[1]!.split(",")) { |
| 100 | + const normalized = family.trim().replace(/^['"]|['"]$/g, ""); |
| 101 | + if (normalized.startsWith("Parity")) families.add(normalized); |
| 102 | + } |
| 103 | + } |
| 104 | + return [...families].sort(); |
| 105 | +} |
| 106 | + |
| 107 | +function resources(document: Document): HtmlParityResource[] { |
| 108 | + const resources: HtmlParityResource[] = []; |
| 109 | + for (const [index, element] of [ |
| 110 | + ...document.querySelectorAll("[src], [href], [poster]"), |
| 111 | + ].entries()) { |
| 112 | + for (const attribute of ["src", "href", "poster"] as const) { |
| 113 | + const value = element.getAttribute(attribute)?.trim(); |
| 114 | + if (!value) continue; |
| 115 | + if (element.tagName.toLowerCase() === "script" && /hyperframes|runtime/i.test(value)) |
| 116 | + continue; |
| 117 | + resources.push({ |
| 118 | + identity: |
| 119 | + element.getAttribute("data-hf-id") ?? |
| 120 | + element.getAttribute("id") ?? |
| 121 | + `${element.tagName.toLowerCase()}[${index}]`, |
| 122 | + attribute, |
| 123 | + locality: /^https?:\/\//i.test(value) ? "remote" : "local", |
| 124 | + }); |
| 125 | + } |
| 126 | + } |
| 127 | + return resources.sort((left, right) => |
| 128 | + `${left.identity}:${left.attribute}`.localeCompare(`${right.identity}:${right.attribute}`), |
| 129 | + ); |
| 130 | +} |
| 131 | + |
| 132 | +/** Extract the browser-visible contract shared by preview and render compilation. */ |
| 133 | +export function extractCompiledHtmlParityContract(html: string): CompiledHtmlParityContract { |
| 134 | + const { document } = parseHTML(html); |
| 135 | + const compositions = [...document.querySelectorAll("[data-composition-id]")].map((element) => { |
| 136 | + const resolved = timing(element); |
| 137 | + return { |
| 138 | + id: element.getAttribute("data-composition-id") ?? "", |
| 139 | + originalId: element.getAttribute("data-hf-original-composition-id"), |
| 140 | + ...resolved, |
| 141 | + width: finiteAttribute(element, "data-width"), |
| 142 | + height: finiteAttribute(element, "data-height"), |
| 143 | + variableValues: normalizeJson(element.getAttribute("data-variable-values")), |
| 144 | + }; |
| 145 | + }); |
| 146 | + const timedElements = [ |
| 147 | + ...document.querySelectorAll( |
| 148 | + "[data-start], [data-duration], [data-end], [data-track-index], [data-layer]", |
| 149 | + ), |
| 150 | + ] |
| 151 | + .filter((element) => !element.hasAttribute("data-composition-id")) |
| 152 | + .map((element, index) => ({ |
| 153 | + identity: |
| 154 | + element.getAttribute("data-hf-id") ?? |
| 155 | + element.getAttribute("id") ?? |
| 156 | + `${element.tagName.toLowerCase()}[${index}]`, |
| 157 | + ...timing(element), |
| 158 | + })); |
| 159 | + return { |
| 160 | + compositions, |
| 161 | + timedElements, |
| 162 | + authoredStyleSignatures: styleSignatures(document), |
| 163 | + parityFontFamilies: parityFontFamilies(html), |
| 164 | + resources: resources(document), |
| 165 | + runtimeBootstrap: /__hyperframes|data-hyperframes-(?:preview-)?runtime/i.test(html), |
| 166 | + variableBootstrap: /__hfVariables(?:ByComp)?/.test(html), |
| 167 | + }; |
| 168 | +} |
0 commit comments