diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 8cc2bb41f..fdcdcf94f 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -10,6 +10,7 @@ import { join, extname } from "node:path"; import { createHash } from "node:crypto"; import type { DesignTokens, DownloadedAsset } from "./types.js"; import type { CatalogedAsset } from "./assetCataloger.js"; +import { rankIconCandidates, type IconCandidate } from "./faviconRanker.js"; interface DownloadBudgetOptions { remainingMs?: () => number; @@ -93,7 +94,7 @@ export async function downloadAssets( tokens: DesignTokens, outputDir: string, catalogedAssets?: CatalogedAsset[], - faviconLinks?: Array<{ rel: string; href: string }>, + faviconLinks?: IconCandidate[], options: DownloadBudgetOptions = {}, ): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts }> { const assetsDir = join(outputDir, "assets"); @@ -133,8 +134,8 @@ export async function downloadAssets( } } - // 2. Favicon - const icons = faviconLinks || []; + // 2. Favicon — best declared candidate first, falling back through the rest on failure. + const icons = rankIconCandidates(faviconLinks || []); for (const [index, icon] of icons.entries()) { const remainingMs = options.remainingMs?.() ?? 10_000; if (remainingMs <= 0) { diff --git a/packages/cli/src/capture/faviconRanker.test.ts b/packages/cli/src/capture/faviconRanker.test.ts new file mode 100644 index 000000000..669bc015f --- /dev/null +++ b/packages/cli/src/capture/faviconRanker.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { parseSizes, rankIconCandidates, type IconCandidate } from "./faviconRanker.js"; + +/** + * Fixtures transcribe the `` tags these sites declare in their document head. + * They are the shapes that made DOM-order downloading land the worst icon on disk. + */ + +// linear.app: legacy .ico declared first, the SVG and the 180px apple-touch after it. +const LINEAR: IconCandidate[] = [ + { rel: "icon", href: "https://linear.app/favicon.ico", sizes: "any", type: null }, + { rel: "icon", href: "https://linear.app/favicon.svg", sizes: null, type: "image/svg+xml" }, + { + rel: "apple-touch-icon", + href: "https://linear.app/apple-touch-icon.png", + sizes: "180x180", + type: null, + }, +]; + +// notion.com: .ico first, then an apple-touch png that declares no `sizes`. +const NOTION: IconCandidate[] = [ + { rel: "icon", href: "https://www.notion.com/front-static/favicon.ico", sizes: null, type: null }, + { + rel: "apple-touch-icon", + href: "https://www.notion.com/front-static/logo-ios.png", + sizes: null, + type: null, + }, +]; + +// stripe.com: svg, a 96x96 png, a shortcut .ico, and a 180x180 apple-touch png. +const STRIPE: IconCandidate[] = [ + { + rel: "icon", + href: "https://images.stripeassets.com/x/favicon.svg", + sizes: null, + type: "image/svg+xml", + }, + { + rel: "icon", + href: "https://images.stripeassets.com/x/favicon.png?w=96&h=96", + sizes: "96x96", + type: "image/png", + }, + { + rel: "shortcut icon", + href: "https://assets.stripeassets.com/x/favicon.ico", + sizes: null, + type: null, + }, + { + rel: "apple-touch-icon", + href: "https://images.stripeassets.com/x/favicon.png?w=180&h=180", + sizes: "180x180", + type: null, + }, +]; + +const hrefs = (cs: IconCandidate[]): string[] => rankIconCandidates(cs).map((c) => c.href); + +describe("rankIconCandidates", () => { + it("puts the SVG first even though the page declares the .ico first", () => { + expect(hrefs(LINEAR)).toEqual([ + "https://linear.app/favicon.svg", + "https://linear.app/apple-touch-icon.png", + "https://linear.app/favicon.ico", + ]); + }); + + it("prefers an unsized apple-touch-icon over a .ico", () => { + expect(hrefs(NOTION)).toEqual([ + "https://www.notion.com/front-static/logo-ios.png", + "https://www.notion.com/front-static/favicon.ico", + ]); + }); + + it("orders svg, then largest declared size, then .ico", () => { + expect(hrefs(STRIPE)).toEqual([ + "https://images.stripeassets.com/x/favicon.svg", + "https://images.stripeassets.com/x/favicon.png?w=180&h=180", + "https://images.stripeassets.com/x/favicon.png?w=96&h=96", + "https://assets.stripeassets.com/x/favicon.ico", + ]); + }); + + it("does not let a Safari pinned-tab silhouette beat the real favicon", () => { + // A mask-icon is an SVG, so ranking on format alone would promote the silhouette. + const masked: IconCandidate[] = [ + { rel: "mask-icon", href: "https://x.test/pinned.svg", sizes: null, type: null }, + { rel: "icon", href: "https://x.test/favicon.png", sizes: "32x32", type: "image/png" }, + { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" }, + ]; + expect(hrefs(masked)).toEqual([ + "https://x.test/favicon.svg", + "https://x.test/favicon.png", + "https://x.test/pinned.svg", + ]); + }); + + it("still returns a mask-icon when the page declares nothing else", () => { + // Ranked last, not dropped: a silhouette on disk beats no icon at all. + const only: IconCandidate[] = [{ rel: "mask-icon", href: "https://x.test/pinned.svg" }]; + expect(hrefs(only)).toEqual(["https://x.test/pinned.svg"]); + }); + + it("drops candidates with no href", () => { + expect(hrefs([{ rel: "icon", href: "" }, ...NOTION])).toHaveLength(2); + }); + + it("keeps DOM order between candidates of equal rank", () => { + const same: IconCandidate[] = [ + { rel: "icon", href: "https://x.test/a.png", sizes: "32x32" }, + { rel: "icon", href: "https://x.test/b.png", sizes: "32x32" }, + ]; + expect(hrefs(same)).toEqual(["https://x.test/a.png", "https://x.test/b.png"]); + }); +}); + +describe("parseSizes", () => { + it("reads a single declaration", () => { + expect(parseSizes("32x32")).toBe(32); + }); + + it("takes the largest of a multi-size declaration", () => { + expect(parseSizes("180x180 167x167")).toBe(180); + }); + + it("scores `any` as no declared pixel size", () => { + expect(parseSizes("any")).toBe(0); + }); + + it("scores a missing attribute as no declared pixel size", () => { + expect(parseSizes(null)).toBe(0); + expect(parseSizes(undefined)).toBe(0); + }); +}); diff --git a/packages/cli/src/capture/faviconRanker.ts b/packages/cli/src/capture/faviconRanker.ts new file mode 100644 index 000000000..424189783 --- /dev/null +++ b/packages/cli/src/capture/faviconRanker.ts @@ -0,0 +1,92 @@ +/** + * Rank declared `` candidates so the capture downloads the BEST one, + * not whichever the page happened to declare first. + * + * Pages routinely declare a legacy 16px `.ico` first and the good asset (an SVG, or a + * 180x180 apple-touch PNG) after it. Downloading in DOM order therefore lands the worst + * icon on disk, and the `sizes`/`type` attributes that say so are the only evidence — + * the bytes are only fetched for the winner, so quality cannot be measured after the fact. + * + * Pure: no IO, no network. Order only. + */ + +export interface IconCandidate { + rel: string; + href: string; + /** `sizes` attribute verbatim, e.g. "32x32", "any", "180x180 167x167". */ + sizes?: string | null; + /** `type` attribute verbatim, e.g. "image/svg+xml". */ + type?: string | null; +} + +/** Apple's spec size for a `apple-touch-icon` that declares no `sizes`. */ +const APPLE_TOUCH_DEFAULT_PX = 180; + +/** + * Largest pixel edge declared in a `sizes` attribute. `any` (used by SVG and by legacy + * `.ico` files alike) declares no pixel size at all, so it scores 0 rather than Infinity. + */ +export function parseSizes(sizes: string | null | undefined): number { + if (!sizes) return 0; + let max = 0; + for (const token of sizes.trim().split(/\s+/)) { + const m = /^(\d+)x(\d+)$/i.exec(token); + if (!m) continue; // "any" and anything malformed + max = Math.max(max, Number(m[1]), Number(m[2])); + } + return max; +} + +function pathnameOf(href: string): string { + try { + return new URL(href).pathname.toLowerCase(); + } catch { + return href.split(/[#?]/)[0]!.toLowerCase(); + } +} + +function isSvg(c: IconCandidate): boolean { + return c.type?.toLowerCase() === "image/svg+xml" || pathnameOf(c.href).endsWith(".svg"); +} + +/** + * `rel="mask-icon"` is Safari's pinned-tab asset: a single-colour silhouette, drawn in whatever + * tint the browser picks. It is not the site mark, and it is served as an SVG, so ranking by + * format alone would promote a monochrome outline over the page's real colour favicon. + */ +function isMaskIcon(c: IconCandidate): boolean { + return c.rel.toLowerCase().split(/\s+/).includes("mask-icon"); +} + +function isIco(c: IconCandidate): boolean { + const t = c.type?.toLowerCase(); + return ( + t === "image/x-icon" || t === "image/vnd.microsoft.icon" || pathnameOf(c.href).endsWith(".ico") + ); +} + +function declaredSize(c: IconCandidate): number { + const parsed = parseSizes(c.sizes); + if (parsed > 0) return parsed; + return c.rel.toLowerCase().split(/\s+/).includes("apple-touch-icon") ? APPLE_TOUCH_DEFAULT_PX : 0; +} + +function tierOf(c: IconCandidate): number { + // ponytail: mask-icon is ranked last rather than filtered out, so a page that declares + // nothing else still lands an icon instead of none. + if (isMaskIcon(c)) return 3; + if (isSvg(c)) return 0; + return isIco(c) ? 2 : 1; +} + +/** + * Best-first order: SVG, then largest declared size, then `.ico`, then a pinned-tab `mask-icon` + * as a last resort. Stable within a tier, so DOM order breaks ties. + */ +export function rankIconCandidates(candidates: IconCandidate[]): IconCandidate[] { + return candidates + .filter((c) => !!c.href) + .map((c, i) => ({ c, i, tier: tierOf(c), size: declaredSize(c) })) + .sort((a, b) => a.tier - b.tier || b.size - a.size || a.i - b.i) + .map((e) => e.c); +} diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index 7b9bb834b..388508ed2 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -23,6 +23,7 @@ import { noDrops, totalDrops, } from "./assetDownloader.js"; +import type { IconCandidate } from "./faviconRanker.js"; import { extractFontMetadata } from "./fontMetadataExtractor.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { diag } from "../ui/diagnostics.js"; @@ -575,10 +576,20 @@ export async function captureWebsite( const visibleTextContent = await extractVisibleText(page1); // Extract favicon links before closing page (removed from tokens to reduce noise) + // `sizes` and `type` are the only evidence of icon quality: page.html on disk does not + // keep the tags, and the bytes are only fetched for the candidate that wins, so + // dropping these attributes here makes the choice unrecoverable downstream. const faviconLinks = (await page1.evaluate(`(() => { var iconEls = Array.from(document.querySelectorAll('link[rel*="icon"], link[rel="apple-touch-icon"]')); - return iconEls.map(function(l) { return { rel: l.rel, href: l.href }; }); - })()`)) as Array<{ rel: string; href: string }>; + return iconEls.map(function(l) { + return { + rel: l.rel, + href: l.href, + sizes: l.getAttribute('sizes'), + type: l.getAttribute('type'), + }; + }); + })()`)) as IconCandidate[]; await page1.close();