@pseolint/core 0.2.2 → 0.3.1

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 (77) hide show
  1. package/README.md +10 -9
  2. package/dist/ai/prompt.d.ts +1 -1
  3. package/dist/ai/prompt.d.ts.map +1 -1
  4. package/dist/ai/prompt.js +13 -1
  5. package/dist/ai/prompt.js.map +1 -1
  6. package/dist/ai/triage.d.ts +15 -1
  7. package/dist/ai/triage.d.ts.map +1 -1
  8. package/dist/ai/triage.js +30 -0
  9. package/dist/ai/triage.js.map +1 -1
  10. package/dist/analytics-blocklist.d.ts +28 -0
  11. package/dist/analytics-blocklist.d.ts.map +1 -0
  12. package/dist/analytics-blocklist.js +129 -0
  13. package/dist/analytics-blocklist.js.map +1 -0
  14. package/dist/auditor.d.ts.map +1 -1
  15. package/dist/auditor.js +130 -46
  16. package/dist/auditor.js.map +1 -1
  17. package/dist/formatters/console.d.ts +9 -0
  18. package/dist/formatters/console.d.ts.map +1 -1
  19. package/dist/formatters/console.js +53 -0
  20. package/dist/formatters/console.js.map +1 -1
  21. package/dist/formatters/html.d.ts.map +1 -1
  22. package/dist/formatters/html.js +557 -144
  23. package/dist/formatters/html.js.map +1 -1
  24. package/dist/index.d.ts +14 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +12 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/renderer.d.ts +14 -0
  29. package/dist/renderer.d.ts.map +1 -1
  30. package/dist/renderer.js +130 -4
  31. package/dist/renderer.js.map +1 -1
  32. package/dist/rule-references.d.ts.map +1 -1
  33. package/dist/rule-references.js +9 -0
  34. package/dist/rule-references.js.map +1 -1
  35. package/dist/rules/aeo/answer-first.d.ts +18 -0
  36. package/dist/rules/aeo/answer-first.d.ts.map +1 -0
  37. package/dist/rules/aeo/answer-first.js +191 -0
  38. package/dist/rules/aeo/answer-first.js.map +1 -0
  39. package/dist/rules/aeo/citable-facts.d.ts +9 -0
  40. package/dist/rules/aeo/citable-facts.d.ts.map +1 -0
  41. package/dist/rules/aeo/citable-facts.js +90 -0
  42. package/dist/rules/aeo/citable-facts.js.map +1 -0
  43. package/dist/rules/aeo/content-modularity.d.ts +11 -0
  44. package/dist/rules/aeo/content-modularity.d.ts.map +1 -0
  45. package/dist/rules/aeo/content-modularity.js +107 -0
  46. package/dist/rules/aeo/content-modularity.js.map +1 -0
  47. package/dist/rules/aeo/crawler-access.d.ts +25 -0
  48. package/dist/rules/aeo/crawler-access.d.ts.map +1 -0
  49. package/dist/rules/aeo/crawler-access.js +116 -0
  50. package/dist/rules/aeo/crawler-access.js.map +1 -0
  51. package/dist/rules/aeo/faq-coverage.d.ts +9 -0
  52. package/dist/rules/aeo/faq-coverage.d.ts.map +1 -0
  53. package/dist/rules/aeo/faq-coverage.js +71 -0
  54. package/dist/rules/aeo/faq-coverage.js.map +1 -0
  55. package/dist/rules/aeo/freshness-signals.d.ts +9 -0
  56. package/dist/rules/aeo/freshness-signals.d.ts.map +1 -0
  57. package/dist/rules/aeo/freshness-signals.js +109 -0
  58. package/dist/rules/aeo/freshness-signals.js.map +1 -0
  59. package/dist/rules/aeo/llms-txt.d.ts +24 -0
  60. package/dist/rules/aeo/llms-txt.d.ts.map +1 -0
  61. package/dist/rules/aeo/llms-txt.js +93 -0
  62. package/dist/rules/aeo/llms-txt.js.map +1 -0
  63. package/dist/rules/aeo/non-replicable-value.d.ts +9 -0
  64. package/dist/rules/aeo/non-replicable-value.d.ts.map +1 -0
  65. package/dist/rules/aeo/non-replicable-value.js +95 -0
  66. package/dist/rules/aeo/non-replicable-value.js.map +1 -0
  67. package/dist/rules/aeo/summary-bait.d.ts +20 -0
  68. package/dist/rules/aeo/summary-bait.d.ts.map +1 -0
  69. package/dist/rules/aeo/summary-bait.js +147 -0
  70. package/dist/rules/aeo/summary-bait.js.map +1 -0
  71. package/dist/rules/scope.d.ts +12 -0
  72. package/dist/rules/scope.d.ts.map +1 -0
  73. package/dist/rules/scope.js +67 -0
  74. package/dist/rules/scope.js.map +1 -0
  75. package/dist/types.d.ts +30 -0
  76. package/dist/types.d.ts.map +1 -1
  77. package/package.json +3 -3
