@cosyte/script-utils 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.js ADDED
@@ -0,0 +1,489 @@
1
+ // @cosyte/script-utils/readme
2
+ //
3
+ // THE README GATE FOR EVERY `@cosyte/*` LIBRARY: the house skeleton, and a size limit under npm's.
4
+ //
5
+ // A library's README ships inside its npm tarball, and the registry keeps only the first 65,536
6
+ // characters of it: past that, npmjs.com renders a README cut off mid-sentence, and nothing says so.
7
+ // This gate refuses a README longer than 60,000 characters, which leaves a release room to grow
8
+ // before npm cuts it, and checks the skeleton every library README shares, so a reader arriving from
9
+ // npm, GitHub or the docs site finds the same page in the same order.
10
+ //
11
+ // THE SKELETON, top to bottom. Each item is one rule id, and each id is what a finding names:
12
+ //
13
+ // tile the shared lockup tile in a <picture> element, byte for byte (`TILE`)
14
+ // tile-alt the tile's alt text is the one assets `alt-text.json` declares for it
15
+ // title `# <package name>` after the tile
16
+ // tagline one blockquote line after the title, at most `TAGLINE_LIMIT` characters
17
+ // badges the badge row this file defines (`badgeRow`): npm version, CI, license, Node
18
+ // section-* the `##` sections in `REQUIRED_SECTIONS`, present, in order, and not empty
19
+ // docs-link a link to the package's page on docs.cosyte.com, after `## Usage` begins and
20
+ // before `## Support`
21
+ // license-last `## License` is the last heading
22
+ // license-link the License section links the repository's LICENSE by absolute URL, because a
23
+ // relative link resolves on GitHub and breaks on npm
24
+ // size at most `README_CHARACTER_LIMIT` characters
25
+ //
26
+ // Other sections may sit between the required ones; what is graded is that the required ones are
27
+ // there, in this order, and that License closes the file.
28
+ //
29
+ // WHAT IS DERIVED, AND FROM WHERE. Everything package-specific comes from the package's own
30
+ // `package.json`: the name (the title, the npm badge and the docs slug), the repository (the CI and
31
+ // license URLs) and `engines.node` (the Node badge). Nothing is configured, so there is no setting
32
+ // that switches a rule off. A manifest that cannot supply one of those is a refusal (exit 2), not a
33
+ // finding, because the gate could not work out what the README should say.
34
+ //
35
+ // EXIT CODES, the contract every gate here keeps: 0 the README follows the skeleton, 1 at least one
36
+ // finding, 2 the gate could not read its input (no README, no package.json, an unusable manifest, an
37
+ // argument it does not know).
38
+ //
39
+ // ZERO DEPENDENCIES AND NO BUILD STEP, like the rest of this package, so a gate that runs before
40
+ // `pnpm install` can import this file by relative path.
41
+
42
+ import { readFileSync } from "node:fs";
43
+ import { join, resolve } from "node:path";
44
+
45
+ import { isCliEntrypoint } from "./index.js";
46
+
47
+ /**
48
+ * The tile's alt text, as assets `alt-text.json` declares it for
49
+ * `tile/cosyte-lockup-tile-on-light-1200x300.png`, the image the `<img>` element carries.
50
+ */
51
+ export const TILE_ALT = "The Cosyte logo on its own white ground: the icon beside the word Cosyte.";
52
+
53
+ /**
54
+ * The shared lockup tile, which opens every library README. The URLs are absolute because npm does
55
+ * not resolve relative images, and the `<img>` fallback carries the light tile, so a renderer that
56
+ * drops `<source>` still shows one.
57
+ */
58
+ export const TILE = [
59
+ '<a href="https://cosyte.com">',
60
+ " <picture>",
61
+ ' <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">',
62
+ ` <img alt="${TILE_ALT}" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">`,
63
+ " </picture>",
64
+ "</a>",
65
+ ].join("\n");
66
+
67
+ /** npm's own cut: the registry keeps this many characters of a README. */
68
+ export const NPM_README_LIMIT = 65_536;
69
+
70
+ /**
71
+ * This gate's limit, in characters as JavaScript counts them (UTF-16 code units, which is never
72
+ * fewer than the code points npm counts). Well under npm's cut, so a README that passes here still
73
+ * has room for a release's additions before npm truncates it.
74
+ */
75
+ export const README_CHARACTER_LIMIT = 60_000;
76
+
77
+ /** A tagline is one line a reader takes in at a glance. */
78
+ export const TAGLINE_LIMIT = 120;
79
+
80
+ /** The `##` sections every library README carries, in this order, with License last. */
81
+ export const REQUIRED_SECTIONS = Object.freeze([
82
+ "Why this exists",
83
+ "Status",
84
+ "Install",
85
+ "Usage",
86
+ "Support",
87
+ "Security",
88
+ "Contributing",
89
+ "License",
90
+ ]);
91
+
92
+ /** Every rule id a finding can carry, with what it requires. */
93
+ export const README_RULES = Object.freeze({
94
+ tile: "The README opens with the shared lockup tile, byte for byte.",
95
+ "tile-alt": `The tile's alt text is "${TILE_ALT}".`,
96
+ title: "The tile is followed by `# <package name>`.",
97
+ tagline: `The title is followed by a one-line blockquote tagline of at most ${TAGLINE_LIMIT} characters.`,
98
+ badges:
99
+ "The tagline is followed by the badge row: npm version, CI, license and Node, in that order.",
100
+ "section-missing": `Every required section is present: ${REQUIRED_SECTIONS.join(", ")}.`,
101
+ "section-order": "The required sections appear in that order.",
102
+ "section-empty": "No required section is empty.",
103
+ "docs-link":
104
+ "A link to the package's page on docs.cosyte.com sits after `## Usage` begins and before `## Support`.",
105
+ "license-last": "`## License` is the last heading.",
106
+ "license-link": "The License section links the repository's LICENSE by its absolute GitHub URL.",
107
+ size: `The README is at most ${README_CHARACTER_LIMIT.toLocaleString("en-US")} characters.`,
108
+ });
109
+
110
+ /** The facts the skeleton is derived from, read out of a package.json. */
111
+ function packageFacts(manifest) {
112
+ if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
113
+ throw new TypeError("package.json is not a JSON object.");
114
+ }
115
+ const name = manifest.name;
116
+ const scoped = typeof name === "string" ? /^@cosyte\/([a-z0-9][a-z0-9.-]*)$/.exec(name) : null;
117
+ if (scoped === null) {
118
+ throw new TypeError(
119
+ `package.json names ${JSON.stringify(name)}, not a @cosyte/<name> package, so the title, the ` +
120
+ `npm badge and the docs link cannot be derived.`,
121
+ );
122
+ }
123
+ const repositoryUrl =
124
+ typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
125
+ const repo =
126
+ typeof repositoryUrl === "string"
127
+ ? /github\.com[/:]cosyte\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(repositoryUrl)
128
+ : null;
129
+ if (repo === null) {
130
+ throw new TypeError(
131
+ `package.json's repository (${JSON.stringify(repositoryUrl ?? null)}) is not a ` +
132
+ `github.com/cosyte/<repo> URL, so the CI and license links cannot be derived.`,
133
+ );
134
+ }
135
+ const node = manifest.engines?.node;
136
+ if (typeof node !== "string" || node.trim() === "") {
137
+ throw new TypeError(
138
+ "package.json declares no engines.node, so the Node badge cannot be derived.",
139
+ );
140
+ }
141
+ return { name, slug: scoped[1], repo: repo[1], node: node.trim() };
142
+ }
143
+
144
+ /** Escape a value for a shields.io static badge path segment: `-` and `_` double, then encode. */
145
+ function shieldsText(value) {
146
+ return encodeURIComponent(value.replace(/-/g, "--").replace(/_/g, "__"));
147
+ }
148
+
149
+ /**
150
+ * The badge row a library README carries, one badge per line, derived from its package.json.
151
+ *
152
+ * @param {Record<string, unknown>} manifest The parsed package.json.
153
+ * @returns {string[]} The four badge lines, in order.
154
+ */
155
+ export function badgeRow(manifest) {
156
+ const { name, repo, node } = packageFacts(manifest);
157
+ const github = `https://github.com/cosyte/${repo}`;
158
+ return [
159
+ `[![npm version](https://img.shields.io/npm/v/${name}.svg)](https://www.npmjs.com/package/${name})`,
160
+ `[![CI](https://img.shields.io/github/actions/workflow/status/cosyte/${repo}/ci.yml?branch=main&label=CI)](${github}/actions/workflows/ci.yml)`,
161
+ `[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](${github}/blob/main/LICENSE)`,
162
+ `[![Node](https://img.shields.io/badge/node-${shieldsText(node)}-brightgreen.svg)](https://nodejs.org)`,
163
+ ];
164
+ }
165
+
166
+ /** The absolute LICENSE URL the License section links. */
167
+ function licenseUrl(repo) {
168
+ return `https://github.com/cosyte/${repo}/blob/main/LICENSE`;
169
+ }
170
+
171
+ /**
172
+ * An ATX heading's level and text, or `null` when the line is not one.
173
+ *
174
+ * Read with index arithmetic rather than one regular expression: a pattern of the shape
175
+ * `[ \t]+(.*?)[ \t]*#*[ \t]*$` backtracks polynomially on a long run of tabs or spaces, and a
176
+ * README is input this gate does not control.
177
+ */
178
+ function parseHeading(line) {
179
+ let level = 0;
180
+ while (level < line.length && line[level] === "#") level += 1;
181
+ if (level === 0 || level > 6) return null;
182
+ if (line[level] !== " " && line[level] !== "\t") return null;
183
+ let text = line.slice(level).trim();
184
+ // An optional closing run of `#`, which counts only when a space or tab comes before it.
185
+ let end = text.length;
186
+ while (end > 0 && text[end - 1] === "#") end -= 1;
187
+ if (end === 0) text = "";
188
+ else if (end < text.length && (text[end - 1] === " " || text[end - 1] === "\t")) {
189
+ text = text.slice(0, end).trimEnd();
190
+ }
191
+ return { level, text };
192
+ }
193
+
194
+ /** Escape a string for use inside a regular expression. */
195
+ function escapeRegExp(value) {
196
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
+ }
198
+
199
+ /** The headings of a Markdown document, skipping fenced code blocks. Lines are 0-based. */
200
+ function headingsOf(lines) {
201
+ const headings = [];
202
+ let fence = null;
203
+ lines.forEach((line, index) => {
204
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
205
+ if (fence !== null) {
206
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length) fence = null;
207
+ return;
208
+ }
209
+ if (marker) {
210
+ fence = marker[1];
211
+ return;
212
+ }
213
+ const heading = parseHeading(line);
214
+ if (heading !== null) headings.push({ ...heading, line: index });
215
+ });
216
+ return headings;
217
+ }
218
+
219
+ /** The index of the first non-blank line at or after `from`, or `lines.length`. */
220
+ function nextContent(lines, from) {
221
+ let i = from;
222
+ while (i < lines.length && lines[i].trim() === "") i += 1;
223
+ return i;
224
+ }
225
+
226
+ /**
227
+ * Check a library README against the house skeleton.
228
+ *
229
+ * @param {string} text The README's contents.
230
+ * @param {Record<string, unknown>} manifest The package's parsed package.json.
231
+ * @returns {{ rule: string, line: number, message: string }[]} The findings, in document order
232
+ * within each rule; empty when the README follows the skeleton. `line` is 1-based, 0 for the
233
+ * whole file.
234
+ * @throws {TypeError} When the manifest cannot supply what the skeleton is derived from.
235
+ */
236
+ export function checkReadme(text, manifest) {
237
+ if (typeof text !== "string")
238
+ throw new TypeError("checkReadme(text, manifest) expects a string.");
239
+ const facts = packageFacts(manifest);
240
+ const findings = [];
241
+ const add = (rule, line, message) => findings.push({ rule, line, message });
242
+ const lines = text.split("\n").map((line) => line.replace(/\r$/, ""));
243
+
244
+ // ---- The head: tile, title, tagline, badges ----------------------------------------------
245
+ const tileLines = TILE.split("\n");
246
+ const head = lines.slice(0, tileLines.length);
247
+ let cursor = 0;
248
+ if (head.join("\n") === TILE) {
249
+ cursor = tileLines.length;
250
+ } else {
251
+ const alt = /<img\b[^>]*\balt="([^"]*)"/.exec(head.join("\n"));
252
+ const withDeclaredAlt = head
253
+ .join("\n")
254
+ .replace(/(<img\b[^>]*\balt=")[^"]*(")/, `$1${TILE_ALT}$2`);
255
+ if (alt !== null && withDeclaredAlt === TILE) {
256
+ add(
257
+ "tile-alt",
258
+ 1 + head.findIndex((line) => line.includes("<img")),
259
+ `the tile's alt text is "${alt[1]}"; it must be the declared "${TILE_ALT}" ` +
260
+ `(assets alt-text.json, tile/cosyte-lockup-tile-on-light-1200x300.png).`,
261
+ );
262
+ cursor = tileLines.length;
263
+ } else {
264
+ add(
265
+ "tile",
266
+ 1,
267
+ `the README must open with the shared lockup tile, starting:\n${tileLines[0]}`,
268
+ );
269
+ // Without the tile, the rest of the head is read from the first heading, so one missing
270
+ // block is one finding rather than four.
271
+ const firstHeading = lines.findIndex((line) => /^#\s/.test(line));
272
+ cursor = firstHeading < 0 ? lines.length : firstHeading;
273
+ }
274
+ }
275
+
276
+ cursor = nextContent(lines, cursor);
277
+ const title = `# ${facts.name}`;
278
+ if (lines[cursor] !== title) {
279
+ add("title", cursor + 1, `the tile must be followed by the title "${title}".`);
280
+ }
281
+ // A wrong H1 is still the title line: step over it, so the tagline is read from where it is.
282
+ if (/^#\s/.test(lines[cursor] ?? "")) cursor += 1;
283
+
284
+ cursor = nextContent(lines, cursor);
285
+ const tagline = /^> (\S.*)$/.exec(lines[cursor] ?? "");
286
+ if (tagline === null) {
287
+ add("tagline", cursor + 1, "the title must be followed by a one-line tagline: `> <tagline>`.");
288
+ } else {
289
+ if ((lines[cursor + 1] ?? "").trim() !== "") {
290
+ add(
291
+ "tagline",
292
+ cursor + 2,
293
+ "the tagline is one line, followed by a blank line; this one continues onto the next.",
294
+ );
295
+ }
296
+ if (tagline[1].length > TAGLINE_LIMIT) {
297
+ add(
298
+ "tagline",
299
+ cursor + 1,
300
+ `the tagline is ${tagline[1].length} characters; the limit is ${TAGLINE_LIMIT}.`,
301
+ );
302
+ }
303
+ cursor += 1;
304
+ while (cursor < lines.length && lines[cursor].trim() !== "") cursor += 1;
305
+ }
306
+
307
+ cursor = nextContent(lines, cursor);
308
+ const badges = badgeRow(manifest);
309
+ const badgeNames = ["npm version", "CI", "license", "Node"];
310
+ for (const [offset, expected] of badges.entries()) {
311
+ if (lines[cursor + offset] !== expected) {
312
+ add(
313
+ "badges",
314
+ cursor + offset + 1,
315
+ `line ${cursor + offset + 1} must be the ${badgeNames[offset]} badge:\n${expected}`,
316
+ );
317
+ break;
318
+ }
319
+ }
320
+ if (
321
+ findings.every((f) => f.rule !== "badges") &&
322
+ (lines[cursor + badges.length] ?? "").trim() !== ""
323
+ ) {
324
+ add(
325
+ "badges",
326
+ cursor + badges.length + 1,
327
+ "the badge row is the four badges and nothing else; follow it with a blank line.",
328
+ );
329
+ }
330
+
331
+ // ---- The sections --------------------------------------------------------------------------
332
+ const headings = headingsOf(lines);
333
+ const sections = headings.filter((h) => h.level === 2);
334
+ const position = new Map();
335
+ for (const name of REQUIRED_SECTIONS) {
336
+ const found = sections.find((h) => h.text === name);
337
+ if (found === undefined) {
338
+ add("section-missing", 0, `the required section "## ${name}" is missing.`);
339
+ } else {
340
+ position.set(name, found);
341
+ }
342
+ }
343
+ const present = REQUIRED_SECTIONS.filter((name) => position.has(name));
344
+ for (let i = 1; i < present.length; i += 1) {
345
+ const before = position.get(present[i - 1]);
346
+ const after = position.get(present[i]);
347
+ if (after.line < before.line) {
348
+ add(
349
+ "section-order",
350
+ after.line + 1,
351
+ `"## ${present[i]}" must come after "## ${present[i - 1]}" (the order is ` +
352
+ `${REQUIRED_SECTIONS.join(", ")}).`,
353
+ );
354
+ break;
355
+ }
356
+ }
357
+ for (const [name, heading] of position) {
358
+ const next = headings.find((h) => h.line > heading.line && h.level <= 2);
359
+ const body = lines.slice(heading.line + 1, next === undefined ? lines.length : next.line);
360
+ if (body.every((line) => line.trim() === "")) {
361
+ add("section-empty", heading.line + 1, `the section "## ${name}" is empty.`);
362
+ }
363
+ }
364
+
365
+ // ---- License last, and linked absolutely --------------------------------------------------
366
+ // A `###` inside the License section belongs to it, so "last" is read over `#` and `##` only.
367
+ const outer = headings.filter((h) => h.level <= 2);
368
+ const lastHeading = outer[outer.length - 1];
369
+ const license = position.get("License");
370
+ if (license !== undefined && lastHeading !== license) {
371
+ add(
372
+ "license-last",
373
+ lastHeading.line + 1,
374
+ `"## License" must be the last heading; "${"#".repeat(lastHeading.level)} ${lastHeading.text}" follows it.`,
375
+ );
376
+ }
377
+ if (license !== undefined) {
378
+ const url = licenseUrl(facts.repo);
379
+ const next = headings.find((h) => h.line > license.line && h.level <= 2);
380
+ const body = lines.slice(license.line + 1, next === undefined ? lines.length : next.line);
381
+ const linked = body.some((line) => line.includes(`](${url})`) || line.includes(`](${url} `));
382
+ if (!linked) {
383
+ add(
384
+ "license-link",
385
+ license.line + 1,
386
+ `the License section must link the LICENSE by its absolute URL, [LICENSE](${url}): a ` +
387
+ `relative link breaks on npm.`,
388
+ );
389
+ }
390
+ }
391
+
392
+ // ---- The docs link -----------------------------------------------------------------------------
393
+ // A Markdown link `](url)` or `](url "title")`, or an autolink `<url>`, to the package's own
394
+ // page or anything under it. Another package's docs page does not count.
395
+ const page = `https://docs\\.cosyte\\.com/${escapeRegExp(facts.slug)}(?:[/#?][^)>\\s]*)?`;
396
+ const docs = new RegExp(`\\]\\(${page}(?:\\)|\\s)|<${page}>`);
397
+ const usage = position.get("Usage");
398
+ const support = position.get("Support");
399
+ const from = usage === undefined ? 0 : usage.line;
400
+ const to = support === undefined ? lines.length : support.line;
401
+ const docsLine = lines.findIndex((line, i) => i > from && i < to && docs.test(line));
402
+ if (docsLine < 0) {
403
+ add(
404
+ "docs-link",
405
+ (usage?.line ?? 0) + 1,
406
+ `link the package's documentation, https://docs.cosyte.com/${facts.slug}, after ` +
407
+ `"## Usage" begins and before "## Support".`,
408
+ );
409
+ }
410
+
411
+ // ---- Size ---------------------------------------------------------------------------------
412
+ if (text.length > README_CHARACTER_LIMIT) {
413
+ add(
414
+ "size",
415
+ 0,
416
+ `the README is ${text.length.toLocaleString("en-US")} characters; the limit is ` +
417
+ `${README_CHARACTER_LIMIT.toLocaleString("en-US")} (npm keeps the first ` +
418
+ `${NPM_README_LIMIT.toLocaleString("en-US")}). Move reference material to ` +
419
+ `https://docs.cosyte.com/${facts.slug}.`,
420
+ );
421
+ }
422
+
423
+ return findings;
424
+ }
425
+
426
+ /**
427
+ * Run the README gate over one package directory: read its README.md and package.json, report,
428
+ * and return the exit code. It writes to stdout on a pass and to stderr otherwise, and never exits.
429
+ *
430
+ * @param {{ dir?: string, argv?: string[] }} [options] `dir` is the package directory (default:
431
+ * the working directory). `argv` is the CLI arguments, read only for `--dir <path>`.
432
+ * @returns {0 | 1 | 2} 0 the README follows the skeleton, 1 a finding, 2 the input was unreadable.
433
+ */
434
+ export function runReadmeGate(options = {}) {
435
+ let dir = options.dir ?? process.cwd();
436
+ const argv = options.argv ?? [];
437
+ for (let i = 0; i < argv.length; i += 1) {
438
+ if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
439
+ dir = argv[i + 1];
440
+ i += 1;
441
+ } else if (argv[i] !== "--") {
442
+ process.stderr.write(
443
+ `README gate: ${JSON.stringify(argv[i])} is not an argument this gate accepts (only ` +
444
+ `--dir <package directory>). Nothing was checked.\n`,
445
+ );
446
+ return 2;
447
+ }
448
+ }
449
+ dir = resolve(dir);
450
+
451
+ let manifest;
452
+ let text;
453
+ try {
454
+ manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
455
+ text = readFileSync(join(dir, "README.md"), "utf8");
456
+ } catch (err) {
457
+ process.stderr.write(`README gate: could not read the package in ${dir}: ${err.message}\n`);
458
+ return 2;
459
+ }
460
+
461
+ let findings;
462
+ try {
463
+ findings = checkReadme(text, manifest);
464
+ } catch (err) {
465
+ process.stderr.write(`README gate: ${err.message} Nothing was checked.\n`);
466
+ return 2;
467
+ }
468
+
469
+ if (findings.length === 0) {
470
+ process.stdout.write(
471
+ `README gate: README.md follows the house skeleton (${text.length.toLocaleString("en-US")} ` +
472
+ `of ${README_CHARACTER_LIMIT.toLocaleString("en-US")} characters).\n`,
473
+ );
474
+ return 0;
475
+ }
476
+ const report = findings
477
+ .map((f) => ` README.md${f.line > 0 ? `:${f.line}` : ""} [${f.rule}] ${f.message}`)
478
+ .join("\n");
479
+ process.stderr.write(
480
+ `README gate: README.md does not follow the house skeleton.\n${report}\n` +
481
+ `${findings.length} finding(s). The skeleton is in the @cosyte/script-utils README, ` +
482
+ `"The README gate".\n`,
483
+ );
484
+ return 1;
485
+ }
486
+
487
+ if (isCliEntrypoint(import.meta.url)) {
488
+ process.exitCode = runReadmeGate({ argv: process.argv.slice(2) });
489
+ }