pagetrace 0.4.0 → 0.6.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/CHANGELOG.md +48 -0
- package/README.md +23 -2
- package/dist/cli.cjs +61 -44
- package/dist/cli.js +61 -44
- package/dist/index.cjs +58 -33
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +57 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
While the version is below 1.0.0, breaking changes ship in a minor release.
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Breaking.** `duplicate.title`, `duplicate.description` and
|
|
14
|
+
`duplicate.canonical` now emit one finding per affected route instead of a
|
|
15
|
+
single finding with `route: null`. Integrations reading `route` on these three
|
|
16
|
+
codes will see a path where they saw null; the codes themselves are unchanged.
|
|
17
|
+
|
|
18
|
+
Found by auditing a real site. The report said "2 pages share the same meta
|
|
19
|
+
description" and could not say which two, because the routes lived in `after`,
|
|
20
|
+
which the rollup drops — leaving the only actionable part of the finding
|
|
21
|
+
invisible. Per-route findings also make the counts truthful and point
|
|
22
|
+
`--format github` annotations at the pages rather than at "site".
|
|
23
|
+
|
|
24
|
+
## [0.5.0] - 2026-09-06
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `check --baseline-branch <ref>` reads the baseline lockfile out of a git ref
|
|
29
|
+
instead of the working tree, so a pull request can diff against `main` without
|
|
30
|
+
carrying a lockfile of its own. Commit the lockfile on the default branch only
|
|
31
|
+
and feature branches stop churning it. An unresolvable ref throws rather than
|
|
32
|
+
reading as an empty baseline, since a typo must not mean "nothing changed"; a
|
|
33
|
+
ref that simply has no lockfile yet returns nothing, which is an ordinary
|
|
34
|
+
first run.
|
|
35
|
+
- A composite GitHub Action (`action.yml`). Three lines in a workflow run the
|
|
36
|
+
check and post the findings as a pull request comment, editing the previous
|
|
37
|
+
comment on each push rather than stacking new ones. It fetches the baseline ref
|
|
38
|
+
first, since a shallow CI checkout usually has only the PR head. The comment is
|
|
39
|
+
skipped for pull requests from a fork, which run with a read-only token and
|
|
40
|
+
would otherwise fail with a 403 through no fault of the contributor; the
|
|
41
|
+
findings still reach the step summary and still set the exit code.
|
|
42
|
+
- `snapshotFromGitRef(ref, path)` is exported.
|
|
43
|
+
- `examples/site`, a small deliberately correct site with a committed baseline.
|
|
44
|
+
The action runs against it on every pull request to this repo, so a change
|
|
45
|
+
that breaks the action's own wiring fails here rather than in someone else's
|
|
46
|
+
CI. It doubles as a worked example of what a clean surface looks like.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `formatMarkdown` escapes angle brackets. `The <h1> was removed.` rendered as
|
|
51
|
+
`The was removed.` on GitHub, which parses a tag name in a table cell as
|
|
52
|
+
inline HTML and drops it — losing the part of the message that mattered, in
|
|
53
|
+
the reporter whose whole purpose is the pull request comment.
|
|
54
|
+
|
|
9
55
|
## [0.4.0] - 2026-09-06
|
|
10
56
|
|
|
11
57
|
### Changed
|
|
@@ -170,6 +216,8 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
|
|
|
170
216
|
crawling; diff classified by transition; absolute, cross-page and hreflang audit
|
|
171
217
|
rules; pretty, JSON, markdown, GitHub and HTML reporters.
|
|
172
218
|
|
|
219
|
+
[0.6.0]: https://github.com/shyamexe/pagetrace/compare/v0.5.0...v0.6.0
|
|
220
|
+
[0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
|
|
173
221
|
[0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.0
|
|
174
222
|
[0.3.0]: https://github.com/shyamexe/pagetrace/compare/v0.2.0...v0.3.0
|
|
175
223
|
[0.2.0]: https://github.com/shyamexe/pagetrace/compare/v0.1.0...v0.2.0
|
package/README.md
CHANGED
|
@@ -151,9 +151,30 @@ Every finding has a stable `code`. Set any code to `error`, `warn`, `info`, or `
|
|
|
151
151
|
|
|
152
152
|
## CI
|
|
153
153
|
|
|
154
|
+
The GitHub Action is the shortest path. It diffs the build against the baseline committed on your default branch and leaves the result as a pull request comment, updating that same comment on each push rather than stacking new ones.
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
- uses: actions/checkout@v5
|
|
158
|
+
- run: npm ci && npm run build
|
|
159
|
+
- uses: shyamexe/pagetrace@v1
|
|
160
|
+
with:
|
|
161
|
+
dir: ./out
|
|
162
|
+
baseline-branch: main
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`baseline-branch` reads the lockfile out of a git ref rather than the working tree, so feature branches never carry one and you get no lockfile churn in pull requests. Commit the lockfile on your default branch only:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npx pagetrace snapshot --dir ./out
|
|
169
|
+
git add pagetrace.lock.json
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Needs `pull-requests: write` for the comment. Set `comment: false` to skip it, or `audit: false` for a pure regression gate.
|
|
173
|
+
|
|
174
|
+
Without the Action:
|
|
175
|
+
|
|
154
176
|
```yaml
|
|
155
|
-
- run:
|
|
156
|
-
- run: npx pagetrace check --dir ./out --format github
|
|
177
|
+
- run: npx pagetrace check --dir ./out --baseline-branch origin/main --format github
|
|
157
178
|
```
|
|
158
179
|
|
|
159
180
|
`--format` accepts `pretty`, `json`, `markdown` (sized for a PR comment), and `github` (workflow annotations).
|
package/dist/cli.cjs
CHANGED
|
@@ -266,39 +266,38 @@ function auditCrossPage(snapshot) {
|
|
|
266
266
|
}
|
|
267
267
|
return map;
|
|
268
268
|
};
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (routes.length > 1) {
|
|
282
|
-
findings.push({
|
|
283
|
-
code: "duplicate.description",
|
|
284
|
-
severity: "warn",
|
|
285
|
-
route: null,
|
|
286
|
-
message: `${routes.length} pages share the same meta description.`,
|
|
287
|
-
after: routes
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
for (const [canonical, routes] of group((p) => p.canonical)) {
|
|
292
|
-
if (routes.length > 1) {
|
|
293
|
-
findings.push({
|
|
294
|
-
code: "duplicate.canonical",
|
|
295
|
-
severity: "error",
|
|
296
|
-
route: null,
|
|
297
|
-
message: `${routes.length} pages canonicalise to ${canonical}.`,
|
|
298
|
-
after: routes
|
|
299
|
-
});
|
|
269
|
+
const duplicates = (code, severity, keyed, message) => {
|
|
270
|
+
for (const [value, routes] of keyed) {
|
|
271
|
+
if (routes.length < 2) continue;
|
|
272
|
+
for (const route of routes) {
|
|
273
|
+
findings.push({
|
|
274
|
+
code,
|
|
275
|
+
severity,
|
|
276
|
+
route,
|
|
277
|
+
message: message(value, routes.length),
|
|
278
|
+
after: routes
|
|
279
|
+
});
|
|
280
|
+
}
|
|
300
281
|
}
|
|
301
|
-
}
|
|
282
|
+
};
|
|
283
|
+
duplicates(
|
|
284
|
+
"duplicate.title",
|
|
285
|
+
"warn",
|
|
286
|
+
group((p) => p.title),
|
|
287
|
+
(title, n) => `${n} pages share the title "${title}".`
|
|
288
|
+
);
|
|
289
|
+
duplicates(
|
|
290
|
+
"duplicate.description",
|
|
291
|
+
"warn",
|
|
292
|
+
group((p) => p.description),
|
|
293
|
+
(_, n) => `${n} pages share the same meta description.`
|
|
294
|
+
);
|
|
295
|
+
duplicates(
|
|
296
|
+
"duplicate.canonical",
|
|
297
|
+
"error",
|
|
298
|
+
group((p) => p.canonical),
|
|
299
|
+
(canonical, n) => `${n} pages canonicalise to ${canonical}.`
|
|
300
|
+
);
|
|
302
301
|
const expectedOrigin = snapshot.site.origin ?? null;
|
|
303
302
|
for (const page of pages) {
|
|
304
303
|
if (!page.canonical) continue;
|
|
@@ -866,7 +865,7 @@ function formatPretty(findings) {
|
|
|
866
865
|
function formatJson(findings) {
|
|
867
866
|
return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
|
|
868
867
|
}
|
|
869
|
-
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
868
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "<").replace(/>/g, ">");
|
|
870
869
|
function formatMarkdown(findings) {
|
|
871
870
|
const s = summarize(findings);
|
|
872
871
|
if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
|
|
@@ -1055,8 +1054,10 @@ ${cards || "<p>No issues found.</p>"}
|
|
|
1055
1054
|
}
|
|
1056
1055
|
|
|
1057
1056
|
// src/snapshot.ts
|
|
1057
|
+
var import_node_child_process = require("child_process");
|
|
1058
1058
|
var import_promises = require("fs/promises");
|
|
1059
1059
|
var import_node_path = require("path");
|
|
1060
|
+
var import_node_util = require("util");
|
|
1060
1061
|
|
|
1061
1062
|
// src/extract.ts
|
|
1062
1063
|
var import_node_html_parser = require("node-html-parser");
|
|
@@ -1278,6 +1279,28 @@ function routeFromUrl(url) {
|
|
|
1278
1279
|
return url;
|
|
1279
1280
|
}
|
|
1280
1281
|
}
|
|
1282
|
+
var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
1283
|
+
async function snapshotFromGitRef(ref, path) {
|
|
1284
|
+
try {
|
|
1285
|
+
await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
1286
|
+
} catch (cause) {
|
|
1287
|
+
throw new Error(
|
|
1288
|
+
`Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
|
|
1289
|
+
{ cause }
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
let stdout;
|
|
1293
|
+
try {
|
|
1294
|
+
({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1295
|
+
} catch {
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
return JSON.parse(stdout);
|
|
1300
|
+
} catch (cause) {
|
|
1301
|
+
throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1281
1304
|
function sameSurface(a, b) {
|
|
1282
1305
|
const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
|
|
1283
1306
|
return strip(a) === strip(b);
|
|
@@ -1478,20 +1501,14 @@ cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").opti
|
|
|
1478
1501
|
written ? import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : import_picocolors2.default.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
|
|
1479
1502
|
);
|
|
1480
1503
|
});
|
|
1481
|
-
cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
|
|
1504
|
+
cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
|
|
1482
1505
|
const failOn = parseFailOn(flags.failOn, false);
|
|
1483
1506
|
const config = await loadConfig(flags.config);
|
|
1484
1507
|
const next = await build(flags, config);
|
|
1485
|
-
|
|
1486
|
-
try {
|
|
1487
|
-
previous = JSON.parse(await (0, import_promises2.readFile)(flags.lockfile, "utf8"));
|
|
1488
|
-
} catch {
|
|
1489
|
-
previous = null;
|
|
1490
|
-
}
|
|
1508
|
+
const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await (0, import_promises2.readFile)(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
|
|
1491
1509
|
if (!previous) {
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
);
|
|
1510
|
+
const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
|
|
1511
|
+
console.error(import_picocolors2.default.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
|
|
1495
1512
|
}
|
|
1496
1513
|
const raw = [
|
|
1497
1514
|
...previous ? diffSnapshots(previous, next) : [],
|
|
@@ -1555,7 +1572,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
|
|
|
1555
1572
|
}
|
|
1556
1573
|
});
|
|
1557
1574
|
cli.help();
|
|
1558
|
-
cli.version("0.
|
|
1575
|
+
cli.version("0.6.0");
|
|
1559
1576
|
async function main() {
|
|
1560
1577
|
try {
|
|
1561
1578
|
cli.parse(process.argv, { run: false });
|
package/dist/cli.js
CHANGED
|
@@ -243,39 +243,38 @@ function auditCrossPage(snapshot) {
|
|
|
243
243
|
}
|
|
244
244
|
return map;
|
|
245
245
|
};
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (routes.length > 1) {
|
|
259
|
-
findings.push({
|
|
260
|
-
code: "duplicate.description",
|
|
261
|
-
severity: "warn",
|
|
262
|
-
route: null,
|
|
263
|
-
message: `${routes.length} pages share the same meta description.`,
|
|
264
|
-
after: routes
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
for (const [canonical, routes] of group((p) => p.canonical)) {
|
|
269
|
-
if (routes.length > 1) {
|
|
270
|
-
findings.push({
|
|
271
|
-
code: "duplicate.canonical",
|
|
272
|
-
severity: "error",
|
|
273
|
-
route: null,
|
|
274
|
-
message: `${routes.length} pages canonicalise to ${canonical}.`,
|
|
275
|
-
after: routes
|
|
276
|
-
});
|
|
246
|
+
const duplicates = (code, severity, keyed, message) => {
|
|
247
|
+
for (const [value, routes] of keyed) {
|
|
248
|
+
if (routes.length < 2) continue;
|
|
249
|
+
for (const route of routes) {
|
|
250
|
+
findings.push({
|
|
251
|
+
code,
|
|
252
|
+
severity,
|
|
253
|
+
route,
|
|
254
|
+
message: message(value, routes.length),
|
|
255
|
+
after: routes
|
|
256
|
+
});
|
|
257
|
+
}
|
|
277
258
|
}
|
|
278
|
-
}
|
|
259
|
+
};
|
|
260
|
+
duplicates(
|
|
261
|
+
"duplicate.title",
|
|
262
|
+
"warn",
|
|
263
|
+
group((p) => p.title),
|
|
264
|
+
(title, n) => `${n} pages share the title "${title}".`
|
|
265
|
+
);
|
|
266
|
+
duplicates(
|
|
267
|
+
"duplicate.description",
|
|
268
|
+
"warn",
|
|
269
|
+
group((p) => p.description),
|
|
270
|
+
(_, n) => `${n} pages share the same meta description.`
|
|
271
|
+
);
|
|
272
|
+
duplicates(
|
|
273
|
+
"duplicate.canonical",
|
|
274
|
+
"error",
|
|
275
|
+
group((p) => p.canonical),
|
|
276
|
+
(canonical, n) => `${n} pages canonicalise to ${canonical}.`
|
|
277
|
+
);
|
|
279
278
|
const expectedOrigin = snapshot.site.origin ?? null;
|
|
280
279
|
for (const page of pages) {
|
|
281
280
|
if (!page.canonical) continue;
|
|
@@ -843,7 +842,7 @@ function formatPretty(findings) {
|
|
|
843
842
|
function formatJson(findings) {
|
|
844
843
|
return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
|
|
845
844
|
}
|
|
846
|
-
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
845
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "<").replace(/>/g, ">");
|
|
847
846
|
function formatMarkdown(findings) {
|
|
848
847
|
const s = summarize(findings);
|
|
849
848
|
if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
|
|
@@ -1032,8 +1031,10 @@ ${cards || "<p>No issues found.</p>"}
|
|
|
1032
1031
|
}
|
|
1033
1032
|
|
|
1034
1033
|
// src/snapshot.ts
|
|
1034
|
+
import { execFile } from "child_process";
|
|
1035
1035
|
import { readdir, readFile } from "fs/promises";
|
|
1036
1036
|
import { join, relative, sep } from "path";
|
|
1037
|
+
import { promisify } from "util";
|
|
1037
1038
|
|
|
1038
1039
|
// src/extract.ts
|
|
1039
1040
|
import { parse } from "node-html-parser";
|
|
@@ -1255,6 +1256,28 @@ function routeFromUrl(url) {
|
|
|
1255
1256
|
return url;
|
|
1256
1257
|
}
|
|
1257
1258
|
}
|
|
1259
|
+
var exec = promisify(execFile);
|
|
1260
|
+
async function snapshotFromGitRef(ref, path) {
|
|
1261
|
+
try {
|
|
1262
|
+
await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
1263
|
+
} catch (cause) {
|
|
1264
|
+
throw new Error(
|
|
1265
|
+
`Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
|
|
1266
|
+
{ cause }
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
let stdout;
|
|
1270
|
+
try {
|
|
1271
|
+
({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1272
|
+
} catch {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
return JSON.parse(stdout);
|
|
1277
|
+
} catch (cause) {
|
|
1278
|
+
throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1258
1281
|
function sameSurface(a, b) {
|
|
1259
1282
|
const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
|
|
1260
1283
|
return strip(a) === strip(b);
|
|
@@ -1455,20 +1478,14 @@ cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").opti
|
|
|
1455
1478
|
written ? pc2.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : pc2.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
|
|
1456
1479
|
);
|
|
1457
1480
|
});
|
|
1458
|
-
cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
|
|
1481
|
+
cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
|
|
1459
1482
|
const failOn = parseFailOn(flags.failOn, false);
|
|
1460
1483
|
const config = await loadConfig(flags.config);
|
|
1461
1484
|
const next = await build(flags, config);
|
|
1462
|
-
|
|
1463
|
-
try {
|
|
1464
|
-
previous = JSON.parse(await readFile2(flags.lockfile, "utf8"));
|
|
1465
|
-
} catch {
|
|
1466
|
-
previous = null;
|
|
1467
|
-
}
|
|
1485
|
+
const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await readFile2(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
|
|
1468
1486
|
if (!previous) {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
);
|
|
1487
|
+
const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
|
|
1488
|
+
console.error(pc2.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
|
|
1472
1489
|
}
|
|
1473
1490
|
const raw = [
|
|
1474
1491
|
...previous ? diffSnapshots(previous, next) : [],
|
|
@@ -1532,7 +1549,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
|
|
|
1532
1549
|
}
|
|
1533
1550
|
});
|
|
1534
1551
|
cli.help();
|
|
1535
|
-
cli.version("0.
|
|
1552
|
+
cli.version("0.6.0");
|
|
1536
1553
|
async function main() {
|
|
1537
1554
|
try {
|
|
1538
1555
|
cli.parse(process.argv, { run: false });
|
package/dist/index.cjs
CHANGED
|
@@ -62,6 +62,7 @@ __export(src_exports, {
|
|
|
62
62
|
shouldFail: () => shouldFail,
|
|
63
63
|
shouldIgnore: () => shouldIgnore,
|
|
64
64
|
snapshotFromDir: () => snapshotFromDir,
|
|
65
|
+
snapshotFromGitRef: () => snapshotFromGitRef,
|
|
65
66
|
snapshotFromOrigin: () => snapshotFromOrigin,
|
|
66
67
|
summarize: () => summarize,
|
|
67
68
|
withGuidance: () => withGuidance
|
|
@@ -306,39 +307,38 @@ function auditCrossPage(snapshot) {
|
|
|
306
307
|
}
|
|
307
308
|
return map;
|
|
308
309
|
};
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (routes.length > 1) {
|
|
322
|
-
findings.push({
|
|
323
|
-
code: "duplicate.description",
|
|
324
|
-
severity: "warn",
|
|
325
|
-
route: null,
|
|
326
|
-
message: `${routes.length} pages share the same meta description.`,
|
|
327
|
-
after: routes
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
for (const [canonical, routes] of group((p) => p.canonical)) {
|
|
332
|
-
if (routes.length > 1) {
|
|
333
|
-
findings.push({
|
|
334
|
-
code: "duplicate.canonical",
|
|
335
|
-
severity: "error",
|
|
336
|
-
route: null,
|
|
337
|
-
message: `${routes.length} pages canonicalise to ${canonical}.`,
|
|
338
|
-
after: routes
|
|
339
|
-
});
|
|
310
|
+
const duplicates = (code, severity, keyed, message) => {
|
|
311
|
+
for (const [value, routes] of keyed) {
|
|
312
|
+
if (routes.length < 2) continue;
|
|
313
|
+
for (const route of routes) {
|
|
314
|
+
findings.push({
|
|
315
|
+
code,
|
|
316
|
+
severity,
|
|
317
|
+
route,
|
|
318
|
+
message: message(value, routes.length),
|
|
319
|
+
after: routes
|
|
320
|
+
});
|
|
321
|
+
}
|
|
340
322
|
}
|
|
341
|
-
}
|
|
323
|
+
};
|
|
324
|
+
duplicates(
|
|
325
|
+
"duplicate.title",
|
|
326
|
+
"warn",
|
|
327
|
+
group((p) => p.title),
|
|
328
|
+
(title, n) => `${n} pages share the title "${title}".`
|
|
329
|
+
);
|
|
330
|
+
duplicates(
|
|
331
|
+
"duplicate.description",
|
|
332
|
+
"warn",
|
|
333
|
+
group((p) => p.description),
|
|
334
|
+
(_, n) => `${n} pages share the same meta description.`
|
|
335
|
+
);
|
|
336
|
+
duplicates(
|
|
337
|
+
"duplicate.canonical",
|
|
338
|
+
"error",
|
|
339
|
+
group((p) => p.canonical),
|
|
340
|
+
(canonical, n) => `${n} pages canonicalise to ${canonical}.`
|
|
341
|
+
);
|
|
342
342
|
const expectedOrigin = snapshot.site.origin ?? null;
|
|
343
343
|
for (const page of pages) {
|
|
344
344
|
if (!page.canonical) continue;
|
|
@@ -1110,7 +1110,7 @@ function formatPretty(findings) {
|
|
|
1110
1110
|
function formatJson(findings) {
|
|
1111
1111
|
return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
|
|
1112
1112
|
}
|
|
1113
|
-
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
1113
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "<").replace(/>/g, ">");
|
|
1114
1114
|
function formatMarkdown(findings) {
|
|
1115
1115
|
const s = summarize(findings);
|
|
1116
1116
|
if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
|
|
@@ -1299,8 +1299,10 @@ ${cards || "<p>No issues found.</p>"}
|
|
|
1299
1299
|
}
|
|
1300
1300
|
|
|
1301
1301
|
// src/snapshot.ts
|
|
1302
|
+
var import_node_child_process = require("child_process");
|
|
1302
1303
|
var import_promises = require("fs/promises");
|
|
1303
1304
|
var import_node_path = require("path");
|
|
1305
|
+
var import_node_util = require("util");
|
|
1304
1306
|
function routeFromFilePath(root, filePath) {
|
|
1305
1307
|
const rel = (0, import_node_path.relative)(root, filePath).split(import_node_path.sep).join("/");
|
|
1306
1308
|
const withoutExt = rel.replace(/\.html?$/i, "");
|
|
@@ -1316,6 +1318,28 @@ function routeFromUrl(url) {
|
|
|
1316
1318
|
return url;
|
|
1317
1319
|
}
|
|
1318
1320
|
}
|
|
1321
|
+
var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
|
|
1322
|
+
async function snapshotFromGitRef(ref, path) {
|
|
1323
|
+
try {
|
|
1324
|
+
await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
1325
|
+
} catch (cause) {
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
|
|
1328
|
+
{ cause }
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
let stdout;
|
|
1332
|
+
try {
|
|
1333
|
+
({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1334
|
+
} catch {
|
|
1335
|
+
return null;
|
|
1336
|
+
}
|
|
1337
|
+
try {
|
|
1338
|
+
return JSON.parse(stdout);
|
|
1339
|
+
} catch (cause) {
|
|
1340
|
+
throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1319
1343
|
function sameSurface(a, b) {
|
|
1320
1344
|
const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
|
|
1321
1345
|
return strip(a) === strip(b);
|
|
@@ -1490,6 +1514,7 @@ async function snapshotFromOrigin(origin, options = {}) {
|
|
|
1490
1514
|
shouldFail,
|
|
1491
1515
|
shouldIgnore,
|
|
1492
1516
|
snapshotFromDir,
|
|
1517
|
+
snapshotFromGitRef,
|
|
1493
1518
|
snapshotFromOrigin,
|
|
1494
1519
|
summarize,
|
|
1495
1520
|
withGuidance
|
package/dist/index.d.cts
CHANGED
|
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
|
|
|
215
215
|
|
|
216
216
|
declare function routeFromFilePath(root: string, filePath: string): string;
|
|
217
217
|
declare function routeFromUrl(url: string): string;
|
|
218
|
+
/**
|
|
219
|
+
* Read a committed lockfile out of a git ref rather than the working tree, so a
|
|
220
|
+
* pull request can diff against the baseline on `main` without carrying a
|
|
221
|
+
* lockfile of its own. Returns null when the ref has no lockfile at that path —
|
|
222
|
+
* an ordinary first run — but throws when the ref itself is unresolvable, since
|
|
223
|
+
* a typo in `--baseline-branch` must not read as "nothing to compare".
|
|
224
|
+
*/
|
|
225
|
+
declare function snapshotFromGitRef(ref: string, path: string): Promise<Snapshot | null>;
|
|
218
226
|
/**
|
|
219
227
|
* Whether two snapshots describe the same surface, ignoring when they were
|
|
220
228
|
* taken. The lockfile is meant to be committed, so writing a fresh timestamp on
|
|
@@ -237,4 +245,4 @@ interface CrawlOptions extends Config {
|
|
|
237
245
|
/** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
|
|
238
246
|
declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
|
|
239
247
|
|
|
240
|
-
export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
|
|
248
|
+
export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromGitRef, snapshotFromOrigin, summarize, withGuidance };
|
package/dist/index.d.ts
CHANGED
|
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
|
|
|
215
215
|
|
|
216
216
|
declare function routeFromFilePath(root: string, filePath: string): string;
|
|
217
217
|
declare function routeFromUrl(url: string): string;
|
|
218
|
+
/**
|
|
219
|
+
* Read a committed lockfile out of a git ref rather than the working tree, so a
|
|
220
|
+
* pull request can diff against the baseline on `main` without carrying a
|
|
221
|
+
* lockfile of its own. Returns null when the ref has no lockfile at that path —
|
|
222
|
+
* an ordinary first run — but throws when the ref itself is unresolvable, since
|
|
223
|
+
* a typo in `--baseline-branch` must not read as "nothing to compare".
|
|
224
|
+
*/
|
|
225
|
+
declare function snapshotFromGitRef(ref: string, path: string): Promise<Snapshot | null>;
|
|
218
226
|
/**
|
|
219
227
|
* Whether two snapshots describe the same surface, ignoring when they were
|
|
220
228
|
* taken. The lockfile is meant to be committed, so writing a fresh timestamp on
|
|
@@ -237,4 +245,4 @@ interface CrawlOptions extends Config {
|
|
|
237
245
|
/** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
|
|
238
246
|
declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
|
|
239
247
|
|
|
240
|
-
export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
|
|
248
|
+
export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromGitRef, snapshotFromOrigin, summarize, withGuidance };
|
package/dist/index.js
CHANGED
|
@@ -236,39 +236,38 @@ function auditCrossPage(snapshot) {
|
|
|
236
236
|
}
|
|
237
237
|
return map;
|
|
238
238
|
};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (routes.length > 1) {
|
|
252
|
-
findings.push({
|
|
253
|
-
code: "duplicate.description",
|
|
254
|
-
severity: "warn",
|
|
255
|
-
route: null,
|
|
256
|
-
message: `${routes.length} pages share the same meta description.`,
|
|
257
|
-
after: routes
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
for (const [canonical, routes] of group((p) => p.canonical)) {
|
|
262
|
-
if (routes.length > 1) {
|
|
263
|
-
findings.push({
|
|
264
|
-
code: "duplicate.canonical",
|
|
265
|
-
severity: "error",
|
|
266
|
-
route: null,
|
|
267
|
-
message: `${routes.length} pages canonicalise to ${canonical}.`,
|
|
268
|
-
after: routes
|
|
269
|
-
});
|
|
239
|
+
const duplicates = (code, severity, keyed, message) => {
|
|
240
|
+
for (const [value, routes] of keyed) {
|
|
241
|
+
if (routes.length < 2) continue;
|
|
242
|
+
for (const route of routes) {
|
|
243
|
+
findings.push({
|
|
244
|
+
code,
|
|
245
|
+
severity,
|
|
246
|
+
route,
|
|
247
|
+
message: message(value, routes.length),
|
|
248
|
+
after: routes
|
|
249
|
+
});
|
|
250
|
+
}
|
|
270
251
|
}
|
|
271
|
-
}
|
|
252
|
+
};
|
|
253
|
+
duplicates(
|
|
254
|
+
"duplicate.title",
|
|
255
|
+
"warn",
|
|
256
|
+
group((p) => p.title),
|
|
257
|
+
(title, n) => `${n} pages share the title "${title}".`
|
|
258
|
+
);
|
|
259
|
+
duplicates(
|
|
260
|
+
"duplicate.description",
|
|
261
|
+
"warn",
|
|
262
|
+
group((p) => p.description),
|
|
263
|
+
(_, n) => `${n} pages share the same meta description.`
|
|
264
|
+
);
|
|
265
|
+
duplicates(
|
|
266
|
+
"duplicate.canonical",
|
|
267
|
+
"error",
|
|
268
|
+
group((p) => p.canonical),
|
|
269
|
+
(canonical, n) => `${n} pages canonicalise to ${canonical}.`
|
|
270
|
+
);
|
|
272
271
|
const expectedOrigin = snapshot.site.origin ?? null;
|
|
273
272
|
for (const page of pages) {
|
|
274
273
|
if (!page.canonical) continue;
|
|
@@ -1040,7 +1039,7 @@ function formatPretty(findings) {
|
|
|
1040
1039
|
function formatJson(findings) {
|
|
1041
1040
|
return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
|
|
1042
1041
|
}
|
|
1043
|
-
var escapeCell = (value) => value.replace(/\|/g, "\\|");
|
|
1042
|
+
var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "<").replace(/>/g, ">");
|
|
1044
1043
|
function formatMarkdown(findings) {
|
|
1045
1044
|
const s = summarize(findings);
|
|
1046
1045
|
if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
|
|
@@ -1229,8 +1228,10 @@ ${cards || "<p>No issues found.</p>"}
|
|
|
1229
1228
|
}
|
|
1230
1229
|
|
|
1231
1230
|
// src/snapshot.ts
|
|
1231
|
+
import { execFile } from "child_process";
|
|
1232
1232
|
import { readdir, readFile } from "fs/promises";
|
|
1233
1233
|
import { join, relative, sep } from "path";
|
|
1234
|
+
import { promisify } from "util";
|
|
1234
1235
|
function routeFromFilePath(root, filePath) {
|
|
1235
1236
|
const rel = relative(root, filePath).split(sep).join("/");
|
|
1236
1237
|
const withoutExt = rel.replace(/\.html?$/i, "");
|
|
@@ -1246,6 +1247,28 @@ function routeFromUrl(url) {
|
|
|
1246
1247
|
return url;
|
|
1247
1248
|
}
|
|
1248
1249
|
}
|
|
1250
|
+
var exec = promisify(execFile);
|
|
1251
|
+
async function snapshotFromGitRef(ref, path) {
|
|
1252
|
+
try {
|
|
1253
|
+
await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
1254
|
+
} catch (cause) {
|
|
1255
|
+
throw new Error(
|
|
1256
|
+
`Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
|
|
1257
|
+
{ cause }
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
let stdout;
|
|
1261
|
+
try {
|
|
1262
|
+
({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
|
|
1263
|
+
} catch {
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
try {
|
|
1267
|
+
return JSON.parse(stdout);
|
|
1268
|
+
} catch (cause) {
|
|
1269
|
+
throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1249
1272
|
function sameSurface(a, b) {
|
|
1250
1273
|
const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
|
|
1251
1274
|
return strip(a) === strip(b);
|
|
@@ -1419,6 +1442,7 @@ export {
|
|
|
1419
1442
|
shouldFail,
|
|
1420
1443
|
shouldIgnore,
|
|
1421
1444
|
snapshotFromDir,
|
|
1445
|
+
snapshotFromGitRef,
|
|
1422
1446
|
snapshotFromOrigin,
|
|
1423
1447
|
summarize,
|
|
1424
1448
|
withGuidance
|