import { normalizeErrorMessage } from "./errorMessage.js"; import { c } from "../ui/colors.js"; /** * Bundle a project to a single HTML string AND localize its fonts — fetch and * embed `@font-face` rules for every requested family (including families * declared only via a remote ``, e.g. Google Fonts) as data URIs. * * Why the audit/snapshot paths need this: core's `bundleToSingleHtml` inlines * only LOCAL stylesheets and leaves remote font ``s as-is, so a snapshot * depends on loading the remote font at capture time. The render pipeline * instead localizes fonts in its compile stage, which is why a render embeds * (say) League Gothic correctly while a snapshot of the same composition can * fall back to an un-styled system sans when the remote font loses the race * against the capture. Running the SAME localization the render path uses makes * snapshot/check captures font-faithful and deterministic — no network race. */ export async function bundleWithLocalizedFonts( projectDir: string, // Injectable for tests. Production callers omit it and get the producer // font-localization pass (see localizeWithProducer). localizeFonts: (html: string) => Promise = localizeWithProducer, ): Promise { const { bundleToSingleHtml } = await import("@hyperframes/core/compiler"); const html = await bundleToSingleHtml(projectDir); return localizeFonts(html); } type FontInjector = (html: string) => Promise; /** * Load the render pipeline's `injectDeterministicFontFaces` through the CLI's * canonical producer loader. That loader uses a literal dynamic import, which * lets tsup see and inline producer into the published CLI bundle. A variable * bare-package import is invisible to tsup and silently fails after `npm * install`, because producer is intentionally only a workspace devDependency. * * Returns `null` (not a throw) when the module simply isn't available in this * environment, so the caller can treat "producer absent" — a benign, expected * condition — differently from "the injector itself failed". */ async function loadFontInjector(): Promise { try { const { loadProducer } = await import("./producer.js"); const mod = (await loadProducer()) as { injectDeterministicFontFaces?: FontInjector; }; return mod.injectDeterministicFontFaces ?? null; } catch { return null; } } const warnedFontLocalizationFailures = new Set(); /** Reset the dedup latch — tests only. */ export function __resetFontLocalizationWarningsForTests(): void { warnedFontLocalizationFailures.clear(); } /** * Localize fonts via the producer injector, distinguishing two failure modes: * * - **Module unavailable** (`loadInjector` yields `null`): benign — producer * isn't in this environment. Return the HTML unchanged, silently; fonts * declared via a remote `` still load at capture time as before. * - **Injector threw** (a fetch layer failed, a family errored past the * injector's own per-family handling): unexpected — surface it ONCE per * distinct message (snapshot/check re-bundle per grid point, so an * un-deduped warning would spam), then fail open to the plain bundle. * * Per-family resolution failures inside a successful pass are already reported * by the injector itself (producer's `warnUnresolvedFonts`); this layer only * owns the module-vs-execution distinction and the dedup. */ export async function localizeWithProducer( html: string, loadInjector: () => Promise = loadFontInjector, warn: (message: string) => void = (m) => console.warn(` ${c.warn("⚠")} ${m}`), ): Promise { const inject = await loadInjector(); if (!inject) return html; try { return await inject(html); } catch (err) { const message = `Font localization failed; capturing with remote/fallback fonts instead: ${normalizeErrorMessage(err)}`; if (!warnedFontLocalizationFailures.has(message)) { warnedFontLocalizationFailures.add(message); warn(message); } return html; } }