@analitikisler/ai-tag-debugger 0.0.0-stage → 0.6.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.
- package/CHANGELOG.md +118 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/package.json +56 -4
- package/src/agent.js +191 -0
- package/src/audit.js +100 -0
- package/src/brand.js +14 -0
- package/src/browser.js +579 -0
- package/src/checks.js +213 -0
- package/src/cli.js +155 -0
- package/src/egress.js +134 -0
- package/src/glossary.js +139 -0
- package/src/guard.js +129 -0
- package/src/i18n.js +273 -0
- package/src/journeys.js +138 -0
- package/src/mcp.js +425 -0
- package/src/parsers.js +234 -0
- package/src/providers.js +250 -0
- package/src/redact.js +130 -0
- package/src/report.js +987 -0
package/src/report.js
ADDED
|
@@ -0,0 +1,987 @@
|
|
|
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
|
+
import { LOGOS } from "./brand.js";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
For each finding with severity broken, risk or warning, give:
|
|
14
|
+
- impact: one sentence on what this does to the data, the reports or ad spend;
|
|
15
|
+
- likely_cause: the most likely cause, in plain language a marketing manager can follow and an analyst can act on;
|
|
16
|
+
- fix_steps: the fix as short, ordered steps.
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
Then write the summary:
|
|
20
|
+
- headline: one sentence naming what matters most for data quality and ad spend;
|
|
21
|
+
- points: the main problems, most important first, each one sentence with its severity;
|
|
22
|
+
- 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.
|
|
23
|
+
|
|
24
|
+
Formatting: in the headline, points, also_check, impact, likely_cause and fix_steps, wrap numbers and counts in double asterisks (for example sent **2** times, at **+70.4s**) and event, parameter, tag and trigger names in backticks (for example \`begin_checkout\`). Use no other formatting: no headings, lists, links or HTML.`;
|
|
25
|
+
|
|
26
|
+
const SEVERITIES = ["broken", "risk", "warning", "info"];
|
|
27
|
+
const EXPLAIN_SCHEMA = {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
summary: {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
headline: { type: "string", description: "One sentence on what matters most." },
|
|
34
|
+
points: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: { severity: { type: "string", enum: SEVERITIES }, text: { type: "string" } },
|
|
39
|
+
required: ["severity", "text"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
also_check: { type: "array", items: { type: "string" } },
|
|
44
|
+
},
|
|
45
|
+
required: ["headline", "points", "also_check"],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
explanations: {
|
|
49
|
+
type: "array",
|
|
50
|
+
items: {
|
|
51
|
+
type: "object",
|
|
52
|
+
properties: {
|
|
53
|
+
finding: { type: "integer", description: "Index of the finding in the input list." },
|
|
54
|
+
impact: { type: "string" },
|
|
55
|
+
likely_cause: { type: "string" },
|
|
56
|
+
fix_steps: { type: "array", items: { type: "string" } },
|
|
57
|
+
},
|
|
58
|
+
required: ["finding", "impact", "likely_cause", "fix_steps"],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["summary", "explanations"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
|
|
68
|
+
function summaryParts(summary) {
|
|
69
|
+
if (!summary) return null;
|
|
70
|
+
if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
|
|
71
|
+
return {
|
|
72
|
+
headline: String(summary.headline ?? ""),
|
|
73
|
+
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) })),
|
|
74
|
+
also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fix steps of an explanation; older explanations had one "fix" string. */
|
|
79
|
+
const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
|
|
80
|
+
|
|
81
|
+
function hitsByStep(run) {
|
|
82
|
+
return run.steps.map((step) => ({
|
|
83
|
+
step: step.label,
|
|
84
|
+
hits: run.hits
|
|
85
|
+
.filter((h) => h.step === step.index)
|
|
86
|
+
.map((h) => ({ platform: h.platform, event: h.name, params: h.params, ...(h.consent ? { consent: h.consent } : {}) })),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Asks the AI model to explain the findings. Returns null on failure so the
|
|
92
|
+
* deterministic report is still written.
|
|
93
|
+
*/
|
|
94
|
+
export async function explainFindings({ findings, plan, runs, gtmContainer, provider, providerName, client, model, lang = "en", log = () => {} }) {
|
|
95
|
+
const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity));
|
|
96
|
+
if (actionable.length === 0) return { summary: { headline: messages(lang).ui.allGood, points: [], also_check: [] }, explanations: [] };
|
|
97
|
+
try {
|
|
98
|
+
const input = {
|
|
99
|
+
findings: actionable,
|
|
100
|
+
measurement_plan: plan,
|
|
101
|
+
journeys: runs.map((r) => ({ journey: r.journeyId, viewport: r.viewport, completed: r.completed, note: r.note, steps: hitsByStep(r), page_errors: r.pageErrors })),
|
|
102
|
+
};
|
|
103
|
+
const texts = [`Audit data:\n${JSON.stringify(input, null, 1)}`];
|
|
104
|
+
if (gtmContainer) texts.push(`GTM container export:\n${JSON.stringify(gtmContainer)}`);
|
|
105
|
+
|
|
106
|
+
const ai = provider ?? (await createProvider({ name: providerName, model, client }));
|
|
107
|
+
const result = await ai.json({
|
|
108
|
+
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.`,
|
|
109
|
+
texts,
|
|
110
|
+
schema: EXPLAIN_SCHEMA,
|
|
111
|
+
});
|
|
112
|
+
if (result.error) {
|
|
113
|
+
log(`The model did not return explanations (${result.error}); writing the report without them.`);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return result.value;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log(`Could not get explanations from the model: ${err.message}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
124
|
+
|
|
125
|
+
function attachExplanations(findings, explained) {
|
|
126
|
+
const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
|
|
127
|
+
return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i) }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
|
|
131
|
+
|
|
132
|
+
/** The most severe finding that points at this hit, if any (for highlighting in the timeline). */
|
|
133
|
+
function hitSeverity(findings, run, step, hit) {
|
|
134
|
+
let worst = null;
|
|
135
|
+
for (const f of findings) {
|
|
136
|
+
if (!(f.severity in SEVERITY_RANK)) continue;
|
|
137
|
+
if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
|
|
138
|
+
if (f.platform !== hit.platform || f.event !== hit.name) continue;
|
|
139
|
+
if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst]) worst = f.severity;
|
|
140
|
+
}
|
|
141
|
+
return worst;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
|
|
145
|
+
const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
|
|
146
|
+
const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) => JSON.stringify(v)).join(", ")})` : JSON.stringify(value));
|
|
147
|
+
|
|
148
|
+
// Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
|
|
149
|
+
const mdText = (v) => String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
150
|
+
const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
|
|
151
|
+
|
|
152
|
+
export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
|
|
153
|
+
const m = messages(lang);
|
|
154
|
+
const rows = attachExplanations(findings, explained);
|
|
155
|
+
const counts = summarize(rows);
|
|
156
|
+
const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", `**${m.ui.counts(counts)}**`, ""];
|
|
157
|
+
const sum = summaryParts(explained?.summary);
|
|
158
|
+
if (sum) {
|
|
159
|
+
lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
|
|
160
|
+
if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
|
|
161
|
+
if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
|
|
162
|
+
}
|
|
163
|
+
lines.push(`## ${m.ui.findings}`, "");
|
|
164
|
+
for (const f of rows.filter((r) => r.severity !== "ok")) {
|
|
165
|
+
lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
|
|
166
|
+
lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
|
|
167
|
+
if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
|
|
168
|
+
if (f.explanation) {
|
|
169
|
+
if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
|
|
170
|
+
lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
}
|
|
174
|
+
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)}`);
|
|
175
|
+
lines.push("", `## ${m.ui.timeline}`, "");
|
|
176
|
+
for (const r of runs) {
|
|
177
|
+
lines.push(`### ${mdText(r.journeyId)} (${mdText(r.viewport)}): ${r.completed ? m.ui.completed : m.ui.notCompleted}`, "", mdText(r.note), "");
|
|
178
|
+
for (const step of r.steps.filter((s) => s.index > 0)) {
|
|
179
|
+
lines.push(`${step.index}. **${mdText(step.label)}** (${seconds(r, step.t)})${step.forced ? ` _${m.ui.clickedThrough}_` : ""}`);
|
|
180
|
+
for (const h of r.hits.filter((x) => x.step === step.index)) {
|
|
181
|
+
const flag = hitSeverity(findings, r, step, h);
|
|
182
|
+
const params = Object.entries(h.params).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
183
|
+
lines.push(` - ${flag ? `**${m.severity[flag]}** ` : ""}${mdText(`${h.platform}:${h.name}${params ? ` (${params})` : ""}`)}`);
|
|
184
|
+
}
|
|
185
|
+
for (const e of (r.dataLayerLog ?? []).filter((x) => x.step === step.index)) {
|
|
186
|
+
lines.push(` - dataLayer: \`${mdCode(isConsentEntry(e.value) ? formatPush(e.value) : JSON.stringify(e.value))}\``);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
lines.push("");
|
|
190
|
+
}
|
|
191
|
+
if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x"];
|
|
196
|
+
const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
197
|
+
const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
|
|
198
|
+
|
|
199
|
+
// Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
|
|
200
|
+
const EVENT_ALIASES = {
|
|
201
|
+
pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
|
|
202
|
+
addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
|
|
203
|
+
completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
|
|
204
|
+
};
|
|
205
|
+
const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
206
|
+
const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
|
|
207
|
+
// GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
|
|
208
|
+
const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
|
|
209
|
+
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));
|
|
210
|
+
|
|
211
|
+
// Per run: step index -> event key -> the pushes with that event name. Built once so rendering stays linear.
|
|
212
|
+
const pushIndexCache = new WeakMap();
|
|
213
|
+
function pushIndex(run) {
|
|
214
|
+
let index = pushIndexCache.get(run);
|
|
215
|
+
if (!index) {
|
|
216
|
+
index = new Map();
|
|
217
|
+
for (const e of run.dataLayerLog ?? []) {
|
|
218
|
+
const name = pushEvent(e.value);
|
|
219
|
+
if (typeof name !== "string" || !eventKey(name)) continue;
|
|
220
|
+
if (!index.has(e.step)) index.set(e.step, new Map());
|
|
221
|
+
const byKey = index.get(e.step);
|
|
222
|
+
const key = eventKey(name);
|
|
223
|
+
if (!byKey.has(key)) byKey.set(key, []);
|
|
224
|
+
byKey.get(key).push(e);
|
|
225
|
+
}
|
|
226
|
+
pushIndexCache.set(run, index);
|
|
227
|
+
}
|
|
228
|
+
return index;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Every dataLayer push in the hit's step whose event name matches it (directly or through a pixel alias). */
|
|
232
|
+
const matchingPushes = (run, hit) => pushIndex(run).get(hit.step)?.get(eventKey(hit.name)) ?? [];
|
|
233
|
+
|
|
234
|
+
/** The push behind the hit. When the same event fires as often as it was pushed (duplicates), they are paired in order. */
|
|
235
|
+
function pushesForHit(run, hit) {
|
|
236
|
+
const pushes = matchingPushes(run, hit);
|
|
237
|
+
const key = eventKey(hit.name);
|
|
238
|
+
const twins = run.hits.filter((h) => h.step === hit.step && h.platform === hit.platform && eventKey(h.name) === key);
|
|
239
|
+
return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
|
|
243
|
+
function callsForHit(run, hit) {
|
|
244
|
+
const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
|
|
245
|
+
const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
|
|
246
|
+
return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
const icon = {
|
|
251
|
+
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>',
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const kvTable = (rows) => `<table class="ptable"><tbody>${rows.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</tbody></table>`;
|
|
255
|
+
// Platform keys go into class names, ids and attribute selectors.
|
|
256
|
+
const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
|
|
257
|
+
const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
258
|
+
|
|
259
|
+
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"]);
|
|
260
|
+
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"]);
|
|
261
|
+
|
|
262
|
+
/** GA4 item strings (pr1=…) as { fields, custom } pairs. */
|
|
263
|
+
const ga4Items = (strings) => (strings ?? []).map(decodeGa4Item);
|
|
264
|
+
/** dataLayer ecommerce.items objects as { fields, custom } pairs: standard GA4 item keys, then the site's own. */
|
|
265
|
+
const objectItems = (list) =>
|
|
266
|
+
list.filter((it) => it && typeof it === "object").map((it) => {
|
|
267
|
+
const entries = Object.entries(it).map(([k, v]) => [k, cell(v)]);
|
|
268
|
+
return { fields: entries.filter(([k]) => STANDARD_ITEM_KEYS.has(k)), custom: entries.filter(([k]) => !STANDARD_ITEM_KEYS.has(k)) };
|
|
269
|
+
});
|
|
270
|
+
/** The items array of a GTM-style ({ecommerce: {items}}) or gtag.js-style (["event", name, {items}]) push. */
|
|
271
|
+
function pushItems(value) {
|
|
272
|
+
const items = Array.isArray(value) ? value[2]?.items : value?.ecommerce?.items ?? value?.items;
|
|
273
|
+
return Array.isArray(items) ? items : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function itemCards(m, items) {
|
|
277
|
+
return items
|
|
278
|
+
.map(({ fields, custom }, i) => {
|
|
279
|
+
const get = (k) => fields.find(([n]) => n === k)?.[1];
|
|
280
|
+
const price = get("price");
|
|
281
|
+
const qty = get("quantity");
|
|
282
|
+
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>`;
|
|
283
|
+
const rest = fields.filter(([n]) => !["item_name", "item_id", "price", "quantity"].includes(n));
|
|
284
|
+
const grid = (rows) => `<dl class="grid4">${rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v === "" ? "–" : v)}</dd></div>`).join("")}</dl>`;
|
|
285
|
+
return `<div class="item">${head}${rest.length ? grid(rest) : ""}${custom.length ? `<h6>${m.ui.customItemParams}</h6>${grid(custom)}` : ""}</div>`;
|
|
286
|
+
})
|
|
287
|
+
.join("");
|
|
288
|
+
}
|
|
289
|
+
const itemsBlock = (m, items) => (items.length ? `<h5>${m.ui.items} <span class="n">${items.length}</span></h5>${itemCards(m, items)}` : "");
|
|
290
|
+
|
|
291
|
+
/** The most severe finding that points at this hit, as { severity, detail }, or null. */
|
|
292
|
+
function hitFlag(findings, run, step, hit) {
|
|
293
|
+
let worst = null;
|
|
294
|
+
for (const f of findings) {
|
|
295
|
+
if (!(f.severity in SEVERITY_RANK)) continue;
|
|
296
|
+
if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
|
|
297
|
+
if (f.platform !== hit.platform || f.event !== hit.name) continue;
|
|
298
|
+
if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
|
|
299
|
+
}
|
|
300
|
+
return worst && { severity: worst.severity, detail: worst.detail };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const hostPath = (url) => {
|
|
304
|
+
try {
|
|
305
|
+
const u = new URL(url);
|
|
306
|
+
return u.host + u.pathname;
|
|
307
|
+
} catch {
|
|
308
|
+
return String(url ?? "");
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/** Radio buttons that filter the list after them by data-p. Only sources present are offered. */
|
|
313
|
+
function filterBar(m, name, rows) {
|
|
314
|
+
const counts = new Map();
|
|
315
|
+
for (const r of rows) counts.set(r.key, { label: r.label, n: (counts.get(r.key)?.n ?? 0) + 1 });
|
|
316
|
+
if (counts.size < 2) return "";
|
|
317
|
+
const keys = [...counts.keys()].sort((a, b) => platformRank(a) - platformRank(b) || counts.get(a).label.localeCompare(counts.get(b).label));
|
|
318
|
+
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
|
|
319
|
+
.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>`)
|
|
320
|
+
.join("")}`;
|
|
321
|
+
}
|
|
322
|
+
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>`;
|
|
323
|
+
|
|
324
|
+
function eventRow(m, findings, run, step, hit) {
|
|
325
|
+
const flag = hitFlag(findings, run, step, hit);
|
|
326
|
+
const pushes = pushesForHit(run, hit);
|
|
327
|
+
const calls = callsForHit(run, hit);
|
|
328
|
+
const items = ga4Items(hit.items);
|
|
329
|
+
const params = Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && items.length));
|
|
330
|
+
const bits = [];
|
|
331
|
+
if (items.length) bits.push(m.ui.itemsCount(items.length));
|
|
332
|
+
if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
|
|
333
|
+
if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
|
|
334
|
+
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>` : "";
|
|
335
|
+
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>`;
|
|
336
|
+
const reqText = calls.length
|
|
337
|
+
? 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("")
|
|
338
|
+
: `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
|
|
339
|
+
return `<li data-p="${safeKey(hit.platform)}"><details class="event${flag ? ` flag-${flag.severity}` : ""}"${flag ? " open" : ""}>
|
|
340
|
+
<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>
|
|
341
|
+
<div class="event-body">${flag ? `<p class="flagnote ${flag.severity}">${escapeHtml(flag.detail)}</p>` : ""}
|
|
342
|
+
<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>
|
|
343
|
+
<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>
|
|
344
|
+
${itemsBlock(m, items)}</div></details></li>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
|
|
348
|
+
|
|
349
|
+
function pushRow(m, run, e) {
|
|
350
|
+
const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : pushEvent(e.value) ?? clip(JSON.stringify(e.value) ?? String(e.value), 80);
|
|
351
|
+
const items = objectItems(pushItems(e.value));
|
|
352
|
+
const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
|
|
353
|
+
const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
|
|
354
|
+
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>
|
|
355
|
+
<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>`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
|
|
359
|
+
function flatten(obj, prefix = "", out = []) {
|
|
360
|
+
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
361
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
362
|
+
if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
|
|
363
|
+
else out.push([key, cell(v)]);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The parameter sets a request carried: one per event in a GA4 batch (query plus
|
|
370
|
+
* each body line), the query plus a flattened JSON body, or just the query.
|
|
371
|
+
*/
|
|
372
|
+
function paramSets(call) {
|
|
373
|
+
let u;
|
|
374
|
+
try {
|
|
375
|
+
u = new URL(call.url);
|
|
376
|
+
} catch {
|
|
377
|
+
return [[]];
|
|
378
|
+
}
|
|
379
|
+
const query = [...u.searchParams];
|
|
380
|
+
const body = call.postData;
|
|
381
|
+
if (!body) return [query];
|
|
382
|
+
const trimmed = body.trim();
|
|
383
|
+
if (trimmed.startsWith("{")) {
|
|
384
|
+
try {
|
|
385
|
+
return [[...query, ...flatten(JSON.parse(trimmed))]];
|
|
386
|
+
} catch { /* not JSON, or cut at the size limit */ }
|
|
387
|
+
}
|
|
388
|
+
if (/^[^\s{]*=/.test(trimmed)) {
|
|
389
|
+
return trimmed.split("\n").filter(Boolean).map((line) => {
|
|
390
|
+
const merged = new Map(query);
|
|
391
|
+
for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
|
|
392
|
+
return [...merged];
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return [query];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function paramRows(m, lang, entries) {
|
|
399
|
+
return entries
|
|
400
|
+
.map(([k, v]) => {
|
|
401
|
+
const tip = paramTip(k, lang);
|
|
402
|
+
const isItem = /^pr\d+$/.test(k);
|
|
403
|
+
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>`;
|
|
404
|
+
})
|
|
405
|
+
.join("");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** One row per event in each network request of the step. */
|
|
409
|
+
function requestRows(m, lang, run, step) {
|
|
410
|
+
const rows = [];
|
|
411
|
+
for (const c of (run.network ?? []).filter((x) => x.step === step.index)) {
|
|
412
|
+
const sets = paramSets(c);
|
|
413
|
+
const hits = parseTrackingRequest(c.url, c.postData ?? null) ?? [];
|
|
414
|
+
const key = safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
|
|
415
|
+
sets.forEach((entries, i) => {
|
|
416
|
+
const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
|
|
417
|
+
const eventEntries = entries.filter(([k]) => isEventParam(k));
|
|
418
|
+
const context = entries.filter(([k]) => !isEventParam(k));
|
|
419
|
+
const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
|
|
420
|
+
const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
|
|
421
|
+
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>
|
|
422
|
+
<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>` : ""}
|
|
423
|
+
${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>` : ""}
|
|
424
|
+
${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
|
|
425
|
+
rows.push({ key, label: PROVIDERS[key] ?? c.vendor ?? key, html });
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return rows;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const stepAnchor = (ri, si) => `j${ri}-s${si}`;
|
|
432
|
+
const skippedText = (counts) => Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
|
|
433
|
+
const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
|
|
434
|
+
const problemCount = (findings, run) => findings.filter((f) => f.severity in SEVERITY_RANK && f.journey === run.journeyId && f.viewport === run.viewport).length;
|
|
435
|
+
|
|
436
|
+
// AI text marks numbers with **x** and names with `x`. Only those two marks become HTML; everything else is escaped.
|
|
437
|
+
const fmt = (s) => escapeHtml(s).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`(.+?)`/g, "<code>$1</code>");
|
|
438
|
+
// Finding texts quote event and parameter names ("add_to_cart"); show those as code.
|
|
439
|
+
const detailHtml = (s) => escapeHtml(s).replace(/"([^&]+?)"/g, "<code>$1</code>");
|
|
440
|
+
|
|
441
|
+
// Lucide icons.
|
|
442
|
+
const ic = (paths, size = 16) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
443
|
+
const ICON = {
|
|
444
|
+
broken: '<path d="m15 9-6 6"/><path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"/><path d="m9 9 6 6"/>',
|
|
445
|
+
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
|
446
|
+
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
447
|
+
impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
|
|
448
|
+
cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
449
|
+
evidence: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
|
450
|
+
fix: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
|
451
|
+
where: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
|
452
|
+
};
|
|
453
|
+
const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "info" : "warning"];
|
|
454
|
+
const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
|
|
455
|
+
const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
|
|
456
|
+
/** A logo with its light and dark versions, switched by the viewer's color scheme. */
|
|
457
|
+
const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
|
|
458
|
+
|
|
459
|
+
/** The journey and step a finding's place points at. Steps are named by label; when labels repeat, prefer the step where the finding's event fired. */
|
|
460
|
+
function whereTarget(runs, w, f) {
|
|
461
|
+
const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
|
|
462
|
+
if (ri < 0) return null;
|
|
463
|
+
const candidates = w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
|
|
464
|
+
const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
|
|
465
|
+
return { ri, step };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function stepDetail(m, lang, findings, run, ri, step, isDefault) {
|
|
469
|
+
const hits = run.hits.filter((h) => h.step === step.index);
|
|
470
|
+
const id = `s${ri}-${step.index}`;
|
|
471
|
+
const sortedHits = [...hits].sort((a, b) => platformRank(a.platform) - platformRank(b.platform) || a.t - b.t);
|
|
472
|
+
const eventRows = sortedHits.map((h) => ({ key: safeKey(h.platform), label: platformLabel(h.platform), html: eventRow(m, findings, run, step, h) }));
|
|
473
|
+
const pushes = (run.dataLayerLog ?? []).filter((e) => e.step === step.index);
|
|
474
|
+
const reqRows = requestRows(m, lang, run, step);
|
|
475
|
+
const skipped = skippedText(run.skipped?.[step.index]);
|
|
476
|
+
const skippedN = skipped.reduce((a, [, n]) => a + n, 0);
|
|
477
|
+
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>`;
|
|
478
|
+
return `<section class="sdetail${isDefault ? " default" : ""}" id="${stepAnchor(ri, step.index)}" aria-label="${escapeHtml(step.label)}">
|
|
479
|
+
<header class="shead"><span class="sidx">${step.index}</span><div><h3>${escapeHtml(step.label)}</h3><div class="muted xs">${escapeHtml(m.ui.stepMeta(seconds(run, step.t), hits.length, pushes.length, skippedN))}${step.forced ? ` · ${m.ui.clickedThrough}` : ""}</div></div></header>
|
|
480
|
+
<div class="tabs seg">${tab(1, m.ui.tabEvents, eventRows.length)}${tab(2, "dataLayer", pushes.length)}${tab(3, m.ui.tabNetwork, reqRows.length)}
|
|
481
|
+
<div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
|
|
482
|
+
<div class="panel">${pushes.length ? `<ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : `<p class="empty">${m.ui.noPushes}</p>`}</div>
|
|
483
|
+
<div class="panel">${reqRows.length ? filtered(m, `${id}n`, reqRows, reqRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noRequests}</p>`}${skippedN ? `<p class="muted xs scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</div>
|
|
484
|
+
</div></section>`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** One journey: a sticky step list on the left and the selected step on the right. It opens on the first step with a problem. */
|
|
488
|
+
function journeyPanel(m, lang, findings, run, ri, flagged) {
|
|
489
|
+
const steps = run.steps.filter((s) => s.index > 0);
|
|
490
|
+
const def = steps.find((s) => flagged.has(stepAnchor(ri, s.index))) ?? steps[0];
|
|
491
|
+
const nav = steps
|
|
492
|
+
.map((s) => {
|
|
493
|
+
const hits = run.hits.filter((h) => h.step === s.index);
|
|
494
|
+
const counts = sortPlatforms(new Set(hits.map((h) => h.platform))).map((p) => `<span class="dot p-${safeKey(p)}"></span>${hits.filter((h) => h.platform === p).length}`);
|
|
495
|
+
const a = stepAnchor(ri, s.index);
|
|
496
|
+
return `<li><a href="#${a}" data-s="${a}"${s === def ? ' class="def"' : ""}><span class="sidx">${s.index}</span><span class="sl">${escapeHtml(s.label)}</span>${flagged.has(a) ? `<span class="dot-flag" title="${m.ui.hasProblems}"></span>` : "<span></span>"}<span class="sc">${counts.join(" ") || '<span class="muted">–</span>'}</span></a></li>`;
|
|
497
|
+
})
|
|
498
|
+
.join("");
|
|
499
|
+
const body = steps.length
|
|
500
|
+
? `<div class="jgrid2"><nav class="stepnav" aria-label="${m.ui.stepsNav}"><ol>${nav}</ol></nav><div class="sdetails">${steps.map((s) => stepDetail(m, lang, findings, run, ri, s, s === def)).join("\n")}</div></div>`
|
|
501
|
+
: `<p class="empty">${m.ui.noSteps}</p>`;
|
|
502
|
+
return `<article class="jpanel" id="j${ri}"><header class="jhead"><div><h2>${escapeHtml(run.journeyId)} <span class="muted">· ${escapeHtml(run.viewport)}</span></h2>${run.note ? `<p class="muted xs">${escapeHtml(run.note)}</p>` : ""}</div>${run.completed ? badge("ok", m.ui.completed) : badge("warning", m.ui.notCompleted)}</header>
|
|
503
|
+
${body}</article>`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function journeysTab(m, lang, findings, runs, flagged) {
|
|
507
|
+
if (!runs.length) return `<p class="empty">${m.ui.noSteps}</p>`;
|
|
508
|
+
const tabs = runs.map((r, ri) => `<a href="#j${ri}">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span><span class="n">${problemCount(findings, r)}</span></a>`).join("");
|
|
509
|
+
return `<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
|
|
513
|
+
function evidenceHtml(evidence) {
|
|
514
|
+
try {
|
|
515
|
+
const v = JSON.parse(evidence);
|
|
516
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return `<table class="ptable ev-table"><tbody>${Object.entries(v).map(([k, x]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(cell(x))}</td></tr>`).join("")}</tbody></table>`;
|
|
517
|
+
} catch { /* plain text */ }
|
|
518
|
+
return `<pre>${escapeHtml(evidence)}</pre>`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function findingCard(m, runs, f, open) {
|
|
522
|
+
const e = f.explanation;
|
|
523
|
+
const steps = fixSteps(e);
|
|
524
|
+
const journeysN = new Set(f.where.map((w) => `${w.journey}|${w.viewport}`)).size;
|
|
525
|
+
const stepsN = f.where.filter((w) => w.step).length;
|
|
526
|
+
const evidence = f.evidence
|
|
527
|
+
? evidenceHtml(f.evidence)
|
|
528
|
+
: f.check === "missing_event" && f.platform
|
|
529
|
+
? `<table class="ptable ev-table"><tbody><tr><th>${escapeHtml(m.ui.requestTo(platformLabel(f.platform)))}</th><td><span class="missing">${m.ui.missing}</span></td></tr></tbody></table>`
|
|
530
|
+
: "";
|
|
531
|
+
const chips = f.where
|
|
532
|
+
.map((w) => {
|
|
533
|
+
const t = whereTarget(runs, w, f);
|
|
534
|
+
const inner = `${t?.step ? `<span class="chip-step">${t.step.index}</span>` : ""}${escapeHtml(w.journey)} · ${escapeHtml(w.viewport)}${w.step ? ` <span class="muted">· ${escapeHtml(w.step)}</span>` : ""}`;
|
|
535
|
+
return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
|
|
536
|
+
})
|
|
537
|
+
.join("");
|
|
538
|
+
return `<li class="finding sev-${f.severity}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
|
|
539
|
+
<span class="sev-icon">${ic(sevIcon(f.severity), 18)}</span>
|
|
540
|
+
<span class="fhead"><span class="ftitle">${detailHtml(f.detail)}</span><span class="fmeta">${f.platform ? `<span class="ptag p-${safeKey(f.platform)}">${escapeHtml(platformLabel(f.platform))}</span>` : ""}<span>${escapeHtml(m.ui.spread(journeysN, stepsN))}</span></span></span>
|
|
541
|
+
${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
|
|
542
|
+
<div class="fbody">${e?.impact ? `<div class="impact">${ic(ICON.impact, 16)}<div><span class="impact-l">${m.ui.impact}</span>${fmt(e.impact)}</div></div>` : ""}
|
|
543
|
+
<div class="fcols"><div class="fcol">${sec("cause", m.ui.likelyCause)}${e?.likely_cause ? `<p>${fmt(e.likely_cause)}</p>` : `<p class="muted">${m.ui.noExplanation}</p>`}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}</div>
|
|
544
|
+
<div class="fcol">${steps.length ? `${sec("fix", m.ui.fixSteps)}<ol class="fix-steps">${steps.map((t) => `<li><span>${fmt(t)}</span></li>`).join("")}</ol>` : ""}${sec("where", m.ui.where)}<div class="where">${chips}</div></div></div></div></details></li>`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
|
|
548
|
+
const SEV_COLOR = { broken: "var(--danger-dot)", risk: "var(--risk)", warning: "var(--warn-dot)", info: "var(--accent)" };
|
|
549
|
+
|
|
550
|
+
function findingsTab(m, runs, rows) {
|
|
551
|
+
const problems = rows.filter((f) => f.severity !== "ok");
|
|
552
|
+
if (!problems.length) return `<p class="empty">${m.ui.noProblems}</p>`;
|
|
553
|
+
const n = Object.fromEntries(FINDING_SEVERITIES.map((s) => [s, problems.filter((f) => f.severity === s).length]));
|
|
554
|
+
const present = FINDING_SEVERITIES.filter((s) => n[s]);
|
|
555
|
+
const radios = ["all", ...present].map((s) => `<input type="radio" name="fs" id="fs-${s}" class="fsr"${s === "all" ? " checked" : ""}>`).join("");
|
|
556
|
+
const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
|
|
557
|
+
const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
|
|
558
|
+
const labels = [`<label for="fs-all">${m.ui.all} <span class="n">${problems.length}</span></label>`, ...present.map((s) => `<label for="fs-${s}">${m.severity[s]} <span class="n">${n[s]}</span></label>`)].join("");
|
|
559
|
+
const sorted = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s));
|
|
560
|
+
return `<div class="filter-host">${radios}
|
|
561
|
+
<div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
|
|
562
|
+
<div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
|
|
563
|
+
<ul class="flist">${sorted.map((f, i) => findingCard(m, runs, f, i === 0 && f.severity !== "info")).join("")}</ul></div>`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function summaryCard(m, explained, problems) {
|
|
567
|
+
const s = summaryParts(explained?.summary);
|
|
568
|
+
let body;
|
|
569
|
+
if (!s) body = `<p class="muted">${problems ? m.ui.noSummary : m.ui.noProblems}</p>`;
|
|
570
|
+
else if (s.text) body = `<p class="summary-text">${fmt(s.text)}</p>`;
|
|
571
|
+
else {
|
|
572
|
+
body = `${s.headline ? `<p class="headline">${fmt(s.headline)}</p>` : ""}${s.points.length ? `<ul class="summary-list">${s.points.map((p) => `<li>${badge(p.severity, m.severity[p.severity])}<span>${fmt(p.text)}</span></li>`).join("")}</ul>` : ""}${s.also_check.length ? `<div><div class="label">${m.ui.alsoCheck}</div><ul class="also">${s.also_check.map((t) => `<li>${fmt(t)}</li>`).join("")}</ul></div>` : ""}`;
|
|
573
|
+
}
|
|
574
|
+
return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.summary}</div><div class="card-d">${m.ui.summaryLead}</div></div><div class="card-c stack">${body}${problems ? `<p><a href="#findings">${m.ui.seeAllFindings}</a></p>` : ""}</div></div>`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The providers that were checked: from the runs, or for older captures the platforms seen. */
|
|
578
|
+
function checkedProviders(runs) {
|
|
579
|
+
const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
|
|
580
|
+
if (!chosen.size) for (const r of runs) for (const h of r.hits) chosen.add(h.platform);
|
|
581
|
+
return sortPlatforms(chosen);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function skippedTotals(runs) {
|
|
585
|
+
const totals = {};
|
|
586
|
+
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;
|
|
587
|
+
return skippedText(totals);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
|
|
591
|
+
|
|
592
|
+
function overviewTab(m, findings, rows, counts, explained, runs) {
|
|
593
|
+
const problems = rows.filter((f) => f.severity !== "ok").length;
|
|
594
|
+
const metric = (sev) => `<div class="card metric ${sev}"><div class="k">${m.severity[sev]}</div><div class="v">${counts[sev]}</div></div>`;
|
|
595
|
+
const journeyRows = runs.map((r, ri) => {
|
|
596
|
+
const n = problemCount(findings, r);
|
|
597
|
+
return `<tr><td><a href="#j${ri}">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span></a></td><td class="num">${r.steps.filter((s) => s.index > 0).length}</td><td class="num${n ? " bad" : ""}">${n}</td></tr>`;
|
|
598
|
+
});
|
|
599
|
+
const skipped = skippedTotals(runs);
|
|
600
|
+
const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
|
|
601
|
+
const provs = checkedProviders(runs);
|
|
602
|
+
return `<div class="stack">
|
|
603
|
+
<div class="metrics">${["broken", "risk", "warning", "ok"].map(metric).join("")}</div>
|
|
604
|
+
<div class="ov-grid">${summaryCard(m, explained, problems)}
|
|
605
|
+
<div class="stack">
|
|
606
|
+
${runs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.ui.tabJourneys}</div></div><div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.journey}</th><th>${m.ui.stepsCol}</th><th>${m.ui.problemsCol}</th></tr></thead><tbody>${journeyRows.join("")}</tbody></table></div></div></div>` : ""}
|
|
607
|
+
${provs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.ui.scopeCard}</div></div><div class="card-c"><div class="scope">${provs.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("")}</div>${skippedN ? `<p class="muted xs scope-skipped">${escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</p>` : ""}</div></div>` : ""}
|
|
608
|
+
</div></div></div>`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
|
|
612
|
+
function coverageCard(m, plan, runs) {
|
|
613
|
+
const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
|
|
614
|
+
if (!plan?.events?.length || !runs.length) return `<div class="card">${head}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
|
|
615
|
+
const events = plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() }));
|
|
616
|
+
const checks = (r, e) => !r.providers?.length || r.providers.includes(e.platform);
|
|
617
|
+
const body = events
|
|
618
|
+
.map((e) => {
|
|
619
|
+
const cells = runs.map((r) => {
|
|
620
|
+
if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
|
|
621
|
+
return r.hits.some((h) => h.platform === e.platform && h.name === e.name) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
|
|
622
|
+
});
|
|
623
|
+
const unchecked = !runs.some((r) => checks(r, e));
|
|
624
|
+
return `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.name)}</code>${unchecked ? ` ${badge("muted", m.ui.notChecked)}` : ""}</td>${cells.join("")}</tr>`;
|
|
625
|
+
})
|
|
626
|
+
.join("");
|
|
627
|
+
return `<div class="card">${head}<div class="card-c"><div class="tscroll"><table class="dt cov"><thead><tr><th>${m.ui.event}</th>${runs.map((r) => `<th>${escapeHtml(r.journeyId)} · ${escapeHtml(r.viewport)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div><p class="muted xs legend">${m.ui.coverageLegend}</p></div></div>`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function providersCard(m, runs) {
|
|
631
|
+
const provs = checkedProviders(runs);
|
|
632
|
+
const skipped = skippedTotals(runs);
|
|
633
|
+
if (!provs.length && !skipped.length) return "";
|
|
634
|
+
const calls = runs.flatMap((r) => r.network ?? []);
|
|
635
|
+
const rows = [
|
|
636
|
+
...provs.map((k) => [platformLabel(k), badge("ok", m.ui.checked), calls.filter((c) => callProvider(c) === k).length]),
|
|
637
|
+
...skipped.map(([v, n]) => [v, badge("muted", m.ui.outOfScopeStatus), n]),
|
|
638
|
+
];
|
|
639
|
+
return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.providers}</div></div><div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.provider}</th><th>${m.ui.status}</th><th>${m.ui.requestsCol}</th></tr></thead><tbody>${rows.map(([n, s, c]) => `<tr><td>${escapeHtml(n)}</td><td>${s}</td><td class="num">${c}</td></tr>`).join("")}</tbody></table></div></div></div>`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Steps a problem finding points at; they get a dot in the step list, and the journey opens on the first one. */
|
|
643
|
+
function flaggedSteps(runs, rows) {
|
|
644
|
+
const out = new Set();
|
|
645
|
+
for (const f of rows) {
|
|
646
|
+
if (!(f.severity in SEVERITY_RANK)) continue;
|
|
647
|
+
for (const w of f.where) {
|
|
648
|
+
const t = whereTarget(runs, w, f);
|
|
649
|
+
if (t?.step) out.add(stepAnchor(t.ri, t.step.index));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Filter rules for the platform keys in this report: picking a source hides the other rows. */
|
|
656
|
+
function filterCss(runs) {
|
|
657
|
+
const keys = new Set(PLATFORM_ORDER);
|
|
658
|
+
for (const r of runs) {
|
|
659
|
+
for (const h of r.hits) keys.add(safeKey(h.platform));
|
|
660
|
+
for (const c of r.network ?? []) keys.add(callProvider(c));
|
|
661
|
+
}
|
|
662
|
+
return `${[...keys].map((k) => `.filter>.f-${k}:checked~.evlist>li:not([data-p="${k}"])`).join(",")}{display:none}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// The tabs, journeys and steps are switched by the URL hash (:target and :has()), so links work across tabs and every view can be shared.
|
|
666
|
+
function navCss(runs) {
|
|
667
|
+
const top = ["overview", "findings", "journeys", "scope"].map((t) => `body:has(#${t}:target,#${t} :target) .toptabs a[href="#${t}"]`).join(",");
|
|
668
|
+
const out = [`${top}{color:var(--fg);border-bottom-color:var(--accent)}`];
|
|
669
|
+
if (runs.length) {
|
|
670
|
+
out.push(`${runs.map((_, ri) => `body:has(#j${ri}:target,#j${ri} :target) .jtabs a[href="#j${ri}"]`).join(",")}{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}`);
|
|
671
|
+
const steps = runs.flatMap((r, ri) => r.steps.filter((s) => s.index > 0).map((s) => `body:has(#${stepAnchor(ri, s.index)}:target) a[data-s="${stepAnchor(ri, s.index)}"]`));
|
|
672
|
+
if (steps.length) out.push(`${steps.join(",")}{background:var(--accent-bg);color:var(--fg)}`);
|
|
673
|
+
}
|
|
674
|
+
const sev = ["all", ...FINDING_SEVERITIES];
|
|
675
|
+
out.push(`${sev.map((s) => `#fs-${s}:checked~.toolbar label[for=fs-${s}]`).join(",")}{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}`);
|
|
676
|
+
out.push(`${sev.map((s) => `#fs-${s}:focus-visible~.toolbar label[for=fs-${s}]`).join(",")}{outline:2px solid var(--accent)}`);
|
|
677
|
+
out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
|
|
678
|
+
return out.join("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const CSS = `
|
|
682
|
+
:root{
|
|
683
|
+
--bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#F1F4F9;--border:#E3E7EF;--input:#E3E7EF;
|
|
684
|
+
--accent:#4F8EF7;--accent-fg:#2F6FDB;--accent-bg:#EBF1FE;--orange:#FF6D33;
|
|
685
|
+
--danger:#C5221F;--danger-bg:#FCE8E6;--warn:#A16207;--warn-bg:#FEF9C3;--warn-dot:#EAB308;--risk:#C2410C;--risk-bg:#FFEDD5;--danger-dot:#DC2626;--ok:#137333;--ok-bg:#E6F4EA;
|
|
686
|
+
--shadow-sm:0 1px 2px rgba(10,14,26,.06);--radius:6px;
|
|
687
|
+
--font-sans:'Google Sans',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--font-mono:'Google Sans Code',ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
688
|
+
@media (prefers-color-scheme:dark){:root{
|
|
689
|
+
--bg:#141927;--page:#0A0E1A;--fg:#F0F4FF;--muted:#8892AA;--muted-bg:#182035;--border:#1E2640;--input:#26304D;
|
|
690
|
+
--accent-fg:#7EAAF9;--accent-bg:#1A2F5C;--danger:#F28B82;--danger-bg:#3A1F1D;--warn:#FACC15;--warn-bg:#332B0A;--warn-dot:#FACC15;--risk:#FB923C;--risk-bg:#3A2312;--danger-dot:#F87171;--ok:#81C995;--ok-bg:#16301F;--shadow-sm:none}}
|
|
691
|
+
*{box-sizing:border-box}
|
|
692
|
+
html{scroll-padding-top:64px}
|
|
693
|
+
body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--page);color:var(--fg);font:400 14px/1.6 var(--font-sans);-webkit-font-smoothing:antialiased}
|
|
694
|
+
a{color:var(--accent-fg);text-decoration:none}
|
|
695
|
+
h1,h2,h3,h4,h5,h6{margin:0;font-weight:500;line-height:1.3}
|
|
696
|
+
h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}h2,h3{font-size:14px}
|
|
697
|
+
p{margin:0}
|
|
698
|
+
strong{font-weight:600;color:var(--fg)}
|
|
699
|
+
code,.mono,.time,pre{font-family:var(--font-mono)}
|
|
700
|
+
code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
|
|
701
|
+
.muted{color:var(--muted)}.xs{font-size:12px}
|
|
702
|
+
.label{font-size:12px;color:var(--muted);margin:0 0 6px}
|
|
703
|
+
.wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 16px}
|
|
704
|
+
.topbar{background:var(--bg);border-bottom:1px solid var(--border)}
|
|
705
|
+
.topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
|
|
706
|
+
.product-logo img{display:block;height:26px;width:auto}
|
|
707
|
+
.byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
|
|
708
|
+
.logo-h img{display:block;height:18px;width:auto}
|
|
709
|
+
.title{margin-top:28px}
|
|
710
|
+
.eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
|
|
711
|
+
.title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
|
|
712
|
+
.toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
|
|
713
|
+
.toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
|
|
714
|
+
.toptabs a{display:inline-flex;align-items:center;gap:6px;padding:12px 10px;color:var(--muted);font-weight:500;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
715
|
+
.toptabs a:hover{color:var(--fg)}
|
|
716
|
+
.n{font:500 12px/1.4 var(--font-mono);background:var(--muted-bg);color:var(--muted);padding:0 6px;border-radius:4px}
|
|
717
|
+
body:not(:has(:target)) .toptabs a[href="#overview"]{color:var(--fg);border-bottom-color:var(--accent)}
|
|
718
|
+
.tabpanel{display:none;padding:24px 0 64px}
|
|
719
|
+
.tabpanel:target,.tabpanel:has(:target){display:block}
|
|
720
|
+
body:not(:has(.tabpanel:target,.tabpanel :target)) #overview{display:block}
|
|
721
|
+
.card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
|
722
|
+
.card-h{padding:16px 20px 0}.card-c{padding:16px 20px 20px}.card-c.tight{padding:8px}
|
|
723
|
+
.card-t{font-weight:500}.card-d{font-size:12px;color:var(--muted);margin-top:2px}
|
|
724
|
+
.badge{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;white-space:nowrap;background:var(--muted-bg);color:var(--muted)}
|
|
725
|
+
.badge.broken{background:var(--danger-bg);color:var(--danger)}.badge.risk{background:var(--risk-bg);color:var(--risk)}.badge.warning{background:var(--warn-bg);color:var(--warn)}.badge.ok{background:var(--ok-bg);color:var(--ok)}.badge.info{background:var(--accent-bg);color:var(--accent-fg)}
|
|
726
|
+
.stack{display:grid;gap:16px}
|
|
727
|
+
.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
|
|
728
|
+
.metric{padding:16px;border-top:3px solid var(--border)}
|
|
729
|
+
.metric .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
730
|
+
.metric .k::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
|
|
731
|
+
.metric.broken{border-top-color:var(--danger-dot)}.metric.broken .k::before{background:var(--danger-dot)}.metric.broken .v{color:var(--danger)}
|
|
732
|
+
.metric.risk{border-top-color:var(--risk)}.metric.risk .k::before{background:var(--risk)}.metric.risk .v{color:var(--risk)}
|
|
733
|
+
.metric.warning{border-top-color:var(--warn-dot)}.metric.warning .k::before{background:var(--warn-dot)}.metric.warning .v{color:var(--warn)}
|
|
734
|
+
.metric.ok{border-top-color:var(--ok)}.metric.ok .k::before{background:var(--ok)}
|
|
735
|
+
.metric .v{font:600 24px/1.3 var(--font-mono);margin-top:4px}
|
|
736
|
+
.headline{font-weight:500}
|
|
737
|
+
.summary-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
|
|
738
|
+
.summary-list li{display:grid;grid-template-columns:72px 1fr;gap:8px;align-items:baseline}
|
|
739
|
+
.summary-list .badge{justify-self:start}
|
|
740
|
+
.also{margin:0;padding-left:18px;display:grid;gap:6px}
|
|
741
|
+
.scope{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;font-size:12px;color:var(--muted)}
|
|
742
|
+
.scope-skipped,.legend{margin-top:8px}
|
|
743
|
+
table.dt{width:100%;border-collapse:collapse}
|
|
744
|
+
table.dt th,table.dt td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
|
|
745
|
+
table.dt th{font-weight:500;color:var(--muted);font-size:12px}
|
|
746
|
+
table.dt tr:last-child td{border-bottom:0}
|
|
747
|
+
table.dt td.num{font-family:var(--font-mono)}
|
|
748
|
+
table.dt td.num.bad{color:var(--danger);font-weight:600}
|
|
749
|
+
table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
|
|
750
|
+
.tscroll{overflow-x:auto}
|
|
751
|
+
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
|
|
752
|
+
.sevfilter{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius)}
|
|
753
|
+
.fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
754
|
+
.sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
|
|
755
|
+
.filter-host{position:relative}
|
|
756
|
+
.flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
|
757
|
+
.finding{--sev:var(--warn-dot);--sev-fg:var(--warn);--sev-bg:var(--warn-bg);background:var(--bg);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
|
758
|
+
.finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
|
|
759
|
+
.finding.sev-risk{--sev:var(--risk);--sev-fg:var(--risk);--sev-bg:var(--risk-bg)}
|
|
760
|
+
.finding.sev-info{--sev:var(--accent);--sev-fg:var(--accent-fg);--sev-bg:var(--accent-bg)}
|
|
761
|
+
.finding>details>summary{display:flex;align-items:center;gap:12px;padding:14px 16px}
|
|
762
|
+
.finding>details[open]>summary{background:linear-gradient(90deg,var(--sev-bg),transparent 70%)}
|
|
763
|
+
.sev-icon{display:inline-grid;place-items:center;width:32px;height:32px;border-radius:var(--radius);background:var(--sev-bg);color:var(--sev-fg);flex:none}
|
|
764
|
+
.fhead{flex:1;min-width:0;display:grid;gap:4px}
|
|
765
|
+
.ftitle{color:var(--fg);overflow-wrap:anywhere}
|
|
766
|
+
.fmeta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted)}
|
|
767
|
+
.finding summary .chev{margin-left:4px}
|
|
768
|
+
.fbody{padding:16px;border-top:1px solid var(--border)}
|
|
769
|
+
.impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--sev-bg);color:var(--fg);border-radius:var(--radius)}
|
|
770
|
+
.impact svg{color:var(--sev-fg);flex:none;margin-top:3px}
|
|
771
|
+
.impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
|
|
772
|
+
.fcols{display:grid;gap:8px 32px;margin-top:4px}
|
|
773
|
+
.fcol{min-width:0}
|
|
774
|
+
.sec-h{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin:16px 0 8px}
|
|
775
|
+
.sec-h svg{color:var(--accent-fg)}
|
|
776
|
+
.ev-table{border:1px solid var(--border);border-radius:var(--radius);border-collapse:separate;border-spacing:0;overflow:hidden}
|
|
777
|
+
.ev-table th{background:var(--muted-bg)}
|
|
778
|
+
.missing{display:inline-block;padding:0 6px;border-radius:4px;background:var(--danger-bg);color:var(--danger)}
|
|
779
|
+
.fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
|
|
780
|
+
.fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
|
|
781
|
+
.fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:50%;background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
|
|
782
|
+
.where{display:flex;flex-wrap:wrap;gap:6px}
|
|
783
|
+
.chip{display:inline-flex;align-items:center;gap:8px;padding:4px 10px 4px 4px;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);font-size:12px;background:var(--bg)}
|
|
784
|
+
span.chip{padding-left:10px}
|
|
785
|
+
a.chip:hover{border-color:var(--accent);color:var(--fg)}
|
|
786
|
+
.chip-step{display:inline-grid;place-items:center;min-width:20px;height:20px;border-radius:4px;background:var(--accent);color:#fff;font:500 12px var(--font-mono)}
|
|
787
|
+
.sevbar{display:flex;height:8px;border-radius:4px;overflow:hidden;gap:2px;margin:0 0 6px}
|
|
788
|
+
.sevbar span{display:block}
|
|
789
|
+
.sevlegend{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12px;color:var(--muted);margin-bottom:16px}
|
|
790
|
+
.sevlegend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
|
|
791
|
+
body>main{flex:1 0 auto;width:100%}
|
|
792
|
+
.footer{border-top:1px solid var(--border);background:var(--bg);margin-top:32px;flex:none}
|
|
793
|
+
.footer .wrap{display:grid;gap:16px;padding:32px 16px}
|
|
794
|
+
.footer p{font-size:12px;color:var(--muted)}
|
|
795
|
+
.footer a{color:var(--fg)}.footer a:hover{color:var(--accent-fg)}
|
|
796
|
+
.footer .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
|
|
797
|
+
.footer .f-meta{font:12px var(--font-mono);color:var(--muted)}
|
|
798
|
+
.logo-link{display:inline-flex}.logo-s img{display:block;height:56px;width:auto}
|
|
799
|
+
.f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
|
|
800
|
+
.jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius);margin-bottom:16px}
|
|
801
|
+
.jtabs a{display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:4px;color:var(--muted);font-weight:500}
|
|
802
|
+
.jtabs a:hover{color:var(--fg)}
|
|
803
|
+
#journeys:not(:has(.jpanel:target,.jpanel :target)) .jtabs a[href="#j0"]{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}
|
|
804
|
+
.jpanel{display:none}
|
|
805
|
+
.jpanel:target,.jpanel:has(:target){display:block}
|
|
806
|
+
#journeys:not(:has(.jpanel:target,.jpanel :target)) .jpanel:first-of-type{display:block}
|
|
807
|
+
.jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
|
|
808
|
+
.jgrid2{display:grid;gap:16px;align-items:start}
|
|
809
|
+
.stepnav{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:6px}
|
|
810
|
+
.stepnav ol{list-style:none;margin:0;padding:0;display:grid;gap:2px}
|
|
811
|
+
.stepnav a{display:grid;grid-template-columns:24px minmax(0,1fr) auto auto;gap:6px;align-items:center;padding:7px 8px;border-radius:4px;color:var(--muted)}
|
|
812
|
+
.stepnav a:hover{background:var(--muted-bg);color:var(--fg)}
|
|
813
|
+
.jpanel:not(:has(.sdetail:target)) .stepnav a.def{background:var(--accent-bg);color:var(--fg)}
|
|
814
|
+
.sidx{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:4px;border:1px solid var(--border);font:500 12px var(--font-mono);color:var(--muted);background:var(--bg)}
|
|
815
|
+
.sl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
|
|
816
|
+
.dot-flag{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--danger-dot)}
|
|
817
|
+
.sc{font:12px var(--font-mono);color:var(--muted);white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
|
|
818
|
+
.sdetail{display:none;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm);scroll-margin-top:64px;min-width:0}
|
|
819
|
+
.sdetail:target{display:block}
|
|
820
|
+
.jpanel:not(:has(.sdetail:target)) .sdetail.default{display:block}
|
|
821
|
+
.shead{display:flex;gap:12px;align-items:flex-start;padding:16px}
|
|
822
|
+
.shead h3{overflow-wrap:anywhere}
|
|
823
|
+
.shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
|
|
824
|
+
.empty{color:var(--muted);padding:8px 0}
|
|
825
|
+
.tabs.seg{display:grid;grid-template-columns:repeat(3,auto) 1fr;padding:0 16px 16px}
|
|
826
|
+
.tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
827
|
+
.tabs.seg>label{padding:6px 12px;font-weight:500;color:var(--muted);cursor:pointer;background:var(--muted-bg);white-space:nowrap;display:inline-flex;gap:6px;align-items:center}
|
|
828
|
+
.tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
|
|
829
|
+
.tabs.seg>label:nth-of-type(3){border-radius:0 var(--radius) var(--radius) 0}
|
|
830
|
+
.tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
|
|
831
|
+
.tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
|
|
832
|
+
.tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
|
|
833
|
+
.tabs.seg>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs.seg>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs.seg>input:nth-of-type(3):checked~.panel:nth-of-type(3){display:block}
|
|
834
|
+
.filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
|
|
835
|
+
.filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
836
|
+
.filter>label{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--input);border-radius:var(--radius);font-size:12px;cursor:pointer;color:var(--muted);background:var(--bg)}
|
|
837
|
+
.filter>label:hover{color:var(--fg)}
|
|
838
|
+
.filter>input:checked+label{background:var(--fg);border-color:var(--fg);color:var(--bg)}
|
|
839
|
+
.filter>input:checked+label .n{background:transparent;color:inherit}
|
|
840
|
+
.filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
|
|
841
|
+
.filter>label:last-of-type{margin-bottom:4px}
|
|
842
|
+
.dot{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--muted)}
|
|
843
|
+
.dot.p-ga4,.ptag.p-ga4::before{background:var(--orange)}.dot.p-meta,.ptag.p-meta::before{background:var(--accent)}.dot.p-google_ads,.ptag.p-google_ads::before{background:var(--ok)}.dot.p-tiktok,.ptag.p-tiktok::before{background:var(--fg)}
|
|
844
|
+
.evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
|
|
845
|
+
.evlist>li+li{border-top:1px solid var(--border)}
|
|
846
|
+
summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
|
|
847
|
+
summary:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
848
|
+
.chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
|
|
849
|
+
.event>summary{display:flex;align-items:center;gap:10px;padding:8px 12px;flex-wrap:wrap}
|
|
850
|
+
.event>summary:hover{background:var(--muted-bg)}
|
|
851
|
+
.event[class*=flag-]>summary .ev::after{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--warn-dot);margin-left:8px;vertical-align:2px}
|
|
852
|
+
.event.flag-broken>summary .ev::after{background:var(--danger-dot)}.event.flag-risk>summary .ev::after{background:var(--risk)}
|
|
853
|
+
.event .tag{display:none}
|
|
854
|
+
.ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
|
|
855
|
+
.event .meta{font-size:12px;color:var(--muted)}
|
|
856
|
+
.event .time,.time{font-size:12px;color:var(--muted);margin-left:auto}
|
|
857
|
+
.ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
858
|
+
.ptag::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
|
|
859
|
+
.event-body{padding:4px 12px 16px}
|
|
860
|
+
.flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius);background:var(--warn-bg);color:var(--warn)}
|
|
861
|
+
.flagnote.broken{background:var(--danger-bg);color:var(--danger)}.flagnote.risk{background:var(--risk-bg);color:var(--risk)}
|
|
862
|
+
.event-body h5{font-size:12px;color:var(--muted);font-weight:400;margin:12px 0 6px}
|
|
863
|
+
.event-body h5:first-child{margin-top:4px}
|
|
864
|
+
h5 .n{margin-left:4px}
|
|
865
|
+
.ebgrid{display:grid;gap:12px}
|
|
866
|
+
.ebgrid>*{min-width:0}
|
|
867
|
+
.ptable{width:100%;border-collapse:collapse;font:12px/1.5 var(--font-mono)}
|
|
868
|
+
.ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
|
|
869
|
+
.ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
|
|
870
|
+
.glossary th{min-width:170px}
|
|
871
|
+
.ptable td{overflow-wrap:anywhere}
|
|
872
|
+
.ptable tr:last-child>*{border-bottom:0}
|
|
873
|
+
.consents{display:flex;flex-wrap:wrap;gap:6px}
|
|
874
|
+
.consent{font-size:12px;padding:2px 6px;border-radius:4px}.consent.granted{background:var(--ok-bg);color:var(--ok)}.consent.denied{background:var(--warn-bg);color:var(--warn)}
|
|
875
|
+
.ebgrid aside{background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;align-self:start}
|
|
876
|
+
.ebgrid aside h5{margin-top:0!important}
|
|
877
|
+
.srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:12px}
|
|
878
|
+
.srclist li{display:grid;gap:2px;overflow-wrap:anywhere}.srclist .lbl{font-size:12px;color:var(--muted)}
|
|
879
|
+
.method{font:500 12px var(--font-mono);padding:0 5px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg)}
|
|
880
|
+
.status{color:var(--ok);font-family:var(--font-mono);font-size:12px}
|
|
881
|
+
.small{font-size:12px}
|
|
882
|
+
.item{border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px}
|
|
883
|
+
.item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:10px}
|
|
884
|
+
.item-head b{font-weight:500}
|
|
885
|
+
.item-idx{font:12px var(--font-mono);color:var(--muted)}
|
|
886
|
+
.item-price{margin-left:auto;font-size:12px}
|
|
887
|
+
.grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
|
|
888
|
+
.grid4 dt{font:12px var(--font-mono);color:var(--muted);overflow-wrap:anywhere}.grid4 dd{margin:0;font:12px var(--font-mono);overflow-wrap:anywhere}
|
|
889
|
+
h6{margin:12px 0 6px;font-size:12px;font-weight:400;color:var(--muted)}
|
|
890
|
+
.pk{margin-right:4px}
|
|
891
|
+
.q{position:relative;display:inline-grid;place-items:center;width:15px;height:15px;border:1px solid var(--input);border-radius:50%;font:500 10px var(--font-sans);color:var(--muted);cursor:help;vertical-align:1px}
|
|
892
|
+
.q:hover,.q:focus{border-color:var(--accent);color:var(--accent-fg);outline:none}
|
|
893
|
+
.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(--fg);color:var(--bg);font:400 12px/1.45 var(--font-sans);border-radius:var(--radius);white-space:normal;text-align:left}
|
|
894
|
+
tr.pr td{color:var(--muted)}
|
|
895
|
+
details.raw{margin-top:10px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
896
|
+
pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
897
|
+
.scope-note{margin-top:10px}
|
|
898
|
+
.cov td.c{text-align:center;font-family:var(--font-mono)}
|
|
899
|
+
.cov .y{color:var(--ok)}.cov .x{color:var(--danger);font-weight:600}.cov .o{color:var(--muted)}
|
|
900
|
+
@media (min-width:768px){
|
|
901
|
+
.wrap{padding:0 24px}
|
|
902
|
+
.metrics{grid-template-columns:repeat(4,1fr)}
|
|
903
|
+
.ov-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:start}
|
|
904
|
+
.fcols{grid-template-columns:1fr 1fr}
|
|
905
|
+
.footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:32px 24px}
|
|
906
|
+
.footer .wrap.plain{grid-template-columns:1fr auto}
|
|
907
|
+
.jgrid2{grid-template-columns:260px minmax(0,1fr)}
|
|
908
|
+
.stepnav{position:sticky;top:60px}
|
|
909
|
+
.ebgrid{grid-template-columns:2fr 1fr}
|
|
910
|
+
.grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
|
911
|
+
.event-body{padding:4px 16px 16px 38px}
|
|
912
|
+
}
|
|
913
|
+
@media (max-width:767px){.ov-grid{display:grid;gap:16px}}
|
|
914
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
915
|
+
@media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
|
|
916
|
+
`;
|
|
917
|
+
|
|
918
|
+
export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true }) {
|
|
919
|
+
const m = messages(lang);
|
|
920
|
+
const rows = attachExplanations(findings, explained);
|
|
921
|
+
const counts = summarize(rows);
|
|
922
|
+
const problems = rows.filter((f) => f.severity !== "ok").length;
|
|
923
|
+
const flagged = flaggedSteps(runs, rows);
|
|
924
|
+
const atd = `<a class="f-atd" href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="16">AI Tag Debugger</a>`;
|
|
925
|
+
const footer = branding
|
|
926
|
+
? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a>
|
|
927
|
+
<div><p>${m.ui.credit(atd)}</p><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div>
|
|
928
|
+
<div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
|
|
929
|
+
: `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
|
|
930
|
+
|
|
931
|
+
return `<!doctype html>
|
|
932
|
+
<html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
933
|
+
<title>${escapeHtml(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
|
|
934
|
+
<style>${CSS}${filterCss(runs)}
|
|
935
|
+
${navCss(runs)}</style></head><body>
|
|
936
|
+
<div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
|
|
937
|
+
<header class="wrap title">
|
|
938
|
+
<div class="eyebrow">${m.ui.eyebrow}</div>
|
|
939
|
+
<h1>${escapeHtml(title)}</h1>
|
|
940
|
+
<div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>
|
|
941
|
+
</header>
|
|
942
|
+
<nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
|
|
943
|
+
<a href="#overview">${m.ui.tabOverview}</a><a href="#findings">${m.ui.findings} <span class="n">${problems}</span></a><a href="#journeys">${m.ui.tabJourneys} <span class="n">${runs.length}</span></a><a href="#scope">${m.ui.tabScope}</a></div></nav>
|
|
944
|
+
<main class="wrap">
|
|
945
|
+
<section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${overviewTab(m, findings, rows, counts, explained, runs)}</section>
|
|
946
|
+
<section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
|
|
947
|
+
<section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
|
|
948
|
+
<section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
|
|
949
|
+
</main>
|
|
950
|
+
<footer class="footer">${footer}</footer>
|
|
951
|
+
</body></html>`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const csvCell = (v) => {
|
|
955
|
+
let s = v === null || v === undefined ? "" : typeof v === "string" ? v : JSON.stringify(v);
|
|
956
|
+
// Text from the page could run as a formula in Excel or Sheets; a leading ' keeps it text.
|
|
957
|
+
if (typeof v === "string" && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
|
958
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/** One row per tracking hit, dataLayer push and network call, in time order, for spreadsheets. */
|
|
962
|
+
export function renderCsv({ findings, runs }) {
|
|
963
|
+
const header = ["journey", "viewport", "step_index", "step", "time_s", "type", "platform_or_vendor", "event", "details", "status", "flag"];
|
|
964
|
+
const rows = [];
|
|
965
|
+
for (const r of runs) {
|
|
966
|
+
const stepOf = (i) => r.steps[i] ?? { index: i, label: "" };
|
|
967
|
+
const t = (x) => ((x - r.startedAt) / 1000).toFixed(2);
|
|
968
|
+
for (const h of r.hits) {
|
|
969
|
+
const s = stepOf(h.step);
|
|
970
|
+
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) ?? ""] });
|
|
971
|
+
}
|
|
972
|
+
for (const e of r.dataLayerLog ?? []) {
|
|
973
|
+
const s = stepOf(e.step);
|
|
974
|
+
const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : e.value?.event ?? "";
|
|
975
|
+
rows.push({ at: e.t, cells: [r.journeyId, r.viewport, s.index, s.label, t(e.t), "datalayer", "", name, e.value, "", ""] });
|
|
976
|
+
}
|
|
977
|
+
for (const c of r.network ?? []) {
|
|
978
|
+
const s = stepOf(c.step);
|
|
979
|
+
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, ""] });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Keep each run's rows together, in time order within the run.
|
|
983
|
+
const order = new Map(runs.map((r, i) => [`${r.journeyId}|${r.viewport}`, i]));
|
|
984
|
+
rows.sort((a, b) => order.get(`${a.cells[0]}|${a.cells[1]}`) - order.get(`${b.cells[0]}|${b.cells[1]}`) || a.at - b.at);
|
|
985
|
+
// The BOM makes Excel open the file as UTF-8, so Turkish characters display correctly.
|
|
986
|
+
return "" + [header, ...rows.map((r) => r.cells)].map((row) => row.map(csvCell).join(",")).join("\n") + "\n";
|
|
987
|
+
}
|