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
5 changes: 5 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ the `/hyperframes` skill uses it when a real product has to appear on screen. Dy
partial results, so read `dropped`, the warnings, and the contact sheets before
you build.

Downloaded fonts are saved under `assets/fonts/`, and their CSS references point
to those local files. Distinct URLs such as `demo.woff2?v=1` and
`demo.woff2?v=10` keep separate files. A failed font download leaves its original
URL unchanged.

For AI image descriptions, set `GEMINI_API_KEY` in a `.env` file
(~$0.001/image), or `OPENROUTER_API_KEY` to route any vision model through
[OpenRouter](https://openrouter.ai) — it wins if both are set, and
Expand Down
95 changes: 95 additions & 0 deletions packages/cli/src/capture/assetDownloader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,101 @@ describe("capture download security boundaries", () => {
});
});

describe("captured font URL rewrites", () => {
afterEach(() => vi.mocked(publicRequest).mockReset());

it.each([
["", "demo.woff2?v=1", "demo.woff2?v=10"],
["'", "demo.woff2?v=1", "demo.woff2?v=10"],
['"', "demo.woff2?v=1", "demo.woff2?v=10"],
["", "demo.woff2", "demo.woff2?v=2"],
["'", "demo.woff2", "demo.woff2?v=2"],
['"', "demo.woff2", "demo.woff2?v=2"],
["", "demo.woff", "demo.woff2"],
["'", "demo.woff", "demo.woff2"],
['"', "demo.woff", "demo.woff2"],
])("keeps both fonts when %s-quoted %s prefixes %s", async (quote, first, second) => {
await withTempDir(async (dir) => {
const firstBytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
const secondBytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/8963f64fa28dc4ae.woff2", import.meta.url),
);
const firstUrl = `https://fonts.example/${first}`;
const secondUrl = `https://fonts.example/${second}`;
const fetch = vi.fn(async (url: string) => {
if (url === firstUrl) return new Response(new Uint8Array(firstBytes));
if (url === secondUrl) return new Response(new Uint8Array(secondBytes));
throw new Error(`Unexpected font URL: ${url}`);
});
mockRequest(fetch);
const firstRule = `@font-face{font-family:Mono;src:url(${quote}${firstUrl}${quote})}`;
const secondRule = `@font-face{font-family:Marker;src:url(${quote}${secondUrl}${quote})}`;

const result = await downloadAndRewriteFonts(`${firstRule}\n${secondRule}`, dir);

expect(result.css).toBe(
`@font-face{font-family:Mono;src:url(${quote}assets/fonts/demo.woff2${quote})}\n` +
`@font-face{font-family:Marker;src:url(${quote}assets/fonts/demo-2.woff2${quote})}`,
);
expect(readFileSync(join(dir, "assets/fonts/demo.woff2"))).toEqual(firstBytes);
expect(readFileSync(join(dir, "assets/fonts/demo-2.woff2"))).toEqual(secondBytes);
expect(result.drops.unavailable).toBe(0);
expect(fetch.mock.calls.map(([url]) => url)).toEqual([firstUrl, secondUrl]);
});
});

it("leaves an unavailable longer URL intact after saving the shorter URL", async () => {
await withTempDir(async (dir) => {
const bytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
mockRequest(
vi.fn(async (url: string) =>
url.endsWith("?v=1")
? new Response(new Uint8Array(bytes))
: new Response("Unavailable", { status: 503 }),
),
);
const source =
'@font-face{font-family:Mono;src:url("https://fonts.example/demo.woff2?v=1")}\n' +
'@font-face{font-family:Marker;src:url("https://fonts.example/demo.woff2?v=10")}';

const result = await downloadAndRewriteFonts(source, dir);

expect(result.css).toBe(
'@font-face{font-family:Mono;src:url("assets/fonts/demo.woff2")}\n' +
'@font-face{font-family:Marker;src:url("https://fonts.example/demo.woff2?v=10")}',
);
expect(readdirSync(join(dir, "assets/fonts"))).toEqual(["demo.woff2"]);
expect(result.drops.unavailable).toBe(1);
});
});

it("localizes repeated references to the same font with one download", async () => {
await withTempDir(async (dir) => {
const bytes = readFileSync(
new URL("../../../../docs/public/catalog/assets/a634cb9e7783af7e.woff2", import.meta.url),
);
const fetch = vi.fn(async () => new Response(new Uint8Array(bytes)));
mockRequest(fetch);
const source =
'@font-face{font-family:Mono;src:url("https://fonts.example/demo.woff2?v=1")}\n' +
"@font-face{font-family:Alias;src:url('https://fonts.example/demo.woff2?v=1')}";

const result = await downloadAndRewriteFonts(source, dir);

expect(result.css).toBe(
'@font-face{font-family:Mono;src:url("assets/fonts/demo.woff2")}\n' +
"@font-face{font-family:Alias;src:url('assets/fonts/demo.woff2')}",
);
expect(readFileSync(join(dir, "assets/fonts/demo.woff2"))).toEqual(bytes);
expect(fetch).toHaveBeenCalledTimes(1);
});
});
});

describe("a planted directory link inside the capture directory (#4304)", () => {
// Creating symlinks needs elevated rights on Windows.
const posixOnly = it.skipIf(process.platform === "win32");
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/capture/assetDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ export async function downloadAndRewriteFonts(
});

const usedFontNames = new Set<string>();
let rewritten = css;
const localFontPaths = new Map<string, string>();
let count = 0;

for (const [index, fontUrl] of sortedUrls.entries()) {
Expand Down Expand Up @@ -631,7 +631,7 @@ export async function downloadAndRewriteFonts(
const localPath = join(assetsDir, filename);
const relativePath = `assets/fonts/${filename}`;
writeCaptureFileSync(localPath, buffer);
rewritten = rewritten.split(fontUrl).join(relativePath);
localFontPaths.set(fontUrl, relativePath);
} else {
drops.unavailable++;
}
Expand All @@ -640,6 +640,10 @@ export async function downloadAndRewriteFonts(
}
}

const rewritten = css.replace(fontUrlRegex, (match: string, url: string) => {
const localPath = localFontPaths.get(url);
return localPath ? match.replace(url, localPath) : match;
});
return { css: rewritten, drops };
}

Expand Down
Loading