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
17 changes: 13 additions & 4 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions changes.d/cli/improve-favicon-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
11 changes: 11 additions & 0 deletions changes.d/cli/support-svg-favicons.md
Original file line number Diff line number Diff line change
@@ -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]
6 changes: 6 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<text>` 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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 142 additions & 22 deletions packages/cli/src/nodeinfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getAsciiArt,
getFaviconUrl,
Jimp,
rasterizeSvg,
readImage,
rgbTo256Color,
runNodeInfo,
Expand Down Expand Up @@ -74,7 +75,7 @@ const HTML_WITH_SVG_ONLY = `
</html>
`;

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/", {
Expand All @@ -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 = `
Expand All @@ -97,7 +98,7 @@ const HTML_WITH_UPPERCASE_SVG_ONLY = `
</html>
`;

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/", {
Expand All @@ -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 = `
Expand All @@ -120,7 +121,7 @@ const HTML_WITH_UPPERCASE_SVG_WITH_QUERY = `
</html>
`;

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/", {
Expand All @@ -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 = `
Expand All @@ -143,7 +144,7 @@ const HTML_WITH_SVG_TYPE_ONLY = `
</html>
`;

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/", {
Expand All @@ -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 = `
Expand Down Expand Up @@ -465,6 +466,20 @@ async function runNodeInfoWithFavicon(): Promise<void> {
});
}

async function captureOutput(run: () => Promise<void>): Promise<string> {
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("") },
Expand Down Expand Up @@ -509,9 +524,14 @@ test("nodeinfo rejects private and non-HTTP icon URLs", async () => {
nodeInfoRoutes('<link rel="icon" href="' + href + '">'),
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"));
Expand All @@ -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"));
});
Expand All @@ -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",
Expand All @@ -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('<link rel="icon" href="/icon.svg">');
routes["https://8.8.8.8/icon.svg"] = () =>
new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">' +
'<rect width="4" height="4"/></svg>',
{ 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('<link rel="icon" href="/icon.svg">');
routes["https://8.8.8.8/icon.svg"] = () =>
new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">' +
'<rect width="4" height="4"/></svg>',
{ 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('<link rel="icon" href="/icon.svg">');
routes["https://8.8.8.8/icon.svg"] = () =>
new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4">' +
'<rect width="4" height="4"/></svg>',
{ 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('<link rel="icon" href="/icon.svg">');
routes["https://8.8.8.8/icon.svg"] = () =>
new Response("<svg", { headers: { "Content-Type": "image/svg+xml" } });
const output = await captureOutput(() =>
withMockFetch(routes, runNodeInfoWithFavicon)
);
assert.match(output, /test v1\.0\.0/);
Comment thread
userjmmm marked this conversation as resolved.
Resolved
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('<link rel="icon" href="/icon.svg">');
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(
Comment thread
userjmmm marked this conversation as resolved.
Resolved
new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" width="512" height="256">' +
'<rect width="512" height="256"/></svg>',
),
);
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(
'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="512">' +
'<rect width="256" height="512"/></svg>',
),
);
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(
'<svg xmlns="http://www.w3.org/2000/svg" width="0.00001" height="0.49"/>',
);
await assert.rejects(() => rasterizeSvg(svg), RangeError);
});

test("rasterizeSvg - rejects an SVG with only text", async () => {
const svg = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">' +
'<text x="0" y="24" font-size="24">F</text></svg>',
);
await assert.rejects(() => rasterizeSvg(svg), /no visible pixels/);
});

test("getFaviconUrl resolves fallback from final page URL", async () => {
Expand Down
Loading
Loading