pagetrace 0.6.0 → 0.8.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,39 @@ 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.8.0] - 2026-09-06
10
+
11
+ ### Fixed
12
+
13
+ - `siteUrl` now overrides the crawled origin for an origin crawl, not just a
14
+ `--dir` crawl. Checking a local build or a preview deployment means crawling
15
+ `http://localhost:3000` while every page's canonical points at production, so
16
+ `canonical.offsite` fired on every page and made the check useless in exactly
17
+ the setup it is most wanted. Where you crawl and what the site calls itself are
18
+ separate things; `siteUrl` is now the authority on the latter. Without it the
19
+ crawled origin is still used, so nothing changes for a plain production crawl.
20
+
21
+ ## [0.7.0] - 2026-09-06
22
+
23
+ ### Changed
24
+
25
+ - The terminal audit report is laid out rather than printed. Findings sit under a
26
+ rule per severity, prose wraps to the terminal instead of running off the right
27
+ edge, the affected routes get their own line, and the page count is right
28
+ aligned against the headline. The old output put explanation, fix and routes at
29
+ the same indent with no wrapping, so on a real site the useful part was the
30
+ hardest part to find.
31
+ - `formatAuditPretty(groups, meta, columns?)` takes an optional terminal width,
32
+ defaulting to 80. The CLI passes `process.stdout.columns`, which keeps
33
+ `report.ts` free of environment reads. Piped output falls back to 80 and drops
34
+ colour, so it stays readable in a CI log.
35
+
36
+ ### Fixed
37
+
38
+ - A word longer than the terminal is hard-broken instead of overflowing. A
39
+ canonical URL is a single word and is routinely longer than 80 characters, so
40
+ `duplicate.canonical` could push a line well past the edge.
41
+
9
42
  ## [0.6.0] - 2026-09-06
10
43
 
11
44
  ### Changed
@@ -216,6 +249,8 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
216
249
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
217
250
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
218
251
 
252
+ [0.8.0]: https://github.com/shyamexe/pagetrace/compare/v0.7.0...v0.8.0
253
+ [0.7.0]: https://github.com/shyamexe/pagetrace/compare/v0.6.0...v0.7.0
219
254
  [0.6.0]: https://github.com/shyamexe/pagetrace/compare/v0.5.0...v0.6.0