@@ -1,24 +1,59 @@
1
1
  const SEVERITY_ORDER = ["critical", "error", "warning", "info"];
2
- function severityColor(severity) {
2
+ const SEVERITY_LABEL = {
3
+ critical: "Critical",
4
+ error: "Error",
5
+ warning: "Warning",
6
+ info: "Info",
7
+ };
8
+ const SEVERITY_WEIGHT = {
9
+ critical: 100,
10
+ error: 50,
11
+ warning: 10,
12
+ info: 1,
13
+ };
14
+ function severityTone(severity) {
3
15
  switch (severity) {
4
16
  case "critical":
5
- return "#dc2626";
6
17
  case "error":
7
- return "#ea580c";
18
+ return "destructive";
8
19
  case "warning":
9
- return "#ca8a04";
20
+ return "warning";
10
21
  case "info":
11
- return "#2563eb";
22
+ return "muted";
12
23
  }
13
24
  }
14
- function scoreColor(score) {
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) {
15
33
  if (score <= 20)
16
- return "#16a34a";
34
+ return "Clean run";
17
35
  if (score <= 40)
18
- return "#ca8a04";
19
- if (score <= 60)
20
- return "#ea580c";
21
- return "#dc2626";
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
+ }
22
57
  }
23
58
  function escapeHtml(text) {
24
59
  return text
@@ -27,14 +62,6 @@ function escapeHtml(text) {
27
62
  .replace(/>/g, "&gt;")
28
63
  .replace(/"/g, "&quot;");
29
64
  }
30
- function effortColor(effort) {
31
- switch (effort) {
32
- case "quick": return "#16a34a";
33
- case "moderate": return "#ca8a04";
34
- case "structural": return "#dc2626";
35
- default: return "#64748b";
36
- }
37
- }
38
65
  function shortenUrl(url) {
39
66
  try {
40
67
  return new URL(url).pathname;
@@ -43,150 +70,536 @@ function shortenUrl(url) {
43
70
  return url;
44
71
  }
45
72
  }
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;
112
+ }
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
+ });
125
+ }
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
+ }
46
139
  function renderTriageHtml(triage) {
47
140
  const sorted = triage.rootCauses.slice().sort((a, b) => a.fixOrder - b.fixOrder);
48
- const cost = triage.estimatedCostUsd !== undefined ? `, est $${triage.estimatedCostUsd.toFixed(2)}` : "";
49
- const cacheLabel = triage.cacheHit ? "cached" : "cache miss";
50
- const causes = sorted.map((c) => `
51
- <li>
52
- <h3>${c.fixOrder}. ${escapeHtml(c.label)}</h3>
53
- <p class="meta">${escapeHtml(c.severity)} &middot; ${c.findingsCount} findings &middot; ${c.affectedRuleIds.map(escapeHtml).join(", ")}</p>
54
- <p>${escapeHtml(c.rationale)}</p>
55
- </li>`).join("\n");
56
- return `
57
- <section class="ai-triage">
58
- <header>
59
- <h2>AI Triage</h2>
60
- <p class="meta">${escapeHtml(triage.modelUsed)} (${cacheLabel}) &mdash; ${triage.tokenUsage.input.toLocaleString()} in / ${triage.tokenUsage.output.toLocaleString()} out${cost}</p>
61
- </header>
62
- ${triage.narrative ? `<p class="narrative">${escapeHtml(triage.narrative)}</p>` : ""}
63
- <ol>${causes}</ol>
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>`;
163
+ }
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>
64
194
  </section>`;
65
195
  }
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>`
200
+ : "";
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).
218
+ let cluster = "";
219
+ const withCluster = items.find((i) => i.context?.type === "cluster");
220
+ if (withCluster && withCluster.context?.type === "cluster") {
221
+ const ctx = withCluster.context;
222
+ 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>`)
225
+ .join("");
226
+ const membersHtml = ctx.members
227
+ .map(m => `<li class="mono">${escapeHtml(shortenUrl(m))}</li>`)
228
+ .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>`;
239
+ }
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}
252
+ </li>`;
253
+ }
66
254
  export function formatHtml(summary) {
67
255
  const grouped = new Map();
68
- for (const sev of SEVERITY_ORDER) {
256
+ for (const sev of SEVERITY_ORDER)
69
257
  grouped.set(sev, []);
70
- }
71
- for (const f of summary.findings) {
258
+ for (const f of summary.findings)
72
259
  grouped.get(f.severity).push(f);
73
- }
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);
74
269
  const categoryRows = Object.entries(summary.categoryScores)
75
270
  .map(([name, value]) => {
76
- const label = name.charAt(0).toUpperCase() + name.slice(1);
77
271
  const pct = value;
78
- return `<tr>
79
- <td>${escapeHtml(label)}</td>
80
- <td>
81
- <div class="bar-bg"><div class="bar-fill" style="width:${pct}%;background:${scoreColor(pct)}"></div></div>
82
- </td>
83
- <td>${pct}</td>
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>
84
278
  </tr>`;
85
279
  })
86
- .join("\n");
280
+ .join("");
87
281
  const findingsSections = SEVERITY_ORDER.map((sev) => {
88
282
  const items = grouped.get(sev);
89
283
  if (items.length === 0)
90
284
  return "";
91
- const itemsHtml = items
92
- .map((item) => {
93
- let li = `<li><strong>${escapeHtml(item.ruleId)}</strong>`;
94
- if (item.effort) {
95
- li += ` <span class="effort-pill" style="background:${effortColor(item.effort)}">${escapeHtml(item.effort)}</span>`;
96
- }
97
- li += `: ${escapeHtml(item.message)}`;
98
- if (item.context?.type === "cluster") {
99
- const ctx = item.context;
100
- const [minSim, maxSim] = ctx.similarityRange;
101
- const worstPairsHtml = ctx.worstPairs
102
- .map(p => `<li>${escapeHtml(shortenUrl(p.left))} &#8596; ${escapeHtml(shortenUrl(p.right))} (${(p.similarity * 100).toFixed(1)}%)</li>`)
103
- .join("\n");
104
- const membersHtml = ctx.members
105
- .map(m => `<li>${escapeHtml(shortenUrl(m))}</li>`)
106
- .join("\n");
107
- li += `
108
- <details>
109
- <summary>${ctx.clusterSize} pages in cluster (${(minSim * 100).toFixed(0)}&ndash;${(maxSim * 100).toFixed(0)}% similar)</summary>
110
- <div class="cluster-details">
111
- <strong>Worst pairs:</strong>
112
- <ul>${worstPairsHtml}</ul>
113
- <strong>All members:</strong>
114
- <ul class="member-list">${membersHtml}</ul>
115
- </div>
116
- </details>`;
117
- }
118
- if (item.fix) {
119
- li += `<div class="fix">Fix: ${escapeHtml(item.fix)}</div>`;
120
- }
121
- if (item.ref) {
122
- li += ` <a href="${escapeHtml(item.ref)}" class="ref" target="_blank">Ref</a>`;
123
- }
124
- li += `</li>`;
125
- return li;
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]);
126
292
  })
