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
7 changes: 4 additions & 3 deletions packages/cli/src/capture/assetDownloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
137 changes: 137 additions & 0 deletions packages/cli/src/capture/faviconRanker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { parseSizes, rankIconCandidates, type IconCandidate } from "./faviconRanker.js";

/**
* Fixtures transcribe the `<link>` 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);
});
});
92 changes: 92 additions & 0 deletions packages/cli/src/capture/faviconRanker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Rank declared `<link rel="icon">` 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);
}
15 changes: 13 additions & 2 deletions packages/cli/src/capture/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <link> 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();

Expand Down
Loading