pagetrace 0.6.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,27 @@ 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
+
9
30
  ## [0.6.0] - 2026-09-06
10
31
 
11
32
  ### Changed
@@ -216,6 +237,7 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
216
237
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
217
238
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
218
239
 
240
+ [0.7.0]: https://github.com/shyamexe/pagetrace/compare/v0.6.0...v0.7.0
219
241
  [0.6.0]: https://github.com/shyamexe/pagetrace/compare/v0.5.0...v0.6.0
220
242
  [0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
221
243
  [0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.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
@@ -940,33 +940,102 @@ function sampleRoutes(routes, limit = 5) {
940
940
  const shown = routes.slice(0, limit).join(", ");
941
941
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
942
942
  }
943
- 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;
944
979
  const lines = [
945
- import_picocolors.default.bold(meta.target),
946
- 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
+ ),
947
988
  ""
948
989
  ];
949
990
  if (groups.length === 0) {
950
- lines.push(import_picocolors.default.green("No issues found."));
991
+ lines.push(import_picocolors.default.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
951
992
  return lines.join("\n");
952
993
  }
953
- for (const group of groups) {
954
- const scope = isTemplateWide(group, meta.pageCount) ? import_picocolors.default.dim(`(${group.count} pages \u2014 one template fix)`) : import_picocolors.default.dim(`(${group.count})`);
955
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${import_picocolors.default.bold(group.message)} ${scope}`);
956
- if (group.detail) lines.push(` ${group.detail}`);
957
- if (group.fix) lines.push(` ${import_picocolors.default.cyan("Fix:")} ${group.fix}`);
958
- if (group.routes.length > 0) lines.push(` ${import_picocolors.default.dim(sampleRoutes(group.routes))}`);
959
- 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
+ }
960
1028
  }
961
1029
  const { issues, instances, total } = countIssues(groups);
962
- lines.push(
963
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
964
- );
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}`);
965
1033
  lines.push(
966
1034
  import_picocolors.default.dim(
967
- `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`
968
1036
  )
969
1037
  );
1038
+ lines.push("");
970
1039
  return lines.join("\n");
971
1040
  }
972
1041
  function formatAuditMarkdown(groups, meta) {
@@ -1559,7 +1628,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1559
1628
  pageCount: pages.length,
1560
1629
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1561
1630
  };
1562
- 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);
1563
1632
  if (flags.out) {
1564
1633
  await (0, import_promises2.writeFile)(flags.out, `${output}
1565
1634
  `, "utf8");
@@ -1572,7 +1641,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1572
1641
  }
1573
1642
  });
1574
1643
  cli.help();
1575
- cli.version("0.6.0");
1644
+ cli.version("0.7.0");
1576
1645
  async function main() {
1577
1646
  try {
1578
1647
  cli.parse(process.argv, { run: false });
package/dist/cli.js CHANGED
@@ -917,33 +917,102 @@ function sampleRoutes(routes, limit = 5) {
917
917
  const shown = routes.slice(0, limit).join(", ");
918
918
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
919
919
  }
920
- 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;
921
956
  const lines = [
922
- pc.bold(meta.target),
923
- 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
+ ),
924
965
  ""
925
966
  ];
926
967
  if (groups.length === 0) {
927
- lines.push(pc.green("No issues found."));
968
+ lines.push(pc.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
928
969
  return lines.join("\n");
929
970
  }
930
- for (const group of groups) {
931
- const scope = isTemplateWide(group, meta.pageCount) ? pc.dim(`(${group.count} pages \u2014 one template fix)`) : pc.dim(`(${group.count})`);
932
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${pc.bold(group.message)} ${scope}`);
933
- if (group.detail) lines.push(` ${group.detail}`);
934
- if (group.fix) lines.push(` ${pc.cyan("Fix:")} ${group.fix}`);
935
- if (group.routes.length > 0) lines.push(` ${pc.dim(sampleRoutes(group.routes))}`);
936
- 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
+ }
937
1005
  }
938
1006
  const { issues, instances, total } = countIssues(groups);
939
- lines.push(
940
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
941
- );
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}`);
942
1010
  lines.push(
943
1011
  pc.dim(
944
- `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`
945
1013
  )
946
1014
  );
1015
+ lines.push("");
947
1016
  return lines.join("\n");
948
1017
  }
949
1018
  function formatAuditMarkdown(groups, meta) {
@@ -1536,7 +1605,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1536
1605
  pageCount: pages.length,
1537
1606
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1538
1607
  };
1539
- 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);
1540
1609
  if (flags.out) {
1541
1610
  await writeFile(flags.out, `${output}
1542
1611
  `, "utf8");
@@ -1549,7 +1618,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1549
1618
  }
