@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 +57 -1
- package/bin/seo-audit.mjs +47 -0
- package/package.json +1 -1
- package/src/audit.mjs +9 -8
- package/src/causes.mjs +45 -6
- package/src/checks.mjs +17 -16
- package/src/kept.mjs +40 -0
- package/src/llms.mjs +4 -3
- package/src/options.mjs +1 -0
- package/src/redirects.mjs +10 -8
- package/src/report.mjs +10 -9
- package/src/schema.mjs +4 -3
- package/src/site.mjs +10 -9
- package/src/sitemap.mjs +4 -2
- package/src/text.mjs +21 -0
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
|
|
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.
|
|
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
|
|
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
|
|
311
|
+
title: `${plural(changed.skipped.length, 'URL')} were unchanged since ${opts.since}`,
|
|
311
312
|
detail:
|
|
312
|
-
`The sitemap says ${changed.changed.length
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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">${
|
|
1200
|
-
<p class="lede">${findings.length} findings are ${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|