@analitikisler/ai-tag-debugger 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +128 -35
- package/README.tr.md +127 -36
- package/package.json +8 -1
- package/src/agent.js +11 -4
- package/src/audit.js +100 -36
- package/src/baseline.js +90 -0
- package/src/browser.js +36 -1
- package/src/changes.js +9 -3
- package/src/checks.js +10 -3
- package/src/ci-output.js +104 -0
- package/src/cli.js +84 -3
- package/src/consent.js +4 -2
- package/src/doctor.js +68 -0
- package/src/gtm.js +209 -3
- package/src/help.js +33 -3
- package/src/history.js +302 -0
- package/src/html/consent.js +152 -0
- package/src/html/css.js +1180 -0
- package/src/html/findings.js +493 -0
- package/src/html/journeys.js +449 -0
- package/src/html/lib.js +272 -0
- package/src/html/scope.js +350 -0
- package/src/html/script.js +300 -0
- package/src/html/shell.js +191 -0
- package/src/html/summary.js +288 -0
- package/src/i18n.js +1107 -141
- package/src/mcp.js +22 -4
- package/src/metrics.js +482 -0
- package/src/notify.js +21 -7
- package/src/plan-html.js +71 -25
- package/src/plan.js +6 -1
- package/src/pool.js +34 -0
- package/src/providers.js +90 -12
- package/src/replay.js +28 -1
- package/src/report-shared.js +123 -0
- package/src/report.js +289 -1018
- package/src/schema.js +190 -0
- package/src/sheet.js +11 -5
- package/src/whitelabel.js +35 -6
package/src/report.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import { consentTable } from "./consent.js";
|
|
2
|
-
import {
|
|
1
|
+
import { AD_PLATFORMS, consentTable } from "./consent.js";
|
|
2
|
+
import { ownerOf, verifyLinks } from "./verify.js";
|
|
3
3
|
import { createProvider } from "./providers.js";
|
|
4
|
-
import {
|
|
4
|
+
import { canonicalEvent, groupFindings, summarize } from "./checks.js";
|
|
5
5
|
import { messages } from "./i18n.js";
|
|
6
|
-
import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
|
|
7
|
-
import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
|
|
8
6
|
import { buildInventory } from "./inventory.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
7
|
+
import { computeMetrics } from "./metrics.js";
|
|
8
|
+
import { historySummaryLines, sparkline } from "./history.js";
|
|
9
|
+
import { cardIds } from "./html/lib.js";
|
|
10
|
+
import {
|
|
11
|
+
VERSION, SEVERITIES, summaryParts, fixSteps, attachExplanations, SEVERITY_RANK, SEVERITY_ORDER_MD, seconds, isConsentEntry, routeList,
|
|
12
|
+
isProblem, placeKey, uniqPlaces, mdHeadline, platformLabel, sortPlatforms, changeLabel, coverageLines, pcSort, planChangeItems,
|
|
13
|
+
} from "./report-shared.js";
|
|
14
|
+
|
|
15
|
+
// The HTML report lives in src/html/: shell.js assembles the page from one module per section.
|
|
16
|
+
// Callers keep importing everything from here.
|
|
17
|
+
export { VERSION, escapeHtml, platformLabel, verdictHeadline } from "./report-shared.js";
|
|
18
|
+
export { renderHtml } from "./html/shell.js";
|
|
19
|
+
export { CSS } from "./html/css.js";
|
|
20
|
+
export { SCRIPT, scriptCspSource } from "./html/script.js";
|
|
21
|
+
export { logo } from "./html/lib.js";
|
|
12
22
|
|
|
13
|
-
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
14
23
|
|
|
15
24
|
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.
|
|
16
25
|
|
|
@@ -18,7 +27,7 @@ For each finding with severity broken, risk or warning, give:
|
|
|
18
27
|
- impact: one sentence on what this does to the data, the reports or ad spend;
|
|
19
28
|
- likely_cause: the most likely cause, in plain language a marketing manager can follow and an analyst can act on;
|
|
20
29
|
- fix_steps: the fix as short, ordered steps.
|
|
21
|
-
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.
|
|
30
|
+
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. When a finding's gtm[].evaluation.why is present, it is a deterministic evaluation of that tag's triggers and consent against the captured dataLayer: rely on it for the likely cause instead of guessing.
|
|
22
31
|
|
|
23
32
|
Then write the summary:
|
|
24
33
|
- headline: one sentence naming what matters most for data quality and ad spend;
|
|
@@ -27,7 +36,6 @@ Then write the summary:
|
|
|
27
36
|
|
|
28
37
|
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.`;
|
|
29
38
|
|
|
30
|
-
const SEVERITIES = ["broken", "risk", "warning", "info"];
|
|
31
39
|
const EXPLAIN_SCHEMA = {
|
|
32
40
|
type: "object",
|
|
33
41
|
properties: {
|
|
@@ -68,20 +76,6 @@ const EXPLAIN_SCHEMA = {
|
|
|
68
76
|
additionalProperties: false,
|
|
69
77
|
};
|
|
70
78
|
|
|
71
|
-
/** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
|
|
72
|
-
function summaryParts(summary) {
|
|
73
|
-
if (!summary) return null;
|
|
74
|
-
if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
|
|
75
|
-
return {
|
|
76
|
-
headline: String(summary.headline ?? ""),
|
|
77
|
-
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) })),
|
|
78
|
-
also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Fix steps of an explanation; older explanations had one "fix" string. */
|
|
83
|
-
const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
|
|
84
|
-
|
|
85
79
|
function hitsByStep(run) {
|
|
86
80
|
return run.steps.map((step) => ({
|
|
87
81
|
step: step.label,
|
|
@@ -96,7 +90,7 @@ function hitsByStep(run) {
|
|
|
96
90
|
* deterministic report is still written.
|
|
97
91
|
*/
|
|
98
92
|
export async function explainFindings({ findings, plan, runs, gtmContainer, provider, providerName, client, model, lang = "en", log = () => {} }) {
|
|
99
|
-
const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity));
|
|
93
|
+
const actionable = groupFindings(findings).map((f, i) => ({ index: i, ...f })).filter((f) => ["broken", "risk", "warning"].includes(f.severity) && !f.accepted);
|
|
100
94
|
if (actionable.length === 0) return { summary: { headline: messages(lang).ui.allGood, points: [], also_check: [] }, explanations: [] };
|
|
101
95
|
try {
|
|
102
96
|
const input = {
|
|
@@ -124,15 +118,6 @@ export async function explainFindings({ findings, plan, runs, gtmContainer, prov
|
|
|
124
118
|
}
|
|
125
119
|
}
|
|
126
120
|
|
|
127
|
-
export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
128
|
-
|
|
129
|
-
function attachExplanations(findings, explained) {
|
|
130
|
-
const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
|
|
131
|
-
return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i) }));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
|
|
135
|
-
|
|
136
121
|
/** The most severe finding that points at this hit, if any (for highlighting in the timeline). */
|
|
137
122
|
function hitSeverity(findings, run, step, hit) {
|
|
138
123
|
let worst = null;
|
|
@@ -145,1045 +130,331 @@ function hitSeverity(findings, run, step, hit) {
|
|
|
145
130
|
return worst;
|
|
146
131
|
}
|
|
147
132
|
|
|
148
|
-
const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
|
|
149
|
-
const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
|
|
150
|
-
const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) => JSON.stringify(v)).join(", ")})` : JSON.stringify(value));
|
|
151
|
-
|
|
152
133
|
// Values from the audited page go into Markdown as text: no raw HTML, and no way out of a code span.
|
|
153
134
|
const mdText = (v) => String(v ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
154
135
|
const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
|
|
155
136
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
if (consentScenarios) {
|
|
173
|
-
const { cols, rows: tagRows } = consentTable(consentScenarios);
|
|
174
|
-
lines.push(`## ${m.ui.scenarios}`, "", mdText(m.ui.scenariosLead(consentScenarios.cmp)), "");
|
|
175
|
-
lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
|
|
176
|
-
for (const r of tagRows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => (c.count === null ? "–" : c.problem ? `**${c.count}** ⚠` : String(c.count))).join(" | ")} |`);
|
|
177
|
-
lines.push("");
|
|
178
|
-
}
|
|
179
|
-
lines.push(`## ${m.ui.findings}`, "");
|
|
180
|
-
for (const f of rows.filter((r) => r.severity !== "ok")) {
|
|
181
|
-
lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
|
|
182
|
-
lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
|
|
183
|
-
lines.push(`- ${m.ui.owners[ownerOf(f)]}`);
|
|
184
|
-
const vl = verifyLinks(f);
|
|
185
|
-
if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
|
|
186
|
-
if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
|
|
187
|
-
if (f.gtm) lines.push(`- ${m.ui.gtmTags}: ${f.gtm.length ? f.gtm.map((t) => `${t.name}${t.paused ? ` (${m.ui.gtmPaused})` : ""}, ${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map((x) => x.name).join(", ")}` : m.ui.gtmNoTrigger}`).join("; ") : m.ui.gtmNoTag}`);
|
|
188
|
-
if (f.explanation) {
|
|
189
|
-
if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
|
|
190
|
-
lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
|
|
191
|
-
}
|
|
192
|
-
lines.push("");
|
|
193
|
-
}
|
|
194
|
-
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)}`);
|
|
195
|
-
lines.push("", `## ${m.ui.timeline}`, "");
|
|
196
|
-
for (const r of runs) {
|
|
197
|
-
lines.push(`### ${mdText(r.journeyId)} (${mdText(r.viewport)}): ${r.completed ? m.ui.completed : m.ui.notCompleted}`, "", mdText(r.note), "");
|
|
198
|
-
for (const step of r.steps.filter((s) => s.index > 0)) {
|
|
199
|
-
lines.push(`${step.index}. **${mdText(step.label)}** (${seconds(r, step.t)})${step.forced ? ` _${m.ui.clickedThrough}_` : ""}`);
|
|
200
|
-
for (const h of r.hits.filter((x) => x.step === step.index)) {
|
|
201
|
-
const flag = hitSeverity(findings, r, step, h);
|
|
202
|
-
const params = Object.entries(h.params).map(([k, v]) => `${k}=${v}`).join(", ");
|
|
203
|
-
lines.push(` - ${flag ? `**${m.severity[flag]}** ` : ""}${mdText(`${h.platform}:${h.name}${params ? ` (${params})` : ""}`)}`);
|
|
204
|
-
}
|
|
205
|
-
for (const e of (r.dataLayerLog ?? []).filter((x) => x.step === step.index)) {
|
|
206
|
-
lines.push(` - dataLayer: \`${mdCode(isConsentEntry(e.value) ? formatPush(e.value) : JSON.stringify(e.value))}\``);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
lines.push("");
|
|
210
|
-
}
|
|
211
|
-
// A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
|
|
212
|
-
if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
|
|
213
|
-
else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
|
|
214
|
-
return lines.join("\n");
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
|
|
218
|
-
export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
219
|
-
const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
|
|
220
|
-
|
|
221
|
-
// Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
|
|
222
|
-
const EVENT_ALIASES = {
|
|
223
|
-
pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
|
|
224
|
-
addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
|
|
225
|
-
completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
|
|
226
|
-
viewbasket: "viewcart", tracktransaction: "purchase",
|
|
227
|
-
};
|
|
228
|
-
const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
229
|
-
const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
|
|
230
|
-
// GTM-style pushes are objects ({event: "add_to_cart"}); gtag.js records arrays (["event", "add_to_cart", {...}]).
|
|
231
|
-
const pushEvent = (value) => (Array.isArray(value) ? (value[0] === "event" ? value[1] : undefined) : value && typeof value === "object" ? value.event : undefined);
|
|
232
|
-
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));
|
|
233
|
-
|
|
234
|
-
// Per run: step index -> event key -> the pushes with that event name. Built once so rendering stays linear.
|
|
235
|
-
const pushIndexCache = new WeakMap();
|
|
236
|
-
function pushIndex(run) {
|
|
237
|
-
let index = pushIndexCache.get(run);
|
|
238
|
-
if (!index) {
|
|
239
|
-
index = new Map();
|
|
240
|
-
for (const e of run.dataLayerLog ?? []) {
|
|
241
|
-
const name = pushEvent(e.value);
|
|
242
|
-
if (typeof name !== "string" || !eventKey(name)) continue;
|
|
243
|
-
if (!index.has(e.step)) index.set(e.step, new Map());
|
|
244
|
-
const byKey = index.get(e.step);
|
|
245
|
-
const key = eventKey(name);
|
|
246
|
-
if (!byKey.has(key)) byKey.set(key, []);
|
|
247
|
-
byKey.get(key).push(e);
|
|
248
|
-
}
|
|
249
|
-
pushIndexCache.set(run, index);
|
|
250
|
-
}
|
|
251
|
-
return index;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** Every dataLayer push in the hit's step whose event name matches it (directly or through a pixel alias). */
|
|
255
|
-
const matchingPushes = (run, hit) => pushIndex(run).get(hit.step)?.get(eventKey(hit.name)) ?? [];
|
|
256
|
-
|
|
257
|
-
/** The push behind the hit. When the same event fires as often as it was pushed (duplicates), they are paired in order. */
|
|
258
|
-
function pushesForHit(run, hit) {
|
|
259
|
-
const pushes = matchingPushes(run, hit);
|
|
260
|
-
const key = eventKey(hit.name);
|
|
261
|
-
const twins = run.hits.filter((h) => h.step === hit.step && h.platform === hit.platform && eventKey(h.name) === key);
|
|
262
|
-
return twins.length > 1 && pushes.length === twins.length ? [pushes[twins.indexOf(hit)]] : pushes;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
|
|
266
|
-
function callsForHit(run, hit) {
|
|
267
|
-
const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
|
|
268
|
-
const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
|
|
269
|
-
return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const icon = {
|
|
274
|
-
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>',
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
const kvTable = (rows) => `<table class="ptable"><tbody>${rows.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`).join("")}</tbody></table>`;
|
|
278
|
-
// Platform keys go into class names, ids and attribute selectors.
|
|
279
|
-
const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
|
|
280
|
-
const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
281
|
-
|
|
282
|
-
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"]);
|
|
283
|
-
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"]);
|
|
284
|
-
|
|
285
|
-
/** GA4 item strings (pr1=…) as { fields, custom } pairs. */
|
|
286
|
-
const ga4Items = (strings) => (strings ?? []).map(decodeGa4Item);
|
|
287
|
-
/** dataLayer ecommerce.items objects as { fields, custom } pairs: standard GA4 item keys, then the site's own. */
|
|
288
|
-
const objectItems = (list) =>
|
|
289
|
-
list.filter((it) => it && typeof it === "object").map((it) => {
|
|
290
|
-
const entries = Object.entries(it).map(([k, v]) => [k, cell(v)]);
|
|
291
|
-
return { fields: entries.filter(([k]) => STANDARD_ITEM_KEYS.has(k)), custom: entries.filter(([k]) => !STANDARD_ITEM_KEYS.has(k)) };
|
|
292
|
-
});
|
|
293
|
-
/** The items array of a GTM-style ({ecommerce: {items}}) or gtag.js-style (["event", name, {items}]) push. */
|
|
294
|
-
function pushItems(value) {
|
|
295
|
-
const items = Array.isArray(value) ? value[2]?.items : value?.ecommerce?.items ?? value?.items;
|
|
296
|
-
return Array.isArray(items) ? items : [];
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function itemCards(m, items) {
|
|
300
|
-
return items
|
|
301
|
-
.map(({ fields, custom }, i) => {
|
|
302
|
-
const get = (k) => fields.find(([n]) => n === k)?.[1];
|
|
303
|
-
const price = get("price");
|
|
304
|
-
const qty = get("quantity");
|
|
305
|
-
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>`;
|
|
306
|
-
const rest = fields.filter(([n]) => !["item_name", "item_id", "price", "quantity"].includes(n));
|
|
307
|
-
const grid = (rows) => `<dl class="grid4">${rows.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v === "" ? "–" : v)}</dd></div>`).join("")}</dl>`;
|
|
308
|
-
return `<div class="item">${head}${rest.length ? grid(rest) : ""}${custom.length ? `<h6>${m.ui.customItemParams}</h6>${grid(custom)}` : ""}</div>`;
|
|
309
|
-
})
|
|
310
|
-
.join("");
|
|
311
|
-
}
|
|
312
|
-
const itemsBlock = (m, items) => (items.length ? `<h5>${m.ui.items} <span class="n">${items.length}</span></h5>${itemCards(m, items)}` : "");
|
|
313
|
-
|
|
314
|
-
/** The most severe finding that points at this hit, as { severity, detail }, or null. */
|
|
315
|
-
function hitFlag(findings, run, step, hit) {
|
|
316
|
-
let worst = null;
|
|
317
|
-
for (const f of findings) {
|
|
318
|
-
if (!(f.severity in SEVERITY_RANK)) continue;
|
|
319
|
-
if (f.journey !== run.journeyId || f.viewport !== run.viewport || f.step !== step.label) continue;
|
|
320
|
-
if (f.platform !== hit.platform || f.event !== hit.name) continue;
|
|
321
|
-
if (worst === null || SEVERITY_RANK[f.severity] < SEVERITY_RANK[worst.severity]) worst = f;
|
|
322
|
-
}
|
|
323
|
-
return worst && { severity: worst.severity, detail: worst.detail };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const hostPath = (url) => {
|
|
137
|
+
// ---------- The Markdown report (docs/design/md-report-spec.md) ----------
|
|
138
|
+
// A verdict first, evidence second, never a dump: the file goes into pull request comments, Slack
|
|
139
|
+
// previews and mails, so it stays around 120 lines and the per-hit timeline lives in timeline.csv.
|
|
140
|
+
|
|
141
|
+
const MD_BLOCKS = 5; // full finding blocks; findings beyond that are one line each
|
|
142
|
+
const MD_EVIDENCE = 200; // characters of raw evidence per finding
|
|
143
|
+
const MD_INVENTORY_ROWS = 10;
|
|
144
|
+
const MD_FLAGGED = 5;
|
|
145
|
+
const MD_PLACES = 3;
|
|
146
|
+
|
|
147
|
+
/** A finding's detail with quoted names as code spans, as the HTML shows it. */
|
|
148
|
+
const mdDetail = (s) => mdText(s).replace(/"([^"]+?)"/g, (_, x) => `\`${x.replace(/`/g, "'")}\``);
|
|
149
|
+
const mdClip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
|
|
150
|
+
/** The path of a page address; the host is the site's and would repeat on every line. */
|
|
151
|
+
const mdPath = (url) => {
|
|
327
152
|
try {
|
|
328
153
|
const u = new URL(url);
|
|
329
|
-
return u.
|
|
154
|
+
return u.pathname + u.search;
|
|
330
155
|
} catch {
|
|
331
156
|
return String(url ?? "");
|
|
332
157
|
}
|
|
333
158
|
};
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
for (const r of rows) counts.set(r.key, { label: r.label, n: (counts.get(r.key)?.n ?? 0) + 1 });
|
|
339
|
-
if (counts.size < 2) return "";
|
|
340
|
-
const keys = [...counts.keys()].sort((a, b) => platformRank(a) - platformRank(b) || counts.get(a).label.localeCompare(counts.get(b).label));
|
|
341
|
-
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
|
|
342
|
-
.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>`)
|
|
343
|
-
.join("")}`;
|
|
344
|
-
}
|
|
345
|
-
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>`;
|
|
346
|
-
|
|
347
|
-
function eventRow(m, findings, run, step, hit) {
|
|
348
|
-
const flag = hitFlag(findings, run, step, hit);
|
|
349
|
-
const pushes = pushesForHit(run, hit);
|
|
350
|
-
const calls = callsForHit(run, hit);
|
|
351
|
-
const items = ga4Items(hit.items);
|
|
352
|
-
const params = Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && items.length));
|
|
353
|
-
const bits = [];
|
|
354
|
-
if (items.length) bits.push(m.ui.itemsCount(items.length));
|
|
355
|
-
if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
|
|
356
|
-
if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
|
|
357
|
-
if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
|
|
358
|
-
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>` : "";
|
|
359
|
-
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>`;
|
|
360
|
-
const reqText = calls.length
|
|
361
|
-
? 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("")
|
|
362
|
-
: `<span class="mono">${escapeHtml(hostPath(hit.url))}</span>`;
|
|
363
|
-
return `<li data-p="${safeKey(hit.platform)}"><details class="event${flag ? ` flag-${flag.severity}` : ""}"${flag ? " open" : ""}>
|
|
364
|
-
<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>
|
|
365
|
-
<div class="event-body">${flag ? `<p class="flagnote ${flag.severity}">${escapeHtml(flag.detail)}</p>` : ""}
|
|
366
|
-
<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>
|
|
367
|
-
<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>
|
|
368
|
-
${itemsBlock(m, items)}</div></details></li>`;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const clip = (t, n) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
|
|
372
|
-
|
|
373
|
-
function pushRow(m, run, e) {
|
|
374
|
-
const name = isConsentEntry(e.value) ? `consent ${e.value[1]}` : pushEvent(e.value) ?? clip(JSON.stringify(e.value) ?? String(e.value), 80);
|
|
375
|
-
const items = objectItems(pushItems(e.value));
|
|
376
|
-
const ec = Array.isArray(e.value) ? e.value[2] : e.value?.ecommerce;
|
|
377
|
-
const bits = items.length ? [m.ui.itemsCount(items.length), ec?.value !== undefined ? `${ec.value} ${ec.currency ?? ""}`.trim() : ""].filter(Boolean).join(" · ") : "";
|
|
378
|
-
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>
|
|
379
|
-
<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>`;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
|
|
383
|
-
function flatten(obj, prefix = "", out = []) {
|
|
384
|
-
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
385
|
-
const key = prefix ? `${prefix}.${k}` : k;
|
|
386
|
-
if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
|
|
387
|
-
else out.push([key, cell(v)]);
|
|
388
|
-
}
|
|
389
|
-
return out;
|
|
390
|
-
}
|
|
159
|
+
const runFor = (runs, w) => runs.find((r) => r.journeyId === w.journey && r.viewport === w.viewport) ?? null;
|
|
160
|
+
const mdPlace = (w) => `${mdText(w.journey)} · ${mdText(w.viewport)}`;
|
|
161
|
+
/** "GA4 `purchase`", or the check name for a finding without an event. */
|
|
162
|
+
const mdTitle = (f) => [f.platform ? mdText(platformLabel(f.platform)) : "", f.event ? `\`${mdCode(f.event)}\`` : ""].filter(Boolean).join(" ") || mdText(f.check);
|
|
391
163
|
|
|
392
164
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
165
|
+
* A text from one of the per-check tables (consequence, fix, verdict headline) that other parts of
|
|
166
|
+
* i18n.js provide: a string as is, a function called with the grouped finding (or the verdict
|
|
167
|
+
* context), nothing when the table or the entry is not there. Never throws, so a missing table only
|
|
168
|
+
* costs the line.
|
|
395
169
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
170
|
+
function mdTemplate(table, key, arg) {
|
|
171
|
+
const v = table?.[key];
|
|
172
|
+
if (typeof v === "string") return v;
|
|
173
|
+
if (typeof v !== "function") return null;
|
|
398
174
|
try {
|
|
399
|
-
|
|
175
|
+
const out = v(arg);
|
|
176
|
+
return typeof out === "string" && out ? out : null;
|
|
400
177
|
} catch {
|
|
401
|
-
return
|
|
402
|
-
}
|
|
403
|
-
const query = [...u.searchParams];
|
|
404
|
-
const body = call.postData;
|
|
405
|
-
if (!body) return [query];
|
|
406
|
-
const trimmed = body.trim();
|
|
407
|
-
if (trimmed.startsWith("{")) {
|
|
408
|
-
try {
|
|
409
|
-
return [[...query, ...flatten(JSON.parse(trimmed))]];
|
|
410
|
-
} catch { /* not JSON, or cut at the size limit */ }
|
|
411
|
-
}
|
|
412
|
-
if (/^[^\s{]*=/.test(trimmed)) {
|
|
413
|
-
return trimmed.split("\n").filter(Boolean).map((line) => {
|
|
414
|
-
const merged = new Map(query);
|
|
415
|
-
for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
|
|
416
|
-
return [...merged];
|
|
417
|
-
});
|
|
178
|
+
return null;
|
|
418
179
|
}
|
|
419
|
-
return [query];
|
|
420
180
|
}
|
|
421
181
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
})
|
|
429
|
-
.join("");
|
|
182
|
+
/** "checkout · mobile", "all 4 journeys", or the first three places and "+n more". */
|
|
183
|
+
function mdPlaces(m, runs, where) {
|
|
184
|
+
const places = uniqPlaces(where);
|
|
185
|
+
if (runs.length > 1 && places.length === runs.length) return m.ui.md.allJourneys(runs.length);
|
|
186
|
+
const shown = places.slice(0, MD_PLACES).map(mdPlace);
|
|
187
|
+
return places.length > MD_PLACES ? `${shown.join(", ")}, ${m.ui.md.more(places.length - MD_PLACES)}` : shown.join(", ");
|
|
430
188
|
}
|
|
431
189
|
|
|
432
|
-
/**
|
|
433
|
-
function
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const name = hits[i]?.name ?? (hits.length === 1 ? hits[0].name : "");
|
|
441
|
-
const eventEntries = entries.filter(([k]) => isEventParam(k));
|
|
442
|
-
const context = entries.filter(([k]) => !isEventParam(k));
|
|
443
|
-
const items = ga4Items(entries.filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v));
|
|
444
|
-
const raw = c.url + (c.postData ? `\n\n${c.postData}` : "");
|
|
445
|
-
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>
|
|
446
|
-
<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>` : ""}
|
|
447
|
-
${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>` : ""}
|
|
448
|
-
${itemsBlock(m, items)}<details class="raw"><summary>${icon.chevron} ${m.ui.rawRequest}</summary><pre class="url">${escapeHtml(raw)}</pre></details></div></details></li>`;
|
|
449
|
-
rows.push({ key, label: PROVIDERS[key] ?? c.vendor ?? key, html });
|
|
450
|
-
});
|
|
190
|
+
/** The step a finding points at, from the first place that names one: { index, label, path, at }. */
|
|
191
|
+
function mdStep(runs, where) {
|
|
192
|
+
for (const w of where) {
|
|
193
|
+
if (!w.step) continue;
|
|
194
|
+
const run = runFor(runs, w);
|
|
195
|
+
const step = run?.steps?.find((s) => s.label === w.step);
|
|
196
|
+
if (!step) return { index: null, label: w.step, path: null, at: null };
|
|
197
|
+
return { index: step.index, label: step.label, path: step.pagePath ? mdPath(step.pagePath) : null, at: typeof run.startedAt === "number" && typeof step.t === "number" ? seconds(run, step.t) : null };
|
|
451
198
|
}
|
|
452
|
-
return
|
|
199
|
+
return null;
|
|
453
200
|
}
|
|
454
201
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const detailHtml = (s) => escapeHtml(s).replace(/"([^&]+?)"/g, "<code>$1</code>");
|
|
464
|
-
|
|
465
|
-
// Lucide icons.
|
|
466
|
-
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>`;
|
|
467
|
-
const ICON = {
|
|
468
|
-
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"/>',
|
|
469
|
-
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"/>',
|
|
470
|
-
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
471
|
-
impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
|
|
472
|
-
cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
473
|
-
gtm: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
|
474
|
-
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"/>',
|
|
475
|
-
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"/>',
|
|
476
|
-
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"/>',
|
|
477
|
-
};
|
|
478
|
-
const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "info" : "warning"];
|
|
479
|
-
const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
|
|
480
|
-
const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
|
|
481
|
-
/** A logo with its light and dark versions, switched by the viewer's color scheme. */
|
|
482
|
-
export 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>`;
|
|
483
|
-
|
|
484
|
-
/** 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. */
|
|
485
|
-
function whereTarget(runs, w, f) {
|
|
486
|
-
const ri = runs.findIndex((r) => r.journeyId === w.journey && r.viewport === w.viewport);
|
|
487
|
-
if (ri < 0) return null;
|
|
488
|
-
const candidates = w.step ? runs[ri].steps.filter((s) => s.index > 0 && s.label === w.step) : [];
|
|
489
|
-
const step = candidates.find((s) => runs[ri].hits.some((h) => h.step === s.index && h.platform === f.platform && h.name === f.event)) ?? candidates[0];
|
|
490
|
-
return { ri, step };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** The push a hit came from: the latest matching push at or before the hit, else the first match. */
|
|
494
|
-
function sourcePush(run, hit) {
|
|
495
|
-
const pushes = pushesForHit(run, hit);
|
|
496
|
-
return pushes.filter((e) => e.t <= hit.t).at(-1) ?? pushes[0] ?? null;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/** The step in order: each dataLayer push, the GTM event it raised, then the platform hits it caused. Hits with no push come last. */
|
|
500
|
-
function timelinePanel(m, findings, run, step, pushes, hits) {
|
|
501
|
-
const bySource = new Map();
|
|
502
|
-
const orphans = [];
|
|
503
|
-
for (const h of [...hits].sort((a, b) => a.t - b.t)) {
|
|
504
|
-
const src = sourcePush(run, h);
|
|
505
|
-
if (src && src.step === step.index) {
|
|
506
|
-
if (!bySource.has(src)) bySource.set(src, []);
|
|
507
|
-
bySource.get(src).push(h);
|
|
508
|
-
} else orphans.push(h);
|
|
202
|
+
/** "checkout · mobile · step 11 "Place the order" · /thanks.html · +6.7s"; the short form stops at the step number. */
|
|
203
|
+
function mdWhere(m, runs, where, { full = true } = {}) {
|
|
204
|
+
const parts = [mdPlaces(m, runs, where)];
|
|
205
|
+
const step = mdStep(runs, where);
|
|
206
|
+
if (step) {
|
|
207
|
+
parts.push(step.index === null ? `"${mdText(step.label)}"` : `${m.ui.md.step(step.index)}${full ? ` "${mdText(step.label)}"` : ""}`);
|
|
208
|
+
if (full && step.path) parts.push(mdText(step.path));
|
|
209
|
+
if (full && step.at) parts.push(step.at);
|
|
509
210
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
const
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
<div class="tabs seg">${tab(1, m.ui.tabTimeline, pushes.length + hits.length)}${tab(2, m.ui.tabEvents, eventRows.length)}${tab(3, "dataLayer", pushes.length)}${tab(4, m.ui.tabNetwork, reqRows.length)}
|
|
537
|
-
<div class="panel">${timelinePanel(m, findings, run, step, pushes, hits)}</div>
|
|
538
|
-
<div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
|
|
539
|
-
<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>
|
|
540
|
-
<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>
|
|
541
|
-
</div></section>`;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/** 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. */
|
|
545
|
-
/** Every Consent Mode command of a journey (default, then updates), one tile per signal. */
|
|
546
|
-
function consentCard(m, run) {
|
|
547
|
-
const cmds = run.dataLayerLog.filter((e) => isConsentEntry(e.value) && (e.value[1] === "default" || e.value[1] === "update"));
|
|
548
|
-
const head = `<div class="card-h"><div class="card-t">${m.ui.consent}</div><div class="card-d">${cmds.length ? m.ui.consentLead : m.ui.consentNone}</div></div>`;
|
|
549
|
-
if (!cmds.length) return `<div class="card jconsent">${head}<div class="card-c"></div></div>`;
|
|
550
|
-
const items = cmds.map((e) => {
|
|
551
|
-
const step = run.steps.find((s) => s.index === e.step);
|
|
552
|
-
const sigs = Object.entries(e.value[2] ?? {}).filter(([, v]) => typeof v === "string");
|
|
553
|
-
return `<li><div class="ch"><b>${e.value[1] === "default" ? m.ui.consentDefault : m.ui.consentUpdate}</b><span class="muted xs">${step ? `${step.index} · ${escapeHtml(step.label)}` : ""}</span></div><div class="sigs">${sigs.map(([k, v]) => `<span class="sig"><span>${escapeHtml(k)}</span><span class="${v === "granted" ? "granted" : "denied"}">${escapeHtml(v)}</span></span>`).join("")}</div></li>`;
|
|
554
|
-
});
|
|
555
|
-
return `<div class="card jconsent">${head}<div class="card-c"><ol class="ctl">${items.join("")}</ol></div></div>`;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
function journeyPanel(m, lang, findings, run, ri, flagged) {
|
|
559
|
-
const steps = run.steps.filter((s) => s.index > 0);
|
|
560
|
-
const def = steps.find((s) => flagged.has(stepAnchor(ri, s.index))) ?? steps[0];
|
|
561
|
-
const nav = steps
|
|
562
|
-
.map((s) => {
|
|
563
|
-
const hits = run.hits.filter((h) => h.step === s.index);
|
|
564
|
-
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}`);
|
|
565
|
-
const a = stepAnchor(ri, s.index);
|
|
566
|
-
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>`;
|
|
567
|
-
})
|
|
568
|
-
.join("");
|
|
569
|
-
const body = steps.length
|
|
570
|
-
? `<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>`
|
|
571
|
-
: `<p class="empty">${m.ui.noSteps}</p>`;
|
|
572
|
-
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>
|
|
573
|
-
${consentCard(m, run)}${body}</article>`;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** Consent scenarios: one row per tag, one column per consent choice, hit counts after the choice. */
|
|
577
|
-
function scenariosCard(m, scenarios) {
|
|
578
|
-
if (!scenarios) return "";
|
|
579
|
-
const { cols, rows } = consentTable(scenarios);
|
|
580
|
-
const head = `<tr><th>${m.ui.scenarioTag}</th>${cols.map((c) => `<th>${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
|
|
581
|
-
const body = rows.map((r) => `<tr><td><span class="ptag p-${safeKey(r.platform)}">${escapeHtml(platformLabel(r.platform))}</span> <code>${escapeHtml(r.event)}</code></td>${r.cells.map((c) => (c.count === null ? `<td class="muted xs">–</td>` : `<td class="num${c.problem ? " bad" : ""}">${c.count}</td>`)).join("")}</tr>`).join("");
|
|
582
|
-
const notRun = cols.filter((c) => c !== "before" && !scenarios.states[c]?.via).map((c) => m.ui.scenarioCols[c]);
|
|
583
|
-
return `<div class="card jconsent scenarios"><div class="card-h"><div class="card-t">${m.ui.scenarios}</div><div class="card-d">${escapeHtml(m.ui.scenariosLead(scenarios.cmp))}${notRun.length ? ` ${escapeHtml(m.ui.scenarioNotRun)}: ${escapeHtml(notRun.join(", "))}.` : ""}</div></div><div class="card-c tight"><table class="dt"><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function journeysTab(m, lang, findings, runs, flagged, scenarios = null) {
|
|
587
|
-
if (!runs.length) return `${scenariosCard(m, scenarios)}<p class="empty">${m.ui.noSteps}</p>`;
|
|
588
|
-
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("");
|
|
589
|
-
return `${scenariosCard(m, scenarios)}<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
|
|
593
|
-
function evidenceHtml(evidence) {
|
|
594
|
-
try {
|
|
595
|
-
const v = JSON.parse(evidence);
|
|
596
|
-
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>`;
|
|
597
|
-
} catch { /* plain text */ }
|
|
598
|
-
return `<pre>${escapeHtml(evidence)}</pre>`;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
/** The tags in the GTM container export that send this finding's event, with their triggers. */
|
|
602
|
-
function gtmHtml(m, tags) {
|
|
603
|
-
if (!tags) return "";
|
|
604
|
-
if (!tags.length) return `${sec("gtm", m.ui.gtmTags)}<p class="muted">${m.ui.gtmNoTag}</p>`;
|
|
605
|
-
const trig = (t) => `${escapeHtml(t.name)}${t.event ? ` <span class="muted">(${escapeHtml(t.event)})</span>` : ""}`;
|
|
606
|
-
const rows = tags.map((t) => {
|
|
607
|
-
const notes = [t.paused ? m.ui.gtmPaused : "", t.consent.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
|
|
608
|
-
return `<tr><th>${escapeHtml(t.name)}${notes.length ? `<div class="muted xs">${escapeHtml(notes.join(" · "))}</div>` : ""}</th><td>${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map(trig).join(", ")}` : `<span class="missing">${m.ui.gtmNoTrigger}</span>`}${t.blocking.length ? `<div class="muted xs">${m.ui.gtmBlockedBy} ${t.blocking.map(trig).join(", ")}</div>` : ""}</td></tr>`;
|
|
609
|
-
});
|
|
610
|
-
return `${sec("gtm", m.ui.gtmTags)}<table class="ptable ev-table gtm-tags"><tbody>${rows.join("")}</tbody></table>`;
|
|
211
|
+
return parts.join(" · ");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Completed runs where the same event fired and this check did not flag it: the contrast a reader needs ("desktop sent both"). */
|
|
215
|
+
function mdAlsoFineOn(m, runs, f) {
|
|
216
|
+
if (!f.event || !f.platform) return "";
|
|
217
|
+
const flagged = new Set(f.where.map(placeKey));
|
|
218
|
+
const fine = runs.filter((r) => r.completed && !flagged.has(`${r.journeyId}|${r.viewport}`) && (r.hits ?? []).some((h) => h.platform === f.platform && canonicalEvent(h.name) === canonicalEvent(f.event)));
|
|
219
|
+
return fine.length ? mdPlaces(m, runs, fine.map((r) => ({ journey: r.journeyId, viewport: r.viewport }))) : "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** "Changed since last run" in six lines at most: the headline, fixed, new, still open, and the route changes. */
|
|
223
|
+
function mdChanges(m, ch, rows) {
|
|
224
|
+
if (!ch?.previous) return [];
|
|
225
|
+
const lines = [`## ${m.ui.changesTitle}`, "", `**${mdText(m.ui.changesLine(ch))}** · ${mdText(m.ui.changesLead(ch.previous, ch.mode))}`, ""];
|
|
226
|
+
const why = (c, bucket) => (ch.mode === "findings" ? mdDetail(c.detail) : mdText(m.ui.bucket[bucket]));
|
|
227
|
+
if (ch.fixed.length) lines.push(`- ${m.ui.changeFixed}: ${ch.fixed.map((c) => `${mdText(changeLabel(c))} (${m.ui.history.fixedWas}: ${why(c, c.was)})`).join("; ")}`);
|
|
228
|
+
if (ch.new.length) lines.push(`- ${m.ui.changeNew}: ${ch.new.map((c) => `${mdText(changeLabel(c))} (${why(c, c.bucket)})`).join("; ")}`);
|
|
229
|
+
// Still open: this run's problems that the diff did not list as new.
|
|
230
|
+
const key = (c) => (ch.mode === "plan" ? `${c.platform ?? ""}|${c.event ?? ""}` : `${c.check ?? ""}|${c.platform ?? ""}|${c.event ?? ""}`);
|
|
231
|
+
const fresh = new Set(ch.new.map(key));
|
|
232
|
+
const open = rows.filter((f) => isProblem(f) && !fresh.has(key(f)));
|
|
233
|
+
if (open.length) lines.push(`- ${m.ui.md.stillOpen}: ${open.slice(0, MD_PLACES).map(mdTitle).join(", ")}${open.length > MD_PLACES ? `, ${m.ui.md.more(open.length - MD_PLACES)}` : ""}`);
|
|
234
|
+
if (ch.routes?.length) lines.push(`- ${mdText(m.ui.routeChanged(routeList(ch)))}`);
|
|
235
|
+
lines.push("");
|
|
236
|
+
return lines;
|
|
611
237
|
}
|
|
612
238
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
239
|
+
/** The to-do list per owner, the owner that recovers the most points first; owners with nothing get one line. */
|
|
240
|
+
function mdPriorities(m, runs, metrics) {
|
|
241
|
+
const lines = [`## ${m.ui.md.priorities}`, ""];
|
|
242
|
+
const idle = [];
|
|
243
|
+
for (const owner of Object.keys(m.ui.owners)) {
|
|
244
|
+
const g = metrics.priorities.find((p) => p.owner === owner);
|
|
245
|
+
if (!g) idle.push(owner);
|
|
246
|
+
}
|
|
247
|
+
for (const g of metrics.priorities) {
|
|
248
|
+
lines.push(`### ${m.ui.owners[g.owner] ?? mdText(g.owner)}`);
|
|
249
|
+
g.top.forEach((f, i) => lines.push(`${i + 1}. **${m.severity[f.severity]}** ${mdDetail(f.detail).replace(/\.$/, "")} · ${mdWhere(m, runs, f.where, { full: false })}`));
|
|
250
|
+
if (g.total > g.top.length) lines.push(`${m.ui.md.more(g.total - g.top.length)}`);
|
|
251
|
+
lines.push("");
|
|
252
|
+
}
|
|
253
|
+
if (idle.length) lines.push(...idle.map((o) => m.ui.md.nothingToDo(m.ui.owners[o])), "");
|
|
254
|
+
return lines;
|
|
617
255
|
}
|
|
618
256
|
|
|
619
|
-
|
|
257
|
+
/** One finding as the fixed field list; the AI's impact, cause and fix steps replace the per-check templates when it ran. */
|
|
258
|
+
function mdFinding(m, runs, f, ids) {
|
|
620
259
|
const e = f.explanation;
|
|
260
|
+
const lines = [`### ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${mdDetail(f.detail)}`, `- ${m.ui.where}: ${mdWhere(m, runs, f.where)}`];
|
|
261
|
+
const impact = e?.impact ?? mdTemplate(m.consequence, f.check, f);
|
|
262
|
+
if (impact) lines.push(`- ${m.ui.impact}: ${mdText(impact)}`);
|
|
263
|
+
if (e?.likely_cause) lines.push(`- ${m.ui.likelyCause}: ${mdText(e.likely_cause)}`);
|
|
264
|
+
// Evidence is the hit's parameters as JSON; "{}" is a hit that carried none, and no evidence at all means there was no hit.
|
|
265
|
+
if (f.evidence === "{}") lines.push(`- ${m.ui.evidence}: ${mdText(m.ui.md.noParams)}`);
|
|
266
|
+
else if (f.evidence) lines.push(`- ${m.ui.evidence}: \`${mdClip(mdCode(f.evidence), MD_EVIDENCE)}\``);
|
|
267
|
+
const fine = mdAlsoFineOn(m, runs, f);
|
|
268
|
+
if (fine) lines.push(`- ${m.ui.md.alsoFineOn}: ${fine}`);
|
|
269
|
+
lines.push(`- ${m.ui.md.owner}: ${m.ui.owners[ownerOf(f)]}`);
|
|
270
|
+
if (f.gtm) lines.push(`- ${m.ui.gtmTags}: ${f.gtm.length ? f.gtm.map((t) => `${mdText(t.name)}${t.paused ? ` (${m.ui.gtmPaused})` : ""}, ${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map((x) => mdText(x.name)).join(", ")}` : m.ui.gtmNoTrigger}`).join("; ") : m.ui.gtmNoTag}`);
|
|
271
|
+
for (const t of (f.gtm ?? []).filter((x) => x.evaluation)) lines.push(`- ${mdText(m.ui.gtmWhy(t.name, m.ui.gtmVerdict[t.evaluation.fired], t.evaluation.why))}`);
|
|
621
272
|
const steps = fixSteps(e);
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const chips = f.where
|
|
630
|
-
.map((w) => {
|
|
631
|
-
const t = whereTarget(runs, w, f);
|
|
632
|
-
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>` : ""}`;
|
|
633
|
-
return t ? `<a href="#${t.step ? stepAnchor(t.ri, t.step.index) : `j${t.ri}`}" class="chip">${inner}</a>` : `<span class="chip">${inner}</span>`;
|
|
634
|
-
})
|
|
635
|
-
.join("");
|
|
636
|
-
return `<li class="finding sev-${f.severity}" data-sev="${f.severity}"><details${open ? " open" : ""}><summary>
|
|
637
|
-
<span class="sev-icon">${ic(sevIcon(f.severity), 18)}</span>
|
|
638
|
-
<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>
|
|
639
|
-
${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
|
|
640
|
-
<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>` : ""}
|
|
641
|
-
<div class="fcols"><div class="fcol">${e?.likely_cause ? `${sec("cause", m.ui.likelyCause)}<p>${fmt(e.likely_cause)}</p>` : aiRan || f.severity === "info" ? "" : `${sec("cause", m.ui.likelyCause)}<p class="muted">${m.ui.noExplanation}</p>`}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}${gtmHtml(m, f.gtm)}</div>
|
|
642
|
-
<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>${verifyHtml(m, f)}</div></div></div></details></li>`;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
|
|
646
|
-
const SEV_COLOR = { broken: "var(--danger-dot)", risk: "var(--risk)", warning: "var(--warn-dot)", info: "var(--accent)" };
|
|
647
|
-
|
|
648
|
-
function findingsTab(m, runs, rows, aiRan) {
|
|
649
|
-
const problems = rows.filter((f) => f.severity !== "ok");
|
|
650
|
-
if (!problems.length) return `<p class="empty">${m.ui.noProblems}</p>`;
|
|
651
|
-
const n = Object.fromEntries(FINDING_SEVERITIES.map((s) => [s, problems.filter((f) => f.severity === s).length]));
|
|
652
|
-
const present = FINDING_SEVERITIES.filter((s) => n[s]);
|
|
653
|
-
const radios = ["all", ...present].map((s) => `<input type="radio" name="fs" id="fs-${s}" class="fsr"${s === "all" ? " checked" : ""}>`).join("");
|
|
654
|
-
const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
|
|
655
|
-
const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
|
|
656
|
-
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("");
|
|
657
|
-
let first = true;
|
|
658
|
-
const groups = OWNERS.map((o) => {
|
|
659
|
-
const mine = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s && ownerOf(f) === o));
|
|
660
|
-
if (!mine.length) return "";
|
|
661
|
-
const cards = mine.map((f) => {
|
|
662
|
-
const open = first && f.severity !== "info";
|
|
663
|
-
if (open) first = false;
|
|
664
|
-
return findingCard(m, runs, f, open, aiRan);
|
|
665
|
-
}).join("");
|
|
666
|
-
return `<section class="ogroup" data-owner="${o}"><h3 class="ogroup-h">${m.ui.owners[o]} <span class="n">${mine.length}</span><span class="muted xs">${m.ui.ownerHint[o]}</span></h3><ul class="flist">${cards}</ul></section>`;
|
|
667
|
-
}).join("");
|
|
668
|
-
return `<div class="filter-host">${radios}
|
|
669
|
-
<div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
|
|
670
|
-
<div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
|
|
671
|
-
${groups}</div>`;
|
|
273
|
+
const fix = steps.length ? steps.map((t, i) => `${i + 1}. ${mdText(t)}`).join("; ") : mdText(mdTemplate(m.fix, f.check, f) ?? "");
|
|
274
|
+
if (fix) lines.push(`- ${m.ui.fix}: ${fix}`);
|
|
275
|
+
const vl = verifyLinks(f);
|
|
276
|
+
if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
|
|
277
|
+
// The HTML card's own id (cardIds): a site-level finding is f-site-…, and a repeated one ends in -2.
|
|
278
|
+
lines.push(`- ${m.ui.md.link}: report.html#${ids.get(f)}`, "");
|
|
279
|
+
return lines;
|
|
672
280
|
}
|
|
673
281
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
if (
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
282
|
+
/** One consent-scenario cell with its reason: the count, ✓ or ✗, and why ("cookieless", "before consent", "should not send"). */
|
|
283
|
+
function mdCell(m, col, cell, raw, adPlatform) {
|
|
284
|
+
const c = m.ui.md.cell;
|
|
285
|
+
if (cell.count === null) return c.notRun;
|
|
286
|
+
const n = cell.count;
|
|
287
|
+
if (cell.problem) return `**${n}** ${c.bad} ${c.shouldNotSend}`;
|
|
288
|
+
if (!n) return "0";
|
|
289
|
+
if (col === "before") return raw.cookieless >= n ? `${n} · ${c.cookieless} ${c.ok}` : `${n} ${c.bad} ${c.beforeConsent}`;
|
|
290
|
+
if (col === "reject") return `${n} · ${c.cookieless} ${c.ok}`;
|
|
291
|
+
if (col === "analytics" && adPlatform && raw.adDenied >= n) return `${n} · ${c.adDenied} ${c.ok}`;
|
|
292
|
+
return `${n} ${c.ok}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The consent section: the Consent Mode timing sentence, then the scenario table with a reason in every cell. */
|
|
296
|
+
function mdConsent(m, metrics, scenarios) {
|
|
297
|
+
const lines = [`## ${m.ui.md.consent}`, ""];
|
|
298
|
+
const cm = metrics.consent?.consentMode;
|
|
299
|
+
const tested = Boolean(cm?.runs?.length);
|
|
300
|
+
if (tested) {
|
|
301
|
+
const present = cm.runs.filter((d) => d.present).length;
|
|
302
|
+
lines.push(present ? m.ui.md.consentMode(present, cm.runs.length, cm.defaultOnTime, cm.v2Signals.present) : m.ui.md.consentModeNone, "");
|
|
681
303
|
}
|
|
682
|
-
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if (!
|
|
689
|
-
return sortPlatforms(chosen);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function skippedTotals(runs) {
|
|
693
|
-
const totals = {};
|
|
694
|
-
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;
|
|
695
|
-
return skippedText(totals);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const callProvider = (c) => safeKey(c.provider ?? providerOf(c.url)?.key ?? c.vendor);
|
|
699
|
-
|
|
700
|
-
/** The run's settings: platforms checked, AI model, plan, journeys, viewports, language and version. */
|
|
701
|
-
function runCard(m, runs, provs, { ai, plan, lang }) {
|
|
702
|
-
const uniq = (xs) => [...new Set(xs)];
|
|
703
|
-
const ptags = provs.map((k) => `<span class="ptag p-${safeKey(k)}">${escapeHtml(platformLabel(k))}</span>`).join("");
|
|
704
|
-
const rows = [
|
|
705
|
-
[m.ui.runPlatforms, ptags || '<span class="muted">–</span>'],
|
|
706
|
-
[m.ui.runAi, escapeHtml(ai ? m.ui.runAiOn(ai.name, ai.model) : m.ui.runAiOff)],
|
|
707
|
-
[m.ui.runPlan, escapeHtml(plan?.events?.length ? m.ui.runPlanEvents(plan.events.length) : m.ui.runNoPlan)],
|
|
708
|
-
[m.ui.runJourneys, escapeHtml(uniq(runs.map((r) => r.journeyId)).join(", ") || "–")],
|
|
709
|
-
[m.ui.runViewports, escapeHtml(uniq(runs.map((r) => r.viewport)).join(", ") || "–")],
|
|
710
|
-
[m.ui.runLanguage, escapeHtml(messages(lang).name)],
|
|
711
|
-
[m.ui.runVersion, `<code>${escapeHtml(VERSION)}</code>`],
|
|
712
|
-
];
|
|
713
|
-
return `<div class="card"><div class="card-h"><div class="card-t">${m.ui.runCard}</div></div><div class="card-c"><dl class="run">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl></div></div>`;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
function overviewTab(m, findings, rows, counts, explained, runs, info) {
|
|
717
|
-
const problems = rows.filter((f) => f.severity !== "ok").length;
|
|
718
|
-
const metric = (sev) => `<div class="card metric ${sev}"><div class="k">${m.severity[sev]}</div><div class="v">${counts[sev]}</div></div>`;
|
|
719
|
-
const journeyRows = runs.map((r, ri) => {
|
|
720
|
-
const n = problemCount(findings, r);
|
|
721
|
-
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>`;
|
|
722
|
-
});
|
|
723
|
-
const skipped = skippedTotals(runs);
|
|
724
|
-
const skippedN = skipped.reduce((a, [, x]) => a + x, 0);
|
|
725
|
-
const provs = checkedProviders(runs);
|
|
726
|
-
return `<div class="stack">
|
|
727
|
-
<div class="metrics">${["broken", "risk", "warning", "ok"].map(metric).join("")}</div>
|
|
728
|
-
<div class="ov-grid">${summaryCard(m, explained, problems)}
|
|
729
|
-
<div class="stack">
|
|
730
|
-
${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>` : ""}
|
|
731
|
-
${runCard(m, runs, provs, info)}${skippedN ? `<p class="muted xs scope-skipped">${escapeHtml(m.ui.skippedLine(skipped.map(([v]) => v).join(", "), skippedN))}</p>` : ""}
|
|
732
|
-
</div></div></div>`;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
|
|
736
|
-
const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
|
|
737
|
-
const coverageLines = (m, pc) => [
|
|
738
|
-
m.ui.coverageLine(pc.coverage),
|
|
739
|
-
...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
|
|
740
|
-
];
|
|
741
|
-
const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
|
|
742
|
-
const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
|
|
743
|
-
|
|
744
|
-
const planChangeItems = (pc) => [
|
|
745
|
-
...pc.added.map((c) => `+ ${changeLabel(c)}`),
|
|
746
|
-
...pc.removed.map((c) => `− ${changeLabel(c)}`),
|
|
747
|
-
...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
|
|
748
|
-
];
|
|
749
|
-
|
|
750
|
-
/** "Changed since last run": one headline, then what was fixed and what is new. */
|
|
751
|
-
const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
|
|
752
|
-
const changeItems = (m, ch) => [
|
|
753
|
-
...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
|
|
754
|
-
...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
|
|
755
|
-
];
|
|
756
|
-
|
|
757
|
-
function changesMd(m, ch) {
|
|
758
|
-
const lines = [`## ${m.ui.changesTitle}`, ""];
|
|
759
|
-
if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
|
|
760
|
-
lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
|
|
761
|
-
const items = changeItems(m, ch);
|
|
762
|
-
if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
|
|
763
|
-
if (ch.routes?.length) lines.push(...ch.routes.map((r) => `- ${mdText(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}`), "");
|
|
304
|
+
if (scenarios) {
|
|
305
|
+
const { cols, rows } = consentTable(scenarios);
|
|
306
|
+
const rawOf = (col, platform, event) => (col === "before" ? scenarios.before?.[platform]?.[event] : scenarios.states?.[col]?.after?.[platform]?.[event]) ?? { n: 0, cookieless: 0, adDenied: 0 };
|
|
307
|
+
lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
|
|
308
|
+
for (const r of rows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => mdCell(m, c.state, c, rawOf(c.state, r.platform, r.event), AD_PLATFORMS.has(r.platform))).join(" | ")} |`);
|
|
309
|
+
lines.push("", m.ui.md.consentLegend, "");
|
|
310
|
+
} else if (!tested) lines.push(m.ui.md.consentNotChecked, "");
|
|
764
311
|
return lines;
|
|
765
312
|
}
|
|
766
313
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
314
|
+
/** What was verified, not only what failed: the ok checks, plan coverage, the purchases that pass, and personal data. */
|
|
315
|
+
function mdPassed(m, runs, findings, metrics, planCheck) {
|
|
316
|
+
const items = [];
|
|
317
|
+
const ok = new Map();
|
|
318
|
+
for (const f of findings.filter((x) => x.severity === "ok")) {
|
|
319
|
+
if (!ok.has(f.check)) ok.set(f.check, { detail: f.detail, where: [] });
|
|
320
|
+
ok.get(f.check).where.push({ journey: f.journey, viewport: f.viewport });
|
|
321
|
+
}
|
|
322
|
+
for (const o of ok.values()) items.push(`${mdDetail(o.detail).replace(/\.$/, "")} · ${mdPlaces(m, runs, o.where)}`);
|
|
323
|
+
if (metrics.coverage && !planCheck) items.push(m.ui.md.coverage(metrics.coverage.counts.implemented, metrics.coverage.checkable, metrics.coverage.percent.implemented));
|
|
324
|
+
for (const r of (metrics.conversions?.rows ?? []).filter((x) => x.pass).slice(0, 3)) items.push(m.ui.md.purchaseOk(mdText(platformLabel(r.platform)), mdPlace({ journey: r.journey, viewport: r.viewport }), mdText(r.value), mdText(r.currency), r.transactionId ? mdText(r.transactionId) : ""));
|
|
325
|
+
for (const p of metrics.parity ?? []) {
|
|
326
|
+
// Two platforms "agree" only when both reported a value; two purchases without one agree on nothing.
|
|
327
|
+
const purchase = p.shared.find((s) => s.event === "purchase" && !s.mismatch.length && Object.values(s.platforms).every((x) => x.value !== null));
|
|
328
|
+
if (purchase) items.push(m.ui.md.parityOk(Object.keys(purchase.platforms).map((k) => mdText(platformLabel(k))).join(" & "), mdPlace({ journey: p.journey, viewport: p.viewport })));
|
|
329
|
+
}
|
|
330
|
+
if (runs.some((r) => r.hits?.length) && !findings.some((f) => String(f.check).startsWith("pii"))) items.push(m.ui.md.noPii);
|
|
331
|
+
return [`## ${m.ui.md.whatPassed}`, "", ...(items.length ? items.slice(0, 10).map((i) => `- ${i}`) : [m.ui.nothing]), ""];
|
|
772
332
|
}
|
|
773
333
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const lines = [`## ${m.ui.
|
|
777
|
-
|
|
778
|
-
|
|
334
|
+
/** One table row per run, then the steps that carry a problem; the full timeline is timeline.csv. */
|
|
335
|
+
function mdJourneys(m, runs, rows) {
|
|
336
|
+
const lines = [`## ${m.ui.tabJourneys}`, ""];
|
|
337
|
+
if (!runs.length) return [...lines, m.ui.nothing, ""];
|
|
338
|
+
const problems = rows.filter(isProblem);
|
|
339
|
+
lines.push(`| ${m.ui.journey} | ${m.ui.md.viewport} | ${m.ui.completed} | ${m.ui.stepsCol} | ${m.ui.md.hits} | ${m.ui.findings} |`, "|---|---|---|---|---|---|");
|
|
340
|
+
for (const r of runs) {
|
|
341
|
+
const n = problems.filter((f) => f.where.some((w) => w.journey === r.journeyId && w.viewport === r.viewport)).length;
|
|
342
|
+
const done = r.completed ? m.ui.md.yes : `${m.ui.md.no}${r.note ? ` · ${mdClip(mdText(r.note), 60)}` : ""}`;
|
|
343
|
+
lines.push(`| ${mdText(r.journeyId)} | ${mdText(r.viewport)} | ${done.replace(/\|/g, "\\|")} | ${(r.steps ?? []).filter((s) => s.index > 0).length} | ${r.hits?.length ?? 0} | ${n} |`);
|
|
344
|
+
}
|
|
779
345
|
lines.push("");
|
|
346
|
+
// The same step flagged on several journeys is one line with its places, as the findings are grouped.
|
|
347
|
+
const flagged = new Map();
|
|
348
|
+
for (const f of problems) {
|
|
349
|
+
for (const w of f.where) {
|
|
350
|
+
if (!w.step) continue;
|
|
351
|
+
if (!flagged.has(w.step)) flagged.set(w.step, { where: [], titles: new Set() });
|
|
352
|
+
flagged.get(w.step).where.push(w);
|
|
353
|
+
flagged.get(w.step).titles.add(mdTitle(f));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (flagged.size) {
|
|
357
|
+
const shown = [...flagged.values()].slice(0, MD_FLAGGED);
|
|
358
|
+
lines.push(`**${m.ui.md.flaggedSteps}**`, ...shown.map(({ where, titles }) => `- ${mdWhere(m, runs, where)}: ${[...titles].join(", ")}`));
|
|
359
|
+
if (flagged.size > shown.length) lines.push(`- ${m.ui.md.moreIn(flagged.size - shown.length, "timeline.csv")}`);
|
|
360
|
+
lines.push("");
|
|
361
|
+
}
|
|
362
|
+
lines.push(m.ui.md.timelineNote, "");
|
|
780
363
|
return lines;
|
|
781
364
|
}
|
|
782
365
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
const
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
|
|
798
|
-
return r.hits.some((h) => hitMatchesEvent(e, h)) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
|
|
799
|
-
});
|
|
800
|
-
const unchecked = !runs.some((r) => checks(r, e));
|
|
801
|
-
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>`;
|
|
802
|
-
})
|
|
803
|
-
.join("");
|
|
804
|
-
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>`;
|
|
366
|
+
/** What this run could not judge, each with the config key that enables it; omitted when nothing is missing. */
|
|
367
|
+
function mdNotChecked(m, runs, rows, plan, scenarios) {
|
|
368
|
+
const items = [];
|
|
369
|
+
if (!plan?.events?.length) items.push(m.ui.md.noPlan);
|
|
370
|
+
const withEvent = rows.filter((f) => isProblem(f) && f.event);
|
|
371
|
+
if (withEvent.length && !withEvent.some((f) => f.gtm)) items.push(m.ui.md.noGtm);
|
|
372
|
+
if (scenarios) {
|
|
373
|
+
if (!scenarios.states?.analytics?.after) items.push(m.ui.md.noAnalyticsOnly);
|
|
374
|
+
} else if (runs.length) items.push(m.ui.md.noScenarios);
|
|
375
|
+
const chosen = new Set(runs.flatMap((r) => r.providers ?? []));
|
|
376
|
+
const seen = new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)));
|
|
377
|
+
const silent = sortPlatforms([...chosen].filter((p) => !seen.has(p)));
|
|
378
|
+
if (silent.length) items.push(m.ui.md.platformsSilent(silent.map((p) => mdText(platformLabel(p))).join(silent.length === 2 ? " & " : ", "), silent.length));
|
|
379
|
+
return items.length ? [`## ${m.ui.md.notChecked}`, "", ...items.map((i) => `- ${i}`), ""] : [];
|
|
805
380
|
}
|
|
806
381
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
function inventoryMd(m, runs) {
|
|
382
|
+
/** The vendor table, capped; the HTML report has the rest. Omitted when nothing was recorded. */
|
|
383
|
+
function mdInventory(m, runs) {
|
|
810
384
|
const inv = buildInventory(runs);
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
lines.push(`| ${
|
|
814
|
-
|
|
385
|
+
if (!inv.vendors.length) return [];
|
|
386
|
+
const lines = [`## ${m.ui.inventoryTitle}`, "", `| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|"];
|
|
387
|
+
for (const v of inv.vendors.slice(0, MD_INVENTORY_ROWS)) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", ")) || "–"} |`);
|
|
388
|
+
if (inv.vendors.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(inv.vendors.length - MD_INVENTORY_ROWS, "report.html"));
|
|
815
389
|
if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
|
|
816
390
|
lines.push("");
|
|
817
391
|
return lines;
|
|
818
392
|
}
|
|
819
393
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const pages = (v) => `${names(v.pages.slice(0, PAGES_SHOWN))}${v.pages.length > PAGES_SHOWN ? ` <span class="muted">${m.ui.morePages(v.pages.length - PAGES_SHOWN)}</span>` : ""}`;
|
|
826
|
-
const rows = inv.vendors.map((v) => `<tr><td>${escapeHtml(v.vendor)}</td><td>${escapeHtml(m.ui.party[v.party])}</td><td class="xs">${pages(v)}</td><td class="xs">${v.cookies.length ? names(v.cookies) : '<span class="muted">–</span>'}</td></tr>`).join("");
|
|
827
|
-
const other = inv.otherCookies.length ? `<p class="muted xs legend">${m.ui.otherCookies}: ${inv.otherCookies.map((c) => `${escapeHtml(c.name)} (${escapeHtml(m.ui.party[c.party])})`).join(", ")}</p>` : "";
|
|
828
|
-
return `<div class="card" id="inventory">${head}<div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.vendorCol}</th><th>${m.ui.partyCol}</th><th>${m.ui.pagesCol}</th><th>${m.ui.cookiesCol}</th></tr></thead><tbody>${rows}</tbody></table></div></div>${other ? `<div class="card-c">${other}</div>` : ""}</div>`;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function providersCard(m, runs) {
|
|
832
|
-
const provs = checkedProviders(runs);
|
|
833
|
-
const skipped = skippedTotals(runs);
|
|
834
|
-
if (!provs.length && !skipped.length) return "";
|
|
835
|
-
const calls = runs.flatMap((r) => r.network ?? []);
|
|
836
|
-
const rows = [
|
|
837
|
-
...provs.map((k) => [platformLabel(k), badge("ok", m.ui.checked), calls.filter((c) => callProvider(c) === k).length]),
|
|
838
|
-
...skipped.map(([v, n]) => [v, badge("muted", m.ui.outOfScopeStatus), n]),
|
|
839
|
-
];
|
|
840
|
-
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>`;
|
|
394
|
+
/** The trend in up to five lines: the text sparkline with the direction, then the open-longest and flapping lines. */
|
|
395
|
+
function mdTrend(m, trend, lang) {
|
|
396
|
+
if (!trend || trend.runs < 2) return [];
|
|
397
|
+
const [headline, ...rest] = historySummaryLines(trend, lang);
|
|
398
|
+
return [`## ${m.ui.md.trend}`, "", `\`${sparkline(trend)}\` ${mdText(headline)}`, ...rest.map(mdText), ""];
|
|
841
399
|
}
|
|
842
400
|
|
|
843
|
-
/**
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if (runs.length) {
|
|
871
|
-
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)}`);
|
|
872
|
-
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)}"]`));
|
|
873
|
-
if (steps.length) out.push(`${steps.join(",")}{background:var(--accent-bg);color:var(--fg)}`);
|
|
401
|
+
/**
|
|
402
|
+
* The Markdown report: the verdict in the title, the meta and counts, what changed, the priorities by
|
|
403
|
+
* owner, up to five full finding blocks, consent, what passed, the journeys with their flagged steps,
|
|
404
|
+
* what was not checked, the inventory and the trend. `metrics` comes from computeMetrics (computed
|
|
405
|
+
* here when the caller has none) and `trend` from history.js (null without a saved history).
|
|
406
|
+
*/
|
|
407
|
+
export function renderMarkdown({ title, site = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null, metrics = null, trend = null }) {
|
|
408
|
+
const m = messages(lang);
|
|
409
|
+
const grouped = attachExplanations(findings, explained);
|
|
410
|
+
// Ids are given in the HTML report's row order, before the sort, so a counter suffix lands on the same card.
|
|
411
|
+
const ids = cardIds(grouped);
|
|
412
|
+
const rows = [...grouped].sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
|
|
413
|
+
const counts = summarize(rows);
|
|
414
|
+
const accepted = rows.filter((f) => f.accepted).length;
|
|
415
|
+
metrics ??= computeMetrics({ plan, runs, findings, consentScenarios });
|
|
416
|
+
const platformsSeen = sortPlatforms([...new Set(runs.flatMap((r) => (r.hits ?? []).map((h) => h.platform)))]);
|
|
417
|
+
const meta = [m.ui.md.meta(mdText(generatedAt), runs.length, m.ui.md.platformsSeen(platformsSeen.length, platformsSeen.map((p) => mdText(platformLabel(p))).join(", ")), VERSION), `[${m.ui.md.htmlReport}](report.html)`, ...(against ? [mdText(m.ui.checkedAgainst(against))] : [])];
|
|
418
|
+
const status = `**${m.verdict.status[metrics.verdict.status]}**${metrics.score?.score === null || metrics.score?.score === undefined ? "" : ` · ${m.ui.md.score(metrics.score.score)}`}`;
|
|
419
|
+
const lines = [`# ${mdText(m.ui.md.title(site ?? title, mdHeadline(m, runs, rows, metrics)))}`, "", meta.join(" · "), status, `**${m.ui.counts(counts)}**${changes && !changes.previous ? ` · ${m.ui.noPreviousRun}` : ""}`, ""];
|
|
420
|
+
if (accepted) lines.push(mdText(m.ui.acceptedLine(accepted)), "");
|
|
421
|
+
if (planChanges) lines.push(mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : []));
|
|
422
|
+
const sum = summaryParts(explained?.summary);
|
|
423
|
+
if (sum) {
|
|
424
|
+
lines.push(`${m.ui.md.aiSummary}: ${sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`}`);
|
|
425
|
+
lines.push(...sum.points.slice(0, 3).map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`));
|
|
426
|
+
if (sum.also_check.length) lines.push(`- ${m.ui.alsoCheck}: ${sum.also_check.slice(0, 2).map(mdText).join("; ")}`);
|
|
427
|
+
lines.push("");
|
|
874
428
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
:
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
html{scroll-padding-top:64px;scrollbar-gutter:stable;overflow-y:scroll}
|
|
895
|
-
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}
|
|
896
|
-
a{color:var(--accent-fg);text-decoration:none}
|
|
897
|
-
h1,h2,h3,h4,h5,h6{margin:0;font-weight:500;line-height:1.3}
|
|
898
|
-
h1{font-size:24px;letter-spacing:-.02em;overflow-wrap:anywhere}h2,h3{font-size:14px}
|
|
899
|
-
p{margin:0}
|
|
900
|
-
strong{font-weight:600;color:var(--fg)}
|
|
901
|
-
code,.mono,.time,pre{font-family:var(--font-mono)}
|
|
902
|
-
code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
|
|
903
|
-
.muted{color:var(--muted)}.xs{font-size:12px}
|
|
904
|
-
.label{font-size:12px;color:var(--muted);margin:0 0 6px}
|
|
905
|
-
.wrap{width:100%;max-width:1120px;margin:0 auto;padding:0 16px}
|
|
906
|
-
.topbar{background:var(--bg);border-bottom:1px solid var(--border)}
|
|
907
|
-
.topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
|
|
908
|
-
.product-logo img{display:block;height:26px;width:auto}
|
|
909
|
-
.brand-name{font:600 16px var(--font-sans);color:var(--fg)}
|
|
910
|
-
.byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
|
|
911
|
-
.logo-h img{display:block;height:18px;width:auto}
|
|
912
|
-
.title{margin-top:20px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}
|
|
913
|
-
.site{display:flex;align-items:center;gap:10px;min-width:0}
|
|
914
|
-
.site .k{font:500 11px/1.4 var(--font-mono);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);background:var(--muted-bg);border:1px solid var(--border);border-radius:2px;padding:2px 6px}
|
|
915
|
-
.site .v{font-size:20px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow-wrap:anywhere}
|
|
916
|
-
.run{margin:0;display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px 16px;font-size:13px}
|
|
917
|
-
.run dt{color:var(--muted)}.run dd{margin:0;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center;min-width:0;overflow-wrap:anywhere}
|
|
918
|
-
.ptag.off{opacity:.55;text-decoration:line-through}
|
|
919
|
-
.ctl{list-style:none;margin:0;padding:0;display:grid;gap:12px}
|
|
920
|
-
.ctl>li{border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--page)}
|
|
921
|
-
.ctl .ch{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;font-size:13px;margin-bottom:8px}
|
|
922
|
-
.ctl .ch b{font-weight:600}
|
|
923
|
-
.sigs{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:6px}
|
|
924
|
-
.sig{display:flex;justify-content:space-between;gap:8px;align-items:center;border:1px solid var(--border);border-radius:4px;padding:5px 10px;font:12px var(--font-mono);background:var(--bg)}
|
|
925
|
-
.sig .granted{color:var(--ok)}.sig .denied{color:var(--danger)}
|
|
926
|
-
.jconsent{margin-bottom:16px}
|
|
927
|
-
.eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
|
|
928
|
-
.title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
|
|
929
|
-
.title .plan-changes ul{margin:4px 0 0;padding-left:18px}
|
|
930
|
-
.toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
|
|
931
|
-
.toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
|
|
932
|
-
.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}
|
|
933
|
-
.toptabs a:hover{color:var(--fg)}
|
|
934
|
-
.n{font:500 12px/1.4 var(--font-mono);background:var(--muted-bg);color:var(--muted);padding:0 6px;border-radius:4px}
|
|
935
|
-
body:not(:has(:target)) .toptabs a[href="#overview"]{color:var(--fg);border-bottom-color:var(--accent)}
|
|
936
|
-
.tabpanel{display:none;padding:24px 0 64px}
|
|
937
|
-
.tabpanel:target,.tabpanel:has(:target){display:block}
|
|
938
|
-
body:not(:has(.tabpanel:target,.tabpanel :target)) #overview{display:block}
|
|
939
|
-
.card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
|
940
|
-
.card-h{padding:16px 20px 0}.card-c{padding:16px 20px 20px}.card-c.tight{padding:8px}
|
|
941
|
-
.card-t{font-weight:500}.card-d{font-size:12px;color:var(--muted);margin-top:2px}
|
|
942
|
-
.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)}
|
|
943
|
-
.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)}
|
|
944
|
-
.stack{display:grid;gap:16px}
|
|
945
|
-
.metrics{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}
|
|
946
|
-
.metric{padding:16px;border-top:3px solid var(--border)}
|
|
947
|
-
.metric .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
948
|
-
.metric .k::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--muted)}
|
|
949
|
-
.metric.broken{border-top-color:var(--danger-dot)}.metric.broken .k::before{background:var(--danger-dot)}.metric.broken .v{color:var(--danger)}
|
|
950
|
-
.metric.risk{border-top-color:var(--risk)}.metric.risk .k::before{background:var(--risk)}.metric.risk .v{color:var(--risk)}
|
|
951
|
-
.metric.warning{border-top-color:var(--warn-dot)}.metric.warning .k::before{background:var(--warn-dot)}.metric.warning .v{color:var(--warn)}
|
|
952
|
-
.metric.ok{border-top-color:var(--ok)}.metric.ok .k::before{background:var(--ok)}
|
|
953
|
-
.metric .v{font:600 24px/1.3 var(--font-mono);margin-top:4px}
|
|
954
|
-
.headline{font-weight:500}
|
|
955
|
-
.summary-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}
|
|
956
|
-
.summary-list li{display:grid;grid-template-columns:72px 1fr;gap:8px;align-items:baseline}
|
|
957
|
-
.summary-list .badge{justify-self:start}
|
|
958
|
-
.also{margin:0;padding-left:18px;display:grid;gap:6px}
|
|
959
|
-
.scope{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;font-size:12px;color:var(--muted)}
|
|
960
|
-
.scope-skipped,.legend{margin-top:8px}
|
|
961
|
-
table.dt{width:100%;border-collapse:collapse}
|
|
962
|
-
table.dt th,table.dt td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:middle}
|
|
963
|
-
table.dt th{font-weight:500;color:var(--muted);font-size:12px}
|
|
964
|
-
table.dt tr:last-child td{border-bottom:0}
|
|
965
|
-
table.dt td.num{font-family:var(--font-mono)}
|
|
966
|
-
table.dt td.num.bad{color:var(--danger);font-weight:600}
|
|
967
|
-
table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
|
|
968
|
-
.tscroll{overflow-x:auto}
|
|
969
|
-
.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
|
|
970
|
-
.sevfilter{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius)}
|
|
971
|
-
.fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
972
|
-
.sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
|
|
973
|
-
.filter-host{position:relative}
|
|
974
|
-
.changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
|
|
975
|
-
.flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
|
976
|
-
.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)}
|
|
977
|
-
.finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
|
|
978
|
-
.finding.sev-risk{--sev:var(--risk);--sev-fg:var(--risk);--sev-bg:var(--risk-bg)}
|
|
979
|
-
.finding.sev-info{--sev:var(--accent);--sev-fg:var(--accent-fg);--sev-bg:var(--accent-bg)}
|
|
980
|
-
.finding>details>summary{display:flex;align-items:center;gap:12px;padding:14px 16px}
|
|
981
|
-
.finding>details[open]>summary{background:linear-gradient(90deg,var(--sev-bg),transparent 70%)}
|
|
982
|
-
.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}
|
|
983
|
-
.fhead{flex:1;min-width:0;display:grid;gap:4px}
|
|
984
|
-
.ftitle{color:var(--fg);overflow-wrap:anywhere}
|
|
985
|
-
.fmeta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted)}
|
|
986
|
-
.finding summary .chev{margin-left:4px}
|
|
987
|
-
.fbody{padding:16px;border-top:1px solid var(--border)}
|
|
988
|
-
.impact{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;background:var(--sev-bg);color:var(--fg);border-radius:var(--radius)}
|
|
989
|
-
.impact svg{color:var(--sev-fg);flex:none;margin-top:3px}
|
|
990
|
-
.impact-l{display:block;font-size:12px;color:var(--sev-fg);margin-bottom:2px}
|
|
991
|
-
.fcols{display:grid;gap:8px 32px;margin-top:4px}
|
|
992
|
-
.fcol{min-width:0}
|
|
993
|
-
.sec-h{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);margin:16px 0 8px}
|
|
994
|
-
.sec-h svg{color:var(--accent-fg)}
|
|
995
|
-
.ev-table{border:1px solid var(--border);border-radius:var(--radius);border-collapse:separate;border-spacing:0;overflow:hidden}
|
|
996
|
-
.ev-table th{background:var(--muted-bg)}
|
|
997
|
-
.missing{display:inline-block;padding:0 6px;border-radius:4px;background:var(--danger-bg);color:var(--danger)}
|
|
998
|
-
.fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
|
|
999
|
-
.fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
|
|
1000
|
-
.fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
|
|
1001
|
-
.ogroup{margin-top:20px}.ogroup-h{display:flex;align-items:baseline;gap:8px;font-size:14px;margin:0 0 10px}.ogroup-h .n{font-size:12px;color:var(--muted)}
|
|
1002
|
-
.verify{display:flex;flex-wrap:wrap;gap:6px 14px;margin:0;padding:0;list-style:none;font-size:13px}.verify a{color:var(--accent-fg)}
|
|
1003
|
-
.where{display:flex;flex-wrap:wrap;gap:6px}
|
|
1004
|
-
.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)}
|
|
1005
|
-
span.chip{padding-left:10px}
|
|
1006
|
-
a.chip:hover{border-color:var(--accent);color:var(--fg)}
|
|
1007
|
-
.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)}
|
|
1008
|
-
.sevbar{display:flex;height:8px;border-radius:4px;overflow:hidden;gap:2px;margin:0 0 6px}
|
|
1009
|
-
.sevbar span{display:block}
|
|
1010
|
-
.sevlegend{display:flex;flex-wrap:wrap;gap:4px 16px;font-size:12px;color:var(--muted);margin-bottom:16px}
|
|
1011
|
-
.sevlegend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
|
|
1012
|
-
body>main{flex:1 0 auto;width:100%}
|
|
1013
|
-
.footer{border-top:1px solid var(--border);background:var(--bg);margin-top:32px;flex:none}
|
|
1014
|
-
.footer .wrap{display:grid;gap:16px;padding:32px 16px}
|
|
1015
|
-
.footer p{font-size:12px;color:var(--muted)}
|
|
1016
|
-
.footer a{color:var(--fg)}.footer a:hover{color:var(--accent-fg)}
|
|
1017
|
-
.footer .f-links{display:flex;flex-wrap:wrap;gap:8px 20px;font-size:12px;margin-top:6px}
|
|
1018
|
-
.footer .f-meta{font:12px var(--font-mono);color:var(--muted)}
|
|
1019
|
-
.logo-link{display:inline-flex}.logo-s img{display:block;height:56px;width:auto}
|
|
1020
|
-
.f-atd{display:inline-flex;align-items:center;gap:6px;vertical-align:-3px}.f-atd img{height:16px;width:auto}
|
|
1021
|
-
.jtabs{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;background:var(--muted-bg);border-radius:var(--radius);margin-bottom:16px}
|
|
1022
|
-
.jtabs a{display:inline-flex;align-items:center;gap:8px;padding:5px 10px;border-radius:4px;color:var(--muted);font-weight:500}
|
|
1023
|
-
.jtabs a:hover{color:var(--fg)}
|
|
1024
|
-
#journeys:not(:has(.jpanel:target,.jpanel :target)) .jtabs a[href="#j0"]{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}
|
|
1025
|
-
.jpanel{display:none}
|
|
1026
|
-
.jpanel:target,.jpanel:has(:target){display:block}
|
|
1027
|
-
#journeys:not(:has(.jpanel:target,.jpanel :target)) .jpanel:first-of-type{display:block}
|
|
1028
|
-
.jhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:16px}
|
|
1029
|
-
.jgrid2{display:grid;gap:16px;align-items:start}
|
|
1030
|
-
.stepnav{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:6px}
|
|
1031
|
-
.stepnav ol{list-style:none;margin:0;padding:0;display:grid;gap:2px}
|
|
1032
|
-
.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)}
|
|
1033
|
-
.stepnav a:hover{background:var(--muted-bg);color:var(--fg)}
|
|
1034
|
-
.jpanel:not(:has(.sdetail:target)) .stepnav a.def{background:var(--accent-bg);color:var(--fg)}
|
|
1035
|
-
.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)}
|
|
1036
|
-
.sl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
|
|
1037
|
-
.dot-flag{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--danger-dot)}
|
|
1038
|
-
.sc{font:12px var(--font-mono);color:var(--muted);white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
|
|
1039
|
-
.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}
|
|
1040
|
-
.sdetail:target{display:block}
|
|
1041
|
-
.jpanel:not(:has(.sdetail:target)) .sdetail.default{display:block}
|
|
1042
|
-
.shead{display:flex;gap:12px;align-items:flex-start;padding:16px}
|
|
1043
|
-
.shead h3{overflow-wrap:anywhere}
|
|
1044
|
-
.shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
|
|
1045
|
-
.empty{color:var(--muted);padding:8px 0}
|
|
1046
|
-
.tabs.seg{display:grid;grid-template-columns:repeat(4,auto) 1fr;padding:0 16px 16px}
|
|
1047
|
-
.tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
1048
|
-
.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}
|
|
1049
|
-
.tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
|
|
1050
|
-
.tabs.seg>label:nth-of-type(4){border-radius:0 var(--radius) var(--radius) 0}
|
|
1051
|
-
.tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
|
|
1052
|
-
.tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
|
|
1053
|
-
.tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
|
|
1054
|
-
.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),.tabs.seg>input:nth-of-type(4):checked~.panel:nth-of-type(4){display:block}
|
|
1055
|
-
.timeline{list-style:none;margin:0;padding:0 0 0 18px;border-left:2px solid var(--border);display:grid;gap:14px}
|
|
1056
|
-
.tl-node{position:relative;display:grid;gap:6px;min-width:0}
|
|
1057
|
-
.tl-node::before{content:"";position:absolute;left:-24px;top:4px;width:10px;height:10px;border-radius:50%;background:var(--bg);border:2px solid var(--accent)}
|
|
1058
|
-
.tl-orphan::before{border-color:var(--muted)}
|
|
1059
|
-
.tl-lbl{font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
1060
|
-
.tl-gtm{display:flex;gap:8px;align-items:center;font-size:13px;padding-left:12px}
|
|
1061
|
-
.tl-k{color:var(--muted)}
|
|
1062
|
-
.tl-hits,.tl-none{margin-left:12px}
|
|
1063
|
-
.filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
|
|
1064
|
-
.filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
1065
|
-
.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)}
|
|
1066
|
-
.filter>label:hover{color:var(--fg)}
|
|
1067
|
-
.filter>input:checked+label{background:var(--fg);border-color:var(--fg);color:var(--bg)}
|
|
1068
|
-
.filter>input:checked+label .n{background:transparent;color:inherit}
|
|
1069
|
-
.filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
|
|
1070
|
-
.filter>label:last-of-type{margin-bottom:4px}
|
|
1071
|
-
.dot{display:inline-block;width:12px;height:12px;border-radius:2px;background:var(--muted);vertical-align:-2px}
|
|
1072
|
-
.dot.p-ga4,.ptag.p-ga4::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjIwIiByeD0iMyIgZmlsbD0iI0Y5QUIwMCIvPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSI2IiBoZWlnaHQ9IjEzIiByeD0iMyIgZmlsbD0iI0UzNzQwMCIvPjxjaXJjbGUgY3g9IjYiIGN5PSIxOSIgcj0iMyIgZmlsbD0iI0UzNzQwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-google_ads,.ptag.p-google_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iOS41IiB5PSIxLjUiIHdpZHRoPSI2IiBoZWlnaHQ9IjIxIiByeD0iMyIgdHJhbnNmb3JtPSJyb3RhdGUoMzAgMTIuNSAxMikiIGZpbGw9IiNGQkJDMDQiLz48cmVjdCB4PSI4LjUiIHk9IjEuNSIgd2lkdGg9IjYiIGhlaWdodD0iMjEiIHJ4PSIzIiB0cmFuc2Zvcm09InJvdGF0ZSgtMzAgMTEuNSAxMikiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSI1LjUiIGN5PSIxOC41IiByPSIzIiBmaWxsPSIjMzRBODUzIi8+PC9zdmc+") center/contain no-repeat}.dot.p-meta,.ptag.p-meta::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIuNSAxNS41YzAtNC40IDIuMy05IDUtOSAzLjQgMCA2IDExIDkuNSAxMSAyLjIgMCA0LjUtMi42IDQuNS02LjVTMTkuOCA2LjUgMTcuNSA2LjVjLTMuNCAwLTYgMTEtOS44IDExLTIuOSAwLTUuMi0uNy01LjItMnoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzA4NjZGRiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-tiktok,.ptag.p-tiktok::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIyLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iIzI1RjRFRSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iI0ZFMkM1NSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIvPjxwYXRoIGQ9Ik0xMyAzdjExLjVhMy41IDMuNSAwIDEgMS0zLTMuNDZNMTMgM2MuNSAyLjUgMi41IDQgNSA0IiBzdHJva2U9IiMxMTEiLz48L2c+PC9zdmc+") center/contain no-repeat}.dot.p-x,.ptag.p-x::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjkwMSAxLjE1M2gzLjY4bC04LjA0IDkuMTlMMjQgMjIuODQ2aC03LjQwNmwtNS44LTcuNTg0LTYuNjM4IDcuNTg0SC40NzRsOC42LTkuODNMMCAxLjE1NGg3LjU5NGw1LjI0MyA2LjkzMlpNMTcuNjEgMjAuNjQ0aDIuMDM5TDYuNDg2IDMuMjRINC4yOThaIiBmaWxsPSIjMDAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-linkedin,.ptag.p-linkedin::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwLjQ0NyAyMC40NTJoLTMuNTU0di01LjU2OWMwLTEuMzI4LS4wMjctMy4wMzctMS44NTItMy4wMzctMS44NTMgMC0yLjEzNiAxLjQ0NS0yLjEzNiAyLjkzOXY1LjY2N0g5LjM1MVY5aDMuNDE0djEuNTYxaC4wNDZjLjQ3Ny0uOSAxLjYzNy0xLjg1IDMuMzctMS44NSAzLjYwMSAwIDQuMjY3IDIuMzcgNC4yNjcgNS40NTV2Ni4yODZ6TTUuMzM3IDcuNDMzYy0xLjE0NCAwLTIuMDYzLS45MjYtMi4wNjMtMi4wNjUgMC0xLjEzOC45Mi0yLjA2MyAyLjA2My0yLjA2MyAxLjE0IDAgMi4wNjQuOTI1IDIuMDY0IDIuMDYzIDAgMS4xMzktLjkyNSAyLjA2NS0yLjA2NCAyLjA2NXptMS43ODIgMTMuMDE5SDMuNTU1VjloMy41NjR2MTEuNDUyek0yMi4yMjUgMEgxLjc3MUMuNzkyIDAgMCAuNzc0IDAgMS43Mjl2MjAuNTQyQzAgMjMuMjI3Ljc5MiAyNCAxLjc3MSAyNGgyMC40NTFDMjMuMiAyNCAyNCAyMy4yMjcgMjQgMjIuMjcxVjEuNzI5QzI0IC43NzQgMjMuMiAwIDIyLjIyMiAwaC4wMDN6IiBmaWxsPSIjMEE2NkMyIi8+PC9zdmc+") center/contain no-repeat}.dot.p-microsoft_ads,.ptag.p-microsoft_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0YyNTAyMiIvPjxyZWN0IHg9IjEyLjUiIHk9IjEiIHdpZHRoPSIxMC41IiBoZWlnaHQ9IjEwLjUiIGZpbGw9IiM3RkJBMDAiLz48cmVjdCB4PSIxIiB5PSIxMi41IiB3aWR0aD0iMTAuNSIgaGVpZ2h0PSIxMC41IiBmaWxsPSIjMDBBNEVGIi8+PHJlY3QgeD0iMTIuNSIgeT0iMTIuNSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0ZGQjkwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-pinterest,.ptag.p-pinterest::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLjAxNyAwQzUuMzk2IDAgLjAyOSA1LjM2Ny4wMjkgMTEuOTg3YzAgNS4wNzkgMy4xNTggOS40MTcgNy42MTggMTEuMTYyLS4xMDUtLjk0OS0uMTk5LTIuNDAzLjA0MS0zLjQzOS4yMTktLjkzNyAxLjQwNi01Ljk1NyAxLjQwNi01Ljk1N3MtLjM1OS0uNzItLjM1OS0xLjc4MWMwLTEuNjYzLjk2Ny0yLjkxMSAyLjE2OC0yLjkxMSAxLjAyNCAwIDEuNTE4Ljc2OSAxLjUxOCAxLjY4OCAwIDEuMDI5LS42NTMgMi41NjctLjk5MiAzLjk5Mi0uMjg1IDEuMTkzLjYgMi4xNjUgMS43NzUgMi4xNjUgMi4xMjggMCAzLjc2OC0yLjI0NSAzLjc2OC01LjQ4NyAwLTIuODYxLTIuMDYzLTQuODY5LTUuMDA4LTQuODY5LTMuNDEgMC01LjQwOSAyLjU2Mi01LjQwOSA1LjE5OSAwIDEuMDMzLjM5NCAyLjE0My44ODkgMi43NDEuMDk5LjEyLjExMi4yMjUuMDg1LjM0NS0uMDkuMzc1LS4yOTMgMS4xOTktLjMzNCAxLjM2My0uMDUzLjIyNS0uMTcyLjI3MS0uNDAxLjE2NS0xLjQ5NS0uNjktMi40MzMtMi44NzgtMi40MzMtNC42NDYgMC0zLjc3NiAyLjc0OC03LjI1MiA3LjkyLTcuMjUyIDQuMTU4IDAgNy4zOTIgMi45NjcgNy4zOTIgNi45MjMgMCA0LjEzNS0yLjYwNyA3LjQ2Mi02LjIzMyA3LjQ2Mi0xLjIxNCAwLTIuMzU0LS42MjktMi43NTgtMS4zNzlsLS43NDkgMi44NDhjLS4yNjkgMS4wNDUtMS4wMDQgMi4zNTItMS40OTggMy4xNDYgMS4xMjMuMzQ1IDIuMzA2LjUzNSAzLjU1LjUzNSA2LjYwNyAwIDExLjk4NS01LjM2NSAxMS45ODUtMTEuOTg3QzIzLjk3IDUuMzkgMTguNTkyLjAyNiAxMS45ODUuMDI2TDEyLjAxNyAweiIgZmlsbD0iI0JEMDgxQyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-snapchat,.ptag.p-snapchat::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iI0ZGRkMwMCIvPjxwYXRoIGQ9Ik0xMi4yMDYuNzkzYy45OSAwIDQuMzQ3LjI3NiA1LjkzIDMuODIxLjUyOSAxLjE5My40MDMgMy4yMTkuMjk5IDQuODQ3bC0uMDAzLjA2Yy0uMDEyLjE4LS4wMjIuMzQ1LS4wMy41MS4wNzUuMDQ1LjIwMy4wOS40MDEuMDkuMy0uMDE2LjY1OS0uMTIgMS4wMzMtLjMwMS4xNjUtLjA4OC4zNDQtLjEwNC40NjQtLjEwNC4xODIgMCAuMzU5LjAyOS41MDkuMDkuNDUuMTQ5LjczNC40NzkuNzM0LjgzOC4wMTUuNDQ5LS4zOS44MzktMS4yMTMgMS4xNjgtLjA4OS4wMjktLjIwOS4wNzUtLjM0NC4xMTktLjQ1LjEzNS0xLjEzOS4zNi0xLjMzMy44MS0uMDkuMjI0LS4wNjEuNTI0LjEyLjg2OGwuMDE1LjAxNWMuMDYuMTM2IDEuNTI2IDMuNDc1IDQuNzkxIDQuMDE0LjI1NS4wNDQuNDM1LjI3LjQyLjUwOSAwIC4wNzUtLjAxNS4xNDktLjA0NS4yMjUtLjI0LjU2OS0xLjI3My45ODgtMy4xNDYgMS4yNzEtLjA1OS4wOTEtLjEyLjM3NS0uMTY0LjU3LS4wMjkuMTc5LS4wNzQuMzYtLjEzNC41NTMtLjA3Ni4yNzEtLjI3LjQwNS0uNTU1LjQwNWgtLjAzYy0uMTM1IDAtLjMxMy0uMDMxLS41MzgtLjA3NC0uMzYtLjA3NS0uNzY1LS4xMzUtMS4yNzMtLjEzNS0uMyAwLS41OTkuMDE1LS45MTMuMDc0LS42LjEwNC0xLjEyMy40NjQtMS43MjMuODg0LS44NTMuNTk5LTEuODI2IDEuMjg4LTMuMjk0IDEuMjg4LS4wNiAwLS4xMTktLjAxNS0uMTgtLjAxNWgtLjE0OWMtMS40NjggMC0yLjQyNy0uNjc1LTMuMjc5LTEuMjg4LS41OTktLjQyLTEuMTA3LS43NzktMS43MDctLjg4NC0uMzE0LS4wNDUtLjYyOS0uMDc0LS45MjgtLjA3NC0uNTQgMC0uOTU4LjA4OS0xLjI3Mi4xNDktLjIxMS4wNDMtLjM5MS4wNzQtLjU0LjA3NC0uMzc0IDAtLjUyMy0uMjI0LS41ODMtLjQyLS4wNjEtLjE5Mi0uMDktLjM4OS0uMTM1LS41NjctLjA0Ni0uMTgxLS4xMDUtLjQ5NC0uMTY2LS41Ny0xLjkxOC0uMjIyLTIuOTUtLjY0Mi0zLjE4OS0xLjIyNi0uMDMxLS4wNjMtLjA1Mi0uMTUtLjA1NS0uMjI1LS4wMTUtLjI0My4xNjUtLjQ2NS40Mi0uNTA5IDMuMjY0LS41NCA0LjczLTMuODc5IDQuNzkxLTQuMDJsLjAxNi0uMDI5Yy4xOC0uMzQ1LjIyNC0uNjQ1LjExOS0uODY5LS4xOTUtLjQzNC0uODg0LS42NTgtMS4zMzItLjgwOS0uMTIxLS4wMjktLjI0LS4wNzQtLjM0Ni0uMTE5LTEuMTA3LS40MzUtMS4yNTctLjkzLTEuMTk3LTEuMjczLjA5LS40NzkuNjc0LS43OTMgMS4xNjgtLjc5My4xNDYgMCAuMjcuMDI5LjM4My4wNzQuNDIuMTk0Ljc4OS4zIDEuMTA0LjMuMjM0IDAgLjM4NC0uMDYuNDY1LS4xMDVsLS4wNDYtLjU2OWMtLjA5OC0xLjYyNi0uMjI1LTMuNjUxLjMwNy00LjgzN0M3LjM5MiAxLjA3NyAxMC43MzkuODA3IDExLjcyNy44MDdsLjQxOS0uMDE1aC4wNnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykgc2NhbGUoLjc1KSIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEuMyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-criteo,.ptag.p-criteo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEiIGZpbGw9IiNGRTUwMDAiLz48cGF0aCBkPSJNMTYuMiA4LjZhNSA1IDAgMSAwIDAgNi44IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi42IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-hotjar,.ptag.p-hotjar::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjExOSA5LjgxNEMxMi44OTkgOC4yNyAxNi43MDQgNi4xNTUgMTYuNzA0IDBoLTQuNjA5YzAgMy40NDQtMS42NzYgNC4zNzUtNC4yMTQgNS43ODZDNS4xIDcuMzMgMS4yOTUgOS40NDQgMS4yOTUgMTUuNmg0LjYxYzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODZ6TTE4LjA5NiA4LjRjMCAzLjQ0NC0xLjY3NyA0LjM3Ni00LjIxNSA1Ljc4NS0yLjc3OCAxLjU0NC02LjU4NSAzLjY2LTYuNTg1IDkuODE1aDQuNjA5YzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODYgMi43OC0xLjU0NCA2LjU4Ni0zLjY1OCA2LjU4Ni05LjgxNGgtNC42MDl6IiBmaWxsPSIjRkYzQzAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-adobe,.ptag.p-adobe::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTAgMmg4LjVMMCAyMnpNMjQgMmgtOC41TDI0IDIyek0xMiA5bDUuNSAxM2gtMy42bC0xLjctNC4ySDguOXoiIGZpbGw9IiNFQjEwMDAiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-matomo,.ptag.p-matomo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThsNi05IDYgOSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzE1MkEwIiBzdHJva2Utd2lkdGg9IjIiLz48Y2lyY2xlIGN4PSI2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiM5NUM3NDgiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjkiIHI9IjMiIGZpbGw9IiMzMTUyQTAiLz48Y2lyY2xlIGN4PSIxOCIgY3k9IjE4IiByPSIzIiBmaWxsPSIjMzE1MkEwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-piwik_pro,.ptag.p-piwik_pro::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzBDMkQ1QiIvPjxwYXRoIGQ9Ik04IDE4VjZoNWEzLjUgMy41IDAgMDEwIDdIOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+") center/contain no-repeat}.dot.p-clarity,.ptag.p-clarity::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzAwNzhENCIvPjxwYXRoIGQ9Ik0xMiA1YTcgNyAwIDEwMCAxNE0xMiA5YTMgMyAwIDEwMCA2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}
|
|
1073
|
-
.evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
|
|
1074
|
-
.evlist>li+li{border-top:1px solid var(--border)}
|
|
1075
|
-
summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
|
|
1076
|
-
summary:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
1077
|
-
.chev{flex:none;color:var(--muted);transition:transform .15s ease}details[open]>summary>.chev{transform:rotate(90deg)}
|
|
1078
|
-
.event>summary{display:flex;align-items:center;gap:10px;padding:8px 12px;flex-wrap:wrap}
|
|
1079
|
-
.event>summary:hover{background:var(--muted-bg)}
|
|
1080
|
-
.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}
|
|
1081
|
-
.event.flag-broken>summary .ev::after{background:var(--danger-dot)}.event.flag-risk>summary .ev::after{background:var(--risk)}
|
|
1082
|
-
.event .tag{display:none}
|
|
1083
|
-
.ev{font:12px var(--font-mono);background:none;padding:0;overflow-wrap:anywhere}
|
|
1084
|
-
.event .meta{font-size:12px;color:var(--muted)}
|
|
1085
|
-
.event .time,.time{font-size:12px;color:var(--muted);margin-left:auto}
|
|
1086
|
-
.ptag{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
1087
|
-
.ptag::before{content:"";width:14px;height:14px;flex:none;border-radius:2px;background:var(--muted)}
|
|
1088
|
-
.event-body{padding:4px 12px 16px}
|
|
1089
|
-
.flagnote{margin:4px 0 12px;padding:8px 12px;border-radius:var(--radius);background:var(--warn-bg);color:var(--warn)}
|
|
1090
|
-
.flagnote.broken{background:var(--danger-bg);color:var(--danger)}.flagnote.risk{background:var(--risk-bg);color:var(--risk)}
|
|
1091
|
-
.event-body h5{font-size:12px;color:var(--muted);font-weight:400;margin:12px 0 6px}
|
|
1092
|
-
.event-body h5:first-child{margin-top:4px}
|
|
1093
|
-
h5 .n{margin-left:4px}
|
|
1094
|
-
.ebgrid{display:grid;gap:12px}
|
|
1095
|
-
.ebgrid>*{min-width:0}
|
|
1096
|
-
.ptable{width:100%;border-collapse:collapse;font:12px/1.5 var(--font-mono)}
|
|
1097
|
-
.ptable th,.ptable td{text-align:left;vertical-align:top;padding:5px 8px;border-bottom:1px solid var(--border)}
|
|
1098
|
-
.ptable th{color:var(--muted);font-weight:400;white-space:nowrap;width:1%;min-width:120px}
|
|
1099
|
-
.glossary th{min-width:170px}
|
|
1100
|
-
.ptable td{overflow-wrap:anywhere}
|
|
1101
|
-
.ptable tr:last-child>*{border-bottom:0}
|
|
1102
|
-
.consents{display:flex;flex-wrap:wrap;gap:6px}
|
|
1103
|
-
.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)}
|
|
1104
|
-
.ebgrid aside{background:var(--muted-bg);border-radius:var(--radius);padding:10px 12px;align-self:start}
|
|
1105
|
-
.ebgrid aside h5{margin-top:0!important}
|
|
1106
|
-
.srclist{list-style:none;margin:0;padding:0;display:grid;gap:8px;font-size:12px}
|
|
1107
|
-
.srclist li{display:grid;gap:2px;overflow-wrap:anywhere}.srclist .lbl{font-size:12px;color:var(--muted)}
|
|
1108
|
-
.method{font:500 12px var(--font-mono);padding:0 5px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg)}
|
|
1109
|
-
.status{color:var(--ok);font-family:var(--font-mono);font-size:12px}
|
|
1110
|
-
.small{font-size:12px}
|
|
1111
|
-
.item{border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:8px}
|
|
1112
|
-
.item-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 10px;margin-bottom:10px}
|
|
1113
|
-
.item-head b{font-weight:500}
|
|
1114
|
-
.item-idx{font:12px var(--font-mono);color:var(--muted)}
|
|
1115
|
-
.item-price{margin-left:auto;font-size:12px}
|
|
1116
|
-
.grid4{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 16px;margin:0}
|
|
1117
|
-
.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}
|
|
1118
|
-
h6{margin:12px 0 6px;font-size:12px;font-weight:400;color:var(--muted)}
|
|
1119
|
-
.pk{margin-right:4px}
|
|
1120
|
-
.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}
|
|
1121
|
-
.q:hover,.q:focus{border-color:var(--accent);color:var(--accent-fg);outline:none}
|
|
1122
|
-
.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}
|
|
1123
|
-
tr.pr td{color:var(--muted)}
|
|
1124
|
-
details.raw{margin-top:10px}details.raw>summary{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
|
1125
|
-
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}
|
|
1126
|
-
.scope-note{margin-top:10px}
|
|
1127
|
-
.cov td.c{text-align:center;font-family:var(--font-mono)}
|
|
1128
|
-
.cov .y{color:var(--ok)}.cov .x{color:var(--danger);font-weight:600}.cov .o{color:var(--muted)}
|
|
1129
|
-
@media (min-width:768px){
|
|
1130
|
-
.wrap{padding:0 24px}
|
|
1131
|
-
.metrics{grid-template-columns:repeat(4,1fr)}
|
|
1132
|
-
.ov-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px;align-items:start}
|
|
1133
|
-
.fcols{grid-template-columns:1fr 1fr}
|
|
1134
|
-
.footer .wrap{grid-template-columns:auto 1fr auto;align-items:center;padding:32px 24px}
|
|
1135
|
-
.footer .wrap.plain{grid-template-columns:1fr auto}
|
|
1136
|
-
.jgrid2{grid-template-columns:260px minmax(0,1fr)}
|
|
1137
|
-
.stepnav{position:sticky;top:60px}
|
|
1138
|
-
.ebgrid{grid-template-columns:2fr 1fr}
|
|
1139
|
-
.grid4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
|
1140
|
-
.event-body{padding:4px 16px 16px 38px}
|
|
429
|
+
lines.push(...mdChanges(m, changes, rows));
|
|
430
|
+
lines.push(...mdTrend(m, trend, lang));
|
|
431
|
+
if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
|
|
432
|
+
lines.push(...mdPriorities(m, runs, metrics));
|
|
433
|
+
lines.push(`## ${m.ui.findings}`, "");
|
|
434
|
+
const problems = rows.filter((f) => f.severity !== "ok");
|
|
435
|
+
if (!problems.length) lines.push(m.ui.noProblems, "");
|
|
436
|
+
for (const f of problems.slice(0, MD_BLOCKS)) lines.push(...mdFinding(m, runs, f, ids));
|
|
437
|
+
const rest = problems.slice(MD_BLOCKS);
|
|
438
|
+
if (rest.length) lines.push(`### ${m.ui.md.also}`, ...rest.map((f) => `- **${m.severity[f.severity]}** ${mdDetail(f.detail).replace(/\.$/, "")} · ${mdWhere(m, runs, f.where, { full: false })}`), "");
|
|
439
|
+
lines.push(...mdConsent(m, metrics, consentScenarios));
|
|
440
|
+
lines.push(...mdPassed(m, runs, findings, metrics, planCheck));
|
|
441
|
+
lines.push(...mdJourneys(m, runs, rows));
|
|
442
|
+
lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
|
|
443
|
+
lines.push(...mdInventory(m, runs));
|
|
444
|
+
// A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
|
|
445
|
+
if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
|
|
446
|
+
else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
|
|
447
|
+
return lines.join("\n");
|
|
1141
448
|
}
|
|
1142
|
-
@media (max-width:767px){.ov-grid{display:grid;gap:16px}}
|
|
1143
|
-
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
1144
|
-
@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}}
|
|
1145
|
-
`;
|
|
1146
449
|
|
|
1147
|
-
export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null }) {
|
|
1148
|
-
const m = messages(lang);
|
|
1149
|
-
const rows = attachExplanations(findings, explained);
|
|
1150
|
-
const counts = summarize(rows);
|
|
1151
|
-
const problems = rows.filter((f) => f.severity !== "ok").length;
|
|
1152
|
-
const flagged = flaggedSteps(runs, rows);
|
|
1153
|
-
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>`;
|
|
1154
|
-
// A custom name or logo takes the place of the Analitik İşler branding. The AI Tag Debugger logo and credit stay, smaller, as with branding false.
|
|
1155
|
-
const whiteLabel = Boolean(brand?.logo || brand?.name);
|
|
1156
|
-
if (whiteLabel) branding = false;
|
|
1157
|
-
const brandName = brand?.name ? escapeHtml(brand.name) : "";
|
|
1158
|
-
const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
|
|
1159
|
-
const productLogo = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd");
|
|
1160
|
-
const atdByline = whiteLabel ? `<a href="https://github.com/analitikisler/ai-tag-debugger" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 18, "logo-atd")}</a>` : "";
|
|
1161
|
-
const footer = branding
|
|
1162
|
-
? `<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>
|
|
1163
|
-
<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>
|
|
1164
|
-
<div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
|
|
1165
|
-
: `<div class="wrap plain"><div class="f-meta">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"}</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
|
|
1166
450
|
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
${
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
<h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
|
|
1175
|
-
<div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}${planChanges ? `\n<div class="meta plan-changes">${escapeHtml(m.ui.planChangesLine(planChanges))}${planChangeItems(planChanges).length ? `<ul>${planChangeItems(planChanges).map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>` : ""}</div>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
|
|
1176
|
-
</header>
|
|
1177
|
-
<nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
|
|
1178
|
-
<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>
|
|
1179
|
-
<main class="wrap">
|
|
1180
|
-
<section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${changes ? changesCard(m, changes) : ""}${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs, { ai, plan, lang })}</section>
|
|
1181
|
-
<section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows, Boolean(explained))}</section>
|
|
1182
|
-
<section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged, consentScenarios)}</section>
|
|
1183
|
-
<section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${inventoryCard(m, runs)}${providersCard(m, runs)}</div></section>
|
|
1184
|
-
</main>
|
|
1185
|
-
<footer class="footer">${footer}</footer>
|
|
1186
|
-
</body></html>`;
|
|
451
|
+
function planVsSiteMd(m, pc) {
|
|
452
|
+
const [overall, ...perPlatform] = coverageLines(m, pc);
|
|
453
|
+
const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
|
|
454
|
+
lines.push(`| ${m.ui.event} | ${m.ui.bucketCol} | |`, "|---|---|---|");
|
|
455
|
+
for (const e of pcSort(pc)) lines.push(`| ${mdText(platformLabel(e.platform))} \`${mdCode(e.event)}\` | ${m.ui.bucket[e.bucket]} | ${mdText((e.details ?? []).join(" ")).replace(/\|/g, "\\|")} |`);
|
|
456
|
+
lines.push("");
|
|
457
|
+
return lines;
|
|
1187
458
|
}
|
|
1188
459
|
|
|
1189
460
|
const csvCell = (v) => {
|