From 4cb4b5eae16f663dfc84330c7e60d9a4786fb7e1 Mon Sep 17 00:00:00 2001
From: Aaron <318790596+buggypather@users.noreply.github.com>
Date: Mon, 2 Nov 2026 17:18:49 -0500
Subject: [PATCH] Add npm run icon: fetch a doc's icon from its website
Finding, resizing and saving a doc's icon is a manual step today. The script
looks up the website's icon (, apple-touch-icon, then
/apple-touch-icon.png and /favicon.ico), reads PNG and ICO files, scales it to
16 and 32 pixels by area averaging and writes public/icons/docs//16.png,
16@2x.png and SOURCE. It uses only Node's standard library. SVG-only icons
can't be converted without a dependency, so it says so and accepts --icon with
an image address instead.
Co-Authored-By: Claude Opus 5.5
---
docs/adding-docs.md | 2 +-
package.json | 5 +-
scripts/fetch_icon.js | 525 ++++++++++++++++++++++++++++++++
test/scripts/fetch_icon_test.js | 219 +++++++++++++
test/scripts/tsconfig.json | 11 +
5 files changed, 759 insertions(+), 3 deletions(-)
create mode 100644 scripts/fetch_icon.js
create mode 100644 test/scripts/fetch_icon_test.js
create mode 100644 test/scripts/tsconfig.json
diff --git a/docs/adding-docs.md b/docs/adding-docs.md
index 5d69ec9cda..35f0ced90d 100644
--- a/docs/adding-docs.md
+++ b/docs/adding-docs.md
@@ -15,7 +15,7 @@ Adding a documentation may look like a daunting task but once you get the hang o
8. Tweak the scraper/filters and repeat 5) and 6) until the pages and metadata are ok. Only the first run downloads the pages; the ones after that are served from the [response cache](./scraper-reference.md#response-cache), until you run `thor docs:clean`.
9. To customize the pages' styling, create an SCSS file in the `assets/stylesheets/pages/` directory and import it in `application.css.scss`. Both the file and CSS class should be named `_[type]` where [type] is equal to the scraper's `type` attribute (documentations with the same type share the same custom CSS and JS). Setting the type to `simple` will apply the general styling rules in `assets/stylesheets/pages/_simple.scss`, which can be used for documentations where little to no CSS changes are needed.
10. To add syntax highlighting or execute custom JavaScript on the pages, create a file in the `assets/javascripts/views/pages/` directory (take a look at the other files to see how it works).
-11. Add the documentation's icon in the `public/icons/docs/[my_doc]/` directory, in both 16x16 and 32x32-pixels formats. The icon spritesheet is automatically generated when you (re)start your local DevDocs instance.
+11. Add the documentation's icon in the `public/icons/docs/[my_doc]/` directory, in both 16x16 and 32x32-pixels formats. The icon spritesheet is automatically generated when you (re)start your local DevDocs instance. `npm run icon -- [my_doc] [website]` downloads the website's icon and saves it there in both sizes, with a `SOURCE` file giving where it came from; check that it looks right at 16 pixels. If the website only offers an SVG icon, save a PNG of it online and add `--icon [image address]`.
12. Add the documentation's copyright details to `options[:attribution]`. This is the data shown in the table on the [about](https://devdocs.io/about) page, and is ordered alphabetically. Please see an existing scraper for the typesetting.
13. Ensure `thor updates:check [my_doc]` shows the correct latest version.
diff --git a/package.json b/package.json
index 8fe320aec4..cbbce2f9ad 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,9 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --import ./test/assets/setup.js --test test/assets/*_test.js",
- "typecheck": "tsc -p tsconfig.json && tsc -p test/assets/tsconfig.json"
+ "test": "node --import ./test/assets/setup.js --test test/assets/*_test.js test/scripts/*_test.js",
+ "icon": "node scripts/fetch_icon.js",
+ "typecheck": "tsc -p tsconfig.json && tsc -p test/assets/tsconfig.json && tsc -p test/scripts/tsconfig.json"
},
"devDependencies": {
"@types/node": "^24.9.2",
diff --git a/scripts/fetch_icon.js b/scripts/fetch_icon.js
new file mode 100644
index 0000000000..3b83a686b1
--- /dev/null
+++ b/scripts/fetch_icon.js
@@ -0,0 +1,525 @@
+// @ts-check
+
+/**
+ * Downloads a website's icon and saves it the way DevDocs expects.
+ *
+ * npm run icon --
+ * npm run icon -- --icon to use that image instead
+ *
+ * It writes public/icons/docs//16.png, 16@2x.png and SOURCE, which the
+ * spritesheet is built from.
+ *
+ * The icon is looked up in the page's and
+ * tags, then at /apple-touch-icon.png and
+ * /favicon.ico. PNG and ICO files are read; SVG ones can't be, without a
+ * dependency, so the script says so instead.
+ */
+
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { crc32, deflateSync, inflateSync } from "node:zlib";
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+const SLUG_RGX = /^[a-z0-9_.]+(?:~[a-z0-9_.]+)?$/;
+const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
+
+/** An error whose message is meant for the person running the script. */
+export class IconError extends Error {
+ name = "IconError";
+}
+
+/**
+ * @typedef {object} Image
+ * @property {number} width
+ * @property {number} height
+ * @property {Uint8Array} rgba Four bytes a pixel, top row first, not premultiplied.
+ */
+
+// --- Finding the icon ---------------------------------------------------------
+
+/**
+ * The icon addresses to try for a page, best first: the page's own icons, the
+ * smallest of those at least 32 pixels wide first, then the usual addresses.
+ *
+ * @param {string} html
+ * @param {string} pageUrl Where the HTML came from, after redirects.
+ * @returns {string[]}
+ */
+export function iconCandidates(html, pageUrl) {
+ const links = [];
+ // Tried last, so that the reason they can't be used is listed if nothing
+ // else works.
+ const svgs = [];
+ for (const tag of html.match(/]*>/gi) || []) {
+ const rel = attribute(tag, "rel")?.toLowerCase().split(/\s+/) || [];
+ const href = attribute(tag, "href");
+ if (!href || !(rel.includes("icon") || rel.includes("apple-touch-icon"))) continue;
+ let url;
+ try {
+ url = new URL(decodeEntities(href), pageUrl).href;
+ } catch {
+ continue;
+ }
+ const type = attribute(tag, "type")?.toLowerCase() || "";
+ if (type.includes("svg") || /\.svg(?:[?#]|$)/i.test(url)) {
+ svgs.push(url);
+ continue;
+ }
+ const sizes = (attribute(tag, "sizes") || "")
+ .split(/\s+/)
+ .map((size) => parseInt(size, 10))
+ .filter((size) => size > 0);
+ const apple = rel.includes("apple-touch-icon");
+ links.push({ url, size: sizes.length ? Math.max(...sizes) : apple ? 180 : 0 });
+ }
+
+ const rank = ({ size }) => (size >= 32 ? size : size === 0 ? 10_000 : 20_000 - size);
+ links.sort((a, b) => rank(a) - rank(b));
+
+ const origin = new URL(pageUrl).origin;
+ const urls = [
+ ...links.map((link) => link.url),
+ `${origin}/apple-touch-icon.png`,
+ `${origin}/favicon.ico`,
+ ...svgs,
+ ];
+ return [...new Set(urls)];
+}
+
+/**
+ * @param {string} tag
+ * @param {string} name
+ */
+function attribute(tag, name) {
+ const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
+ return match ? (match[1] ?? match[2] ?? match[3]) : undefined;
+}
+
+/** @param {string} text */
+function decodeEntities(text) {
+ return text.replace(/&/g, "&").replace(/&/g, "&");
+}
+
+// --- Reading images -----------------------------------------------------------
+
+/**
+ * @param {Uint8Array} bytes A PNG or ICO file.
+ * @returns {Image}
+ */
+export function decodeImage(bytes) {
+ const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ if (buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return decodePng(buffer);
+ if (buffer.length > 6 && buffer.readUInt16LE(0) === 0 && buffer.readUInt16LE(2) === 1) {
+ return decodeIco(buffer);
+ }
+ const start = buffer.subarray(0, 200).toString("latin1").trimStart().toLowerCase();
+ if (start.startsWith("