From b1b03782a1364f160b5504d46ad36cef277cca91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 1 Jun 2026 20:12:53 -0400 Subject: [PATCH] fix(producer): localize remote @font-face src URLs before render (#1155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(producer): localize remote @font-face src URLs before render Remote font URLs in @font-face blocks fail with a CORS rejection when the renderer fetches them from http://localhost:PORT (S3 does not echo the local origin in Access-Control-Allow-Origin). Chrome falls back to the next font in the stack (e.g. Arial), producing wrong typography. localizeRemoteFontFaces() scans `; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).not.toContain(FONT_URL); + expect(result).toContain("_remote_media/"); + expect(remoteMediaAssets.size).toBe(1); + } finally { + globalThis.fetch = orig; + } + }); + + it("ignores url() references outside @font-face (e.g. background-image)", async () => { + const orig = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => new Response(new Uint8Array(16), { status: 200 }); + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-bg-")); + const BG_URL = "https://cdn.example.com/bg.png"; + const html = ``; + const { html: result } = await localizeRemoteFontFaces(html, dl); + // Font URL rewritten, background URL untouched + expect(result).not.toContain(FONT_URL); + expect(result).toContain(BG_URL); + } finally { + globalThis.fetch = orig; + } + }); + + it("preserves original URL when download fails", async () => { + const orig = globalThis.fetch; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => new Response(null, { status: 403 }); + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-fail-")); + const FAIL_URL = "https://fail-font.example.com/f.ttf"; + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toContain(FAIL_URL); + expect(remoteMediaAssets.size).toBe(0); + } finally { + globalThis.fetch = orig; + } + }); + + it("deduplicates: same font URL in two @font-face blocks → 1 download", async () => { + const orig = globalThis.fetch; + let fetchCount = 0; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = async () => { + fetchCount++; + return new Response(new Uint8Array(16), { status: 200 }); + }; + try { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-dedup-")); + const DEDUP_URL = "https://dedup-font.example.com/d.ttf"; + const html = ``; + const { remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(fetchCount).toBe(1); + expect(remoteMediaAssets.size).toBe(1); + } finally { + globalThis.fetch = orig; + } + }); + + it("no-ops when no @font-face blocks are present", async () => { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-noop-")); + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toBe(html); + expect(remoteMediaAssets.size).toBe(0); + }); + + it("ignores local (non-HTTP) @font-face src URLs", async () => { + const dl = mkdtempSync(join(tmpdir(), "hf-ff-local-")); + const html = ``; + const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl); + expect(result).toBe(html); + expect(remoteMediaAssets.size).toBe(0); + }); +}); + describe("discoverAudioVolumeAutomationFromTimeline", () => { it("samples video-derived audio volume without firing GSAP callbacks", async () => { class TestAudioElement {} diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 85edddbc0..cd9c713c5 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -887,6 +887,63 @@ const REMOTE_MEDIA_SUBDIR = "_remote_media"; const REMOTE_MEDIA_TAG_RE = /<(?:video|audio)\b[^>]*?\bsrc\s*=\s*["'](https?:\/\/[^"']+)["'][^>]*>/gi; +/** + * Download a set of remote URLs in parallel into `remoteDir`, build the + * `{ relPath → absPath }` asset map, and rewrite every occurrence of each + * URL inside `html` with its relative local path. + * + * The `warnLabel` appears in console.warn messages for download failures. + * The `logLabel` appears in the success console.log line. + * `extraRewrite`, if provided, is called per URL pair after the standard + * double/single-quote rewrite — used for url(...) CSS rewriting. + */ +async function downloadAndRewriteUrls( + urlSet: Set, + html: string, + remoteDir: string, + warnLabel: string, + logLabel: string, + extraRewrite?: (html: string, url: string, relPath: string) => string, +): Promise<{ html: string; remoteMediaAssets: Map }> { + if (urlSet.size === 0) return { html, remoteMediaAssets: new Map() }; + if (!existsSync(remoteDir)) mkdirSync(remoteDir, { recursive: true }); + + const urlToLocal = new Map(); + await Promise.all( + [...urlSet].map(async (url) => { + try { + const localPath = await downloadToTemp(url, remoteDir); + urlToLocal.set(url, localPath); + } catch (err) { + console.warn( + `[Compiler] ${warnLabel} ${url} — using original URL as fallback. ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }), + ); + + if (urlToLocal.size === 0) return { html, remoteMediaAssets: new Map() }; + + const remoteMediaAssets = new Map(); + const urlToRelPath = new Map(); + for (const [url, absPath] of urlToLocal) { + const relPath = `${REMOTE_MEDIA_SUBDIR}/${basename(absPath)}`; + remoteMediaAssets.set(relPath, absPath); + urlToRelPath.set(url, relPath); + } + + let result = html; + for (const [url, relPath] of urlToRelPath) { + result = result.replaceAll(`"${url}"`, `"${relPath}"`).replaceAll(`'${url}'`, `'${relPath}'`); + if (extraRewrite) result = extraRewrite(result, url, relPath); + } + + console.log(`[Compiler] ${logLabel} ${urlToLocal.size} to ${REMOTE_MEDIA_SUBDIR}/`); + return { html: result, remoteMediaAssets }; +} + /** * Download any remote `src` URLs on `