@analitikisler/ai-tag-debugger 0.0.0-stage → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.js ADDED
@@ -0,0 +1,827 @@
1
+ import { createProvider } from "./providers.js";
2
+ import { groupFindings, summarize } from "./checks.js";
3
+ import { messages } from "./i18n.js";
4
+ import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
5
+ import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
6
+
7
+ const EXPLAIN_SYSTEM = `You are a senior digital analytics consultant reviewing an automated tracking audit. You receive findings from deterministic checks, the measurement plan, the hits captured on each step of each journey, and optionally a Google Tag Manager container export.
8
+
9
+ For each finding with severity broken, risk or warning, give:
10
+ - impact: one sentence on what this does to the data, the reports or ad spend;
11
+ - likely_cause: the most likely cause, in plain language a marketing manager can follow and an analyst can act on;
12
+ - fix_steps: the fix as short, ordered steps.
13
+ When a GTM container is provided, name the specific tags, triggers or variables involved. Only state causes the evidence supports; when the cause is uncertain, say what to check.
14
+
15
+ Then write the summary:
16
+ - headline: one sentence naming what matters most for data quality and ad spend;
17
+ - points: the main problems, most important first, each one sentence with its severity;
18
+ - also_check: things you can see in the captured hits that the findings list does not cover (for example a conversion that fires on page load, or a cookie banner click counted as a conversion). Only include what the captured data shows; leave the list empty otherwise.`;
19
+
20
+ const SEVERITIES = ["broken", "risk", "warning", "info"];
21
+ const EXPLAIN_SCHEMA = {
22
+ type: "object",
23
+ properties: {
24
+ summary: {
25
+ type: "object",
26
+ properties: {
27
+ headline: { type: "string", description: "One sentence on what matters most." },
28
+ points: {
29
+ type: "array",
30
+ items: {
31
+ type: "object",
32
+ properties: { severity: { type: "string", enum: SEVERITIES }, text: { type: "string" } },
33
+ required: ["severity", "text"],
34
+ additionalProperties: false,
35
+ },
36
+ },
37
+ also_check: { type: "array", items: { type: "string" } },
38
+ },
39
+ required: ["headline", "points", "also_check"],
40
+ additionalProperties: false,
41
+ },
42
+ explanations: {
43
+ type: "array",
44
+ items: {
45
+ type: "object",
46
+ properties: {
47
+ finding: { type: "integer", description: "Index of the finding in the input list." },
48
+ impact: { type: "string" },
49
+ likely_cause: { type: "string" },
50
+ fix_steps: { type: "array", items: { type: "string" } },
51
+ },
52
+ required: ["finding", "impact", "likely_cause", "fix_steps"],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ },
57
+ required: ["summary", "explanations"],
58
+ additionalProperties: false,
59
+ };
60
+
61
+ /** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
62
+ function summaryParts(summary) {
63
+ if (!summary) return null;
64
+ if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
65
+ return {
66
+ headline: String(summary.headline ?? ""),
67
+ points: (Array.isArray(summary.points) ? summary.points : []).filter((p) => p && p.text).map((p) => ({ severity: SEVERITIES.includes(p.severity) ? p.severity : "info", text: String(p.text) })),
68
+ also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
69
+ };
70
+ }
71
+
72
+ /** Fix steps of an explanation; older explanations had one "fix" string. */
73
+ const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
74
+
75
+ function hitsByStep(run) {
76
+ return run.steps.map((step) => ({
77
+ step: step.label,
78
+ hits: run.hits
79
+ .filter((h) => h.step === step.index)
80
+ .map((h) => ({ platform: h.platform, event: h.name, params: h.params, ...(h.consent ? { consent: h.consent } : {}) })),
81
+ }));
82
+ }
83
+
84
+ /**
85
+ * Asks the AI model to explain the findings. Returns null on failure so the
86
+ * deterministic report is still written.
87
+ */
88
+ export async function explainFindings({ findings, plan, runs, gtmContainer, provider, providerName, client, model, lang = "en", log = () => {} }) {
89
+ const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity));
90
+ if (actionable.length === 0) return { summary: { headline: messages(lang).ui.allGood, points: [], also_check: [] }, explanations: [] };
91
+ try {
92
+ const input = {
93
+ findings: actionable,
94
+ measurement_plan: plan,
95
+ journeys: runs.map((r) => ({ journey: r.journeyId, viewport: r.viewport, completed: r.completed, note: r.note, steps: hitsByStep(r), page_errors: r.pageErrors })),
96
+ };
97
+ const texts = [`Audit data:\n${JSON.stringify(input, null, 1)}`];
98
+ if (gtmContainer) texts.push(`GTM container export:\n${JSON.stringify(gtmContainer)}`);
99
+
100
+ const ai = provider ?? (await createProvider({ name: providerName, model, client }));
101
+ const result = await ai.json({
102
+ system: lang === "en" ? EXPLAIN_SYSTEM : `${EXPLAIN_SYSTEM}\n\nWrite the summary, impacts, likely causes and fix steps in ${messages(lang).name}. Keep event, parameter, tag and trigger names exactly as they are.`,
103
+ texts,
104
+ schema: EXPLAIN_SCHEMA,
105
+ });
106
+ if (result.error) {
107
+ log(`The model did not return explanations (${result.error}); writing the report without them.`);
108
+ return null;
109
+ }
110
+ return result.value;
111
+ } catch (err) {
112
+ log(`Could not get explanations from the model: ${err.message}`);
113
+ return null;
114
+ }
115
+ }
116
+
117
+ const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
118
+
119
+ function attachExplanations(findings, explained) {
120
+ const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
121
+ return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i) }));
122
+ }
123
+
124
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
125
+
126
+ /** The most severe finding that points at this hit, if any (for highlighting in the timeline). */
127
+ function hitSeverity(findings, run, step, hit) {
128
+ let worst = null;
129
+ for (const f of findings) {
130
+ if (!(f.severity in SEVERITY_RANK)) continue;
131
+ if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
132
+ if (f.platform !== hit.platform || f.event !== hit.name) continue;
133
+ if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst]) worst = f.severity;
134
+ }
135
+ return worst;
136
+ }
137
+
138
+ const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
139
+ const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
140
+ const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) => JSON.stringify(v)).join(", ")})` : JSON.stringify(value));
141
+
142
+ // Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
143
+ const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
144
+ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
145
+
146
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
147
+ const m = messages(lang);
148
+ const rows = attachExplanations(findings, explained);
149
+ const counts = summarize(rows);
150
+ const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", `**${m.ui.counts(counts)}**`, ""];
151
+ const sum = summaryParts(explained?.summary);
152
+ if (sum) {
153
+ lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
154
+ if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
155
+ if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
156
+ }
157
+ lines.push(`## ${m.ui.findings}`, "");
158
+ for (const f of rows.filter((r) => r.severity !== "ok")) {
159
+ lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
160
+ lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
161
+ if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
162
+ if (f.explanation) {
163
+ if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
164
+ lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
165
+ }
166
+ lines.push("");
167
+ }
168
+ for (const f of rows.filter((r) => r.severity === "ok")) lines.push(`- ${m.severity.ok}, ${mdText(f.where[0].journey)} (${mdText(f.where[0].viewport)}): ${mdText(f.detail)}`);
169
+ lines.push("", `## ${m.ui.timeline}`, "");
170
+ for (const r of runs) {
171
+ lines.push(`### ${mdText(r.journeyId)} (${mdText(r.viewport)}): ${r.completed ? m.ui.completed : m.ui.notCompleted}`, "", mdText(r.note), "");
172
+ for (const step of r.steps.filter((s) => s.index > 0)) {
173
+ lines.push(`${step.index}. **${mdText(step.label)}** (${seconds(r, step.t)})${step.forced ? ` _${m.ui.clickedThrough}_` : ""}`);
174
+ for (const h of r.hits.filter((x) => x.step === step.index)) {
175
+ const flag = hitSeverity(findings, r, step, h);
176
+ const params = Object.entries(h.params).map(([k, v]) => `${k}=${v}`).join(", ");
177
+ lines.push(` - ${flag ? `**${m.severity[flag]}** ` : ""}${mdText(`${h.platform}:${h.name}${params ? ` (${params})` : ""}`)}`);
178
+ }
179
+ for (const e of (r.dataLayerLog ?? []).filter((x) => x.step === step.index)) {
180
+ lines.push(` - dataLayer: \`${mdCode(isConsentEntry(e.value) ? formatPush(e.value) : JSON.stringify(e.value))}\``);
181
+ }
182
+ }
183
+ lines.push("");
184
+ }
185
+ if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
186
+ return lines.join("\n");
187
+ }
188
+
189
+ const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x"];
190
+ const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
191
+ const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
192
+
193
+ // Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
194
+ const EVENT_ALIASES = {
195
+ pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
196
+ addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
197
+ completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
198
+ };
199
+ const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
200
+ const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
201
+ // GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
202
+ const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
203
+ const pushText = (value) => (Array.isArray(value) && typeof value[0] === "string" ? `gtag(${value.map((v) => JSON.stringify(v, null, 2)).join(", ")})` : JSON.stringify(value, null, 2));
204
+
205
+ // Per run: step index -> event key -> the pushes with that event name. Built once so rendering stays linear.
206
+ const pushIndexCache = new WeakMap();
207
+ function pushIndex(run) {
208
+ let index = pushIndexCache.get(run);
209
+ if (!index) {
210
+ index = new Map();
211
+ for (const e of run.dataLayerLog ?? []) {
212
+ const name = pushEvent(e.value);
213
+ if (typeof name !== "string" || !eventKey(name)) continue;
214
+ if (!index.has(e.step)) index.set(e.step, new Map());
215
+ const byKey = index.get(e.step);
216
+ const key = eventKey(name);
217
+ if (!byKey.has(key)) byKey.set(key, []);
218
+ byKey.get(key).push(e);
219
+ }
220
+ pushIndexCache.set(run, index);
221
+ }
222
+ return index;
223
+ }
224
+
225
+ /** Every dataLayer push in the hit's step whose event name matches it (directly or through a pixel alias). */
226
+ const matchingPushes = (run, hit) => pushIndex(run).get(hit.step)?.get(eventKey(hit.name)) ?? [];
227
+
228
+ /** The push behind the hit. When the same event fires as often as it was pushed (duplicates), they are paired in order. */
229
+ function pushesForHit(run, hit) {
230
+ const pushes = matchingPushes(run, hit);
231
+ const key = eventKey(hit.name);
232
+ const twins = run.hits.filter((h) => h.step === hit.step && h.platform === hit.platform && eventKey(h.name) === key);
233
+ return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
234
+ }
235
+
236
+ /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
237
+ function callsForHit(run, hit) {
238
+ const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
239
+ const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
240
+ return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
241
+ }
242
+
243
+ /** "4 planned events matched the plan: ga4:page_view, meta:AddToCart." as a lead line and event chips. */
244
+ function planMatchHtml(detail) {
245
+ const at = detail.indexOf(": ");
246
+ if (at < 0) return escapeHtml(detail);
247
+ const chips = detail.slice(at + 2).replace(/\.$/, "").split(", ").map((key) => {
248
+ const i = key.indexOf(":");
249
+ const p = i > 0 ? key.slice(0, i) : "";
250
+ return `<span class="ptag${p ? ` p-${escapeHtml(p)}` : ""}">${p ? `${escapeHtml(platformLabel(p))} <b>${escapeHtml(key.slice(i + 1))}</b>` : `<b>${escapeHtml(key)}</b>`}</span>`;
251
+ });
252
+ return `<span>${escapeHtml(detail.slice(0, at))}</span><span class="chips">${chips.join("")}</span>`;
253
+ }
254
+
255
+ const icon = {
256
+ chevron: '<svg class="chev" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>',
257
+ };
258
+
259
+ const kvTable = (rows) => `<table class="ptable"><tbody>${rows.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</tbody></table>`;
260
+ // Platform keys go into class names, ids and attribute selectors.
261
+ const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
262
+ const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
263
+
264
+ const STANDARD_ITEM_KEYS = new Set(["item_id", "item_name", "affiliation", "coupon", "discount", "index", "item_brand", "item_category", "item_category2", "item_category3", "item_category4", "item_category5", "item_list_id", "item_list_name", "item_variant", "location_id", "price", "quantity", "promotion_id", "promotion_name", "creative_name", "creative_slot"]);
265
+ const ECOMMERCE_EVENTS = new Set(["view_item", "view_item_list", "select_item", "add_to_cart", "remove_from_cart", "view_cart", "begin_checkout", "add_shipping_info", "add_payment_info", "purchase", "refund", "add_to_wishlist", "view_promotion", "select_promotion"]);
266
+
267
+ /** GA4 item strings (pr1=…) as { fields, custom } pairs. */
268
+ const ga4Items = (strings) => (strings ?? []).map(decodeGa4Item);
269
+ /** dataLayer ecommerce.items objects as { fields, custom } pairs: standard GA4 item keys, then the site's own. */
270
+ const objectItems = (list) =>
271
+ list.filter((it) => it && typeof it === "object").map((it) => {
272
+ const entries = Object.entries(it).map(([k, v]) => [k, cell(v)]);
273
+ return { fields: entries.filter(([k]) => STANDARD_ITEM_KEYS.has(k)), custom: entries.filter(([k]) => !STANDARD_ITEM_KEYS.has(k)) };
274
+ });
275
+ /** The items array of a GTM-style ({ecommerce: {items}}) or gtag.js-style (["event", name, {items}]) push. */
276
+ function pushItems(value) {
277
+ const items = Array.isArray(value) ? value[2]?.items : value?.ecommerce?.items ?? value?.items;
278
+ return Array.isArray(items) ? items : [];
279
+ }
280
+
281
+ function itemCards(m, items) {
282
+ return items
283
+ .map(({ fields, custom }, i) => {
284
+ const get = (k) => fields.find(([n]) => n === k)?.[1];
285
+ const price = get("price");
286
+ const qty = get("quantity");
287
+ const head = `<div class="item-head"><span class="item-idx">${i + 1}</span><b>${escapeHtml(get("item_name") ?? "")}</b><span class="mono muted">${escapeHtml(get("item_id") ?? "")}</span>${price !== undefined || qty !== undefined ? `<span class="item-price mono">${escapeHtml(price ?? "–")} × ${escapeHtml(qty ?? "1")}</span>` : ""}</div>`;
288
+ const rest = fields.filter(([n]) => !["item_name", "item_id", "price", "quantity"].includes(n));
289
+ const grid = (rows) => `<dl class="grid4">${rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v === "" ? "–" : v)}</dd></div>`).join("")}</dl>`;
290
+ return `<div class="item">${head}${rest.length ? grid(rest) : ""}${custom.length ? `<h6>${m.ui.customItemParams}</h6>${grid(custom)}` : ""}</div>`;
291
+ })
292
+ .join("");
293
+ }
294
+ const itemsBlock = (m, items) => (items.length ? `<h5>${m.ui.items} <span class="n">${items.length}</span></h5>${itemCards(m, items)}` : "");
295
+
296
+ /** The most severe finding that points at this hit, as { severity, detail }, or null. */
297
+ function hitFlag(findings, run, step, hit) {
298
+ let worst = null;
299
+ for (const f of findings) {
300
+ if (!(f.severity in SEVERITY_RANK)) continue;
301
+ if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
302
+ if (f.platform !== hit.platform || f.event !== hit.name) continue;
303
+ if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
304
+ }
305
+ return worst && { severity: worst.severity, detail: worst.detail };
306
+ }
307
+
308
+ const hostPath = (url) => {
309
+ try {
310
+ const u = new URL(url);
311
+ return u.host + u.pathname;
312
+ } catch {
313
+ return String(url ?? "");
314
+ }
315
+ };
316
+
317
+ /** Radio buttons that filter the list after them by data-p. Only sources present are offered. */
318
+ function filterBar(m, name, rows) {
319
+ const counts = new Map();
320
+ for (const r of rows) counts.set(r.key, { label: r.label, n: (counts.get(r.key)?.n ?? 0) + 1 });
321
+ if (counts.size < 2) return "";
322
+ const keys = [...counts.keys()].sort((a, b) => platformRank(a) - platformRank(b) || counts.get(a).label.localeCompare(counts.get(b).label));
323
+ return `<input type="radio" name="${name}" id="${name}-all" class="f-all" checked><label for="${name}-all">${m.ui.all} <span class="n">${rows.length}</span></label>${keys
324
+ .map((k) => `<input type="radio" name="${name}" id="${name}-${k}" class="f-${k}"><label for="${name}-${k}"><span class="dot p-${k}"></span>${escapeHtml(counts.get(k).label)} <span class="n">${counts.get(k).n}</span></label>`)
325
+ .join("")}`;
326
+ }
327
+ const filtered = (m, name, rows, listHtml) => `<div class="filter" role="group" aria-label="${m.ui.filterBy}">${filterBar(m, name, rows)}<ul class="evlist">${listHtml}</ul></div>`;
328
+
329
+ function eventRow(m, findings, run, step, hit) {
330
+ const flag = hitFlag(findings, run, step, hit);
331
+ const pushes = pushesForHit(run, hit);
332
+ const calls = callsForHit(run, hit);
333
+ const items = ga4Items(hit.items);
334
+ const params = Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && items.length));
335
+ const bits = [];
336
+ if (items.length) bits.push(m.ui.itemsCount(items.length));
337
+ if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
338
+ if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
339
+ const consent = hit.consent ? `<h5>${m.ui.consent}</h5><div class="consents">${Object.entries(hit.consent).map(([k, v]) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)}: ${escapeHtml(v)}</span>`).join("")}</div>` : "";
340
+ const pushLine = pushes.length ? pushes.map((e) => `<span class="mono">${escapeHtml(pushEvent(e.value) ?? "")} · ${escapeHtml(seconds(run, e.t))}</span>`).join("") : `<span class="muted">${m.ui.noMatchingPush}</span>`;
341
+ const reqText = calls.length
342
+ ? calls.map((c) => `<span class="mono"><span class="method">${escapeHtml(c.method)}</span> ${escapeHtml(hostPath(c.url))} <span class="status">${escapeHtml(c.status ?? "")}</span>${c.blocked ? ` <span class="muted">${m.ui.blockedNote}</span>` : ""}</span>`).join("")
343
+ : `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
344
+ return `<li data-p="${safeKey(hit.platform)}"><details class="event${flag ? ` flag-${flag.severity}` : ""}"${flag ? " open" : ""}>
345
+ <summary>${icon.chevron}<span class="ptag p-${safeKey(hit.platform)}">${escapeHtml(platformLabel(hit.platform))}</span><code class="ev">${escapeHtml(hit.name)}</code>${flag ? `<span class="tag ${flag.severity}">${m.severity[flag.severity]}</span>` : ""}<span class="meta">${escapeHtml(bits.join(" · ") || `${params.length} ${m.ui.paramsShort}`)}</span><span class="time">${escapeHtml(seconds(run, hit.t))}</span></summary>
346
+ <div class="event-body">${flag ? `<p class="flagnote ${flag.severity}">${escapeHtml(flag.detail)}</p>` : ""}
347
+ <div class="ebgrid"><section><h5>${m.ui.parameters} <span class="n">${params.length}</span></h5>${params.length ? kvTable(params) : `<p class="muted small">${m.ui.noParameters}</p>`}${consent}</section>
348
+ <aside><h5>${m.ui.source}</h5><ul class="srclist"><li><span class="lbl">dataLayer</span>${pushLine}</li><li><span class="lbl">${m.ui.request}</span>${reqText}</li></ul></aside></div>
349
+ ${itemsBlock(m, items)}</div></details></li>`;
350
+ }
351
+
352
+ const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
353
+
354
+ function pushRow(m, run, e) {
355
+ const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : pushEvent(e.value) ?? clip(JSON.stringify(e.value) ?? String(e.value), 80);
356
+ const items = objectItems(pushItems(e.value));
357
+ const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
358
+ const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
359
+ return `<li><details class="event"><summary>${icon.chevron}<code class="ev">${escapeHtml(name)}</code>${bits ? `<span class="meta">${escapeHtml(bits)}</span>` : ""}<span class="time">${escapeHtml(seconds(run, e.t))}</span></summary>
360
+ <div class="event-body">${itemsBlock(m, items)}${items.length ? `<details class="raw"><summary>${icon.chevron} ${m.ui.rawJson}</summary><pre>${escapeHtml(pushText(e.value))}</pre></details>` : `<pre>${escapeHtml(pushText(e.value))}</pre>`}</div></details></li>`;
361
+ }
362
+
363
+ /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
364
+ function flatten(obj, prefix = "", out = []) {
365
+ for (const [k, v] of Object.entries(obj ?? {})) {
366
+ const key = prefix ? `${prefix}.${k}` : k;
367
+ if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
368
+ else out.push([key, cell(v)]);
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /**
374
+ * The parameter sets a request carried: one per event in a GA4 batch (query plus
375
+ * each body line), the query plus a flattened JSON body, or just the query.
376
+ */
377
+ function paramSets(call) {
378
+ let u;
379
+ try {
380
+ u = new URL(call.url);
381
+ } catch {
382
+ return [[]];
383
+ }
384
+ const query = [...u.searchParams];
385
+ const body = call.postData;
386
+ if (!body) return [query];
387
+ const trimmed = body.trim();
388
+ if (trimmed.startsWith("{")) {
389
+ try {
390
+ return [[...query, ...flatten(JSON.parse(trimmed))]];
391
+ } catch { /* not JSON, or cut at the size limit */ }
392
+ }
393
+ if (/^[^\s{]*=/.test(trimmed)) {
394
+ return trimmed.split("\n").filter(Boolean).map((line) => {
395
+ const merged = new Map(query);
396
+ for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
397
+ return [...merged];
398
+ });
399
+ }
400
+ return [query];
401
+ }
402
+
403
+ function paramRows(m, lang, entries) {
404
+ return entries
405
+ .map(([k, v]) => {
406
+ const tip = paramTip(k, lang);
407
+ const isItem = /^pr\d+$/.test(k);
408
+ return `<tr${isItem ? ' class="pr"' : ""}><th><span class="pk">${escapeHtml(k)}</span>${tip ? `<span class="q" tabindex="0" role="note" aria-label="${escapeHtml(tip)}" data-tip="${escapeHtml(tip)}">?</span>` : ""}</th><td>${isItem ? `<span class="muted small">${escapeHtml(paramTip(k, lang))}</span>` : escapeHtml(v)}</td></tr>`;
409
+ })
410
+ .join("");
411
+ }
412
+
413
+ /** One row per event in each network request of the step. */
414
+ function requestRows(m, lang, run, step) {
415
+ const rows = [];
416
+ for (const c of (run.network ?? []).filter((x) => x.step === step.index)) {
417
+ const sets = paramSets(c);
418
+ const hits = parseTrackingRequest(c.url, c.postData ?? null) ?? [];
419
+ const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
420
+ sets.forEach((entries, i) => {
421
+ const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
422
+ const eventEntries = entries.filter(([k]) => isEventParam(k));
423
+ const context = entries.filter(([k]) => !isEventParam(k));
424
+ const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
425
+ const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
426
+ const html = `<li data-p="${key}"><details class="event"><summary>${icon.chevron}<span class="ptag p-${key}">${escapeHtml(c.vendor)}</span><span class="method">${escapeHtml(c.method)}</span>${name ? `<code class="ev">${escapeHtml(name)}</code>` : ""}<span class="meta mono">${escapeHtml(hostPath(c.url))}${sets.length > 1 ? ` · ${escapeHtml(m.ui.inBatch(i + 1, sets.length))}` : ""}</span><span class="status mono">${escapeHtml(c.status ?? "")}</span><span class="time">${escapeHtml(seconds(run, c.t))}</span></summary>
427
+ <div class="event-body">${c.blocked ? `<p class="muted small">${m.ui.blockedNote}</p>` : ""}${c.bodyDropped ? `<p class="muted small">${m.ui.bodyDropped}</p>` : ""}${eventEntries.length ? `<h5>${m.ui.eventParams}</h5><table class="ptable glossary"><tbody>${paramRows(m, lang, eventEntries)}</tbody></table>` : ""}
428
+ ${context.length ? `<details class="raw ctx"${eventEntries.length ? "" : " open"}><summary>${icon.chevron} ${m.ui.contextParams} <span class="n">${context.length}</span></summary><table class="ptable glossary"><tbody>${paramRows(m, lang, context)}</tbody></table></details>` : ""}
429
+ ${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
430
+ rows.push({ key, label: PROVIDERS[key] ?? c.vendor ?? key, html });
431
+ });
432
+ }
433
+ return rows;
434
+ }
435
+
436
+ const stepAnchor = (ri, si) => `j${ri}-s${si}`;
437
+ const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
438
+
439
+ function stepHtml(m, lang, findings, run, ri, step) {
440
+ const hits = run.hits.filter((h) => h.step === step.index);
441
+ const flagged = hits.some((h) => hitFlag(findings, run, step, h));
442
+ const groups = [...new Set(hits.map((h) => h.platform))].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
443
+ const counts = groups.map((p) => `<span class="ptag p-${safeKey(p)}">${escapeHtml(platformLabel(p))} <b>${hits.filter((h) => h.platform === p).length}</b></span>`).join("");
444
+ const id = `s${ri}-${step.index}`;
445
+ const sortedHits = [...hits].sort((a, b) => platformRank(a.platform) - platformRank(b.platform) || a.t - b.t);
446
+ const eventRows = sortedHits.map((h) => ({ key: safeKey(h.platform), label: platformLabel(h.platform), html: eventRow(m, findings, run, step, h) }));
447
+ const pushes = (run.dataLayerLog ?? []).filter((e) => e.step === step.index);
448
+ const reqRows = requestRows(m, lang, run, step);
449
+ const skipped = skippedText(run.skipped?.[step.index]);
450
+ const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
451
+ const tab = (n, label, count) => `<input type="radio" name="${id}" id="${id}-${n}"${n === 1 ? " checked" : ""}><label for="${id}-${n}">${label} <span class="n">${count}</span></label>`;
452
+ return `<li class="step" id="${stepAnchor(ri, step.index)}"><details${flagged ? " open" : ""}>
453
+ <summary><span class="idx">${step.index}</span><span class="label"><b>${escapeHtml(step.label)}</b>${step.forced ? `<span class="muted"> · ${m.ui.clickedThrough}</span>` : ""}</span><span class="time">${escapeHtml(seconds(run, step.t))}</span><span class="counts">${counts || `<span class="muted">${m.ui.noHits}</span>`}</span>${flagged ? `<span class="tag broken dot" title="${m.ui.hasProblems}"></span>` : ""}</summary>
454
+ <div class="step-body v2"><div class="tabs steptabs">${tab(1, m.ui.tabEvents, eventRows.length)}${tab(2, "dataLayer", pushes.length)}${tab(3, m.ui.tabNetwork, reqRows.length)}
455
+ <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="muted small">${m.ui.noHits}</p>`}</div>
456
+ <div class="panel">${pushes.length ? `<ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : `<p class="muted small">${m.ui.noPushes}</p>`}</div>
457
+ <div class="panel">${reqRows.length ? filtered(m, `${id}n`, reqRows, reqRows.map((r) => r.html).join("")) : `<p class="muted small">${m.ui.noRequests}</p>`}${skippedN ? `<p class="muted small scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</div>
458
+ </div></div></details></li>`;
459
+ }
460
+
461
+ function journeysHtml(m, lang, findings, runs) {
462
+ const overview = runs
463
+ .map((r, ri) => {
464
+ const flagged = findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === r.journeyId && f.viewport === r.viewport).length;
465
+ return `<a class="jcard" href="#j${ri}"><span class="jname">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></span><span class="tag ${r.completed ? "ok" : "warning"}">${r.completed ? m.ui.completed : m.ui.notCompleted}</span><span class="jstats"><span><b>${r.steps.length - 1}</b> ${m.ui.stepsShort}</span><span><b>${r.hits.length}</b> ${m.ui.eventsShort}</span><span${flagged ? ' class="bad"' : ""}><b>${flagged}</b> ${m.ui.problemsShort}</span></span></a>`;
466
+ })
467
+ .join("");
468
+ const journeys = runs
469
+ .map((r, ri) => `<article class="journey" id="j${ri}"><header><h3>${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></h3><span class="tag ${r.completed ? "ok" : "warning"}">${r.completed ? m.ui.completed : m.ui.notCompleted}</span></header>${r.note ? `<p class="note">${escapeHtml(r.note)}</p>` : ""}
470
+ <ol class="steps">${r.steps.filter((s) => s.index > 0).map((s) => stepHtml(m, lang, findings, r, ri, s)).join("\n")}</ol></article>`)
471
+ .join("\n");
472
+ return `<nav class="jgrid" aria-label="${m.ui.timeline}">${overview}</nav>${journeys}`;
473
+ }
474
+
475
+ function whereLink(runs, w, f) {
476
+ const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
477
+ // Findings name the step by label; when labels repeat, prefer the step where the finding's event fired.
478
+ const candidates = ri >= 0 && w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
479
+ const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
480
+ const href = ri < 0 ? null : step ? `#${stepAnchor(ri, step.index)}` : `#j${ri}`;
481
+ const text = `<b>${escapeHtml(w.journey)}</b> · ${escapeHtml(w.viewport)}${w.step ? `<span class="muted"> · ${escapeHtml(w.step)}</span>` : ""}`;
482
+ return href ? `<li><a href="${href}">${text}</a></li>` : `<li>${text}</li>`;
483
+ }
484
+
485
+ /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
486
+ function evidenceHtml(evidence) {
487
+ try {
488
+ const v = JSON.parse(evidence);
489
+ if (v && typeof v === "object" && !Array.isArray(v)) return kvTable(Object.entries(v).map(([k, x]) => [k, cell(x)]));
490
+ } catch { /* plain text */ }
491
+ return `<pre>${escapeHtml(evidence)}</pre>`;
492
+ }
493
+
494
+ function findingHtml(m, runs, f, open) {
495
+ const e = f.explanation;
496
+ const steps = fixSteps(e);
497
+ return `<li class="finding ${f.severity}"><details${open ? " open" : ""}><summary>${icon.chevron}<span class="tag ${f.severity}">${m.severity[f.severity]}</span><span class="title">${escapeHtml(f.detail)}</span><span class="where-mini">${escapeHtml(m.ui.places(f.where.length))}</span></summary>
498
+ <div class="fbody">${e?.impact ? `<p class="impact"><b>${m.ui.impact}.</b> ${escapeHtml(e.impact)}</p>` : ""}
499
+ <div class="fcols"><section><h5>${m.ui.likelyCause}</h5>${e ? `<p>${escapeHtml(e.likely_cause)}</p>` : `<p class="muted">${m.ui.noExplanation}</p>`}${f.evidence ? `<h5>${m.ui.evidence}</h5>${evidenceHtml(f.evidence)}` : ""}</section>
500
+ <section>${steps.length ? `<h5>${m.ui.fixSteps}</h5><ol class="fix-steps">${steps.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ol>` : ""}<h5>${m.ui.where}</h5><ul class="where">${f.where.map((w) => whereLink(runs, w, f)).join("")}</ul></section></div></div></details></li>`;
501
+ }
502
+
503
+ function findingsHtml(m, runs, rows) {
504
+ const problems = rows.filter((f) => f.severity !== "ok");
505
+ if (!problems.length) return `<p class="lead">${m.ui.noProblems}</p>`;
506
+ let first = true;
507
+ return ["broken", "risk", "warning", "info"]
508
+ .map((sev) => {
509
+ const list = problems.filter((f) => f.severity === sev);
510
+ if (!list.length) return "";
511
+ const html = list.map((f) => {
512
+ const out = findingHtml(m, runs, f, first && sev !== "info");
513
+ first = false;
514
+ return out;
515
+ });
516
+ return `<div class="sevgroup">${m.severity[sev]} · ${list.length}</div><ul class="findings">${html.join("")}</ul>`;
517
+ })
518
+ .join("");
519
+ }
520
+
521
+ function summaryHtml(m, summary) {
522
+ const s = summaryParts(summary);
523
+ if (!s) return "";
524
+ if (s.text) return `<div class="summary">${escapeHtml(s.text)}</div>`;
525
+ return `<div class="summary v2">${s.headline ? `<p class="headline">${escapeHtml(s.headline)}</p>` : ""}${s.points.length ? `<ol>${s.points.map((p) => `<li><span class="tag ${p.severity}">${m.severity[p.severity]}</span><span>${escapeHtml(p.text)}</span></li>`).join("")}</ol>` : ""}${s.also_check.length ? `<h5>${m.ui.alsoCheck}</h5><ul class="also">${s.also_check.map((t) => `<li>${escapeHtml(t)}</li>`).join("")}</ul>` : ""}</div>`;
526
+ }
527
+
528
+ /** "Providers checked: GA4, Meta · Out of scope: Microsoft Clarity (5 requests skipped)". */
529
+ function scopeHtml(m, runs) {
530
+ const chosen = [...new Set(runs.flatMap((r) => r.providers ?? []))];
531
+ if (!chosen.length) return "";
532
+ const totals = {};
533
+ for (const r of runs) for (const counts of Object.values(r.skipped ?? {})) for (const [v, n] of Object.entries(counts)) totals[v] = (totals[v] ?? 0) + n;
534
+ const skipped = skippedText(totals);
535
+ const n = skipped.reduce((a, [, x]) => a + x, 0);
536
+ return `<div class="scope"><span>${m.ui.providersChecked}</span>${chosen.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("")}${n ? `<span>· ${escapeHtml(m.ui.outOfScope(skipped.map(([v]) => v).join(", "), n))}</span>` : ""}</div>`;
537
+ }
538
+
539
+ /** Filter rules for the platform keys in this report: picking a source hides the other rows. */
540
+ function filterCss(runs) {
541
+ const keys = new Set(PLATFORM_ORDER);
542
+ for (const r of runs) {
543
+ for (const h of r.hits) keys.add(safeKey(h.platform));
544
+ for (const c of r.network ?? []) keys.add(safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor));
545
+ }
546
+ return `${[...keys].map((k) => `.filter>.f-${k}:checked~.evlist>li:not([data-p="${k}"])`).join(",")}{display:none}`;
547
+ }
548
+
549
+ const CSS = `
550
+ :root{
551
+ --bg:#F7F9FC;--surface:#FFFFFF;--surface-2:#F0F3F9;--border:#E1E6F0;--text:#0A0E1A;--muted:#5A6480;--heading:#0A0E1A;
552
+ --accent:#4F8EF7;--accent-ink:#2F6FDB;--accent-soft:#EBF1FE;--orange:#FF6D33;--orange-soft:#FFF0E9;
553
+ --danger:#C5221F;--danger-soft:#FCE8E6;--warn:#B45309;--warn-soft:#FEF3E2;--ok:#137333;--ok-soft:#E6F4EA;
554
+ --font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace;
555
+ --radius-sm:2px;--radius-md:4px;--radius-lg:6px}
556
+ @media (prefers-color-scheme:dark){:root{
557
+ --bg:#0A0E1A;--surface:#141927;--surface-2:#182035;--border:#1E2640;--text:#F0F4FF;--muted:#8892AA;--heading:#FFFFFF;
558
+ --accent:#4F8EF7;--accent-ink:#7EAAF9;--accent-soft:#1A2F5C;--orange:#FF6D33;--orange-soft:#2A1A0F;
559
+ --danger:#F28B82;--danger-soft:#3A1F1D;--warn:#FDBA74;--warn-soft:#33241A;--ok:#81C995;--ok-soft:#16301F}}
560
+ *{box-sizing:border-box}
561
+ body{margin:0;background:var(--bg);color:var(--text);font:400 1rem/1.6 var(--font-sans);-webkit-font-smoothing:antialiased}
562
+ a{color:var(--accent-ink);text-decoration:none}a:hover{text-decoration:underline}
563
+ main{max-width:1120px;margin:0 auto;padding:24px 16px 96px}
564
+ .muted{color:var(--muted)}
565
+ code,pre,.time,.mono{font-family:var(--font-mono)}
566
+ h1,h2,h3,h4{color:var(--heading);line-height:1.2;margin:0}
567
+ h1{font-size:1.875rem;font-weight:700;letter-spacing:-.02em}
568
+ h2{font-size:1.5rem;font-weight:700;margin:64px 0 8px}
569
+ h3{font-size:1.25rem;font-weight:600}
570
+ h5{margin:16px 0 8px;font:500 .75rem/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
571
+ .lead{color:var(--muted);margin:0 0 24px;max-width:720px}
572
+ .brand{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:16px;margin-bottom:40px;border-bottom:1px solid var(--border)}
573
+ .credit{margin:32px 0 8px;font-size:13px;text-align:center}
574
+ .logo{display:inline-flex;align-items:center;gap:.4em;font:800 18px/1 var(--font-sans);letter-spacing:-.03em;color:var(--heading)}
575
+ .bars{display:inline-flex;align-items:flex-end;gap:.12em;height:.9em}.bars i{display:block;width:.22em;border-radius:1px;background:linear-gradient(var(--accent),var(--accent-soft))}
576
+ .bars i:nth-child(1){height:45%}.bars i:nth-child(2){height:100%}.bars i:nth-child(3){height:70%;background:linear-gradient(var(--orange),var(--orange-soft))}
577
+ .product{font:500 .75rem var(--font-mono);color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
578
+ .eyebrow{font:500 .75rem var(--font-mono);color:var(--accent-ink);text-transform:uppercase;letter-spacing:.08em;margin-bottom:12px}
579
+ .generated{color:var(--muted);font-size:.875rem;margin-top:8px}
580
+ .metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:1px;background:var(--border);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:32px 0 24px}
581
+ .metric{background:var(--surface);padding:16px 20px}
582
+ .metric .num{display:block;font:500 26px/1.2 var(--font-mono);color:var(--heading)}
583
+ .metric .lbl{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
584
+ .metric .lbl::before{content:"";width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
585
+ .metric.broken .lbl::before{background:var(--danger)}.metric.broken .num{color:var(--danger)}
586
+ .metric.risk .lbl::before,.metric.warning .lbl::before{background:var(--warn)}.metric.ok .lbl::before{background:var(--ok)}
587
+ .summary{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--radius-lg);padding:16px 20px;font-size:1.125rem;line-height:1.5}
588
+ .tag{display:inline-block;font:500 10px/1.5 var(--font-mono);padding:3px 8px;border-radius:var(--radius-sm);text-transform:uppercase;letter-spacing:.08em;white-space:nowrap;background:var(--accent-soft);color:var(--accent-ink)}
589
+ .tag.broken{background:var(--danger-soft);color:var(--danger)}.tag.risk,.tag.warning{background:var(--warn-soft);color:var(--warn)}.tag.ok,.tag.info{background:var(--ok-soft);color:var(--ok)}
590
+ .tag.dot{width:8px;height:8px;padding:0;background:var(--danger)}
591
+ .findings{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:12px}
592
+ .finding{background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--warn);border-radius:var(--radius-lg);padding:20px;transition:border-color .2s ease}
593
+ .finding.broken{border-left-color:var(--danger)}
594
+ .finding .head{display:flex;gap:12px;align-items:flex-start}
595
+ .finding .title{font-size:1.0625rem;font-weight:600;color:var(--heading);line-height:1.4}
596
+ .finding .cols{display:grid;gap:16px;margin-top:16px}
597
+ .where{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;font-size:.875rem}
598
+ .where a,.where li>b{color:var(--text)}.where a:hover{color:var(--accent-ink);text-decoration:none}
599
+ .where a::before{content:"→ ";color:var(--accent)}
600
+ .fix p{margin:0 0 8px;font-size:.9375rem}.fix b{font-weight:600}
601
+ details.evidence{margin-top:12px}
602
+ details.evidence>summary{font-size:.875rem;color:var(--muted)}
603
+ .okplan{list-style:none;margin:16px 0 0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--surface)}
604
+ .okplan li{padding:12px 16px;border-top:1px solid var(--border);font-size:.9375rem;display:flex;flex-direction:column;gap:8px}
605
+ .okplan li:first-child{border-top:0}.okplan .who{flex:none;min-width:150px;font-weight:500}
606
+ .okplan .what{display:flex;flex-direction:column;gap:8px}.chips{display:flex;flex-wrap:wrap;gap:6px}
607
+ .step .counts>.muted{font-size:.8125rem}
608
+ summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
609
+ summary:focus-visible,.tabs>input:focus-visible+label,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
610
+ .chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
611
+ .jgrid{display:grid;grid-template-columns:1fr;gap:1px;background:var(--border);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;margin:24px 0 8px}
612
+ .jcard{display:grid;grid-template-columns:1fr auto;gap:8px;background:var(--surface);padding:16px 20px;color:var(--text);transition:background .15s ease}
613
+ .jcard:hover{background:var(--surface-2);text-decoration:none}
614
+ .jname{font-weight:600}.jstats{grid-column:1/-1;display:flex;gap:16px;font-size:.875rem;color:var(--muted)}
615
+ .jstats b{font:500 1rem var(--font-mono);color:var(--heading);margin-right:2px}.jstats .bad b{color:var(--danger)}
616
+ .journey{margin-top:48px;scroll-margin-top:16px}
617
+ .journey>header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)}
618
+ .note{color:var(--muted);font-size:.875rem;margin:8px 0 0}
619
+ .steps{list-style:none;margin:16px 0 0;padding:0;position:relative}
620
+ .steps::before{content:"";position:absolute;left:13px;top:8px;bottom:8px;width:1px;background:var(--border)}
621
+ .step{position:relative;margin:0 0 8px;scroll-margin-top:16px}
622
+ .step>details>summary{display:grid;grid-template-columns:28px 1fr auto;column-gap:12px;row-gap:6px;align-items:center;padding:8px 12px 8px 0;border-radius:var(--radius-lg)}
623
+ .step>details>summary:hover .label b{color:var(--accent-ink)}
624
+ .idx{position:relative;display:grid;place-items:center;width:28px;height:28px;border-radius:var(--radius-md);background:var(--surface);border:1px solid var(--border);font:500 .8125rem var(--font-mono);color:var(--muted)}
625
+ .step>details[open]>summary .idx{background:var(--accent);border-color:var(--accent);color:#fff}
626
+ .step .label{min-width:0;overflow-wrap:anywhere}
627
+ .step .time,.event .time{font-size:.75rem;color:var(--muted);white-space:nowrap}
628
+ .step .counts{grid-column:2/-1;display:flex;flex-wrap:wrap;gap:6px}
629
+ .step .tag.dot{position:absolute;left:22px;top:6px}
630
+ .step-body{margin:4px 0 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:12px}
631
+ .ptag{display:inline-flex;align-items:center;gap:6px;font:500 .75rem var(--font-mono);color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface)}
632
+ .ptag b{font-weight:500;color:var(--heading)}
633
+ .ptag::before{content:"";width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
634
+ .ptag.p-ga4::before{background:var(--orange)}.ptag.p-meta::before{background:var(--accent)}.ptag.p-google_ads::before{background:var(--ok)}.ptag.p-tiktok::before{background:var(--heading)}
635
+ .platform+.platform{margin-top:16px}
636
+ .platform h4{margin:0 0 8px}
637
+ .events{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
638
+ .events>li+li{border-top:1px solid var(--border)}
639
+ .event>summary{display:flex;align-items:center;gap:10px;padding:10px 12px;flex-wrap:wrap}
640
+ .event>summary:hover{background:var(--surface-2)}
641
+ .event.flag-broken>summary{background:var(--danger-soft)}.event.flag-risk>summary,.event.flag-warning>summary{background:var(--warn-soft)}
642
+ .ev{font:500 .875rem var(--font-mono);color:var(--heading);overflow-wrap:anywhere}
643
+ .event .meta,.context .meta{font-size:.75rem;color:var(--muted)}
644
+ .event .time{margin-left:auto}
645
+ .tabs{display:grid;grid-template-columns:repeat(3,minmax(0,auto)) 1fr;border-top:1px solid var(--border);background:var(--surface)}
646
+ .tabs>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
647
+ .tabs>label{padding:8px 6px;font-size:.75rem;white-space:nowrap;min-width:0;font-weight:500;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
648
+ .tabs>label:hover{color:var(--text)}
649
+ .tabs>label .n{font:500 .6875rem var(--font-mono);background:var(--surface-2);color:var(--muted);padding:1px 4px;border-radius:var(--radius-sm)}
650
+ .tabs>input:checked+label{color:var(--accent-ink);border-bottom-color:var(--accent)}
651
+ .tabs>input:checked+label .n{background:var(--accent-soft);color:var(--accent-ink)}
652
+ .tabs>.panel{display:none;grid-column:1/-1;padding:12px;border-top:1px solid var(--border)}
653
+ .tabs>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs>input:nth-of-type(3):checked~.panel:nth-of-type(3){display:block}
654
+ dl.kv{display:grid;grid-template-columns:1fr;gap:0 16px;margin:0;font:.8125rem/1.5 var(--font-mono)}
655
+ dl.kv dt{color:var(--muted)}dl.kv dd+dt{margin-top:6px}dl.kv dd{margin:0;overflow-wrap:anywhere;color:var(--text)}
656
+ .consent{font-size:.75rem;padding:1px 6px;border-radius:var(--radius-sm)}.consent.granted{background:var(--ok-soft);color:var(--ok)}.consent.denied{background:var(--warn-soft);color:var(--warn)}
657
+ .hint{font-size:.8125rem;color:var(--muted);margin:0 0 8px}
658
+ pre{margin:0;font:.8125rem/1.5 var(--font-mono);background:var(--surface-2);border:1px solid var(--border);border-radius:var(--radius-md);padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
659
+ pre.url{font-size:.75rem}
660
+ .push{display:grid;grid-template-columns:1fr;gap:4px;align-items:start;margin-bottom:8px}
661
+ .push .time{font-size:.75rem;color:var(--muted)}
662
+ .req{margin-bottom:8px}
663
+ .req-head{display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:.8125rem;margin-bottom:6px}
664
+ .req-head .method{font:500 .6875rem var(--font-mono);padding:2px 6px;border-radius:var(--radius-sm);background:var(--accent-soft);color:var(--accent-ink)}
665
+ .req-head .status{font:500 .8125rem var(--font-mono);color:var(--ok)}
666
+ .req-head .time{font-size:.75rem;color:var(--muted)}
667
+ details.context{margin-top:16px;border-top:1px solid var(--border);padding-top:12px}
668
+ details.context>summary{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:.875rem;font-weight:500;color:var(--muted)}
669
+ @media (min-width:768px){
670
+ main{padding:40px 24px 96px}
671
+ h1{font-size:2.25rem}
672
+ .metrics{grid-template-columns:repeat(4,1fr)}
673
+ .finding .cols{grid-template-columns:minmax(220px,1fr) 2fr}
674
+ .jgrid{grid-template-columns:repeat(2,1fr)}
675
+ .okplan li{flex-direction:row;gap:16px}
676
+ .step>details>summary{grid-template-columns:28px 1fr auto auto}
677
+ .step .counts{grid-column:auto;justify-content:flex-end}
678
+ .step-body{margin-left:40px;padding:16px}
679
+ .tabs>label{padding:8px 14px;font-size:.8125rem}
680
+ .push{grid-template-columns:52px 1fr;gap:8px}.push .time{padding-top:11px}
681
+ dl.kv{grid-template-columns:minmax(110px,max-content) 1fr;gap:4px 16px}dl.kv dd+dt{margin-top:0}
682
+ }
683
+ @media (max-width:339px){.tabs>label{white-space:normal}}
684
+ @media (prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
685
+ @media print{details>*{display:block}.tabs>.panel{display:block}.tabs>label{display:none}}
686
+ .small{font-size:.8125rem}
687
+ .scope{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;margin:0 0 24px;font-size:.875rem;color:var(--muted)}
688
+ .summary.v2{font-size:1rem;line-height:1.6;padding:20px 24px}
689
+ .summary.v2 .headline{font-size:1.125rem;font-weight:600;color:var(--heading);margin:0 0 12px;line-height:1.45}
690
+ .summary.v2 ol{margin:0;padding:0;list-style:none;display:grid;gap:8px}
691
+ .summary.v2 ol li{display:grid;grid-template-columns:64px 1fr;gap:12px;align-items:baseline}
692
+ .summary.v2 ol li .tag{justify-self:start}
693
+ .summary.v2 h5{margin-top:20px}
694
+ .summary.v2 ul.also{margin:0;padding-left:18px;display:grid;gap:6px}
695
+ .finding{padding:0}
696
+ .finding>details>summary{display:flex;align-items:center;gap:12px;padding:16px 20px}
697
+ .finding .title{flex:1;min-width:0}
698
+ .where-mini{font-size:.75rem;color:var(--muted);white-space:nowrap}
699
+ .fbody{padding:0 16px 20px}
700
+ .impact{margin:0 0 16px;padding:10px 12px;background:var(--surface-2);border-radius:var(--radius-md);font-size:.9375rem}
701
+ .fcols{display:grid;gap:24px}
702
+ .fcols p{margin:0;font-size:.9375rem}
703
+ .fix-steps{margin:0;padding-left:20px;display:grid;gap:6px;font-size:.9375rem}
704
+ .sevgroup{margin:24px 0 8px;font:500 .75rem var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
705
+ .sevgroup+.findings{margin-top:0}
706
+ .step-body.v2{padding:0;overflow:visible}
707
+ .steptabs{border-top:0;border-radius:var(--radius-lg)}
708
+ .steptabs>label{font-size:.875rem;padding:12px 16px}
709
+ .steptabs>.panel{padding:12px}
710
+ .filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
711
+ .filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
712
+ .filter>label{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border:1px solid var(--border);border-radius:var(--radius-md);font-size:.8125rem;cursor:pointer;color:var(--muted);background:var(--surface)}
713
+ .filter>label:hover{border-color:var(--accent)}
714
+ .filter>input:checked+label{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)}
715
+ .filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
716
+ .filter .n{font:500 .6875rem var(--font-mono)}
717
+ .dot{width:8px;height:8px;border-radius:var(--radius-sm);background:var(--muted)}
718
+ .dot.p-ga4{background:var(--orange)}.dot.p-meta{background:var(--accent)}.dot.p-google_ads{background:var(--ok)}.dot.p-tiktok{background:var(--heading)}
719
+ .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius-lg);flex-basis:100%}
720
+ .evlist>li+li{border-top:1px solid var(--border)}
721
+ .event-body{padding:4px 12px 16px}
722
+ .flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warn-soft);color:var(--warn);font-size:.875rem}
723
+ .flagnote.broken{background:var(--danger-soft);color:var(--danger)}
724
+ .ebgrid{display:grid;gap:16px}
725
+ .ptable{width:100%;border-collapse:collapse;font:.8125rem/1.5 var(--font-mono)}
726
+ .ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
727
+ .ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
728
+ .glossary th{min-width:170px}
729
+ .ptable td{overflow-wrap:anywhere}
730
+ .ptable tr:last-child>*{border-bottom:0}
731
+ details.ctx>summary .n,h5 .n{font:500 .6875rem var(--font-mono);background:var(--surface-2);padding:1px 5px;border-radius:var(--radius-sm)}
732
+ h5 .n{margin-left:4px}
733
+ .consents{display:flex;flex-wrap:wrap;gap:6px}
734
+ .srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:.8125rem}
735
+ .srclist li{display:grid;gap:2px}.srclist .lbl{font-size:.75rem;color:var(--muted)}
736
+ .method{font:500 .6875rem var(--font-mono);padding:2px 6px;border-radius:var(--radius-sm);background:var(--accent-soft);color:var(--accent-ink)}
737
+ .status{color:var(--ok);font-family:var(--font-mono);font-size:.8125rem}
738
+ .item{border:1px solid var(--border);border-radius:var(--radius-md);padding:12px;margin-bottom:8px;background:var(--surface)}
739
+ .item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px 12px;margin-bottom:10px}
740
+ .item-idx{font:500 .75rem var(--font-mono);color:var(--muted)}
741
+ .item-price{margin-left:auto;color:var(--heading)}
742
+ .grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
743
+ .grid4 dt{font:.6875rem var(--font-mono);color:var(--muted);overflow-wrap:anywhere}.grid4 dd{margin:0;font:.8125rem var(--font-mono);overflow-wrap:anywhere}
744
+ h6{margin:12px 0 6px;font:500 .6875rem var(--font-mono);text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
745
+ .pk{margin-right:4px}
746
+ .q{position:relative;display:inline-grid;place-items:center;width:15px;height:15px;border:1px solid var(--border);border-radius:50%;font:500 10px var(--font-sans);color:var(--muted);cursor:help;vertical-align:1px}
747
+ .q:hover,.q:focus{border-color:var(--accent);color:var(--accent-ink);outline:none}
748
+ .q:hover::after,.q:focus::after{content:attr(data-tip);position:absolute;left:0;top:calc(100% + 6px);z-index:5;width:260px;padding:8px 10px;background:var(--heading);color:var(--bg);font:400 .8125rem/1.45 var(--font-sans);border-radius:var(--radius-md);white-space:normal;text-align:left}
749
+ tr.pr td{color:var(--muted)}
750
+ details.raw{margin-top:12px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:.8125rem;color:var(--muted)}details.raw pre{margin-top:8px}
751
+ .scope-note{margin:12px 0 0}
752
+ @media (min-width:768px){
753
+ .fcols{grid-template-columns:3fr 2fr}
754
+ .event-body{padding:4px 16px 16px 38px}
755
+ .steptabs>.panel{padding:16px}
756
+ .ebgrid{grid-template-columns:2fr 1fr}
757
+ .grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
758
+ .summary.v2 ol li{grid-template-columns:72px 1fr}
759
+ .fbody{padding:0 20px 20px 48px}
760
+ }
761
+ @media print{.filter>label,.filter>input{display:none}}
762
+ `;
763
+
764
+ export function renderHtml({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
765
+ const m = messages(lang);
766
+ const rows = attachExplanations(findings, explained);
767
+ const counts = summarize(rows);
768
+ const okItems = rows.filter((f) => f.severity === "ok").map((f) => `<li><span class="who">${escapeHtml(f.where[0].journey)} <span class="muted">· ${escapeHtml(f.where[0].viewport)}</span></span><span class="what">${planMatchHtml(f.detail)}</span></li>`).join("");
769
+ const metric = (sev) => `<div class="metric ${sev}"><span class="num">${counts[sev]}</span><span class="lbl">${m.severity[sev]}</span></div>`;
770
+
771
+ return `<!doctype html>
772
+ <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
773
+ <title>${escapeHtml(title)}</title>
774
+ <style>${CSS}${filterCss(runs)}</style></head><body><main>
775
+ <div class="brand">${branding ? `<span class="logo"><span class="bars" aria-hidden="true"><i></i><i></i><i></i></span>analitik işler</span>` : ""}<span class="product">AI Tag Debugger</span></div>
776
+ <header>
777
+ <div class="eyebrow">${m.ui.eyebrow}</div>
778
+ <h1>${escapeHtml(title)}</h1>
779
+ <div class="generated">${escapeHtml(m.ui.generated(generatedAt))}</div>
780
+ </header>
781
+ <section class="metrics" aria-label="${m.ui.findings}">${metric("broken")}${metric("risk")}${metric("warning")}${metric("ok")}</section>
782
+ ${scopeHtml(m, runs)}
783
+ ${summaryHtml(m, explained?.summary)}
784
+ <section><h2>${m.ui.findings}</h2>
785
+ ${findingsHtml(m, runs, rows)}</section>
786
+ ${okItems ? `<section><h2>${m.ui.matchingPlan}</h2><ul class="okplan">${okItems}</ul></section>` : ""}
787
+ <section><h2>${m.ui.timeline}</h2>
788
+ <p class="lead">${m.ui.timelineLeadV2}</p>
789
+ ${journeysHtml(m, lang, findings, runs)}</section>
790
+ ${branding ? `<footer class="credit muted">${m.ui.generatedBy} · <a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a></footer>` : ""}
791
+ </main></body></html>`;
792
+ }
793
+
794
+ const csvCell = (v) => {
795
+ let s = v === null || v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
796
+ // Text from the page could run as a formula in Excel or Sheets; a leading ' keeps it text.
797
+ if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
798
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
799
+ };
800
+
801
+ /** One row per tracking hit, dataLayer push and network call, in time order, for spreadsheets. */
802
+ export function renderCsv({ findings, runs }) {
803
+ const header = ["journey", "viewport", "step_index", "step", "time_s", "type", "platform_or_vendor", "event", "details", "status", "flag"];
804
+ const rows = [];
805
+ for (const r of runs) {
806
+ const stepOf = (i) => r.steps[i] ?? { index: i, label: "" };
807
+ const t = (x) => ((x - r.startedAt) / 1000).toFixed(2);
808
+ for (const h of r.hits) {
809
+ const s = stepOf(h.step);
810
+ rows.push({ at: h.t, cells: [r.journeyId, r.viewport, s.index, s.label, t(h.t), "hit", h.platform, h.name, h.params, "", hitSeverity(findings, r, s, h) ?? ""] });
811
+ }
812
+ for (const e of r.dataLayerLog ?? []) {
813
+ const s = stepOf(e.step);
814
+ const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : e.value?.event ?? "";
815
+ rows.push({ at: e.t, cells: [r.journeyId, r.viewport, s.index, s.label, t(e.t), "datalayer", "", name, e.value, "", ""] });
816
+ }
817
+ for (const c of r.network ?? []) {
818
+ const s = stepOf(c.step);
819
+ rows.push({ at: c.t, cells: [r.journeyId, r.viewport, s.index, s.label, t(c.t), "network", c.vendor, c.method, c.url, c.blocked ? `${c.status} (local)` : c.status, ""] });
820
+ }
821
+ }
822
+ // Keep each run's rows together, in time order within the run.
823
+ const order = new Map(runs.map((r, i) => [`${r.journeyId}|${r.viewport}`, i]));
824
+ rows.sort((a, b) => order.get(`${a.cells[0]}|${a.cells[1]}`) - order.get(`${b.cells[0]}|${b.cells[1]}`) || a.at - b.at);
825
+ // The BOM makes Excel open the file as UTF-8, so Turkish characters display correctly.
826
+ return "" + [header, ...rows.map((r) => r.cells)].map((row) => row.map(csvCell).join(",")).join("\n") + "\n";
827
+ }