Catalog: previews decode in full, zones checked, prose addresses trimmed, install steps left to maintainers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8775745d9d
commit
ab42b6f05b
5 files changed
+72
-27
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "18.0.14",
|
||||
"pngjs": "7.0.0",
|
||||
"twitter-text": "3.1.0"
|
||||
}
|
||||
}
|
||||
Generated
+9
@@ -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: {}
|
||||
|
||||
+42
-21
@@ -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)}`);
|
||||
|
||||
+19
-5
@@ -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/],
|
||||
|
||||
Reference in new issue
Block a user