@nurkamol/seo-audit 1.34.0 → 1.35.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
@@ -14,6 +14,8 @@
14
14
  <p align="center">
15
15
  <a href="https://github.com/marketplace/actions/full-site-seo-audit"><img src="https://img.shields.io/badge/GitHub%20Marketplace-Full--site%20SEO%20Audit-f97316?logo=github&logoColor=white" alt="GitHub Marketplace"></a>
16
16
  <a href="https://github.com/nurkamol/seo-audit/actions/workflows/test.yml"><img src="https://github.com/nurkamol/seo-audit/actions/workflows/test.yml/badge.svg" alt="tests"></a>
17
+ <a href="https://github.com/nurkamol/seo-audit/releases/latest"><img src="https://img.shields.io/badge/Download-macOS%20app-0a0a0a?logo=apple&logoColor=white" alt="Download the macOS app"></a>
18
+ <a href="https://www.npmjs.com/package/@nurkamol/seo-audit"><img src="https://img.shields.io/npm/v/@nurkamol/seo-audit?color=cb3837&logo=npm&logoColor=white&label=npm" alt="npm"></a>
17
19
  <a href="https://github.com/nurkamol/seo-audit/releases"><img src="https://img.shields.io/github/v/release/nurkamol/seo-audit?color=f97316" alt="release"></a>
18
20
  <img src="https://img.shields.io/badge/node-%E2%89%A518-3c873a" alt="node >= 18">
19
21
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
@@ -630,6 +632,31 @@ survivable in CI instead of being switched off in week two.
630
632
 
631
633
  ---
632
634
 
635
+ ## The window, on Linux and Windows
636
+
637
+ The macOS app is a thin client over a local server, and that server runs anywhere Node does:
638
+
639
+ ```bash
640
+ npx @nurkamol/seo-audit --serve
641
+ ```
642
+
643
+ It opens a browser onto the same window the macOS app draws: a sidebar of kept runs down the left, the report beside it, score ring and all. Everything the command line takes is in the form — the same table that decides what the macOS window reaches decides what this draws, so neither can quietly fall behind the other. Finished runs are kept and listed at `/reports`, two of them can be compared, and on macOS it is **the same folder the app uses**: a crawl started in the window is in the browser's list a second later, because there is one folder rather than two.
644
+
645
+ Nothing leaves the machine. It binds to the loopback address, which is the whole of its security model.
646
+
647
+ There is also a **native window for Windows and Linux** built on exactly this: a
648
+ Tauri shell that starts the same server and shows the same report, in
649
+ [`desktop/`](desktop/README.md). It ships a Node inside it, so there is nothing
650
+ to install first. Every release attaches a `setup.exe`, a `.deb` and an
651
+ AppImage, each built on its own runner and then **installed and run there**
652
+ before it is attached. It tells you when there is a new version and offers
653
+ whatever is safe for the way you installed it — `winget upgrade` in place, or
654
+ the command to run, or the release page.
655
+
656
+ `--no-open` if you would rather it did not open a browser. It opens one when a person ran the command and never when something else did, so the macOS window — which spawns this — is unaffected.
657
+
658
+ ---
659
+
633
660
  ## Hosting it, for people who will not open a terminal
634
661
 
635
662
  Optional, and off the main path. Everything above is free and runs on your own
@@ -935,6 +962,13 @@ Two rules that keep the tool trustworthy:
935
962
  1. **No false positives.** A check that cries wolf gets the whole report ignored. If a pattern is sometimes legitimate, it is a `note`, not an `error`.
936
963
  2. **No dependencies.** It must keep running with a bare `npx` on a machine with nothing installed.
937
964
 
965
+ ```bash
966
+ npm test # the engine, the Worker and the extension — no install, any platform
967
+ npm run test:all # and the macOS app's own Swift suite, where there is a toolchain for it
968
+ ```
969
+
970
+ There are two suites and `npm test` runs one. Keeping it portable is the point — it works on a machine with nothing on it. `test:all` runs both and says plainly when it could not run the second, rather than exiting green having skipped half the work.
971
+
938
972
  See [ROADMAP.md](ROADMAP.md) for what is planned, [CHANGELOG.md](CHANGELOG.md) for what changed.
939
973
 
940
974
  ## Licence
