ssrwire 0.2.0 → 0.4.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/CONTRIBUTING.md +3 -1
  3. package/PUBLISHING.md +15 -15
  4. package/README.md +179 -29
  5. package/dist/analyze.d.ts.map +1 -1
  6. package/dist/analyze.js +162 -6
  7. package/dist/analyze.js.map +1 -1
  8. package/dist/audit-report.d.ts +8 -0
  9. package/dist/audit-report.d.ts.map +1 -0
  10. package/dist/audit-report.js +243 -0
  11. package/dist/audit-report.js.map +1 -0
  12. package/dist/audit.d.ts.map +1 -1
  13. package/dist/audit.js +2 -0
  14. package/dist/audit.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +77 -10
  17. package/dist/cli.js.map +1 -1
  18. package/dist/compare.d.ts +6 -0
  19. package/dist/compare.d.ts.map +1 -0
  20. package/dist/compare.js +720 -0
  21. package/dist/compare.js.map +1 -0
  22. package/dist/comparison-reporters.d.ts +9 -0
  23. package/dist/comparison-reporters.d.ts.map +1 -0
  24. package/dist/comparison-reporters.js +223 -0
  25. package/dist/comparison-reporters.js.map +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +20 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/http-probe.d.ts.map +1 -1
  30. package/dist/http-probe.js +4 -0
  31. package/dist/http-probe.js.map +1 -1
  32. package/dist/index.d.ts +4 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/redact.d.ts.map +1 -1
  37. package/dist/redact.js +6 -0
  38. package/dist/redact.js.map +1 -1
  39. package/dist/reporters.d.ts.map +1 -1
  40. package/dist/reporters.js +27 -0
  41. package/dist/reporters.js.map +1 -1
  42. package/dist/social.d.ts +14 -0
  43. package/dist/social.d.ts.map +1 -0
  44. package/dist/social.js +88 -0
  45. package/dist/social.js.map +1 -0
  46. package/dist/stability.d.ts.map +1 -1
  47. package/dist/stability.js +84 -4
  48. package/dist/stability.js.map +1 -1
  49. package/dist/stream-parser.d.ts.map +1 -1
  50. package/dist/stream-parser.js +21 -1
  51. package/dist/stream-parser.js.map +1 -1
  52. package/dist/types.d.ts +92 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/examples/github-actions.yml +2 -2
  55. package/examples/ssrwire.config.yml +8 -2
  56. package/package.json +5 -2
  57. package/src/analyze.ts +208 -7
  58. package/src/audit-report.ts +269 -0
  59. package/src/audit.ts +2 -0
  60. package/src/cli.ts +106 -12
  61. package/src/compare.ts +949 -0
  62. package/src/comparison-reporters.ts +276 -0
  63. package/src/config.ts +19 -1
  64. package/src/http-probe.ts +4 -0
  65. package/src/index.ts +29 -0
  66. package/src/redact.ts +6 -0
  67. package/src/reporters.ts +46 -0
  68. package/src/social.ts +116 -0
  69. package/src/stability.ts +123 -4
  70. package/src/stream-parser.ts +31 -1
  71. package/src/types.ts +117 -0
@@ -0,0 +1,276 @@
1
+ import type {
2
+ AuditComparison,
3
+ ComparisonChange,
4
+ ComparisonKind,
5
+ ComparisonReportFormat,
6
+ ComparisonTimelineEvent,
7
+ ComparisonTimelineSnapshot,
8
+ TargetComparison,
9
+ } from "./types.js";
10
+
11
+ export interface ComparisonReporterOptions {
12
+ readonly color?: boolean;
13
+ }
14
+
15
+ const ANSI = {
16
+ red: "\u001b[31m",
17
+ green: "\u001b[32m",
18
+ yellow: "\u001b[33m",
19
+ blue: "\u001b[36m",
20
+ bold: "\u001b[1m",
21
+ dim: "\u001b[2m",
22
+ reset: "\u001b[0m",
23
+ } as const;
24
+
25
+ const EVENT_SHORT_LABELS: Readonly<Record<string, string>> = {
26
+ headers: "H",
27
+ "first-byte": "B",
28
+ title: "T",
29
+ description: "D",
30
+ canonical: "C",
31
+ "open-graph": "OG",
32
+ "twitter-card": "X",
33
+ required: "R",
34
+ main: "M",
35
+ complete: "✓",
36
+ };
37
+
38
+ function paint(value: string, code: string, enabled: boolean): string {
39
+ return enabled ? `${code}${value}${ANSI.reset}` : value;
40
+ }
41
+
42
+ function terminalSafe(value: string): string {
43
+ let safe = "";
44
+ for (const character of value) {
45
+ const codePoint = character.codePointAt(0) ?? 0;
46
+ safe +=
47
+ codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
48
+ ? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
49
+ : character;
50
+ }
51
+ return safe;
52
+ }
53
+
54
+ function stableValue(value: unknown): unknown {
55
+ if (Array.isArray(value)) return value.map(stableValue);
56
+ if (value !== null && typeof value === "object") {
57
+ return Object.fromEntries(
58
+ Object.entries(value)
59
+ .sort(([left], [right]) => left.localeCompare(right))
60
+ .map(([key, entry]) => [key, stableValue(entry)]),
61
+ );
62
+ }
63
+ return value;
64
+ }
65
+
66
+ function stableJson(value: unknown): string {
67
+ return `${JSON.stringify(stableValue(value), null, 2)}\n`;
68
+ }
69
+
70
+ function kindLabel(kind: ComparisonKind): string {
71
+ if (kind === "regression") return "REGRESS";
72
+ if (kind === "fixed") return "FIXED";
73
+ return "CHANGED";
74
+ }
75
+
76
+ function kindColor(kind: ComparisonKind): string {
77
+ if (kind === "regression") return ANSI.red;
78
+ if (kind === "fixed") return ANSI.green;
79
+ return ANSI.blue;
80
+ }
81
+
82
+ function formatChange(change: ComparisonChange, color: boolean): string {
83
+ const label = paint(kindLabel(change.kind).padEnd(7), kindColor(change.kind), color);
84
+ const qualifiers = [change.agent, change.field].filter(
85
+ (value): value is string => value !== undefined,
86
+ );
87
+ const context = qualifiers.length === 0 ? "" : ` [${qualifiers.map(terminalSafe).join("/")}]`;
88
+ const values =
89
+ change.baseline === undefined && change.candidate === undefined
90
+ ? ""
91
+ : ` (${terminalSafe(String(change.baseline ?? "—"))} → ${terminalSafe(String(change.candidate ?? "—"))})`;
92
+ return ` ${label} ${terminalSafe(change.code)}${context}: ${terminalSafe(change.message)}${values}`;
93
+ }
94
+
95
+ export function renderComparisonTerminal(
96
+ comparison: AuditComparison,
97
+ options: ComparisonReporterOptions = {},
98
+ ): string {
99
+ const color = options.color ?? Boolean(process.stdout.isTTY);
100
+ const lines = [
101
+ paint(`SSRWire ${comparison.version} comparison`, ANSI.bold, color),
102
+ `${paint("Baseline", ANSI.dim, color)}: ${terminalSafe(comparison.baseline.label)} · SSRWire ${terminalSafe(comparison.baseline.version)} · ${terminalSafe(comparison.baseline.generatedAt)}`,
103
+ `${paint("Candidate", ANSI.dim, color)}: ${terminalSafe(comparison.candidate.label)} · SSRWire ${terminalSafe(comparison.candidate.version)} · ${terminalSafe(comparison.candidate.generatedAt)}`,
104
+ paint(
105
+ `Timing regression requires >${comparison.thresholds.timingRegressionMs} ms and >${comparison.thresholds.timingRegressionPercent}% median increase`,
106
+ ANSI.dim,
107
+ color,
108
+ ),
109
+ ];
110
+
111
+ for (const result of comparison.results) {
112
+ lines.push("", paint(terminalSafe(result.key), ANSI.bold, color));
113
+ if (result.baselineUrl !== undefined) {
114
+ lines.push(` Baseline: ${terminalSafe(result.baselineUrl)}`);
115
+ }
116
+ if (result.candidateUrl !== undefined) {
117
+ lines.push(` Candidate: ${terminalSafe(result.candidateUrl)}`);
118
+ }
119
+ if (result.changes.length === 0) {
120
+ lines.push(paint(" No differences", ANSI.dim, color));
121
+ } else {
122
+ lines.push(...result.changes.map((change) => formatChange(change, color)));
123
+ }
124
+ }
125
+
126
+ const { summary } = comparison;
127
+ const summaryText =
128
+ `Summary: ${summary.matchedTargets} matched, ${summary.addedTargets} added, ` +
129
+ `${summary.removedTargets} removed, ${summary.unchangedTargets} unchanged; ` +
130
+ `${summary.regressions} regression(s), ${summary.fixed} fixed, ${summary.changed} changed`;
131
+ const summaryColor = summary.regressions > 0 ? ANSI.red : ANSI.green;
132
+ lines.push("", paint(summaryText, summaryColor, color));
133
+ return `${lines.join("\n")}\n`;
134
+ }
135
+
136
+ export function renderComparisonJson(comparison: AuditComparison): string {
137
+ return stableJson(comparison);
138
+ }
139
+
140
+ function escapeHtml(value: string): string {
141
+ return value
142
+ .replaceAll("&", "&amp;")
143
+ .replaceAll("<", "&lt;")
144
+ .replaceAll(">", "&gt;")
145
+ .replaceAll('"', "&quot;")
146
+ .replaceAll("'", "&#39;");
147
+ }
148
+
149
+ function formatMs(value: number): string {
150
+ return `${Math.round(value)} ms`;
151
+ }
152
+
153
+ function maxTimelineMs(result: TargetComparison): number {
154
+ return Math.max(
155
+ 1,
156
+ ...result.timelines.flatMap((lane) => [
157
+ ...(lane.baseline?.events.map((event) => event.medianMs) ?? []),
158
+ ...(lane.candidate?.events.map((event) => event.medianMs) ?? []),
159
+ ]),
160
+ );
161
+ }
162
+
163
+ function eventTitle(event: ComparisonTimelineEvent): string {
164
+ const details = [event.label, formatMs(event.medianMs)];
165
+ if (event.location !== undefined) details.push(event.location);
166
+ if (event.observedByByte !== undefined) {
167
+ details.push(`by byte ${Math.round(event.observedByByte)}`);
168
+ }
169
+ return details.join(" · ");
170
+ }
171
+
172
+ function renderTimelineSnapshot(
173
+ side: string,
174
+ snapshot: ComparisonTimelineSnapshot | undefined,
175
+ maxMs: number,
176
+ ): string {
177
+ if (snapshot === undefined) {
178
+ return `<div class="timeline-row"><div class="side">${escapeHtml(side)}</div><div class="missing">not present</div></div>`;
179
+ }
180
+ const events = snapshot.events
181
+ .map((event, index) => {
182
+ const position = Math.max(0, Math.min(100, (event.medianMs / maxMs) * 100));
183
+ const short = EVENT_SHORT_LABELS[event.key] ?? event.key.slice(0, 2).toUpperCase();
184
+ const title = escapeHtml(eventTitle(event));
185
+ return `<span class="event" style="--position:${position.toFixed(3)}%;--level:${index % 2}" title="${title}" aria-label="${title}" tabindex="0"><span>${escapeHtml(short)}</span></span>`;
186
+ })
187
+ .join("");
188
+ return `<div class="timeline-row"><div class="side">${escapeHtml(side)} <small>n=${snapshot.samples}</small></div><div class="track">${events}</div></div>`;
189
+ }
190
+
191
+ function renderTimelines(result: TargetComparison): string {
192
+ if (result.timelines.length === 0) return "";
193
+ const maxMs = maxTimelineMs(result);
194
+ const lanes = result.timelines
195
+ .map(
196
+ (lane) => `<section class="agent-lane">
197
+ <h4>${escapeHtml(lane.label)} <code>${escapeHtml(lane.agent)}</code></h4>
198
+ ${renderTimelineSnapshot("Baseline", lane.baseline, maxMs)}
199
+ ${renderTimelineSnapshot("Candidate", lane.candidate, maxMs)}
200
+ </section>`,
201
+ )
202
+ .join("");
203
+ return `<details class="waterfall" open><summary>Wire waterfall <span>0–${escapeHtml(formatMs(maxMs))}</span></summary>${lanes}</details>`;
204
+ }
205
+
206
+ function renderChange(change: ComparisonChange): string {
207
+ const context = [change.agent, change.field]
208
+ .filter((value): value is string => value !== undefined)
209
+ .map((value) => `<code>${escapeHtml(value)}</code>`)
210
+ .join(" ");
211
+ const values =
212
+ change.baseline === undefined && change.candidate === undefined
213
+ ? ""
214
+ : `<dl><div><dt>Baseline</dt><dd>${escapeHtml(String(change.baseline ?? "—"))}</dd></div><div><dt>Candidate</dt><dd>${escapeHtml(String(change.candidate ?? "—"))}</dd></div></dl>`;
215
+ return `<article class="change ${change.kind}">
216
+ <div class="change-heading"><span>${escapeHtml(kindLabel(change.kind))}</span><strong>${escapeHtml(change.code)}</strong>${context}</div>
217
+ <p>${escapeHtml(change.message)}</p>${values}
218
+ </article>`;
219
+ }
220
+
221
+ function renderTarget(result: TargetComparison): string {
222
+ const urls = [
223
+ result.baselineUrl === undefined
224
+ ? ""
225
+ : `<div><span>Baseline</span>${escapeHtml(result.baselineUrl)}</div>`,
226
+ result.candidateUrl === undefined
227
+ ? ""
228
+ : `<div><span>Candidate</span>${escapeHtml(result.candidateUrl)}</div>`,
229
+ ].join("");
230
+ const changes =
231
+ result.changes.length === 0
232
+ ? '<p class="clean">No differences detected.</p>'
233
+ : `<div class="changes">${result.changes.map(renderChange).join("")}</div>`;
234
+ return `<section class="target">
235
+ <header><div><span class="target-status">${escapeHtml(result.status)}</span><h2>${escapeHtml(result.key)}</h2></div><div class="urls">${urls}</div></header>
236
+ ${renderTimelines(result)}${changes}
237
+ </section>`;
238
+ }
239
+
240
+ export function renderComparisonHtml(comparison: AuditComparison): string {
241
+ const { summary } = comparison;
242
+ const outcome = summary.regressions > 0 ? "Regressions detected" : "No regressions";
243
+ const targets = comparison.results.map(renderTarget).join("\n");
244
+ return `<!doctype html>
245
+ <html lang="en">
246
+ <head>
247
+ <meta charset="utf-8">
248
+ <meta name="viewport" content="width=device-width,initial-scale=1">
249
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
250
+ <title>SSRWire comparison · ${escapeHtml(outcome)}</title>
251
+ <style>
252
+ :root{color-scheme:light dark;--bg:#f5f2eb;--panel:#fffdf8;--ink:#191c1b;--muted:#656c68;--line:#d8d5cc;--red:#b42318;--red-bg:#fff0ed;--green:#18794e;--green-bg:#ebf8f1;--blue:#1769aa;--blue-bg:#edf6ff;--track:#e3e7e4}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:48px 0 80px}.hero{display:grid;grid-template-columns:1fr auto;gap:32px;align-items:end;margin-bottom:28px}.eyebrow,.target-status{color:var(--muted);font-size:12px;font-weight:750;letter-spacing:.1em;text-transform:uppercase}h1{font-size:clamp(34px,6vw,64px);line-height:1;margin:.18em 0}.hero p{color:var(--muted);margin:0}.outcome{border:1px solid var(--line);border-radius:18px;background:var(--panel);padding:18px 22px;min-width:260px}.outcome strong{display:block;font-size:22px}.outcome.bad strong{color:var(--red)}.counts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}.counts div{border-radius:10px;padding:8px;background:var(--bg)}.counts b{display:block;font-size:20px}.counts span{color:var(--muted);font-size:12px}.sources{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:28px}.source,.target{background:var(--panel);border:1px solid var(--line);border-radius:18px}.source{padding:16px 18px}.source span,.urls span{display:block;color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.source strong{display:block;font-size:17px}.source small{color:var(--muted)}.threshold{color:var(--muted);font-size:13px;margin:-14px 0 28px}.target{overflow:hidden;margin:18px 0}.target>header{display:grid;grid-template-columns:minmax(180px,.55fr) 1fr;gap:24px;padding:22px 24px;border-bottom:1px solid var(--line)}.target h2{font-size:23px;margin:3px 0 0;overflow-wrap:anywhere}.urls{display:grid;gap:8px;overflow-wrap:anywhere}.waterfall{padding:18px 24px;border-bottom:1px solid var(--line)}.waterfall summary{cursor:pointer;font-weight:700}.waterfall summary span{color:var(--muted);font-weight:400;margin-left:8px}.agent-lane{margin:18px 0}.agent-lane h4{margin:0 0 8px}.agent-lane code,.change-heading code{color:var(--muted);font-size:12px}.timeline-row{display:grid;grid-template-columns:126px 1fr;align-items:center;margin:5px 0}.side{color:var(--muted);font-size:13px}.side small{opacity:.75}.track{position:relative;height:48px;border-radius:8px;background:linear-gradient(90deg,var(--track),transparent 1px) 0 0/10% 100%,color-mix(in srgb,var(--track) 55%,transparent)}.event{position:absolute;left:var(--position);top:calc(5px + var(--level)*20px);transform:translateX(-50%);cursor:help}.event:focus-visible{outline:2px solid var(--blue);outline-offset:2px}.event:before{content:"";position:absolute;left:50%;top:-5px;height:42px;border-left:1px solid color-mix(in srgb,var(--ink) 22%,transparent);z-index:0}.event span{position:relative;display:block;min-width:20px;padding:1px 4px;border:1px solid var(--line);border-radius:5px;background:var(--panel);font-size:10px;font-weight:800;text-align:center;z-index:1}.missing{color:var(--muted);font-size:13px}.changes{display:grid;gap:10px;padding:18px 24px 24px}.change{border-left:4px solid var(--blue);border-radius:8px;background:var(--blue-bg);padding:12px 14px}.change.regression{border-color:var(--red);background:var(--red-bg)}.change.fixed{border-color:var(--green);background:var(--green-bg)}.change-heading{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.change-heading>span{font-size:11px;font-weight:850;letter-spacing:.08em}.change p{margin:5px 0 0}.change dl{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:10px 0 0}.change dl div{min-width:0;padding:7px 9px;border-radius:6px;background:color-mix(in srgb,var(--panel) 70%,transparent)}dt{color:var(--muted);font-size:11px;text-transform:uppercase}dd{margin:1px 0 0;overflow-wrap:anywhere}.clean{color:var(--green);font-weight:700;padding:18px 24px;margin:0}.legend{color:var(--muted);font-size:12px;margin-top:30px}.legend code{color:var(--ink)}@media(max-width:720px){main{width:min(100% - 20px,1180px);padding-top:24px}.hero,.sources,.target>header{grid-template-columns:1fr}.outcome{min-width:0}.timeline-row{grid-template-columns:1fr}.side{margin-bottom:3px}.change dl{grid-template-columns:1fr}}
253
+ @media(prefers-color-scheme:dark){:root{--bg:#151817;--panel:#1d211f;--ink:#f0f3f1;--muted:#aab2ad;--line:#363d39;--red:#ff8a80;--red-bg:#321d1a;--green:#73d6a6;--green-bg:#173125;--blue:#75bfff;--blue-bg:#172a3a;--track:#323a36}}
254
+ </style>
255
+ </head>
256
+ <body><main>
257
+ <section class="hero"><div><div class="eyebrow">SSRWire ${escapeHtml(comparison.version)} · deployment diff</div><h1>${escapeHtml(outcome)}</h1><p>Generated ${escapeHtml(comparison.generatedAt)}</p></div><aside class="outcome ${summary.regressions > 0 ? "bad" : "good"}"><strong>${summary.regressions} regression${summary.regressions === 1 ? "" : "s"}</strong><div class="counts"><div><b>${summary.fixed}</b><span>fixed</span></div><div><b>${summary.changed}</b><span>changed</span></div><div><b>${summary.unchangedTargets}</b><span>unchanged</span></div></div></aside></section>
258
+ <section class="sources"><div class="source"><span>Baseline</span><strong>${escapeHtml(comparison.baseline.label)}</strong><small>SSRWire ${escapeHtml(comparison.baseline.version)} · ${escapeHtml(comparison.baseline.generatedAt)} · repeat ${comparison.baseline.repeat}</small></div><div class="source"><span>Candidate</span><strong>${escapeHtml(comparison.candidate.label)}</strong><small>SSRWire ${escapeHtml(comparison.candidate.version)} · ${escapeHtml(comparison.candidate.generatedAt)} · repeat ${comparison.candidate.repeat}</small></div></section>
259
+ <p class="threshold">Timing regressions require both &gt;${comparison.thresholds.timingRegressionMs} ms and &gt;${comparison.thresholds.timingRegressionPercent}% median increase.</p>
260
+ ${targets || '<section class="target"><p class="clean">Both reports contain no targets.</p></section>'}
261
+ <p class="legend"><code>H</code> headers · <code>B</code> first byte · <code>T</code> title · <code>D</code> description · <code>C</code> canonical · <code>OG</code> Open Graph ready · <code>X</code> Twitter Card ready · <code>R</code> required signals ready · <code>M</code> main text · <code>✓</code> complete. Hover markers for timing, location, and byte evidence.</p>
262
+ </main></body></html>\n`;
263
+ }
264
+
265
+ export function renderComparisonReport(
266
+ comparison: AuditComparison,
267
+ format: ComparisonReportFormat,
268
+ options: ComparisonReporterOptions = {},
269
+ ): string {
270
+ if (format === "terminal") return renderComparisonTerminal(comparison, options);
271
+ if (format === "json") return renderComparisonJson(comparison);
272
+ if (format === "html") return renderComparisonHtml(comparison);
273
+
274
+ const exhaustive: never = format;
275
+ return exhaustive;
276
+ }
package/src/config.ts CHANGED
@@ -23,11 +23,17 @@ const requireSchema = z
23
23
  canonical: z.boolean().optional(),
24
24
  h1: z.boolean().optional(),
25
25
  mainText: z.boolean().optional(),
26
+ openGraph: z.boolean().optional(),
27
+ twitterCard: z.boolean().optional(),
26
28
  })
27
29
  .strict();
28
30
 
29
31
  const targetObjectSchema = z
30
32
  .object({
33
+ id: z
34
+ .string()
35
+ .regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i)
36
+ .optional(),
31
37
  url: z.string().min(1),
32
38
  expectedStatus: z
33
39
  .union([
@@ -129,11 +135,14 @@ function normalizeTarget(value: string | z.infer<typeof targetObjectSchema>): Au
129
135
  requireCanonical: required?.canonical ?? true,
130
136
  requireH1: required?.h1 ?? true,
131
137
  requireMainText: required?.mainText ?? true,
138
+ requireOpenGraph: required?.openGraph ?? false,
139
+ requireTwitterCard: required?.twitterCard ?? false,
132
140
  ...(item.maxFirstByteMs === undefined ? {} : { maxFirstByteMs: item.maxFirstByteMs }),
133
141
  ...(item.maxCriticalMs === undefined ? {} : { maxCriticalMs: item.maxCriticalMs }),
134
142
  };
135
143
 
136
144
  return {
145
+ ...(item.id === undefined ? {} : { id: item.id }),
137
146
  url: validateHttpUrl(item.url, "target URL"),
138
147
  expectations,
139
148
  };
@@ -255,13 +264,22 @@ async function readConfig(path: string | undefined): Promise<FileConfig> {
255
264
 
256
265
  function uniqueTargets(targets: readonly AuditTarget[]): readonly AuditTarget[] {
257
266
  const seen = new Set<string>();
258
- return targets.filter((target) => {
267
+ const unique = targets.filter((target) => {
259
268
  if (seen.has(target.url)) {
260
269
  return false;
261
270
  }
262
271
  seen.add(target.url);
263
272
  return true;
264
273
  });
274
+ const ids = new Set<string>();
275
+ for (const target of unique) {
276
+ if (target.id === undefined) continue;
277
+ if (ids.has(target.id)) {
278
+ throw new ConfigError(`Target id ${target.id} is duplicated.`);
279
+ }
280
+ ids.add(target.id);
281
+ }
282
+ return unique;
265
283
  }
266
284
 
267
285
  export async function loadConfig(options: LoadConfigOptions = {}): Promise<SsrWireConfig> {
package/src/http-probe.ts CHANGED
@@ -55,6 +55,7 @@ function emptySignals(): DocumentSignals {
55
55
  descriptions: [],
56
56
  canonicals: [],
57
57
  robots: [],
58
+ socialMetadata: [],
58
59
  h1s: [],
59
60
  jsonLd: [],
60
61
  };
@@ -91,6 +92,9 @@ function redactSignals(signals: DocumentSignals, redaction: RedactionPlan): Docu
91
92
  descriptions: signals.descriptions.map(element),
92
93
  canonicals: signals.canonicals.map(element),
93
94
  robots: signals.robots.map(element),
95
+ ...(signals.socialMetadata === undefined
96
+ ? {}
97
+ : { socialMetadata: signals.socialMetadata.map(element) }),
94
98
  h1s: signals.h1s.map(element),
95
99
  ...(signals.firstMainText === undefined
96
100
  ? {}
package/src/index.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  export { BUILTIN_AGENTS, resolveAgent, resolveAgents } from "./agents.js";
2
2
  export { analyzeTarget, summarizeAudit } from "./analyze.js";
3
3
  export { runAudit } from "./audit.js";
4
+ export {
5
+ AUDIT_SCHEMA_VERSION,
6
+ AuditReportError,
7
+ parseAuditReport,
8
+ parseAuditReportText,
9
+ } from "./audit-report.js";
10
+ export { ComparisonError, compareAudits } from "./compare.js";
11
+ export {
12
+ renderComparisonHtml,
13
+ renderComparisonJson,
14
+ renderComparisonReport,
15
+ renderComparisonTerminal,
16
+ } from "./comparison-reporters.js";
4
17
  export { loadConfig, parseHeaderOption } from "./config.js";
5
18
  export { probeUrl } from "./http-probe.js";
6
19
  export { redactProbe } from "./redact.js";
@@ -9,9 +22,21 @@ export { createStreamInspector } from "./stream-parser.js";
9
22
  export type {
10
23
  AgentProfile,
11
24
  AgentStability,
25
+ AuditComparison,
26
+ AuditReportDescriptor,
12
27
  AuditResult,
13
28
  AuditSummary,
14
29
  AuditTarget,
30
+ CompareAuditOptions,
31
+ ComparisonChange,
32
+ ComparisonKind,
33
+ ComparisonReportFormat,
34
+ ComparisonScope,
35
+ ComparisonSummary,
36
+ ComparisonThresholds,
37
+ ComparisonTimelineEvent,
38
+ ComparisonTimelineLane,
39
+ ComparisonTimelineSnapshot,
15
40
  DocumentSignals,
16
41
  ElementLocation,
17
42
  ElementSignal,
@@ -27,10 +52,14 @@ export type {
27
52
  RobotsAudience,
28
53
  RobotsSignal,
29
54
  Severity,
55
+ SocialMetadataProperty,
56
+ SocialMetadataSignal,
30
57
  SsrWireConfig,
31
58
  StabilityTimings,
32
59
  StabilityVariants,
33
60
  TargetAuditResult,
61
+ TargetComparison,
62
+ TargetComparisonStatus,
34
63
  TargetExpectations,
35
64
  TimingMark,
36
65
  TimingStats,
package/src/redact.ts CHANGED
@@ -90,6 +90,11 @@ function redactSignals(signals: DocumentSignals, plan: RedactionPlan): DocumentS
90
90
  descriptions: signals.descriptions.map((signal) => redactElement(signal, plan)),
91
91
  canonicals: signals.canonicals.map((signal) => redactElement(signal, plan)),
92
92
  robots: signals.robots.map((signal) => redactElement(signal, plan)),
93
+ ...(signals.socialMetadata === undefined
94
+ ? {}
95
+ : {
96
+ socialMetadata: signals.socialMetadata.map((signal) => redactElement(signal, plan)),
97
+ }),
93
98
  h1s: signals.h1s.map((signal) => redactElement(signal, plan)),
94
99
  ...(signals.firstMainText === undefined
95
100
  ? {}
@@ -151,6 +156,7 @@ export function redactAudit(audit: AuditResult, secrets: readonly string[]): Aud
151
156
  results: audit.results.map((result) => ({
152
157
  target: {
153
158
  ...result.target,
159
+ ...(result.target.id === undefined ? {} : { id: redactText(result.target.id, plan) }),
154
160
  url: redactText(result.target.url, plan),
155
161
  expectations: {
156
162
  ...result.target.expectations,
package/src/reporters.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import {
2
+ effectiveTwitterCardSignal,
3
+ firstSocialSignal,
4
+ OPEN_GRAPH_REQUIRED_PROPERTIES,
5
+ TWITTER_CARD_REQUIRED_FIELDS,
6
+ } from "./social.js";
1
7
  import type {
2
8
  AgentStability,
3
9
  AuditResult,
@@ -86,6 +92,31 @@ function probeRow(probe: ProbeResult, showSample: boolean): readonly string[] {
86
92
  ];
87
93
  }
88
94
 
95
+ function formatSignalSet(signals: readonly (ElementSignal | undefined)[]): string {
96
+ const present = signals.filter((signal): signal is ElementSignal => signal !== undefined);
97
+ if (present.length < signals.length) return `${present.length}/${signals.length}`;
98
+ const arrivalMs = Math.max(...present.map((signal) => signal.atMs));
99
+ const location = present.some((signal) => signal.location === "body")
100
+ ? "body"
101
+ : present.some((signal) => signal.location === "document")
102
+ ? "document"
103
+ : "head";
104
+ return `${Math.round(arrivalMs)} ms/${location}`;
105
+ }
106
+
107
+ function socialRow(probe: ProbeResult, showSample: boolean): readonly string[] {
108
+ return [
109
+ ...(showSample ? [String(probe.sample ?? "—")] : []),
110
+ truncate(probe.agent.label, 24),
111
+ formatSignalSet(
112
+ OPEN_GRAPH_REQUIRED_PROPERTIES.map((property) => firstSocialSignal(probe.signals, property)),
113
+ ),
114
+ formatSignalSet(
115
+ TWITTER_CARD_REQUIRED_FIELDS.map((field) => effectiveTwitterCardSignal(probe.signals, field)),
116
+ ),
117
+ ];
118
+ }
119
+
89
120
  function stabilityRows(stability: readonly AgentStability[]): readonly (readonly string[])[] {
90
121
  const labels: Readonly<Record<keyof AgentStability["timings"], string>> = {
91
122
  headers: "Headers",
@@ -171,6 +202,21 @@ export function renderTerminal(audit: AuditResult, options: ReporterOptions = {}
171
202
  ),
172
203
  );
173
204
 
205
+ const showSocial =
206
+ result.target.expectations.requireOpenGraph === true ||
207
+ result.target.expectations.requireTwitterCard === true ||
208
+ result.probes.some((probe) => (probe.signals.socialMetadata?.length ?? 0) > 0);
209
+ if (showSocial) {
210
+ lines.push(
211
+ "",
212
+ "Social preview readiness",
213
+ renderTable(
214
+ [...(showSample ? ["Sample"] : []), "Agent", "Open Graph", "Twitter Card"],
215
+ result.probes.map((probe) => socialRow(probe, showSample)),
216
+ ),
217
+ );
218
+ }
219
+
174
220
  if (result.stability !== undefined) {
175
221
  const rows = stabilityRows(result.stability);
176
222
  if (rows.length > 0) {
package/src/social.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { DocumentSignals, SocialMetadataProperty, SocialMetadataSignal } from "./types.js";
2
+
3
+ export const SOCIAL_METADATA_PROPERTIES = [
4
+ "og:title",
5
+ "og:type",
6
+ "og:url",
7
+ "og:image",
8
+ "og:description",
9
+ "twitter:card",
10
+ "twitter:title",
11
+ "twitter:description",
12
+ "twitter:image",
13
+ ] as const satisfies readonly SocialMetadataProperty[];
14
+
15
+ export const OPEN_GRAPH_PROPERTIES = [
16
+ "og:title",
17
+ "og:type",
18
+ "og:url",
19
+ "og:image",
20
+ "og:description",
21
+ ] as const satisfies readonly SocialMetadataProperty[];
22
+
23
+ export const OPEN_GRAPH_REQUIRED_PROPERTIES = [
24
+ "og:title",
25
+ "og:type",
26
+ "og:url",
27
+ "og:image",
28
+ ] as const satisfies readonly SocialMetadataProperty[];
29
+
30
+ export const TWITTER_PROPERTIES = [
31
+ "twitter:card",
32
+ "twitter:title",
33
+ "twitter:description",
34
+ "twitter:image",
35
+ ] as const satisfies readonly SocialMetadataProperty[];
36
+
37
+ export const TWITTER_CARD_REQUIRED_FIELDS = ["card", "title", "description", "image"] as const;
38
+ export type TwitterCardField = (typeof TWITTER_CARD_REQUIRED_FIELDS)[number];
39
+
40
+ const SOCIAL_METADATA_PROPERTY_SET = new Set<string>(SOCIAL_METADATA_PROPERTIES);
41
+ const SOCIAL_URL_PROPERTY_SET = new Set<SocialMetadataProperty>([
42
+ "og:url",
43
+ "og:image",
44
+ "twitter:image",
45
+ ]);
46
+
47
+ export function isSocialMetadataProperty(
48
+ value: string | undefined,
49
+ ): value is SocialMetadataProperty {
50
+ return value !== undefined && SOCIAL_METADATA_PROPERTY_SET.has(value);
51
+ }
52
+
53
+ export function socialSignals(
54
+ signals: DocumentSignals,
55
+ property: SocialMetadataProperty,
56
+ ): readonly SocialMetadataSignal[] {
57
+ return (signals.socialMetadata ?? []).filter((signal) => signal.property === property);
58
+ }
59
+
60
+ export function firstSocialSignal(
61
+ signals: DocumentSignals,
62
+ property: SocialMetadataProperty,
63
+ ): SocialMetadataSignal | undefined {
64
+ return socialSignals(signals, property).find((signal) => signal.value.trim().length > 0);
65
+ }
66
+
67
+ export function effectiveTwitterCardSignal(
68
+ signals: DocumentSignals,
69
+ field: TwitterCardField,
70
+ ): SocialMetadataSignal | undefined {
71
+ if (field === "card") return firstSocialSignal(signals, "twitter:card");
72
+ if (field === "title") {
73
+ return firstSocialSignal(signals, "twitter:title") ?? firstSocialSignal(signals, "og:title");
74
+ }
75
+ if (field === "description") {
76
+ return (
77
+ firstSocialSignal(signals, "twitter:description") ??
78
+ firstSocialSignal(signals, "og:description")
79
+ );
80
+ }
81
+ return firstSocialSignal(signals, "twitter:image") ?? firstSocialSignal(signals, "og:image");
82
+ }
83
+
84
+ export function normalizeSocialValue(
85
+ property: SocialMetadataProperty,
86
+ value: string,
87
+ baseUrl: string,
88
+ ): string {
89
+ const normalized = value.trim().replace(/\s+/gu, " ");
90
+ if (normalized.length === 0) return "";
91
+ if (SOCIAL_URL_PROPERTY_SET.has(property)) {
92
+ try {
93
+ const url = new URL(normalized, baseUrl);
94
+ url.hash = "";
95
+ return url.href;
96
+ } catch {
97
+ return normalized;
98
+ }
99
+ }
100
+ return property === "og:type" || property === "twitter:card"
101
+ ? normalized.toLowerCase()
102
+ : normalized;
103
+ }
104
+
105
+ export function isAbsoluteHttpSocialUrl(value: string): boolean {
106
+ try {
107
+ const url = new URL(value.trim());
108
+ return (
109
+ (url.protocol === "http:" || url.protocol === "https:") &&
110
+ url.username.length === 0 &&
111
+ url.password.length === 0
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
116
+ }