@nurkamol/seo-audit 1.35.0 → 1.37.0

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.md CHANGED
@@ -205,7 +205,62 @@ brew install --cask seo-audit
205
205
  ./mac/build.sh --run
206
206
  ```
207
207
 
208
- <p align="center"><img src="docs/shots/app.png" alt="The macOS app showing a report: 25 pages, 171 findings, 55 things to change" width="820"></p>
208
+ <p align="center"><img src="docs/shots/app.png" alt="The macOS app showing a real audit of astro.build: 60 pages, 232 findings, 37 things to change, scored 87 out of 100" width="820"></p>
209
+
210
+ <p align="center"><em>A real run against astro.build — and the one error at the top is a link to a page that does not exist, which is the bug this tool was written to catch.</em></p>
211
+
212
+ <p align="center"><img src="docs/shots/compare.png" alt="The compare sheet: docs.astro.build against astro.build, showing the 26 findings that appeared" width="820"></p>
213
+
214
+ <p align="center"><em>Compare two runs: a site against itself last week, or one property against another. Different hosts are matched by path.</em></p>
215
+
216
+ #### If you downloaded the zip instead: "SEO Audit is damaged and can't be opened"
217
+
218
+ It is not damaged. The app is **ad-hoc signed** rather than notarised, because
219
+ notarising needs a paid Apple Developer account this project does not have.
220
+ macOS puts a `com.apple.quarantine` flag on anything a browser downloads, and
221
+ for an app without a notarisation ticket Gatekeeper refuses it — with a message
222
+ that says "damaged" and offers to move it to the Trash, which reads exactly like
223
+ malware and is the single most confusing thing about installing this.
224
+
225
+ **`brew install --cask seo-audit` does not have this problem.** Homebrew checks
226
+ the download against a checksum written by the build that produced it, then
227
+ clears the flag for you. That is the recommended route, and the rest of this
228
+ section is for people who would rather not use Homebrew.
229
+
230
+ If you downloaded the zip by hand, verify it first. Every release attaches a
231
+ `SHA256SUMS.txt` covering all four downloads, and lists the same checksums in
232
+ its notes — save it next to the file and:
233
+
234
+ ```bash
235
+ shasum -a 256 --ignore-missing -c SHA256SUMS.txt
236
+ ```
237
+
238
+ `--ignore-missing` because you almost certainly downloaded one of the four, not
239
+ all of them. The same command verifies the `.deb`, the `.AppImage` and the
240
+ `setup.exe`, which matters most on Windows, where the advice for SmartScreen is
241
+ otherwise just "run it anyway".
242
+
243
+ Then, once it matches, clear the flag:
244
+
245
+ ```bash
246
+ xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"
247
+ ```
248
+
249
+ No `sudo`: the app is yours, in a directory you can write to, and the command
250
+ works as you. If it ever answers `Operation not permitted`, the copy is owned by
251
+ another user — `sudo xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"`
252
+ is the fallback, but reach for it second, not first.
253
+
254
+ This is exactly what right-click → **Open** does in the Finder, minus the
255
+ dialog. Do it because the checksum matched, not because a README said to — the
256
+ same command on a file you have not checked is how people get hurt.
257
+
258
+ Or avoid the question entirely and build it yourself, which produces a signature
259
+ your own machine already trusts:
260
+
261
+ ```bash
262
+ ./mac/build.sh --run
263
+ ```
209
264
 
210
265
  SwiftUI throughout, Liquid Glass, and the report drawn natively: cause cards
211
266
  that expand into the pages they affect, filtering, search, and export as PDF,
@@ -478,6 +533,7 @@ reason.
478
533
  |---|---|---|
479
534
  | `--md <file>` | — | Write a Markdown report |
480
535
  | `--html <file>` | — | Write a self-contained HTML report — one file, no assets |
536
+ | `--reports [date]` | — | List the runs kept on this machine and stop. With a date, only those finished on or after it |
481
537
  | `--since <date>` | — | Crawl only URLs the sitemap says changed on or after this date. Refuses when `lastmod` cannot answer it |
482
538
  | `--exclude <glob>` | — | Leave URLs out of the crawl. Repeatable; `*` stops at a slash, `**` does not |
483
539
  | `--dry-run` | — | Say what would be crawled and stop. A handful of requests instead of hundreds |
