pagetrace 0.4.0 → 0.5.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,30 @@ 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.5.0] - 2026-09-06
10
+
11
+ ### Added
12
+
13
+ - `check --baseline-branch <ref>` reads the baseline lockfile out of a git ref
14
+ instead of the working tree, so a pull request can diff against `main` without
15
+ carrying a lockfile of its own. Commit the lockfile on the default branch only
16
+ and feature branches stop churning it. An unresolvable ref throws rather than
17
+ reading as an empty baseline, since a typo must not mean "nothing changed"; a
18
+ ref that simply has no lockfile yet returns nothing, which is an ordinary
19
+ first run.
20
+ - A composite GitHub Action (`action.yml`). Three lines in a workflow run the
21
+ check and post the findings as a pull request comment, editing the previous
22
+ 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.
24
+ - `snapshotFromGitRef(ref, path)` is exported.
25
+
26
+ ### Fixed
27
+
28
+ - `formatMarkdown` escapes angle brackets. `The <h1> was removed.` rendered as
29
+ `The was removed.` on GitHub, which parses a tag name in a table cell as
30
+ inline HTML and drops it — losing the part of the message that mattered, in
31
+ the reporter whose whole purpose is the pull request comment.
32
+
9
33
  ## [0.4.0] - 2026-09-06
10
34
 
11
35
  ### Changed
@@ -170,6 +194,7 @@ Initial release. `snapshot`, `check` and `audit` commands; filesystem and HTTP
170
194
  crawling; diff classified by transition; absolute, cross-page and hreflang audit
171
195
  rules; pretty, JSON, markdown, GitHub and HTML reporters.
172
196
 
197
+ [0.5.0]: https://github.com/shyamexe/pagetrace/compare/v0.4.0...v0.5.0
173
198
  [0.4.0]: https://github.com/shyamexe/pagetrace/compare/v0.3.0...v0.4.0
174
199
  [0.3.0]: https://github.com/shyamexe/pagetrace/compare/v0.2.0...v0.3.0
175
200
  [0.2.0]: https://github.com/shyamexe/pagetrace/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -151,9 +151,30 @@ Every finding has a stable `code`. Set any code to `error`, `warn`, `info`, or `
151
151
 
152
152
  ## CI
153
153
 
154
+ The GitHub Action is the shortest path. It diffs the build against the baseline committed on your default branch and leaves the result as a pull request comment, updating that same comment on each push rather than stacking new ones.
155
+
156
+ ```yaml
157
+ - uses: actions/checkout@v5
158
+ - run: npm ci && npm run build
159
+ - uses: shyamexe/pagetrace@v1
160
+ with:
161
+ dir: ./out
162
+ baseline-branch: main
163
+ ```
164
+
165
+ `baseline-branch` reads the lockfile out of a git ref rather than the working tree, so feature branches never carry one and you get no lockfile churn in pull requests. Commit the lockfile on your default branch only:
166
+
167
+ ```bash
168
+ npx pagetrace snapshot --dir ./out
169
+ git add pagetrace.lock.json
170
+ ```
171
+
172
+ Needs `pull-requests: write` for the comment. Set `comment: false` to skip it, or `audit: false` for a pure regression gate.
173
+
174
+ Without the Action:
175
+
154
176
  ```yaml
155
- - run: npm run build
156
- - run: npx pagetrace check --dir ./out --format github
177
+ - run: npx pagetrace check --dir ./out --baseline-branch origin/main --format github
157
178
  ```
158
179
 
159
180
  `--format` accepts `pretty`, `json`, `markdown` (sized for a PR comment), and `github` (workflow annotations).
