Skip to content

Commit 1fa31d5

Browse files
feat(cli): keep every icon a site declares and headline the bare mark (#3727)
A brand band shows logo artwork on a transparency checker, where a badge (a mark knocked out of a full-bleed disc or square) reads as a solid blob. The capture kept only the first icon that downloaded, so which of a site's icons survived was decided by ranking alone, and nothing recorded what the survivor actually looked like. Keep them all. Each declared icon is written as assets/icon-<rel>-<sizes>.<ext> and classified as bare-mark, badge, or unknown, then the best one is copied onto the historical assets/favicon.<ext> stem so a stem match still finds it. Classification is deterministic and cheap. SVG is read from markup, not rendered: these files routinely fill via CSS custom properties behind a prefers-color-scheme query, and librsvg resolves neither, rasterising one real favicon to a fully transparent image while Chrome draws it correctly. Raster icons are sampled at the midpoint of each edge rather than the corners, because a full-bleed disc has four transparent corners and would otherwise read as a bare mark. The headline prefers a bare mark, then falls back to the existing ranking. Preference is binary on purpose: an "unknown" is not promoted above a badge, or an undecodable .ico would outrank a good SVG purely for being unexaminable. extracted/icons-manifest.json records every icon, its shape and the evidence for that shape, plus which file became the headline, its rank, and why. A choice whose losers are invisible is indistinguishable from having had no choice, which is how a substituted icon went unnoticed in the first place. Worth noting for consumers: sites often declare no bare mark at all. An apple-touch-icon is composited onto an opaque tile per Apple's spec, so it is a badge, not a mark. The manifest says so rather than implying the preference was satisfied.
1 parent 97fde27 commit 1fa31d5

5 files changed

Lines changed: 678 additions & 54 deletions

File tree

packages/cli/src/capture/assetDownloader.test.ts

Lines changed: 163 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import type { DesignTokens } from "./types.js";
1313
import type { IconCandidate } from "./faviconRanker.js";
1414
import { CAPTURE_USER_AGENT } from "./userAgent.js";
15+
import sharp from "sharp";
1516

1617
describe("isPrivateUrl — SSRF denylist (security: F-003)", () => {
1718
it("blocks loopback, private, and metadata IPv4", () => {
@@ -199,6 +200,31 @@ function tokensWithNoSvgs(): DesignTokens {
199200
return { svgs: [], sections: [], ogImage: "" } as unknown as DesignTokens;
200201
}
201202

203+
/**
204+
* openai.com's declared icons, in DOM order, with the attributes the page really carries.
205+
* `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
206+
*/
207+
const OPENAI_ICONS: IconCandidate[] = [
208+
{
209+
rel: "icon",
210+
href: "https://openai.example/favicon.svg",
211+
sizes: null,
212+
type: "image/svg+xml",
213+
},
214+
{
215+
rel: "icon",
216+
href: "https://openai.example/favicon.ico",
217+
sizes: "48x48",
218+
type: "image/x-icon",
219+
},
220+
{
221+
rel: "apple-touch-icon",
222+
href: "https://openai.example/apple-icon.png",
223+
sizes: "180x180",
224+
type: "image/png",
225+
},
226+
];
227+
202228
describe("drop counts — why a referenced asset is not in the capture", () => {
203229
afterEach(() => vi.unstubAllGlobals());
204230

@@ -334,31 +360,6 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
334360
describe("asset fetches present the same identity as the page navigation", () => {
335361
afterEach(() => vi.unstubAllGlobals());
336362

337-
/**
338-
* openai.com's declared icons, in DOM order, with the attributes the page really carries.
339-
* `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
340-
*/
341-
const OPENAI_ICONS: IconCandidate[] = [
342-
{
343-
rel: "icon",
344-
href: "https://openai.example/favicon.svg",
345-
sizes: null,
346-
type: "image/svg+xml",
347-
},
348-
{
349-
rel: "icon",
350-
href: "https://openai.example/favicon.ico",
351-
sizes: "48x48",
352-
type: "image/x-icon",
353-
},
354-
{
355-
rel: "apple-touch-icon",
356-
href: "https://openai.example/apple-icon.png",
357-
sizes: "180x180",
358-
type: "image/png",
359-
},
360-
];
361-
362363
/**
363364
* Measured against the real origin: `GET /favicon.svg` answers `403 text/html` to
364365
* `User-Agent: HyperFrames/1.0` and `200 image/svg+xml` to the browser UA the capture
@@ -383,9 +384,9 @@ describe("asset fetches present the same identity as the page navigation", () =>
383384
// next candidate, so the file on disk is chosen by the CDN rather than by the ranker.
384385
await withTempDir(async (dir) => {
385386
vi.stubGlobal("fetch", serveLikeAnAntiBotEdge());
386-
const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
387-
expect(assets.map((a) => a.localPath)).toEqual(["assets/favicon.svg"]);
388-
expect(assets[0]?.url).toBe("https://openai.example/favicon.svg");
387+
const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
388+
expect(icons.headline?.file).toBe("assets/favicon.svg");
389+
expect(icons.icons[0]?.url).toBe("https://openai.example/favicon.svg");
389390
});
390391
});
391392

@@ -402,3 +403,137 @@ describe("asset fetches present the same identity as the page navigation", () =>
402403
});
403404
});
404405
});
406+
407+
describe("declared icons — keep them all, headline the bare mark", () => {
408+
afterEach(() => vi.unstubAllGlobals());
409+
410+
/** A mark knocked out of a full-bleed rounded rect: the shape openai.com's favicon.svg has. */
411+
const BADGE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
412+
<rect width="180" height="180" rx="90" fill="#000" /><path d="M60 60h60v60H60z" fill="#fff" />
413+
</svg>`;
414+
415+
/** One shape with transparent margins. */
416+
const BARE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
417+
<path d="M40 40h100v100H40z" fill="#000" />
418+
</svg>`;
419+
420+
function solidPng(alpha: number): Promise<Buffer> {
421+
return sharp({
422+
create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } },
423+
})
424+
.png()
425+
.toBuffer();
426+
}
427+
428+
const CONTENT_TYPE: Record<string, string> = {
429+
".svg": "image/svg+xml",
430+
".png": "image/png",
431+
".ico": "image/vnd.microsoft.icon",
432+
};
433+
434+
/** Serve each URL the bytes the test names for it; 404 anything unexpected. */
435+
function serve(bodies: Record<string, Buffer | string>) {
436+
return vi.fn(async (url: string) => {
437+
const body = Object.entries(bodies).find(([suffix]) => url.endsWith(suffix))?.[1];
438+
if (body === undefined) return new Response("nope", { status: 404 });
439+
const ext = url.slice(url.lastIndexOf("."));
440+
const bytes = typeof body === "string" ? Buffer.from(body) : body;
441+
return new Response(new Uint8Array(bytes), {
442+
status: 200,
443+
headers: { "content-type": CONTENT_TYPE[ext] ?? "application/octet-stream" },
444+
});
445+
});
446+
}
447+
448+
it("writes one file per declared icon, named for its rel and sizes", async () => {
449+
await withTempDir(async (dir) => {
450+
vi.stubGlobal(
451+
"fetch",
452+
serve({
453+
"favicon.svg": BADGE_SVG,
454+
"favicon.ico": Buffer.from("not a decodable ico"),
455+
"apple-icon.png": await solidPng(1),
456+
}),
457+
);
458+
const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
459+
expect(icons.icons.map((i) => i.file)).toEqual([
460+
"assets/icon-icon-unsized.svg",
461+
"assets/icon-apple-touch-icon-180x180.png",
462+
"assets/icon-icon-48x48.ico",
463+
]);
464+
});
465+
});
466+
467+
it("records openai.com's icons as badges, and says the headline had no bare mark to pick", async () => {
468+
// Measured against the real files: the SVG is a mark knocked out of a full-bleed disc, and
469+
// the apple-touch PNG is an opaque white square, because Apple composites those onto an
470+
// opaque tile. openai.com declares no bare mark at all, so the headline falls back to the
471+
// ranker and the manifest has to say so rather than implying a preference was satisfied.
472+
await withTempDir(async (dir) => {
473+
vi.stubGlobal(
474+
"fetch",
475+
serve({
476+
"favicon.svg": BADGE_SVG,
477+
"favicon.ico": Buffer.from("not a decodable ico"),
478+
"apple-icon.png": await solidPng(1),
479+
}),
480+
);
481+
const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS);
482+
expect(icons.icons.map((i) => i.shape)).toEqual(["badge", "badge", "unknown"]);
483+
expect(icons.headline).toMatchObject({
484+
file: "assets/favicon.svg",
485+
source: "assets/icon-icon-unsized.svg",
486+
shape: "badge",
487+
});
488+
expect(icons.headline?.reason).toContain("no bare-mark candidate");
489+
});
490+
});
491+
492+
it("headlines a lower-ranked bare mark over the better-ranked badge", async () => {
493+
// The ordering rule, on its own. By declared quality the SVG wins outright; by shape the
494+
// PNG does, and shape is what the brand band needs. Under the old rule this lands
495+
// assets/favicon.svg.
496+
await withTempDir(async (dir) => {
497+
vi.stubGlobal(
498+
"fetch",
499+
serve({ "favicon.svg": BADGE_SVG, "apple-icon.png": await solidPng(0) }),
500+
);
501+
const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
502+
{ rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
503+
{
504+
rel: "apple-touch-icon",
505+
href: "https://x.test/apple-icon.png",
506+
sizes: "180x180",
507+
type: "image/png",
508+
},
509+
] as IconCandidate[]);
510+
expect(icons.headline).toMatchObject({
511+
file: "assets/favicon.png",
512+
source: "assets/icon-apple-touch-icon-180x180.png",
513+
shape: "bare-mark",
514+
rank: 1,
515+
});
516+
expect(icons.headline?.reason).toContain("bare mark preferred over 1 badge");
517+
});
518+
});
519+
520+
it("headlines the SVG when it is the bare mark", async () => {
521+
await withTempDir(async (dir) => {
522+
vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG }));
523+
const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
524+
{ rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
525+
] as IconCandidate[]);
526+
expect(icons.headline).toMatchObject({ file: "assets/favicon.svg", shape: "bare-mark" });
527+
});
528+
});
529+
530+
it("keeps assets/favicon.<ext> so a stem match still finds the icon", async () => {
531+
await withTempDir(async (dir) => {
532+
vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG }));
533+
const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], [
534+
{ rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" },
535+
] as IconCandidate[]);
536+
expect(assets.map((a) => a.localPath)).toContain("assets/favicon.svg");
537+
});
538+
});
539+
});

0 commit comments

Comments
 (0)