package/bin/seo-audit.mjs CHANGED
@@ -45,6 +45,8 @@ const HELP = `
45
45
  strings the crawl actually read. A page whose trail
46
46
  has an uncrawled step is skipped rather than given a
47
47
  name invented from its slug
48
+ --reports [date] list the runs kept on this machine and stop; with a
49
+ date, only those finished on or after it
48
50
  --since <date> crawl only URLs the sitemap says changed on or after
49
51
  this date. Refuses when lastmod cannot answer it —
50
52
  absent, or one build stamp on every URL
@@ -152,6 +154,14 @@ function parseArgs(argv) {
152
154
  else if (arg === '--verbose') opts.verbose = true;
153
155
  else if (arg === '--dry-run') opts.dryRun = true;
154
156
  else if (arg === '--since') opts.since = value();
157
+ // The only flag here whose value is optional: `--reports` lists everything
158
+ // kept, `--reports 2026-08-01` lists what was kept since. Peeked rather
159
+ // than consumed, so `--reports` followed by nothing, or by another flag,
160
+ // does not swallow it.
161
+ else if (arg === '--reports') {
162
+ const next = argv[i + 1];
163
+ opts.reports = next !== undefined && !next.startsWith('-') ? argv[++i] : true;
164
+ }
155
165
  // Repeatable: one pattern per flag reads better than one flag with a
156
166
  // comma-separated list, and a URL can contain a comma.
157
167
  else if (arg === '--exclude') (opts.exclude ??= []).push(value());
@@ -299,6 +309,43 @@ const live = (origin) =>
299
309
  // the config may carry its own overrides, which land on top of the shared ones.
300
310
  let sites = resolveSites(cli.targets ?? [], file);
301
311
 
312
+ // The runs kept on this machine. Also a different program: it reads the same
313
+ // folder the window and `--serve` read, and crawls nothing.
314
+ if (opts.reports !== undefined) {
315
+ const { library } = await import('../src/library.mjs');
316
+ const { sinceWhen, keptSince } = await import('../src/kept.mjs');
317
+ const asked = sinceWhen(opts.reports === true ? null : opts.reports);
318
+ if (asked.error) {
319
+ console.error(`\n --reports wants ${asked.error}\n`);
320
+ process.exit(2);
321
+ }
322
+
323
+ const store = library();
324
+ const all = store.list();
325
+ const rows = keptSince(all, asked.at);
326
+
327
+ if (!all.length) {
328
+ console.log(`\n Nothing kept yet. Finished runs are kept in ${store.where()}\n`);
329
+ } else if (!rows.length) {
330
+ // Saying how many were skipped, because an empty list and an empty library
331
+ // read identically and only one of them means "widen the date".
332
+ console.log(`\n None of the ${all.length} kept runs finished on or after that date.\n`);
333
+ } else {
334
+ const width = Math.max(...rows.map((r) => (r.site ?? '').length));
335
+ console.log('');
336
+ for (const row of rows) {
337
+ const when = String(row.finishedAt ?? '').slice(0, 10);
338
+ const score = row.score === undefined || row.score === null ? ' —' : String(row.score).padStart(3);
339
+ console.log(
340
+ ` ${(row.site ?? '').padEnd(width)} ${when} ${String(row.pages ?? '?').padStart(5)} pages ${score}`,
341
+ );
342
+ }
343
+ const shown = rows.length === all.length ? `${all.length}` : `${rows.length} of ${all.length}`;
344
+ console.log(`\n ${shown} kept in ${store.where()}\n`);
345
+ }
346
+ process.exit(0);
347
+ }
348
+
302
349
  // The local UI, which is a different program from here on: no target, no
303
350
  // report file, and it runs until interrupted.
304
351
  // `!== undefined` rather than truthiness: --serve 0 asks the operating system
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurkamol/seo-audit",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Crawl a site's sitemap and check every page for SEO, metadata and structured-data problems that single-page graders miss. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/audit.mjs CHANGED
@@ -15,6 +15,7 @@ import { applyIgnores, expectationChecks, matchGlob } from './config.mjs';
15
15
  import { psiChecks, psiTargets, estimateSeconds } from './psi.mjs';
16
16
  import { sectionOf } from './causes.mjs';
17
17
  import { rebuild, changedSince } from './sitemap.mjs';
18
+ import { plural } from './text.mjs';
18
19
 
19
20
  /** Sitemap URLs, following a sitemap index one level down.
20
21
  *
@@ -246,7 +247,7 @@ export async function audit(target, opts = {}) {
246
247
  ? {
247
248
  level: 'error',
248
249
  id: 'tls-expired',
249
- title: `The TLS certificate expired ${lapsed} day(s) ago`,
250
+ title: `The TLS certificate expired ${plural(lapsed, 'day')} ago`,
250
251
  detail:
251
252
  `It ran out on ${new Date(expiresAt).toISOString().slice(0, 10)}, which is why nothing here ` +
252
253
  'could be fetched — browsers refuse the site outright. Renew it; nothing else about this ' +
@@ -307,9 +308,9 @@ export async function audit(target, opts = {}) {
307
308
  findings.push({
308
309
  level: 'info',
309
310
  id: 'since',
310
- title: `${changed.skipped.length} URL(s) were unchanged since ${opts.since}`,
311
+ title: `${plural(changed.skipped.length, 'URL')} were unchanged since ${opts.since}`,
311
312
  detail:
312
- `The sitemap says ${changed.changed.length} page(s) changed on or after ${opts.since}` +
313
+ `The sitemap says ${plural(changed.changed.length, 'page')} changed on or after ${opts.since}` +
313
314
  (changed.unknown.length
314
315
  ? `, and ${changed.unknown.length} carry no lastmod and were checked anyway — not knowing ` +
315
316
  'when a page changed is not evidence that it did not'
@@ -342,7 +343,7 @@ export async function audit(target, opts = {}) {
342
343
  findings.push({
343
344
  level: 'info',
344
345
  id: 'excluded',
345
- title: `${excluded.length} URL(s) were excluded by --exclude`,
346
+ title: `${plural(excluded.length, 'URL')} were excluded by --exclude`,
346
347
  detail:
347
348
  `${patterns.join(', ')} matched ${excluded.length} of the ${considered.length} URLs considered, ` +
348
349
  `so ${wanted.length} were left to check. This is a fact about the run, not about the site — ` +
@@ -400,7 +401,7 @@ export async function audit(target, opts = {}) {
400
401
  detail:
401
402
  `Tried: ${tried.join(', ')}. The server answered HTTP 429 — "ask later" — so this run ` +
402
403
  'never saw whether a sitemap is there, and followed links from the homepage instead, ' +
403
- `reaching ${pages.length} page(s). This is a fact about the crawl, not about the site. ` +
404
+ `reaching ${plural(pages.length, 'page')}. This is a fact about the crawl, not about the site. ` +
404
405
  'Run it again with a lower --concurrency, or pass --sitemap <url>.',
405
406
  url: origin,
406
407
  }
@@ -504,7 +505,7 @@ export async function audit(target, opts = {}) {
504
505
  findings.push(...notes);
505
506
  if (targets.length) {
506
507
  opts.onNote?.(
507
- `measuring ${targets.length} page(s) with PageSpeed Insights — about ` +
508
+ `measuring ${plural(targets.length, 'page')} with PageSpeed Insights — about ` +
508
509
  `${Math.ceil(estimateSeconds(targets.length) / 60)} min …`,
509
510
  );
510
511
  findings.push(...(await psiChecks(targets, { strategy: opts.psiStrategy, onProgress })));
@@ -521,7 +522,7 @@ export async function audit(target, opts = {}) {
521
522
  id: 'rate-limit-slowed',
522
523
  title: 'The crawl was slowed down to get through',
523
524
  detail:
524
- `The server answered HTTP 429 — asking for a slower crawl — ${fetcher.rateLimited} time(s), so ` +
525
+ `The server answered HTTP 429 — asking for a slower crawl — ${plural(fetcher.rateLimited, 'time')}, so ` +
525
526
  `requests were paused and the concurrency came down to ${fetcher.concurrency}. This is not a ` +
526
527
  'finding about the site — it explains the elapsed time, and any page reported as rate-limited ' +
527
528
  'was not read at all. Pass a lower --concurrency to get through cleanly.',
@@ -602,7 +603,7 @@ export async function audit(target, opts = {}) {
602
603
  findings.push({
603
604
  level: 'warn',
604
605
  id: 'sitemap-not-indexable',
605
- title: `${contradictions.length} sitemap URL(s) will not be indexed`,
606
+ title: `${plural(contradictions.length, 'sitemap URL')} will not be indexed`,
606
607
  detail:
607
608
  `${contradictions.slice(0, 3).map((p) => `${p.url} (${why(p)})`).join(', ')}` +
608
609
  `${contradictions.length > 3 ? `, and ${contradictions.length - 3} more` : ''}. A sitemap is a ` +
package/src/causes.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  // guesses at severity or invents a score; it groups, counts, and orders.
14
14
 
15
15
  import { categoryOf } from './areas.mjs';
16
+ import { noun } from './text.mjs';
16
17
 
17
18
  /** The template a URL belongs to: everything up to its last segment.
18
19
  *
@@ -53,16 +54,41 @@ const WORST_FIRST = { error: 0, warn: 1, info: 2 };
53
54
  *
54
55
  * Ordered by level, then by how many pages carry it, then by id so two runs of
55
56
  * an unchanged site produce the same report and --baseline stays meaningful. */
56
- export function byCause(findings) {
57
+ export function byCause(findings, totalPages = 0) {
58
+ // A check that fires on every page crawled is not a section problem, and
59
+ // splitting it by section makes the reader do arithmetic to find that out.
60
+ // gohugo.io produced 200 things to change, and 104 of them were four checks
61
+ // that each applied to all 150 pages — `canonical-missing` alone arrived as
62
+ // twenty-six separate pieces of work. One sentence says it better than
63
+ // twenty-six do.
64
+ //
65
+ // Exactly every page, never nearly: on 149 of 150, "every page" is a false
66
+ // sentence, and this file's whole argument is that a report is worth reading
67
+ // because its sentences are true.
68
+ const everywhere = new Set();
69
+ if (totalPages > 1) {
70
+ const seenPerCheck = new Map();
71
+ for (const finding of findings ?? []) {
72
+ if (!finding.url) continue;
73
+ if (!seenPerCheck.has(finding.id)) seenPerCheck.set(finding.id, new Set());
74
+ seenPerCheck.get(finding.id).add(finding.url);
75
+ }
76
+ for (const [id, pages] of seenPerCheck) {
77
+ if (pages.size === totalPages) everywhere.add(id);
78
+ }
79
+ }
80
+
57
81
  const causes = new Map();
58
82
  for (const finding of findings ?? []) {
59
- const section = sectionOf(finding.url);
83
+ const onEveryPage = everywhere.has(finding.id);
84
+ const section = onEveryPage ? '*' : sectionOf(finding.url);
60
85
  const key = `${finding.id} ${section}`;
61
86
  const cause = causes.get(key) ?? {
62
87
  id: finding.id,
63
88
  title: finding.title,
64
89
  level: finding.level,
65
90
  section,
91
+ everywhere: onEveryPage,
66
92
  findings: [],
67
93
  };
68
94
  cause.findings.push(finding);
@@ -135,16 +161,26 @@ export function causeScope(cause, totalPages) {
135
161
  const pages = cause.pages.length;
136
162
  if (pages <= 1) return cause.section === '/' ? 'once' : `on one page under ${cause.section}`;
137
163
 
138
- const where = cause.section === '/' ? 'across the site' : `under ${cause.section}`;
164
+ const where = cause.everywhere
165
+ ? null
166
+ : cause.section === '/'
167
+ ? 'across the site'
168
+ : `under ${cause.section}`;
139
169
  const share =
140
170
  totalPages && pages / totalPages >= 0.5 ? `, ${Math.round((pages / totalPages) * 100)}% of the crawl` : '';
141
171
  const seen = cause.impressions
142
- ? `, ${cause.impressions.toLocaleString()} impressions in 28 days` +
172
+ ? `, ${cause.impressions.toLocaleString()} ${noun(cause.impressions, 'impression')} in 28 days` +
143
173
  (cause.position ? `, best at position ${cause.position}` : '')
144
174
  : '';
145
- const reach = seen || (cause.inlinks ? `, ${cause.inlinks.toLocaleString()} links in` : '');
175
+ // "1 links in" found by running this against a real site, which is the only
176
+ // way anything here has ever been found.
177
+ const reach =
178
+ seen || (cause.inlinks ? `, ${cause.inlinks.toLocaleString()} ${noun(cause.inlinks, 'link')} in` : '');
146
179
  const near =
147
180
  cause.depth === 0 ? ', starting at the homepage' : cause.depth === 1 ? ', one click from home' : '';
181
+ // No share for a cause that is on everything: "100% of the crawl" is the
182
+ // sentence it already just said.
183
+ if (where === null) return `every page crawled (${pages})${reach}${near}`;
148
184
  return `${pages} pages ${where}${share}${reach}${near}`;
149
185
  }
150
186
 
@@ -159,11 +195,14 @@ export function causeScope(cause, totalPages) {
159
195
  * terminal, the HTML and the app all print, and a second phrasing of it in
160
196
  * another language is exactly the drift this project refuses everywhere else. */
161
197
  export function causePayload(findings, totalPages) {
162
- return byCause(findings).map((cause) => ({
198
+ return byCause(findings, totalPages).map((cause) => ({
163
199
  id: cause.id,
164
200
  title: cause.title,
165
201
  level: cause.level,
166
202
  section: cause.section,
203
+ // So a native client can say "every page" in its own words rather than
204
+ // matching on the sentence this file writes.
205
+ ...(cause.everywhere ? { everywhere: true } : {}),
167
206
  count: cause.count,
168
207
  pages: cause.pages,
169
208
  scope: causeScope(cause, totalPages),
package/src/checks.mjs CHANGED
@@ -14,6 +14,7 @@ import { linkGraph, key as graphKey } from './graph.mjs';
14
14
 
15
15
  import { attr, bodyKind, stripMarkupInAttributes } from './parse.mjs';
16
16
  import { cluster } from './dupes.mjs';
17
+ import { plural } from './text.mjs';
17
18
 
18
19
  // Defaults, overridable per site under `limits` in the config file. A
19
20
  // documentation site and a shop disagree about what "thin" means, and the tool
@@ -602,7 +603,7 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
602
603
  // An <img> can have no src either — a lazy-loading placeholder, or markup
603
604
  // waiting on JavaScript. Saying "First: null" helped nobody find it.
604
605
  const where = noAlt[0].src ?? 'an <img> with no src attribute either';
605
- out.push(f('error', 'img-alt', `${noAlt.length} image(s) with no alt attribute`,
606
+ out.push(f('error', 'img-alt', `${plural(noAlt.length, 'image')} with no alt attribute`,
606
607
  `First: ${where}. Decorative images need alt="" — the attribute must exist either way.`, url));
607
608
  }
608
609
  // Alt text that exists but says nothing. alt="" is deliberate and correct for
@@ -613,13 +614,13 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
613
614
 
614
615
  const filename = described.filter((i) => ALT_FILENAME.test(i.alt.trim()) || ALT_SERIAL.test(i.alt.trim()));
615
616
  if (filename.length) {
616
- out.push(f('warn', 'img-alt-filename', `${filename.length} image(s) with a filename as alt text`,
617
+ out.push(f('warn', 'img-alt-filename', `${plural(filename.length, 'image')} with a filename as alt text`,
617
618
  `First: alt="${filename[0].alt}" on ${filename[0].src}. That is what a CMS fills in when nobody typed anything — it describes the file, not the picture.`, url));
618
619
  }
619
620
 
620
621
  const placeholder = described.filter((i) => ALT_PLACEHOLDER.has(norm(i)));
621
622
  if (placeholder.length) {
622
- out.push(f('warn', 'img-alt-placeholder', `${placeholder.length} image(s) with placeholder alt text`,
623
+ out.push(f('warn', 'img-alt-placeholder', `${plural(placeholder.length, 'image')} with placeholder alt text`,
623
624
  `First: alt="${placeholder[0].alt}" on ${placeholder[0].src}. It names the medium, not the content — a screen reader already announces "image" before reading it.`, url));
624
625
  }
625
626
 
@@ -645,7 +646,7 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
645
646
  (i) => i.title && i.alt && i.title.trim() === i.alt.trim(),
646
647
  );
647
648
  if (titledSameAsAlt.length) {
648
- out.push(f('info', 'img-title-duplicates-alt', `${titledSameAsAlt.length} image(s) repeat the alt text as a title`,
649
+ out.push(f('info', 'img-title-duplicates-alt', `${plural(titledSameAsAlt.length, 'image')} repeat the alt text as a title`,
649
650
  `First: "${titledSameAsAlt[0].title}" on ${titledSameAsAlt[0].src}. One field filling both is the usual ` +
650
651
  'cause. It adds nothing for a sighted visitor and a screen reader that surfaces both reads it twice.', url));
651
652
  }
@@ -654,7 +655,7 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
654
655
  (i) => i.title && (i.alt === '' || decorativeByRole(i)),
655
656
  );
656
657
  if (titledDecorative.length) {
657
- out.push(f('info', 'img-title-on-decorative', `${titledDecorative.length} decorative image(s) carry a title`,
658
+ out.push(f('info', 'img-title-on-decorative', `${plural(titledDecorative.length, 'decorative image')} carry a title`,
658
659
  `First: "${titledDecorative[0].title}" on ${titledDecorative[0].src}. The markup declares the image ` +
659
660
  'decorative and then attaches a tooltip to it — one of the two is wrong.', url));
660
661
  }
@@ -672,7 +673,7 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
672
673
  (i) => /^lazy$/i.test(i.loading ?? '') && /^high$/i.test(i.fetchpriority ?? ''),
673
674
  );
674
675
  if (hurriedAndDeferred.length) {
675
- out.push(f('info', 'img-lazy-priority', `${hurriedAndDeferred.length} image(s) are both deferred and prioritised`,
676
+ out.push(f('info', 'img-lazy-priority', `${plural(hurriedAndDeferred.length, 'image')} are both deferred and prioritised`,
676
677
  `First: ${hurriedAndDeferred[0].src}. loading="lazy" and fetchpriority="high" on one element ask ` +
677
678
  'for opposite things, and lazy decides when the request happens. If this is the image the page ' +
678
679
  'is judged on, drop the lazy; if it is not, drop the priority.', url));
@@ -680,18 +681,18 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
680
681
 
681
682
  const longAlt = described.filter((i) => i.alt.length > ALT_MAX);
682
683
  if (longAlt.length) {
683
- out.push(f('info', 'img-alt-long', `${longAlt.length} image(s) with very long alt text`,
684
+ out.push(f('info', 'img-alt-long', `${plural(longAlt.length, 'image')} with very long alt text`,
684
685
  `First: ${longAlt[0].alt.length} chars on ${longAlt[0].src}. Alt is read in one breath, with no way to skim — a description this long belongs in the page text, where everyone gets it.`, url));
685
686
  }
686
687
 
687
688
  const noDim = doc.images.filter((i) => i.src && (!i.width || !i.height));
688
689
  if (noDim.length) {
689
- out.push(f('warn', 'img-dimensions', `${noDim.length} image(s) without width/height`,
690
+ out.push(f('warn', 'img-dimensions', `${plural(noDim.length, 'image')} without width/height`,
690
691
  `First: ${noDim[0].src}. Without them the page reflows as images arrive (layout shift).`, url));
691
692
  }
692
693
  const noSrcset = doc.images.filter((i) => i.src && !i.srcset && !i.inPicture && !/\.svg($|\?)/i.test(i.src));
693
694
  if (noSrcset.length) {
694
- out.push(f('info', 'img-srcset', `${noSrcset.length} image(s) served at one size`,
695
+ out.push(f('info', 'img-srcset', `${plural(noSrcset.length, 'image')} served at one size`,
695
696
  `First: ${noSrcset[0].src}. A phone downloads the desktop file.`, url));
696
697
  }
697
698
 
@@ -705,7 +706,7 @@ export function pageChecks(page, limits = DEFAULT_LIMITS) {
705
706
  // crawled — so a note, not a complaint.
706
707
  const nofollowed = doc.links.nofollowInternal ?? [];
707
708
  if (nofollowed.length) {
708
- out.push(f('info', 'internal-nofollow', `${nofollowed.length} internal link(s) marked nofollow`,
709
+ out.push(f('info', 'internal-nofollow', `${plural(nofollowed.length, 'internal link')} marked nofollow`,
709
710
  `First: ${nofollowed.slice(0, 3).join(', ')}. Fair for a login or a filter nobody should crawl; ` +
710
711
  'on an ordinary page it withholds a path through your own site for no gain.', url));
711
712
  }
@@ -874,7 +875,7 @@ export function sitemapChecks(entries, source, now = Date.now(), files = []) {
874
875
  });
875
876
  if (listedTwice.length) {
876
877
  const [loc, inFiles] = listedTwice[0];
877
- out.push(f('info', 'sitemap-duplicate-url', `${listedTwice.length} URL(s) are listed more than once`,
878
+ out.push(f('info', 'sitemap-duplicate-url', `${plural(listedTwice.length, 'URL')} are listed more than once`,
878
879
  `First: ${loc}${inFiles.size > 1 ? `, in ${[...inFiles].join(' and ')}` : ' — twice in one file'}. ` +
879
880
  'A sitemap is a list of the pages you want indexed, and listing one twice says nothing extra ' +
880
881
  'while making the file harder to trust.', source ?? loc));
@@ -897,7 +898,7 @@ export function sitemapChecks(entries, source, now = Date.now(), files = []) {
897
898
  return Number.isFinite(at) && at > now + DAY;
898
899
  });
899
900
  if (future.length) {
900
- out.push(f('warn', 'sitemap-lastmod-future', `${future.length} page(s) claim a lastmod in the future`,
901
+ out.push(f('warn', 'sitemap-lastmod-future', `${plural(future.length, 'page')} claim a lastmod in the future`,
901
902
  `First: ${future[0].loc} says ${future[0].lastmod}. A date that has not happened yet is not a ` +
902
903
  'signal a crawler can use, and it is usually a timezone or a scheduling bug in the generator.', source));
903
904
  }
@@ -975,7 +976,7 @@ export function crossPageChecks(pages, opts = {}) {
975
976
  const uncomparable = live.filter((p) => !p.doc.fingerprint).length;
976
977
  if (uncomparable > 0 && live.length > 1) {
977
978
  out.push(f('info', 'duplicate-content-not-checked',
978
- `${uncomparable} page(s) were not compared for duplicate content`,
979
+ `${plural(uncomparable, 'page')} were not compared for duplicate content`,
979
980
  'Content is compared inside <main> or <article>. Without one of those the text of a page is ' +
980
981
  'the whole document, navigation and footer included, and every page of a small site would ' +
981
982
  'look like a copy of every other. Pages under about a hundred words are skipped for the ' +
@@ -997,7 +998,7 @@ export function crossPageChecks(pages, opts = {}) {
997
998
  const unfetched = pages.length - live.length;
998
999
  const partial =
999
1000
  opts.truncated > 0
1000
- ? `the crawl stopped ${opts.truncated} page(s) short of the whole site`
1001
+ ? `the crawl stopped ${plural(opts.truncated, 'page')} short of the whole site`
1001
1002
  : unfetched > pages.length * 0.1
1002
1003
  ? `${unfetched} of ${pages.length} crawled pages did not load`
1003
1004
  : null;
@@ -1148,7 +1149,7 @@ export function crossPageChecks(pages, opts = {}) {
1148
1149
  for (const [href, pages] of namelessTargets.slice(0, 10)) {
1149
1150
  out.push(f('warn', 'link-no-text', 'Link with nothing to read',
1150
1151
  `${href} is linked with no text, no image alt, no aria-label and no title, from ` +
1151
- `${pages.length} page(s): ${pages.slice(0, 3).join(', ')}. Google is told the page exists and ` +
1152
+ `${plural(pages.length, 'page')}: ${pages.slice(0, 3).join(', ')}. Google is told the page exists and ` +
1152
1153
  'nothing about it, and a screen reader announces the URL instead of a description.',
1153
1154
  pages[0]));
1154
1155
  }
@@ -1171,7 +1172,7 @@ export function crossPageChecks(pages, opts = {}) {
1171
1172
  const names = inbound.get(withoutSlash(p.url));
1172
1173
  const shown = [...new Set(names.map((n) => `"${n}"`))].slice(0, 3).join(', ');
1173
1174
  out.push(f('info', 'anchor-generic', 'Every link to this page says the same empty thing',
1174
- `${names.length} link(s) point here and all of them read ${shown}. Anchor text is the one ` +
1175
+ `${plural(names.length, 'link')} point here and all of them read ${shown}. Anchor text is the one ` +
1175
1176
  'description of a page that comes from somewhere other than the page itself, and this one has ' +
1176
1177
  'none — the words say what to do, not what is there.', p.url));
1177
1178
  }
package/src/kept.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // Which kept runs a date is asking for.
2
+ //
3
+ // Separate from `library.mjs` on purpose. That module opens files, so it
4
+ // imports `node:fs` — and the Worker, which shows the same list in a browser,
5
+ // has no filesystem and would not survive the import. These two functions are
6
+ // the part both sides need and the part that is pure, so they live where both
7
+ // can reach them. Web-standard only; nothing here may grow a Node built-in.
8
+
9
+ /** A date on the command line or in a query string, as a timestamp.
10
+ *
11
+ * `2026-08-01` means midnight UTC that day, so `--reports 2026-08-01` includes
12
+ * everything from that day. A full ISO timestamp is taken as written.
13
+ *
14
+ * Returns `{ error }` for anything it cannot read, and the callers refuse
15
+ * rather than carrying on: listing every run when somebody asked for one week
16
+ * is the quiet kind of wrong — it looks like an answer. */
17
+ export function sinceWhen(value) {
18
+ if (value === undefined || value === null || value === '') return { at: null };
19
+ const text = String(value).trim();
20
+ const at = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T00:00:00Z` : text);
21
+ if (Number.isNaN(at)) {
22
+ return { error: `a date like 2026-08-01 or a full timestamp, not "${text}"` };
23
+ }
24
+ return { at };
25
+ }
26
+
27
+ /** Kept runs finished on or after `at`, in the order they were given.
28
+ *
29
+ * Pure, and web-standard: the CLI and the hosted list both call it, so the two
30
+ * cannot disagree about what "since" means — which is the drift this project
31
+ * refuses everywhere else. A run with no readable date is kept rather than
32
+ * dropped: an index written by an older version should not vanish from a
33
+ * filtered list without saying so. */
34
+ export function keptSince(rows, at) {
35
+ if (at === null || at === undefined) return rows ?? [];
36
+ return (rows ?? []).filter((row) => {
37
+ const when = Date.parse(row?.finishedAt ?? '');
38
+ return Number.isNaN(when) ? true : when >= at;
39
+ });
40
+ }
package/src/llms.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  // need a parser.
20
20
 