package/bin/seo-audit.mjs CHANGED
@@ -79,6 +79,9 @@ const HELP = `
79
79
  --check-external also check links pointing off the site. Off by default:
80
80
  other people's servers rate-limit and bot-block, so only
81
81
  a 404, a 410 or no answer at all is ever reported
82
+ --no-open with --serve, do not open a browser. It opens one when
83
+ a person ran the command and never when something else
84
+ did, so this is only for the person who wants neither
82
85
  --serve [port] open the same form the hosted version serves, on this
83
86
  machine (default 4321). No account, no bill, and none of
84
87
  the limits a Worker has — the crawl is only bounded by
@@ -155,6 +158,7 @@ function parseArgs(argv) {
155
158
  else if (arg === '--write-sitemap') opts.writeSitemap = value();
156
159
  else if (arg === '--write-llms') opts.writeLlms = value();
157
160
  else if (arg === '--write-schema') opts.writeSchema = value();
161
+ else if (arg === '--no-open') opts.noOpen = true;
158
162
  else if (arg === '--md') opts.md = value();
159
163
  else if (arg === '--html') opts.html = value();
160
164
  else if (arg === '--json') opts.json = value();
@@ -335,6 +339,19 @@ if (opts.serve !== undefined) {
335
339
  process.stdin.on('end', () => process.exit(0));
336
340
  process.stdin.on('close', () => process.exit(0));
337
341
  }
342
+
343
+ // Opened for a person, never for a parent. The same distinction the pipe
344
+ // check above already makes: somebody who typed `--serve` wants the page,
345
+ // and the macOS window — which spawns this and draws its own report — would
346
+ // get a browser it never asked for on every launch.
347
+ //
348
+ // This is the whole of "the desktop UI for Linux and Windows": a command that
349
+ // opens a window. Failing to open one is not a reason to refuse to serve, so
350
+ // the URL is printed either way and nothing here throws.
351
+ if (!stdinIsPipe && !opts.noOpen) {
352
+ const { openUrl } = await import('../src/open-url.mjs');
353
+ if (!openUrl(url)) console.log(' Open that address yourself — this system has no launcher I know.\n');
354
+ }
338
355
  } else {
339
356
 
340
357
  // --- sign in, and stop ----------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurkamol/seo-audit",
3
- "version": "1.34.0",
3
+ "version": "1.35.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": {
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/audit.mjs",
11
11
  "./causes": "./src/causes.mjs",
12
+ "./exports": "./src/exports.mjs",
12
13
  "./report": "./src/report.mjs",
13
14
  "./score": "./src/score.mjs",
14
15
  "./sitemap": "./src/sitemap.mjs",
@@ -29,6 +30,8 @@
29
30
  "scripts": {
30
31
  "pretest": "node scripts/link-engine.mjs",
31
32
  "test": "node --test test/*.test.mjs",
33
+ "pretest:all": "node scripts/link-engine.mjs",
34
+ "test:all": "node scripts/test-all.mjs",
32
35
  "audit": "node bin/seo-audit.mjs"
33
36
  },
34
37
  "keywords": [
@@ -0,0 +1,84 @@
1
+ // A report, in whatever shape somebody needs it.
2
+ //
3
+ // This owns none of the formats. `html()`, `markdown()` and `csv()` are the
4
+ // writers the command line calls for `--html`, `--md` and `--csv`; the
5
+ // corrected sitemap, the llms.txt and the structured data are whatever the run
6
+ // produced when it was asked. A file downloaded from the window and one written
7
+ // by `seo-audit --csv` are the same file, which is the rule that lets this
8
+ // project have five front ends.
9
+ //
10
+ // What it does own is the **list**: which formats exist, what they are called,
11
+ // and what a saved file is named. That was written out twice — once in the
12
+ // macOS app's `ExportFormat` and once in the Raycast extension — and a third
13
+ // copy for the served window would have been the point at which they started
14
+ // disagreeing about whether "Structured data" is called that.
15
+ //
16
+ // Web-standard only: no `node:fs` here, because the Worker imports it. Writing
17
+ // the file is the caller's job and differs per front end anyway — a browser
18
+ // downloads it, a launcher writes it to Downloads, a window opens a save panel.
19
+
20
+ import { html, markdown, csv } from './report.mjs';
21
+
22
+ /** Every shape a report can be written as, in the order somebody wants them. */
23
+ export const FORMATS = [
24
+ { id: 'html', label: 'HTML report', extension: 'html', mime: 'text/html; charset=utf-8',
25
+ detail: 'The full report, one file, opens in any browser.' },
26
+ { id: 'markdown', label: 'Markdown', extension: 'md', mime: 'text/markdown; charset=utf-8',
27
+ detail: 'For committing, or pasting into a ticket.' },
28
+ { id: 'csv', label: 'Spreadsheet', extension: 'csv', mime: 'text/csv; charset=utf-8',
29
+ detail: 'The whole checklist, one row each. For sorting and filtering.' },
30
+ { id: 'json', label: 'JSON', extension: 'json', mime: 'application/json; charset=utf-8',
31
+ detail: 'Everything, exactly as the engine produced it.' },
32
+ { id: 'sitemap', label: 'Corrected sitemap', extension: 'xml', mime: 'application/xml; charset=utf-8',
33
+ detail: 'The sitemap this site should have had.' },
34
+ { id: 'llms', label: 'llms.txt', extension: 'txt', mime: 'text/plain; charset=utf-8',
35
+ detail: "The llms.txt this site should have had, from its own words." },
36
+ { id: 'schema', label: 'Structured data', extension: 'json', mime: 'application/json; charset=utf-8',
37
+ detail: 'The JSON-LD this site could add, from what it already says.' },
38
+ ];
39
+
40
+ export const formatById = (id) => FORMATS.find((format) => format.id === id) ?? null;
41
+
42
+ /** A file name somebody can find again, and that sorts. */
43
+ export function filenameFor(format, host, at = new Date()) {
44
+ const spec = formatById(format);
45
+ const stamp = at.toISOString().slice(0, 10);
46
+ const safe = (host || 'report').replace(/[^a-z0-9.-]+/gi, '-');
47
+ return `seo-audit-${safe}-${stamp}.${spec?.extension ?? 'txt'}`;
48
+ }
49
+
50
+ /** The report as text, or the reason there is none.
51
+ *
52
+ * Three of these can legitimately be nothing, and the refusal is the useful
53
+ * half: the engine declines to build a sitemap from a crawl that did not see
54
+ * the whole site, declines an llms.txt for the same reason, and tells a site
55
+ * that already declares all the structured data it could write exactly that.
56
+ * Carrying the reason is the difference between "unavailable" and "here is
57
+ * why, and here is the run that would work". */
58
+ export function renderExport(format, report) {
59
+ if (!report?.meta) return { text: null, refused: 'There is no report to write.' };
60
+
61
+ const built = (held, text, what) => {
62
+ if (!held) return { text: null, refused: `This run did not build ${what}.` };
63
+ return { text, refused: text ? null : held.refused };
64
+ };
65
+
66
+ switch (format) {
67
+ case 'html':
68
+ return { text: html(report.findings ?? [], report.meta, { score: report.score }), refused: null };
69
+ case 'markdown':
70
+ return { text: markdown(report.findings ?? [], report.meta, { score: report.score }), refused: null };
71
+ case 'csv':
72
+ return { text: csv(report.findings ?? [], report.meta, { score: report.score }), refused: null };
73
+ case 'json':
74
+ return { text: JSON.stringify(report, null, 2), refused: null };
75
+ case 'sitemap':
76
+ return built(report.sitemap, report.sitemap?.xml, 'a sitemap');
77
+ case 'llms':
78
+ return built(report.llms, report.llms?.text, 'an llms.txt');
79
+ case 'schema':
80
+ return built(report.schema, report.schema?.json, 'any structured data');
81
+ default:
82
+ return { text: null, refused: `Unknown format "${format}".` };
83
+ }
84
+ }
@@ -0,0 +1,156 @@
1
+ // Runs, kept on disk, for the local server.
2
+ //
3
+ // The macOS window has kept every finished run since 1.23.0 and `--serve` kept
4
+ // none, so somebody on Linux or Windows got one report and lost it the moment
5
+ // they audited something else. A seven-minute crawl should only ever happen
6
+ // once, and that is not a macOS-only claim.
7
+ //
8
+ // It writes **the same folder the app uses**, in the same shape, so a run
9
+ // started in the window is in the browser's list a second later and the other
10
+ // way round — nothing is synchronised, exported or copied, because there is one
11
+ // folder and both front ends read it.
12
+ //
13
+ // This is Node, and `worker/index.mjs` must not be: it runs on Cloudflare too,
14
+ // where there is no filesystem. So the server hands the worker an object with
15
+ // these four methods and the worker calls them if they are there. A deployed
16
+ // Worker passes nothing and has no library, which is correct — a shared host
17
+ // keeping strangers' crawls is a thing nobody asked for.
18
+
19
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, rmSync, statSync } from 'node:fs';
20
+ import { homedir, platform } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { randomUUID } from 'node:crypto';
23
+
24
+ /** Where an application keeps documents it manages, per platform.
25
+ *
26
+ * macOS is `Application Support`, and deliberately the *same* path
27
+ * `Support.directory()` uses on the Swift side — named for the bundle id
28
+ * rather than the display name — so the window and the browser share one
29
+ * library rather than each having their own.
30
+ *
31
+ * Caches would be wrong everywhere: the system may delete those, and seven
32
+ * minutes of crawling is not a cache. */
33
+ export function libraryRoot(env = process.env, os = platform()) {
34
+ if (env.SEO_AUDIT_HOME) return env.SEO_AUDIT_HOME;
35
+ const home = homedir();
36
+ if (os === 'darwin') return join(home, 'Library', 'Application Support', 'seo-audit');
37
+ if (os === 'win32') {
38
+ return join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'seo-audit');
39
+ }
40
+ // Linux and the BSDs: the XDG basedir spec, which says $XDG_DATA_HOME and
41
+ // falls back to ~/.local/share.
42
+ return join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'seo-audit');
43
+ }
44
+
45
+ /** How many runs to keep. The same bound the window applies, for the same
46
+ * reason: this is a list to click rather than an archive. */
47
+ const KEEP = 40;
48
+
49
+ /** A store the Worker can call without knowing it is talking to a filesystem. */
50
+ export function library(root = libraryRoot()) {
51
+ const folder = join(root, 'reports');
52
+ const indexFile = join(root, 'index.json');
53
+ mkdirSync(folder, { recursive: true });
54
+
55
+ const readIndex = () => {
56
+ if (!existsSync(indexFile)) return [];
57
+ try {
58
+ const rows = JSON.parse(readFileSync(indexFile, 'utf8'));
59
+ // An index entry whose file is gone is a row that opens onto nothing,
60
+ // which is worse than not listing it — a folder can be emptied by a sync
61
+ // tool without the index being told.
62
+ return Array.isArray(rows)
63
+ ? rows.filter((row) => row?.id && existsSync(join(folder, `${row.id}.json`)))
64
+ : [];
65
+ } catch {
66
+ return [];
67
+ }
68
+ };
69
+
70
+ const writeIndex = (rows) => {
71
+ try {
72
+ writeFileSync(indexFile, JSON.stringify(rows, null, 2));
73
+ } catch {
74
+ /* a library that cannot be written is not a reason to lose the report */
75
+ }
76
+ };
77
+
78
+ return {
79
+ /** Every kept run, newest first. */
80
+ list() {
81
+ return readIndex().sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt)));
82
+ },
83
+
84
+ /** One run, exactly as the engine wrote it. `null` when it is not there. */
85
+ read(id) {
86
+ // The id comes off a URL, so it is checked rather than trusted: anything
87
+ // but a UUID cannot become a path.
88
+ if (!/^[0-9a-f-]{36}$/i.test(id ?? '')) return null;
89
+ try {
90
+ return JSON.parse(readFileSync(join(folder, `${id}.json`), 'utf8'));
91
+ } catch {
92
+ return null;
93
+ }
94
+ },
95
+
96
+ /** Keep a finished run. `payload` is what the engine produced, byte for
97
+ * byte — not this server's idea of it, so a report saved by one version
98
+ * still opens in the next and `jq` works on it. */
99
+ keep(payload, { site, finishedAt = new Date().toISOString() } = {}) {
100
+ const id = randomUUID();
101
+ const counts = { error: 0, warn: 0, info: 0 };
102
+ for (const finding of payload.findings ?? []) counts[finding.level] += 1;
103
+
104
+ let host = site;
105
+ try {
106
+ host = new URL(payload.meta?.origin ?? site).host;
107
+ } catch {
108
+ /* keep whatever was passed */
109
+ }
110
+
111
+ const row = {
112
+ id,
113
+ host,
114
+ site: payload.meta?.origin ?? site,
115
+ finishedAt,
116
+ pages: payload.meta?.pages ?? 0,
117
+ findings: (payload.findings ?? []).length,
118
+ causes: (payload.causes ?? []).length,
119
+ errors: counts.error,
120
+ warnings: counts.warn,
121
+ // The same field the window writes, so one list shows both.
122
+ ...(typeof payload.score?.score === 'number' ? { score: payload.score.score } : {}),
123
+ };
124
+
125
+ try {
126
+ writeFileSync(join(folder, `${id}.json`), JSON.stringify(payload));
127
+ } catch {
128
+ return null;
129
+ }
130
+
131
+ const rows = [row, ...readIndex()];
132
+ for (const old of rows.slice(KEEP)) {
133
+ try {
134
+ rmSync(join(folder, `${old.id}.json`), { force: true });
135
+ } catch {
136
+ /* already gone */
137
+ }
138
+ }
139
+ writeIndex(rows.slice(0, KEEP));
140
+ return row;
141
+ },
142
+
143
+ /** Where the files are, and what they take up — "small" is a claim
144
+ * somebody is entitled to check. */
145
+ where: () => folder,
146
+ bytes() {
147
+ return readIndex().reduce((total, row) => {
148
+ try {
149
+ return total + statSync(join(folder, `${row.id}.json`)).size;
150
+ } catch {
151
+ return total;
152
+ }
153
+ }, 0);
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,49 @@
1
+ // Opening a URL in whatever the person's system calls a browser.
2
+ //
3
+ // Three commands for three platforms and no dependency, which is the whole
4
+ // reason this is nine lines rather than an `open` package. Detached and with
5
+ // its output thrown away: the launcher exits immediately on every platform, and
6
+ // a server that waits on it would hang on the one that does not.
7
+
8
+ import { spawn } from 'node:child_process';
9
+
10
+ /** The command that opens a URL, per platform. `null` where there is nothing
11
+ * to try — a container, or a system this has no answer for. */
12
+ export function opener(platform = process.platform) {
13
+ switch (platform) {
14
+ case 'darwin':
15
+ return { command: 'open', args: [] };
16
+ // `start` is a shell builtin rather than a program, so it needs cmd. The
17
+ // empty string is the window title `start` otherwise steals from the URL,
18
+ // and leaving it out is the classic bug where nothing opens.
19
+ case 'win32':
20
+ return { command: 'cmd', args: ['/c', 'start', ''] };
21
+ case 'linux':
22
+ case 'freebsd':
23
+ case 'openbsd':
24
+ return { command: 'xdg-open', args: [] };
25
+ default:
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** Open a URL. Returns false when there was nothing to try, or when the
31
+ * launcher could not be started — never throws, because failing to open a
32
+ * browser is not a reason for a server not to run. The URL is already on
33
+ * screen either way. */
34
+ export function openUrl(url, { platform = process.platform, spawnFn = spawn } = {}) {
35
+ const how = opener(platform);
36
+ if (!how) return false;
37
+ try {
38
+ const child = spawnFn(how.command, [...how.args, url], {
39
+ stdio: 'ignore',
40
+ detached: true,
41
+ });
42
+ // Not waited on: the parent is a server that should outlive the launcher.
43
+ child.on?.('error', () => {});
44
+ child.unref?.();
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
package/src/options.mjs CHANGED
@@ -23,21 +23,48 @@
23
23
  */
24
24
  export const OPTIONS = [
25
25
  // --- what a run does ----------------------------------------------------
26
- { flag: '--limit', query: 'limit', app: true },
27
- { flag: '--concurrency', query: 'concurrency', app: true },
28
- { flag: '--check-external', query: 'external', app: true },
29
- { flag: '--sitemap', query: 'sitemap', app: true },
30
- { flag: '--browser', query: 'browser', app: true },
31
- { flag: '--os', query: 'os', app: true },
32
- { flag: '--user-agent', query: 'userAgent', app: true },
33
- { flag: '--search-console', query: 'search-console', app: true },
26
+ { flag: '--limit', query: 'limit', app: true,
27
+ field: { type: 'number', label: 'Pages at most', min: 1,
28
+ help: 'A big site is minutes. Preview first if you are not sure this is the right one.' } },
29
+ { flag: '--concurrency', query: 'concurrency', app: true,
30
+ field: { type: 'select', label: 'Speed',
31
+ // The same three the macOS window offers, so "Gentle" means the
32
+ // same thing in both. The numbers live in one place.
33
+ choices: [['', 'Normal — 6 at a time'], ['1', 'Gentle one at a time, for a server that rate limits'],
34
+ ['12', 'Fast — 12 at a time, if you own the server']] } },
35
+ { flag: '--check-external', query: 'external', app: true,
36
+ field: { type: 'checkbox', label: 'Check outbound links too', value: '1',
37
+ help: 'Slower, and other people\'s servers decide how much slower.' } },
38
+ { flag: '--sitemap', query: 'sitemap', app: true,
39
+ field: { type: 'url', label: 'Sitemap', placeholder: 'Found automatically',
40
+ help: 'Only if robots.txt does not declare one and it is somewhere unusual.' } },
41
+ { flag: '--browser', query: 'browser', app: true,
42
+ field: { type: 'agent', label: 'Ask as', which: 'browser' } },
43
+ { flag: '--os', query: 'os', app: true,
44
+ field: { type: 'agent', label: 'On', which: 'os' } },
45
+ { flag: '--user-agent', query: 'userAgent', app: true,
46
+ field: { type: 'text', label: 'Or a user agent of your own', placeholder: 'Replaces the two menus above' } },
47
+ { flag: '--search-console', query: 'search-console', app: true,
48
+ // Reads somebody's Search Console, so it is only offered where the
49
+ // deployment has said those credentials are the visitor's own.
50
+ field: { type: 'text', label: 'Search Console property', placeholder: 'sc-domain:example.com',
51
+ needs: 'ALLOW_SEARCH_CONSOLE',
52
+ help: 'A domain property is named sc-domain:example.com, not by its URL.' } },
34
53
  { flag: '--write-sitemap', query: 'sitemap-out', app: true, via: 'the Export menu' },
35
54
  { flag: '--write-llms', query: 'llms-out', app: true, via: 'the Export menu' },
36
55
  { flag: '--write-schema', query: 'schema-out', app: true, via: 'the Export menu' },
37
- { flag: '--ignore', query: 'ignore', app: true, via: 'right-clicking a finding, and the Settings list' },
38
- { flag: '--psi', query: 'psi', app: true, via: 'Settings → Performance' },
39
- { flag: '--psi-sample', query: 'psi-sample', app: true, via: 'Settings Performance' },
40
- { flag: '--psi-strategy', query: 'psi-strategy', app: true, via: 'Settings → Performance' },
56
+ { flag: '--ignore', query: 'ignore', app: true, via: 'right-clicking a finding, and the Settings list',
57
+ field: { type: 'text', label: 'Silence these checks', placeholder: 'og-webp, img-srcset',
58
+ help: 'Check ids, comma separated. They are still counted, and the report says how many.' } },
59
+ { flag: '--psi', query: 'psi', app: true, via: 'Settings → Performance',
60
+ field: { type: 'text', label: 'Measure performance', placeholder: '/ or /blog/**',
61
+ needs: 'ALLOW_PSI',
62
+ help: 'Asks Google. A URL, a path, or a glob — and it spends the quota of whoever runs this.' } },
63
+ { flag: '--psi-sample', query: 'psi-sample', app: true, via: 'Settings → Performance',
64
+ field: { type: 'number', label: 'Pages to measure', min: 1, needs: 'ALLOW_PSI' } },
65
+ { flag: '--psi-strategy', query: 'psi-strategy', app: true, via: 'Settings → Performance',
66
+ field: { type: 'select', label: 'Measured as', needs: 'ALLOW_PSI',
67
+ choices: [['', 'A phone — what Google indexes with'], ['desktop', 'A desktop']] } },
41
68
  { flag: '--since', query: null, app: 'not yet — it needs a date picker and a sense of when the last run was, which the window has in the library and does not offer yet' },
42
69
  { flag: '--exclude', query: null, app: 'not yet — a list of patterns needs somewhere to live in Settings, and one text field would be worse than nothing' },
43
70
 
@@ -49,6 +76,8 @@ export const OPTIONS = [
49
76
  { flag: '--md', query: null, app: true, via: 'the Export menu' },
50
77
  { flag: '--html', query: null, app: true, via: 'the Export menu' },
51
78
 
79
+ { flag: '--no-open', query: null, app: 'the window is the browser this would open — it spawns --serve itself and draws the report natively, and the pipe it hands over is what already stops a browser appearing' },
80
+
52
81
  // --- deliberately not in a window ---------------------------------------
53
82
  { flag: '--help', query: null, app: 'a window has no command line to explain' },
54
83
  { flag: '--version', query: null, app: 'the sidebar shows it, and offers every other one' },
@@ -74,6 +103,21 @@ export const OPTIONS = [
74
103
  export const runParameters = () =>
75
104
  OPTIONS.filter((o) => o.app === true && o.query).map(({ flag, query }) => ({ flag, query }));
76
105
 
106
+ /** The controls a form should draw, in the order they are declared above.
107
+ *
108
+ * Its own list rather than a second table: the hosted form used to hard-code
109
+ * two inputs while the engine took a dozen parameters, so somebody at a
110
+ * browser could reach a sixth of what somebody at a terminal could. Adding a
111
+ * flag with a `field` now adds the control, and a flag without one is simply
112
+ * not offered — which is a decision, written down beside the flag it is about.
113
+ *
114
+ * `allow` answers the gates: PageSpeed spends somebody's quota and Search
115
+ * Console reads somebody's account, so neither is drawn unless the deployment
116
+ * has said those are the visitor's own to spend. */
117
+ export const formFields = (allow = () => true) =>
118
+ OPTIONS.filter((o) => o.field && o.query && (!o.field.needs || allow(o.field.needs)))
119
+ .map(({ flag, query, field }) => ({ flag, query, ...field }));
120
+
77
121
  /** Everything the window does not reach, and why. Served so the answer is
78
122
  * discoverable rather than only being in a source file. */
79
123
  export const notInApp = () =>
package/src/report.mjs CHANGED
@@ -104,7 +104,9 @@ export function terminal(findings, meta, { score } = {}) {
104
104
  lines.push(` ${paint(bold(`${score.score}`))}${dim('/100')} ${paint(bold(score.grade))} ${paint(scoreBar(score.score))}`);
105
105
  lines.push(
106
106
  dim(
107
- ` ${score.lost} points across ${score.checks.failed} check${score.checks.failed === 1 ? '' : 's'} · ` +
107
+ ` ${score.checks.failed
108
+ ? `${score.lost} points across ${score.checks.failed} check${score.checks.failed === 1 ? '' : 's'}`
109
+ : 'Nothing took points off'} · ` +
108
110
  `${score.checks.passed} passed · ${score.checks.skipped} did not apply`,
109
111
  ),
110
112
  );
@@ -344,7 +346,9 @@ function scoreMarkdown(score) {
344
346
  );
345
347
  out.push('');
346
348
  out.push(
347
- `${score.lost} points across ${score.checks.failed} check${score.checks.failed === 1 ? '' : 's'}. ` +
349
+ `${score.checks.failed
350
+ ? `${score.lost} points across ${score.checks.failed} check${score.checks.failed === 1 ? '' : 's'}. `
351
+ : 'Nothing took points off. '}` +
348
352
  `${score.checks.passed} passed and ${score.checks.skipped} did not apply.` +
349
353
  (score.ifErrorsFixed > score.score ? ` Clear the errors alone and it is **${score.ifErrorsFixed}**.` : ''),
350
354
  );
@@ -730,7 +734,7 @@ export function csv(findings, meta, { score } = {}) {
730
734
  return `\uFEFF${[columns, ...rows].map((row) => row.map(cell).join(',')).join('\r\n')}\r\n`;
731
735
  }
732
736
 
733
- export function html(findings, meta, { backHref, backLabel = 'New audit', score } = {}) {
737
+ export function reportParts(findings, meta, { backHref, backLabel = 'New audit', score } = {}) {
734
738
  const n = counts(findings);
735
739
  const esc = (s) =>
736
740
  String(s ?? '')
@@ -767,8 +771,13 @@ export function html(findings, meta, { backHref, backLabel = 'New audit', score
767
771
  </div>
768
772
  <div class="story">
769
773
  <h2>${score.score} out of 100</h2>
770
- <p class="lede">${score.lost} points across ${plural(score.checks.failed, 'check')}.
771
- ${score.checks.passed} passed, ${score.checks.skipped} did not apply.${
774
+ <p class="lede">${
775
+ // "0 points across 0 checks" is what a perfect score used to read as,
776
+ // which is arithmetic rather than a sentence.
777
+ score.checks.failed
778
+ ? `${score.lost} points across ${plural(score.checks.failed, 'check')}. `
779
+ : 'Nothing took points off. '
780
+ }${score.checks.passed} passed, ${score.checks.skipped} did not apply.${
772
781
  score.ifErrorsFixed > score.score
773
782
  ? ` Clear the errors alone and it is <b>${score.ifErrorsFixed}</b>.`
774
783
  : ''
@@ -858,13 +867,15 @@ export function html(findings, meta, { backHref, backLabel = 'New audit', score
858
867
  .join('')}`;
