pagetrace 0.5.0 → 0.7.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 CHANGED
@@ -6,6 +6,42 @@ 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.7.0] - 2026-09-06
10
+
11
+ ### Changed
12
+
13
+ - The terminal audit report is laid out rather than printed. Findings sit under a
14
+ rule per severity, prose wraps to the terminal instead of running off the right
15
+ edge, the affected routes get their own line, and the page count is right
16
+ aligned against the headline. The old output put explanation, fix and routes at
17
+ the same indent with no wrapping, so on a real site the useful part was the
18
+ hardest part to find.
19
+ - `formatAuditPretty(groups, meta, columns?)` takes an optional terminal width,
20
+ defaulting to 80. The CLI passes `process.stdout.columns`, which keeps
21
+ `report.ts` free of environment reads. Piped output falls back to 80 and drops
22
+ colour, so it stays readable in a CI log.
23
+
24
+ ### Fixed
25
+
26
+ - A word longer than the terminal is hard-broken instead of overflowing. A
27
+ canonical URL is a single word and is routinely longer than 80 characters, so
28
+ `duplicate.canonical` could push a line well past the edge.
29
+
30
+ ## [0.6.0] - 2026-09-06
31
+
32
+ ### Changed
33
+
34
+ - **Breaking.** `duplicate.title`, `duplicate.description` and
35
+ `duplicate.canonical` now emit one finding per affected route instead of a
36
+ single finding with `route: null`. Integrations reading `route` on these three
37
+ codes will see a path where they saw null; the codes themselves are unchanged.
38
+
39
+ Found by auditing a real site. The report said "2 pages share the same meta
40
+ description" and could not say which two, because the routes lived in `after`,
41
+ which the rollup drops — leaving the only actionable part of the finding
42
+ invisible. Per-route findings also make the counts truthful and point
43
+ `--format github` annotations at the pages rather than at "site".
44
+
9
45
  ## [0.5.0] - 2026-09-06
10
46
 
11
47
  ### Added
@@ -20,8 +56,15 @@ While the version is below 1.0.0, breaking changes ship in a minor release.
20
56
  - A composite GitHub Action (`action.yml`). Three lines in a workflow run the
21
57
  check and post the findings as a pull request comment, editing the previous
22
58
  comment on each push rather than stacking new ones. It fetches the baseline ref
23
- first, since a shallow CI checkout usually has only the PR head.
59
+ first, since a shallow CI checkout usually has only the PR head. The comment is
60
+ skipped for pull requests from a fork, which run with a read-only token and
61
+ would otherwise fail with a 403 through no fault of the contributor; the
62
+ findings still reach the step summary and still set the exit code.
24
63
  - `snapshotFromGitRef(ref, path)` is exported.
64
+ - `examples/site`, a small deliberately correct site with a committed baseline.
65
+ The action runs against it on every pull request to this repo, so a change
66
+ that breaks the action's own wiring fails here rather than in someone else's
67
+ CI. It doubles as a worked example of what a clean surface looks like.
25
68
 
26
69
  ### Fixed
27
70
 
@@ -194,6 +237,8 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
194
237
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
195
238
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
196
239
 
