diff --git a/CHANGES.md b/CHANGES.md index d577a6b86..6c264b137 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -84,6 +84,12 @@ To be released. ### @fedify/cli + - Added SVG favicon support to `fedify nodeinfo`. SVG icons are now + rasterized and shown as terminal art instead of being skipped, so servers + that only declare an SVG favicon get an icon too. Bitmap icons are still + preferred when a page declares both. If the chosen favicon cannot be + fetched or rendered, the command now retries `/favicon.ico` once. + [[#1238], [#1346] by Lee Jeongmin\] - Added a `fedify-cli` Agent Skill for command-line ActivityPub diagnostics and testing, including servers built without Fedify. Install it through the Claude Code plugin, the `@fedify/cli` npm package, or a manual @@ -100,10 +106,11 @@ To be released. project excludes the matching deprecation lint rule so that `deno lint` succeeds. [[#1335], [#1340]] - Changed the `fedify nodeinfo` favicon selector to pick a usable bitmap - icon more often. It now skips SVG icons declared with - `type="image/svg+xml"`, not just those with a `.svg` URL, and it considers - every declared size instead of only the first, so an icon offering a large - size is no longer discarded because its smallest size is too small. + icon more often. It now recognizes SVG icons declared with + `type="image/svg+xml"`, not just those with a `.svg` URL, so a bitmap + icon is preferred over them, and it considers every declared size instead + of only the first, so an icon offering a large size is no longer + discarded because its smallest size is too small. [[#893], [#1187] by Lee Jeongmin\] - Changed the `fedify webfinger` command's error message when a lookup fails. It now explains why—resource not found, a network error, a @@ -115,12 +122,14 @@ To be released. [#897]: https://github.com/fedify-dev/fedify/issues/897 [#901]: https://github.com/fedify-dev/fedify/issues/901 [#1187]: https://github.com/fedify-dev/fedify/pull/1187 +[#1238]: https://github.com/fedify-dev/fedify/issues/1238 [#1265]: https://github.com/fedify-dev/fedify/pull/1265 [#1296]: https://github.com/fedify-dev/fedify/pull/1296 [#1329]: https://github.com/fedify-dev/fedify/issues/1329 [#1330]: https://github.com/fedify-dev/fedify/pull/1330 [#1335]: https://github.com/fedify-dev/fedify/issues/1335 [#1340]: https://github.com/fedify-dev/fedify/pull/1340 +[#1346]: https://github.com/fedify-dev/fedify/pull/1346 [#1352]: https://github.com/fedify-dev/fedify/pull/1352 ### @fedify/debugger diff --git a/changes.d/cli/improve-favicon-selection.md b/changes.d/cli/improve-favicon-selection.md index af631d7fe..efc5db5ae 100644 --- a/changes.d/cli/improve-favicon-selection.md +++ b/changes.d/cli/improve-favicon-selection.md @@ -4,8 +4,9 @@ links: '#893': https://github.com/fedify-dev/fedify/issues/893 --- - Changed the `fedify nodeinfo` favicon selector to pick a usable bitmap - icon more often. It now skips SVG icons declared with - `type="image/svg+xml"`, not just those with a `.svg` URL, and it considers - every declared size instead of only the first, so an icon offering a large - size is no longer discarded because its smallest size is too small. + icon more often. It now recognizes SVG icons declared with + `type="image/svg+xml"`, not just those with a `.svg` URL, so a bitmap + icon is preferred over them, and it considers every declared size instead + of only the first, so an icon offering a large size is no longer + discarded because its smallest size is too small. [[#893], [#1187] by Lee Jeongmin] diff --git a/changes.d/cli/support-svg-favicons.md b/changes.d/cli/support-svg-favicons.md new file mode 100644 index 000000000..db15f1c44 --- /dev/null +++ b/changes.d/cli/support-svg-favicons.md @@ -0,0 +1,11 @@ +--- +links: + '#1238': https://github.com/fedify-dev/fedify/issues/1238 + '#1346': https://github.com/fedify-dev/fedify/pull/1346 +--- + - Added SVG favicon support to `fedify nodeinfo`. SVG icons are now + rasterized and shown as terminal art instead of being skipped, so servers + that only declare an SVG favicon get an icon too. Bitmap icons are still + preferred when a page declares both. If the chosen favicon cannot be + fetched or rendered, the command now retries `/favicon.ico` once. + [[#1238], [#1346] by Lee Jeongmin] diff --git a/deno.lock b/deno.lock index 732137ae3..c4d221fea 100644 --- a/deno.lock +++ b/deno.lock @@ -110,6 +110,7 @@ "npm:@preact/signals@^2.5.1": "2.9.1_preact@10.29.2", "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.2_vite@7.3.2", "npm:@remix-run/node-fetch-server@0.12": "0.12.0", + "npm:@resvg/resvg-wasm@^2.6.2": "2.6.2", "npm:@solidjs/start@^1.3.0": "1.3.2_@netlify+blobs@11.0.3_vite@7.3.2", "npm:@standard-schema/spec@^1.1.0": "1.1.0", "npm:@sveltejs/kit@2": "2.57.1_@opentelemetry+api@1.9.1_typescript@6.0.3_vite@7.3.2", @@ -4999,6 +5000,9 @@ "@repeaterjs/repeater@3.1.0": { "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==" }, + "@resvg/resvg-wasm@2.6.2": { + "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==" + }, "@rolldown/binding-android-arm64@1.1.5": { "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "os": ["android"], @@ -16873,6 +16877,7 @@ "npm:@jimp/core@^1.6.1", "npm:@jimp/wasm-webp@^1.6.1", "npm:@poppanator/http-constants@^1.1.1", + "npm:@resvg/resvg-wasm@^2.6.2", "npm:chalk@^5.6.2", "npm:cli-table3@~0.6.5", "npm:fetch-mock@^12.5.4", @@ -16893,6 +16898,7 @@ "npm:@jimp/core@^1.6.1", "npm:@jimp/wasm-webp@^1.6.1", "npm:@poppanator/http-constants@^1.1.1", + "npm:@resvg/resvg-wasm@^2.6.2", "npm:@types/pngjs@^6.0.5", "npm:chalk@^5.6.2", "npm:cli-highlight@^2.1.11", diff --git a/docs/cli.md b/docs/cli.md index 5cc67a142..b9767e206 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1392,7 +1392,9 @@ visualized along with the favicon.](cli/fedify-nodeinfo.png) The `fedify nodeinfo` command fetches the given instance's [NodeInfo] document and visualizes it in [`neofetch`]-style. The argument can be either a bare -hostname or a full URL. +hostname or a full URL. SVG favicons are rasterized, but `` elements +are not drawn because the rasterizer loads no fonts, and a text-only SVG falls +back to `/favicon.ico`. > [!TIP] > Not all instances provide the NodeInfo document. If the given instance does diff --git a/packages/cli/deno.json b/packages/cli/deno.json index 6637a7765..8685150e7 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -10,6 +10,7 @@ "@jimp/core": "npm:@jimp/core@^1.6.1", "@jimp/wasm-webp": "npm:@jimp/wasm-webp@^1.6.1", "@poppanator/http-constants": "npm:@poppanator/http-constants@^1.1.1", + "@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@^2.6.2", "chalk": "npm:chalk@^5.6.2", "cli-table3": "npm:cli-table3@^0.6.5", "fetch-mock": "npm:fetch-mock@^12.5.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 394b79283..0804d7ffe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -92,6 +92,7 @@ "@logtape/file": "catalog:", "@logtape/logtape": "catalog:", "@poppanator/http-constants": "^1.1.1", + "@resvg/resvg-wasm": "^2.6.2", "byte-encodings": "catalog:", "chalk": "^5.6.2", "cli-highlight": "^2.1.11", diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index 930380213..503bf3ee7 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -6,6 +6,7 @@ import { getAsciiArt, getFaviconUrl, Jimp, + rasterizeSvg, readImage, rgbTo256Color, runNodeInfo, @@ -74,7 +75,7 @@ const HTML_WITH_SVG_ONLY = ` `; -test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => { +test("getFaviconUrl - svg icon is selected when no bitmap exists", async () => { fetchMock.spyGlobal(); fetchMock.get("https://8.8.8.8/", { @@ -83,7 +84,7 @@ test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => { }); const result = await getFaviconUrl("https://8.8.8.8/"); - assert.equal(result.href, "https://8.8.8.8/favicon.ico"); + assert.equal(result.href, "https://8.8.8.8/icon.svg"); }); const HTML_WITH_UPPERCASE_SVG_ONLY = ` @@ -97,7 +98,7 @@ const HTML_WITH_UPPERCASE_SVG_ONLY = ` `; -test("getFaviconUrl - uppercase svg icons only falls back to /favicon.ico", async () => { +test("getFaviconUrl - uppercase svg icon is selected", async () => { fetchMock.spyGlobal(); fetchMock.get("https://8.8.8.8/", { @@ -106,7 +107,7 @@ test("getFaviconUrl - uppercase svg icons only falls back to /favicon.ico", asyn }); const result = await getFaviconUrl("https://8.8.8.8/"); - assert.equal(result.href, "https://8.8.8.8/favicon.ico"); + assert.equal(result.href, "https://8.8.8.8/icon.SVG"); }); const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = ` @@ -120,7 +121,7 @@ const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = ` `; -test("getFaviconUrl - uppercase svg icons with query and hash fall back to /favicon.ico", async () => { +test("getFaviconUrl - uppercase svg icon with query and hash is selected", async () => { fetchMock.spyGlobal(); fetchMock.get("https://8.8.8.8/", { @@ -129,7 +130,7 @@ test("getFaviconUrl - uppercase svg icons with query and hash fall back to /favi }); const result = await getFaviconUrl("https://8.8.8.8/"); - assert.equal(result.href, "https://8.8.8.8/favicon.ico"); + assert.equal(result.href, "https://8.8.8.8/icon.SVG?v=1#icon"); }); const HTML_WITH_SVG_TYPE_ONLY = ` @@ -143,7 +144,7 @@ const HTML_WITH_SVG_TYPE_ONLY = ` `; -test("getFaviconUrl - svg icons with type attribute only falls back to /favicon.ico", async () => { +test("getFaviconUrl - svg icon with type attribute only is selected", async () => { fetchMock.spyGlobal(); fetchMock.get("https://example.com/", { @@ -152,7 +153,7 @@ test("getFaviconUrl - svg icons with type attribute only falls back to /favicon. }); const result = await getFaviconUrl("https://example.com/"); - assert.equal(result.href, "https://example.com/favicon.ico"); + assert.equal(result.href, "https://example.com/icon"); }); const HTML_WITH_SIZES_ANY_ICON = ` @@ -465,6 +466,20 @@ async function runNodeInfoWithFavicon(): Promise { }); } +async function captureOutput(run: () => Promise): Promise { + const originalLog = console.log; + const output: string[] = []; + console.log = (...values: unknown[]) => { + output.push(values.join(" ")); + }; + try { + await run(); + } finally { + console.log = originalLog; + } + return output.join("\n"); +} + test("getFaviconUrl rejects a private page before fetch", async () => { await withMockFetch( { "http://127.0.0.1:9000/": () => new Response("") }, @@ -509,9 +524,14 @@ test("nodeinfo rejects private and non-HTTP icon URLs", async () => { nodeInfoRoutes(''), async (calls) => { await runNodeInfoWithFavicon(); - assert.equal( - calls.length, - 3, + assert.deepEqual( + calls.map((call) => call.url), + [ + "https://8.8.8.8/.well-known/nodeinfo", + "https://8.8.8.8/nodeinfo/2.1", + "https://8.8.8.8/", + "https://8.8.8.8/favicon.ico", + ], "No icon fetch should occur for " + href, ); assert.ok(calls.every((call) => call.redirect === "manual")); @@ -535,6 +555,7 @@ test("nodeinfo rejects an icon redirect to loopback", async () => { "https://8.8.8.8/nodeinfo/2.1", "https://8.8.8.8/", "https://8.8.8.8/icon.png", + "https://8.8.8.8/favicon.ico", ]); assert.ok(calls.every((call) => call.redirect === "manual")); }); @@ -549,13 +570,8 @@ test("nodeinfo renders a public favicon", async () => { headers: { "Content-Type": "image/png" }, }); - const originalLog = console.log; - const output: string[] = []; - console.log = (...values: unknown[]) => { - output.push(values.join(" ")); - }; - try { - await withMockFetch(routes, async (calls) => { + const output = await captureOutput(() => + withMockFetch(routes, async (calls) => { await runNodeInfoWithFavicon(); assert.deepEqual(calls.map((call) => call.url), [ "https://8.8.8.8/.well-known/nodeinfo", @@ -564,11 +580,115 @@ test("nodeinfo renders a public favicon", async () => { "https://8.8.8.8/icon.png", ]); assert.ok(calls.every((call) => call.redirect === "manual")); + }) + ); + assert.match(output, /█/); +}); + +test("nodeinfo renders an SVG favicon", async () => { + const routes = nodeInfoRoutes(''); + routes["https://8.8.8.8/icon.svg"] = () => + new Response( + '' + + '', + { headers: { "Content-Type": "image/svg+xml" } }, + ); + const output = await captureOutput(() => + withMockFetch(routes, runNodeInfoWithFavicon) + ); + assert.match(output, /█/); +}); + +test("nodeinfo renders an SVG favicon served as octet-stream", async () => { + const routes = nodeInfoRoutes(''); + routes["https://8.8.8.8/icon.svg"] = () => + new Response( + '' + + '', + { headers: { "Content-Type": "application/octet-stream" } }, + ); + const output = await captureOutput(() => + withMockFetch(routes, runNodeInfoWithFavicon) + ); + assert.match(output, /█/); +}); + +test("nodeinfo renders an SVG favicon with a content type parameter", async () => { + const routes = nodeInfoRoutes(''); + routes["https://8.8.8.8/icon.svg"] = () => + new Response( + '' + + '', + { headers: { "Content-Type": "Image/SVG+XML; charset=utf-8" } }, + ); + const output = await captureOutput(() => + withMockFetch(routes, runNodeInfoWithFavicon) + ); + assert.match(output, /█/); +}); + +test("nodeinfo falls back when an SVG favicon cannot be rendered", async () => { + const routes = nodeInfoRoutes(''); + routes["https://8.8.8.8/icon.svg"] = () => + new Response(" + withMockFetch(routes, runNodeInfoWithFavicon) + ); + assert.match(output, /test v1\.0\.0/); + assert.doesNotMatch(output, /[█▓▒░]/); +}); + +test("nodeinfo retries /favicon.ico when the chosen favicon fails", async () => { + const png = await new Jimp({ width: 4, height: 4, color: 0x000000ff }) + .getBuffer("image/png"); + const routes = nodeInfoRoutes(''); + routes["https://8.8.8.8/icon.svg"] = () => + new Response(null, { status: 404 }); + routes["https://8.8.8.8/favicon.ico"] = () => + new Response(Uint8Array.from(png).buffer, { + headers: { "Content-Type": "image/png" }, }); - } finally { - console.log = originalLog; - } - assert.match(output.join("\n"), /█/); + const output = await captureOutput(() => + withMockFetch(routes, runNodeInfoWithFavicon) + ); + assert.match(output, /█/); +}); + +test("rasterizeSvg - fits a wide SVG to the width", async () => { + const png = await rasterizeSvg( + new TextEncoder().encode( + '' + + '', + ), + ); + const image = await readImage(png); + assert.deepEqual([image.width, image.height], [38, 19]); +}); + +test("rasterizeSvg - fits a tall SVG to the height", async () => { + const png = await rasterizeSvg( + new TextEncoder().encode( + '' + + '', + ), + ); + const image = await readImage(png); + assert.deepEqual([image.width, image.height], [19, 38]); +}); + +test("rasterizeSvg - rejects an SVG whose size rounds to zero", async () => { + const svg = new TextEncoder().encode( + '', + ); + await assert.rejects(() => rasterizeSvg(svg), RangeError); +}); + +test("rasterizeSvg - rejects an SVG with only text", async () => { + const svg = new TextEncoder().encode( + '' + + 'F', + ); + await assert.rejects(() => rasterizeSvg(svg), /no visible pixels/); }); test("getFaviconUrl resolves fallback from final page URL", async () => { diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index ab6570cb8..599477c10 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -22,6 +22,7 @@ import type { ChalkInstance } from "chalk"; import { isICO, parseICO } from "icojs"; import { defaultFormats, defaultPlugins, intToRGBA } from "jimp"; import { Buffer } from "node:buffer"; +import { readFile } from "node:fs/promises"; import os from "node:os"; import process from "node:process"; import ora from "ora"; @@ -33,6 +34,7 @@ import { colors, formatObject } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "nodeinfo"]); const MAX_FAVICON_REDIRECTS = 5; +const SVG_SNIFF_LENGTH = 1024; async function fetchFaviconResource( url: URL | string, @@ -74,6 +76,69 @@ type ImageBuffer = ArrayBuffer | Uint8Array; const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +type ResvgModule = typeof import("@resvg/resvg-wasm"); + +let svgRasterizer: Promise | undefined; + +function loadSvgRasterizer(): Promise { + return svgRasterizer ??= (async () => { + try { + const resvg = await import("@resvg/resvg-wasm"); + const wasmUrl = import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"); + await resvg.initWasm(await readFile(new URL(wasmUrl))); + return resvg; + } catch (error) { + logger.debug("Failed to load the SVG rasterizer: {error}", { error }); + throw error; + } + })(); +} + +export async function rasterizeSvg(buffer: ImageBuffer): Promise { + const svg = toUint8Array(buffer); + const { Resvg } = await loadSvgRasterizer(); + const probe = new Resvg(svg); + const { width, height } = probe; + probe.free(); + if (width < 1 || height < 1) { + throw new RangeError(`Unusable SVG dimensions: ${width}x${height}`); + } + const resvg = new Resvg(svg, { + fitTo: { + mode: width >= height ? "width" : "height", + value: DEFAULT_IMAGE_WIDTH, + }, + }); + try { + const rendered = resvg.render(); + try { + // Resvg WASM has no fonts, so text-only SVGs render fully transparent + if (!hasVisiblePixel(rendered.pixels)) { + throw new Error("The SVG favicon has no visible pixels."); + } + return rendered.asPng(); + } finally { + rendered.free(); + } + } finally { + resvg.free(); + } +} + +function hasVisiblePixel(pixels: Uint8Array): boolean { + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] !== 0) return true; + } + return false; +} + +function isSvg(buffer: ImageBuffer): boolean { + const text = new TextDecoder().decode( + toUint8Array(buffer).subarray(0, SVG_SNIFF_LENGTH), + ); + return /]/i.test(text); +} + function toUint8Array(buffer: ImageBuffer): Uint8Array { return buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); } @@ -187,6 +252,38 @@ export const nodeInfoCommand = Command( nodeInfoMetadata, ); +async function renderFavicon(url: URL, userAgent?: string): Promise { + const { response } = await fetchFaviconResource(url, userAgent); + if (!response.ok) { + await response.body?.cancel(); + throw new Error( + `Failed to fetch the favicon: ${response.status} ${response.statusText}`, + ); + } + const contentType = response.headers.get("Content-Type") + ?.split(";")[0].trim().toLowerCase(); + let buffer: ImageBuffer = await response.arrayBuffer(); + if ( + contentType === "image/vnd.microsoft.icon" || + contentType === "image/x-icon" || + isICO(buffer) + ) { + const images = await parseICO(buffer); + if (images.length < 1) { + throw new Error("No images found in the ICO file."); + } + buffer = images[0].buffer; + } else if ( + !isPng(buffer) && (contentType === "image/svg+xml" || isSvg(buffer)) + ) { + buffer = await rasterizeSvg(buffer); + } + const image = await readImage(buffer); + const colorSupport = checkTerminalColorSupport(); + return getAsciiArt(image, DEFAULT_IMAGE_WIDTH, colorSupport, colors) + .split("\n").map((line) => ` ${line} `); +} + export async function runNodeInfo( command: InferValue, ) { @@ -238,36 +335,18 @@ export async function runNodeInfo( spinner.text = "Fetching the favicon..."; try { const faviconUrl = await getFaviconUrl(url, command.userAgent); - const { response } = await fetchFaviconResource( - faviconUrl, - command.userAgent, - ); - if (response.ok) { - const contentType = response.headers.get("Content-Type"); - let buffer: ArrayBuffer = await response.arrayBuffer(); - if ( - contentType === "image/vnd.microsoft.icon" || - contentType === "image/x-icon" || - isICO(buffer) - ) { - const images = await parseICO(buffer); - if (images.length < 1) { - throw new Error("No images found in the ICO file."); - } - buffer = images[0].buffer; - } - const image = await readImage(buffer); - const colorSupport = checkTerminalColorSupport(); - layout = getAsciiArt(image, DEFAULT_IMAGE_WIDTH, colorSupport, colors) - .split("\n").map((line) => ` ${line} `); - defaultWidth = 41; - } else { - logger.error( - "Failed to fetch the favicon: {status} {statusText}", - { status: response.status, statusText: response.statusText }, + const fallbackUrl = new URL("/favicon.ico", url); + try { + layout = await renderFavicon(faviconUrl, command.userAgent); + } catch (error) { + if (faviconUrl.href === fallbackUrl.href) throw error; + logger.debug( + "Failed to fetch or render {url}; retrying {fallbackUrl}: {error}", + { url: faviconUrl.href, fallbackUrl: fallbackUrl.href, error }, ); - layout = [""]; + layout = await renderFavicon(fallbackUrl, command.userAgent); } + defaultWidth = 41; } catch (error) { logger.error( "Failed to fetch or render the favicon: {error}", @@ -388,6 +467,7 @@ export async function getFaviconUrl( userAgent, ); const text = await response.text(); + let svgCandidate: URL | undefined; for (const match of text.matchAll(LINK_REGEXP)) { const attrs: Record = {}; for (const attrMatch of match[1].matchAll(LINK_ATTRS_REGEXP)) { @@ -398,21 +478,26 @@ export async function getFaviconUrl( } const rel = attrs.rel?.toLowerCase()?.trim()?.split(/\s+/) ?? []; if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue; - const tokens = attrs.sizes?.match(/\d+x\d+/g); - if ( - tokens != null && !tokens.some((token) => { - const [w, h] = token.split("x").map((v) => Number.parseInt(v)); - return w >= 38 && h >= 19; - }) - ) continue; if ("href" in attrs) { const parsedUrl = new URL(attrs.href, finalUrl); - if (parsedUrl.pathname.toLowerCase().endsWith(".svg")) continue; - if (attrs.type?.toLowerCase()?.trim() === "image/svg+xml") continue; + if ( + parsedUrl.pathname.toLowerCase().endsWith(".svg") || + attrs.type?.toLowerCase()?.trim() === "image/svg+xml" + ) { + svgCandidate ??= parsedUrl; + continue; + } + const tokens = attrs.sizes?.match(/\d+x\d+/g); + if ( + tokens != null && !tokens.some((token) => { + const [w, h] = token.split("x").map((v) => Number.parseInt(v)); + return w >= 38 && h >= 19; + }) + ) continue; return parsedUrl; } } - return new URL("/favicon.ico", finalUrl); + return svgCandidate ?? new URL("/favicon.ico", finalUrl); } function checkTerminalColorSupport(): "truecolor" | "256color" | "none" { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f34791dc7..222cab7d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1206,6 +1206,9 @@ importers: '@poppanator/http-constants': specifier: ^1.1.1 version: 1.1.1 + '@resvg/resvg-wasm': + specifier: ^2.6.2 + version: 2.6.2 '@standard-schema/spec': specifier: 'catalog:' version: 1.1.0 @@ -7494,6 +7497,10 @@ packages: '@repeaterjs/repeater@3.1.0': resolution: {integrity: sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==} + '@resvg/resvg-wasm@2.6.2': + resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} + engines: {node: '>= 10'} + '@rolldown/binding-android-arm64@1.0.0-rc.12': resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -25553,6 +25560,8 @@ snapshots: '@repeaterjs/repeater@3.1.0': {} + '@resvg/resvg-wasm@2.6.2': {} + '@rolldown/binding-android-arm64@1.0.0-rc.12': optional: true