1550
1619
  });
1551
1620
  cli.help();
1552
- cli.version("0.6.0");
1621
+ cli.version("0.7.0");
1553
1622
  async function main() {
1554
1623
  try {
1555
1624
  cli.parse(process.argv, { run: false });
package/dist/index.cjs CHANGED
@@ -1185,33 +1185,102 @@ function sampleRoutes(routes, limit = 5) {
1185
1185
  const shown = routes.slice(0, limit).join(", ");
1186
1186
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
1187
1187
  }
1188
- 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;
1189
1224
  const lines = [
1190
- import_picocolors.default.bold(meta.target),
1191
- 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
+ ),
1192
1233
  ""
1193
1234
  ];
1194
1235
  if (groups.length === 0) {
1195
- lines.push(import_picocolors.default.green("No issues found."));
1236
+ lines.push(import_picocolors.default.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
1196
1237
  return lines.join("\n");
1197
1238
  }
1198
- for (const group of groups) {
1199
- const scope = isTemplateWide(group, meta.pageCount) ? import_picocolors.default.dim(`(${group.count} pages \u2014 one template fix)`) : import_picocolors.default.dim(`(${group.count})`);
1200
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${import_picocolors.default.bold(group.message)} ${scope}`);
1201
- if (group.detail) lines.push(` ${group.detail}`);
1202
- if (group.fix) lines.push(` ${import_picocolors.default.cyan("Fix:")} ${group.fix}`);
1203
- if (group.routes.length > 0) lines.push(` ${import_picocolors.default.dim(sampleRoutes(group.routes))}`);
1204
- 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
+ }
1205
1273
  }
1206
1274
  const { issues, instances, total } = countIssues(groups);
1207
- lines.push(
1208
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
1209
- );
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}`);
1210
1278
  lines.push(
1211
1279
  import_picocolors.default.dim(
1212
- `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`
1213
1281
  )
1214
1282
  );
1283
+ lines.push("");
1215
1284
  return lines.join("\n");
1216
1285
  }
1217
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
@@ -1114,33 +1114,102 @@ function sampleRoutes(routes, limit = 5) {
1114
1114
  const shown = routes.slice(0, limit).join(", ");
1115
1115
  return routes.length > limit ? `${shown} +${routes.length - limit} more` : shown;
1116
1116
  }
1117
- 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;
1118
1153
  const lines = [
1119
- pc.bold(meta.target),
1120
- 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
+ ),
1121
1162
  ""
1122
1163
  ];
1123
1164
  if (groups.length === 0) {
1124
- lines.push(pc.green("No issues found."));
1165
+ lines.push(pc.green(`\u2713 No issues found across ${meta.pageCount} pages.`), "");
1125
1166
  return lines.join("\n");
1126
1167
  }
1127
- for (const group of groups) {
1128
- const scope = isTemplateWide(group, meta.pageCount) ? pc.dim(`(${group.count} pages \u2014 one template fix)`) : pc.dim(`(${group.count})`);
1129
- lines.push(`${BADGE[group.severity](group.severity.toUpperCase())} ${pc.bold(group.message)} ${scope}`);
1130
- if (group.detail) lines.push(` ${group.detail}`);
1131
- if (group.fix) lines.push(` ${pc.cyan("Fix:")} ${group.fix}`);
1132
- if (group.routes.length > 0) lines.push(` ${pc.dim(sampleRoutes(group.routes))}`);
1133
- 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
+ }
1134
1202
  }
1135
1203
  const { issues, instances, total } = countIssues(groups);
1136
- lines.push(
1137
- `${total} issue${total === 1 ? "" : "s"}: ${issues.error} error, ${issues.warn} warning, ${issues.info} info`
1138
- );
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}`);
1139
1207
  lines.push(
1140
1208
  pc.dim(
1141
- `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`
1142
1210
  )
1143
1211
  );
1212
+ lines.push("");
1144
1213
  return lines.join("\n");
1145
1214
  }
1146
1215
  function formatAuditMarkdown(groups, meta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagetrace",
3
- "version": "0.6.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": {