Files
hyperframes/packages/core/src/compiler/htmlDocument.ts
T
Miguel Ángel b6ff3ab745 fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query

Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.

It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.

Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.

Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.

Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.

* fix(cli): serve the runtime ahead of every author script

injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.

Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.

The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.

Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.

injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.

Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
2026-08-08 13:07:10 -07:00

230 lines
6.9 KiB
TypeScript

import { parseHTML } from "linkedom";
export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
const RUNTIME_SRC_MARKERS = [
"hyperframe.runtime.iife.js",
"hyperframes-runtime.modular.inline.js",
"hyperframe-runtime.modular-runtime.inline.js",
RUNTIME_BOOTSTRAP_ATTR,
];
const RUNTIME_INLINE_MARKERS = [
"__hyperframeRuntimeBootstrapped",
"__hyperframeRuntime",
"__hyperframeRuntimeTeardown",
"__HF_EXPORT_RENDER_SEEK_CONFIG",
"window.__player =",
];
const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [
/^window\.__playerReady\s*=\s*(?:true|false)\s*;?$/,
/^window\.__renderReady\s*=\s*(?:true|false)\s*;?$/,
];
/**
* Parse a full HTML document or wrap a fragment so linkedom consistently puts
* fragment content under document.body.
*/
export function parseHTMLContent(html: string): Document {
const trimmed = html.trimStart().toLowerCase();
if (trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")) {
return parseHTML(html).document;
}
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
}
export function stripEmbeddedRuntimeScripts(html: string): string {
if (!html) return html;
const loweredHtml = html.toLowerCase();
let output = "";
let cursor = 0;
while (cursor < html.length) {
const scriptStart = findScriptStart(loweredHtml, cursor);
if (scriptStart === -1) {
output += html.slice(cursor);
break;
}
output += html.slice(cursor, scriptStart);
const startTagEnd = findTagEnd(html, scriptStart + 1);
if (startTagEnd === -1) {
output += html.slice(scriptStart);
break;
}
const closeTagEnd = findScriptCloseTagEnd(loweredHtml, startTagEnd + 1);
const scriptEnd = closeTagEnd === -1 ? html.length : closeTagEnd;
const block = html.slice(scriptStart, scriptEnd);
if (!shouldStripRuntimeScriptBlock(block)) {
output += block;
}
cursor = scriptEnd;
}
return output;
}
function findScriptStart(loweredHtml: string, from: number): number {
let index = loweredHtml.indexOf("<script", from);
while (index !== -1) {
const next = loweredHtml[index + "<script".length] ?? "";
if (isTagBoundary(next)) return index;
index = loweredHtml.indexOf("<script", index + 1);
}
return -1;
}
function findTagEnd(html: string, from: number): number {
let quote: string | undefined;
for (let index = from; index < html.length; index += 1) {
const char = html[index];
if (quote) {
if (char === quote) quote = undefined;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === ">") return index;
}
return -1;
}
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
let index = loweredHtml.indexOf("</script", from);
while (index !== -1) {
const closeTagEnd = findScriptCloseTagBoundary(loweredHtml, index + "</script".length);
if (closeTagEnd !== -1) return closeTagEnd;
index = loweredHtml.indexOf("</script", index + 1);
}
return -1;
}
function findScriptCloseTagBoundary(loweredHtml: string, from: number): number {
let cursor = from;
while (cursor < loweredHtml.length && isHtmlWhitespace(loweredHtml[cursor] ?? "")) {
cursor += 1;
}
return loweredHtml[cursor] === ">" ? cursor + 1 : -1;
}
function shouldStripRuntimeScriptBlock(block: string): boolean {
const lowered = block.toLowerCase();
for (const marker of RUNTIME_SRC_MARKERS) {
if (lowered.includes(marker.toLowerCase())) return true;
}
for (const marker of RUNTIME_INLINE_MARKERS) {
if (block.includes(marker)) return true;
}
const scriptSource = getScriptSource(block).trim();
for (const pattern of SIMPLE_RUNTIME_FLAG_ASSIGNMENTS) {
if (pattern.test(scriptSource)) return true;
}
return false;
}
function getScriptSource(block: string): string {
const startTagEnd = findTagEnd(block, 1);
if (startTagEnd === -1) return "";
const loweredBlock = block.toLowerCase();
const closeTagStart = loweredBlock.lastIndexOf("</script");
const end = closeTagStart === -1 ? block.length : closeTagStart;
return block.slice(startTagEnd + 1, end);
}
function isTagBoundary(char: string): boolean {
return char === "" || char === ">" || char === "/" || isHtmlWhitespace(char);
}
function isHtmlWhitespace(char: string): boolean {
return char === " " || char === "\n" || char === "\t" || char === "\r" || char === "\f";
}
function escapeInlineScriptSource(source: string): string {
return escapeCaseInsensitiveToken(
escapeCaseInsensitiveToken(source, "</script", "<\\/script"),
"<!--",
"<\\!--",
);
}
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
const loweredSource = source.toLowerCase();
const loweredToken = token.toLowerCase();
let output = "";
let cursor = 0;
while (cursor < source.length) {
const tokenStart = loweredSource.indexOf(loweredToken, cursor);
if (tokenStart === -1) {
output += source.slice(cursor);
break;
}
output += source.slice(cursor, tokenStart) + replacement;
cursor = tokenStart + token.length;
}
return output;
}
function inlineScriptTags(scripts: readonly string[]): string {
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
}
/**
* Insert raw tag markup at the very start of `<head>`, ahead of every author
* script (inline or external). Falls back to just before `<body>`, then to the
* top of the document, for fragments that carry neither.
*/
export function injectTagsAtHeadStart(html: string, tags: string): string {
if (html.includes("<head")) {
return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${tags}`);
}
if (html.includes("<body")) {
return html.replace("<body", () => `${tags}\n<body`);
}
return `${tags}\n${html}`;
}
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
if (scripts.length === 0) return html;
return injectTagsAtHeadStart(html, inlineScriptTags(scripts));
}
export function injectScriptsIntoHtml(
html: string,
headScripts: readonly string[],
bodyScripts: readonly string[],
stripEmbeddedRuntime = true,
): string {
if (stripEmbeddedRuntime) {
html = stripEmbeddedRuntimeScripts(html);
}
if (headScripts.length > 0) {
const headTags = inlineScriptTags(headScripts);
if (html.includes("</head>")) {
// Function replacement avoids `$&` interpolation in runtime source.
html = html.replace("</head>", () => `${headTags}\n</head>`);
} else if (html.includes("<body")) {
html = html.replace("<body", () => `${headTags}\n<body`);
} else {
html = `${headTags}\n${html}`;
}
}
if (bodyScripts.length > 0) {
const bodyTags = inlineScriptTags(bodyScripts);
if (html.includes("</body>")) {
html = html.replace("</body>", () => `${bodyTags}\n</body>`);
} else {
html = `${html}\n${bodyTags}`;
}
}
return html;
}