859
868
  };
860
869
 
861
- return `<!doctype html>
862
- <html lang="en">
863
- <head>
864
- <meta charset="utf-8">
865
- <meta name="viewport" content="width=device-width, initial-scale=1">
866
- <title>SEO audit ${esc(meta.origin)}</title>
867
- <style>
870
+ // The stylesheet and the report, separately.
871
+ //
872
+ // `html()` composes them into a document, which is what `--html` writes
873
+ // and what has to keep working with no network and no assets. The local
874
+ // server composes the same two into its own shell instead, so the report
875
+ // in a browser and the report in a file are the same report rather than
876
+ // two renderings that drift — which is the rule this project applies to
877
+ // every other pair of front ends.
878
+ const css = `
868
879
  :root {
869
880
  color-scheme: light dark;
870
881
  --bg: #fff;
@@ -1161,10 +1172,9 @@ export function html(findings, meta, { backHref, backLabel = 'New audit', score
1161
1172
  .finding a[href^="http"]::after { content: " (" attr(href) ")"; font-size: .8em; color: #4b5563; word-break: break-all; }
1162
1173
  .back, .js-only { display: none !important; }
1163
1174
  }
1164
- </style>
1165
- </head>
1166
- <body>
1167
- <main>
1175
+ `;
1176
+
1177
+ const body = `
1168
1178
  <div class="bar">
1169
1179
  <a class="mark" href="https://github.com/nurkamol/seo-audit">seo<span>-</span>audit</a>
1170
1180
  ${backHref ? `<a class="back" href="${esc(backHref)}">← ${esc(backLabel)}</a>` : ''}
@@ -1234,7 +1244,24 @@ export function html(findings, meta, { backHref, backLabel = 'New audit', score
1234
1244
  <a href="https://pagespeed.web.dev">PageSpeed Insights</a> when <code>--psi</code> is used, never estimated here.
1235
1245
  Generated by <a href="https://github.com/nurkamol/seo-audit">seo-audit</a>.
1236
1246
  </footer>
1237
- </main>
1247
+ `;
1248
+
1249
+ return { css, body, title: `SEO audit — ${esc(meta.origin)}` };
1250
+ }
1251
+
1252
+ /** Self-contained HTML: one file, no assets, safe to email or attach. */
1253
+ export function html(findings, meta, options = {}) {
1254
+ const { css, body, title } = reportParts(findings, meta, options);
1255
+ return `<!doctype html>
1256
+ <html lang="en">
1257
+ <head>
1258
+ <meta charset="utf-8">
1259
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1260
+ <title>${title}</title>
1261
+ <style>${css}</style>
1262
+ </head>
1263
+ <body>
1264
+ <main>${body}</main>
1238
1265
  </body>
1239
1266
  </html>
1240
1267
  `;
package/src/serve.mjs CHANGED
@@ -13,6 +13,7 @@ import { Readable } from 'node:stream';
13
13
  import { randomUUID } from 'node:crypto';
14
14
 
15
15
  import { handle } from '../worker/index.mjs';
16
+ import { library } from './library.mjs';
16
17
 
17
18
  /** Start the local UI. Returns `{ url, close }`.
18
19
  *
@@ -50,6 +51,16 @@ export async function serve({ port = 4321, host = '127.0.0.1', maxPages, allowed
50
51
  CAN_READ_CERTIFICATES: '1',
51
52
  };
52
53
 
54
+ // Every finished run, kept — and kept in the folder the macOS window already
55
+ // uses, so a crawl started in one is in the other's list a second later.
56
+ //
57
+ // Handed over as an object rather than imported by the Worker, which must
58
+ // stay web-standard: this is `node:fs`, and Cloudflare has no filesystem. A
59
+ // deployed Worker is passed nothing and simply has no library, which is the
60
+ // right answer for a shared host — keeping strangers' crawls is a thing
61
+ // nobody asked for.
62
+ env.STORE = library();
63
+
53
64
  const server = createServer(async (incoming, outgoing) => {
54
65
  try {
55
66
  const url = `http://${incoming.headers.host ?? `${host}:${port}`}${incoming.url}`;