@nurkamol/seo-audit 1.31.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/src/report.mjs ADDED
@@ -0,0 +1,868 @@
1
+ // Two renderings of the same findings: one for the terminal, one for a file
2
+ // you can commit, diff between runs, or send to a client.
3
+
4
+ import { byCause, causeScope, sectionOf } from './causes.mjs';
5
+ import { CATEGORIES, categoryOf } from './areas.mjs';
6
+
7
+ const COLOR = process.env.NO_COLOR === undefined && process.stdout.isTTY;
8
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
9
+ const red = (s) => c('31', s);
10
+ const yellow = (s) => c('33', s);
11
+ const blue = (s) => c('36', s);
12
+ const dim = (s) => c('2', s);
13
+ const bold = (s) => c('1', s);
14
+
15
+ const MARK = { error: '✗', warn: '!', info: '·' };
16
+ const PAINT = { error: red, warn: yellow, info: blue };
17
+
18
+ /** Findings grouped by check, worst level first, biggest group first. */
19
+ /** The top of every report: the work, not the findings.
20
+ *
21
+ * A real store produced 2,081 findings, and the first thing anyone needs to
22
+ * know is that they are 62 pieces of work and four of them are most of it.
23
+ * Shown when there is something to summarise — under a handful of causes the
24
+ * list below already reads as the summary. */
25
+ export function worstCauses(findings, limit = 8) {
26
+ const causes = byCause(findings);
27
+ return causes.length > limit + 2 ? causes.slice(0, limit) : [];
28
+ }
29
+
30
+ export function group(findings) {
31
+ const order = { error: 0, warn: 1, info: 2 };
32
+ const byId = new Map();
33
+ for (const finding of findings) {
34
+ const entry = byId.get(finding.id) ?? { ...finding, items: [] };
35
+ entry.items.push(finding);
36
+ entry.level = order[finding.level] < order[entry.level] ? finding.level : entry.level;
37
+ byId.set(finding.id, entry);
38
+ }
39
+ return [...byId.values()].sort(
40
+ (a, b) => order[a.level] - order[b.level] || b.items.length - a.items.length,
41
+ );
42
+ }
43
+
44
+ export function counts(findings) {
45
+ return {
46
+ error: findings.filter((x) => x.level === 'error').length,
47
+ warn: findings.filter((x) => x.level === 'warn').length,
48
+ info: findings.filter((x) => x.level === 'info').length,
49
+ };
50
+ }
51
+
52
+ export function terminal(findings, meta) {
53
+ const lines = [];
54
+ const n = counts(findings);
55
+
56
+ lines.push('');
57
+ lines.push(bold(` ${meta.origin}`));
58
+ lines.push(
59
+ dim(
60
+ ` ${meta.pages} pages · ${meta.requests} requests · ${(meta.ms / 1000).toFixed(1)}s` +
61
+ (meta.ignored ? ` · ${meta.ignored} ignored` : ''),
62
+ ),
63
+ );
64
+ lines.push('');
65
+
66
+ if (!findings.length) {
67
+ lines.push(` ${c('32', '✓')} nothing to report`);
68
+ lines.push('');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ const causes = worstCauses(findings);
73
+ if (causes.length) {
74
+ const total = byCause(findings).length;
75
+ lines.push(dim(` ── Start here ${'─'.repeat(46)}`));
76
+ lines.push('');
77
+ lines.push(dim(` ${findings.length} findings are ${total} things to change. The widest:`));
78
+ lines.push('');
79
+ for (const cause of causes) {
80
+ lines.push(
81
+ ` ${PAINT[cause.level](MARK[cause.level])} ${bold(cause.title)}` +
82
+ dim(` ${causeScope(cause, meta.pages)}`),
83
+ );
84
+ }
85
+ lines.push('');
86
+ }
87
+
88
+ for (const { name, entries } of byCategory(findings)) {
89
+ lines.push(dim(` ── ${name} ${'─'.repeat(Math.max(0, 58 - name.length))}`));
90
+ lines.push('');
91
+ for (const entry of entries) {
92
+ const paint = PAINT[entry.level];
93
+ const count = entry.items.length;
94
+ lines.push(
95
+ ` ${paint(MARK[entry.level])} ${bold(entry.title)}${count > 1 ? dim(` ×${count}`) : ''}`,
96
+ );
97
+ // One example in full, then the other pages by URL only — the detail
98
+ // repeats and the list is what you act on.
99
+ lines.push(` ${dim(entry.items[0].detail)}`);
100
+ for (const item of entry.items.slice(0, 8)) {
101
+ // A page Google will not index is a page whose problems cost nothing.
102
+ const aside = item.indexable === false ? dim(' (not indexable)') : '';
103
+ lines.push(` ${dim('·')} ${item.url ?? ''}${aside}`);
104
+ }
105
+ if (count > 8) lines.push(` ${dim(`… and ${count - 8} more`)}`);
106
+ lines.push('');
107
+ }
108
+ }
109
+
110
+ lines.push(
111
+ ` ${red(`${n.error} error`)} ${yellow(`${n.warn} warning`)} ${blue(`${n.info} note`)}`,
112
+ );
113
+ lines.push('');
114
+ return lines.join('\n');
115
+ }
116
+
117
+ export function markdown(findings, meta) {
118
+ const n = counts(findings);
119
+ const out = [];
120
+
121
+ out.push(`# SEO audit — ${meta.origin}`);
122
+ out.push('');
123
+ out.push(
124
+ `${meta.date} · ${meta.pages} pages crawled · **${n.error} errors, ${n.warn} warnings, ${n.info} notes**`,
125
+ );
126
+ out.push('');
127
+
128
+ if (!findings.length) {
129
+ out.push('Nothing to report.');
130
+ out.push('');
131
+ return out.join('\n');
132
+ }
133
+
134
+ const causes = worstCauses(findings);
135
+ if (causes.length) {
136
+ out.push('## Start here');
137
+ out.push('');
138
+ out.push(`${findings.length} findings are **${byCause(findings).length} things to change**. The widest:`);
139
+ out.push('');
140
+ out.push('| | What to change | Where |');
141
+ out.push('|:-:|---|---|');
142
+ for (const cause of causes) {
143
+ out.push(`| ${MARK[cause.level]} | ${cause.title} | ${causeScope(cause, meta.pages)} |`);
144
+ }
145
+ out.push('');
146
+ }
147
+
148
+ out.push('## Summary');
149
+ out.push('');
150
+ out.push('| | Area | Finding | Pages |');
151
+ out.push('|---|---|---|---:|');
152
+ for (const { name, entries } of byCategory(findings)) {
153
+ for (const entry of entries) {
154
+ out.push(`| ${MARK[entry.level]} | ${name} | ${entry.title} | ${entry.items.length} |`);
155
+ }
156
+ }
157
+ out.push('');
158
+
159
+ for (const { name, entries } of byCategory(findings)) {
160
+ out.push(`## ${name}`);
161
+ out.push('');
162
+ for (const entry of entries) {
163
+ out.push(`### ${MARK[entry.level]} ${entry.title}`);
164
+ out.push('');
165
+ // Every item carries its own detail (word counts, filenames, hop
166
+ // chains), so each line stands alone rather than repeating a shared
167
+ // preamble that only fits the first one.
168
+ for (const item of entry.items) {
169
+ const aside = item.indexable === false ? ' _(not indexable)_' : '';
170
+ out.push(`- ${item.url ?? ''}${aside}${item.detail ? ` \n ${item.detail}` : ''}`);
171
+ }
172
+ out.push('');
173
+ }
174
+ }
175
+
176
+ out.push('---');
177
+ out.push('');
178
+ out.push(
179
+ 'Performance and Core Web Vitals are deliberately not measured here — use ' +
180
+ '[PageSpeed Insights](https://pagespeed.web.dev) and [WebPageTest](https://webpagetest.org), ' +
181
+ 'which run real browsers. Generated by [seo-audit](https://github.com/nurkamol/seo-audit).',
182
+ );
183
+ out.push('');
184
+ return out.join('\n');
185
+ }
186
+
187
+ // --- Categories -------------------------------------------------------------
188
+ // Moved to areas.mjs, and re-exported here because this is where every caller
189
+ // already looks for them.
190
+ export { CATEGORIES, categoryOf } from './areas.mjs';
191
+
192
+
193
+ /** Findings grouped by category, in the fixed order above, worst first inside. */
194
+ export function byCategory(findings) {
195
+ const buckets = new Map();
196
+ for (const entry of group(findings)) {
197
+ const name = categoryOf(entry.id);
198
+ buckets.set(name, [...(buckets.get(name) ?? []), entry]);
199
+ }
200
+ const order = [...CATEGORIES, 'Other'];
201
+ return [...buckets]
202
+ .sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0]))
203
+ .map(([name, entries]) => ({ name, entries }));
204
+ }
205
+
206
+ // --- Live progress ----------------------------------------------------------
207
+ // One line per event, written as it happens. Deliberately not a spinner or a
208
+ // redrawing counter: a long run is exactly the run whose output gets piped to a
209
+ // file or read back out of a CI log, and neither of those can show a cursor
210
+ // trick. Plain lines are also greppable, and make the page the crawl is stuck
211
+ // on visible rather than hidden behind an animation.
212
+
213
+ const statusColour = (status) => {
214
+ if (status >= 500 || status === 0) return red;
215
+ if (status >= 400) return red;
216
+ if (status >= 300) return yellow;
217
+ return dim;
218
+ };
219
+
220
+ /** A single progress line. `phase` names the stage; the rest is what happened. */
221
+ export function progressLine({ phase, status, ms, url, detail }, origin) {
222
+ const parts = [dim(String(phase).padEnd(9))];
223
+
224
+ if (status !== undefined) parts.push(statusColour(status)(String(status).padStart(3)));
225
+ if (ms !== undefined) parts.push(dim(`${String(ms).padStart(5)}ms`));
226
+
227
+ if (url) {
228
+ // The origin is already on screen from the header, so a path reads better
229
+ // in a long column. Anything off-origin keeps its host.
230
+ let shown = url;
231
+ if (origin && url.startsWith(origin)) shown = url.slice(origin.length) || '/';
232
+ parts.push(shown);
233
+ }
234
+ if (detail) parts.push(status === undefined && !url ? detail : dim(detail));
235
+
236
+ return ` ${parts.join(' ')}`;
237
+ }
238
+
239
+ // --- Portfolio --------------------------------------------------------------
240
+ // One command over several sites, and one table. The point is the comparison:
241
+ // which of the twenty sites regressed this week is a question no per-site
242
+ // report can answer, because each one only ever sees itself.
243
+
244
+ const host = (run) => {
245
+ try {
246
+ return new URL(run.meta.origin).host;
247
+ } catch {
248
+ return run.meta.origin ?? '?';
249
+ }
250
+ };
251
+
252
+ /** Per-site tallies, worst site first — that is the row you act on. */
253
+ export function portfolioRows(runs) {
254
+ return runs
255
+ .map((run) => ({ host: host(run), run, n: counts(run.findings) }))
256
+ .sort((a, b) => b.n.error - a.n.error || b.n.warn - a.n.warn || a.host.localeCompare(b.host));
257
+ }
258
+
259
+ export function portfolio(runs) {
260
+ const rows = portfolioRows(runs);
261
+ const lines = [''];
262
+ const totals = rows.reduce(
263
+ (acc, r) => ({
264
+ error: acc.error + r.n.error,
265
+ warn: acc.warn + r.n.warn,
266
+ info: acc.info + r.n.info,
267
+ pages: acc.pages + (r.run.meta.pages ?? 0),
268
+ ms: acc.ms + (r.run.meta.ms ?? 0),
269
+ }),
270
+ { error: 0, warn: 0, info: 0, pages: 0, ms: 0 },
271
+ );
272
+
273
+ lines.push(
274
+ bold(` Portfolio — ${rows.length} sites`) +
275
+ dim(` · ${totals.pages} pages · ${(totals.ms / 1000).toFixed(1)}s`),
276
+ );
277
+ lines.push('');
278
+
279
+ const width = Math.max(4, ...rows.map((r) => r.host.length));
280
+ const pad = (s, n) => String(s).padStart(n);
281
+ lines.push(
282
+ dim(` ${'SITE'.padEnd(width)} ${pad('PAGES', 5)} ${pad('✗', 4)} ${pad('!', 4)} ${pad('·', 4)}`),
283
+ );
284
+
285
+ for (const { host: h, run, n } of rows) {
286
+ // A site that never answered has no tallies worth lining up — say so in
287
+ // the row rather than printing four zeros that look like a clean bill.
288
+ const failed = run.meta.pages === 0;
289
+ lines.push(
290
+ ` ${h.padEnd(width)} ${pad(run.meta.pages ?? 0, 5)} ` +
291
+ `${n.error ? red(pad(n.error, 4)) : dim(pad(0, 4))} ` +
292
+ `${n.warn ? yellow(pad(n.warn, 4)) : dim(pad(0, 4))} ` +
293
+ `${n.info ? blue(pad(n.info, 4)) : dim(pad(0, 4))}` +
294
+ (failed ? dim(' — nothing crawled') : ''),
295
+ );
296
+ }
297
+
298
+ lines.push('');
299
+ const bad = rows.filter((r) => r.n.error).length;
300
+ lines.push(
301
+ bad
302
+ ? ` ${red(`${totals.error} error${totals.error === 1 ? '' : 's'}`)} across ${bad} of ${rows.length} sites` +
303
+ dim(` · ${totals.warn} warnings · ${totals.info} notes`)
304
+ : ` ${c('32', '✓')} no errors across ${rows.length} sites` +
305
+ dim(` · ${totals.warn} warnings · ${totals.info} notes`),
306
+ );
307
+ lines.push('');
308
+ return lines.join('\n');
309
+ }
310
+
311
+ /** The same table, then each site's full report underneath it. */
312
+ export function portfolioMarkdown(runs) {
313
+ const rows = portfolioRows(runs);
314
+ const out = [];
315
+ out.push('# SEO audit — portfolio');
316
+ out.push('');
317
+ out.push(`${runs[0]?.meta.date ?? ''} · ${rows.length} sites`);
318
+ out.push('');
319
+ out.push('| Site | Pages | Errors | Warnings | Notes |');
320
+ out.push('|---|---:|---:|---:|---:|');
321
+ for (const { host: h, run, n } of rows) {
322
+ out.push(`| [${h}](${run.meta.origin}) | ${run.meta.pages ?? 0} | ${n.error} | ${n.warn} | ${n.info} |`);
323
+ }
324
+ out.push('');
325
+ out.push('---');
326
+ out.push('');
327
+ // Each site's own report, unchanged, so a single site's section can be
328
+ // lifted out and sent to whoever owns that site.
329
+ for (const { run } of rows) out.push(markdown(run.findings, run.meta), '');
330
+ return out.join('\n');
331
+ }
332
+
333
+ export function portfolioHtml(runs) {
334
+ const rows = portfolioRows(runs);
335
+ const esc = (s) =>
336
+ String(s ?? '')
337
+ .replace(/&/g, '&amp;')
338
+ .replace(/</g, '&lt;')
339
+ .replace(/>/g, '&gt;')
340
+ .replace(/"/g, '&quot;');
341
+
342
+ // Each site rendered by the existing single-site view, then spliced in below
343
+ // the table — one file, and every section is the report that site would have
344
+ // produced on its own.
345
+ const sections = rows
346
+ .map(({ run }) => {
347
+ const body = html(run.findings, run.meta).match(/<main>([\s\S]*)<\/main>/)?.[1] ?? '';
348
+ return `<section class="site" id="${esc(host(run))}">${body}</section>`;
349
+ })
350
+ .join('');
351
+
352
+ const shell = html([], { origin: 'portfolio', date: runs[0]?.meta.date ?? '', pages: 0 });
353
+ const table = `
354
+ <h1>SEO audit — ${rows.length} sites</h1>
355
+ <p class="meta">${esc(runs[0]?.meta.date ?? '')}</p>
356
+ <table>
357
+ <thead><tr><th>Site</th><th class="n">Pages</th><th class="n">Errors</th><th class="n">Warnings</th><th class="n">Notes</th></tr></thead>
358
+ <tbody>${rows
359
+ .map(
360
+ ({ host: h, run, n }) =>
361
+ `<tr><td><a href="#${esc(h)}">${esc(h)}</a></td><td class="n">${run.meta.pages ?? 0}</td>` +
362
+ `<td class="n">${n.error}</td><td class="n">${n.warn}</td><td class="n">${n.info}</td></tr>`,
363
+ )
364
+ .join('')}</tbody>
365
+ </table>
366
+ ${sections}`;
367
+
368
+ return shell
369
+ .replace(/<title>[\s\S]*?<\/title>/, `<title>SEO audit — ${rows.length} sites</title>`)
370
+ .replace(/<main>[\s\S]*<\/main>/, `<main>${table}</main>`);
371
+ }
372
+
373
+ /** Baseline comparison: what changed since the last run, and nothing else. */
374
+ export function diffReport({ added, fixed, unchanged, previousDate }) {
375
+ const lines = [''];
376
+
377
+ if (fixed.length) {
378
+ lines.push(` ${c('32', `✓ ${fixed.length} fixed since ${previousDate}`)}`);
379
+ for (const item of fixed.slice(0, 10)) {
380
+ lines.push(` ${dim('·')} ${item.title} ${dim(item.url ?? '')}`);
381
+ }
382
+ if (fixed.length > 10) lines.push(` ${dim(`… and ${fixed.length - 10} more`)}`);
383
+ lines.push('');
384
+ }
385
+
386
+ if (added.length) {
387
+ lines.push(` ${red(`✗ ${added.length} new since ${previousDate}`)}`);
388
+ for (const entry of group(added)) {
389
+ lines.push(` ${PAINT[entry.level](MARK[entry.level])} ${bold(entry.title)}`);
390
+ lines.push(` ${dim(entry.items[0].detail)}`);
391
+ for (const item of entry.items.slice(0, 6)) lines.push(` ${dim('·')} ${item.url ?? ''}`);
392
+ }
393
+ lines.push('');
394
+ }
395
+
396
+ if (!added.length && !fixed.length) {
397
+ lines.push(` ${c('32', '✓')} no change since ${previousDate} ${dim(`(${unchanged} known)`)}`);
398
+ lines.push('');
399
+ } else {
400
+ lines.push(dim(` ${unchanged} unchanged`));
401
+ lines.push('');
402
+ }
403
+
404
+ return lines.join('\n');
405
+ }
406
+
407
+ /** Self-contained HTML — one file, no assets, safe to email or attach. */
408
+ /** Self-contained HTML — one file, no assets, safe to email or attach.
409
+ *
410
+ * Everything is inline and nothing is fetched: no CDN, no webfont, no script.
411
+ * A report that needs the network to render is a report that renders blank in
412
+ * an email client, on a plane, or in three years' time when the CDN is gone.
413
+ */
414
+ /** The findings as a spreadsheet: one row per finding, one column per thing
415
+ * somebody might sort or filter by.
416
+ *
417
+ * A flat table on purpose. The grouped view is what the report is for; this is
418
+ * for the person who wants to sort 2,081 rows by impressions, hand a filtered
419
+ * slice to a developer, or paste the lot into a tracker. Anything that cannot
420
+ * survive a column — the shortest route to a deep page, the list of files a
421
+ * duplicate URL appears in — stays in `detail`, whole.
422
+ *
423
+ * Written with a byte-order mark, which is the difference between Excel
424
+ * showing "Maison Éthérique" and showing "Maison Éthérique". Every other
425
+ * reader ignores it. */
426
+ export function csv(findings, meta) {
427
+ // RFC 4180: quote everything that could contain a delimiter, and double any
428
+ // quote inside. Quoting every field is simpler than deciding per value, and
429
+ // a spreadsheet cannot tell the difference.
430
+ const cell = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
431
+ const columns = [
432
+ 'level', 'check', 'finding', 'page', 'section', 'indexable',
433
+ 'inlinks', 'clicks_from_home', 'impressions', 'clicks', 'detail',
434
+ ];
435
+
436
+ const rows = findings.map((finding) => [
437
+ finding.level,
438
+ finding.id,
439
+ finding.title,
440
+ finding.url ?? '',
441
+ finding.url ? sectionOf(finding.url) : '',
442
+ finding.indexable === false ? 'no' : 'yes',
443
+ finding.reach?.inlinks ?? '',
444
+ finding.reach?.depth ?? '',
445
+ finding.traffic?.impressions ?? '',
446
+ finding.traffic?.clicks ?? '',
447
+ finding.detail,
448
+ ]);
449
+
450
+ return `\uFEFF${[columns, ...rows].map((row) => row.map(cell).join(',')).join('\r\n')}\r\n`;
451
+ }
452
+
453
+ export function html(findings, meta, { backHref, backLabel = 'New audit' } = {}) {
454
+ const n = counts(findings);
455
+ const esc = (s) =>
456
+ String(s ?? '')
457
+ .replace(/&/g, '&amp;')
458
+ .replace(/</g, '&lt;')
459
+ .replace(/>/g, '&gt;')
460
+ .replace(/"/g, '&quot;');
461
+
462
+ const LABEL = { error: 'Error', warn: 'Warning', info: 'Note' };
463
+ const HEADING = { error: 'Errors', warn: 'Warnings', info: 'Notes' };
464
+ const groups = byCategory(findings);
465
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
466
+
467
+ const section = ({ name, entries: list }) => {
468
+ if (!list.length) return '';
469
+ const slug = name.toLowerCase().replace(/[^a-z]+/g, '-');
470
+ return `
471
+ <h2 id="${slug}"><span>${esc(name)}</span><span class="rule"></span><span class="tick">${list.length}</span></h2>
472
+ ${list
473
+ .map(
474
+ (entry) => `
475
+ <article class="finding ${entry.level}">
476
+ <header>
477
+ <span class="pill ${entry.level}">${LABEL[entry.level]}</span>
478
+ <h3>${esc(entry.title)}</h3>
479
+ ${entry.items.length > 1 ? `<span class="badge">${plural(entry.items.length, 'page')}</span>` : ''}
480
+ </header>
481
+ <code class="id">${esc(entry.id)}</code>
482
+ <ul>
483
+ ${entry.items
484
+ .map(
485
+ (item) => `<li>
486
+ ${item.url ? `<a href="${esc(item.url)}">${esc(item.url)}</a>` : ''}
487
+ ${item.indexable === false ? '<span class="noidx">not indexable</span>' : ''}
488
+ <span class="detail">${esc(item.detail)}</span>
489
+ </li>`,
490
+ )
491
+ .join('')}
492
+ </ul>
493
+ </article>`,
494
+ )
495
+ .join('')}`;
496
+ };
497
+
498
+ return `<!doctype html>
499
+ <html lang="en">
500
+ <head>
501
+ <meta charset="utf-8">
502
+ <meta name="viewport" content="width=device-width, initial-scale=1">
503
+ <title>SEO audit — ${esc(meta.origin)}</title>
504
+ <style>
505
+ :root {
506
+ color-scheme: light dark;
507
+ --bg: #fff;
508
+ --panel: #fafafa;
509
+ --fg: #0a0a0a;
510
+ --muted: #666;
511
+ --faint: #8f8f8f;
512
+ --line: #eaeaea;
513
+ --line-strong: #d4d4d4;
514
+ --error: #c5292f;
515
+ --warn: #a35200;
516
+ --info: #0059c8;
517
+ --error-bg: #fdf0f0;
518
+ --warn-bg: #fdf4e7;
519
+ --info-bg: #eef4ff;
520
+ --ok: #0a7c42;
521
+ --radius: 7px;
522
+ }
523
+ @media (prefers-color-scheme: dark) {
524
+ :root {
525
+ --bg: #000;
526
+ --panel: #0e0e0e;
527
+ --fg: #ededed;
528
+ --muted: #a1a1a1;
529
+ --faint: #7a7a7a;
530
+ --line: #262626;
531
+ --line-strong: #3a3a3a;
532
+ --error: #ff6166;
533
+ --warn: #f5a623;
534
+ --info: #6ea8ff;
535
+ --error-bg: #1c0d0e;
536
+ --warn-bg: #1c1408;
537
+ --info-bg: #0b1220;
538
+ --ok: #3fcf7f;
539
+ }
540
+ }
541
+
542
+ * { box-sizing: border-box; }
543
+ html { -webkit-text-size-adjust: 100%; }
544
+ body {
545
+ margin: 0;
546
+ padding: 0 1.25rem 6rem;
547
+ background: var(--bg);
548
+ color: var(--fg);
549
+ font: 400 15px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
550
+ -webkit-font-smoothing: antialiased;
551
+ }
552
+ main { max-width: 62rem; margin-inline: auto; }
553
+ a { color: inherit; }
554
+
555
+ /* --- Masthead ------------------------------------------------------- */
556
+ .bar {
557
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
558
+ padding: 1.1rem 0; margin-bottom: 3rem;
559
+ border-bottom: 1px solid var(--line);
560
+ }
561
+ .mark {
562
+ font: 600 12.5px/1 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
563
+ letter-spacing: .02em; color: var(--fg); text-decoration: none;
564
+ }
565
+ .mark span { color: var(--faint); }
566
+ .causes { margin: 0 0 3rem; }
567
+ .causes .lede { color: var(--muted); margin: 0 0 1rem; font-size: .95rem; }
568
+ .causes ol { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
569
+ .causes li {
570
+ display: flex; align-items: baseline; gap: .7rem; flex-wrap: wrap;
571
+ padding: .7rem .9rem; border: 1px solid var(--line); border-radius: 8px;
572
+ }
573
+ .causes li b { font-weight: 600; }
574
+ .causes .where { color: var(--muted); font-size: .88rem; margin-left: auto; }
575
+ @media (max-width: 40rem) { .causes .where { margin-left: 0; width: 100%; } }
576
+
577
+ .stamp {
578
+ font: 500 12px/1 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
579
+ color: var(--faint); font-variant-numeric: tabular-nums;
580
+ }
581
+ /* Only rendered when a caller has somewhere to go back to — a report saved
582
+ to disk does not. */
583
+ .back {
584
+ font: 500 12px/1 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
585
+ color: var(--faint); text-decoration: none; margin-left: auto; margin-right: 1rem;
586
+ }
587
+ .back:hover { color: var(--fg); }
588
+ @media print { .back { display: none; } }
589
+
590
+ h1 {
591
+ font-size: 1.75rem; line-height: 1.2; font-weight: 600;
592
+ letter-spacing: -.021em; margin: 0 0 .55rem;
593
+ }
594
+ h1 a { text-decoration: none; }
595
+ h1 a:hover { text-decoration: underline; text-underline-offset: 3px; }
596
+
597
+ .facts {
598
+ display: flex; flex-wrap: wrap; gap: .4rem .95rem;
599
+ margin: 0 0 2.25rem; padding: 0; list-style: none;
600
+ font-size: .82rem; color: var(--muted);
601
+ font-variant-numeric: tabular-nums;
602
+ }
603
+ .facts li { display: flex; gap: .38rem; }
604
+ .facts b { font-weight: 600; color: var(--fg); }
605
+
606
+ /* --- Tally ---------------------------------------------------------- */
607
+ .tally { display: grid; grid-template-columns: repeat(3, 1fr); gap: .7rem; margin: 0 0 3rem; }
608
+ .tally div {
609
+ border: 1px solid var(--line); border-radius: var(--radius);
610
+ background: var(--panel); padding: .9rem 1rem;
611
+ }
612
+ .tally b {
613
+ display: block; font-size: 1.9rem; line-height: 1.1; font-weight: 600;
614
+ letter-spacing: -.028em; font-variant-numeric: tabular-nums;
615
+ }
616
+ .tally small {
617
+ display: block; margin-top: .18rem; font-size: .715rem; font-weight: 600;
618
+ text-transform: uppercase; letter-spacing: .075em; color: var(--muted);
619
+ }
620
+ .tally .e b { color: var(--error); }
621
+ .tally .w b { color: var(--warn); }
622
+ .tally .i b { color: var(--info); }
623
+ .tally .zero b { color: var(--faint); }
624
+
625
+ /* --- Tables --------------------------------------------------------- */
626
+ .scroll { overflow-x: auto; margin: 0 0 3.25rem; border: 1px solid var(--line); border-radius: var(--radius); }
627
+ table { width: 100%; border-collapse: collapse; font-size: .875rem; }
628
+ thead th {
629
+ text-align: left; padding: .62rem .85rem;
630
+ font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .075em;
631
+ color: var(--muted); background: var(--panel); border-bottom: 1px solid var(--line);
632
+ white-space: nowrap;
633
+ }
634
+ tbody td { padding: .62rem .85rem; border-bottom: 1px solid var(--line); }
635
+ tbody tr:last-child td { border-bottom: 0; }
636
+ td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
637
+ td.n { color: var(--muted); }
638
+ tbody a { text-decoration: none; font-weight: 500; }
639
+ tbody a:hover { text-decoration: underline; text-underline-offset: 2px; }
640
+
641
+ /* --- Pills ---------------------------------------------------------- */
642
+ .pill {
643
+ display: inline-block; flex: none;
644
+ padding: .12rem .42rem; border-radius: 4px;
645
+ font-size: .655rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
646
+ border: 1px solid currentColor;
647
+ }
648
+ .pill.error { color: var(--error); background: var(--error-bg); }
649
+ .pill.warn { color: var(--warn); background: var(--warn-bg); }
650
+ .pill.info { color: var(--info); background: var(--info-bg); }
651
+
652
+ /* --- Section headings ----------------------------------------------- */
653
+ h2 {
654
+ display: flex; align-items: center; gap: .8rem;
655
+ font-size: .74rem; font-weight: 600; text-transform: uppercase; letter-spacing: .085em;
656
+ color: var(--muted); margin: 0 0 1.1rem;
657
+ }
658
+ h2 .rule { flex: 1; height: 1px; background: var(--line); }
659
+ h2 .tick { font-variant-numeric: tabular-nums; color: var(--faint); }
660
+
661
+ /* --- Findings ------------------------------------------------------- */
662
+ .finding {
663
+ border: 1px solid var(--line); border-radius: var(--radius);
664
+ margin: 0 0 .8rem; overflow: hidden; background: var(--bg);
665
+ }
666
+ .finding > header {
667
+ display: flex; align-items: baseline; gap: .55rem; flex-wrap: wrap;
668
+ padding: .8rem .95rem .1rem;
669
+ }
670
+ .finding h3 {
671
+ font-size: .975rem; font-weight: 600; letter-spacing: -.011em;
672
+ margin: 0; flex: 1 1 20rem;
673
+ }
674
+ .badge {
675
+ font-size: .715rem; font-weight: 500; color: var(--muted);
676
+ font-variant-numeric: tabular-nums; white-space: nowrap;
677
+ }
678
+ .id {
679
+ display: inline-block; margin: 0 .95rem .7rem;
680
+ font: 500 .71rem/1 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
681
+ color: var(--faint);
682
+ }
683
+ .finding ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
684
+ .finding li { padding: .55rem .95rem; border-bottom: 1px solid var(--line); }
685
+ .finding li:last-child { border-bottom: 0; }
686
+ .finding li a {
687
+ font: 500 .8rem/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
688
+ text-decoration: none; word-break: break-word; color: var(--fg);
689
+ }
690
+ .finding li a:hover { text-decoration: underline; text-underline-offset: 2px; }
691
+ .detail { display: block; color: var(--muted); font-size: .83rem; margin-top: .12rem; }
692
+ .noidx {
693
+ display: inline-block; margin-left: .4rem; padding: .04rem .34rem; border-radius: 4px;
694
+ border: 1px solid var(--line-strong); color: var(--faint);
695
+ font-size: .625rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
696
+ vertical-align: 1px; white-space: nowrap;
697
+ }
698
+
699
+ /* --- Clean bill ------------------------------------------------------ */
700
+ .clean {
701
+ border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel);
702
+ padding: 2.25rem 1.25rem; text-align: center;
703
+ }
704
+ .clean b { display: block; font-size: 1.05rem; font-weight: 600; color: var(--ok); }
705
+ .clean span { color: var(--muted); font-size: .87rem; }
706
+
707
+ /* --- Portfolio ------------------------------------------------------- */
708
+ .site { margin: 0 0 4.5rem; }
709
+ .site h1 { font-size: 1.25rem; }
710
+
711
+ footer {
712
+ margin-top: 4.5rem; padding-top: 1.15rem; border-top: 1px solid var(--line);
713
+ color: var(--faint); font-size: .8rem; line-height: 1.7;
714
+ }
715
+ footer a { color: var(--muted); }
716
+
717
+ @media (max-width: 34rem) {
718
+ .tally { grid-template-columns: 1fr; }
719
+ h1 { font-size: 1.4rem; }
720
+ }
721
+ /* Printing is how this reaches somebody who did not run it: ⌘P, save as
722
+ PDF, send. The screen version is dark and scrolls forever; a page is
723
+ neither, so the colours are forced light rather than left to a browser's
724
+ "print backgrounds" setting, and nothing is allowed to break across a
725
+ page boundary in the middle of a finding. */
726
+ @media print {
727
+ :root { --bg: #fff; --fg: #111827; --muted: #4b5563; --line: #d1d5db; --panel: #fff; }
728
+ body { padding: 0; color: #000; background: #fff; font-size: 11pt; }
729
+ @page { margin: 16mm 14mm; }
730
+ .finding, .tally div, .scroll, .causes li, tr { break-inside: avoid; }
731
+ h2 { break-after: avoid; }
732
+ .bar { margin-bottom: 1.5rem; }
733
+ footer { break-before: avoid; }
734
+ /* The causes are the point of the first page, so the detail starts on the
735
+ second one rather than trailing off the bottom of it. */
736
+ .causes { break-after: page; }
737
+ /* A link is useless on paper unless it says where it goes. Findings list
738
+ full URLs already; this is for the ones written as link text. */
739
+ .finding a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #4b5563; word-break: break-all; }
740
+ .back, .js-only { display: none !important; }
741
+ }
742
+ </style>
743
+ </head>
744
+ <body>
745
+ <main>
746
+ <div class="bar">
747
+ <a class="mark" href="https://github.com/nurkamol/seo-audit">seo<span>-</span>audit</a>
748
+ ${backHref ? `<a class="back" href="${esc(backHref)}">← ${esc(backLabel)}</a>` : ''}
749
+ <span class="stamp">${esc(meta.date)}</span>
750
+ </div>
751
+
752
+ <h1><a href="${esc(meta.origin)}">${esc(meta.origin)}</a></h1>
753
+ <ul class="facts">
754
+ <li><b>${meta.pages ?? 0}</b> pages crawled</li>
755
+ ${meta.requests ? `<li><b>${meta.requests}</b> requests</li>` : ''}
756
+ ${meta.ms ? `<li><b>${(meta.ms / 1000).toFixed(1)}s</b> elapsed</li>` : ''}
757
+ ${meta.ignored ? `<li><b>${meta.ignored}</b> silenced by config</li>` : ''}
758
+ ${meta.notIndexable ? `<li><b>${meta.notIndexable}</b> pages not indexable</li>` : ''}
759
+ </ul>
760
+
761
+ ${(() => {
762
+ const causes = worstCauses(findings);
763
+ if (!causes.length) return '';
764
+ return `<section class="causes">
765
+ <h2 id="start-here"><span>Start here</span><span class="rule"></span><span class="tick">${byCause(findings).length}</span></h2>
766
+ <p class="lede">${findings.length} findings are ${byCause(findings).length} things to change. The widest:</p>
767
+ <ol>${causes
768
+ .map(
769
+ (cause) => `<li class="${cause.level}">
770
+ <span class="pill ${cause.level}">${LABEL[cause.level]}</span>
771
+ <b>${esc(cause.title)}</b>
772
+ <span class="where">${esc(causeScope(cause, meta.pages))}</span>
773
+ </li>`,
774
+ )
775
+ .join('')}</ol>
776
+ </section>`;
777
+ })()}
778
+
779
+ <div class="tally">
780
+ <div class="e${n.error ? '' : ' zero'}"><b>${n.error}</b><small>${n.error === 1 ? 'Error' : 'Errors'}</small></div>
781
+ <div class="w${n.warn ? '' : ' zero'}"><b>${n.warn}</b><small>${n.warn === 1 ? 'Warning' : 'Warnings'}</small></div>
782
+ <div class="i${n.info ? '' : ' zero'}"><b>${n.info}</b><small>${n.info === 1 ? 'Note' : 'Notes'}</small></div>
783
+ </div>
784
+
785
+ ${
786
+ findings.length
787
+ ? `<div class="scroll"><table>
788
+ <thead><tr><th>Level</th><th>Area</th><th>Finding</th><th class="n">Pages</th></tr></thead>
789
+ <tbody>${groups
790
+ .flatMap(({ name, entries: list }) =>
791
+ list.map(
792
+ (e) =>
793
+ `<tr><td><span class="pill ${e.level}">${LABEL[e.level]}</span></td>` +
794
+ `<td><a href="#${name.toLowerCase().replace(/[^a-z]+/g, '-')}">${esc(name)}</a></td>` +
795
+ `<td>${esc(e.title)}</td><td class="n">${e.items.length}</td></tr>`,
796
+ ),
797
+ )
798
+ .join('')}</tbody>
799
+ </table></div>
800
+ ${groups.map(section).join('')}`
801
+ : `<div class="clean"><b>Nothing to report</b><span>Every check passed on all ${meta.pages ?? 0} pages.</span></div>`
802
+ }
803
+
804
+ <footer>
805
+ Correctness across every page. Performance is measured by Google via
806
+ <a href="https://pagespeed.web.dev">PageSpeed Insights</a> when <code>--psi</code> is used, never estimated here.
807
+ Generated by <a href="https://github.com/nurkamol/seo-audit">seo-audit</a>.
808
+ </footer>
809
+ </main>
810
+ </body>
811
+ </html>
812
+ `;
813
+ }
814
+
815
+ /** What a `--dry-run` found, for the terminal.
816
+ *
817
+ * Deliberately not a report: it has no findings in it, and printing it in the
818
+ * report's shape would suggest a crawl happened. */
819
+ export function dryRunReport(plan) {
820
+ const out = [''];
821
+ out.push(` ${bold(plan.origin)}`);
822
+ if (plan.redirected) {
823
+ out.push(dim(` ${plan.redirected.from}/ redirects here, so this is the host that would be read`));
824
+ }
825
+
826
+ if (!plan.reachable) {
827
+ out.push('', ` ${red('Nothing answered.')} ${plan.rateLimited
828
+ ? 'Every request came back HTTP 429 — wait, then try a lower --concurrency.'
829
+ : 'The host did not return a single response.'}`);
830
+ out.push('');
831
+ return out.join('\n');
832
+ }
833
+
834
+ if (plan.sitemap) {
835
+ out.push(dim(` sitemap ${plan.sitemap}`));
836
+ } else {
837
+ out.push('', ` ${yellow('No sitemap found.')} The crawl would follow links from the home page`);
838
+ out.push(dim(` instead, up to --limit ${plan.limit}. Tried: ${plan.tried.join(', ')}`));
839
+ out.push('');
840
+ return out.join('\n');
841
+ }
842
+
843
+ out.push('');
844
+ if (plan.sinceRefused) {
845
+ out.push(` ${yellow('--since was not usable.')} ${plan.sinceRefused}`);
846
+ out.push('');
847
+ }
848
+ out.push(` ${bold(String(plan.listed))} URLs listed, ${bold(String(plan.wouldCheck))} would be checked`
849
+ + (plan.skippedByLimit
850
+ ? `, ${yellow(`${plan.skippedByLimit} past --limit ${plan.limit}`)}`
851
+ : ''));
852
+ if (plan.skippedBySince) out.push(dim(` ${plan.skippedBySince} unchanged since the date given`));
853
+ if (plan.excluded) out.push(dim(` ${plan.excluded} excluded by --exclude`));
854
+
855
+ if (plan.sections.length > 1) {
856
+ out.push('');
857
+ const width = Math.max(...plan.sections.map((s) => s.path.length));
858
+ for (const section of plan.sections) {
859
+ out.push(` ${section.path.padEnd(width)} ${dim(String(section.count))}`);
860
+ }
861
+ }
862
+
863
+ out.push('');
864
+ out.push(dim(` first few: ${plan.sample.slice(0, 3).join(', ')}`));
865
+ out.push(dim(` ${plan.requests} requests, ${(plan.ms / 1000).toFixed(1)}s — no page was fetched`));
866
+ out.push('');
867
+ return out.join('\n');
868
+ }