package/dist/cli.cjs CHANGED
@@ -866,7 +866,7 @@ function formatPretty(findings) {
866
866
  function formatJson(findings) {
867
867
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
868
868
  }
869
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
869
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
870
870
  function formatMarkdown(findings) {
871
871
  const s = summarize(findings);
872
872
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1055,8 +1055,10 @@ ${cards || "<p>No issues found.</p>"}
1055
1055
  }
1056
1056
 
1057
1057
  // src/snapshot.ts
1058
+ var import_node_child_process = require("child_process");
1058
1059
  var import_promises = require("fs/promises");
1059
1060
  var import_node_path = require("path");
1061
+ var import_node_util = require("util");
1060
1062
 
1061
1063
  // src/extract.ts
1062
1064
  var import_node_html_parser = require("node-html-parser");
@@ -1278,6 +1280,28 @@ function routeFromUrl(url) {
1278
1280
  return url;
1279
1281
  }
1280
1282
  }
1283
+ var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
1284
+ async function snapshotFromGitRef(ref, path) {
1285
+ try {
1286
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1287
+ } catch (cause) {
1288
+ throw new Error(
1289
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1290
+ { cause }
1291
+ );
1292
+ }
1293
+ let stdout;
1294
+ try {
1295
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ try {
1300
+ return JSON.parse(stdout);
1301
+ } catch (cause) {
1302
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1303
+ }
1304
+ }
1281
1305
  function sameSurface(a, b) {
1282
1306
  const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1283
1307
  return strip(a) === strip(b);
@@ -1478,20 +1502,14 @@ cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").opti
1478
1502
  written ? import_picocolors2.default.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : import_picocolors2.default.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
1479
1503
  );
1480
1504
  });
1481
- cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
1505
+ cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
1482
1506
  const failOn = parseFailOn(flags.failOn, false);
1483
1507
  const config = await loadConfig(flags.config);
1484
1508
  const next = await build(flags, config);
1485
- let previous = null;
1486
- try {
1487
- previous = JSON.parse(await (0, import_promises2.readFile)(flags.lockfile, "utf8"));
1488
- } catch {
1489
- previous = null;
1490
- }
1509
+ const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await (0, import_promises2.readFile)(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1491
1510
  if (!previous) {
1492
- console.error(
1493
- import_picocolors2.default.yellow(`No lockfile at ${flags.lockfile}. Run \`pagetrace snapshot\` first to set a baseline.`)
1494
- );
1511
+ const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
1512
+ console.error(import_picocolors2.default.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
1495
1513
  }
1496
1514
  const raw = [
1497
1515
  ...previous ? diffSnapshots(previous, next) : [],
@@ -1555,7 +1573,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1555
1573
  }
1556
1574
  });
1557
1575
  cli.help();