240
+ [0.7.0]: https://github.com/shyamexe/pagetrace/compare/v0.6.0...v0.7.0
241
+ [0.6.0]: https://github.com/shyamexe/pagetrace/compare/v0.5.0...v0.6.0
197
242
  [0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
198
243
  [0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.0
199
244
  [0.3.0]: https://github.com/shyamexe/pagetrace/compare/v0.2.0...v0.3.0
package/README.md CHANGED
@@ -72,16 +72,27 @@ npx pagetrace audit --url https://example.com --format html --out audit.html
72
72
  Findings are rolled up by issue rather than by page, so one template defect reads as a single row affecting 43 pages instead of 43 separate lines. Each row carries why it matters and how to fix it, and the fix is platform-aware — `pagetrace` reads the generator tag and asset paths, so a WordPress site gets Yoast and Rank Math instructions rather than generic advice.
73
73
 
74
74
  ```
75
- ERROR 2 pages canonicalise to https://acme.test/shop. (1)
75
+ pagetrace · https://acme.test
76
+ 43 pages · WordPress · 2026-09-06
77
+
78
+ ERRORS ─────────────────────────────────────────────────────────────────────── 2
79
+
80
+ ✗ 2 pages canonicalise to https://acme.test/shop. 2 pages
76
81
  Several pages pointing at one canonical means those pages are declaring
77
82
  themselves duplicates and will not rank independently.
78
- Fix: A common symptom of a plugin canonicalising every archive page to the parent.
83
+ A common symptom of a plugin canonicalising every archive page to the
84
+ parent.
85
+ /shop/page/2, /shop/page/3
79
86
 
80
- ERROR Page has no <h1>. (1)
87
+ Page has no <h1>. 1 page
81
88
  The h1 anchors the document outline used for passage extraction.
82
- Fix: Many themes render the post title as h2 inside archive templates.
83
- Check single.php or the block template for this post type.
89
+ Many themes render the post title as h2 inside archive templates. Check
90
+ single.php or the block template for this post type.
84
91
  /tag/widgets
92
+
93
+ ────────────────────────────────────────────────────────────────────────────────
94
+ 2 issues 2 errors
95
+ 3 findings across 43 pages
85
96
  ```
86
97
 
87
98
  Auditing runs cross-page rules the per-page checks cannot see: duplicate titles and descriptions, several pages canonicalising to one URL, canonicals pointing away from their own path or at another host entirely, and a full hreflang check. Paginated archives and AMP variants are left alone, since canonicalising those to their parent is correct.
package/dist/cli.cjs CHANGED
@@ -266,39 +266,38 @@ function auditCrossPage(snapshot) {
266
266
  }
267
267
  return map;
268
268
  };
269
- for (const [title, routes] of group((p) => p.title)) {
270
- if (routes.length > 1) {
271
- findings.push({
272
- code: "duplicate.title",
273
- severity: "warn",
274
- route: null,
275
- message: `${routes.length} pages share the title "${title}".`,
276
- after: routes
277
- });
278
- }
279
- }
280
- for (const [, routes] of group((p) => p.description)) {
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;
@@ -941,33 +940,102 @@ function sampleRoutes(routes, limit = 5) {
941
940
  const shown = routes.slice(0, limit).join(", ");
942
941
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
943
942
  }
944
- function formatAuditPretty(groups, meta) {
943
+ var GLYPH = { error: "\u2717", warn: "\u25B2", info: "\xB7" };
944
+ var PLURAL = { error: "errors", warn: "warnings", info: "notes" };
945
+ var SINGULAR = { error: "error", warn: "warning", info: "note" };
946
+ function wrapText(text2, width) {
947
+ const lines = [];
948
+ let line = "";
949
+ const flush = () => {
950
+ if (line) lines.push(line);
951
+ line = "";
952
+ };
953
+ for (const word of text2.split(/\s+/)) {
954
+ if (word.length > width) {
955
+ flush();
956
+ for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
957
+ continue;
958
+ }
959
+ if (line && line.length + 1 + word.length > width) {
960
+ flush();
961
+ line = word;
962
+ } else {
963
+ line = line ? `${line} ${word}` : word;
964
+ }
965
+ }
966
+ flush();
967
+ return lines;
968
+ }
969
+ function sectionRule(severity, count, width) {
970
+ const label = PLURAL[severity].toUpperCase();
971
+ const tail = String(count);
972
+ const dashes = Math.max(3, width - label.length - tail.length - 2);
973
+ return `${BADGE[severity](import_picocolors.default.bold(label))} ${import_picocolors.default.dim("\u2500".repeat(dashes))} ${import_picocolors.default.dim(tail)}`;
974
+ }
975
+ function formatAuditPretty(groups, meta, columns = 80) {
976
+ const width = Math.min(Math.max(columns, 48), 96);
977
+ const indent = " ";
978
+ const body = width - indent.length;
945
979
  const lines = [
946
- import_picocolors.default.bold(meta.target),
947
- import_picocolors.default.dim(`${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages \xB7 ${meta.generatedAt}`),
980
+ `${import_picocolors.default.bold("pagetrace")} ${import_picocolors.default.dim("\xB7")} ${meta.target}`,
981
+ import_picocolors.default.dim(
982
+ [
983
+ `${meta.pageCount} page${meta.pageCount === 1 ? "" : "s"}`,
984
+ meta.platform === "unknown" ? null : PLATFORM_LABEL[meta.platform],
985
+ meta.generatedAt
986
+ ].filter(Boolean).join(" \xB7 ")
987
+ ),
948
988
  ""
949
989
  ];
950
990
  if (groups.length === 0) {
951
- lines.push(import_picocolors.default.green("No issues found."));
991
+ lines.push(import_picocolors.default.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
952
992
  return lines.join("\n");
953
993
  }
954
- for (const group of groups) {
955
- const scope = isTemplateWide(group, meta.pageCount) ? import_picocolors.default.dim(`(${group.count} pages \u2014 one template fix)`) : import_picocolors.default.dim(`(${group.count})`);
956
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${import_picocolors.default.bold(group.message)} ${scope}`);
957
- if (group.detail) lines.push(` ${group.detail}`);
958
- if (group.fix) lines.push(` ${import_picocolors.default.cyan("Fix:")} ${group.fix}`);
959
- if (group.routes.length > 0) lines.push(` ${import_picocolors.default.dim(sampleRoutes(group.routes))}`);
960
- lines.push("");
994
+ for (const severity of ["error", "warn", "info"]) {
995
+ const inSeverity = groups.filter((g) => g.severity === severity);
996
+ if (inSeverity.length === 0) continue;
997
+ lines.push(sectionRule(severity, inSeverity.length, width), "");
998
+ for (const group of inSeverity) {
999
+ const scope = isTemplateWide(group, meta.pageCount) ? `${group.count} pages \xB7 one template fix` : `${group.count} page${group.count === 1 ? "" : "s"}`;
1000
+ const headline = group.message;
1001
+ const room = width - 2 - scope.length - 1;
1002
+ if (headline.length <= room) {
1003
+ const pad = " ".repeat(Math.max(1, room - headline.length + 1));
1004
+ lines.push(
1005
+ `${BADGE[severity](GLYPH[severity])} ${import_picocolors.default.bold(headline)}${pad}${import_picocolors.default.dim(scope)}`
1006
+ );
1007
+ } else {
1008
+ for (const [i, line] of wrapText(headline, width - 2).entries()) {
1009
+ lines.push(i === 0 ? `${BADGE[severity](GLYPH[severity])} ${import_picocolors.default.bold(line)}` : `${indent}${import_picocolors.default.bold(line)}`);
1010
+ }
1011
+ lines.push(`${indent}${import_picocolors.default.dim(scope)}`);
1012
+ }
1013
+ if (group.detail) {
1014
+ for (const line of wrapText(group.detail, body)) lines.push(`${indent}${import_picocolors.default.dim(line)}`);
1015
+ }
1016
+ if (group.fix) {
1017
+ const [first, ...rest] = wrapText(group.fix, body - 2);
1018
+ lines.push(`${indent}${import_picocolors.default.cyan("\u2192")} ${first}`);
1019
+ for (const line of rest) lines.push(`${indent} ${line}`);
1020
+ }
1021
+ if (group.routes.length > 0) {
1022
+ for (const line of wrapText(sampleRoutes(group.routes), body)) {
1023
+ lines.push(`${indent}${import_picocolors.default.dim(line)}`);
1024
+ }
1025
+ }
1026
+ lines.push("");
1027
+ }
961
1028
  }
962
1029
  const { issues, instances, total } = countIssues(groups);
963
- lines.push(
964
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
965
- );
1030
+ const counted = ["error", "warn", "info"].filter((s) => issues[s] > 0).map((s) => BADGE[s](`${issues[s]} ${issues[s] === 1 ? SINGULAR[s] : PLURAL[s]}`)).join(import_picocolors.default.dim(" \xB7 "));
1031
+ lines.push(import_picocolors.default.dim("\u2500".repeat(width)));
1032
+ lines.push(`${import_picocolors.default.bold(`${total} issue${total === 1 ? "" : "s"}`)} ${counted}`);
966
1033
  lines.push(
967
1034
  import_picocolors.default.dim(
968
- `across ${instances.error + instances.warn + instances.info} page findings on ${meta.pageCount} pages`
1035
+ `${instances.error + instances.warn + instances.info} findings across ${meta.pageCount} pages`
969
1036
  )
970
1037
  );
1038
+ lines.push("");
971
1039
  return lines.join("\n");
972
1040
  }
973
1041
  function formatAuditMarkdown(groups, meta) {
@@ -1560,7 +1628,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1560
1628
  pageCount: pages.length,
1561
1629
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1562
1630
  };
1563
- const output = flags.format === "json" ? JSON.stringify({ schemaVersion: 1, meta, summary: summarize(findings), groups }, null, 2) : flags.format === "markdown" ? formatAuditMarkdown(groups, meta) : flags.format === "html" ? formatAuditHtml(groups, meta) : formatAuditPretty(groups, meta);
1631
+ const output = flags.format === "json" ? JSON.stringify({ schemaVersion: 1, meta, summary: summarize(findings), groups }, null, 2) : flags.format === "markdown" ? formatAuditMarkdown(groups, meta) : flags.format === "html" ? formatAuditHtml(groups, meta) : formatAuditPretty(groups, meta, process.stdout.columns);
1564
1632
  if (flags.out) {
1565
1633
  await (0, import_promises2.writeFile)(flags.out, `${output}
1566
1634
  `, "utf8");
@@ -1573,7 +1641,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1573
1641
  }
1574
1642
  });
1575
1643
  cli.help();
1576
- cli.version("0.5.0");
1644
+ cli.version("0.7.0");
1577
1645
  async function main() {
1578
1646
  try {
1579
1647
  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
- for (const [title, routes] of group((p) => p.title)) {
247
- if (routes.length > 1) {
248
- findings.push({
249
- code: "duplicate.title",
250
- severity: "warn",
251
- route: null,
252
- message: `${routes.length} pages share the title "${title}".`,
253
- after: routes
254
- });
255
- }
256
- }
257
- for (const [, routes] of group((p) => p.description)) {
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;
@@ -918,33 +917,102 @@ function sampleRoutes(routes, limit = 5) {
918
917
  const shown = routes.slice(0, limit).join(", ");
919
918
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
920
919
  }
921
- function formatAuditPretty(groups, meta) {
920
+ var GLYPH = { error: "\u2717", warn: "\u25B2", info: "\xB7" };
921
+ var PLURAL = { error: "errors", warn: "warnings", info: "notes" };
922
+ var SINGULAR = { error: "error", warn: "warning", info: "note" };
923
+ function wrapText(text2, width) {
924
+ const lines = [];
925
+ let line = "";
926
+ const flush = () => {
927
+ if (line) lines.push(line);
928
+ line = "";
929
+ };
930
+ for (const word of text2.split(/\s+/)) {
931
+ if (word.length > width) {
932
+ flush();
933
+ for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
934
+ continue;
935
+ }
936
+ if (line && line.length + 1 + word.length > width) {
937
+ flush();
938
+ line = word;
939
+ } else {
940
+ line = line ? `${line} ${word}` : word;
941
+ }
942
+ }
943
+ flush();
944
+ return lines;
945
+ }
946
+ function sectionRule(severity, count, width) {
947
+ const label = PLURAL[severity].toUpperCase();
948
+ const tail = String(count);
949
+ const dashes = Math.max(3, width - label.length - tail.length - 2);
950
+ return `${BADGE[severity](pc.bold(label))} ${pc.dim("\u2500".repeat(dashes))} ${pc.dim(tail)}`;
951
+ }
952
+ function formatAuditPretty(groups, meta, columns = 80) {
953
+ const width = Math.min(Math.max(columns, 48), 96);
954
+ const indent = " ";
955
+ const body = width - indent.length;
922
956
  const lines = [
923
- pc.bold(meta.target),
924
- pc.dim(`${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages \xB7 ${meta.generatedAt}`),
957
+ `${pc.bold("pagetrace")} ${pc.dim("\xB7")} ${meta.target}`,
958
+ pc.dim(
959
+ [
960
+ `${meta.pageCount} page${meta.pageCount === 1 ? "" : "s"}`,
961
+ meta.platform === "unknown" ? null : PLATFORM_LABEL[meta.platform],
962
+ meta.generatedAt
963
+ ].filter(Boolean).join(" \xB7 ")
964
+ ),
925
965
  ""
926
966
  ];
927
967
  if (groups.length === 0) {
928
- lines.push(pc.green("No issues found."));
968
+ lines.push(pc.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
929
969
  return lines.join("\n");
930
970
  }
931
- for (const group of groups) {
932
- const scope = isTemplateWide(group, meta.pageCount) ? pc.dim(`(${group.count} pages \u2014 one template fix)`) : pc.dim(`(${group.count})`);
933
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${pc.bold(group.message)} ${scope}`);
934
- if (group.detail) lines.push(` ${group.detail}`);
935
- if (group.fix) lines.push(` ${pc.cyan("Fix:")} ${group.fix}`);
936
- if (group.routes.length > 0) lines.push(` ${pc.dim(sampleRoutes(group.routes))}`);
937
- lines.push("");
971
+ for (const severity of ["error", "warn", "info"]) {
972
+ const inSeverity = groups.filter((g) => g.severity === severity);
973
+ if (inSeverity.length === 0) continue;
974
+ lines.push(sectionRule(severity, inSeverity.length, width), "");
975
+ for (const group of inSeverity) {
976
+ const scope = isTemplateWide(group, meta.pageCount) ? `${group.count} pages \xB7 one template fix` : `${group.count} page${group.count === 1 ? "" : "s"}`;
977
+ const headline = group.message;
978
+ const room = width - 2 - scope.length - 1;
979
+ if (headline.length <= room) {
980
+ const pad = " ".repeat(Math.max(1, room - headline.length + 1));
981
+ lines.push(
982
+ `${BADGE[severity](GLYPH[severity])} ${pc.bold(headline)}${pad}${pc.dim(scope)}`
983
+ );
984
+ } else {
985
+ for (const [i, line] of wrapText(headline, width - 2).entries()) {
986
+ lines.push(i === 0 ? `${BADGE[severity](GLYPH[severity])} ${pc.bold(line)}` : `${indent}${pc.bold(line)}`);
987
+ }
988
+ lines.push(`${indent}${pc.dim(scope)}`);
989
+ }
990
+ if (group.detail) {
991
+ for (const line of wrapText(group.detail, body)) lines.push(`${indent}${pc.dim(line)}`);
992
+ }
993
+ if (group.fix) {
994
+ const [first, ...rest] = wrapText(group.fix, body - 2);
995
+ lines.push(`${indent}${pc.cyan("\u2192")} ${first}`);
996
+ for (const line of rest) lines.push(`${indent} ${line}`);
997
+ }
998
+ if (group.routes.length > 0) {
999
+ for (const line of wrapText(sampleRoutes(group.routes), body)) {
1000
+ lines.push(`${indent}${pc.dim(line)}`);
1001
+ }
1002
+ }
1003
+ lines.push("");
1004
+ }
938
1005
  }
939
1006
  const { issues, instances, total } = countIssues(groups);
940
- lines.push(
941
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
942
- );
1007
+ const counted = ["error", "warn", "info"].filter((s) => issues[s] > 0).map((s) => BADGE[s](`${issues[s]} ${issues[s] === 1 ? SINGULAR[s] : PLURAL[s]}`)).join(pc.dim(" \xB7 "));
1008
+ lines.push(pc.dim("\u2500".repeat(width)));
1009
+ lines.push(`${pc.bold(`${total} issue${total === 1 ? "" : "s"}`)} ${counted}`);
943
1010
  lines.push(
944
1011
  pc.dim(
945
- `across ${instances.error + instances.warn + instances.info} page findings on ${meta.pageCount} pages`
1012
+ `${instances.error + instances.warn + instances.info} findings across ${meta.pageCount} pages`
946
1013
  )
947
1014
  );
1015
+ lines.push("");
948
1016
  return lines.join("\n");
949
1017
  }
950
1018
  function formatAuditMarkdown(groups, meta) {
@@ -1537,7 +1605,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1537
1605
  pageCount: pages.length,
1538
1606
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1539
1607
  };
1540
- const output = flags.format === "json" ? JSON.stringify({ schemaVersion: 1, meta, summary: summarize(findings), groups }, null, 2) : flags.format === "markdown" ? formatAuditMarkdown(groups, meta) : flags.format === "html" ? formatAuditHtml(groups, meta) : formatAuditPretty(groups, meta);
1608
+ const output = flags.format === "json" ? JSON.stringify({ schemaVersion: 1, meta, summary: summarize(findings), groups }, null, 2) : flags.format === "markdown" ? formatAuditMarkdown(groups, meta) : flags.format === "html" ? formatAuditHtml(groups, meta) : formatAuditPretty(groups, meta, process.stdout.columns);
1541
1609
  if (flags.out) {
1542
1610
  await writeFile(flags.out, `${output}
1543
1611
  `, "utf8");
@@ -1550,7 +1618,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1550
1618
  }
1551
1619
  });
1552
1620
  cli.help();
1553
- cli.version("0.5.0");
1621
+ cli.version("0.7.0");
1554
1622
  async function main() {
1555
1623
  try {
1556
1624
  cli.parse(process.argv, { run: false });
package/dist/index.cjs CHANGED
@@ -307,39 +307,38 @@ function auditCrossPage(snapshot) {
307
307
  }
308
308
  return map;
309
309
  };
310
- for (const [title, routes] of group((p) => p.title)) {
311
- if (routes.length > 1) {
312
- findings.push({
313
- code: "duplicate.title",
314
- severity: "warn",
315
- route: null,
316
- message: `${routes.length} pages share the title "${title}".`,
317
- after: routes
318
- });
319
- }
320
- }
321
- for (const [, routes] of group((p) => p.description)) {
322
- if (routes.length > 1) {
323
- findings.push({
324
- code: "duplicate.description",
325
- severity: "warn",
326
- route: null,
327
- message: `${routes.length} pages share the same meta description.`,
328
- after: routes
329
- });
330
- }
331
- }
332
- for (const [canonical, routes] of group((p) => p.canonical)) {
333
- if (routes.length > 1) {
334
- findings.push({
335
- code: "duplicate.canonical",
336
- severity: "error",
337
- route: null,
338
- message: `${routes.length} pages canonicalise to ${canonical}.`,
339
- after: routes
340
- });
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
+ }
341
322
  }
342
- }
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
+ );
343
342
  const expectedOrigin = snapshot.site.origin ?? null;
344
343
  for (const page of pages) {
345
344
  if (!page.canonical) continue;
@@ -1186,33 +1185,102 @@ function sampleRoutes(routes, limit = 5) {
1186
1185
  const shown = routes.slice(0, limit).join(", ");
1187
1186
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
1188
1187
  }
1189
- function formatAuditPretty(groups, meta) {
1188
+ var GLYPH = { error: "\u2717", warn: "\u25B2", info: "\xB7" };
1189
+ var PLURAL = { error: "errors", warn: "warnings", info: "notes" };
1190
+ var SINGULAR = { error: "error", warn: "warning", info: "note" };
1191
+ function wrapText(text2, width) {
1192
+ const lines = [];
1193
+ let line = "";
1194
+ const flush = () => {
1195
+ if (line) lines.push(line);
1196
+ line = "";
1197
+ };
1198
+ for (const word of text2.split(/\s+/)) {
1199
+ if (word.length > width) {
1200
+ flush();
1201
+ for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
1202
+ continue;
1203
+ }
1204
+ if (line && line.length + 1 + word.length > width) {
1205
+ flush();
1206
+ line = word;
1207
+ } else {
1208
+ line = line ? `${line} ${word}` : word;
1209
+ }
1210
+ }
1211
+ flush();
1212
+ return lines;
1213
+ }
1214
+ function sectionRule(severity, count, width) {
1215
+ const label = PLURAL[severity].toUpperCase();
1216
+ const tail = String(count);
1217
+ const dashes = Math.max(3, width - label.length - tail.length - 2);
1218
+ return `${BADGE[severity](import_picocolors.default.bold(label))} ${import_picocolors.default.dim("\u2500".repeat(dashes))} ${import_picocolors.default.dim(tail)}`;
1219
+ }
1220
+ function formatAuditPretty(groups, meta, columns = 80) {
1221
+ const width = Math.min(Math.max(columns, 48), 96);
1222
+ const indent = " ";
1223
+ const body = width - indent.length;
1190
1224
  const lines = [
1191
- import_picocolors.default.bold(meta.target),
1192
- import_picocolors.default.dim(`${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages \xB7 ${meta.generatedAt}`),
1225
+ `${import_picocolors.default.bold("pagetrace")} ${import_picocolors.default.dim("\xB7")} ${meta.target}`,
1226
+ import_picocolors.default.dim(
1227
+ [
1228
+ `${meta.pageCount} page${meta.pageCount === 1 ? "" : "s"}`,
1229
+ meta.platform === "unknown" ? null : PLATFORM_LABEL[meta.platform],
1230
+ meta.generatedAt
1231
+ ].filter(Boolean).join(" \xB7 ")
1232
+ ),
1193
1233
  ""
1194
1234
  ];
1195
1235
  if (groups.length === 0) {
1196
- lines.push(import_picocolors.default.green("No issues found."));
1236
+ lines.push(import_picocolors.default.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
1197
1237
  return lines.join("\n");
1198
1238
  }
1199
- for (const group of groups) {
1200
- const scope = isTemplateWide(group, meta.pageCount) ? import_picocolors.default.dim(`(${group.count} pages \u2014 one template fix)`) : import_picocolors.default.dim(`(${group.count})`);
1201
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${import_picocolors.default.bold(group.message)} ${scope}`);
1202
- if (group.detail) lines.push(` ${group.detail}`);
1203
- if (group.fix) lines.push(` ${import_picocolors.default.cyan("Fix:")} ${group.fix}`);
1204
- if (group.routes.length > 0) lines.push(` ${import_picocolors.default.dim(sampleRoutes(group.routes))}`);
1205
- lines.push("");
1239
+ for (const severity of ["error", "warn", "info"]) {
1240
+ const inSeverity = groups.filter((g) => g.severity === severity);
1241
+ if (inSeverity.length === 0) continue;
1242
+ lines.push(sectionRule(severity, inSeverity.length, width), "");
1243
+ for (const group of inSeverity) {
1244
+ const scope = isTemplateWide(group, meta.pageCount) ? `${group.count} pages \xB7 one template fix` : `${group.count} page${group.count === 1 ? "" : "s"}`;
1245
+ const headline = group.message;
1246
+ const room = width - 2 - scope.length - 1;
1247
+ if (headline.length <= room) {
1248
+ const pad = " ".repeat(Math.max(1, room - headline.length + 1));
1249
+ lines.push(
1250
+ `${BADGE[severity](GLYPH[severity])} ${import_picocolors.default.bold(headline)}${pad}${import_picocolors.default.dim(scope)}`
1251
+ );
1252
+ } else {
1253
+ for (const [i, line] of wrapText(headline, width - 2).entries()) {
1254
+ lines.push(i === 0 ? `${BADGE[severity](GLYPH[severity])} ${import_picocolors.default.bold(line)}` : `${indent}${import_picocolors.default.bold(line)}`);
1255
+ }
1256
+ lines.push(`${indent}${import_picocolors.default.dim(scope)}`);
1257
+ }
1258
+ if (group.detail) {
1259
+ for (const line of wrapText(group.detail, body)) lines.push(`${indent}${import_picocolors.default.dim(line)}`);
1260
+ }
1261
+ if (group.fix) {
1262
+ const [first, ...rest] = wrapText(group.fix, body - 2);
1263
+ lines.push(`${indent}${import_picocolors.default.cyan("\u2192")} ${first}`);
1264
+ for (const line of rest) lines.push(`${indent} ${line}`);
1265
+ }
1266
+ if (group.routes.length > 0) {
1267
+ for (const line of wrapText(sampleRoutes(group.routes), body)) {
1268
+ lines.push(`${indent}${import_picocolors.default.dim(line)}`);
1269
+ }
1270
+ }
1271
+ lines.push("");
1272
+ }
1206
1273
  }
1207
1274
  const { issues, instances, total } = countIssues(groups);
1208
- lines.push(
1209
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
1210
- );
1275
+ const counted = ["error", "warn", "info"].filter((s) => issues[s] > 0).map((s) => BADGE[s](`${issues[s]} ${issues[s] === 1 ? SINGULAR[s] : PLURAL[s]}`)).join(import_picocolors.default.dim(" \xB7 "));
1276
+ lines.push(import_picocolors.default.dim("\u2500".repeat(width)));
1277
+ lines.push(`${import_picocolors.default.bold(`${total} issue${total === 1 ? "" : "s"}`)} ${counted}`);
1211
1278
  lines.push(
1212
1279
  import_picocolors.default.dim(
1213
- `across ${instances.error + instances.warn + instances.info} page findings on ${meta.pageCount} pages`
1280
+ `${instances.error + instances.warn + instances.info} findings across ${meta.pageCount} pages`
1214
1281
  )
1215
1282
  );
1283
+ lines.push("");
1216
1284
  return lines.join("\n");
1217
1285
  }
1218
1286
  function formatAuditMarkdown(groups, meta) {
package/dist/index.d.cts CHANGED
@@ -192,7 +192,13 @@ interface AuditMeta {
192
192
  pageCount: number;
193
193
  generatedAt: string;
194
194
  }
195
- declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta): string;
195
+ /**
196
+ * The terminal report. Findings are grouped under a rule per severity, prose is
197
+ * wrapped to the terminal rather than running off it, and the affected routes
198
+ * sit on their own line — they are the part you act on, and they used to blend
199
+ * into the surrounding text.
200
+ */
201
+ declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta, columns?: number): string;
196
202
  declare function formatAuditMarkdown(groups: Aggregate[], meta: AuditMeta): string;
197
203
  /** Self-contained HTML report, suitable for handing to a client. */
198
204
  declare function formatAuditHtml(groups: Aggregate[], meta: AuditMeta): string;
package/dist/index.d.ts CHANGED
@@ -192,7 +192,13 @@ interface AuditMeta {
192
192
  pageCount: number;
193
193
  generatedAt: string;
194
194
  }
195
- declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta): string;
195
+ /**
196
+ * The terminal report. Findings are grouped under a rule per severity, prose is
197
+ * wrapped to the terminal rather than running off it, and the affected routes
198
+ * sit on their own line — they are the part you act on, and they used to blend
199
+ * into the surrounding text.
200
+ */
201
+ declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta, columns?: number): string;
196
202
  declare function formatAuditMarkdown(groups: Aggregate[], meta: AuditMeta): string;
197
203
  /** Self-contained HTML report, suitable for handing to a client. */
198
204
  declare function formatAuditHtml(groups: Aggregate[], meta: AuditMeta): string;
package/dist/index.js CHANGED
@@ -236,39 +236,38 @@ function auditCrossPage(snapshot) {
236
236
  }
237
237
  return map;
238
238
  };
239
- for (const [title, routes] of group((p) => p.title)) {
240
- if (routes.length > 1) {
241
- findings.push({
242
- code: "duplicate.title",
243
- severity: "warn",
244
- route: null,
245
- message: `${routes.length} pages share the title "${title}".`,
246
- after: routes
247
- });
248
- }
249
- }
250
- for (const [, routes] of group((p) => p.description)) {
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;
@@ -1115,33 +1114,102 @@ function sampleRoutes(routes, limit = 5) {
1115
1114
  const shown = routes.slice(0, limit).join(", ");
1116
1115
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
1117
1116
  }
1118
- function formatAuditPretty(groups, meta) {
1117
+ var GLYPH = { error: "\u2717", warn: "\u25B2", info: "\xB7" };
1118
+ var PLURAL = { error: "errors", warn: "warnings", info: "notes" };
1119
+ var SINGULAR = { error: "error", warn: "warning", info: "note" };
1120
+ function wrapText(text2, width) {
1121
+ const lines = [];
1122
+ let line = "";
1123
+ const flush = () => {
1124
+ if (line) lines.push(line);
1125
+ line = "";
1126
+ };
1127
+ for (const word of text2.split(/\s+/)) {
1128
+ if (word.length > width) {
1129
+ flush();
1130
+ for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
1131
+ continue;
1132
+ }
1133
+ if (line && line.length + 1 + word.length > width) {
1134
+ flush();
1135
+ line = word;
1136
+ } else {
1137
+ line = line ? `${line} ${word}` : word;
1138
+ }
1139
+ }
1140
+ flush();
1141
+ return lines;
1142
+ }
1143
+ function sectionRule(severity, count, width) {
1144
+ const label = PLURAL[severity].toUpperCase();
1145
+ const tail = String(count);
1146
+ const dashes = Math.max(3, width - label.length - tail.length - 2);
1147
+ return `${BADGE[severity](pc.bold(label))} ${pc.dim("\u2500".repeat(dashes))} ${pc.dim(tail)}`;
1148
+ }
1149
+ function formatAuditPretty(groups, meta, columns = 80) {
1150
+ const width = Math.min(Math.max(columns, 48), 96);
1151
+ const indent = " ";
1152
+ const body = width - indent.length;
1119
1153
  const lines = [
1120
- pc.bold(meta.target),
1121
- pc.dim(`${PLATFORM_LABEL[meta.platform]} \xB7 ${meta.pageCount} pages \xB7 ${meta.generatedAt}`),
1154
+ `${pc.bold("pagetrace")} ${pc.dim("\xB7")} ${meta.target}`,
1155
+ pc.dim(
1156
+ [
1157
+ `${meta.pageCount} page${meta.pageCount === 1 ? "" : "s"}`,
1158
+ meta.platform === "unknown" ? null : PLATFORM_LABEL[meta.platform],
1159
+ meta.generatedAt
1160
+ ].filter(Boolean).join(" \xB7 ")
1161
+ ),
1122
1162
  ""
1123
1163
  ];
1124
1164
  if (groups.length === 0) {
1125
- lines.push(pc.green("No issues found."));
1165
+ lines.push(pc.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
1126
1166
  return lines.join("\n");
1127
1167
  }
1128
- for (const group of groups) {
1129
- const scope = isTemplateWide(group, meta.pageCount) ? pc.dim(`(${group.count} pages \u2014 one template fix)`) : pc.dim(`(${group.count})`);
1130
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${pc.bold(group.message)} ${scope}`);
1131
- if (group.detail) lines.push(` ${group.detail}`);
1132
- if (group.fix) lines.push(` ${pc.cyan("Fix:")} ${group.fix}`);
1133
- if (group.routes.length > 0) lines.push(` ${pc.dim(sampleRoutes(group.routes))}`);
1134
- lines.push("");
1168
+ for (const severity of ["error", "warn", "info"]) {
1169
+ const inSeverity = groups.filter((g) => g.severity === severity);
1170
+ if (inSeverity.length === 0) continue;
1171
+ lines.push(sectionRule(severity, inSeverity.length, width), "");
1172
+ for (const group of inSeverity) {
1173
+ const scope = isTemplateWide(group, meta.pageCount) ? `${group.count} pages \xB7 one template fix` : `${group.count} page${group.count === 1 ? "" : "s"}`;
1174
+ const headline = group.message;
1175
+ const room = width - 2 - scope.length - 1;
1176
+ if (headline.length <= room) {
1177
+ const pad = " ".repeat(Math.max(1, room - headline.length + 1));
1178
+ lines.push(
1179
+ `${BADGE[severity](GLYPH[severity])} ${pc.bold(headline)}${pad}${pc.dim(scope)}`
1180
+ );
1181
+ } else {
1182
+ for (const [i, line] of wrapText(headline, width - 2).entries()) {
1183
+ lines.push(i === 0 ? `${BADGE[severity](GLYPH[severity])} ${pc.bold(line)}` : `${indent}${pc.bold(line)}`);
1184
+ }
1185
+ lines.push(`${indent}${pc.dim(scope)}`);
1186
+ }
1187
+ if (group.detail) {
1188
+ for (const line of wrapText(group.detail, body)) lines.push(`${indent}${pc.dim(line)}`);
1189
+ }
1190
+ if (group.fix) {
1191
+ const [first, ...rest] = wrapText(group.fix, body - 2);
1192
+ lines.push(`${indent}${pc.cyan("\u2192")} ${first}`);
1193
+ for (const line of rest) lines.push(`${indent} ${line}`);
1194
+ }
1195
+ if (group.routes.length > 0) {
1196
+ for (const line of wrapText(sampleRoutes(group.routes), body)) {
1197
+ lines.push(`${indent}${pc.dim(line)}`);
1198
+ }
1199
+ }
1200
+ lines.push("");
1201
+ }
1135
1202
  }
1136
1203
  const { issues, instances, total } = countIssues(groups);
1137
- lines.push(
1138
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
1139
- );
1204
+ const counted = ["error", "warn", "info"].filter((s) => issues[s] > 0).map((s) => BADGE[s](`${issues[s]} ${issues[s] === 1 ? SINGULAR[s] : PLURAL[s]}`)).join(pc.dim(" \xB7 "));
1205
+ lines.push(pc.dim("\u2500".repeat(width)));
1206
+ lines.push(`${pc.bold(`${total} issue${total === 1 ? "" : "s"}`)} ${counted}`);
1140
1207
  lines.push(
1141
1208
  pc.dim(
1142
- `across ${instances.error + instances.warn + instances.info} page findings on ${meta.pageCount} pages`
1209
+ `${instances.error + instances.warn + instances.info} findings across ${meta.pageCount} pages`
1143
1210
  )
1144
1211
  );
1212
+ lines.push("");
1145
1213
  return lines.join("\n");
1146
1214
  }
1147
1215
  function formatAuditMarkdown(groups, meta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagetrace",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Baseline your site's SEO and AEO surface, diff every build against it, and fail CI on regressions.",
5
5
  "main": "./dist/index.cjs",
6
6
  "scripts": {