diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7b32c0..a66f2d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ Use your desktop's window capture, such as GNOME's Print Screen and its Window m - Write characters as they are, not as `&...;` references. - Any address in the text is the `website` or the `source`. -`pnpm validate` checks the size, the links, the images and the markup. +`pnpm validate` checks the size, the links, the addresses, the images and the markup. Maintainers read the words: a README that tells how to install is sent back. ## Pull request diff --git a/package.json b/package.json index b3672eb..2998f85 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "marked": "18.0.14", + "pngjs": "7.0.0", "twitter-text": "3.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 677c3b0..43149e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: marked: specifier: 18.0.14 version: 18.0.14 + pngjs: + specifier: 7.0.0 + version: 7.0.0 twitter-text: specifier: 3.1.0 version: 3.1.0 @@ -30,6 +33,10 @@ packages: engines: {node: '>= 20'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -47,6 +54,8 @@ snapshots: marked@18.0.14: {} + pngjs@7.0.0: {} + punycode@1.4.1: {} twemoji-parser@11.0.2: {} diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs index 0987a3d..06f28bb 100644 --- a/scripts/catalog.mjs +++ b/scripts/catalog.mjs @@ -1,7 +1,8 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import { crc32, inflateSync } from "node:zlib"; +import { inflateSync } from "node:zlib"; import { Lexer } from "marked"; +import { PNG as Decoder } from "pngjs"; import twitter from "twitter-text"; export const CATEGORIES = { @@ -20,7 +21,7 @@ const REQUIRED = ["id", "name", "author", "description", "category", "platforms" const OPTIONAL = ["building", "stars", "starsUpdatedAt"]; const TIME = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(\.\d+)?(Z|[+-]\d\d:\d\d)$/; const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i; -const URLISH = /[a-z][a-z0-9+.-]*:\/\/[^\s<>()\[\]"'`]*/gi; +const URLISH = /[a-z][a-z0-9+.-]*:\/\/[^\s<>\[\]"'`]*/gi; const TOKENS = new Set(["space", "heading", "paragraph", "text", "strong", "em", "del", "br", "hr", "blockquote", "list", "list_item", "table", "escape", "checkbox", "def", "link", "image"]); /** Twitter's weighted length. */ @@ -32,7 +33,16 @@ function time(value) { if (!m) return false; const [y, mo, d, h, mi, sec] = m.slice(1, 7).map(Number); const at = new Date(Date.UTC(y, mo - 1, d, h, mi, sec)); - return at.getUTCMonth() === mo - 1 && at.getUTCDate() === d && h < 24 && mi < 60 && sec < 60; + const zone = m[8] === "Z" || (Number(m[8].slice(1, 3)) < 24 && Number(m[8].slice(4)) < 60); + return zone && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d && h < 24 && mi < 60 && sec < 60 && !Number.isNaN(Date.parse(value)); +} + +/** An address as written in prose, less the punctuation that ends a sentence. */ +function trim(url) { + let out = url.replace(/[.,;:!?*_~]+$/, ""); + const count = (c) => out.split(c).length - 1; + while (out.endsWith(")") && count(")") > count("(")) out = out.slice(0, -1).replace(/[.,;:!?*_~]+$/, ""); + return out; } function fail(where, what) { @@ -63,31 +73,42 @@ export function github(source) { return m ? { owner: m[1], repo: m[2] } : null; } +const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; +const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]]; + +/** The bytes a PNG's image data must inflate to, from its header. */ +function rasterBytes(bytes) { + const [w, h, depth, color, interlace] = [bytes.readUInt32BE(16), bytes.readUInt32BE(20), bytes[24], bytes[25], bytes[28]]; + const row = (width) => 1 + Math.ceil((width * depth * CHANNELS[color]) / 8); + if (!interlace) return h * row(w); + return ADAM7.reduce((n, [x, y, dx, dy]) => { + const [pw, ph] = [Math.ceil((w - x) / dx), Math.ceil((h - y) / dy)]; + return pw > 0 && ph > 0 ? n + ph * row(pw) : n; + }, 0); +} + +/** The PNG's image data, inflated. */ +function raster(bytes) { + const data = []; + for (let at = 8; at + 8 <= bytes.length; ) { + const length = bytes.readUInt32BE(at); + if (bytes.toString("latin1", at + 4, at + 8) === "IDAT") data.push(bytes.subarray(at + 8, at + 8 + length)); + at += 12 + length; + } + return inflateSync(Buffer.concat(data)); +} + function png(where, dir, name) { if (!FILE.test(name) || !name.endsWith(".png")) fail(where, `${name} must be a lowercase .png file name in the app folder`); const path = join(dir, name); if (!existsSync(path)) fail(where, `${name} is missing`); const bytes = readFileSync(path); if (!bytes.subarray(0, 8).equals(PNG)) fail(where, `${name} is not a PNG`); - const types = []; - const data = []; - for (let at = 8; at < bytes.length; ) { - if (at + 12 > bytes.length) fail(where, `${name} is cut short`); - const length = bytes.readUInt32BE(at); - const end = at + 8 + length; - if (end + 4 > bytes.length) fail(where, `${name} is cut short`); - const type = bytes.toString("latin1", at + 4, at + 8); - if (crc32(bytes.subarray(at + 4, end)) !== bytes.readUInt32BE(end)) fail(where, `${name} has a bad ${type} checksum`); - if (type === "IHDR" && (length !== 13 || !bytes.readUInt32BE(at + 8) || !bytes.readUInt32BE(at + 12))) fail(where, `${name} has a bad header`); - if (type === "IDAT") data.push(bytes.subarray(at + 8, end)); - types.push(type); - at = end + 4; - } - if (types[0] !== "IHDR" || types.at(-1) !== "IEND" || !data.length) fail(where, `${name} is not a whole PNG`); try { - inflateSync(Buffer.concat(data)); + if (raster(bytes).length !== rasterBytes(bytes)) throw new Error("image data does not fill the image"); + Decoder.sync.read(bytes); } catch (error) { - fail(where, `${name} has broken image data: ${error.message}`); + fail(where, `${name} does not decode: ${error.message}`); } } @@ -135,7 +156,7 @@ function readme(dir, app) { const markdown = bytes.toString("utf8"); const links = [app.website, app.source].filter(Boolean); if (ENTITY.test(markdown)) fail(where, `write characters as they are, not as references: ${markdown.match(ENTITY)[0]}`); - for (const url of markdown.replaceAll("\\", "").match(URLISH) ?? []) if (!links.includes(url)) fail(where, `addresses go only to the website or source: ${url}`); + for (const url of markdown.replaceAll("\\", "").match(URLISH) ?? []) if (!links.includes(trim(url))) fail(where, `addresses go only to the website or source: ${url}`); const walk = (tokens) => { for (const t of tokens) { if (!TOKENS.has(t.type)) fail(where, `${t.type === "html" ? "raw HTML" : t.type === "code" ? "a code block" : t.type} is not allowed: ${t.raw.trim().slice(0, 60)}`); diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index c8d111b..201b405 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -15,9 +15,13 @@ function chunk(type, data) { return Buffer.concat([head, data, sum]); } -/** A one-pixel gray PNG. */ -const header = Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0]); -const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", deflateSync(Buffer.from([0, 128]))), chunk("IEND", Buffer.alloc(0))]); +/** A gray PNG `side` pixels square whose data is one pixel, its scanline led by `filter`. */ +function png(filter, side = 1) { + const header = Buffer.from([0, 0, 0, side, 0, 0, 0, side, 8, 0, 0, 0, 0]); + const image = deflateSync(Buffer.from([filter, 1])); + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", header), chunk("IDAT", image), chunk("IEND", Buffer.alloc(0))]); +} +const PNG = png(0); const broken = Buffer.from(PNG); broken[broken.length - 20] ^= 0xff; @@ -55,6 +59,12 @@ test("a good app reads back with its README", () => { assert.deepEqual(featured, ["tally"]); }); +test("addresses in prose may end a sentence or hold parentheses", () => { + const app = { ...good, website: "https://tally.app/foo(bar)" }; + const readme = "See https://github.com/ada/tally. Or [the site](https://tally.app/foo(bar)), (https://github.com/ada/tally).\n"; + assert.equal(read(catalog({ app, files: { "README.md": readme } })).apps.length, 1); +}); + test("the empty catalog reads", () => { const root = mkdtempSync(join(tmpdir(), "showcase-")); mkdirSync(join(root, "apps")); @@ -76,11 +86,15 @@ const bad = [ ["a remote preview", { app: { ...good, previews: ["https://x.io/a.png"] } }, /lowercase .png file name/], ["a missing preview", { app: { ...good, previews: ["preview1.png"] } }, /preview1.png is missing/], ["a preview that is no PNG", { app: { ...good, previews: ["fake.png"] }, files: { "fake.png": "GIF89a" } }, /not a PNG/], - ["a PNG cut short", { app: { ...good, previews: ["cut.png"] }, files: { "cut.png": PNG.subarray(0, 40) } }, /cut short/], - ["a PNG with a bad checksum", { app: { ...good, previews: ["bad.png"] }, files: { "bad.png": broken } }, /checksum/], + ["a PNG cut short", { app: { ...good, previews: ["cut.png"] }, files: { "cut.png": PNG.subarray(0, 40) } }, /does not decode/], + ["a PNG with a bad checksum", { app: { ...good, previews: ["bad.png"] }, files: { "bad.png": broken } }, /does not decode/], + ["a PNG with too little data", { app: { ...good, previews: ["short.png"] }, files: { "short.png": png(0, 4) } }, /does not fill the image/], + ["a PNG with an unknown filter", { app: { ...good, previews: ["odd.png"] }, files: { "odd.png": png(99) } }, /does not decode/], ["a category list", { app: { ...good, category: ["work"] } }, /category must be one of/], ["no publishedAt", { app: { ...good, publishedAt: undefined } }, /publishedAt is required/], ["a time without its zone", { app: { ...good, publishedAt: "2026-10-04T00:00:00" } }, /with its zone/], + ["a zone past 23 hours", { app: { ...good, publishedAt: "2026-10-04T00:00:00+99:99" } }, /with its zone/], + ["a zone past 59 minutes", { app: { ...good, publishedAt: "2026-10-04T00:00:00+01:60" } }, /with its zone/], ["a day that does not exist", { app: { ...good, publishedAt: "2026-02-30T00:00:00Z" } }, /with its zone/], ["an encoded host", { app: { ...good, website: "https://good.example@evil.com/" } }, /plain host/], ["a host with credentials", { app: { ...good, website: "https://good.example@evil.com/" } }, /plain host/],