@analitikisler/ai-tag-debugger 0.17.0 → 0.18.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.
@@ -0,0 +1,300 @@
1
+ // The report's one inline script, allowed by its hash in the page's CSP and the MCP server's header. Every behavior
2
+ // hangs off data attributes (CONTRACT-IMPL.md lists them); every word it shows comes from the markup, so the script is
3
+ // the same in every language and its hash never changes with the report's content.
4
+ import { createHash } from "node:crypto";
5
+
6
+ // ---------- shell: routing, rail, tabs, filters, single select, expand all, theme ----------
7
+ const SHELL = `/* Direction D shell: routing, rail, tabs, filters, single select, expand all, theme. The only script on the page,
8
+ so the product can hash it for its CSP; every behavior hangs off data attributes documented in NOTES-shell.md. */
9
+ (function () {
10
+ "use strict";
11
+ var html = document.documentElement, body = document.body;
12
+ html.classList.add("js");
13
+ var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
14
+ var byId = function (s) { return s ? document.getElementById(s.replace(/^#/, "")) : null; };
15
+ var main = byId("main"), sections = $$("main > .sec");
16
+ var store = function (k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } };
17
+ var typing = function (el) { return el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)); };
18
+ var on = function (el, attr) { return el.getAttribute(attr) === "true"; };
19
+ var step = function (list, i, key, prev, next) { // arrow, Home and End movement through a list, wrapping
20
+ var j = key === next ? i + 1 : key === prev ? i - 1 : key === "Home" ? 0 : key === "End" ? list.length - 1 : null;
21
+ return j === null || !list.length ? null : list[(j + list.length) % list.length];
22
+ };
23
+
24
+ /* ---------- tabs: [data-tabs] > [role=tablist] > [role=tab][aria-controls] ---------- */
25
+ var enabled = function (t) { return !t.disabled && t.getAttribute("aria-disabled") !== "true"; };
26
+ var tabsOf = function (box) { var l = box.querySelector("[role=tablist]"); return l ? $$("[role=tab]", l) : []; };
27
+ var tabIndex = function (box) { return box ? tabsOf(box).findIndex(function (t) { return on(t, "aria-selected"); }) : -1; };
28
+ function selectTab(tab, focus) {
29
+ tabsOf(tab.closest("[data-tabs]")).forEach(function (t) {
30
+ var sel = t === tab, p = byId(t.getAttribute("aria-controls"));
31
+ t.setAttribute("aria-selected", sel); t.tabIndex = sel ? 0 : -1;
32
+ if (p) p.hidden = !sel;
33
+ });
34
+ if (focus) tab.focus();
35
+ }
36
+ $$("[data-tabs]").forEach(function (box, b) {
37
+ box.classList.add("js");
38
+ var tabs = tabsOf(box);
39
+ tabs.forEach(function (t, i) {
40
+ var p = byId(t.getAttribute("aria-controls"));
41
+ if (!t.id) t.id = "tab-" + b + "-" + i;
42
+ if (p && !p.hasAttribute("aria-labelledby")) p.setAttribute("aria-labelledby", t.id);
43
+ });
44
+ var first = tabs.filter(function (t) { return on(t, "aria-selected") && enabled(t); })[0] || tabs.filter(enabled)[0];
45
+ if (first) selectTab(first);
46
+ });
47
+
48
+ /* ---------- single select: [data-inspector] holds [data-select-target] buttons and [data-select-pane] panes ---------- */
49
+ var mine = function (sel, insp) { return $$(sel, insp).filter(function (n) { return n.parentElement.closest("[data-inspector]") === insp; }); };
50
+ var itemsOf = function (insp) { return mine("[data-select-target]", insp); };
51
+ var currentPane = function (insp) { var v = mine("[data-select-pane]", insp).filter(function (p) { return !p.hidden; }); return v.length === 1 ? v[0] : null; };
52
+ // The tab chosen in the old pane (Hits, Data layer, Requests) carries over to the new one, by position.
53
+ function selectPane(insp, pane, tabAt) {
54
+ var old = currentPane(insp);
55
+ if (tabAt === undefined && old && old !== pane) tabAt = tabIndex(old.querySelector("[data-tabs]"));
56
+ mine("[data-select-pane]", insp).forEach(function (p) { p.hidden = p !== pane; });
57
+ itemsOf(insp).forEach(function (b) { if (byId(b.getAttribute("data-select-target")) === pane) b.setAttribute("aria-current", "true"); else b.removeAttribute("aria-current"); });
58
+ var box = pane.querySelector("[data-tabs]"), t = box && tabAt >= 0 ? tabsOf(box)[tabAt] : null;
59
+ if (t && enabled(t)) selectTab(t);
60
+ }
61
+ $$("[data-inspector]").forEach(function (insp) {
62
+ var panes = mine("[data-select-pane]", insp), btn = mine("[data-select-target][data-selected]", insp)[0];
63
+ var first = panes.filter(function (p) { return p.hasAttribute("data-selected"); })[0] || (btn && byId(btn.getAttribute("data-select-target"))) || panes.filter(function (p) { return p.hasAttribute("data-flagged"); })[0] || panes[0];
64
+ if (first) selectPane(insp, first);
65
+ });
66
+ // [data-vp-switch] buttons each show one inspector ([data-vp-target]) and keep the step and tab of the one they replace.
67
+ function pressVp(btn, keep) {
68
+ var from = null, to = byId(btn.getAttribute("data-vp-target"));
69
+ $$("[data-vp-target]", btn.closest("[data-vp-switch]")).forEach(function (b) {
70
+ var insp = byId(b.getAttribute("data-vp-target"));
71
+ if (insp && !insp.hidden && b !== btn) from = insp;
72
+ b.setAttribute("aria-pressed", b === btn);
73
+ if (insp) insp.hidden = b !== btn;
74
+ });
75
+ if (!keep || !from || !to) return;
76
+ var src = itemsOf(from), i = src.findIndex(function (b) { return b.hasAttribute("aria-current"); });
77
+ var key = i >= 0 && src[i].getAttribute("data-step"), dst = itemsOf(to);
78
+ var match = (key && dst.filter(function (b) { return b.getAttribute("data-step") === key; })[0]) || dst[Math.min(Math.max(i, 0), dst.length - 1)];
79
+ var pane = currentPane(from);
80
+ if (match) selectPane(to, byId(match.getAttribute("data-select-target")), tabIndex(pane && pane.querySelector("[data-tabs]")));
81
+ }
82
+ $$("[data-vp-switch]").forEach(function (sw) {
83
+ var bs = $$("[data-vp-target]", sw);
84
+ var first = bs.filter(function (b) { return on(b, "aria-pressed"); })[0] || bs[0];
85
+ if (first) pressVp(first, false);
86
+ });
87
+
88
+ /* ---------- filters: .filterbar[data-filterbar="#LIST"]; rows are the [data-text] elements of LIST ---------- */
89
+ var bars = $$("[data-filterbar]").map(function (bar) {
90
+ var list = document.querySelector(bar.getAttribute("data-filterbar"));
91
+ if (!list) return null;
92
+ var rows = $$("[data-text]", list), chips = $$("[data-filter]", bar), search = bar.querySelector("[data-filter-search]");
93
+ var count = bar.querySelector("[data-filter-count]"), tpl = count && (count.getAttribute("data-filter-count") || (main && main.getAttribute("data-filter-count")) || "{n}/{m}");
94
+ var groups = $$("[data-filter-group]", list).map(function (g) { return { el: g, open: g.open }; });
95
+ var clear = bar.querySelector("[data-filter-clear]"), timer;
96
+ if (!clear) { clear = document.createElement("button"); clear.type = "button"; clear.className = "btn fb-clear"; clear.setAttribute("data-filter-clear", ""); clear.textContent = bar.getAttribute("data-clear-label") || (main && main.getAttribute("data-clear-label")) || "×"; bar.insertBefore(clear, count); }
97
+ var kv = function (c) { var s = c.getAttribute("data-filter"), i = s.indexOf("="); return [s.slice(0, i), s.slice(i + 1)]; };
98
+ var has = function (row, k, vals) { var toks = (row.getAttribute("data-" + k) || "").split(/\\s+/); return vals.some(function (v) { return toks.indexOf(v) >= 0; }); };
99
+ // A row the baseline accepted is listed but not counted, as the tally behind Summary, the rail and the headings.
100
+ var counted = function (r) { return !r.hasAttribute("data-accepted"); }, total = rows.filter(counted).length;
101
+ chips.forEach(function (c) { // static count of the rows this chip alone would show
102
+ if (!c.hasAttribute("data-count-for")) return;
103
+ var p = kv(c), n = c.querySelector(".n") || c.appendChild(document.createElement("span"));
104
+ n.className = "n"; n.textContent = rows.filter(function (r) { return counted(r) && has(r, p[0], [p[1]]); }).length;
105
+ });
106
+ function pressed() { var o = {}; chips.forEach(function (c) { if (on(c, "aria-pressed")) { var p = kv(c); (o[p[0]] = o[p[0]] || []).push(p[1]); } }); return o; }
107
+ function apply(write) {
108
+ var o = pressed(), keys = Object.keys(o), q = search ? search.value.trim() : "", ql = q.toLowerCase(), shown = 0, visible = 0, active = keys.length > 0 || !!q;
109
+ rows.forEach(function (r) {
110
+ var ok = (!ql || (r.getAttribute("data-text") || "").toLowerCase().indexOf(ql) >= 0) && keys.every(function (k) { return has(r, k, o[k]); });
111
+ r.hidden = !ok; if (ok) { visible++; if (counted(r)) shown++; }
112
+ });
113
+ // With a filter on, a group with visible rows opens and one without hides; off, back to default. Visible rows
114
+ // include accepted ones, so a filter that matches only those still shows them; the heading counts as the tally.
115
+ groups.forEach(function (g) {
116
+ var vis = $$("[data-text]", g.el).filter(function (r) { return !r.hidden; });
117
+ g.el.hidden = active && !vis.length;
118
+ if (g.el.tagName === "DETAILS") g.el.open = active ? vis.length > 0 : g.open;
119
+ $$("[data-group-count]", g.el).forEach(function (c) { c.textContent = vis.filter(counted).length; });
120
+ });
121
+ if (count) count.textContent = tpl.replace("{n}", shown).replace("{m}", total);
122
+ $$("[data-filter-empty]", list).forEach(function (e) { e.hidden = visible > 0; });
123
+ clear.hidden = !active;
124
+ var sec = bar.closest("main > .sec");
125
+ if (write && sec) history.replaceState(null, "", "#" + sec.id + (active ? "?" + keys.map(function (k) { return k + "=" + o[k].join(","); }).concat(q ? ["q=" + encodeURIComponent(q)] : []).join("&") : ""));
126
+ }
127
+ function set(params, write) { // params: { key: [values], q: text }; {} clears
128
+ chips.forEach(function (c) { var p = kv(c); c.setAttribute("aria-pressed", !!(params[p[0]] && params[p[0]].indexOf(p[1]) >= 0)); });
129
+ if (search) search.value = params.q || "";
130
+ apply(write);
131
+ }
132
+ bar.addEventListener("click", function (e) {
133
+ var c = e.target.closest("[data-filter]");
134
+ if (c && enabled(c)) { c.setAttribute("aria-pressed", !on(c, "aria-pressed")); apply(true); }
135
+ else if (e.target.closest("[data-filter-clear]")) { set({}, true); if (search) search.focus(); }
136
+ });
137
+ if (search) search.addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(function () { apply(true); }, 150); });
138
+ apply(false);
139
+ return { bar: bar, list: list, set: set };
140
+ }).filter(Boolean);
141
+
142
+ /* ---------- routing: #section, #section?key=v1,v2&q=text, #element-id ---------- */
143
+ var mock = document.querySelector(".mock-switch");
144
+ function show(sec) {
145
+ var state = sec.id.indexOf("failed-") === 0 ? "failed" : "demo";
146
+ sections.forEach(function (s) { s.hidden = s !== sec; });
147
+ body.setAttribute("data-state", state);
148
+ $$("[data-only]").forEach(function (n) { n.hidden = n.getAttribute("data-only") !== state; });
149
+ if (mock) { mock.setAttribute("aria-pressed", state === "failed"); mock.querySelector(".m-val").textContent = mock.getAttribute(state === "failed" ? "data-on" : "data-off"); }
150
+ $$(".rail a").forEach(function (a) { if (a.getAttribute("href") === "#" + sec.id) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); });
151
+ }
152
+ function reveal(el) {
153
+ var chain = [];
154
+ for (var n = el; n && n !== main; n = n.parentElement) chain.unshift(n);
155
+ chain.forEach(function (n) { // outermost first, so an inner tab choice wins over the tab a pane carries over
156
+ if (n.hidden && n.hasAttribute("data-text")) bars.forEach(function (b) { if (b.list.contains(n)) b.set({}, false); });
157
+ if (n.getAttribute("role") === "tabpanel") { var t = document.querySelector('[role=tab][aria-controls="' + n.id + '"]'); if (t) selectTab(t); }
158
+ if (n.hasAttribute("data-inspector") && n.hidden) { var v = document.querySelector('[data-vp-target="#' + n.id + '"]'); if (v) pressVp(v, false); }
159
+ if (n.hasAttribute("data-select-pane")) selectPane(n.parentElement.closest("[data-inspector]"), n);
160
+ if (n.tagName === "DETAILS") n.open = true;
161
+ });
162
+ var inner = el.querySelector(":scope > details");
163
+ if (inner) inner.open = true;
164
+ var f = el;
165
+ if (!f.matches("a[href],button,summary,input,[tabindex]")) f.setAttribute("tabindex", "-1");
166
+ el.scrollIntoView({ block: "start" });
167
+ f.classList.add("revealed");
168
+ f.addEventListener("blur", function () { f.classList.remove("revealed"); }, { once: true });
169
+ f.focus({ preventScroll: true });
170
+ el.classList.add("flash");
171
+ setTimeout(function () { el.classList.remove("flash"); }, 1500);
172
+ }
173
+ function route(nav) {
174
+ var h = location.hash.slice(1), qi = h.indexOf("?"), el;
175
+ try { el = byId(decodeURIComponent(qi < 0 ? h : h.slice(0, qi))); } catch (err) { el = null; }
176
+ var sec = el && el.closest("main > .sec");
177
+ if (!sec) { el = sec = byId("summary") || sections[0]; qi = -1; if (!sec) return; }
178
+ show(sec);
179
+ if (el !== sec) return reveal(el);
180
+ var params = {};
181
+ if (qi >= 0) h.slice(qi + 1).split("&").forEach(function (p) {
182
+ var i = p.indexOf("="), k = p.slice(0, i), v = p.slice(i + 1);
183
+ try { v = decodeURIComponent(v); } catch (err) { /* keep the raw text */ }
184
+ if (i > 0) params[k] = k === "q" ? v : v.split(",");
185
+ });
186
+ bars.forEach(function (b) { if (sec.contains(b.bar)) b.set(params, false); });
187
+ window.scrollTo(0, 0);
188
+ var h2 = sec.querySelector("h2");
189
+ if (nav && h2) { h2.setAttribute("tabindex", "-1"); h2.focus({ preventScroll: true }); }
190
+ }
191
+ var go = function (hash) { if (location.hash === hash) route(true); else location.hash = hash; };
192
+ window.addEventListener("hashchange", function () { route(true); });
193
+
194
+ /* ---------- rail collapse ---------- */
195
+ var railBtn = document.querySelector("[data-rail-toggle]");
196
+ function setRail(collapsed, save) {
197
+ html.setAttribute("data-rail", collapsed ? "collapsed" : "expanded");
198
+ if (railBtn) {
199
+ var label = railBtn.getAttribute(collapsed ? "data-expand" : "data-collapse");
200
+ railBtn.setAttribute("aria-expanded", !collapsed); railBtn.setAttribute("aria-label", label); railBtn.setAttribute("data-tip", label);
201
+ }
202
+ if (save) store("aitd.rail", collapsed ? "collapsed" : "expanded");
203
+ }
204
+ setRail(store("aitd.rail") === "collapsed");
205
+ // The width transition starts after the first paint, so a remembered collapsed rail does not animate on load.
206
+ requestAnimationFrame(function () { requestAnimationFrame(function () { html.classList.add("rail-anim"); }); });
207
+
208
+ /* ---------- clicks and keys ---------- */
209
+ document.addEventListener("click", function (e) {
210
+ var t = e.target, a = t.closest("a[href^='#']"), x;
211
+ if (a && a.getAttribute("href") === location.hash) { e.preventDefault(); route(true); }
212
+ else if ((x = t.closest("[data-tabs] [role=tab]")) && enabled(x)) selectTab(x);
213
+ else if ((x = t.closest("[data-select-target]")) && x.closest("[data-inspector]")) select(x);
214
+ else if ((x = t.closest("[data-vp-target]"))) pressVp(x, true);
215
+ else if ((x = t.closest("[data-expand-all],[data-collapse-all]"))) $$("details.card", x.closest(".sec")).forEach(function (d) { d.open = x.hasAttribute("data-expand-all"); });
216
+ else if (t.closest("[data-rail-toggle]")) setRail(html.getAttribute("data-rail") !== "collapsed", true);
217
+ else if (t.closest(".mock-switch")) go(body.getAttribute("data-state") === "failed" ? "#summary" : "#failed-summary");
218
+ });
219
+ function select(btn, focus) {
220
+ var insp = btn.closest("[data-inspector]"), pane = byId(btn.getAttribute("data-select-target"));
221
+ if (!pane) return;
222
+ selectPane(insp, pane);
223
+ if (focus) btn.focus();
224
+ if (pane.id) history.replaceState(null, "", "#" + pane.id);
225
+ }
226
+ document.addEventListener("keydown", function (e) {
227
+ var t = e.target, x, n;
228
+ // "[" is AltGr+8 on Turkish Q and German layouts (Ctrl+Alt on Windows, Option+5 on a Mac), and on Turkish Q the
229
+ // "[" key itself types "ğ": either folds the rail. Any other modifier means the key is not ours.
230
+ var rail = e.key === "[" || (e.code === "BracketLeft" && !e.shiftKey);
231
+ var altGr = e.key === "[" && e.altKey || (e.getModifierState && e.getModifierState("AltGraph"));
232
+ if (e.metaKey || ((e.ctrlKey || e.altKey) && !(rail && altGr))) return;
233
+ if ((x = t.closest && t.closest("[data-tabs] [role=tab]"))) {
234
+ n = step(tabsOf(x.closest("[data-tabs]")).filter(enabled), tabsOf(x.closest("[data-tabs]")).filter(enabled).indexOf(x), e.key, "ArrowLeft", "ArrowRight");
235
+ if (n) { e.preventDefault(); return selectTab(n, true); }
236
+ }
237
+ if ((x = t.closest && t.closest("[data-select-target]")) && x.closest("[data-inspector]")) {
238
+ var items = itemsOf(x.closest("[data-inspector]")), vert = { ArrowUp: "ArrowUp", ArrowDown: "ArrowDown" }[e.key];
239
+ n = step(items, items.indexOf(x), e.key, vert ? "ArrowUp" : "ArrowLeft", vert ? "ArrowDown" : "ArrowRight");
240
+ if (n) { e.preventDefault(); return select(n, true); }
241
+ }
242
+ if (typing(t)) return;
243
+ var state = body.getAttribute("data-state") === "failed" ? "failed-" : "";
244
+ if (rail) { e.preventDefault(); setRail(html.getAttribute("data-rail") !== "collapsed", true); }
245
+ else if (/^[1-7]$/.test(e.key)) {
246
+ var a = $$('.rail ul:not([hidden]) > li > a')[+e.key - 1];
247
+ if (a && a.getAttribute("href")) { e.preventDefault(); go(a.getAttribute("href")); }
248
+ } else if (e.key === "/") {
249
+ var s = byId(state + "findings"), q = s && s.querySelector("[data-filter-search]");
250
+ if (s && s.hidden) { history.pushState(null, "", "#" + s.id); route(false); }
251
+ if (q) { e.preventDefault(); q.focus(); }
252
+ }
253
+ });
254
+
255
+ /* ---------- theme: auto → light → dark, remembered when storage allows ---------- */
256
+ var btn = byId("theme"), order = ["auto", "light", "dark"], mode = store("aitd.theme");
257
+ function apply(m) {
258
+ if (m === "auto") html.removeAttribute("data-theme"); else html.setAttribute("data-theme", m);
259
+ if (btn) { btn.setAttribute("aria-label", btn.getAttribute("data-" + m)); btn.title = btn.getAttribute("data-" + m); }
260
+ }
261
+ if (order.indexOf(mode) < 0) mode = "auto";
262
+ apply(mode);
263
+ if (btn) btn.addEventListener("click", function () { mode = order[(order.indexOf(mode) + 1) % order.length]; apply(mode); store("aitd.theme", mode); });
264
+
265
+ route(false);
266
+ })();`;
267
+ // ---------- end of shell ----------
268
+
269
+ // ---------- journeys: keeps the viewport when the journey changes ----------
270
+ const JOURNEYS = `/* Journeys: what the generic behaviors do not do. shell.js keeps the step when the viewport changes
271
+ ([data-vp-switch]); this keeps the viewport when the journey changes, so a reader comparing mobile runs stays
272
+ on mobile, and writes the step now on screen to the hash (replaceState) after either switch. It only clicks
273
+ the generic switch, so shell.js stays in charge of state. */
274
+ (function () {
275
+ var pick = document.querySelector("#journeys .jr-pick");
276
+ if (!pick) return;
277
+ var shown = function (el) { return el.offsetParent !== null; };
278
+ var each = function (sel, fn) { Array.prototype.forEach.call(pick.querySelectorAll(sel), fn); };
279
+ function writeHash() {
280
+ each("[data-select-pane]", function (p) { if (shown(p) && history.replaceState) history.replaceState(null, "", "#" + p.id); });
281
+ }
282
+ pick.addEventListener("click", function (e) {
283
+ if (e.target.closest("[data-vp-target]")) return setTimeout(writeHash, 0);
284
+ var tab = e.target.closest("[role=tab]");
285
+ if (!tab || tab.parentNode.parentNode !== pick) return;
286
+ var cur = null;
287
+ each("[data-vp][aria-pressed=true]", function (b) { if (shown(b)) cur = b.getAttribute("data-vp"); });
288
+ setTimeout(function () {
289
+ var panel = document.getElementById(tab.getAttribute("aria-controls"));
290
+ var want = cur && panel && panel.querySelector("[data-vp='" + cur + "']");
291
+ if (want && want.getAttribute("aria-pressed") !== "true") want.click(); else writeHash();
292
+ }, 0);
293
+ });
294
+ })();`;
295
+ // ---------- end of journeys ----------
296
+
297
+ export const SCRIPT = [SHELL, JOURNEYS].join("\n");
298
+
299
+ /** The CSP source for the inline script, for the page's meta tag and the MCP server's Content-Security-Policy header. */
300
+ export const scriptCspSource = () => `'sha256-${createHash("sha256").update(SCRIPT).digest("base64")}'`;
@@ -0,0 +1,191 @@
1
+ // The new HTML report: the head, the top bar, the rail, the seven sections and the footer around them. Each section
2
+ // module (summary.js, findings.js, journeys.js, consent.js, scope.js) renders from the one context built here, so the
3
+ // numbers on the rail, the top bar and the sections come from the same rows. src/report.js still exports the 0.17
4
+ import { messages } from "../i18n.js";
5
+ import { summarize } from "../checks.js";
6
+ import { computeMetrics } from "../metrics.js";
7
+ import { LOGOS } from "../brand.js";
8
+ import { VERSION, attachExplanations, escapeHtml, verdictHeadline } from "../report-shared.js";
9
+ import { cardIds, logo, passedRows, sprite, use } from "./lib.js";
10
+ import { CSS, brandCss } from "./css.js";
11
+ import { SCRIPT, scriptCspSource } from "./script.js";
12
+ import * as summary from "./summary.js";
13
+ import * as findingsSection from "./findings.js";
14
+ import * as journeys from "./journeys.js";
15
+ import * as consent from "./consent.js";
16
+ import * as scope from "./scope.js";
17
+
18
+ /** The rail's entries in order (1–7 on the keyboard) and their sprite icons. */
19
+ export const SECTIONS = ["summary", "findings", "journeys", "consent", "coverage", "inventory", "run"];
20
+ const RAIL_ICONS = { summary: "ui-gtm", findings: "ui-cause", journeys: "ui-journeys", consent: "ui-consent", coverage: "ui-events", inventory: "ui-vendors", run: "ui-clock" };
21
+ const VERDICT_SEV = { fail: "broken", attention: "warning", pass: "ok" };
22
+ const PROBLEM_RANK = { broken: 0, risk: 1, warning: 2 };
23
+ const CONSENT_CHECKS = new Set(["before_consent", "after_reject", "consent_default_missing"]);
24
+ const ATD_REPO = "https://github.com/analitikisler/ai-tag-debugger";
25
+
26
+ const worst = (rows) => rows.map((f) => f.severity).filter((s) => s in PROBLEM_RANK).sort((a, b) => PROBLEM_RANK[a] - PROBLEM_RANK[b])[0] ?? null;
27
+
28
+ /**
29
+ * The numbers more than one place shows, counted once from the grouped rows: the rail, the top bar and the sections
30
+ * read them here so they cannot disagree. Accepted rows (baseline) are not counted, as summarize() does.
31
+ */
32
+ function tallyOf(rows, runs, metrics, findings) {
33
+ const counted = rows.filter((f) => !f.accepted);
34
+ const failedRows = counted.filter((f) => f.severity in PROBLEM_RANK);
35
+ const chosen = [...new Set(runs.flatMap((r) => r.providers ?? []))];
36
+ const seen = [...new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)))];
37
+ const checked = chosen.length ? chosen : seen;
38
+ return {
39
+ failed: failedRows.length,
40
+ worstFailed: worst(failedRows),
41
+ unverified: counted.filter((f) => f.severity === "info").length,
42
+ passed: passedRows({ findings, metrics }).length,
43
+ consent: worst(counted.filter((f) => CONSENT_CHECKS.has(f.check))) ?? "ok",
44
+ runs: runs.length,
45
+ completed: runs.filter((r) => r.completed).length,
46
+ coverage: metrics.coverage?.percent?.implemented ?? null,
47
+ vendors: { seen: checked.filter((p) => seen.includes(p)), checked },
48
+ // A site that never loaded has no score worth showing: null renders as "–".
49
+ score: runs.some((r) => r.completed) ? (metrics.score?.score ?? null) : null,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * @typedef {object} Ctx What every section's render(ctx) receives. CONTRACT-IMPL.md describes each field.
55
+ * @property {object} m messages(lang)
56
+ * @property {string} lang
57
+ * @property {string} title
58
+ * @property {string|null} site
59
+ * @property {string} generatedAt
60
+ * @property {{name: string, model: string}|null} ai
61
+ * @property {object[]} findings the raw findings, one per place
62
+ * @property {object|null} explained the model's summary and explanations
63
+ * @property {object[]} rows the grouped findings with their explanation and accepted flag (attachExplanations)
64
+ * @property {Map<object, string>} ids problem row -> card id (cardIds)
65
+ * @property {object[]} runs
66
+ * @property {object|null} plan
67
+ * @property {object} metrics computeMetrics
68
+ * @property {{broken: number, risk: number, warning: number, info: number, ok: number}} counts summarize(rows)
69
+ * @property {object|null} consentScenarios
70
+ * @property {object|null} planCheck
71
+ * @property {object|null} changes
72
+ * @property {object|null} planChanges
73
+ * @property {object|null} trend
74
+ * @property {string|null} against
75
+ * @property {{name: string|null, logo: string|null, color: string|null}|null} brand
76
+ * @property {boolean} branding false for a white label too
77
+ * @property {number} accepted grouped rows the baseline accepted
78
+ * @property {{status: string, statusText: string, headline: string, score: number|null}} verdict verdictHeadline
79
+ * @property {ReturnType<typeof tallyOf>} tally
80
+ */
81
+
82
+ /** The context the sections render from, built from renderHtml's argument. Exported so a section's tests can build it. */
83
+ export function context({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null, metrics = null, trend = null }) {
84
+ const m = messages(lang);
85
+ const rows = attachExplanations(findings, explained);
86
+ metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
87
+ // A custom name or logo takes the place of the Analitik İşler branding, as branding false does.
88
+ const whiteLabel = Boolean(brand?.logo || brand?.name);
89
+ return {
90
+ m, lang, title, site, generatedAt, ai, findings, explained, rows, ids: cardIds(rows), runs, plan, metrics, counts: summarize(rows),
91
+ consentScenarios, planCheck, changes, planChanges, trend, against, brand, branding: whiteLabel ? false : branding, whiteLabel,
92
+ accepted: rows.filter((f) => f.accepted).length, verdict: verdictHeadline({ findings, runs, metrics, lang }), tally: tallyOf(rows, runs, metrics, findings),
93
+ };
94
+ }
95
+
96
+ /** The token beside a rail entry: a glyph or a short text, and the tone that colours it and the collapsed rail's dot. */
97
+ function railToken(ctx, id) {
98
+ const { m, tally, metrics } = ctx;
99
+ if (id === "summary") {
100
+ const sev = VERDICT_SEV[metrics.verdict.status] ?? "info";
101
+ return { glyph: sev, tone: sev, tip: m.verdict.status[metrics.verdict.status] };
102
+ }
103
+ if (id === "findings") return { text: String(tally.failed), tone: tally.worstFailed ?? "ok" };
104
+ if (id === "journeys") return { text: `${tally.completed}/${tally.runs}`, tone: tally.completed === tally.runs ? "completed" : "incomplete" };
105
+ if (id === "consent") return { glyph: tally.consent, tone: tally.consent, tip: m.severity[tally.consent] };
106
+ if (id === "coverage") return tally.coverage === null ? null : { text: `${tally.coverage}%`, tone: "neutral" };
107
+ if (id === "inventory") return tally.vendors.checked.length ? { text: `${tally.vendors.seen.length}/${tally.vendors.checked.length}`, tone: "neutral" } : null;
108
+ return null;
109
+ }
110
+
111
+ function rail(ctx) {
112
+ const t = ctx.m.html.shell;
113
+ const items = SECTIONS.map((id) => {
114
+ const label = t.sections[id];
115
+ const tok = railToken(ctx, id);
116
+ const tip = tok ? t.railTip(label, tok.tip ?? tok.text) : label;
117
+ const token = tok ? `<span class="r-t" data-tone="${tok.tone}">${tok.glyph ? use(`sev-${tok.glyph}`, 14) : escapeHtml(tok.text)}</span>${tok.tone === "neutral" ? "" : `<span class="r-d" data-tone="${tok.tone}"></span>`}` : "";
118
+ return `<li><a href="#${id}" data-tip="${escapeHtml(tip)}" aria-label="${escapeHtml(tip)}">${use(RAIL_ICONS[id], 18)}<span class="r-l">${escapeHtml(label)}</span>${token}</a></li>`;
119
+ }).join("");
120
+ return `<nav class="rail" id="rail" aria-label="${escapeHtml(t.railLabel)}"><div class="r-head"><button type="button" class="r-toggle js-only" data-rail-toggle aria-expanded="true" aria-controls="rail" aria-label="${escapeHtml(t.collapse)}" data-tip="${escapeHtml(t.collapse)}" data-collapse="${escapeHtml(t.collapse)}" data-expand="${escapeHtml(t.expand)}">${use("ui-chevron", 18)}</button></div><ul>${items}</ul></nav>`;
121
+ }
122
+
123
+ function topBar(ctx) {
124
+ const { m, site, title, generatedAt, brand, branding, whiteLabel, metrics, tally } = ctx;
125
+ const t = m.html.shell;
126
+ const brandName = brand?.name ? escapeHtml(brand.name) : "";
127
+ const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
128
+ const product = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : `${logo("atd-lockup", "AI Tag Debugger", 22, "l-lockup")}<img class="l-mark" src="${LOGOS["atd-icon"]}" alt="AI Tag Debugger" height="24">`;
129
+ const atdByline = whiteLabel ? `<a href="${ATD_REPO}" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 16, "logo-atd")}</a>` : "";
130
+ const aiByline = branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 16, "logo-h")}</a>` : "";
131
+ const word = m.verdict.status[metrics.verdict.status];
132
+ // 0–49 red, 50–89 amber, 90–100 green, as Lighthouse bands its scores; no score reads as a fail.
133
+ const band = tally.score === null || tally.score < 50 ? "fail" : tally.score < 90 ? "mid" : "pass";
134
+ const label = tally.score === null ? t.noScore(word) : t.scoreLabel(tally.score, word);
135
+ const verdict = `<a class="top-verdict v-${band}" href="#summary" aria-label="${escapeHtml(label)}"><span class="sc-l">${escapeHtml(t.score)}</span><b>${tally.score ?? "–"}</b><span class="sc">${escapeHtml(t.outOf)}</span></a>`;
136
+ const theme = `<button type="button" class="icon-btn js-only" id="theme" aria-label="${escapeHtml(t.themeAuto)}" title="${escapeHtml(t.themeAuto)}" data-auto="${escapeHtml(t.themeAuto)}" data-light="${escapeHtml(t.themeLight)}" data-dark="${escapeHtml(t.themeDark)}">${use("ui-theme", 18)}</button>`;
137
+ return `<header class="top"><span class="brand">${product}</span><span class="top-site"><span class="eyebrow">${escapeHtml(t.eyebrow)}</span><b>${escapeHtml(site ?? title)}</b></span><span class="top-date muted">${escapeHtml(generatedAt)}</span>${verdict}<div class="top-end">${atdByline}${aiByline}${theme}</div></header>`;
138
+ }
139
+
140
+ function footer(ctx) {
141
+ const { m, site, title, generatedAt, brand, branding, whiteLabel } = ctx;
142
+ const atd = `<a class="f-atd" href="${ATD_REPO}" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="14"> AI Tag Debugger</a>`;
143
+ const meta = `<span class="muted">${escapeHtml(site ?? title)} · ${escapeHtml(generatedAt)} · v${VERSION}</span>`;
144
+ if (branding) return `<footer class="footer"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 40, "logo-s")}</a><p class="xs">${m.ui.credit(atd)} ${meta}</p></footer>`;
145
+ const brandName = brand?.name ? escapeHtml(brand.name) : "";
146
+ return `<footer class="footer"><p class="xs">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"} ${meta}</p></footer>`;
147
+ }
148
+
149
+ /** The expand / collapse pair under a section's h2, for every section but Summary that holds a details.card. */
150
+ function withTools(m, html) {
151
+ const tools = `<div class="sec-tools js-only"><button type="button" class="btn" data-expand-all>${escapeHtml(m.html.shell.expandAll)}</button><button type="button" class="btn" data-collapse-all>${escapeHtml(m.html.shell.collapseAll)}</button></div>`;
152
+ return html.split(/(?=<section\b[^>]*\bclass="sec(?=[ "]))/).map((chunk) => {
153
+ const id = chunk.match(/^<section\b[^>]*\bid="([^"]+)"/)?.[1];
154
+ if (!id || id === "summary" || !/<details\b[^>]*\bclass="[^"]*\bcard\b/.test(chunk)) return chunk;
155
+ return chunk.replace(/<\/h2>/, `</h2>${tools}`);
156
+ }).join("");
157
+ }
158
+
159
+ /**
160
+ * The HTML report, with the same argument as the 0.17 renderHtml. metrics (computeMetrics) and trend (history.js)
161
+ * come from the caller when it has them; metrics is computed here otherwise.
162
+ */
163
+ export function renderHtml(report) {
164
+ const ctx = context(report);
165
+ const { m, lang, title, brand } = ctx;
166
+ const t = m.html.shell;
167
+ const sections = withTools(m, [summary, findingsSection, journeys, consent, scope].map((s) => s.render(ctx)).join("\n"));
168
+ const top = topBar(ctx);
169
+ const nav = rail(ctx);
170
+ const foot = footer(ctx);
171
+ return `<!doctype html>
172
+ <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
173
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${scriptCspSource()}">
174
+ <meta name="color-scheme" content="light dark">
175
+ <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
176
+ <style>${CSS}${brandCss(brand?.color)}</style></head><body>
177
+ <a class="skip" href="#main">${escapeHtml(t.skip)}</a>
178
+ ${sprite(`${top}${nav}${sections}${foot}`)}
179
+ ${top}
180
+ <div class="shell">
181
+ ${nav}
182
+ <main id="main" tabindex="-1" data-clear-label="${escapeHtml(t.clear)}" data-filter-count="${escapeHtml(t.filterCount("{n}", "{m}"))}">
183
+ <h1 class="sr">${escapeHtml(t.heading(ctx.site ?? title))}</h1>
184
+ ${sections}
185
+ </main>
186
+ </div>
187
+ ${foot}
188
+ <script>${SCRIPT}</script>
189
+ </body></html>`;
190
+ }
191
+