From d475c8306d4d23ffcf16c69b53e68d90c01914a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Jul 2026 04:46:24 +0000 Subject: [PATCH] fix(render): normalize external SVG namespaces --- .../core/src/compiler/htmlBundler.test.ts | 23 +++++++++++++++++++ packages/core/src/compiler/htmlBundler.ts | 5 +++- packages/core/src/compiler/index.ts | 1 + packages/core/src/compiler/svgNamespace.ts | 8 +++++++ .../producer/src/services/fileServer.test.ts | 22 ++++++++++++++++++ packages/producer/src/services/fileServer.ts | 17 +++++++++++++- 6 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/compiler/svgNamespace.ts diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index b820d536fa..d76754e262 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -95,6 +95,29 @@ describe("bundleToSingleHtml", () => { expect(bundled).not.toContain("./bg.svg"); }); + it("adds the SVG namespace before inlining an external image", async () => { + const svg = ''; + const dir = makeTempProject({ + "index.html": ` +
+ +
+ + `, + "assets/icon.svg": svg, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const src = document.getElementById("external-svg")?.getAttribute("src") ?? ""; + const encodedSvg = src.replace("data:image/svg+xml;base64,", ""); + + expect(src).toMatch(/^data:image\/svg\+xml;base64,/); + expect(Buffer.from(encodedSvg, "base64").toString("utf8")).toBe( + '', + ); + }); + it("preserves external SVG fragment references used by ", async () => { const spriteSvg = ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 222b8dde4f..7cb3ae78f4 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -26,6 +26,7 @@ import { inlineSubCompositions } from "./inlineSubCompositions"; import { queryByAttr } from "../utils/cssSelector"; import { isSafePath, resolveWithinProject } from "../safePath.js"; import { HF_COLOR_GRADING_ATTR } from "../colorGrading"; +import { ensureSvgNamespace } from "./svgNamespace"; const DEFAULT_RUNTIME_SCRIPT_URL = ""; @@ -306,7 +307,9 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri if (!mimeType) return null; const content = safeReadFileBuffer(filePath); if (content == null) return null; - const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`; + const inlinedContent = + ext === ".svg" ? Buffer.from(ensureSvgNamespace(content.toString("utf8")), "utf8") : content; + const dataUrl = `data:${mimeType};base64,${inlinedContent.toString("base64")}`; return appendSuffixToUrl(dataUrl, suffix); } diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index 6de76049d9..85eb14da26 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -88,3 +88,4 @@ export { // Asset-path primitives (shared across core, producer, CLI) export { CSS_URL_RE, PATH_ATTRS, isNonRelativeUrl, isPathInside } from "./assetPaths"; +export { ensureSvgNamespace } from "./svgNamespace"; diff --git a/packages/core/src/compiler/svgNamespace.ts b/packages/core/src/compiler/svgNamespace.ts new file mode 100644 index 0000000000..0649f1e575 --- /dev/null +++ b/packages/core/src/compiler/svgNamespace.ts @@ -0,0 +1,8 @@ +const SVG_XMLNS = 'xmlns="http://www.w3.org/2000/svg"'; + +/** Ensure a standalone SVG document has the namespace browsers require in image contexts. */ +export function ensureSvgNamespace(source: string): string { + const root = source.match(/]*>/i)?.[0]; + if (!root || /\sxmlns\s*=/.test(root)) return source; + return source.replace(/ { }); describe("createFileServer", () => { + it("adds the SVG namespace before serving an external image", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-svg-xmlns-")); + try { + writeEmptyIndex(projectDir); + writeFileSync( + join(projectDir, "icon.svg"), + '', + ); + + await withFileServer(projectDir, async (server) => { + const response = await fetch(`${server.url}/icon.svg`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/svg+xml"); + expect(await response.text()).toBe( + '', + ); + }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("serves ES modules with a JavaScript MIME type", async () => { const projectDir = mkdtempSync(join(tmpdir(), "hf-file-server-mjs-")); try { diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 4760de574a..91bdeb10ea 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -15,7 +15,11 @@ import { existsSync, realpathSync, statSync, createReadStream } from "node:fs"; import { readFile } from "node:fs/promises"; import { Readable } from "node:stream"; import { join, extname, resolve, sep } from "node:path"; -import { injectScriptsAtHeadStart, injectScriptsIntoHtml } from "@hyperframes/core/compiler"; +import { + ensureSvgNamespace, + injectScriptsAtHeadStart, + injectScriptsIntoHtml, +} from "@hyperframes/core/compiler"; import { fpsToNumber, type Fps } from "@hyperframes/core"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js"; @@ -790,6 +794,17 @@ export function createFileServer(options: FileServerOptions): Promise