From 2ade934fcce2f0413918e0025889d68ff35b4af1 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Fri, 30 Oct 2026 14:28:57 +0900 Subject: [PATCH 01/16] Add resvg-wasm for SVG rasterization Declare @resvg/resvg-wasm in the CLI manifests and update both lockfiles to prepare for converting SVG favicons to bitmap images. Codex assisted with dependency setup guidance. Changelog: none Assisted-by: Codex:gpt-6.1-sol --- deno.lock | 6 +++++ packages/cli/deno.json | 1 + packages/cli/package.json | 1 + pnpm-lock.yaml | 54 +++++++++++++++++++++++++++------------ 4 files changed, 46 insertions(+), 16 deletions(-) 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index f34791dc7..45393a0ef 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 @@ -26183,7 +26192,7 @@ snapshots: - react-native-b4a - supports-color - '@stackbit/cms-contentful@0.4.56(debug@4.4.1)': + '@stackbit/cms-contentful@0.4.56(debug@4.4.1)(graphql@16.14.2)': dependencies: '@contentful/rich-text-types': 15.15.1 '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) @@ -26197,6 +26206,7 @@ snapshots: - bare-buffer - debug - encoding + - graphql - react-native-b4a - supports-color @@ -26272,47 +26282,57 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/cms-git@0.4.18': + '@stackbit/cms-git@0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': dependencies: '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) + '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 - axios: 0.24.0 + axios: 0.24.0(debug@4.4.1) fs-extra: 11.3.6 git-url-parse: 13.1.1 lodash: 4.18.1 micromatch: 4.0.8 slugify: 1.6.9 transitivePeerDependencies: + - '@types/react' - bare-abort-controller - bare-buffer + - bufferutil + - canvas - debug - encoding + - graphql - react-native-b4a - supports-color + - utf-8-validate - '@stackbit/cms-git@0.4.18(debug@4.4.1)': + '@stackbit/cms-git@0.4.18(@types/react@19.1.8)(graphql@16.14.2)': dependencies: '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 - axios: 0.24.0(debug@4.4.1) + axios: 0.24.0 fs-extra: 11.3.6 git-url-parse: 13.1.1 lodash: 4.18.1 micromatch: 4.0.8 slugify: 1.6.9 transitivePeerDependencies: + - '@types/react' - bare-abort-controller - bare-buffer + - bufferutil + - canvas - debug - encoding + - graphql - react-native-b4a - supports-color + - utf-8-validate '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)': dependencies: @@ -26341,7 +26361,7 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)(debug@4.4.1)': + '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': dependencies: '@sanity/block-tools': 2.36.6 '@sanity/client': 3.4.1 @@ -26364,6 +26384,7 @@ snapshots: - canvas - debug - encoding + - graphql - react-native-b4a - supports-color - utf-8-validate @@ -26374,7 +26395,7 @@ snapshots: '@stackbit/artisanal-names': 1.0.1 '@stackbit/cms-contentful': 0.4.56 '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18 + '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(graphql@16.14.2) '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 @@ -26408,14 +26429,14 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/dev-common@0.5.51(@types/react@19.1.8)(debug@4.4.1)': + '@stackbit/dev-common@0.5.51(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': dependencies: '@iarna/toml': 2.2.5 '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-contentful': 0.4.56(debug@4.4.1) + '@stackbit/cms-contentful': 0.4.56(debug@4.4.1)(graphql@16.14.2) '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(debug@4.4.1) - '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8)(debug@4.4.1) + '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 @@ -26444,6 +26465,7 @@ snapshots: - canvas - debug - encoding + - graphql - react-native-b4a - supports-color - utf-8-validate @@ -26451,8 +26473,8 @@ snapshots: '@stackbit/dev@1.0.35(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': dependencies: '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(debug@4.4.1) - '@stackbit/dev-common': 0.5.51(@types/react@19.1.8)(debug@4.4.1) + '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/dev-common': 0.5.51(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 axios: 0.25.0(debug@4.4.1) chalk: 4.1.2 @@ -26486,7 +26508,7 @@ snapshots: '@stackbit/dev@1.0.35(@types/react@19.1.8)(graphql@16.14.2)': dependencies: '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18 + '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(graphql@16.14.2) '@stackbit/dev-common': 0.5.51(@types/react@19.1.8) '@stackbit/sdk': 2.0.1 axios: 0.25.0 From e2a01c5a7d2340e9f0dee16754c8e838e1f68731 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Fri, 30 Oct 2026 15:34:53 +0900 Subject: [PATCH 02/16] Update tests to verify SVG favicon selection Expect SVG favicon URLs instead of the /favicon.ico fallback for SVG-only pages, including uppercase extensions, query strings and fragments, and icons identified only by MIME type. Keep the existing bitmap preference test unchanged. Ran the NodeInfo tests on Deno: the four updated tests fail as expected before implementation, and the other 22 pass. Codex assisted with reviewing the test changes. Changelog: none Assisted-by: Codex:gpt-6.1-sol --- packages/cli/src/nodeinfo.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index 930380213..4b808b9c2 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -74,7 +74,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 +83,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 +97,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 +106,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 +120,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 +129,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 +143,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 +152,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 = ` From 54a310b6c0645c6f7e14986f29c04b034a93a18e Mon Sep 17 00:00:00 2001 From: userjmmm Date: Fri, 30 Oct 2026 17:59:31 +0900 Subject: [PATCH 03/16] Rasterize SVG favicons when found Initialize the WebAssembly module in loadSvgRasterizer() only when an SVG is actually rendered, so that it is loaded lazily. - Fit SVGs in rasterizeSvg() to DEFAULT_IMAGE_WIDTH, the size at which the ASCII art is shown in the terminal. - Use the first declared SVG icon instead of /favicon.ico when no bitmap icon is declared. Claude Code reviewed and revised the code, and Codex verified that the changes satisfy the scope of the issue. https://github.com/fedify-dev/fedify/issues/1238 Assisted-by: Claude Code:claude-opus-5-5 Assisted-by: Codex:gpt-6.1-sol --- changes.d/cli/improve-favicon-selection.md | 9 ++-- changes.d/cli/support-svg-favicons.md | 8 ++++ packages/cli/src/nodeinfo.ts | 50 ++++++++++++++++++++-- 3 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 changes.d/cli/support-svg-favicons.md 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..1ed548429 --- /dev/null +++ b/changes.d/cli/support-svg-favicons.md @@ -0,0 +1,8 @@ +--- +links: + '#1238': https://github.com/fedify-dev/fedify/issues/1238 +--- + - 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. [[#1238] by Lee Jeongmin] diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index ab6570cb8..274062406 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,38 @@ 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 () => { + 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; + })(); +} + +export async function rasterizeSvg(buffer: ImageBuffer): Promise { + const svg = toUint8Array(buffer); + const { Resvg } = await loadSvgRasterizer(); + const { width, height } = new Resvg(svg); + return new Resvg(svg, { + fitTo: { + mode: width >= height ? "width" : "height", + value: DEFAULT_IMAGE_WIDTH, + }, + }).render().asPng(); +} + +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); } @@ -244,7 +278,7 @@ export async function runNodeInfo( ); if (response.ok) { const contentType = response.headers.get("Content-Type"); - let buffer: ArrayBuffer = await response.arrayBuffer(); + let buffer: ImageBuffer = await response.arrayBuffer(); if ( contentType === "image/vnd.microsoft.icon" || contentType === "image/x-icon" || @@ -255,6 +289,8 @@ export async function runNodeInfo( throw new Error("No images found in the ICO file."); } buffer = images[0].buffer; + } else if (contentType === "image/svg+xml" || isSvg(buffer)) { + buffer = await rasterizeSvg(buffer); } const image = await readImage(buffer); const colorSupport = checkTerminalColorSupport(); @@ -388,6 +424,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)) { @@ -407,12 +444,17 @@ export async function getFaviconUrl( ) 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; + } return parsedUrl; } } - return new URL("/favicon.ico", finalUrl); + return svgCandidate ?? new URL("/favicon.ico", finalUrl); } function checkTerminalColorSupport(): "truecolor" | "256color" | "none" { From a5daf46590708072af367f144600c46241f5b0a0 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Fri, 30 Oct 2026 18:21:18 +0900 Subject: [PATCH 04/16] Add tests for SVG favicon rasterization Verify that the changes satisfy the scope of the issue: - Rasterize an SVG favicon before readImage(). - Limit the size of a large SVG to the terminal art width. - Fall back to rendering NodeInfo without a favicon when the SVG cannot be rendered. Move the console output capture into captureOutput() so that the new tests can share it with the existing favicon test. Claude Code reviewed and revised the tests. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.test.ts | 66 +++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index 4b808b9c2..cce72ba5b 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, @@ -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("") }, @@ -549,13 +564,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 +574,43 @@ test("nodeinfo renders a public favicon", async () => { "https://8.8.8.8/icon.png", ]); assert.ok(calls.every((call) => call.redirect === "manual")); - }); - } finally { - console.log = originalLog; - } - assert.match(output.join("\n"), /█/); + }) + ); + 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 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/); +}); + +test("rasterizeSvg - limits the size of a large SVG", async () => { + const png = await rasterizeSvg( + new TextEncoder().encode( + '', + ), + ); + const image = await readImage(png); + assert.deepEqual([image.width, image.height], [38, 19]); }); test("getFaviconUrl resolves fallback from final page URL", async () => { From f2e74a467628f47081e12cc5b363bf98af76fc6a Mon Sep 17 00:00:00 2001 From: userjmmm Date: Fri, 30 Oct 2026 19:21:04 +0900 Subject: [PATCH 05/16] Add the PR reference to the changelog --- CHANGES.md | 14 ++++++++++---- changes.d/cli/support-svg-favicons.md | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d577a6b86..7e70e571e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -84,6 +84,10 @@ 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. [[#1238] 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 +104,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,6 +120,7 @@ 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 diff --git a/changes.d/cli/support-svg-favicons.md b/changes.d/cli/support-svg-favicons.md index 1ed548429..4b1f99d76 100644 --- a/changes.d/cli/support-svg-favicons.md +++ b/changes.d/cli/support-svg-favicons.md @@ -1,8 +1,9 @@ --- 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. [[#1238] by Lee Jeongmin] + preferred when a page declares both. [[#1238], [#1346] by Lee Jeongmin] From a9a69ffbc399ec5081af6f460cd8bfc8ae90fd2b Mon Sep 17 00:00:00 2001 From: userjmmm Date: Mon, 2 Nov 2026 17:22:13 +0900 Subject: [PATCH 06/16] Sync changelog and lockfile after rebase Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 3 ++- pnpm-lock.yaml | 45 ++++++++++++++++----------------------------- 2 files changed, 18 insertions(+), 30 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 7e70e571e..9d6567281 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -87,7 +87,7 @@ To be released. - 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. [[#1238] by Lee Jeongmin\] + preferred when a page declares both. [[#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 @@ -127,6 +127,7 @@ To be released. [#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/pnpm-lock.yaml b/pnpm-lock.yaml index 45393a0ef..222cab7d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26192,7 +26192,7 @@ snapshots: - react-native-b4a - supports-color - '@stackbit/cms-contentful@0.4.56(debug@4.4.1)(graphql@16.14.2)': + '@stackbit/cms-contentful@0.4.56(debug@4.4.1)': dependencies: '@contentful/rich-text-types': 15.15.1 '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) @@ -26206,7 +26206,6 @@ snapshots: - bare-buffer - debug - encoding - - graphql - react-native-b4a - supports-color @@ -26282,57 +26281,47 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/cms-git@0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': + '@stackbit/cms-git@0.4.18': dependencies: '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 - axios: 0.24.0(debug@4.4.1) + axios: 0.24.0 fs-extra: 11.3.6 git-url-parse: 13.1.1 lodash: 4.18.1 micromatch: 4.0.8 slugify: 1.6.9 transitivePeerDependencies: - - '@types/react' - bare-abort-controller - bare-buffer - - bufferutil - - canvas - debug - encoding - - graphql - react-native-b4a - supports-color - - utf-8-validate - '@stackbit/cms-git@0.4.18(@types/react@19.1.8)(graphql@16.14.2)': + '@stackbit/cms-git@0.4.18(debug@4.4.1)': dependencies: '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) + '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 - axios: 0.24.0 + axios: 0.24.0(debug@4.4.1) fs-extra: 11.3.6 git-url-parse: 13.1.1 lodash: 4.18.1 micromatch: 4.0.8 slugify: 1.6.9 transitivePeerDependencies: - - '@types/react' - bare-abort-controller - bare-buffer - - bufferutil - - canvas - debug - encoding - - graphql - react-native-b4a - supports-color - - utf-8-validate '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)': dependencies: @@ -26361,7 +26350,7 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': + '@stackbit/cms-sanity@0.2.56(@types/react@19.1.8)(debug@4.4.1)': dependencies: '@sanity/block-tools': 2.36.6 '@sanity/client': 3.4.1 @@ -26384,7 +26373,6 @@ snapshots: - canvas - debug - encoding - - graphql - react-native-b4a - supports-color - utf-8-validate @@ -26395,7 +26383,7 @@ snapshots: '@stackbit/artisanal-names': 1.0.1 '@stackbit/cms-contentful': 0.4.56 '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(graphql@16.14.2) + '@stackbit/cms-git': 0.4.18 '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 @@ -26429,14 +26417,14 @@ snapshots: - supports-color - utf-8-validate - '@stackbit/dev-common@0.5.51(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': + '@stackbit/dev-common@0.5.51(@types/react@19.1.8)(debug@4.4.1)': dependencies: '@iarna/toml': 2.2.5 '@stackbit/artisanal-names': 1.0.1 - '@stackbit/cms-contentful': 0.4.56(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-contentful': 0.4.56(debug@4.4.1) '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-git': 0.4.18(debug@4.4.1) + '@stackbit/cms-sanity': 0.2.56(@types/react@19.1.8)(debug@4.4.1) '@stackbit/sdk': 2.0.1 '@stackbit/types': 1.0.1 '@stackbit/utils': 0.4.19 @@ -26465,7 +26453,6 @@ snapshots: - canvas - debug - encoding - - graphql - react-native-b4a - supports-color - utf-8-validate @@ -26473,8 +26460,8 @@ snapshots: '@stackbit/dev@1.0.35(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2)': dependencies: '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) - '@stackbit/dev-common': 0.5.51(@types/react@19.1.8)(debug@4.4.1)(graphql@16.14.2) + '@stackbit/cms-git': 0.4.18(debug@4.4.1) + '@stackbit/dev-common': 0.5.51(@types/react@19.1.8)(debug@4.4.1) '@stackbit/sdk': 2.0.1 axios: 0.25.0(debug@4.4.1) chalk: 4.1.2 @@ -26508,7 +26495,7 @@ snapshots: '@stackbit/dev@1.0.35(@types/react@19.1.8)(graphql@16.14.2)': dependencies: '@stackbit/cms-core': 2.0.1(@types/react@19.1.8)(graphql@16.14.2) - '@stackbit/cms-git': 0.4.18(@types/react@19.1.8)(graphql@16.14.2) + '@stackbit/cms-git': 0.4.18 '@stackbit/dev-common': 0.5.51(@types/react@19.1.8) '@stackbit/sdk': 2.0.1 axios: 0.25.0 From cc4eb2d12d6a21ec9573c0ec90d15776c9c5ba95 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 15:11:19 +0900 Subject: [PATCH 07/16] Reject degenerate SVG sizes before rendering As pointed out in the review, Resvg rounds its width and height getters, so an SVG with a tiny intrinsic size could be rendered into a huge image instead of falling back. Throw a RangeError when either rounded dimension is below 1, before the renderer is constructed. Also free the probe right after reading its size, and free the renderer and the rendered image with try/finally. Add a regression test that expects rasterizeSvg() to reject the reported size. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.test.ts | 7 +++++++ packages/cli/src/nodeinfo.ts | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index cce72ba5b..af6a51dc1 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -613,6 +613,13 @@ test("rasterizeSvg - limits the size of a large SVG", async () => { assert.deepEqual([image.width, image.height], [38, 19]); }); +test("rasterizeSvg - rejects an SVG whose size rounds to zero", async () => { + const svg = new TextEncoder().encode( + '', + ); + await assert.rejects(() => rasterizeSvg(svg), RangeError); +}); + test("getFaviconUrl resolves fallback from final page URL", async () => { await withMockFetch( { diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 274062406..baa07fd56 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -92,13 +92,28 @@ function loadSvgRasterizer(): Promise { export async function rasterizeSvg(buffer: ImageBuffer): Promise { const svg = toUint8Array(buffer); const { Resvg } = await loadSvgRasterizer(); - const { width, height } = new Resvg(svg); - return new Resvg(svg, { + 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, }, - }).render().asPng(); + }); + try { + const rendered = resvg.render(); + try { + return rendered.asPng(); + } finally { + rendered.free(); + } + } finally { + resvg.free(); + } } function isSvg(buffer: ImageBuffer): boolean { From 93564a7ae68b6bcd16288c6b814a7205baf97a93 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 15:27:31 +0900 Subject: [PATCH 08/16] Check that the SVG fallback test shows no art The test only checked that the NodeInfo summary was printed, which happens whether or not the favicon is rendered. Assert that no terminal art is printed so that the test fails if the SVG path stops falling back. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index af6a51dc1..3964016e1 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -601,6 +601,7 @@ test("nodeinfo falls back when an SVG favicon cannot be rendered", async () => { withMockFetch(routes, runNodeInfoWithFavicon) ); assert.match(output, /test v1\.0\.0/); + assert.doesNotMatch(output, /[█▓▒░]/); }); test("rasterizeSvg - limits the size of a large SVG", async () => { From acb9f65cc48706ea24328ebb5c53cceec54cb36d Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 15:44:15 +0900 Subject: [PATCH 09/16] Test the height fit and SVG content sniffing The existing tests covered rasterizeSvg() and SVG detection only partially: they exercised the width-fitting branch, and only SVG favicons served as image/svg+xml. Add a test that fits a tall SVG to the height, and one that renders an SVG served as application/octet-stream, which is recognized only by isSvg(). Also rename the existing size test so that its name shows the width branch it covers. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.test.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index 3964016e1..ae960fd8a 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -593,6 +593,20 @@ test("nodeinfo renders an SVG favicon", async () => { 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 falls back when an SVG favicon cannot be rendered", async () => { const routes = nodeInfoRoutes(''); routes["https://8.8.8.8/icon.svg"] = () => @@ -604,7 +618,7 @@ test("nodeinfo falls back when an SVG favicon cannot be rendered", async () => { assert.doesNotMatch(output, /[█▓▒░]/); }); -test("rasterizeSvg - limits the size of a large SVG", async () => { +test("rasterizeSvg - fits a wide SVG to the width", async () => { const png = await rasterizeSvg( new TextEncoder().encode( '', @@ -614,6 +628,16 @@ test("rasterizeSvg - limits the size of a large SVG", async () => { 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( '', From 6902626f93cb2ce97f1404601e256e2cee78fc74 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 16:13:21 +0900 Subject: [PATCH 10/16] Check for SVG icons before the size filter An SVG link with sizes=32x32 was dropped by the minimum size filter before it could become the SVG candidate, so a page whose only icon was such an SVG fell back to /favicon.ico. SVGs are resolution independent, so classify them first and apply the size filter only to bitmap icons. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index baa07fd56..6bfad2dcf 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -450,13 +450,6 @@ 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 ( @@ -466,6 +459,13 @@ export async function getFaviconUrl( 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; } } From 0c156dda2a645337c72e9d01fd5fe9566b61f749 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 17:13:16 +0900 Subject: [PATCH 11/16] Fall back when an SVG favicon renders nothing Resvg WASM has no fonts, so an SVG favicon that consists only of text renders fully transparent, and a blank icon was shown instead of the no-favicon fallback. Throw when the rendered pixels have no non-zero alpha, so that the existing fallback runs. Add a test for a text-only SVG, and add a to the SVGs in the size tests, which were empty and would now be rejected. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.test.ts | 14 ++++++++++++-- packages/cli/src/nodeinfo.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index ae960fd8a..1ce29a188 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -621,7 +621,8 @@ test("nodeinfo falls back when an SVG favicon cannot be rendered", async () => { test("rasterizeSvg - fits a wide SVG to the width", async () => { const png = await rasterizeSvg( new TextEncoder().encode( - '', + '' + + '', ), ); const image = await readImage(png); @@ -631,7 +632,8 @@ test("rasterizeSvg - fits a wide SVG to the width", async () => { test("rasterizeSvg - fits a tall SVG to the height", async () => { const png = await rasterizeSvg( new TextEncoder().encode( - '', + '' + + '', ), ); const image = await readImage(png); @@ -645,6 +647,14 @@ test("rasterizeSvg - rejects an SVG whose size rounds to zero", async () => { 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 () => { await withMockFetch( { diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 6bfad2dcf..4f502a6fc 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -107,6 +107,10 @@ export async function rasterizeSvg(buffer: ImageBuffer): Promise { 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(); @@ -116,6 +120,13 @@ export async function rasterizeSvg(buffer: ImageBuffer): Promise { } } +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), From 4836e7d100299183d6de8fd9f83c0e02c0993c68 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 17:28:27 +0900 Subject: [PATCH 12/16] Normalize favicon content types and check PNG first Compare only the lowercased media type, so that values such as `image/svg+xml; charset=utf-8` are recognized for both SVG and ICO. Also check the PNG signature before sniffing for ` { 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"] = () => diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 4f502a6fc..e50a44dc5 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -303,7 +303,8 @@ export async function runNodeInfo( command.userAgent, ); if (response.ok) { - const contentType = response.headers.get("Content-Type"); + const contentType = response.headers.get("Content-Type") + ?.split(";")[0].trim().toLowerCase(); let buffer: ImageBuffer = await response.arrayBuffer(); if ( contentType === "image/vnd.microsoft.icon" || @@ -315,7 +316,9 @@ export async function runNodeInfo( throw new Error("No images found in the ICO file."); } buffer = images[0].buffer; - } else if (contentType === "image/svg+xml" || isSvg(buffer)) { + } else if ( + contentType === "image/svg+xml" || (!isPng(buffer) && isSvg(buffer)) + ) { buffer = await rasterizeSvg(buffer); } const image = await readImage(buffer); From 132e8babfa035935efcd0504901c6b48a5e5e8b7 Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 17:43:27 +0900 Subject: [PATCH 13/16] Log why the SVG rasterizer failed to load Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index e50a44dc5..88936277e 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -82,10 +82,15 @@ let svgRasterizer: Promise | undefined; function loadSvgRasterizer(): Promise { return svgRasterizer ??= (async () => { - 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; + 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; + } })(); } From bd16a8aa1b7b2250b574bbfaf5f37bad93e84bbf Mon Sep 17 00:00:00 2001 From: userjmmm Date: Tue, 3 Nov 2026 18:00:03 +0900 Subject: [PATCH 14/16] Retry `/favicon.ico` when the favicon fails When fetching or rendering the chosen favicon fails, retry `/favicon.ico` once. Move the fetch-and-render steps into `renderFavicon()` so that they can run twice, and throw on a non-OK response so that it is retried as well. Assisted-by: Claude Code:claude-opus-5-5 --- CHANGES.md | 4 +- changes.d/cli/support-svg-favicons.md | 4 +- packages/cli/src/nodeinfo.test.ts | 28 ++++++++-- packages/cli/src/nodeinfo.ts | 75 +++++++++++++++------------ 4 files changed, 73 insertions(+), 38 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 9d6567281..6c264b137 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -87,7 +87,9 @@ To be released. - 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. [[#1238], [#1346] by Lee Jeongmin\] + 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 diff --git a/changes.d/cli/support-svg-favicons.md b/changes.d/cli/support-svg-favicons.md index 4b1f99d76..db15f1c44 100644 --- a/changes.d/cli/support-svg-favicons.md +++ b/changes.d/cli/support-svg-favicons.md @@ -6,4 +6,6 @@ links: - 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. [[#1238], [#1346] by Lee Jeongmin] + 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/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts index e15a0ecb5..503bf3ee7 100644 --- a/packages/cli/src/nodeinfo.test.ts +++ b/packages/cli/src/nodeinfo.test.ts @@ -524,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")); @@ -550,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")); }); @@ -632,6 +638,22 @@ test("nodeinfo falls back when an SVG favicon cannot be rendered", async () => { 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" }, + }); + 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( diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 88936277e..e225dcd90 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -252,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 ( + contentType === "image/svg+xml" || (!isPng(buffer) && 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, ) { @@ -303,41 +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") - ?.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 ( - contentType === "image/svg+xml" || (!isPng(buffer) && isSvg(buffer)) - ) { - buffer = await rasterizeSvg(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}", From 5acab07e7d630656ac889acac1c5f4f23eba354c Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 4 Nov 2026 00:41:52 +0900 Subject: [PATCH 15/16] Check the PNG signature before the SVG content type A PNG served as image/svg+xml was sent to resvg and lost. Apply the PNG signature check to both SVG detection paths, as the ICO path already checks its content as well as its content type. Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- packages/cli/src/nodeinfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index e225dcd90..599477c10 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -274,7 +274,7 @@ async function renderFavicon(url: URL, userAgent?: string): Promise { } buffer = images[0].buffer; } else if ( - contentType === "image/svg+xml" || (!isPng(buffer) && isSvg(buffer)) + !isPng(buffer) && (contentType === "image/svg+xml" || isSvg(buffer)) ) { buffer = await rasterizeSvg(buffer); } From c5695f0ea67b245bb2fbb59f1fd06348c50f347d Mon Sep 17 00:00:00 2001 From: userjmmm Date: Wed, 4 Nov 2026 00:50:24 +0900 Subject: [PATCH 16/16] Document that SVG favicon text is not rendered Changelog: none Assisted-by: Claude Code:claude-opus-5-5 --- docs/cli.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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