diff --git a/CHANGES.md b/CHANGES.md
index cdac996b2..42690e03a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -8,6 +8,18 @@ Version 2.5.0
To be released.
+### @fedify/cli
+
+ - 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.
+ [[#893], [#1187] by Lee Jeongmin\]
+
+[#893]: https://github.com/fedify-dev/fedify/issues/893
+[#1187]: https://github.com/fedify-dev/fedify/pull/1187
+
Version 2.4.0
-------------
diff --git a/changes.d/cli/improve-favicon-selection.md b/changes.d/cli/improve-favicon-selection.md
new file mode 100644
index 000000000..af631d7fe
--- /dev/null
+++ b/changes.d/cli/improve-favicon-selection.md
@@ -0,0 +1,11 @@
+---
+links:
+ '#1187': https://github.com/fedify-dev/fedify/pull/1187
+ '#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.
+ [[#893], [#1187] by Lee Jeongmin]
diff --git a/packages/cli/src/nodeinfo.test.ts b/packages/cli/src/nodeinfo.test.ts
index 5e0d2f111..0ebd2b8f4 100644
--- a/packages/cli/src/nodeinfo.test.ts
+++ b/packages/cli/src/nodeinfo.test.ts
@@ -1,7 +1,7 @@
-import assert from "node:assert/strict";
-import { test } from "node:test";
import { Chalk } from "chalk";
import fetchMock from "fetch-mock";
+import assert from "node:assert/strict";
+import { afterEach, test } from "node:test";
import {
getAsciiArt,
getFaviconUrl,
@@ -10,6 +10,10 @@ import {
rgbTo256Color,
} from "./nodeinfo.ts";
+afterEach(() => {
+ fetchMock.hardReset();
+});
+
const HTML_WITH_SMALL_ICON = `
@@ -32,8 +36,6 @@ test("getFaviconUrl - small favicon.ico and apple-touch-icon.png", async () => {
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/apple-touch-icon.png");
-
- fetchMock.hardReset();
});
const HTML_WITH_ICON = `
@@ -58,8 +60,6 @@ test("getFaviconUrl - favicon.ico and apple-touch-icon.png", async () => {
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/favicon.ico");
-
- fetchMock.hardReset();
});
const HTML_WITH_SVG_ONLY = `
@@ -83,8 +83,6 @@ test("getFaviconUrl - svg icons only falls back to /favicon.ico", async () => {
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/favicon.ico");
-
- fetchMock.hardReset();
});
const HTML_WITH_UPPERCASE_SVG_ONLY = `
@@ -92,7 +90,7 @@ const HTML_WITH_UPPERCASE_SVG_ONLY = `
Test Site
-
+
Test
@@ -108,8 +106,6 @@ test("getFaviconUrl - uppercase svg icons only falls back to /favicon.ico", asyn
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/favicon.ico");
-
- fetchMock.hardReset();
});
const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = `
@@ -117,7 +113,7 @@ const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = `
Test Site
-
+
Test
@@ -133,8 +129,123 @@ test("getFaviconUrl - uppercase svg icons with query and hash fall back to /favi
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/favicon.ico");
+});
- fetchMock.hardReset();
+const HTML_WITH_SVG_TYPE_ONLY = `
+
+
+
+ Test Site
+
+
+Test
+
+`;
+
+test("getFaviconUrl - svg icons with type attribute only falls back to /favicon.ico", async () => {
+ fetchMock.spyGlobal();
+
+ fetchMock.get("https://example.com/", {
+ body: HTML_WITH_SVG_TYPE_ONLY,
+ headers: { "Content-Type": "text/html" },
+ });
+
+ const result = await getFaviconUrl("https://example.com/");
+ assert.equal(result.href, "https://example.com/favicon.ico");
+});
+
+const HTML_WITH_SIZES_ANY_ICON = `
+
+
+
+ Test Site
+
+
+Test
+
+`;
+
+test("getFaviconUrl - icons with sizes='any' is selected", async () => {
+ fetchMock.spyGlobal();
+
+ fetchMock.get("https://example.com/", {
+ body: HTML_WITH_SIZES_ANY_ICON,
+ headers: { "Content-Type": "text/html" },
+ });
+
+ const result = await getFaviconUrl("https://example.com/");
+ assert.equal(result.href, "https://example.com/favicon.png");
+});
+
+const HTML_WITH_MULTI_SIZE_ICON = `
+
+
+
+ Test Site
+
+
+
+Test
+
+`;
+
+test("getFaviconUrl - icons with multiple sizes", async () => {
+ fetchMock.spyGlobal();
+
+ fetchMock.get("https://example.com/", {
+ body: HTML_WITH_MULTI_SIZE_ICON,
+ headers: { "Content-Type": "text/html" },
+ });
+
+ const result = await getFaviconUrl("https://example.com/");
+ assert.equal(result.href, "https://example.com/multi-size-icon.ico");
+});
+
+const HTML_WITH_PREFERRED_BITMAP_ICON = `
+
+
+
+ Test Site
+
+
+
+Test
+
+`;
+
+test("getFaviconUrl - prefer bitmap icons", async () => {
+ fetchMock.spyGlobal();
+
+ fetchMock.get("https://example.com/", {
+ body: HTML_WITH_PREFERRED_BITMAP_ICON,
+ headers: { "Content-Type": "text/html" },
+ });
+
+ const result = await getFaviconUrl("https://example.com/");
+ assert.equal(result.href, "https://example.com/favicon.png");
+});
+
+const HTML_WITH_MULTIPLE_REL_TOKENS = `
+
+
+
+ Test Site
+
+
+Test
+
+`;
+
+test("getFaviconUrl - icon with multiple rel tokens", async () => {
+ fetchMock.spyGlobal();
+
+ fetchMock.get("https://example.com/", {
+ body: HTML_WITH_MULTIPLE_REL_TOKENS,
+ headers: { "Content-Type": "text/html" },
+ });
+
+ const result = await getFaviconUrl("https://example.com/");
+ assert.equal(result.href, "https://example.com/favicon.png");
});
const HTML_WITHOUT_ICON = `
@@ -157,8 +268,6 @@ test("getFaviconUrl - falls back to /favicon.ico", async () => {
const result = await getFaviconUrl("https://example.com/");
assert.equal(result.href, "https://example.com/favicon.ico");
-
- fetchMock.hardReset();
});
test("rgbTo256Color - check RGB cube", () => {
diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts
index 1ed07e136..fde225eca 100644
--- a/packages/cli/src/nodeinfo.ts
+++ b/packages/cli/src/nodeinfo.ts
@@ -371,13 +371,17 @@ export async function getFaviconUrl(
}
const rel = attrs.rel?.toLowerCase()?.trim()?.split(/\s+/) ?? [];
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
- if ("sizes" in attrs && attrs.sizes.match(/\d+x\d+/)) {
- const [w, h] = attrs.sizes.split("x").map((v) => Number.parseInt(v));
- if (w < 38 || h < 19) 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, response.url);
if (parsedUrl.pathname.toLowerCase().endsWith(".svg")) continue;
+ if (attrs.type?.toLowerCase()?.trim() === "image/svg+xml") continue;
return parsedUrl;
}
}