127
- .join("\n");
128
- return `<h3 style="color:${severityColor(sev)}">${sev.charAt(0).toUpperCase() + sev.slice(1)} (${items.length})</h3>
129
- <ul>${itemsHtml}</ul>`;
130
- }).join("\n");
131
- return `<!DOCTYPE html>
132
- <html lang="en">
133
- <head>
134
- <meta charset="utf-8">
135
- <meta name="viewport" content="width=device-width,initial-scale=1">
136
- <title>pSEOlint Audit Report</title>
137
- <style>
138
- *{margin:0;padding:0;box-sizing:border-box}
139
- body{font-family:system-ui,-apple-system,sans-serif;max-width:800px;margin:0 auto;padding:2rem;color:#1e293b;background:#f8fafc}
140
- h1{margin-bottom:.5rem}
141
- h2{margin-top:1.5rem;margin-bottom:.5rem;border-bottom:1px solid #e2e8f0;padding-bottom:.25rem}
142
- h3{margin-top:1rem;margin-bottom:.25rem}
143
- table{width:100%;border-collapse:collapse;margin:.5rem 0}
144
- th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #e2e8f0}
145
- th{font-weight:600}
146
- td:last-child{text-align:right;width:3rem}
147
- .score{font-size:2rem;font-weight:700}
148
- .meta{color:#64748b;margin-bottom:1rem}
149
- .bar-bg{background:#e2e8f0;border-radius:4px;height:14px;width:100%}
150
- .bar-fill{height:100%;border-radius:4px;transition:width .3s}
151
- ul{list-style:disc;padding-left:1.5rem;margin-bottom:.5rem}
152
- li{margin:.2rem 0}
153
- .fix{color:#64748b;font-size:.9em;margin-top:.2rem}
154
- .ref{color:#2563eb;font-size:.85em}
155
- .effort-pill{display:inline-block;padding:.1rem .4rem;border-radius:9999px;color:white;font-size:.75em;font-weight:600;vertical-align:middle}
156
- .template-banner{background:#fef3c7;border:1px solid #f59e0b;border-radius:6px;padding:.5rem 1rem;margin:.5rem 0;color:#92400e;font-size:.9em}
157
- details{margin:.25rem 0}
158
- summary{cursor:pointer;color:#2563eb;font-size:.9em}
159
- .cluster-details{padding:.5rem;background:#f1f5f9;border-radius:4px;margin:.25rem 0;font-size:.85em}
160
- .cluster-details ul{margin:.25rem 0}
161
- .member-list{max-height:200px;overflow-y:auto}
162
- </style>
163
- </head>
164
- <body>
165
- <h1>pSEOlint Audit Report</h1>
166
- <p class="meta">Pages analysed: ${summary.pageCount}</p>
167
- <p class="score" style="color:${scoreColor(summary.score)}">SpamBrain Risk Score: ${summary.score}/100</p>
168
- ${summary.templateDetected ? `<p class="template-banner">Template-generated content detected. Fix suggestions are tailored for template authors.</p>` : ""}
169
- <h2>Category Scores</h2>
170
- <table>
171
- <thead><tr><th>Category</th><th>Bar</th><th>Score</th></tr></thead>
172
- <tbody>${categoryRows}</tbody>
173
- </table>
174
-
175
- ${summary.groupScores && summary.groupPageCounts ? `
176
- <h2>Group Scores</h2>
177
- <table>
178
- <thead><tr><th>Group</th><th>Score</th><th>Pages</th></tr></thead>
179
- <tbody>${Object.entries(summary.groupScores).map(([name, value]) => {
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]) => {
180
315
  const count = summary.groupPageCounts[name] ?? 0;
181
- return `<tr><td>${escapeHtml(name)}</td><td style="text-align:right">${value}</td><td style="text-align:right">${count}</td></tr>`;
182
- }).join("\n")}</tbody>
183
- </table>` : ""}
184
-
185
- ${summary.triage ? renderTriageHtml(summary.triage) : ""}
186
-
187
- <h2>Findings</h2>
188
- ${findingsSections}
189
- </body>
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>
571
+ </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>
190
603
  </html>`;
191
604
  }
192
605
  //# sourceMappingURL=html.js.map