Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,13 @@ async function captureSnapshots(
zoomScale?: number;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");

const numFrames = opts.frames ?? 5;

const html = await bundleToSingleHtml(projectDir);
// Localize fonts (embed remote @font-face as data URIs, matching the render
// path) so snapshots render the real font instead of a fallback sans.
const html = await bundleWithLocalizedFonts(projectDir);
const server = await serveStaticProjectHtml(projectDir, html);

const savedPaths: string[] = [];
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/src/utils/bundleWithLocalizedFonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => "<html><body>bundled</body></html>"),
}));

import {
__resetFontLocalizationWarningsForTests,
bundleWithLocalizedFonts,
localizeWithProducer,
} from "./bundleWithLocalizedFonts.js";

afterEach(() => {
__resetFontLocalizationWarningsForTests();
vi.clearAllMocks();
});

describe("bundleWithLocalizedFonts (call-site integration)", () => {
it("runs the injected font localizer over the plain bundle", async () => {
const localize = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts"));
const html = await bundleWithLocalizedFonts("/project", localize);
expect(localize).toHaveBeenCalledOnce();
expect(localize).toHaveBeenCalledWith("<html><body>bundled</body></html>");
expect(html).toBe("<html><body>bundled+fonts</body></html>");
});

it("returns the localizer output verbatim (localization is the last step)", async () => {
const html = await bundleWithLocalizedFonts("/project", async () => "<html>embedded</html>");
expect(html).toBe("<html>embedded</html>");
});
});

describe("localizeWithProducer", () => {
it("embeds fonts when the injector is available", async () => {
const inject = vi.fn(async (html: string) => `${html}<!--fonts-->`);
const warn = vi.fn();
const out = await localizeWithProducer("<html></html>", async () => inject, warn);
expect(out).toBe("<html></html><!--fonts-->");
expect(warn).not.toHaveBeenCalled();
});

it("fails open silently when producer is unavailable (module absent → null)", async () => {
const warn = vi.fn();
const out = await localizeWithProducer("<html>plain</html>", async () => null, warn);
// Never worse than a plain bundle; benign absence is not a warning.
expect(out).toBe("<html>plain</html>");
expect(warn).not.toHaveBeenCalled();
});

it("fails open WITH a diagnostic when the injector itself throws", async () => {
const warn = vi.fn();
const boom: () => Promise<never> = () => Promise.reject(new Error("fetch layer down"));
const out = await localizeWithProducer("<html>plain</html>", async () => boom, warn);
expect(out).toBe("<html>plain</html>");
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0]?.[0]).toContain("fetch layer down");
});

it("dedups repeated identical injector failures across re-bundles", async () => {
const warn = vi.fn();
const boom: () => Promise<never> = () => Promise.reject(new Error("same failure"));
for (let i = 0; i < 5; i++) {
await localizeWithProducer("<html></html>", async () => boom, warn);
}
// snapshot/check re-bundle per grid point; the warning must fire once.
expect(warn).toHaveBeenCalledOnce();
});
});
96 changes: 96 additions & 0 deletions packages/cli/src/utils/bundleWithLocalizedFonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 `<link>`, 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 `<link>`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<string> = localizeWithProducer,
): Promise<string> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir);
return localizeFonts(html);
}

type FontInjector = (html: string) => Promise<string>;

/**
* Load the render pipeline's `injectDeterministicFontFaces`, resolving
* `@hyperframes/producer` at RUNTIME only. The specifier is kept out of the
* bundler's/test-runner's static module graph (`@vite-ignore` + a variable
* specifier) on purpose: the CLI test job builds with `--filter
* '!@hyperframes/producer'`, so a static `import("@hyperframes/producer")`
* would fail Vitest's transform-time resolution. At runtime — the built CLI or
* an installed package — producer is a real dependency and resolves via
* node_modules.
*
* 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<FontInjector | null> {
try {
const producerSpecifier = "@hyperframes/producer";
const mod = (await import(/* @vite-ignore */ producerSpecifier)) as {
injectDeterministicFontFaces?: FontInjector;
};
return mod.injectDeterministicFontFaces ?? null;
} catch {
return null;
}
}

const warnedFontLocalizationFailures = new Set<string>();

/** 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 `<link>` 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<FontInjector | null> = loadFontInjector,
warn: (message: string) => void = (m) => console.warn(` ${c.warn("⚠")} ${m}`),
): Promise<string> {
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;
}
}
8 changes: 4 additions & 4 deletions packages/cli/src/utils/checkBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ export async function runBrowserCheck(
motion: MotionSpecResolution,
runGrid: RunAuditGrid,
): Promise<CheckBrowserResult> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(project.dir);
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
const drafts: RuntimeDraft[] = [];
let currentTime = 0;
Expand Down Expand Up @@ -145,8 +145,8 @@ export async function captureFindingCrops(
requests: CheckFindingCropRequest[],
): Promise<string[]> {
if (requests.length === 0) return [];
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(project.dir);
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
let chromeBrowser: import("puppeteer-core").Browser | undefined;
const written: string[] = [];
Expand Down
9 changes: 9 additions & 0 deletions packages/producer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ export {

// ── Utilities ───────────────────────────────────────────────────────────────
export { normalizeErrorMessage } from "./utils/errorMessage.js";
// Font localization: fetch + embed @font-face rules for requested families
// (including those declared only via a remote <link>) so a bundled composition
// renders with the real font instead of a fallback, regardless of network
// timing. The render pipeline runs this in its compile stage; the CLI audit
// paths (snapshot/check) reuse it so their captures match the render.
export {
injectDeterministicFontFaces,
type InjectDeterministicFontFacesOptions,
} from "./services/deterministicFonts.js";
export { quantizeTimeToFrame } from "./utils/parityContract.js";
export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";

Expand Down
Loading