21
21
  import { sectionOf } from './causes.mjs';
22
+ import { plural } from './text.mjs';
22
23
 
23
24
  /** Markdown's structural characters, inside text that came off a page.
24
25
  *
@@ -54,13 +55,13 @@ export function buildLlms(pages, context = {}) {
54
55
 
55
56
  if (truncated > 0) {
56
57
  return refuse(
57
- `The crawl stopped at its limit with ${truncated} URL(s) unread, so this file would present a ` +
58
+ `The crawl stopped at its limit with ${plural(truncated, 'URL')} unread, so this file would present a ` +
58
59
  `fraction of the site as the whole of it. Run again with --limit ${pages.length + truncated}.`,
59
60
  );
60
61
  }
61
62
  if (rateLimited > 0) {
62
63
  return refuse(
63
- `${rateLimited} page(s) were never read because the server was rate limiting, so what belongs ` +
64
+ `${plural(rateLimited, 'page')} were never read because the server was rate limiting, so what belongs ` +
64
65
  'in this file is not known. Run again with a lower --concurrency.',
65
66
  );
66
67
  }
@@ -154,7 +155,7 @@ const REASONS = {
154
155
  /** The summary line for a terminal, and for the note the CLI prints. */
155
156
  export function describeLlms(result, path) {
156
157
  if (result.refused) return ` Did not write ${path}: ${result.refused}\n`;
157
- const out = [` wrote ${path} — ${result.urls.length} pages in ${result.sections} section(s)`];
158
+ const out = [` wrote ${path} — ${result.urls.length} pages in ${plural(result.sections, 'section')}`];
158
159
  for (const [reason, count] of Object.entries(result.excluded).sort((a, b) => b[1] - a[1])) {
159
160
  out.push(` dropped ${String(count).padEnd(3)} (${REASONS[reason] ?? reason})`);
160
161
  }
package/src/options.mjs CHANGED
@@ -84,6 +84,7 @@ export const OPTIONS = [
84
84
  { flag: '--quiet', query: null, app: 'the crawl log is on screen while it runs' },
85
85
  { flag: '--verbose', query: null, app: 'the crawl log is on screen while it runs' },
86
86
  { flag: '--serve', query: null, app: 'the window is what --serve serves' },
87
+ { flag: '--reports', query: null, app: 'the sidebar is this list, and it is always on' },
87
88
  { flag: '--fail-on', query: null, app: 'a window has no exit code for a build to read' },
88
89
  { flag: '--update-baseline', query: null, app: 'a baseline is a file a repository commits' },
89
90
  { flag: '--config', query: null, app: 'a config file is a file a repository commits' },
package/src/redirects.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  // A rule with a wildcard or a placeholder cannot be tested by asking for it
17
17
  // literally, so those are counted and reported rather than guessed at.
18
18
 
19
+ import { plural } from './text.mjs';
20
+
19
21
  const HAS_PATTERN = /[*:]/;
20
22
 
21
23
  /** Rules from a redirect map. `to` and `status` may be null. */
@@ -56,13 +58,13 @@ export async function redirectChecks(rules, fetcher, origin, { limit = 200, onPr
56
58
  const checked = testable.slice(0, limit);
57
59
 
58
60
  if (patterned.length) {
59
- out.push(f('info', 'redirect-pattern-skipped', `${patterned.length} wildcard rule(s) were not tested`,
61
+ out.push(f('info', 'redirect-pattern-skipped', `${plural(patterned.length, 'wildcard rule')} were not tested`,
60
62
  `Rules like ${patterned.slice(0, 2).map((r) => r.from).join(', ')} match a shape rather than a URL, ` +
61
63
  'so asking for them literally proves nothing. Add a real example of each to the map, or test them by hand.',
62
64
  origin));
63
65
  }
64
66
  if (testable.length > checked.length) {
65
- out.push(f('info', 'redirect-map-capped', `${testable.length - checked.length} rule(s) were not tested`,
67
+ out.push(f('info', 'redirect-map-capped', `${plural(testable.length - checked.length, 'rule')} were not tested`,
66
68
  `The map has ${testable.length} testable rules and the limit is ${limit}. Raise it with maxRedirectChecks.`,
67
69
  origin));
68
70
  }
@@ -128,17 +130,17 @@ export async function redirectChecks(rules, fetcher, origin, { limit = 200, onPr
128
130
  `${shown}${lines.length > 3 ? `, and ${lines.length - 3} more` : ''}. ${detail}`, origin));
129
131
  };
130
132
 
131
- say('gone', 'error', 'redirect-dead', (n) => `${n} old URL(s) in the redirect map are simply gone`,
133
+ say('gone', 'error', 'redirect-dead', (n) => `${plural(n, 'old URL')} in the redirect map are simply gone`,
132
134
  'The rule is not in effect, so every link and every ranking pointing at these lands on nothing.');
133
- say('broken', 'error', 'redirect-broken', (n) => `${n} redirect(s) land on a page that does not load`,
135
+ say('broken', 'error', 'redirect-broken', (n) => `${plural(n, 'redirect')} land on a page that does not load`,
134
136
  'The rule fires and then arrives nowhere, which is worse than no rule: it looks handled.');
135
- say('notRedirecting', 'warn', 'redirect-not-applied', (n) => `${n} old URL(s) answer 200 instead of redirecting`,
137
+ say('notRedirecting', 'warn', 'redirect-not-applied', (n) => `${plural(n, 'old URL')} answer 200 instead of redirecting`,
136
138
  'The map says these moved, and the server disagrees. Either the rule never shipped or something serves the old path.');
137
- say('hops', 'warn', 'redirect-hops', (n) => `${n} redirect(s) take more than one hop`,
139
+ say('hops', 'warn', 'redirect-hops', (n) => `${plural(n, 'redirect')} take more than one hop`,
138
140
  'Each hop is a round trip a visitor and a crawler both pay for. Point the first rule at the final URL.');
139
- say('elsewhere', 'warn', 'redirect-elsewhere', (n) => `${n} redirect(s) land somewhere the map does not expect`,
141
+ say('elsewhere', 'warn', 'redirect-elsewhere', (n) => `${plural(n, 'redirect')} land somewhere the map does not expect`,
140
142
  'Another rule is probably matching first. The map is no longer describing what the site does.');
141
- say('temporary', 'warn', 'redirect-temporary', (n) => `${n} permanent redirect(s) are served as 302`,
143
+ say('temporary', 'warn', 'redirect-temporary', (n) => `${plural(n, 'permanent redirect')} are served as 302`,
142
144
  'A 302 tells Google the move is temporary, so it keeps the old URL indexed and passes less through it.');
143
145
 
144
146
  return out;
package/src/report.mjs CHANGED
@@ -22,8 +22,8 @@ const PAINT = { error: red, warn: yellow, info: blue };
22
22
  * know is that they are 62 pieces of work and four of them are most of it.
23
23
  * Shown when there is something to summarise — under a handful of causes the
24
24
  * list below already reads as the summary. */
25
- export function worstCauses(findings, limit = 8) {
26
- const causes = byCause(findings);
25
+ export function worstCauses(findings, totalPages = 0, limit = 8) {
26
+ const causes = byCause(findings, totalPages);
27
27
  return causes.length > limit + 2 ? causes.slice(0, limit) : [];
28
28
  }
29
29
 
@@ -127,9 +127,9 @@ export function terminal(findings, meta, { score } = {}) {
127
127
  }
128
128
 
129
129
  const costs = costsById(score);
130
- const causes = worstCauses(findings);
130
+ const causes = worstCauses(findings, meta.pages);
131
131
  if (causes.length) {
132
- const total = byCause(findings).length;
132
+ const total = byCause(findings, meta.pages).length;
133
133
  lines.push(rule('Start here'));
134
134
  lines.push('');
135
135
  lines.push(dim(` ${findings.length} findings are ${total} things to change. The widest:`));
@@ -252,11 +252,11 @@ export function markdown(findings, meta, { score } = {}) {
252
252
  }
253
253
 
254
254
  const costs = costsById(score);
255
- const causes = worstCauses(findings);
255
+ const causes = worstCauses(findings, meta.pages);
256
256
  if (causes.length) {
257
257
  out.push('## Start here');
258
258
  out.push('');
259
- out.push(`${findings.length} findings are **${byCause(findings).length} things to change**. The widest:`);
259
+ out.push(`${findings.length} findings are **${byCause(findings, meta.pages).length} things to change**. The widest:`);
260
260
  out.push('');
261
261
  out.push('| | What to change | Where | Worth |');
262
262
  out.push('|:-:|---|---|--:|');
@@ -1193,11 +1193,12 @@ export function reportParts(findings, meta, { backHref, backLabel = 'New audit',
1193
1193
  ${dial()}
1194
1194
 
1195
1195
  ${(() => {
1196
- const causes = worstCauses(findings);
1196
+ const causes = worstCauses(findings, meta.pages);
1197
1197
  if (!causes.length) return '';
1198
+ const total = byCause(findings, meta.pages).length;
1198
1199
  return `<section class="causes">
1199
- <h2 id="start-here"><span>Start here</span><span class="rule"></span><span class="tick">${byCause(findings).length}</span></h2>
1200
- <p class="lede">${findings.length} findings are ${byCause(findings).length} things to change. The widest:</p>
1200
+ <h2 id="start-here"><span>Start here</span><span class="rule"></span><span class="tick">${total}</span></h2>
1201
+ <p class="lede">${findings.length} findings are ${total} things to change. The widest:</p>
1201
1202
  <ol>${causes
1202
1203
  .map((cause) => {
1203
1204
  const points = causeCost(cause, costs);
package/src/schema.mjs CHANGED
@@ -27,6 +27,7 @@
27
27
  // prettified slug.
28
28
 
29
29
  import { GENERIC_ANCHORS, anchorPhrase } from './checks.mjs';
30
+ import { plural } from './text.mjs';
30
31
 
31
32
  const SCHEMA = 'https://schema.org';
32
33
 
@@ -163,14 +164,14 @@ export function buildSchema(pages, context = {}) {
163
164
 
164
165
  if (truncated > 0) {
165
166
  return refuse(
166
- `The crawl stopped at its limit with ${truncated} URL(s) unread. A breadcrumb trail is only ` +
167
+ `The crawl stopped at its limit with ${plural(truncated, 'URL')} unread. A breadcrumb trail is only ` +
167
168
  'honest when every step of it was fetched, so this would silently describe fewer pages than ' +
168
169
  `it looks like. Run again with --limit ${pages.length + truncated}.`,
169
170
  );
170
171
  }
171
172
  if (rateLimited > 0) {
172
173
  return refuse(
173
- `${rateLimited} page(s) were never read because the server was rate limiting, so the trails ` +
174
+ `${plural(rateLimited, 'page')} were never read because the server was rate limiting, so the trails ` +
174
175
  'through them cannot be built. Run again with a lower --concurrency.',
175
176
  );
176
177
  }
@@ -313,7 +314,7 @@ export function describeSchema(result, path) {
313
314
  for (const entry of result.generated) {
314
315
  types[entry.jsonld['@type']] = (types[entry.jsonld['@type']] ?? 0) + 1;
315
316
  }
316
- const out = [` wrote ${path} — ${result.generated.length} block(s) for ${new Set(result.generated.map((e) => e.url)).size} page(s)`];
317
+ const out = [` wrote ${path} — ${plural(result.generated.length, 'block')} for ${plural(new Set(result.generated.map((e) => e.url)).size, 'page')}`];
317
318
  for (const [type, count] of Object.entries(types).sort((a, b) => b[1] - a[1])) {
318
319
  out.push(` ${String(count).padEnd(4)} ${type}`);
319
320
  }
package/src/site.mjs CHANGED
@@ -6,6 +6,7 @@ import { parseRobots, robotsVerdict } from './robots.mjs';
6
6
  import { aiAccess, describeAccess } from './agents-ai.mjs';
7
7
  import { parseHtml } from './parse.mjs';
8
8
  import { schemaNodes, seriesOf, paginatedCanonical } from './checks.mjs';
9
+ import { plural } from './text.mjs';
9
10
 
10
11
  // Two weeks is enough to renew by hand if the automation has quietly stopped,
11
12
  // which is the failure this is for — nobody is short of warning about a
@@ -123,7 +124,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
123
124
  if (blocked.length) {
124
125
  const shown = blocked.slice(0, 3).map((b) => `${b.listed} (Disallow: ${b.rule.path})`).join(', ');
125
126
  out.push(f('error', 'robots-blocks-sitemap-url',
126
- `${blocked.length} sitemap URL(s) are disallowed by robots.txt`,
127
+ `${plural(blocked.length, 'sitemap URL')} are disallowed by robots.txt`,
127
128
  `${shown}${blocked.length > 3 ? `, and ${blocked.length - 3} more` : ''}. The sitemap asks Google ` +
128
129
  'to index these and robots.txt forbids fetching them, so they land in the index without a ' +
129
130
  'description, or not at all. One of the two files is wrong.', robots.url));
@@ -149,7 +150,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
149
150
  const shut = describeAccess(access);
150
151
  if (shut) {
151
152
  out.push(f('info', 'ai-crawler-blocked',
152
- `${shut.blocked.length} AI crawler(s) are disallowed by robots.txt`,
153
+ `${plural(shut.blocked.length, 'AI crawler')} are disallowed by robots.txt`,
153
154
  shut.detail, robotsUrl));
154
155
 
155
156
  // The site contradicting itself. llms.txt exists to tell an AI assistant
@@ -321,11 +322,11 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
321
322
  const days = Math.floor((expiresAt - (opts.now ?? Date.now())) / DAY);
322
323
  const on = new Date(expiresAt).toISOString().slice(0, 10);
323
324
  if (days < 0) {
324
- out.push(f('error', 'tls-expired', `The TLS certificate expired ${-days} day(s) ago`,
325
+ out.push(f('error', 'tls-expired', `The TLS certificate expired ${plural(-days, 'day')} ago`,
325
326
  `It ran out on ${on}. Browsers refuse to load the site, so nothing else in this report matters ` +
326
327
  'until it is renewed.', origin));
327
328
  } else if (days <= CERT_WARN_DAYS) {
328
- out.push(f('warn', 'tls-expiring', `The TLS certificate expires in ${days} day(s)`,
329
+ out.push(f('warn', 'tls-expiring', `The TLS certificate expires in ${plural(days, 'day')}`,
329
330
  `On ${on}. Usually this means automatic renewal has stopped without anyone noticing — the ` +
330
331
  'certificates that lapse are the ones nobody was worried about.', origin));
331
332
  }
@@ -441,7 +442,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
441
442
  // is a legitimate reason to have one.
442
443
  const redirecting = results.filter((r) => r.status >= 300 && r.status < 400);
443
444
  if (redirecting.length) {
444
- out.push(f('info', 'link-redirects', `${redirecting.length} internal link(s) point at a redirect`,
445
+ out.push(f('info', 'link-redirects', `${plural(redirecting.length, 'internal link')} point at a redirect`,
445
446
  `First: ${redirecting.slice(0, 3).map((r) => `${r.target} (${r.status})`).join(', ')}. ` +
446
447
  'Linking to the final URL saves the hop.', origin));
447
448
  }
@@ -545,7 +546,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
545
546
  (r) => r.final.status === 404 || r.final.status === 410 || r.final.status === 0,
546
547
  );
547
548
  if (dead.length) {
548
- out.push(f('warn', 'external-broken', `${dead.length} outbound link(s) do not resolve`,
549
+ out.push(f('warn', 'external-broken', `${plural(dead.length, 'outbound link')} do not resolve`,
549
550
  `${dead.slice(0, 3).map((r) => `${r.href} (${r.final.status || r.final.error})`).join(', ')}` +
550
551
  `${dead.length > 3 ? `, and ${dead.length - 3} more` : ''}. A link out that goes nowhere is a dead ` +
551
552
  'end for a reader. Checked leniently — anything but a 404, a 410 or no answer at all is left alone.',
@@ -554,7 +555,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
554
555
 
555
556
  const moved = externalResults.filter((r) => r.first >= 300 && r.first < 400 && r.final.ok);
556
557
  if (moved.length) {
557
- out.push(f('info', 'external-redirects', `${moved.length} outbound link(s) point at a redirect`,
558
+ out.push(f('info', 'external-redirects', `${plural(moved.length, 'outbound link')} point at a redirect`,
558
559
  `${moved.slice(0, 3).map((r) => `${r.href} → ${r.final.url}`).join(', ')}` +
559
560
  `${moved.length > 3 ? `, and ${moved.length - 3} more` : ''}. They work; linking to the final ` +
560
561
  'URL is tidier and survives the day the redirect is removed.', origin));
@@ -593,7 +594,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
593
594
  (r) => r.status === 404 || r.status === 410 || r.status === 0,
594
595
  );
595
596
  if (deadSchemaImages.length) {
596
- out.push(f('warn', 'schema-image-broken', `${deadSchemaImages.length} image(s) named in structured data do not load`,
597
+ out.push(f('warn', 'schema-image-broken', `${plural(deadSchemaImages.length, 'image')} named in structured data do not load`,
597
598
  `${deadSchemaImages.slice(0, 3).map((r) => `${r.href} (${r.status || r.error})`).join(', ')}` +
598
599
  `${deadSchemaImages.length > 3 ? `, and ${deadSchemaImages.length - 3} more` : ''}. Google is told to ` +
599
600
  'use these for rich results and finds nothing there. The markup is valid, so nothing else reports it.',
@@ -633,7 +634,7 @@ export async function siteChecks(origin, fetcher, pages, opts = {}) {
633
634
  }
634
635
  for (const [source, dead] of deadByPage) {
635
636
  const shown = dead.slice(0, 3).join(', ');
636
- out.push(f('error', 'hreflang-dead', `${dead.length} hreflang target(s) do not load`,
637
+ out.push(f('error', 'hreflang-dead', `${plural(dead.length, 'hreflang target')} do not load`,
637
638
  `${shown}${dead.length > 3 ? `, and ${dead.length - 3} more` : ''} — declared on ${source}. Each ` +
638
639
  'version that does not load drops out of the set, and the pages pointing at it lose the annotation.',
639
640
  source));
package/src/sitemap.mjs CHANGED
@@ -11,6 +11,8 @@
11
11
  // So this refuses to write anything from a run that did not see the whole site,
12
12
  // and says which run would.
13
13
 
14
+ import { plural } from './text.mjs';
15
+
14
16
  /** The five characters XML cannot carry raw. `&` first, or the others get
15
17
  * their own ampersands escaped a second time. */
16
18
  const escape = (text) =>
@@ -46,13 +48,13 @@ export function rebuild(pages, findings = [], context = {}) {
46
48
  // --- when not to write anything ----------------------------------------
47
49
  if (truncated > 0) {
48
50
  return refuse(
49
- `The crawl stopped at its limit with ${truncated} URL(s) unread, so this file would leave ` +
51
+ `The crawl stopped at its limit with ${plural(truncated, 'URL')} unread, so this file would leave ` +
50
52
  `them out of the sitemap entirely. Run again with --limit ${pages.length + truncated}.`,
51
53
  );
52
54
  }
53
55
  if (rateLimited > 0) {
54
56
  return refuse(
55
- `${rateLimited} page(s) were never read because the server was rate limiting, so whether they ` +
57
+ `${plural(rateLimited, 'page')} were never read because the server was rate limiting, so whether they ` +
56
58
  'belong in a sitemap is not known. Run again with a lower --concurrency.',
57
59
  );
58
60
  }
package/src/text.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // Counting things in a sentence.
2
+ //
3
+ // "1 image(s) without width/height" was the most repeated finding title in a
4
+ // real report — 150 pages of it — and it reads like a form somebody did not
5
+ // finish. A report is worth reading because its sentences are true; a sentence
6
+ // that cannot decide whether it is singular is a smaller version of the same
7
+ // problem.
8
+ //
9
+ // Every plural this project needs is regular, so this stays five lines rather
10
+ // than growing into a library. `many` is there for the first one that is not.
11
+
12
+ /** `1 image`, `3 images`. Pass `many` where adding an s would be wrong. */
13
+ export function plural(n, one, many = `${one}s`) {
14
+ return `${n} ${n === 1 ? one : many}`;
15
+ }
16
+
17
+ /** Just the noun, for a count that is formatted separately — a thousands
18
+ * separator, say. `${n.toLocaleString()} ${noun(n, 'link')} in`. */
19
+ export function noun(n, one, many = `${one}s`) {
20
+ return n === 1 ? one : many;
21
+ }