1558
- cli.version("0.4.0");
1576
+ cli.version("0.5.0");
1559
1577
  async function main() {
1560
1578
  try {
1561
1579
  cli.parse(process.argv, { run: false });
package/dist/cli.js CHANGED
@@ -843,7 +843,7 @@ function formatPretty(findings) {
843
843
  function formatJson(findings) {
844
844
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
845
845
  }
846
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
846
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
847
847
  function formatMarkdown(findings) {
848
848
  const s = summarize(findings);
849
849
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1032,8 +1032,10 @@ ${cards || "<p>No issues found.</p>"}
1032
1032
  }
1033
1033
 
1034
1034
  // src/snapshot.ts
1035
+ import { execFile } from "child_process";
1035
1036
  import { readdir, readFile } from "fs/promises";
1036
1037
  import { join, relative, sep } from "path";
1038
+ import { promisify } from "util";
1037
1039
 
1038
1040
  // src/extract.ts
1039
1041
  import { parse } from "node-html-parser";
@@ -1255,6 +1257,28 @@ function routeFromUrl(url) {
1255
1257
  return url;
1256
1258
  }
1257
1259
  }
1260
+ var exec = promisify(execFile);
1261
+ async function snapshotFromGitRef(ref, path) {
1262
+ try {
1263
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1264
+ } catch (cause) {
1265
+ throw new Error(
1266
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1267
+ { cause }
1268
+ );
1269
+ }
1270
+ let stdout;
1271
+ try {
1272
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ try {
1277
+ return JSON.parse(stdout);
1278
+ } catch (cause) {
1279
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1280
+ }
1281
+ }
1258
1282
  function sameSurface(a, b) {
1259
1283
  const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1260
1284
  return strip(a) === strip(b);
@@ -1455,20 +1479,14 @@ cli.command("snapshot", "Record the current SEO/AEO surface to a lockfile").opti
1455
1479
  written ? pc2.green(`Wrote ${flags.out} \u2014 ${count} page${count === 1 ? "" : "s"}.`) : pc2.dim(`${flags.out} is already up to date \u2014 ${count} page${count === 1 ? "" : "s"}.`)
1456
1480
  );
1457
1481
  });
1458
- cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").action(async (flags) => {
1482
+ cli.command("check", "Compare the current surface against the lockfile").option("--dir <dir>", "Directory of built HTML").option("--url <origin>", "Live origin to crawl").option("--limit <n>", "Max pages to crawl", { default: 200 }).option("--concurrency <n>", "Parallel requests", { default: 5 }).option("--lockfile <file>", "Lockfile path", { default: DEFAULT_LOCKFILE }).option("--config <file>", "Config file", { default: DEFAULT_CONFIG }).option("--format <format>", "pretty | json | markdown | github", { default: "pretty" }).option("--fail-on <severity>", "error | warn | info", { default: "error" }).option("--audit", "Also run absolute rules, not just the diff", { default: true }).option("--update", "Write the new state to the lockfile after reporting").option("--baseline-branch <ref>", "Read the baseline lockfile from a git ref instead of disk").action(async (flags) => {
1459
1483
  const failOn = parseFailOn(flags.failOn, false);
1460
1484
  const config = await loadConfig(flags.config);
1461
1485
  const next = await build(flags, config);
1462
- let previous = null;
1463
- try {
1464
- previous = JSON.parse(await readFile2(flags.lockfile, "utf8"));
1465
- } catch {
1466
- previous = null;
1467
- }
1486
+ const previous = flags.baselineBranch ? await snapshotFromGitRef(flags.baselineBranch, flags.lockfile) : await readFile2(flags.lockfile, "utf8").then((text2) => JSON.parse(text2)).catch(() => null);
1468
1487
  if (!previous) {
1469
- console.error(
1470
- pc2.yellow(`No lockfile at ${flags.lockfile}. Run \`pagetrace snapshot\` first to set a baseline.`)
1471
- );
1488
+ const where = flags.baselineBranch ? `No ${flags.lockfile} at ${flags.baselineBranch}.` : `No lockfile at ${flags.lockfile}.`;
1489
+ console.error(pc2.yellow(`${where} Run \`pagetrace snapshot\` first to set a baseline.`));
1472
1490
  }
1473
1491
  const raw = [
1474
1492
  ...previous ? diffSnapshots(previous, next) : [],
@@ -1532,7 +1550,7 @@ cli.command("audit", "Audit a site as it stands, with explanations and fixes").o
1532
1550
  }
1533
1551
  });
1534
1552
  cli.help();
1535
- cli.version("0.4.0");
1553
+ cli.version("0.5.0");
1536
1554
  async function main() {
1537
1555
  try {
1538
1556
  cli.parse(process.argv, { run: false });
package/dist/index.cjs CHANGED
@@ -62,6 +62,7 @@ __export(src_exports, {
62
62
  shouldFail: () => shouldFail,
63
63
  shouldIgnore: () => shouldIgnore,
64
64
  snapshotFromDir: () => snapshotFromDir,
65
+ snapshotFromGitRef: () => snapshotFromGitRef,
65
66
  snapshotFromOrigin: () => snapshotFromOrigin,
66
67
  summarize: () => summarize,
67
68
  withGuidance: () => withGuidance
@@ -1110,7 +1111,7 @@ function formatPretty(findings) {
1110
1111
  function formatJson(findings) {
1111
1112
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
1112
1113
  }
1113
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
1114
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1114
1115
  function formatMarkdown(findings) {
1115
1116
  const s = summarize(findings);
1116
1117
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1299,8 +1300,10 @@ ${cards || "<p>No issues found.</p>"}
1299
1300
  }
1300
1301
 
1301
1302
  // src/snapshot.ts
1303
+ var import_node_child_process = require("child_process");
1302
1304
  var import_promises = require("fs/promises");
1303
1305
  var import_node_path = require("path");
1306
+ var import_node_util = require("util");
1304
1307
  function routeFromFilePath(root, filePath) {
1305
1308
  const rel = (0, import_node_path.relative)(root, filePath).split(import_node_path.sep).join("/");
1306
1309
  const withoutExt = rel.replace(/\.html?$/i, "");
@@ -1316,6 +1319,28 @@ function routeFromUrl(url) {
1316
1319
  return url;
1317
1320
  }
1318
1321
  }
1322
+ var exec = (0, import_node_util.promisify)(import_node_child_process.execFile);
1323
+ async function snapshotFromGitRef(ref, path) {
1324
+ try {
1325
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1326
+ } catch (cause) {
1327
+ throw new Error(
1328
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1329
+ { cause }
1330
+ );
1331
+ }
1332
+ let stdout;
1333
+ try {
1334
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1335
+ } catch {
1336
+ return null;
1337
+ }
1338
+ try {
1339
+ return JSON.parse(stdout);
1340
+ } catch (cause) {
1341
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1342
+ }
1343
+ }
1319
1344
  function sameSurface(a, b) {
1320
1345
  const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1321
1346
  return strip(a) === strip(b);
@@ -1490,6 +1515,7 @@ async function snapshotFromOrigin(origin, options = {}) {
1490
1515
  shouldFail,
1491
1516
  shouldIgnore,
1492
1517
  snapshotFromDir,
1518
+ snapshotFromGitRef,
1493
1519
  snapshotFromOrigin,
1494
1520
  summarize,
1495
1521
  withGuidance
package/dist/index.d.cts CHANGED
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
215
215
 
216
216
  declare function routeFromFilePath(root: string, filePath: string): string;
217
217
  declare function routeFromUrl(url: string): string;
218
+ /**
219
+ * Read a committed lockfile out of a git ref rather than the working tree, so a
220
+ * pull request can diff against the baseline on `main` without carrying a
221
+ * lockfile of its own. Returns null when the ref has no lockfile at that path —
222
+ * an ordinary first run — but throws when the ref itself is unresolvable, since
223
+ * a typo in `--baseline-branch` must not read as "nothing to compare".
224
+ */
225
+ declare function snapshotFromGitRef(ref: string, path: string): Promise<Snapshot | null>;
218
226
  /**
219
227
  * Whether two snapshots describe the same surface, ignoring when they were
220
228
  * taken. The lockfile is meant to be committed, so writing a fresh timestamp on
@@ -237,4 +245,4 @@ interface CrawlOptions extends Config {
237
245
  /** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
238
246
  declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
239
247
 
240
- export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
248
+ export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromGitRef, snapshotFromOrigin, summarize, withGuidance };
package/dist/index.d.ts CHANGED
@@ -215,6 +215,14 @@ declare const DEFAULT_AI_AGENTS: string[];
215
215
 
216
216
  declare function routeFromFilePath(root: string, filePath: string): string;
217
217
  declare function routeFromUrl(url: string): string;
218
+ /**
219
+ * Read a committed lockfile out of a git ref rather than the working tree, so a
220
+ * pull request can diff against the baseline on `main` without carrying a
221
+ * lockfile of its own. Returns null when the ref has no lockfile at that path —
222
+ * an ordinary first run — but throws when the ref itself is unresolvable, since
223
+ * a typo in `--baseline-branch` must not read as "nothing to compare".
224
+ */
225
+ declare function snapshotFromGitRef(ref: string, path: string): Promise<Snapshot | null>;
218
226
  /**
219
227
  * Whether two snapshots describe the same surface, ignoring when they were
220
228
  * taken. The lockfile is meant to be committed, so writing a fresh timestamp on
@@ -237,4 +245,4 @@ interface CrawlOptions extends Config {
237
245
  /** Build a snapshot by fetching a live origin, discovering routes via sitemap. */
238
246
  declare function snapshotFromOrigin(origin: string, options?: CrawlOptions): Promise<Snapshot>;
239
247
 
240
- export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromOrigin, summarize, withGuidance };
248
+ export { type Aggregate, type AuditMeta, type Config, DEFAULT_AI_AGENTS, type Finding, GUIDANCE, type Guidance, type JsonLdEntity, type PageFingerprint, type Platform, RICH_RESULT_RULES, type Severity, type SiteFingerprint, type Snapshot, aggregate, applyConfig, auditCrossPage, auditHreflang, auditPage, auditSite, auditSnapshot, detectPlatform, diffPage, diffSite, diffSnapshots, extractJsonLd, extractLlmsTxt, extractPage, extractRobotsTxt, extractSitemapUrls, formatAuditHtml, formatAuditMarkdown, formatAuditPretty, formatGithub, formatJson, formatMarkdown, formatPretty, routeFromFilePath, routeFromUrl, sameSurface, shouldFail, shouldIgnore, snapshotFromDir, snapshotFromGitRef, snapshotFromOrigin, summarize, withGuidance };
package/dist/index.js CHANGED
@@ -1040,7 +1040,7 @@ function formatPretty(findings) {
1040
1040
  function formatJson(findings) {
1041
1041
  return JSON.stringify({ schemaVersion: 1, summary: summarize(findings), findings }, null, 2);
1042
1042
  }
1043
- var escapeCell = (value) => value.replace(/\|/g, "\\|");
1043
+ var escapeCell = (value) => value.replace(/\|/g, "\\|").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1044
1044
  function formatMarkdown(findings) {
1045
1045
  const s = summarize(findings);
1046
1046
  if (findings.length === 0) return "### pagetrace\n\nNo SEO/AEO changes or issues found.";
@@ -1229,8 +1229,10 @@ ${cards || "<p>No issues found.</p>"}
1229
1229
  }
1230
1230
 
1231
1231
  // src/snapshot.ts
1232
+ import { execFile } from "child_process";
1232
1233
  import { readdir, readFile } from "fs/promises";
1233
1234
  import { join, relative, sep } from "path";
1235
+ import { promisify } from "util";
1234
1236
  function routeFromFilePath(root, filePath) {
1235
1237
  const rel = relative(root, filePath).split(sep).join("/");
1236
1238
  const withoutExt = rel.replace(/\.html?$/i, "");
@@ -1246,6 +1248,28 @@ function routeFromUrl(url) {
1246
1248
  return url;
1247
1249
  }
1248
1250
  }
1251
+ var exec = promisify(execFile);
1252
+ async function snapshotFromGitRef(ref, path) {
1253
+ try {
1254
+ await exec("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1255
+ } catch (cause) {
1256
+ throw new Error(
1257
+ `Cannot resolve git ref "${ref}". Fetch it first \u2014 a shallow CI checkout often has only the PR head.`,
1258
+ { cause }
1259
+ );
1260
+ }
1261
+ let stdout;
1262
+ try {
1263
+ ({ stdout } = await exec("git", ["show", `${ref}:${path}`], { maxBuffer: 256 * 1024 * 1024 }));
1264
+ } catch {
1265
+ return null;
1266
+ }
1267
+ try {
1268
+ return JSON.parse(stdout);
1269
+ } catch (cause) {
1270
+ throw new Error(`${path} at ${ref} is not valid JSON.`, { cause });
1271
+ }
1272
+ }
1249
1273
  function sameSurface(a, b) {
1250
1274
  const strip = (s) => JSON.stringify({ ...s, createdAt: "" });
1251
1275
  return strip(a) === strip(b);
@@ -1419,6 +1443,7 @@ export {
1419
1443
  shouldFail,
1420
1444
  shouldIgnore,
1421
1445
  snapshotFromDir,
1446
+ snapshotFromGitRef,
1422
1447
  snapshotFromOrigin,
1423
1448
  summarize,
1424
1449
  withGuidance
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagetrace",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {