@pseolint/core 0.3.2 → 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 (76) hide show
  1. package/README.md +49 -1
  2. package/dist/ai/triage.d.ts.map +1 -1
  3. package/dist/ai/triage.js +8 -1
  4. package/dist/ai/triage.js.map +1 -1
  5. package/dist/auditor.d.ts.map +1 -1
  6. package/dist/auditor.js +495 -130
  7. package/dist/auditor.js.map +1 -1
  8. package/dist/backpressure.d.ts +68 -0
  9. package/dist/backpressure.d.ts.map +1 -0
  10. package/dist/backpressure.js +81 -0
  11. package/dist/backpressure.js.map +1 -0
  12. package/dist/cache.d.ts +73 -0
  13. package/dist/cache.d.ts.map +1 -1
  14. package/dist/cache.js +258 -19
  15. package/dist/cache.js.map +1 -1
  16. package/dist/enrich-findings.d.ts.map +1 -1
  17. package/dist/enrich-findings.js +1 -14
  18. package/dist/enrich-findings.js.map +1 -1
  19. package/dist/fetch-observer.d.ts +97 -0
  20. package/dist/fetch-observer.d.ts.map +1 -0
  21. package/dist/fetch-observer.js +124 -0
  22. package/dist/fetch-observer.js.map +1 -0
  23. package/dist/formatters/console.d.ts +7 -9
  24. package/dist/formatters/console.d.ts.map +1 -1
  25. package/dist/formatters/console.js +218 -254
  26. package/dist/formatters/console.js.map +1 -1
  27. package/dist/formatters/html.d.ts +5 -1
  28. package/dist/formatters/html.d.ts.map +1 -1
  29. package/dist/formatters/html.js +352 -570
  30. package/dist/formatters/html.js.map +1 -1
  31. package/dist/formatters/index.d.ts +4 -1
  32. package/dist/formatters/index.d.ts.map +1 -1
  33. package/dist/formatters/index.js +1 -1
  34. package/dist/formatters/index.js.map +1 -1
  35. package/dist/formatters/json.d.ts +11 -1
  36. package/dist/formatters/json.d.ts.map +1 -1
  37. package/dist/formatters/json.js +5 -1
  38. package/dist/formatters/json.js.map +1 -1
  39. package/dist/formatters/markdown.d.ts +7 -1
  40. package/dist/formatters/markdown.d.ts.map +1 -1
  41. package/dist/formatters/markdown.js +77 -70
  42. package/dist/formatters/markdown.js.map +1 -1
  43. package/dist/index.d.ts +13 -8
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +6 -7
  46. package/dist/index.js.map +1 -1
  47. package/dist/rule-references.d.ts.map +1 -1
  48. package/dist/rule-references.js +0 -6
  49. package/dist/rule-references.js.map +1 -1
  50. package/dist/rules/content/unique-value.d.ts.map +1 -1
  51. package/dist/rules/content/unique-value.js +1 -0
  52. package/dist/rules/content/unique-value.js.map +1 -1
  53. package/dist/rules/scope.d.ts.map +1 -1
  54. package/dist/rules/scope.js +6 -14
  55. package/dist/rules/scope.js.map +1 -1
  56. package/dist/rules/tech/robots-sitemap-presence.d.ts +9 -1
  57. package/dist/rules/tech/robots-sitemap-presence.d.ts.map +1 -1
  58. package/dist/rules/tech/robots-sitemap-presence.js +14 -5
  59. package/dist/rules/tech/robots-sitemap-presence.js.map +1 -1
  60. package/dist/safe-mode-preset.d.ts +27 -0
  61. package/dist/safe-mode-preset.d.ts.map +1 -0
  62. package/dist/safe-mode-preset.js +54 -0
  63. package/dist/safe-mode-preset.js.map +1 -0
  64. package/dist/site-classifier.d.ts +83 -0
  65. package/dist/site-classifier.d.ts.map +1 -0
  66. package/dist/site-classifier.js +205 -0
  67. package/dist/site-classifier.js.map +1 -0
  68. package/dist/ssrf-guard.d.ts +96 -0
  69. package/dist/ssrf-guard.d.ts.map +1 -0
  70. package/dist/ssrf-guard.js +268 -0
  71. package/dist/ssrf-guard.js.map +1 -0
  72. package/dist/types.d.ts +171 -19
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/types.js +2 -1
  75. package/dist/types.js.map +1 -1
  76. package/package.json +2 -2
@@ -1,60 +1,28 @@
1
- const SEVERITY_ORDER = ["critical", "error", "warning", "info"];
2
- const SEVERITY_LABEL = {
1
+ const VERDICT_LABEL = {
2
+ ready: "Ready",
3
+ caution: "Caution",
4
+ concerning: "Concerning",
3
5
  critical: "Critical",
4
- error: "Error",
5
- warning: "Warning",
6
- info: "Info",
7
6
  };
8
- const SEVERITY_WEIGHT = {
9
- critical: 100,
10
- error: 50,
11
- warning: 10,
12
- info: 1,
7
+ const VERDICT_TONE = {
8
+ ready: "success",
9
+ caution: "warning",
10
+ concerning: "destructive",
11
+ critical: "critical",
12
+ };
13
+ const GRADE_TONE = {
14
+ A: "success",
15
+ B: "success",
16
+ C: "warning",
17
+ D: "destructive",
18
+ F: "critical",
19
+ };
20
+ const CATEGORY_LABEL = {
21
+ integrity: "Integrity",
22
+ discoverability: "Discoverability",
23
+ citation: "Citation",
24
+ data: "Data",
13
25
  };
14
- function severityTone(severity) {
15
- switch (severity) {
16
- case "critical":
17
- case "error":
18
- return "destructive";
19
- case "warning":
20
- return "warning";
21
- case "info":
22
- return "muted";
23
- }
24
- }
25
- function scoreTone(score) {
26
- if (score <= 40)
27
- return "success";
28
- if (score <= 69)
29
- return "warning";
30
- return "destructive";
31
- }
32
- function scoreVerdict(score) {
33
- if (score <= 20)
34
- return "Clean run";
35
- if (score <= 40)
36
- return "Low risk";
37
- if (score <= 69)
38
- return "Watch list";
39
- if (score <= 84)
40
- return "Elevated risk";
41
- return "Doorway garden";
42
- }
43
- function categoryTone(pct) {
44
- if (pct <= 40)
45
- return "success";
46
- if (pct <= 69)
47
- return "warning";
48
- return "destructive";
49
- }
50
- function effortLabel(effort) {
51
- switch (effort) {
52
- case "quick": return "quick fix";
53
- case "moderate": return "moderate";
54
- case "structural": return "structural";
55
- default: return effort;
56
- }
57
- }
58
26
  function escapeHtml(text) {
59
27
  return text
60
28
  .replace(/&/g, "&amp;")
@@ -64,542 +32,356 @@ function escapeHtml(text) {
64
32
  }
65
33
  function shortenUrl(url) {
66
34
  try {
67
- return new URL(url).pathname;
35
+ return new URL(url).pathname || "/";
68
36
  }
69
37
  catch {
70
38
  return url;
71
39
  }
72
40
  }
73
- function categoryLabel(name) {
74
- const map = {
75
- spam: "Spam signals",
76
- content: "Content quality",
77
- links: "Link graph",
78
- tech: "Technical SEO",
79
- data: "Data binding",
80
- schema: "Structured data",
81
- cannibal: "Cannibalisation",
82
- aeo: "AEO readiness",
83
- };
84
- return map[name] ?? name.charAt(0).toUpperCase() + name.slice(1);
85
- }
86
- /**
87
- * AEO-specific score band label. Low score = AI-Ready (site is citable by
88
- * LLMs in AI Overviews / answer engines); high score = Ghost (not discoverable).
89
- */
90
- function aeoScoreLabel(score) {
91
- if (score <= 20)
92
- return "AI-Ready";
93
- if (score <= 40)
94
- return "Partial";
95
- if (score <= 60)
96
- return "Vulnerable";
97
- if (score <= 80)
98
- return "Invisible";
99
- return "Ghost";
100
- }
101
- /** Group findings by ruleId. Preserves encounter order per bucket. */
102
- function groupByRule(findings) {
103
- const m = new Map();
104
- for (const f of findings) {
105
- const bucket = m.get(f.ruleId);
106
- if (bucket)
107
- bucket.push(f);
108
- else
109
- m.set(f.ruleId, [f]);
110
- }
111
- return m;
41
+ function severityTone(s) {
42
+ if (s === "critical" || s === "error")
43
+ return "destructive";
44
+ if (s === "warning")
45
+ return "warning";
46
+ return "muted";
112
47
  }
113
- function rankByImpact(findings) {
114
- const byRule = groupByRule(findings);
115
- const impacts = [];
116
- for (const [ruleId, items] of byRule) {
117
- const sev = items[0].severity;
118
- impacts.push({
119
- ruleId,
120
- severity: sev,
121
- pageCount: items.length,
122
- representative: items[0],
123
- impact: SEVERITY_WEIGHT[sev] * items.length,
124
- });
48
+ function effortLabel(effort) {
49
+ switch (effort) {
50
+ case "quick":
51
+ return "quick fix";
52
+ case "moderate":
53
+ return "moderate";
54
+ case "structural":
55
+ return "structural";
56
+ default:
57
+ return effort;
125
58
  }
126
- impacts.sort((a, b) => {
127
- if (b.impact !== a.impact)
128
- return b.impact - a.impact;
129
- const sa = SEVERITY_ORDER.indexOf(a.severity);
130
- const sb = SEVERITY_ORDER.indexOf(b.severity);
131
- if (sa !== sb)
132
- return sa - sb;
133
- if (b.pageCount !== a.pageCount)
134
- return b.pageCount - a.pageCount;
135
- return a.ruleId.localeCompare(b.ruleId);
136
- });
137
- return impacts;
138
59
  }
139
- function renderTriageHtml(triage) {
140
- const sorted = triage.rootCauses.slice().sort((a, b) => a.fixOrder - b.fixOrder);
141
- const cost = triage.estimatedCostUsd !== undefined ? ` · est $${triage.estimatedCostUsd.toFixed(2)}` : "";
142
- const cacheLabel = triage.cacheHit ? "cached" : "fresh";
143
- const tokens = `${triage.tokenUsage.input.toLocaleString()} in / ${triage.tokenUsage.output.toLocaleString()} out`;
144
- const causes = sorted.map((c) => `
145
- <li class="cause">
146
- <div class="cause-head">
147
- <span class="cause-order">${c.fixOrder}</span>
148
- <h3>${escapeHtml(c.label)}</h3>
149
- <span class="sev sev-${severityTone(c.severity)}">${escapeHtml(c.severity)}</span>
150
- </div>
151
- <p class="cause-meta">${c.findingsCount} findings · ${c.affectedRuleIds.map(escapeHtml).join(", ")}</p>
152
- <p class="cause-body">${escapeHtml(c.rationale)}</p>
153
- </li>`).join("");
154
- return `
155
- <section class="ai-triage">
156
- <header class="section-head">
157
- <span class="eyebrow">AI Triage</span>
158
- <span class="meta-mono">${escapeHtml(triage.modelUsed)} · ${cacheLabel} · ${tokens}${cost}</span>
159
- </header>
160
- ${triage.narrative ? `<p class="narrative">${escapeHtml(triage.narrative)}</p>` : ""}
161
- <ol class="causes">${causes}</ol>
162
- </section>`;
60
+ function renderCategoryTile(label, cell) {
61
+ const tone = GRADE_TONE[cell.grade];
62
+ const issuesLabel = cell.issues === 0 ? "no issues" : `${cell.issues} ${cell.issues === 1 ? "issue" : "issues"}`;
63
+ return `<div class="cat-tile cat-${tone}">
64
+ <div class="cat-grade">${escapeHtml(cell.grade)}</div>
65
+ <div class="cat-label">${escapeHtml(label)}</div>
66
+ <div class="cat-issues mono">${escapeHtml(issuesLabel)}</div>
67
+ </div>`;
163
68
  }
164
- function renderTopFixes(impacts) {
165
- if (impacts.length === 0)
166
- return "";
167
- const top = impacts.slice(0, 5);
168
- const rows = top.map((imp, idx) => {
169
- const r = imp.representative;
170
- const effortPill = r.effort
171
- ? `<span class="effort-pill effort-${escapeHtml(r.effort)}">${escapeHtml(effortLabel(r.effort))}</span>`
172
- : "";
173
- const pagesLabel = imp.pageCount === 1 ? "1 page" : `${imp.pageCount} pages`;
174
- return `<li class="top-fix">
175
- <span class="top-fix-rank">${idx + 1}</span>
176
- <div class="top-fix-body">
177
- <div class="top-fix-head">
178
- <code class="rule-id">${escapeHtml(imp.ruleId)}</code>
179
- <span class="sev sev-${severityTone(imp.severity)}">${escapeHtml(imp.severity)}</span>
180
- ${effortPill}
181
- <span class="top-fix-pages mono">${pagesLabel}</span>
182
- </div>
183
- <p class="top-fix-msg">${escapeHtml(r.message)}</p>
184
- </div>
185
- </li>`;
186
- }).join("");
187
- return `
188
- <section class="top-fixes">
189
- <header class="section-head">
190
- <span class="eyebrow">Top fixes by impact</span>
191
- <span class="meta-mono">severity × pages affected</span>
192
- </header>
193
- <ol class="top-fix-list">${rows}</ol>
194
- </section>`;
69
+ function renderCategoryTiles(categories) {
70
+ const order = [
71
+ "integrity",
72
+ "discoverability",
73
+ "citation",
74
+ "data",
75
+ ];
76
+ return order
77
+ .map((key) => {
78
+ const cell = categories[key];
79
+ if (!cell)
80
+ return "";
81
+ return renderCategoryTile(CATEGORY_LABEL[key], cell);
82
+ })
83
+ .join("");
195
84
  }
196
- function renderRuleCard(ruleId, items, sev) {
197
- const representative = items[0];
198
- const effortPill = representative.effort
199
- ? ` <span class="effort-pill effort-${escapeHtml(representative.effort)}">${escapeHtml(effortLabel(representative.effort))}</span>`
85
+ function renderFindingRow(f) {
86
+ const tone = severityTone(f.severity);
87
+ const effortPill = f.effort
88
+ ? ` <span class="effort-pill effort-${escapeHtml(f.effort)}">${escapeHtml(effortLabel(f.effort))}</span>`
200
89
  : "";
201
- const pagesLabel = items.length === 1 ? "1 page" : `${items.length} pages`;
202
- // Sample + full URL list only include findings that have a pageUrl.
203
- const urls = items.map((i) => i.pageUrl).filter((u) => !!u);
204
- let pageList = "";
205
- if (urls.length > 0) {
206
- const preview = urls.slice(0, 3);
207
- const rest = urls.slice(3);
208
- const previewHtml = `<ul class="page-preview">${preview.map((u) => `<li class="mono">${escapeHtml(shortenUrl(u))}</li>`).join("")}</ul>`;
209
- const restHtml = rest.length > 0
210
- ? `<details class="page-extra">
211
- <summary>${rest.length} more ${rest.length === 1 ? "page" : "pages"}</summary>
212
- <ul class="page-list">${rest.map((u) => `<li class="mono">${escapeHtml(shortenUrl(u))}</li>`).join("")}</ul>
213
- </details>`
214
- : "";
215
- pageList = previewHtml + restHtml;
216
- }
217
- // Cluster context (if any finding has it, show the first one — site-wide rules only fire once).
90
+ const url = f.pageUrl ? `<span class="finding-url mono">${escapeHtml(shortenUrl(f.pageUrl))}</span>` : "";
91
+ const fix = f.fix ? `<div class="fix"><span class="fix-label">Fix</span>${escapeHtml(f.fix)}</div>` : "";
92
+ const docsHref = f.docsUrl ?? `https://pseolint.dev/rules/${f.ruleId.split("/").pop() ?? f.ruleId}`;
93
+ // Cluster context (collapsed details) — only on findings that carry one.
218
94
  let cluster = "";
219
- const withCluster = items.find((i) => i.context?.type === "cluster");
220
- if (withCluster && withCluster.context?.type === "cluster") {
221
- const ctx = withCluster.context;
95
+ if (f.context?.type === "cluster") {
96
+ const ctx = f.context;
222
97
  const [minSim, maxSim] = ctx.similarityRange;
223
- const worstPairsHtml = ctx.worstPairs
224
- .map(p => `<li><span class="mono">${escapeHtml(shortenUrl(p.left))}</span> <span class="arrow">↔</span> <span class="mono">${escapeHtml(shortenUrl(p.right))}</span> <span class="sim">${(p.similarity * 100).toFixed(1)}%</span></li>`)
98
+ const worstPairs = ctx.worstPairs
99
+ .map((p) => `<li><span class="mono">${escapeHtml(shortenUrl(p.left))}</span> <span class="mono">${escapeHtml(shortenUrl(p.right))}</span> <span class="sim mono">${(p.similarity * 100).toFixed(1)}%</span></li>`)
225
100
  .join("");
226
- const membersHtml = ctx.members
227
- .map(m => `<li class="mono">${escapeHtml(shortenUrl(m))}</li>`)
101
+ const members = ctx.members
102
+ .map((m) => `<li class="mono">${escapeHtml(shortenUrl(m))}</li>`)
228
103
  .join("");
229
- cluster = `
230
- <details>
231
- <summary>${ctx.clusterSize} pages in cluster · ${(minSim * 100).toFixed(0)}–${(maxSim * 100).toFixed(0)}% similar</summary>
232
- <div class="cluster-body">
233
- <p class="cluster-label">Worst pairs</p>
234
- <ul class="pair-list">${worstPairsHtml}</ul>
235
- <p class="cluster-label">All members</p>
236
- <ul class="member-list">${membersHtml}</ul>
237
- </div>
238
- </details>`;
104
+ cluster = `<details class="cluster">
105
+ <summary>${ctx.clusterSize} pages in cluster · ${(minSim * 100).toFixed(0)}–${(maxSim * 100).toFixed(0)}% similar</summary>
106
+ <p class="cluster-label">Worst pairs</p>
107
+ <ul class="pair-list">${worstPairs}</ul>
108
+ <p class="cluster-label">All members</p>
109
+ <ul class="member-list">${members}</ul>
110
+ </details>`;
239
111
  }
240
- const fix = representative.fix ? `<div class="fix"><span class="fix-label">Fix</span>${escapeHtml(representative.fix)}</div>` : "";
241
- const ref = representative.ref ? `<a href="${escapeHtml(representative.ref)}" class="ref" target="_blank" rel="noopener">ref ↗</a>` : "";
242
- return `<li class="finding finding-${severityTone(sev)}">
243
- <div class="finding-head">
244
- <code class="rule-id">${escapeHtml(ruleId)}</code>${effortPill}
245
- <span class="finding-pages mono">${pagesLabel}</span>
246
- </div>
247
- <p class="finding-msg">${escapeHtml(representative.message)}</p>
248
- ${pageList}
249
- ${cluster}
250
- ${fix}
251
- ${ref}
112
+ return `<li class="finding finding-${tone}">
113
+ <div class="finding-head">
114
+ <code class="rule-id">${escapeHtml(f.ruleId)}</code>${effortPill}
115
+ ${url}
116
+ </div>
117
+ <p class="finding-msg">${escapeHtml(f.message)}</p>
118
+ ${fix}
119
+ ${cluster}
120
+ <a class="docs-link" href="${escapeHtml(docsHref)}" target="_blank" rel="noopener">${escapeHtml(docsHref.replace(/^https?:\/\//, ""))} ↗</a>
252
121
  </li>`;
253
122
  }
254
- export function formatHtml(summary) {
255
- const grouped = new Map();
256
- for (const sev of SEVERITY_ORDER)
257
- grouped.set(sev, []);
258
- for (const f of summary.findings)
259
- grouped.get(f.severity).push(f);
260
- const counts = {
261
- critical: grouped.get("critical").length,
262
- error: grouped.get("error").length,
263
- warning: grouped.get("warning").length,
264
- info: grouped.get("info").length,
265
- };
266
- const totalErrors = counts.critical + counts.error;
267
- const impacts = rankByImpact(summary.findings);
268
- const topFixesHtml = renderTopFixes(impacts);
269
- const categoryRows = Object.entries(summary.categoryScores)
270
- .map(([name, value]) => {
271
- const pct = value;
272
- const clean = Math.max(0, 100 - pct);
273
- const tone = categoryTone(pct);
274
- return `<tr>
275
- <td class="cat-name">${escapeHtml(categoryLabel(name))}</td>
276
- <td class="cat-bar"><div class="bar-bg"><div class="bar-fill bar-${tone}" style="width:${clean}%"></div></div></td>
277
- <td class="cat-val mono tabular">${pct}</td>
278
- </tr>`;
279
- })
123
+ function renderBucketSection(label, items, tone) {
124
+ if (items.length === 0) {
125
+ return `<section class="bucket bucket-empty">
126
+ <h2 class="bucket-heading bucket-${tone}"><span class="bucket-dot"></span>${escapeHtml(label)} <span class="bucket-count">none</span></h2>
127
+ </section>`;
128
+ }
129
+ const rows = items.map(renderFindingRow).join("");
130
+ return `<section class="bucket">
131
+ <h2 class="bucket-heading bucket-${tone}"><span class="bucket-dot"></span>${escapeHtml(label)} <span class="bucket-count">${items.length}</span></h2>
132
+ <ul class="finding-list">${rows}</ul>
133
+ </section>`;
134
+ }
135
+ function renderOriginReadinessCard(report) {
136
+ if (!report)
137
+ return "";
138
+ const verdictTone = report.verdict === "ready"
139
+ ? "success"
140
+ : report.verdict === "concerning"
141
+ ? "warning"
142
+ : "destructive";
143
+ const fw = report.detectedFramework ? ` · framework: ${escapeHtml(report.detectedFramework)}` : "";
144
+ return `<section class="card readiness">
145
+ <header class="section-head">
146
+ <span class="eyebrow">Origin readiness</span>
147
+ <span class="meta-mono">${report.liveFetchCount} live fetch${report.liveFetchCount === 1 ? "" : "es"}${fw}</span>
148
+ </header>
149
+ <div class="readiness-grid">
150
+ <div><span class="stat-label">Verdict</span><span class="stat-val tone-${verdictTone}">${escapeHtml(report.verdict)}</span></div>
151
+ <div><span class="stat-label">Median</span><span class="stat-val mono">${report.medianMs} ms</span></div>
152
+ <div><span class="stat-label">p95</span><span class="stat-val mono">${report.p95Ms} ms</span></div>
153
+ <div><span class="stat-label">5xx ratio</span><span class="stat-val mono">${(report.serverErrorRatio * 100).toFixed(1)}%</span></div>
154
+ <div><span class="stat-label">Cache assist</span><span class="stat-val mono">${(report.cacheAssistRatio * 100).toFixed(0)}%</span></div>
155
+ </div>
156
+ </section>`;
157
+ }
158
+ function renderTriageHtml(triage) {
159
+ const sorted = triage.rootCauses.slice().sort((a, b) => a.fixOrder - b.fixOrder);
160
+ const cost = triage.estimatedCostUsd !== undefined ? ` · est $${triage.estimatedCostUsd.toFixed(2)}` : "";
161
+ const cacheLabel = triage.cacheHit ? "cached" : "fresh";
162
+ const tokens = `${triage.tokenUsage.input.toLocaleString()} in / ${triage.tokenUsage.output.toLocaleString()} out`;
163
+ const causes = sorted
164
+ .map((c) => `
165
+ <li class="cause">
166
+ <div class="cause-head">
167
+ <span class="cause-order">${c.fixOrder}</span>
168
+ <h3>${escapeHtml(c.label)}</h3>
169
+ <span class="sev sev-${severityTone(c.severity)}">${escapeHtml(c.severity)}</span>
170
+ </div>
171
+ <p class="cause-meta">${c.findingsCount} findings · ${c.affectedRuleIds.map(escapeHtml).join(", ")}</p>
172
+ <p class="cause-body">${escapeHtml(c.rationale)}</p>
173
+ </li>`)
280
174
  .join("");
281
- const findingsSections = SEVERITY_ORDER.map((sev) => {
282
- const items = grouped.get(sev);
283
- if (items.length === 0)
284
- return "";
285
- const byRule = groupByRule(items);
286
- // Preserve rule-level ordering by rank (severity × pageCount) within the severity group.
287
- const orderedRuleIds = Array.from(byRule.entries())
288
- .sort((a, b) => {
289
- if (b[1].length !== a[1].length)
290
- return b[1].length - a[1].length;
291
- return a[0].localeCompare(b[0]);
292
- })
293
- .map(([ruleId]) => ruleId);
294
- const itemsHtml = orderedRuleIds.map((rid) => renderRuleCard(rid, byRule.get(rid), sev)).join("");
295
- const ruleCount = byRule.size;
296
- return `
297
- <div class="sev-group">
298
- <h3 class="sev-heading sev-${severityTone(sev)}">
299
- <span class="sev-dot"></span>${SEVERITY_LABEL[sev]}
300
- <span class="sev-count">${ruleCount} ${ruleCount === 1 ? "rule" : "rules"} · ${items.length} ${items.length === 1 ? "finding" : "findings"}</span>
301
- </h3>
302
- <ul class="finding-list">${itemsHtml}</ul>
303
- </div>`;
304
- }).join("");
305
- const tone = scoreTone(summary.score);
306
- const verdict = scoreVerdict(summary.score);
307
- const groupScoresHtml = summary.groupScores && summary.groupPageCounts ? `
308
- <section class="card">
309
- <header class="section-head">
310
- <span class="eyebrow">Group scores</span>
311
- </header>
312
- <table class="data-table">
313
- <thead><tr><th>Group</th><th>Score</th><th>Pages</th></tr></thead>
314
- <tbody>${Object.entries(summary.groupScores).map(([name, value]) => {
315
- const count = summary.groupPageCounts[name] ?? 0;
316
- const gTone = categoryTone(value);
317
- return `<tr>
318
- <td>${escapeHtml(name)}</td>
319
- <td class="mono tabular tone-${gTone}">${value}</td>
320
- <td class="mono tabular muted">${count}</td>
321
- </tr>`;
322
- }).join("")}</tbody>
323
- </table>
324
- </section>` : "";
325
- return `<!DOCTYPE html>
326
- <html lang="en">
327
- <head>
328
- <meta charset="utf-8">
329
- <meta name="viewport" content="width=device-width,initial-scale=1">
330
- <title>pseolint · audit report</title>
331
- <style>
332
- :root{
333
- --bg:#16181c; --fg:#eef2f6; --muted:#9aa4ae; --muted-2:#6a7380;
334
- --card:#1a1d22; --card-2:#1f2329; --border:#262a30; --border-strong:#3a3f47;
335
- --success:#39d19f; --warning:#fbb838; --destructive:#e94b4b; --primary:#39d19f;
336
- --r:18px; --r-lg:28px;
337
- }
338
- *{margin:0;padding:0;box-sizing:border-box}
339
- html{color-scheme:dark}
340
- body{
341
- font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
342
- background:var(--bg); color:var(--fg);
343
- font-feature-settings:"rlig" 1,"calt" 1;
344
- -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
345
- line-height:1.5;
346
- }
347
- main{max-width:960px;margin:0 auto;padding:56px 24px 80px}
348
- .mono{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
349
- .tabular{font-variant-numeric:tabular-nums}
350
- .display{font-family:"Instrument Serif","Times New Roman",Georgia,serif;font-style:italic;font-weight:400;letter-spacing:-0.01em}
351
- .muted{color:var(--muted)}
352
- .meta-mono{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;color:var(--muted);font-size:12px}
353
- .eyebrow{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
354
-
355
- .status-row{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;letter-spacing:0.14em;text-transform:uppercase}
356
- .status-dot{display:inline-block;width:6px;height:6px;border-radius:999px;background:var(--success)}
357
- .status-dot.tone-warning{background:var(--warning)} .status-dot.tone-destructive{background:var(--destructive)}
358
-
359
- h1.title{margin-top:12px;font-size:clamp(32px,5vw,56px);line-height:1;letter-spacing:-0.01em}
360
- .title-row{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 12px;margin-top:12px}
361
- .src-link{color:var(--muted);font-size:12px;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;text-decoration:none}
362
- .src-link:hover{color:var(--fg)}
363
- .lead{margin-top:12px;color:var(--muted);font-size:14px;max-width:640px}
364
-
365
- .card{margin-top:28px;padding:28px;background:color-mix(in oklab,var(--card) 85%,transparent);
366
- border:1px solid var(--border);border-radius:var(--r-lg)}
367
- .hero{display:grid;grid-template-columns:auto 1fr;gap:36px;align-items:center}
368
- @media (max-width:640px){.hero{grid-template-columns:1fr;gap:24px}}
369
- .score-block{display:flex;flex-direction:column;align-items:flex-start;min-width:180px}
370
- .score{font-family:"Instrument Serif","Times New Roman",Georgia,serif;font-weight:400;
371
- font-size:128px;line-height:0.9;font-variant-numeric:tabular-nums;letter-spacing:-0.02em}
372
- .tone-success{color:var(--success)} .tone-warning{color:var(--warning)} .tone-destructive{color:var(--destructive)}
373
- .score-label{margin-top:6px;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted)}
374
- .verdict{margin-top:14px;display:inline-flex;align-items:center;gap:8px;
375
- padding:5px 10px;border:1px solid var(--border-strong);border-radius:999px;
376
- background:var(--card-2);font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
377
- font-size:11px;color:var(--muted)}
378
- .verdict-dot{width:5px;height:5px;border-radius:999px;background:var(--success)}
379
-
380
- .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:18px 28px;margin-top:8px}
381
- @media (max-width:640px){.stats{grid-template-columns:repeat(2,minmax(0,1fr))}}
382
- .stat-label{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted)}
383
- .stat-val{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
384
- font-size:20px;font-variant-numeric:tabular-nums;margin-top:2px}
385
-
386
- .template-banner{margin-top:20px;padding:14px 18px;border:1px solid color-mix(in oklab,var(--warning) 40%,transparent);
387
- border-radius:var(--r);background:color-mix(in oklab,var(--warning) 8%,transparent);color:var(--fg);font-size:13px}
388
- .template-banner strong{color:var(--warning)}
389
-
390
- .section-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:18px}
391
- .data-table{width:100%;border-collapse:collapse}
392
- .data-table th,.data-table td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);font-size:14px}
393
- .data-table th{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
394
- .data-table tr:last-child td{border-bottom:0}
395
- .cat-name{width:40%}
396
- .cat-bar{width:auto}
397
- .cat-val{width:56px;text-align:right}
398
- .bar-bg{background:var(--border);border-radius:999px;height:8px;width:100%;overflow:hidden}
399
- .bar-fill{height:100%;border-radius:999px;transition:width .4s ease}
400
- .bar-success{background:var(--success)} .bar-warning{background:var(--warning)} .bar-destructive{background:var(--destructive)}
401
-
402
- .aeo-callout{margin-top:20px;padding:16px 20px;background:color-mix(in oklab,var(--card) 85%,transparent);
403
- border:1px solid var(--border);border-radius:var(--r-lg)}
404
- .aeo-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:10px}
405
- .aeo-body{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center}
406
- @media (max-width:640px){.aeo-body{grid-template-columns:1fr;gap:8px}}
407
- .aeo-label{font-family:"Instrument Serif","Times New Roman",Georgia,serif;font-style:italic;font-size:28px;line-height:1;letter-spacing:-0.01em}
408
- .aeo-bar{display:block;height:8px;background:var(--border);border-radius:999px;overflow:hidden}
409
- .aeo-bar .bar-fill{height:100%;display:block;border-radius:999px}
410
- .aeo-meta{font-size:11px;color:var(--muted)}
411
-
412
- .top-fixes{margin-top:28px;padding:24px 28px 28px;background:color-mix(in oklab,var(--primary) 6%,var(--card) 94%);
413
- border:1px solid color-mix(in oklab,var(--primary) 25%,var(--border));border-radius:var(--r-lg)}
414
- .top-fix-list{list-style:none;display:flex;flex-direction:column;gap:10px}
415
- .top-fix{display:grid;grid-template-columns:32px 1fr;gap:12px;align-items:start;padding:12px 14px;
416
- background:var(--card);border:1px solid var(--border);border-radius:14px}
417
- .top-fix-rank{display:inline-grid;place-items:center;width:24px;height:24px;border-radius:999px;
418
- background:color-mix(in oklab,var(--primary) 22%,transparent);color:var(--primary);
419
- font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:12px;font-weight:700;margin-top:1px}
420
- .top-fix-body{min-width:0}
421
- .top-fix-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:4px}
422
- .top-fix-pages{color:var(--muted);font-size:11px;margin-left:auto}
423
- .top-fix-msg{color:var(--fg);font-size:14px}
424
-
425
- .findings-head{display:flex;align-items:baseline;justify-content:space-between;margin:56px 0 18px}
426
- .findings-head h2{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
427
- .findings-head .meta{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:12px;color:var(--muted)}
428
-
429
- .sev-group{margin-top:28px}
430
- .sev-group:first-child{margin-top:0}
431
- .sev-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;margin-bottom:10px}
432
- .sev-dot{display:inline-block;width:8px;height:8px;border-radius:999px;background:currentColor}
433
- .sev-count{margin-left:auto;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
434
- font-size:12px;color:var(--muted);font-weight:400}
435
- .sev-destructive{color:var(--destructive)} .sev-warning{color:var(--warning)} .sev-muted{color:var(--muted)}
436
- .sev-heading.sev-muted{color:var(--muted-2)}
437
-
438
- .finding-list{list-style:none;column-count:2;column-gap:10px;column-fill:balance}
439
- @media (max-width:720px){.finding-list{column-count:1}}
440
- .finding{position:relative;padding:14px 16px 14px 18px;background:var(--card);
441
- border:1px solid var(--border);border-radius:var(--r);overflow:hidden;
442
- break-inside:avoid;-webkit-column-break-inside:avoid;page-break-inside:avoid;
443
- margin-bottom:10px;display:inline-block;width:100%}
444
- .finding::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--border-strong)}
445
- .finding-destructive::before{background:var(--destructive)}
446
- .finding-warning::before{background:var(--warning)}
447
- .finding-muted::before{background:var(--muted-2)}
448
- .finding-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:4px}
449
- .rule-id{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:12px;
450
- color:var(--fg);background:var(--card-2);padding:2px 8px;border-radius:6px;border:1px solid var(--border)}
451
- .finding-pages{color:var(--muted);font-size:11px;margin-left:auto}
452
- .finding-msg{color:var(--fg);font-size:14px}
453
- .page-preview{list-style:none;display:flex;flex-direction:column;gap:2px;margin-top:8px;font-size:12px;color:var(--muted)}
454
- .page-extra{margin-top:6px}
455
- .page-extra>summary{cursor:pointer;color:var(--muted);font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
456
- font-size:12px;list-style:none;padding:4px 0}
457
- .page-extra>summary::-webkit-details-marker{display:none}
458
- .page-extra>summary::before{content:"▸ ";color:var(--muted-2)}
459
- .page-extra[open]>summary::before{content:"▾ "}
460
- .page-list{list-style:none;display:flex;flex-direction:column;gap:2px;font-size:12px;color:var(--muted);
461
- max-height:240px;overflow-y:auto;padding:6px 0 0}
462
-
463
- .effort-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;
464
- font-size:10px;font-weight:600;letter-spacing:0.04em;text-transform:lowercase;
465
- border:1px solid transparent}
466
- .effort-quick{background:color-mix(in oklab,var(--success) 14%,transparent);
467
- color:var(--success);border-color:color-mix(in oklab,var(--success) 35%,transparent)}
468
- .effort-moderate{background:color-mix(in oklab,var(--warning) 14%,transparent);
469
- color:var(--warning);border-color:color-mix(in oklab,var(--warning) 35%,transparent)}
470
- .effort-structural{background:color-mix(in oklab,var(--destructive) 14%,transparent);
471
- color:var(--destructive);border-color:color-mix(in oklab,var(--destructive) 35%,transparent)}
472
-
473
- .sev{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;font-size:10px;
474
- font-weight:600;text-transform:lowercase;letter-spacing:0.04em;border:1px solid transparent}
475
- .sev-destructive.sev,.sev.sev-destructive{background:color-mix(in oklab,var(--destructive) 14%,transparent);color:var(--destructive);border-color:color-mix(in oklab,var(--destructive) 35%,transparent)}
476
- .sev-warning.sev,.sev.sev-warning{background:color-mix(in oklab,var(--warning) 14%,transparent);color:var(--warning);border-color:color-mix(in oklab,var(--warning) 35%,transparent)}
477
- .sev-muted.sev,.sev.sev-muted{background:var(--card-2);color:var(--muted);border-color:var(--border-strong)}
478
-
479
- .fix{margin-top:8px;padding:10px 12px;background:var(--card-2);border-radius:10px;
480
- color:var(--muted);font-size:13px;line-height:1.55}
481
- .fix-label{display:inline-block;margin-right:8px;padding:1px 6px;border-radius:4px;
482
- background:color-mix(in oklab,var(--primary) 18%,transparent);color:var(--primary);
483
- font-size:10px;letter-spacing:0.08em;text-transform:uppercase;font-weight:700;vertical-align:1px}
484
- .ref{display:inline-block;margin-top:8px;color:var(--primary);font-size:12px;text-decoration:none}
485
- .ref:hover{text-decoration:underline}
486
-
487
- .finding details{margin-top:10px;border:1px solid var(--border);border-radius:12px;background:var(--card-2)}
488
- .finding details.page-extra{border:0;background:transparent;margin-top:6px}
489
- .finding details>summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px;
490
- font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;list-style:none}
491
- .finding details.page-extra>summary{padding:4px 0}
492
- .finding details>summary::-webkit-details-marker{display:none}
493
- .finding details>summary::before{content:"▸ ";color:var(--muted-2)}
494
- .finding details[open]>summary::before{content:"▾ "}
495
- .cluster-body{padding:0 14px 14px;border-top:1px solid var(--border)}
496
- .cluster-label{margin:12px 0 6px;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
497
- .pair-list,.member-list{list-style:none;display:flex;flex-direction:column;gap:4px;font-size:12px}
498
- .member-list{max-height:220px;overflow-y:auto;padding-right:4px}
499
- .arrow{color:var(--muted-2)}
500
- .sim{color:var(--warning);font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
501
-
502
- .ai-triage{margin-top:28px;padding:28px;background:color-mix(in oklab,var(--card) 85%,transparent);
503
- border:1px solid var(--border);border-radius:var(--r-lg)}
504
- .ai-triage .narrative{color:var(--fg);font-size:15px;line-height:1.6;margin-bottom:18px}
505
- .causes{list-style:none;display:flex;flex-direction:column;gap:12px}
506
- .cause{padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:14px}
507
- .cause-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
508
- .cause-order{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:999px;
509
- background:color-mix(in oklab,var(--primary) 20%,transparent);color:var(--primary);
510
- font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:11px;font-weight:700}
511
- .cause h3{font-size:15px;font-weight:600}
512
- .cause-meta{color:var(--muted);font-size:12px;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;margin-bottom:6px}
513
- .cause-body{color:var(--fg);font-size:14px;line-height:1.55}
514
-
515
- .footer-note{margin-top:56px;padding:20px 22px;border:1px solid var(--border);border-radius:var(--r-lg);
516
- background:color-mix(in oklab,var(--card) 60%,transparent);color:var(--muted);font-size:12px;line-height:1.6}
517
- .footer-note strong{color:var(--fg);font-weight:600}
518
-
519
- ::selection{background:color-mix(in oklab,var(--primary) 75%,transparent);color:#0b1a14}
520
- ::-webkit-scrollbar{width:8px;height:8px}
521
- ::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:999px}
522
- </style>
523
- </head>
524
- <body>
525
- <main>
526
- <div class="status-row">
527
- <span class="status-dot tone-${tone}"></span>
528
- pseolint · audit complete
529
- </div>
530
-
531
- <div class="title-row">
532
- <h1 class="title display">Audit report</h1>
533
- <span class="src-link">${summary.pageCount} pages · ${summary.findings.length} findings</span>
534
- </div>
535
- <p class="lead">Scored against 40+ rules covering Google's public SpamBrain guidance, programmatic-SEO research, and Answer Engine Optimization (AEO) — how citable your pages are to LLMs in AI Overviews. Risk score — lower is safer.</p>
536
-
537
- <section class="card hero">
538
- <div class="score-block">
539
- <span class="score tone-${tone}">${summary.score}</span>
540
- <span class="score-label">Risk score · /100</span>
541
- <span class="verdict"><span class="verdict-dot tone-${tone}" style="background:var(--${tone})"></span>${escapeHtml(verdict)}</span>
542
- </div>
543
- <dl class="stats">
544
- <div><dt class="stat-label">Pages</dt><dd class="stat-val">${summary.pageCount}</dd></div>
545
- <div><dt class="stat-label">Errors</dt><dd class="stat-val tone-destructive">${totalErrors}</dd></div>
546
- <div><dt class="stat-label">Warnings</dt><dd class="stat-val tone-warning">${counts.warning}</dd></div>
547
- <div><dt class="stat-label">Info</dt><dd class="stat-val muted">${counts.info}</dd></div>
548
- </dl>
549
- </section>
550
-
551
- ${summary.templateDetected ? `<div class="template-banner"><strong>Template-generated content detected.</strong> Fix suggestions are tailored for template authors — one change can fix hundreds of pages.</div>` : ""}
552
-
553
- ${(() => {
554
- const aeoScore = summary.categoryScores?.aeo;
555
- if (aeoScore === undefined)
556
- return "";
557
- const aeoTone = categoryTone(aeoScore);
558
- const label = aeoScoreLabel(aeoScore);
559
- const invert = 100 - aeoScore;
560
- return `
561
- <section class="aeo-callout">
562
- <div class="aeo-head">
563
- <span class="eyebrow">AEO readiness</span>
564
- <span class="meta-mono">answer-engine citability</span>
565
- </div>
566
- <div class="aeo-body">
567
- <span class="aeo-label tone-${aeoTone}">${escapeHtml(label)}</span>
568
- <span class="aeo-bar"><span class="bar-fill bar-${aeoTone}" style="width:${invert}%"></span></span>
569
- <span class="aeo-meta mono">${invert}/100 ready · ${aeoScore}/100 risk</span>
570
- </div>
175
+ return `
176
+ <section class="ai-triage">
177
+ <header class="section-head">
178
+ <span class="eyebrow">AI Triage</span>
179
+ <span class="meta-mono">${escapeHtml(triage.modelUsed)} · ${cacheLabel} · ${tokens}${cost}</span>
180
+ </header>
181
+ ${triage.narrative ? `<p class="narrative">${escapeHtml(triage.narrative)}</p>` : ""}
182
+ <ol class="causes">${causes}</ol>
571
183
  </section>`;
572
- })()}
573
-
574
- ${topFixesHtml}
575
-
576
- <section class="card">
577
- <header class="section-head">
578
- <span class="eyebrow">Category scores</span>
579
- <span class="meta-mono">bar = how clean · number = risk (lower is better)</span>
580
- </header>
581
- <table class="data-table">
582
- <tbody>${categoryRows}</tbody>
583
- </table>
584
- </section>
585
-
586
- ${groupScoresHtml}
587
-
588
- ${summary.triage ? renderTriageHtml(summary.triage) : ""}
589
-
590
- <div class="findings-head">
591
- <h2>Findings · ${summary.findings.length}</h2>
592
- <span class="meta">grouped by rule · sampled ${summary.pageCount} page${summary.pageCount === 1 ? "" : "s"}</span>
593
- </div>
594
- ${findingsSections}
595
-
596
- <section class="footer-note">
597
- <strong>About this report.</strong> Score is a structured heuristic, not a verdict from Google. Categories are weighted equally. Severities escalate: info → warning → error → critical. Effort tags (<span class="effort-pill effort-quick">quick fix</span> <span class="effort-pill effort-moderate">moderate</span> <span class="effort-pill effort-structural">structural</span>) estimate the change cost per finding. "Top fixes by impact" ranks by severity × pages affected — the same heuristic Pro uses for its fix queue (which also factors Search Console impressions once connected).
598
- <br><br>
599
- <strong>Analytics-safe.</strong> Rendered audits intercept outbound beacons to 40+ telemetry endpoints (GA, PostHog, Mixpanel, Segment, Hotjar, Sentry, Cloudflare/Vercel Insights, etc.) so your dashboards stay untouched — no fake sessions, no polluted funnels.
600
- </section>
601
- </main>
602
- </body>
184
+ }
185
+ export function formatHtml(summary, _options) {
186
+ const verdict = summary.verdict;
187
+ const verdictTone = VERDICT_TONE[verdict];
188
+ const issues = summary.issues;
189
+ const crawl = summary.diagnostics?.crawlStats;
190
+ const readiness = summary.diagnostics?.originReadiness;
191
+ const crawlMeta = crawl
192
+ ? `${crawl.fetched} fetched · ${crawl.discovered} discovered · ${crawl.skipped} skipped`
193
+ : `${summary.pageCount} pages`;
194
+ return `<!DOCTYPE html>
195
+ <html lang="en">
196
+ <head>
197
+ <meta charset="utf-8">
198
+ <meta name="viewport" content="width=device-width,initial-scale=1">
199
+ <title>pseolint · audit report</title>
200
+ <style>
201
+ :root{
202
+ --bg:#16181c; --fg:#eef2f6; --muted:#9aa4ae; --muted-2:#6a7380;
203
+ --card:#1a1d22; --card-2:#1f2329; --border:#262a30; --border-strong:#3a3f47;
204
+ --success:#39d19f; --warning:#fbb838; --destructive:#e94b4b; --critical:#c0185a; --primary:#39d19f;
205
+ --r:18px; --r-lg:28px;
206
+ }
207
+ *{margin:0;padding:0;box-sizing:border-box}
208
+ html{color-scheme:dark}
209
+ body{
210
+ font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
211
+ background:var(--bg); color:var(--fg);
212
+ -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
213
+ line-height:1.5;
214
+ }
215
+ main{max-width:960px;margin:0 auto;padding:56px 24px 80px}
216
+ .mono{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
217
+ .tabular{font-variant-numeric:tabular-nums}
218
+ .display{font-family:"Instrument Serif","Times New Roman",Georgia,serif;font-style:italic;font-weight:400;letter-spacing:-0.01em}
219
+ .muted{color:var(--muted)}
220
+ .meta-mono{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;color:var(--muted);font-size:12px}
221
+ .eyebrow{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
222
+
223
+ .status-row{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;letter-spacing:0.14em;text-transform:uppercase}
224
+ .status-dot{display:inline-block;width:6px;height:6px;border-radius:999px;background:var(--success)}
225
+ .status-dot.tone-warning{background:var(--warning)} .status-dot.tone-destructive{background:var(--destructive)} .status-dot.tone-critical{background:var(--critical)}
226
+
227
+ h1.title{margin-top:12px;font-size:clamp(32px,5vw,56px);line-height:1;letter-spacing:-0.01em}
228
+ .title-row{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 12px;margin-top:12px}
229
+ .src-link{color:var(--muted);font-size:12px;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
230
+ .lead{margin-top:12px;color:var(--muted);font-size:14px;max-width:640px}
231
+
232
+ .card{margin-top:28px;padding:28px;background:color-mix(in oklab,var(--card) 85%,transparent);
233
+ border:1px solid var(--border);border-radius:var(--r-lg)}
234
+
235
+ /* Verdict badge */
236
+ .verdict-badge{display:inline-flex;align-items:center;gap:10px;padding:10px 18px;border-radius:999px;
237
+ border:1px solid var(--border-strong);background:var(--card-2);
238
+ font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:14px;font-weight:600;
239
+ text-transform:uppercase;letter-spacing:0.08em}
240
+ .verdict-badge.tone-success{color:var(--success);border-color:color-mix(in oklab,var(--success) 45%,var(--border))}
241
+ .verdict-badge.tone-warning{color:var(--warning);border-color:color-mix(in oklab,var(--warning) 45%,var(--border))}
242
+ .verdict-badge.tone-destructive{color:var(--destructive);border-color:color-mix(in oklab,var(--destructive) 45%,var(--border))}
243
+ .verdict-badge.tone-critical{color:var(--critical);border-color:color-mix(in oklab,var(--critical) 45%,var(--border));background:color-mix(in oklab,var(--critical) 8%,var(--card-2))}
244
+ .verdict-dot{width:8px;height:8px;border-radius:999px;background:currentColor}
245
+ .verdict-headline{display:block;margin-top:14px;font-size:18px;color:var(--fg)}
246
+
247
+ /* Category tiles */
248
+ .cat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin-top:24px}
249
+ @media (max-width:640px){.cat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
250
+ .cat-tile{padding:18px 16px;border-radius:var(--r);background:var(--card);border:1px solid var(--border);
251
+ display:flex;flex-direction:column;align-items:flex-start;gap:6px}
252
+ .cat-grade{font-family:"Instrument Serif","Times New Roman",Georgia,serif;font-style:italic;font-size:48px;line-height:1;font-variant-numeric:tabular-nums}
253
+ .cat-tile.cat-success .cat-grade{color:var(--success)}
254
+ .cat-tile.cat-warning .cat-grade{color:var(--warning)}
255
+ .cat-tile.cat-destructive .cat-grade{color:var(--destructive)}
256
+ .cat-tile.cat-critical .cat-grade{color:var(--critical)}
257
+ .cat-tile.cat-success{border-color:color-mix(in oklab,var(--success) 30%,var(--border))}
258
+ .cat-tile.cat-warning{border-color:color-mix(in oklab,var(--warning) 30%,var(--border))}
259
+ .cat-tile.cat-destructive{border-color:color-mix(in oklab,var(--destructive) 30%,var(--border))}
260
+ .cat-tile.cat-critical{border-color:color-mix(in oklab,var(--critical) 30%,var(--border))}
261
+ .cat-label{font-size:13px;font-weight:600;color:var(--fg)}
262
+ .cat-issues{font-size:11px;color:var(--muted)}
263
+
264
+ .template-banner{margin-top:20px;padding:14px 18px;border:1px solid color-mix(in oklab,var(--warning) 40%,transparent);
265
+ border-radius:var(--r);background:color-mix(in oklab,var(--warning) 8%,transparent);font-size:13px}
266
+ .template-banner strong{color:var(--warning)}
267
+
268
+ /* Origin readiness card */
269
+ .readiness-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-top:8px}
270
+ @media (max-width:720px){.readiness-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
271
+ .readiness-grid>div{display:flex;flex-direction:column;gap:2px}
272
+ .stat-label{font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted)}
273
+ .stat-val{font-size:18px;font-variant-numeric:tabular-nums}
274
+ .tone-success{color:var(--success)} .tone-warning{color:var(--warning)} .tone-destructive{color:var(--destructive)} .tone-critical{color:var(--critical)}
275
+
276
+ .section-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:18px}
277
+
278
+ /* Buckets */
279
+ .bucket{margin-top:36px}
280
+ .bucket-empty{opacity:0.55}
281
+ .bucket-heading{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;margin-bottom:14px;
282
+ text-transform:uppercase;letter-spacing:0.08em}
283
+ .bucket-dot{display:inline-block;width:8px;height:8px;border-radius:999px;background:currentColor}
284
+ .bucket-count{margin-left:auto;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;
285
+ font-size:12px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0}
286
+ .bucket-destructive{color:var(--destructive)}
287
+ .bucket-warning{color:var(--warning)}
288
+ .bucket-muted{color:var(--muted-2)}
289
+
290
+ .finding-list{list-style:none;display:flex;flex-direction:column;gap:10px}
291
+ .finding{position:relative;padding:14px 16px 14px 18px;background:var(--card);
292
+ border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
293
+ .finding::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--border-strong)}
294
+ .finding-destructive::before{background:var(--destructive)}
295
+ .finding-warning::before{background:var(--warning)}
296
+ .finding-muted::before{background:var(--muted-2)}
297
+ .finding-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:4px}
298
+ .rule-id{font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:12px;
299
+ color:var(--fg);background:var(--card-2);padding:2px 8px;border-radius:6px;border:1px solid var(--border)}
300
+ .finding-url{color:var(--muted);font-size:12px;margin-left:auto}
301
+ .finding-msg{color:var(--fg);font-size:14px}
302
+
303
+ .effort-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;
304
+ font-size:10px;font-weight:600;letter-spacing:0.04em;text-transform:lowercase;border:1px solid transparent}
305
+ .effort-quick{background:color-mix(in oklab,var(--success) 14%,transparent);color:var(--success);border-color:color-mix(in oklab,var(--success) 35%,transparent)}
306
+ .effort-moderate{background:color-mix(in oklab,var(--warning) 14%,transparent);color:var(--warning);border-color:color-mix(in oklab,var(--warning) 35%,transparent)}
307
+ .effort-structural{background:color-mix(in oklab,var(--destructive) 14%,transparent);color:var(--destructive);border-color:color-mix(in oklab,var(--destructive) 35%,transparent)}
308
+
309
+ .sev{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;font-size:10px;font-weight:600;text-transform:lowercase;letter-spacing:0.04em;border:1px solid transparent}
310
+ .sev.sev-destructive{background:color-mix(in oklab,var(--destructive) 14%,transparent);color:var(--destructive);border-color:color-mix(in oklab,var(--destructive) 35%,transparent)}
311
+ .sev.sev-warning{background:color-mix(in oklab,var(--warning) 14%,transparent);color:var(--warning);border-color:color-mix(in oklab,var(--warning) 35%,transparent)}
312
+ .sev.sev-muted{background:var(--card-2);color:var(--muted);border-color:var(--border-strong)}
313
+
314
+ .fix{margin-top:8px;padding:10px 12px;background:var(--card-2);border-radius:10px;color:var(--muted);font-size:13px;line-height:1.55}
315
+ .fix-label{display:inline-block;margin-right:8px;padding:1px 6px;border-radius:4px;
316
+ background:color-mix(in oklab,var(--primary) 18%,transparent);color:var(--primary);
317
+ font-size:10px;letter-spacing:0.08em;text-transform:uppercase;font-weight:700;vertical-align:1px}
318
+ .docs-link{display:inline-block;margin-top:8px;color:var(--primary);font-size:12px;text-decoration:none;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}
319
+ .docs-link:hover{text-decoration:underline}
320
+
321
+ details.cluster{margin-top:10px;border:1px solid var(--border);border-radius:12px;background:var(--card-2);padding:0 14px 14px}
322
+ details.cluster>summary{cursor:pointer;padding:10px 0;color:var(--muted);font-size:12px;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;list-style:none}
323
+ details.cluster>summary::-webkit-details-marker{display:none}
324
+ details.cluster>summary::before{content:"▸ ";color:var(--muted-2)}
325
+ details.cluster[open]>summary::before{content:"▾ "}
326
+ .cluster-label{margin:12px 0 6px;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--muted);font-weight:600}
327
+ .pair-list,.member-list{list-style:none;display:flex;flex-direction:column;gap:4px;font-size:12px}
328
+ .member-list{max-height:220px;overflow-y:auto;padding-right:4px}
329
+ .sim{color:var(--warning)}
330
+
331
+ .ai-triage{margin-top:28px;padding:28px;background:color-mix(in oklab,var(--card) 85%,transparent);
332
+ border:1px solid var(--border);border-radius:var(--r-lg)}
333
+ .ai-triage .narrative{color:var(--fg);font-size:15px;line-height:1.6;margin-bottom:18px}
334
+ .causes{list-style:none;display:flex;flex-direction:column;gap:12px}
335
+ .cause{padding:14px 16px;background:var(--card);border:1px solid var(--border);border-radius:14px}
336
+ .cause-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
337
+ .cause-order{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:999px;
338
+ background:color-mix(in oklab,var(--primary) 20%,transparent);color:var(--primary);
339
+ font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;font-size:11px;font-weight:700}
340
+ .cause h3{font-size:15px;font-weight:600}
341
+ .cause-meta{color:var(--muted);font-size:12px;font-family:ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace;margin-bottom:6px}
342
+ .cause-body{color:var(--fg);font-size:14px;line-height:1.55}
343
+
344
+ .footer-note{margin-top:56px;padding:20px 22px;border:1px solid var(--border);border-radius:var(--r-lg);
345
+ background:color-mix(in oklab,var(--card) 60%,transparent);color:var(--muted);font-size:12px;line-height:1.6}
346
+ .footer-note strong{color:var(--fg);font-weight:600}
347
+ </style>
348
+ </head>
349
+ <body>
350
+ <main>
351
+ <div class="status-row">
352
+ <span class="status-dot tone-${verdictTone}"></span>
353
+ pseolint · audit complete
354
+ </div>
355
+
356
+ <div class="title-row">
357
+ <h1 class="title display">Audit report</h1>
358
+ <span class="src-link">${escapeHtml(crawlMeta)}</span>
359
+ </div>
360
+ <p class="lead">Schema ${escapeHtml(summary.schemaVersion)}. Verdict, category grades, and bucketed issues — see docs links per finding for what each rule checks.</p>
361
+
362
+ <section class="card">
363
+ <span class="verdict-badge tone-${verdictTone}"><span class="verdict-dot"></span>${escapeHtml(VERDICT_LABEL[verdict])}</span>
364
+ <span class="verdict-headline">${escapeHtml(summary.headline)}</span>
365
+ <div class="cat-grid">${renderCategoryTiles(summary.categories)}</div>
366
+ </section>
367
+
368
+ ${summary.templateDetected ? `<div class="template-banner"><strong>Template-generated content detected.</strong> Fix suggestions are tailored for template authors — one change can fix hundreds of pages.</div>` : ""}
369
+
370
+ ${renderOriginReadinessCard(readiness)}
371
+
372
+ ${renderBucketSection("Blockers", issues.blockers, "destructive")}
373
+ ${renderBucketSection("Should fix", issues.shouldFix, "warning")}
374
+ ${renderBucketSection("Informational", issues.informational, "muted")}
375
+
376
+ ${summary.triage ? renderTriageHtml(summary.triage) : ""}
377
+
378
+ <section class="footer-note">
379
+ <strong>About this report.</strong> Verdict and grades are structured heuristics, not a verdict from Google. Severities escalate: info → warning → error → critical. Effort tags
380
+ (<span class="effort-pill effort-quick">quick fix</span> <span class="effort-pill effort-moderate">moderate</span> <span class="effort-pill effort-structural">structural</span>)
381
+ estimate the change cost per finding. Findings are bucketed: <em>blockers</em> must be fixed before shipping, <em>should fix</em> before scaling, <em>informational</em> for trend-watching.
382
+ </section>
383
+ </main>
384
+ </body>
603
385
  </html>`;
604
386
  }
605
387
  //# sourceMappingURL=html.js.map