@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 +34 -0
- package/bin/seo-audit.mjs +17 -0
- package/package.json +4 -1
- package/src/exports.mjs +84 -0
- package/src/library.mjs +156 -0
- package/src/open-url.mjs +49 -0
- package/src/options.mjs +56 -12
- package/src/report.mjs +44 -17
- package/src/serve.mjs +11 -0
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.
|
|
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": [
|
package/src/exports.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/library.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/open-url.mjs
ADDED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
{ flag: '--
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
{ flag: '--psi
|
|
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.
|
|
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.
|
|
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
|
|
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">${
|
|
771
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
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}`;
|