mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio-server): inject page tags at the document's own head and body, not inside a script (#4484)
This commit is contained in:
@@ -17,6 +17,7 @@ import { transformSync } from "esbuild";
|
||||
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
import {
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
insertBeforeCloseTag,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
@@ -65,16 +66,8 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
|
||||
const inlinedRuntime = getHyperframeRuntimeScript();
|
||||
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
|
||||
}
|
||||
if (sanitized.includes("</head>")) {
|
||||
// Use a function replacer so `String.prototype.replace`'s substitution
|
||||
// patterns (`$&`, `$$`, `$'`, `` $` ``, `$1`–`$99`) inside the inlined
|
||||
// runtime IIFE are passed through verbatim. The minified runtime
|
||||
// contains the literal sequence `$&` as part of legitimate JS, and
|
||||
// the older `(pattern, string)` form would expand it to the matched
|
||||
// `</head>`, silently corrupting the runtime and breaking every
|
||||
// timeline in the bundle with a parse-time SyntaxError.
|
||||
return sanitized.replace("</head>", () => `${tag}\n</head>`);
|
||||
}
|
||||
const withHead = insertBeforeCloseTag(sanitized, "head", `${tag}\n`);
|
||||
if (withHead !== null) return withHead;
|
||||
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
|
||||
if (htmlOpenMatch?.index != null) {
|
||||
const insertPos = htmlOpenMatch.index + htmlOpenMatch[0].length;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
injectScriptsAtHeadStart,
|
||||
injectScriptsIntoHtml,
|
||||
injectTagsAtHeadStart,
|
||||
insertBeforeCloseTag,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument.js";
|
||||
@@ -80,4 +82,123 @@ describe("htmlDocument helpers", () => {
|
||||
expect(injected).toContain("<\\!-- kept as script text");
|
||||
expect(injected).not.toContain("</script ><script>window.pwned = true;");
|
||||
});
|
||||
|
||||
it("injects at the document's own </head> and </body>, not at those strings inside script, style or comment text", () => {
|
||||
const vendor = 'w.print("<head>"),w.print("</head>"),w.print("<body>"),w.print("</body>")';
|
||||
const html = `<html><head><script>${vendor}</script><style>a::after{content:"</head>"}</style><!-- </body> --></HEAD ><body><script>${vendor}</script></body></html>`;
|
||||
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], ["window.b = 1;"]);
|
||||
|
||||
expect(injected.split(`<script>${vendor}</script>`)).toHaveLength(3);
|
||||
expect(injected).toContain("<script>window.h = 1;</script>\n</HEAD >");
|
||||
expect(injected).toContain("<script>window.b = 1;</script>\n</body></html>");
|
||||
});
|
||||
|
||||
it("falls back to the document's own <body> when </head> is omitted", () => {
|
||||
const vendor = 'w.print("<head>"),w.print("<body>")';
|
||||
const html = `<html><head><script>${vendor}</script><body><p>x</p></body></html>`;
|
||||
const injected = injectScriptsIntoHtml(html, ["window.h = 1;"], []);
|
||||
|
||||
expect(injected).toContain(`<script>${vendor}</script><script>window.h = 1;</script>\n<body>`);
|
||||
});
|
||||
|
||||
it("injects at head start past a script that prints <head>, and before <body> without a head", () => {
|
||||
const vendor = 'w.print("<head>"),w.print("<body>")';
|
||||
const noHead = `<html><body><script>${vendor}</script></body></html>`;
|
||||
|
||||
expect(injectTagsAtHeadStart(noHead, "<meta x>")).toBe(`<html><meta x>\n${noHead.slice(6)}`);
|
||||
});
|
||||
|
||||
it("skips title text and an empty comment", () => {
|
||||
const html = "<html><head><title>Intro to <script></title><!--></head><body></body></html>";
|
||||
|
||||
expect(insertBeforeCloseTag(html, "head", "X")).toBe(
|
||||
"<html><head><title>Intro to <script></title><!-->X</head><body></body></html>",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps indexes right after a character that lowercases to two (İ)", () => {
|
||||
const page = "<html><head><title>İzmir</title></head><body><h1>İstanbul</h1></body></html>";
|
||||
const injected = injectScriptsIntoHtml(page, ["a=1"], ["b=2"]);
|
||||
expect(injected).toContain("<script>a=1</script>\n</head>");
|
||||
expect(injected).toContain("<script>b=2</script>\n</body></html>");
|
||||
|
||||
const stripped = stripEmbeddedRuntimeScripts(
|
||||
'<p>İİ</p><script src="hyperframe.runtime.iife.js"></script><p>kept</p>',
|
||||
);
|
||||
expect(stripped).toBe("<p>İİ</p><p>kept</p>");
|
||||
|
||||
const escaped = injectScriptsIntoHtml(page, ['x="İİ</SCRIPT>"'], []);
|
||||
expect(escaped).toContain('<script>x="İİ<\\/script>"</script>');
|
||||
});
|
||||
|
||||
it("skips a script tag written inside an attribute value", () => {
|
||||
const html =
|
||||
'<html><head><meta content="<script>"></head><body><script>a</script></body></html>';
|
||||
|
||||
expect(insertBeforeCloseTag(html, "head", "X")).toBe(
|
||||
'<html><head><meta content="<script>">X</head><body><script>a</script></body></html>',
|
||||
);
|
||||
});
|
||||
|
||||
it("reads past stray quotes, a self-closing SVG title and a look-alike close tag", () => {
|
||||
expect(insertBeforeCloseTag("<body><img alt=it's><p>hi</p></body>", "body", "X")).toBe(
|
||||
"<body><img alt=it's><p>hi</p>X</body>",
|
||||
);
|
||||
expect(insertBeforeCloseTag("<body><svg><title/></svg></body>", "body", "X")).toBe(
|
||||
"<body><svg><title/></svg>X</body>",
|
||||
);
|
||||
const lookAlike = '<head><script>x="</scripts>";y="</head>"</script></head><body></body>';
|
||||
expect(insertBeforeCloseTag(lookAlike, "head", "X")).toBe(
|
||||
'<head><script>x="</scripts>";y="</head>"</script>X</head><body></body>',
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a quote as a value only after =, like the browser", () => {
|
||||
const page = "<html><head><meta name=it's></head><body><p>don't</p></body></html>";
|
||||
expect(injectScriptsIntoHtml(page, ["H"], [])).toContain(
|
||||
"<meta name=it's><script>H</script>\n</head>",
|
||||
);
|
||||
|
||||
for (const meta of [
|
||||
'<meta content=a=" x>',
|
||||
'<meta b="c"="d>',
|
||||
'<meta ="x>',
|
||||
'<meta="b>',
|
||||
'<meta b/="x>',
|
||||
'</ a="x>',
|
||||
]) {
|
||||
const odd = `<html><head>${meta}</head><body class="y"></body></html>`;
|
||||
expect(insertBeforeCloseTag(odd, "head", "X")).toBe(odd.replace("</head>", "X</head>"));
|
||||
}
|
||||
|
||||
const headAttr = "<html><head data-x=it's></head><body>it's</body></html>";
|
||||
expect(injectTagsAtHeadStart(headAttr, "T")).toBe(
|
||||
"<html><head data-x=it's>\nT</head><body>it's</body></html>",
|
||||
);
|
||||
});
|
||||
|
||||
it("ends a comment at --!>, but not on its own opening dashes", () => {
|
||||
expect(insertBeforeCloseTag("<head><!-- a --!></head>", "head", "X")).toBe(
|
||||
"<head><!-- a --!>X</head>",
|
||||
);
|
||||
expect(insertBeforeCloseTag("<head><!--!></head>-->", "head", "X")).toBeNull();
|
||||
expect(insertBeforeCloseTag("<head><!---></head>", "head", "X")).toBe("<head><!--->X</head>");
|
||||
});
|
||||
|
||||
it("stays linear over many unclosed raw-text tags and an unfinished quoted attribute", () => {
|
||||
const many = `<body>${"<script/>".repeat(40_000)}</body>`;
|
||||
const unfinished = `<body>${'<p data-if="a>b" '.repeat(20_000)}`;
|
||||
const started = performance.now();
|
||||
expect(insertBeforeCloseTag(many, "body", "X")).toBe(many.replace("</body>", "X</body>"));
|
||||
expect(insertBeforeCloseTag(unfinished, "body", "X")).toBeNull();
|
||||
expect(performance.now() - started).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("finds no head end past an unclosed script", () => {
|
||||
expect(insertBeforeCloseTag("<html><head><script>a</head>", "head", "X")).toBeNull();
|
||||
});
|
||||
|
||||
it("finds no close tag in a fragment", () => {
|
||||
expect(insertBeforeCloseTag('<div><script>"</head>"</script></div>', "head", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,14 @@ export function parseHTMLContent(html: string): Document {
|
||||
return parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`).document;
|
||||
}
|
||||
|
||||
/** Lowercases A-Z only, so indexes found in the result are valid in the input ("İ" lowercases to two chars). */
|
||||
function lowerAscii(text: string): string {
|
||||
return text.replace(/[A-Z]+/g, (letters) => letters.toLowerCase());
|
||||
}
|
||||
|
||||
export function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const loweredHtml = html.toLowerCase();
|
||||
const loweredHtml = lowerAscii(html);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
@@ -76,23 +81,44 @@ function findScriptStart(loweredHtml: string, from: number): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
type TagState = "tagName" | "between" | "name" | "equals" | "value";
|
||||
|
||||
function findTagEnd(html: string, from: number): number {
|
||||
let quote: string | undefined;
|
||||
let state: TagState = "tagName";
|
||||
for (let index = from; index < html.length; index += 1) {
|
||||
const char = html[index];
|
||||
const char = html.charAt(index);
|
||||
if (quote) {
|
||||
if (char === quote) quote = undefined;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
quote = char === quote ? undefined : quote;
|
||||
continue;
|
||||
}
|
||||
if (char === ">") return index;
|
||||
if (state === "equals" && (char === '"' || char === "'")) {
|
||||
quote = char;
|
||||
state = "between";
|
||||
continue;
|
||||
}
|
||||
state = nextTagState(state, char, index === from);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function nextTagState(state: TagState, char: string, first: boolean): TagState {
|
||||
if (isHtmlWhitespace(char)) return stateAfterWhitespace(state);
|
||||
if (char === "/" && !first) return stateAfterSlash(state);
|
||||
if (char === "=" && state === "name") return "equals";
|
||||
if (state === "equals") return "value";
|
||||
return state === "between" ? "name" : state;
|
||||
}
|
||||
|
||||
function stateAfterWhitespace(state: TagState): TagState {
|
||||
return state === "tagName" || state === "value" ? "between" : state;
|
||||
}
|
||||
|
||||
function stateAfterSlash(state: TagState): TagState {
|
||||
return state === "equals" || state === "value" ? "value" : "between";
|
||||
}
|
||||
|
||||
function findScriptCloseTagEnd(loweredHtml: string, from: number): number {
|
||||
let index = loweredHtml.indexOf("</script", from);
|
||||
while (index !== -1) {
|
||||
@@ -129,7 +155,7 @@ function shouldStripRuntimeScriptBlock(block: string): boolean {
|
||||
function getScriptSource(block: string): string {
|
||||
const startTagEnd = findTagEnd(block, 1);
|
||||
if (startTagEnd === -1) return "";
|
||||
const loweredBlock = block.toLowerCase();
|
||||
const loweredBlock = lowerAscii(block);
|
||||
const closeTagStart = loweredBlock.lastIndexOf("</script");
|
||||
const end = closeTagStart === -1 ? block.length : closeTagStart;
|
||||
return block.slice(startTagEnd + 1, end);
|
||||
@@ -152,8 +178,8 @@ function escapeInlineScriptSource(source: string): string {
|
||||
}
|
||||
|
||||
function escapeCaseInsensitiveToken(source: string, token: string, replacement: string): string {
|
||||
const loweredSource = source.toLowerCase();
|
||||
const loweredToken = token.toLowerCase();
|
||||
const loweredSource = lowerAscii(source);
|
||||
const loweredToken = lowerAscii(token);
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
@@ -174,19 +200,98 @@ function inlineScriptTags(scripts: readonly string[]): string {
|
||||
return scripts.map((source) => `<script>${escapeInlineScriptSource(source)}</script>`).join("\n");
|
||||
}
|
||||
|
||||
const RAW_TEXT_TAGS = ["script", "style", "title", "textarea"] as const;
|
||||
|
||||
type DocumentTag = "<head" | "</head" | "<body" | "</body";
|
||||
const COMMENT_END = /--!?>/g;
|
||||
|
||||
function findDocumentTag(html: string, tag: DocumentTag): number {
|
||||
const lowered = lowerAscii(html);
|
||||
const unclosedRawText = new Set<string>();
|
||||
let cursor = 0;
|
||||
while (cursor !== -1) {
|
||||
const open = lowered.indexOf("<", cursor);
|
||||
if (open === -1) return -1;
|
||||
if (isTagAt(lowered, open, tag)) return open;
|
||||
cursor = skipMarkup(lowered, open, unclosedRawText);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isTagAt(lowered: string, at: number, token: string): boolean {
|
||||
return lowered.startsWith(token, at) && isTagBoundary(lowered.charAt(at + token.length));
|
||||
}
|
||||
|
||||
/** Cursor just past the markup that starts at `open`, or -1 when the document ends inside it. */
|
||||
function skipMarkup(lowered: string, open: number, unclosedRawText: Set<string>): number {
|
||||
if (lowered.startsWith("<!--", open)) return skipComment(lowered, open);
|
||||
const next = lowered.charAt(open + 1);
|
||||
if (next === "/" && !/[a-z]/.test(lowered.charAt(open + 2))) {
|
||||
const bogusEnd = lowered.indexOf(">", open);
|
||||
return bogusEnd === -1 ? -1 : bogusEnd + 1;
|
||||
}
|
||||
if (!/[a-z/]/.test(next)) return open + 1;
|
||||
const tagEnd = findTagEnd(lowered, open + 1);
|
||||
return tagEnd === -1 ? -1 : skipRawText(lowered, open, tagEnd, unclosedRawText);
|
||||
}
|
||||
|
||||
function skipComment(lowered: string, open: number): number {
|
||||
if (lowered.startsWith("<!-->", open) || lowered.startsWith("<!--->", open)) {
|
||||
return lowered.indexOf(">", open) + 1;
|
||||
}
|
||||
COMMENT_END.lastIndex = open + 4;
|
||||
const end = COMMENT_END.exec(lowered);
|
||||
return end ? end.index + end[0].length : -1;
|
||||
}
|
||||
|
||||
function skipRawText(
|
||||
lowered: string,
|
||||
open: number,
|
||||
tagEnd: number,
|
||||
unclosedRawText: Set<string>,
|
||||
): number {
|
||||
const rawText = RAW_TEXT_TAGS.find((name) => isTagAt(lowered, open, `<${name}`));
|
||||
if (!rawText) return tagEnd + 1;
|
||||
const close = unclosedRawText.has(rawText) ? -1 : findRawTextClose(lowered, rawText, tagEnd + 1);
|
||||
if (close !== -1) return close;
|
||||
unclosedRawText.add(rawText);
|
||||
return lowered.charAt(tagEnd - 1) === "/" ? tagEnd + 1 : -1;
|
||||
}
|
||||
|
||||
function findRawTextClose(lowered: string, name: string, from: number): number {
|
||||
const close = `</${name}`;
|
||||
for (let at = lowered.indexOf(close, from); at !== -1; at = lowered.indexOf(close, at + 1)) {
|
||||
if (isTagAt(lowered, at, close)) return at;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function insertBeforeDocumentTag(html: string, tag: DocumentTag, markup: string): string | null {
|
||||
const at = findDocumentTag(html, tag);
|
||||
return at === -1 ? null : html.slice(0, at) + markup + html.slice(at);
|
||||
}
|
||||
|
||||
/** Insert markup just before the document's own `</head>` or `</body>`; null when it has none. */
|
||||
export function insertBeforeCloseTag(
|
||||
html: string,
|
||||
name: "head" | "body",
|
||||
markup: string,
|
||||
): string | null {
|
||||
return insertBeforeDocumentTag(html, `</${name}`, markup);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
const headOpen = findDocumentTag(html, "<head");
|
||||
const headOpenEnd = headOpen === -1 ? -1 : findTagEnd(html, headOpen + 1);
|
||||
if (headOpenEnd !== -1) {
|
||||
return `${html.slice(0, headOpenEnd + 1)}\n${tags}${html.slice(headOpenEnd + 1)}`;
|
||||
}
|
||||
if (html.includes("<body")) {
|
||||
return html.replace("<body", () => `${tags}\n<body`);
|
||||
}
|
||||
return `${tags}\n${html}`;
|
||||
return insertBeforeDocumentTag(html, "<body", `${tags}\n`) ?? `${tags}\n${html}`;
|
||||
}
|
||||
|
||||
export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string {
|
||||
@@ -206,23 +311,14 @@ export function injectScriptsIntoHtml(
|
||||
|
||||
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}`;
|
||||
}
|
||||
const withHead = insertBeforeCloseTag(html, "head", `${headTags}\n`);
|
||||
html =
|
||||
withHead ?? insertBeforeDocumentTag(html, "<body", `${headTags}\n`) ?? `${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}`;
|
||||
}
|
||||
html = insertBeforeCloseTag(html, "body", `${bodyTags}\n`) ?? `${html}\n${bodyTags}`;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
@@ -52,6 +52,7 @@ export {
|
||||
injectScriptsAtHeadStart,
|
||||
injectTagsAtHeadStart,
|
||||
injectScriptsIntoHtml,
|
||||
insertBeforeCloseTag,
|
||||
parseHTMLContent,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
} from "./htmlDocument";
|
||||
|
||||
@@ -166,6 +166,14 @@ describe("injectSdkPositionEditsRenderScript", () => {
|
||||
expect(out).toContain("data-hf-edit-base-x");
|
||||
});
|
||||
|
||||
it("injects before the document's own </body>, not inside an inlined script that prints one", () => {
|
||||
const vendor = 'p.print("</body>")';
|
||||
const html = `<html><body><h1 data-hf-edit-base-x="0">Hi</h1><script>${vendor}</script></body></html>`;
|
||||
const out = injectSdkPositionEditsRenderScript(html);
|
||||
expect(out).toContain(`<script>${vendor}</script><script>`);
|
||||
expect(out.endsWith("</script></body></html>")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends the script when there is no </body> tag", () => {
|
||||
const out = injectSdkPositionEditsRenderScript('<div data-hf-edit-base-y="0"></div>');
|
||||
expect(out.startsWith('<div data-hf-edit-base-y="0"></div>')).toBe(true);
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
readDeclaredDefaults,
|
||||
parseHostVariableValues,
|
||||
inlineScriptRuns,
|
||||
insertBeforeCloseTag,
|
||||
} from "@hyperframes/core/compiler";
|
||||
import {
|
||||
checkSubCompositionUsability,
|
||||
@@ -125,9 +126,7 @@ export function injectSdkPositionEditsRenderScript(html: string): string {
|
||||
}
|
||||
const scriptBody = getPositionEditsRenderScript().replace(/<\/script/gi, "<\\/script");
|
||||
const script = `<script>${scriptBody}</script>`;
|
||||
const bodyClose = html.search(/<\/body\s*>/i);
|
||||
if (bodyClose < 0) return `${html}${script}`;
|
||||
return `${html.slice(0, bodyClose)}${script}${html.slice(bodyClose)}`;
|
||||
return insertBeforeCloseTag(html, "body", script) ?? `${html}${script}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2035,10 +2034,11 @@ export async function compileForRender(
|
||||
];
|
||||
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => assembledHtml.includes(attr));
|
||||
const htmlWithPositionScript = hasPositionEdits
|
||||
? assembledHtml.replace(
|
||||
/<\/body>/i,
|
||||
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
|
||||
)
|
||||
? (insertBeforeCloseTag(
|
||||
assembledHtml,
|
||||
"body",
|
||||
`<script>${createStudioPositionSeekReapplyScript()}</script>`,
|
||||
) ?? assembledHtml)
|
||||
: assembledHtml;
|
||||
const htmlWithSdkPositionScript = injectSdkPositionEditsRenderScript(htmlWithPositionScript);
|
||||
|
||||
|
||||
@@ -111,3 +111,21 @@ describe("injectMediaCodecMapIntoHtml pre-warm", () => {
|
||||
expect(resolveProxy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectMediaCodecMapIntoHtml placement", () => {
|
||||
it("puts the map before the document's own </head>, not inside an inlined script that prints one", async () => {
|
||||
const { injectMediaCodecMapIntoHtml } = await loadHelper({
|
||||
"/audio/seg01.webm": hostile("vp9", 'video/webm; codecs="vp09.00.10.08"'),
|
||||
});
|
||||
const vendor = 'p.print("<head>"),p.print("</head>"),p.print("<body>")';
|
||||
|
||||
const html = await injectMediaCodecMapIntoHtml(
|
||||
`<html><head><script>${vendor}</script></head><body></body></html>`,
|
||||
tmpProject(),
|
||||
[{ html: "<html></html>" }],
|
||||
);
|
||||
|
||||
expect(html).toContain(`<script>${vendor}</script>`);
|
||||
expect(html.indexOf("data-hf-media-codec-map")).toBeGreaterThan(html.indexOf(vendor));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolve } from "node:path";
|
||||
import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import {
|
||||
createMediaCodecProbeCache,
|
||||
@@ -66,14 +67,6 @@ export function proxyEtagSalt(raw: string | undefined): string {
|
||||
return `:proxy:${raw}:${PROXY_PARAMS_VERSION}`;
|
||||
}
|
||||
|
||||
// Mirrors `injectScriptTagIntoHead` in routes/preview.ts (kept local rather
|
||||
// than imported to avoid a helpers → routes dependency edge for one
|
||||
// two-line utility).
|
||||
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
|
||||
return `${scriptTag}\n${html}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects `window.__HF_MEDIA_CODEC_MAP__` (the U1 codec-facts scan) into
|
||||
* served composition HTML, and fire-and-forget pre-warms `resolveProxy` for
|
||||
@@ -130,7 +123,7 @@ export async function injectMediaCodecMapIntoHtml(
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
const tag = `<script data-hf-media-codec-map>window.__HF_MEDIA_CODEC_MAP__=${json};</script>`;
|
||||
return injectScriptTagIntoHead(html, tag);
|
||||
return insertBeforeCloseTag(html, "head", `${tag}\n`) ?? `${tag}\n${html}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
} from "../helpers/compositionInsertion.js";
|
||||
import { resolveGsapWriter } from "./gsapMutationCapabilities.js";
|
||||
import { requestSubPath } from "../helpers/requestSubPath.js";
|
||||
import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
|
||||
|
||||
// ── Server cutover flag ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1276,9 +1277,7 @@ async function prepareGsapMutationScript(
|
||||
`window.__timelines["${compId}"] = tl;`,
|
||||
"</script>",
|
||||
].join("\n");
|
||||
html = html.includes("</body>")
|
||||
? html.replace("</body>", `${bootstrap}\n</body>`)
|
||||
: `${html}\n${bootstrap}`;
|
||||
html = insertBeforeCloseTag(html, "body", `${bootstrap}\n`) ?? `${html}\n${bootstrap}`;
|
||||
block = extractGsapScriptBlock(html);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
injectScriptsIntoHtml,
|
||||
insertBeforeCloseTag,
|
||||
stripEmbeddedRuntimeScripts,
|
||||
type BundleOptions,
|
||||
} from "@hyperframes/core/compiler";
|
||||
@@ -62,8 +63,7 @@ function injectProjectSignature(html: string, signature: string): string {
|
||||
tag,
|
||||
);
|
||||
}
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${tag}\n</head>`);
|
||||
return `${tag}\n${html}`;
|
||||
return insertBeforeCloseTag(html, "head", `${tag}\n`) ?? `${tag}\n${html}`;
|
||||
}
|
||||
|
||||
function readStudioMotionManifestContent(projectDir: string): string {
|
||||
@@ -95,8 +95,7 @@ function parseStudioMotionManifestContent(content: string): {
|
||||
}
|
||||
|
||||
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
|
||||
return `${scriptTag}\n${html}`;
|
||||
return insertBeforeCloseTag(html, "head", `${scriptTag}\n`) ?? `${scriptTag}\n${html}`;
|
||||
}
|
||||
|
||||
function htmlHasGsap(html: string): boolean {
|
||||
@@ -371,9 +370,8 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
!bundled.includes("hyperframes-preview-runtime")
|
||||
) {
|
||||
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
|
||||
bundled = bundled.includes("</body>")
|
||||
? bundled.replace("</body>", `${runtimeTag}\n</body>`)
|
||||
: bundled + `\n${runtimeTag}`;
|
||||
bundled =
|
||||
insertBeforeCloseTag(bundled, "body", `${runtimeTag}\n`) ?? `${bundled}\n${runtimeTag}`;
|
||||
}
|
||||
|
||||
// Inject <base> for relative asset resolution
|
||||
|
||||
Reference in New Issue
Block a user