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(" 4096 || height > 4096) { + throw new IconError("the PNG image uses a format this script can't read"); + } + + const raw = inflateSync(Buffer.concat(data)); + const bytesPerPixel = Math.max(1, (channels * depth) / 8); + const stride = Math.ceil((width * channels * depth) / 8); + if (raw.length < (stride + 1) * height) throw new IconError("the PNG image is incomplete"); + const rows = unfilter(raw, stride, height, bytesPerPixel); + + const rgba = new Uint8Array(width * height * 4); + const max = (1 << Math.min(depth, 8)) - 1; + for (let y = 0; y < height; y++) { + const row = rows.subarray(y * stride, (y + 1) * stride); + /** @param {number} index Which sample in the row. */ + const sample = (index) => { + if (depth === 16) return row[index * 2]; + if (depth === 8) return row[index]; + const bit = index * depth; + return (row[bit >> 3] >> (8 - depth - (bit & 7))) & max; + }; + for (let x = 0; x < width; x++) { + const out = (y * width + x) * 4; + let r, g, b, a = 255; + if (colorType === 3) { + const i = sample(x); + if (!palette || i * 3 + 2 >= palette.length) throw new IconError("the PNG image is damaged"); + [r, g, b] = [palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2]]; + if (transparency && i < transparency.length) a = transparency[i]; + } else if (colorType === 0 || colorType === 4) { + const grey = sample(x * channels); + r = g = b = depth < 8 ? Math.round((grey * 255) / max) : grey; + if (colorType === 4) a = sample(x * channels + 1); + else if (transparency && transparency.length >= 2 && grey === transparency.readUInt16BE(0) >> (depth === 16 ? 8 : 0)) a = 0; + } else { + r = sample(x * channels); + g = sample(x * channels + 1); + b = sample(x * channels + 2); + if (colorType === 6) a = sample(x * channels + 3); + } + rgba.set([r, g, b, a], out); + } + } + return { width, height, rgba }; +} + +/** + * Undoes PNG's per-row filters. + * + * @param {Buffer} raw + * @param {number} stride + * @param {number} height + * @param {number} bytesPerPixel + */ +function unfilter(raw, stride, height, bytesPerPixel) { + const out = new Uint8Array(stride * height); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const at = y * stride; + for (let x = 0; x < stride; x++) { + const left = x >= bytesPerPixel ? out[at + x - bytesPerPixel] : 0; + const up = y > 0 ? out[at - stride + x] : 0; + const upLeft = y > 0 && x >= bytesPerPixel ? out[at - stride + x - bytesPerPixel] : 0; + let value = line[x]; + if (filter === 1) value += left; + else if (filter === 2) value += up; + else if (filter === 3) value += (left + up) >> 1; + else if (filter === 4) { + const p = left + up - upLeft; + const [pa, pb, pc] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)]; + value += pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + } else if (filter !== 0) throw new IconError("the PNG image is damaged"); + out[at + x] = value & 0xff; + } + } + return out; +} + +/** + * Reads the largest image in an ICO file. + * + * @param {Buffer} buffer + * @returns {Image} + */ +export function decodeIco(buffer) { + const count = buffer.readUInt16LE(4); + let best; + for (let i = 0; i < count; i++) { + const entry = 6 + i * 16; + if (entry + 16 > buffer.length) break; + const width = buffer[entry] || 256; + const size = buffer.readUInt32LE(entry + 8); + const offset = buffer.readUInt32LE(entry + 12); + if (offset + size > buffer.length) continue; + if (!best || width > best.width) best = { width, data: buffer.subarray(offset, offset + size) }; + } + if (!best) throw new IconError("the ICO file holds no images"); + return best.data.subarray(0, 8).equals(PNG_SIGNATURE) ? decodePng(best.data) : decodeBmp(best.data); +} + +/** + * Reads the bitmap form of an image in an ICO file: a BMP header without the + * file header, twice the height (the colours, then a transparency mask). + * + * @param {Buffer} data + * @returns {Image} + */ +function decodeBmp(data) { + const headerSize = data.readUInt32LE(0); + const width = data.readInt32LE(4); + const height = Math.abs(data.readInt32LE(8)) / 2; + const bits = data.readUInt16LE(14); + const compression = data.readUInt32LE(16); + if (compression !== 0 || ![1, 4, 8, 24, 32].includes(bits) || width <= 0 || width > 256) { + throw new IconError("the ICO file uses a format this script can't read"); + } + const colours = bits <= 8 ? data.readUInt32LE(32) || 1 << bits : 0; + const palette = data.subarray(headerSize, headerSize + colours * 4); + const pixels = headerSize + colours * 4; + const stride = Math.ceil((width * bits) / 32) * 4; + const maskStart = pixels + stride * height; + const maskStride = Math.ceil(width / 32) * 4; + if (maskStart > data.length) throw new IconError("the ICO file is damaged"); + + const rgba = new Uint8Array(width * height * 4); + let anyAlpha = false; + for (let y = 0; y < height; y++) { + // Bitmaps are stored bottom row first. + const row = pixels + (height - 1 - y) * stride; + for (let x = 0; x < width; x++) { + const out = (y * width + x) * 4; + if (bits >= 24) { + const at = row + x * (bits / 8); + const a = bits === 32 ? data[at + 3] : 255; + if (bits === 32 && a > 0) anyAlpha = true; + rgba.set([data[at + 2], data[at + 1], data[at], a], out); + } else { + const bit = x * bits; + const index = (data[row + (bit >> 3)] >> (8 - bits - (bit & 7))) & ((1 << bits) - 1); + rgba.set([palette[index * 4 + 2], palette[index * 4 + 1], palette[index * 4], 255], out); + } + } + } + // Without an alpha channel (or with an empty one), the mask says which + // pixels are transparent. + if (bits < 32 || !anyAlpha) { + for (let y = 0; y < height; y++) { + const row = maskStart + (height - 1 - y) * maskStride; + for (let x = 0; x < width; x++) { + const masked = row + (x >> 3) < data.length && (data[row + (x >> 3)] >> (7 - (x & 7))) & 1; + rgba[(y * width + x) * 4 + 3] = masked ? 0 : 255; + } + } + } + return { width, height, rgba }; +} + +// --- Resizing and writing ----------------------------------------------------- + +/** + * Scales an image to fit a square, centred, by averaging the pixels each new + * pixel covers, which keeps small icons crisp where simpler resizing blurs or + * drops detail. + * + * @param {Image} image + * @param {number} size + * @returns {Image} + */ +export function resize(image, size) { + const scale = Math.min(size / image.width, size / image.height); + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const left = Math.floor((size - width) / 2); + const top = Math.floor((size - height) / 2); + + // Premultiplied so that transparent pixels' colours don't bleed into edges. + const source = new Float64Array(image.width * image.height * 4); + for (let i = 0; i < image.width * image.height; i++) { + const a = image.rgba[i * 4 + 3] / 255; + source[i * 4] = image.rgba[i * 4] * a; + source[i * 4 + 1] = image.rgba[i * 4 + 1] * a; + source[i * 4 + 2] = image.rgba[i * 4 + 2] * a; + source[i * 4 + 3] = image.rgba[i * 4 + 3]; + } + const columns = weights(image.width, width); + const rows = weights(image.height, height); + + const rgba = new Uint8Array(size * size * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sum = [0, 0, 0, 0]; + let total = 0; + for (const [sy, wy] of rows[y]) { + for (const [sx, wx] of columns[x]) { + const weight = wx * wy; + const at = (sy * image.width + sx) * 4; + for (let c = 0; c < 4; c++) sum[c] += source[at + c] * weight; + total += weight; + } + } + const a = sum[3] / total; + const out = ((top + y) * size + left + x) * 4; + rgba[out + 3] = Math.round(a); + if (a > 0) { + for (let c = 0; c < 3; c++) { + rgba[out + c] = Math.min(255, Math.round(sum[c] / total / (a / 255))); + } + } + } + } + return { width: size, height: size, rgba }; +} + +/** + * For each new pixel along one side, the old pixels it covers and how much. + * + * @param {number} from + * @param {number} to + * @returns {[number, number][][]} + */ +function weights(from, to) { + const step = from / to; + return Array.from({ length: to }, (_, i) => { + const start = i * step; + const end = start + step; + const cover = []; + for (let s = Math.floor(start); s < Math.min(from, Math.ceil(end)); s++) { + cover.push([s, Math.min(end, s + 1) - Math.max(start, s)]); + } + return /** @type {[number, number][]} */ (cover); + }); +} + +/** + * @param {Image} image + * @returns {Buffer} + */ +export function encodePng({ width, height, rgba }) { + const rows = Buffer.alloc((width * 4 + 1) * height); + for (let y = 0; y < height; y++) { + rows.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1); + } + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", header), + chunk("IDAT", deflateSync(rows, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +/** + * @param {string} type + * @param {Buffer} data + */ +function chunk(type, data) { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, "latin1"); + data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} + +// --- Saving ------------------------------------------------------------------- + +/** + * @param {string} url + * @returns {Promise<{ bytes: Uint8Array, url: string, type: string }>} + */ +async function download(url) { + let response; + try { + response = await fetch(url, { redirect: "follow", headers: { "User-Agent": "DevDocs icon fetcher" } }); + } catch { + throw new IconError(`couldn't connect to ${new URL(url).host}`); + } + if (!response.ok) throw new IconError(`the server answered ${response.status}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length > MAX_DOWNLOAD_BYTES) throw new IconError("the file is too big for an icon"); + return { bytes, url: response.url || url, type: response.headers.get("content-type") || "" }; +} + +/** + * @param {string} website + * @param {string | undefined} iconUrl An image to use instead of looking one up. + * @param {(message: string) => void} log + * @returns {Promise<{ image: Image, url: string }>} + */ +async function findIcon(website, iconUrl, log) { + let candidates = iconUrl ? [iconUrl] : []; + if (!iconUrl) { + const page = await download(website).catch((error) => { + throw new IconError(`Couldn't open ${website}: ${error.message}. Check the address, or pass the icon's own address with --icon.`); + }); + candidates = iconCandidates(new TextDecoder().decode(page.bytes), page.url); + } + const problems = []; + for (const url of candidates) { + try { + const { bytes } = await download(url); + const image = decodeImage(bytes); + if (image.width < 32 || image.height < 32) { + log(`Note: the best icon found is ${image.width}×${image.height} pixels, so the 32-pixel version is enlarged and may look soft.`); + } + return { image, url }; + } catch (error) { + if (!(error instanceof IconError)) throw error; + problems.push(`${url}: ${error.message}`); + } + } + throw new IconError( + `No usable icon was found. Tried:\n ${problems.join("\n ")}\n` + + "Save a PNG of the icon somewhere online and pass its address with --icon, or add the files by hand.", + ); +} + +/** + * @param {string[]} args + * @param {{ root?: string, log?: (message: string) => void }} [options] + */ +export async function run(args, { root = process.cwd(), log = console.log } = {}) { + const positional = []; + const flags = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--icon") flags.icon = args[++i]; + else positional.push(args[i]); + } + const [slug, website] = positional; + if (!slug || !(website || flags.icon) || positional.length > 2) { + throw new IconError( + "Usage: npm run icon -- [--icon ]\n" + + "Example: npm run icon -- vue https://vuejs.org", + ); + } + if (!SLUG_RGX.test(slug)) { + throw new IconError(`"${slug}" isn't a doc slug. Slugs are lower case, like "vue" or "my_notes~2".`); + } + for (const [name, value] of [["website", website], ["--icon", flags.icon]]) { + if (value && !/^https?:\/\//i.test(value)) { + throw new IconError(`The ${name} address should start with https://, like https://vuejs.org.`); + } + } + + const { image, url } = await findIcon(website, flags.icon, log); + + const folder = join(root, "public", "icons", "docs", slug.split("~")[0]); + await mkdir(folder, { recursive: true }); + await writeFile(join(folder, "16.png"), encodePng(resize(image, 16))); + await writeFile(join(folder, "16@2x.png"), encodePng(resize(image, 32))); + await writeFile(join(folder, "SOURCE"), url + "\n"); + log(`Saved the icon from ${url} to ${folder}. Restart DevDocs to rebuild the spritesheet, and check it looks right at 16 pixels.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = fileURLToPath(new URL("..", import.meta.url)); + run(process.argv.slice(2), { root }).catch((error) => { + console.error(error instanceof IconError ? error.message : `Something went wrong: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/test/scripts/fetch_icon_test.js b/test/scripts/fetch_icon_test.js new file mode 100644 index 0000000000..0ca7964406 --- /dev/null +++ b/test/scripts/fetch_icon_test.js @@ -0,0 +1,219 @@ +// @ts-check + +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { deflateSync } from "node:zlib"; + +import { + decodeImage, + encodePng, + iconCandidates, + IconError, + resize, + run, +} from "../../scripts/fetch_icon.js"; + +/** A square image of one colour. */ +function solid(size, [r, g, b, a]) { + const rgba = new Uint8Array(size * size * 4); + for (let i = 0; i < size * size; i++) rgba.set([r, g, b, a], i * 4); + return { width: size, height: size, rgba }; +} + +/** @param {number} x @param {number} y */ +const pixel = (image, x, y) => [...image.rgba.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)]; + +test("a PNG written by the script reads back the same", () => { + const image = solid(4, [10, 20, 30, 128]); + image.rgba.set([255, 0, 0, 255], 0); + const back = decodeImage(encodePng(image)); + assert.equal(back.width, 4); + assert.deepEqual([...back.rgba], [...image.rgba]); +}); + +test("PNG row filters are undone", () => { + // A 2×2 RGB image: row 1 uses the Sub filter, row 2 Paeth. + const header = Buffer.alloc(13); + header.writeUInt32BE(2, 0); + header.writeUInt32BE(2, 4); + header.set([8, 2, 0, 0, 0], 8); + const rows = Buffer.from([ + 1, 100, 50, 0, /* + */ 10, 10, 10, + 4, 5, 5, 5, /* Paeth picks up or left */ 0, 0, 0, + ]); + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(rows)), + chunk("IEND", Buffer.alloc(0)), + ]); + const image = decodeImage(png); + assert.deepEqual(pixel(image, 0, 0), [100, 50, 0, 255]); + assert.deepEqual(pixel(image, 1, 0), [110, 60, 10, 255]); + assert.deepEqual(pixel(image, 0, 1), [105, 55, 5, 255]); + // Paeth: left (105,55,5), up (110,60,10), up-left (100,50,0) → p is nearest up. + assert.deepEqual(pixel(image, 1, 1), [110, 60, 10, 255]); +}); + +function chunk(type, data) { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, "latin1"); + data.copy(out, 8); + // The script doesn't check CRCs, so any value does here. + return out; +} + +/** @param {[number, Buffer][]} entries Each image's width and file. */ +function ico(entries) { + const header = Buffer.alloc(6 + entries.length * 16); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(entries.length, 4); + let offset = header.length; + entries.forEach(([width, data], i) => { + header[6 + i * 16] = width === 256 ? 0 : width; + header.writeUInt32LE(data.length, 6 + i * 16 + 8); + header.writeUInt32LE(offset, 6 + i * 16 + 12); + offset += data.length; + }); + return Buffer.concat([header, ...entries.map(([, data]) => data)]); +} + +test("the largest image in an ICO file is read, PNG or bitmap", () => { + const small = encodePng(solid(16, [0, 0, 255, 255])); + const large = encodePng(solid(48, [0, 255, 0, 255])); + const image = decodeImage(ico([[16, small], [48, large]])); + assert.equal(image.width, 48); + assert.deepEqual(pixel(image, 0, 0), [0, 255, 0, 255]); + + // A 2×2, 32-bit bitmap: bottom row first, BGRA, then the 1-bit mask. + const bmp = Buffer.alloc(40 + 2 * 2 * 4 + 2 * 4); + bmp.writeUInt32LE(40, 0); + bmp.writeInt32LE(2, 4); + bmp.writeInt32LE(4, 8); + bmp.writeUInt16LE(1, 12); + bmp.writeUInt16LE(32, 14); + bmp.set([0, 0, 255, 255, 0, 0, 255, 255], 40); // bottom row: red + bmp.set([255, 0, 0, 255, 255, 0, 0, 0], 48); // top row: blue, then transparent + const fromBmp = decodeImage(ico([[2, bmp]])); + assert.deepEqual(pixel(fromBmp, 0, 0), [0, 0, 255, 255]); + assert.equal(pixel(fromBmp, 1, 0)[3], 0); + assert.deepEqual(pixel(fromBmp, 0, 1), [255, 0, 0, 255]); +}); + +test("an SVG icon is refused with a reason", () => { + assert.throws( + () => decodeImage(new TextEncoder().encode('')), + (error) => error instanceof IconError && /SVG/.test(error.message), + ); +}); + +test("resizing averages pixels without dark edges", () => { + // Left half opaque white, right half transparent black. + const image = solid(64, [0, 0, 0, 0]); + for (let y = 0; y < 64; y++) for (let x = 0; x < 32; x++) image.rgba.set([255, 255, 255, 255], (y * 64 + x) * 4); + const small = resize(image, 16); + assert.deepEqual(pixel(small, 0, 0), [255, 255, 255, 255]); + assert.equal(pixel(small, 15, 0)[3], 0); + + // Odd sizes average partly covered pixels: 3 → 2. + const odd = { width: 3, height: 1, rgba: new Uint8Array([0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255]) }; + const two = resize(odd, 2); + assert.equal(two.width, 2); + assert.equal(pixel(two, 0, 0)[0], 85); // two thirds black, one third white +}); + +test("a wide image is centred in the square", () => { + const wide = { width: 4, height: 2, rgba: solid(4, [255, 0, 0, 255]).rgba.subarray(0, 32) }; + const square = resize(wide, 4); + assert.equal(pixel(square, 0, 0)[3], 0); + assert.deepEqual(pixel(square, 0, 1), [255, 0, 0, 255]); + assert.equal(pixel(square, 0, 3)[3], 0); +}); + +test("the page's icons are tried smallest first from 32 pixels, before the usual addresses", () => { + const html = ` + + + + + + + `; + assert.deepEqual(iconCandidates(html, "https://example.com/docs/"), [ + "https://example.com/favicon-32.png", + "https://example.com/docs/touch.png", + "https://example.com/old.ico?a=1&b=2", + "https://example.com/favicon-16.png", + "https://example.com/apple-touch-icon.png", + "https://example.com/favicon.ico", + "https://example.com/logo.svg", + ]); +}); + +/** Swaps fetch for a fake website for the length of `fn`. */ +async function withSite(files, fn) { + const real = globalThis.fetch; + globalThis.fetch = async (url) => { + const body = files[String(url)]; + return body ? new Response(body, { status: 200 }) : new Response("Not found", { status: 404 }); + }; + try { + return await fn(); + } finally { + globalThis.fetch = real; + } +} + +test("a built-in doc gets both sizes and a SOURCE file", async () => { + const root = await mkdtemp(join(tmpdir(), "icon-")); + try { + await withSite( + { + "https://example.com/": '', + "https://example.com/icon.png": encodePng(solid(64, [200, 0, 0, 255])), + }, + () => run(["example", "https://example.com/"], { root, log: () => {} }), + ); + const folder = join(root, "public", "icons", "docs", "example"); + assert.equal(decodeImage(await readFile(join(folder, "16.png"))).width, 16); + assert.deepEqual(pixel(decodeImage(await readFile(join(folder, "16@2x.png"))), 5, 5), [200, 0, 0, 255]); + assert.equal(await readFile(join(folder, "SOURCE"), "utf8"), "https://example.com/icon.png\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("without icon tags, the usual /favicon.ico is used", async () => { + const root = await mkdtemp(join(tmpdir(), "icon-")); + try { + await withSite( + { + "https://example.com/": "Example", + "https://example.com/favicon.ico": ico([[32, encodePng(solid(32, [0, 0, 200, 255]))]]), + }, + () => run(["notes~2", "https://example.com/"], { root, log: () => {} }), + ); + // Versions share their doc's icon. + const folder = join(root, "public", "icons", "docs", "notes"); + assert.equal(decodeImage(await readFile(join(folder, "16@2x.png"))).width, 32); + assert.equal(await readFile(join(folder, "SOURCE"), "utf8"), "https://example.com/favicon.ico\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("problems are explained in plain sentences", async () => { + await assert.rejects(run([], { log: () => {} }), /Usage: npm run icon/); + await assert.rejects(run(["Vue", "https://vuejs.org"], { log: () => {} }), /isn't a doc slug/); + await assert.rejects(run(["vue", "vuejs.org"], { log: () => {} }), /should start with https:\/\//); + await assert.rejects( + withSite({ "https://example.com/": "

No icons

" }, () => + run(["example", "https://example.com/"], { root: tmpdir(), log: () => {} }), + ), + /No usable icon was found[\s\S]*--icon/, + ); +}); diff --git a/test/scripts/tsconfig.json b/test/scripts/tsconfig.json new file mode 100644 index 0000000000..a0710cc1ae --- /dev/null +++ b/test/scripts/tsconfig.json @@ -0,0 +1,11 @@ +{ + // The icon script and its tests run under Node only. + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["*.js", "../../scripts/*.js"] +}