220
255
  [0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
221
256
  [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.
@@ -145,7 +156,7 @@ Alongside the diff, `check` runs absolute rules: missing title, canonical, `h1`,
145
156
  }
146
157
  ```
147
158
 
148
- `siteUrl` is only needed for a `--dir` crawl, and only to detect canonicals pointing at another host a staging hostname leaking into production canonicals. A `--url` crawl infers it.
159
+ `siteUrl` is what the site calls itself, which is not always where you are crawling it. It is what `canonical.offsite` compares against, so set it when checking a `--dir` build, and when crawling a local build or a preview deployment whose pages carry production canonicals. A plain crawl of production infers it.
149
160
 
150
161
  Every finding has a stable `code`. Set any code to `error`, `warn`, `info`, or `off`.
151
162
 
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) {
@@ -1382,7 +1451,11 @@ async function snapshotFromOrigin(origin, options = {}) {
1382
1451
  const base = new URL(origin);
1383
1452
  const agents = options.aiAgents ?? DEFAULT_AI_AGENTS;
1384
1453
  const timeout = options.timeout;
1385
- const site = { origin: base.origin, robotsTxt: null, llmsTxt: null };
1454
+ const site = {
1455
+ origin: options.siteUrl ? new URL(options.siteUrl).origin : base.origin,
1456
+ robotsTxt: null,
1457
+ llmsTxt: null
1458
+ };
1386
1459
  const limit = options.limit ?? 200;
1387
1460
  const robots = await fetchText(new URL("/robots.txt", base).href, timeout);
1388
1461
  if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
@@ -1559,7 +1632,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1559
1632
  pageCount: pages.length,
1560
1633
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1561
1634
  };
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);
1635
+ 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
1636
  if (flags.out) {
1564
1637
  await (0, import_promises2.writeFile)(flags.out, `${output}
1565
1638
  `, "utf8");
@@ -1572,7 +1645,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1572
1645
  }
1573
1646
  });
1574
1647
  cli.help();
1575
- cli.version("0.6.0");
1648
+ cli.version("0.8.0");
1576
1649
  async function main() {
1577
1650
  try {
1578
1651
  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) {
@@ -1359,7 +1428,11 @@ async function snapshotFromOrigin(origin, options = {}) {
1359
1428
  const base = new URL(origin);
1360
1429
  const agents = options.aiAgents ?? DEFAULT_AI_AGENTS;
1361
1430
  const timeout = options.timeout;
1362
- const site = { origin: base.origin, robotsTxt: null, llmsTxt: null };
1431
+ const site = {
1432
+ origin: options.siteUrl ? new URL(options.siteUrl).origin : base.origin,
1433
+ robotsTxt: null,
1434
+ llmsTxt: null
1435
+ };
1363
1436
  const limit = options.limit ?? 200;
1364
1437
  const robots = await fetchText(new URL("/robots.txt", base).href, timeout);
1365
1438
  if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
@@ -1536,7 +1609,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1536
1609
  pageCount: pages.length,
1537
1610
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
1538
1611
  };
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);
1612
+ 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
1613
  if (flags.out) {
1541
1614
  await writeFile(flags.out, `${output}
1542
1615
  `, "utf8");
@@ -1549,7 +1622,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1549
1622
  }
1550
1623
  });
1551
1624
  cli.help();
1552
- cli.version("0.6.0");
1625
+ cli.version("0.8.0");
1553
1626
  async function main() {
1554
1627
  try {
1555
1628
  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) {
@@ -1421,7 +1490,11 @@ async function snapshotFromOrigin(origin, options = {}) {
1421
1490
  const base = new URL(origin);
1422
1491
  const agents = options.aiAgents ?? DEFAULT_AI_AGENTS;
1423
1492
  const timeout = options.timeout;
1424
- const site = { origin: base.origin, robotsTxt: null, llmsTxt: null };
1493
+ const site = {
1494
+ origin: options.siteUrl ? new URL(options.siteUrl).origin : base.origin,
1495
+ robotsTxt: null,
1496
+ llmsTxt: null
1497
+ };
1425
1498
  const limit = options.limit ?? 200;
1426
1499
  const robots = await fetchText(new URL("/robots.txt", base).href, timeout);
1427
1500
  if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
package/dist/index.d.cts CHANGED
@@ -94,8 +94,10 @@ interface Config {
94
94
  /** Minimum word count before a page is flagged as thin. */
95
95
  minWordCount?: number;
96
96
  /**
97
- * The site's own origin, e.g. "https://example.com". Lets a --dir crawl detect
98
- * canonicals pointing at another host; an origin crawl infers it.
97
+ * The site's own origin, e.g. "https://example.com". Used to detect canonicals
98
+ * pointing at another host. A --dir crawl has no other source for it, and an
99
+ * origin crawl of a local build or preview deployment needs it to override the
100
+ * URL being crawled, since those serve production canonicals.
99
101
  */
100
102
  siteUrl?: string;
101
103
  }
@@ -192,7 +194,13 @@ interface AuditMeta {
192
194
  pageCount: number;
193
195
  generatedAt: string;
194
196
  }
195
- declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta): string;
197
+ /**
198
+ * The terminal report. Findings are grouped under a rule per severity, prose is
199
+ * wrapped to the terminal rather than running off it, and the affected routes
200
+ * sit on their own line — they are the part you act on, and they used to blend
201
+ * into the surrounding text.
202
+ */
203
+ declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta, columns?: number): string;
196
204
  declare function formatAuditMarkdown(groups: Aggregate[], meta: AuditMeta): string;
197
205
  /** Self-contained HTML report, suitable for handing to a client. */
198
206
  declare function formatAuditHtml(groups: Aggregate[], meta: AuditMeta): string;
package/dist/index.d.ts CHANGED
@@ -94,8 +94,10 @@ interface Config {
94
94
  /** Minimum word count before a page is flagged as thin. */
95
95
  minWordCount?: number;
96
96
  /**
97
- * The site's own origin, e.g. "https://example.com". Lets a --dir crawl detect
98
- * canonicals pointing at another host; an origin crawl infers it.
97
+ * The site's own origin, e.g. "https://example.com". Used to detect canonicals
98
+ * pointing at another host. A --dir crawl has no other source for it, and an
99
+ * origin crawl of a local build or preview deployment needs it to override the
100
+ * URL being crawled, since those serve production canonicals.
99
101
  */
100
102
  siteUrl?: string;
101
103
  }
@@ -192,7 +194,13 @@ interface AuditMeta {
192
194
  pageCount: number;
193
195
  generatedAt: string;
194
196
  }
195
- declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta): string;
197
+ /**
198
+ * The terminal report. Findings are grouped under a rule per severity, prose is
199
+ * wrapped to the terminal rather than running off it, and the affected routes
200
+ * sit on their own line — they are the part you act on, and they used to blend
201
+ * into the surrounding text.
202
+ */
203
+ declare function formatAuditPretty(groups: Aggregate[], meta: AuditMeta, columns?: number): string;
196
204
  declare function formatAuditMarkdown(groups: Aggregate[], meta: AuditMeta): string;
197
205
  /** Self-contained HTML report, suitable for handing to a client. */
198
206
  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) {
@@ -1350,7 +1419,11 @@ async function snapshotFromOrigin(origin, options = {}) {
1350
1419
  const base = new URL(origin);
1351
1420
  const agents = options.aiAgents ?? DEFAULT_AI_AGENTS;
1352
1421
  const timeout = options.timeout;
1353
- const site = { origin: base.origin, robotsTxt: null, llmsTxt: null };
1422
+ const site = {
1423
+ origin: options.siteUrl ? new URL(options.siteUrl).origin : base.origin,
1424
+ robotsTxt: null,
1425
+ llmsTxt: null
1426
+ };
1354
1427
  const limit = options.limit ?? 200;
1355
1428
  const robots = await fetchText(new URL("/robots.txt", base).href, timeout);
1356
1429
  if (robots !== null) site.robotsTxt = extractRobotsTxt(robots, agents);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagetrace",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {