@analitikisler/ai-tag-debugger 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/metrics.js CHANGED
@@ -210,7 +210,10 @@ export function platformParity({ runs = [] } = {}) {
210
210
  const platforms = Object.fromEntries([...byPlatform].map(([platform, hits]) => {
211
211
  const valued = hits.find((h) => paramOf(h, "value") !== undefined) ?? hits.find((h) => paramOf(h, "currency") !== undefined) ?? hits[0];
212
212
  const value = paramOf(valued, "value");
213
- return [platform, { names: uniq(hits.map((h) => h.name)), count: hits.length, value: value === undefined ? null : Number(value), currency: paramOf(valued, "currency") ?? null }];
213
+ // A value that is not a number (a template left unresolved, a product name) stays as the text the
214
+ // tag sent, so the report shows what the platform received instead of NaN. Only numbers are compared.
215
+ const n = Number(value);
216
+ return [platform, { names: uniq(hits.map((h) => h.name)), count: hits.length, value: value === undefined ? null : Number.isFinite(n) ? n : String(value), currency: paramOf(valued, "currency") ?? null }];
214
217
  }));
215
218
  if (byPlatform.size < 2) {
216
219
  const [[platform, p]] = Object.entries(platforms);
@@ -220,8 +223,11 @@ export function platformParity({ runs = [] } = {}) {
220
223
  const list = Object.values(platforms);
221
224
  const mismatch = [];
222
225
  if (uniq(list.map((p) => p.count)).length > 1) mismatch.push("count");
223
- const values = list.map((p) => p.value).filter((v) => v !== null && Number.isFinite(v));
224
- if (values.length > 1 && Math.max(...values) - Math.min(...values) > 0.005) mismatch.push("value");
226
+ // Numbers compare within rounding; once a side sent text, the platforms agree only on the same text.
227
+ const sent = list.map((p) => p.value).filter((v) => v !== null);
228
+ const numbers = sent.filter((v) => Number.isFinite(v));
229
+ const differs = numbers.length === sent.length ? Math.max(...numbers) - Math.min(...numbers) > 0.005 : uniq(sent.map(String)).length > 1;
230
+ if (sent.length > 1 && differs) mismatch.push("value");
225
231
  const currencies = uniq(list.map((p) => p.currency).filter(Boolean).map((c) => c.toUpperCase()));
226
232
  if (currencies.length > 1) mismatch.push("currency");
227
233
  // A value one platform has and the other lacks is a mismatch too: the platforms will not agree on revenue.
package/src/plan-html.js CHANGED
@@ -1,24 +1,67 @@
1
- import { CSS, VERSION, escapeHtml, logo, platformLabel } from "./report.js";
1
+ import { VERSION } from "./report-shared.js";
2
+ import { TOKENS, COMPONENTS, PRODUCT } from "./html/css.js";
3
+ import { chip, escapeHtml, logo, plat, platformLabel, sprite } from "./html/lib.js";
2
4
  import { LOGOS } from "./brand.js";
3
5
 
4
6
  const e = escapeHtml;
5
7
  const text = (v) => (v === undefined || v === null || v === "" ? `<span class="muted">–</span>` : e(v));
6
8
 
9
+ // Only what the plan page needs on top of the report's tokens and components: the top bar (a trimmed copy of the
10
+ // report shell's, which this page does not load, with no rail or verdict), the page column, and the event list. Colours come from tokens, so dark mode
11
+ // follows both selectors in TOKENS and nothing here names a colour.
7
12
  const PLAN_CSS = `
8
- .pl-event{padding:16px 20px}
13
+ .top{display:flex;align-items:center;gap:var(--s3);height:var(--top);padding:0 var(--s4);background:var(--card);border-bottom:1px solid var(--border);white-space:nowrap}
14
+ .top .brand{display:inline-flex;align-items:center;flex:none}
15
+ .top .l-mark{display:none}
16
+ .top-site{display:flex;align-items:baseline;gap:var(--s2);min-width:0;overflow:hidden}
17
+ .top-site b{font-weight:600;overflow:hidden;text-overflow:ellipsis}
18
+ .top-end{margin-left:auto;display:flex;align-items:center;gap:var(--s2)}
19
+ .byline{display:inline-flex;align-items:center;gap:var(--s2);font-size:12px;color:var(--muted)}
20
+ .logo{display:inline-flex}
21
+ .logo .l-dark{display:none}
22
+ html[data-theme="dark"] .logo .l-light{display:none}
23
+ html[data-theme="dark"] .logo .l-dark{display:inline}
24
+ @media (prefers-color-scheme:dark){html:not([data-theme="light"]) .logo .l-light{display:none}html:not([data-theme="light"]) .logo .l-dark{display:inline}}
25
+ .eyebrow{font:600 11px/1.4 var(--font-sans);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
26
+ .plan{max-width:1100px;margin:0 auto;padding:var(--s5) var(--s6) var(--s7)}
27
+ .plan-title{display:grid;gap:var(--s2);margin-bottom:var(--s5)}
28
+ .plan-title .chips{margin-top:var(--s1)}
29
+ .plan .card+.card{margin-top:var(--s4)}
30
+ .pl-card-h{padding:var(--s3) var(--s4);border-bottom:1px solid var(--border)}
31
+ .pl-card-d{margin-top:2px;font-size:12.5px;color:var(--muted)}
32
+ .pl-empty{padding:var(--s3) var(--s4)}
33
+ .pl-event{padding:var(--s4)}
9
34
  .pl-event+.pl-event{border-top:1px solid var(--border)}
10
- .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
35
+ .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2)}
11
36
  .pl-head code{font-size:14px}
12
37
  .pl-desc{margin:6px 0 10px}
13
38
  .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
14
39
  .pl-trigger b{font-weight:500;color:var(--fg)}
15
- .flag{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;background:var(--muted-bg);color:var(--muted)}
16
- .flag.on{background:var(--accent-bg);color:var(--accent-fg)}
17
- .flag.req{background:var(--warn-bg);color:var(--warn)}
18
- table.dt td,table.dt th{vertical-align:top}
40
+ .chip .plat{display:inline-flex;align-items:center;gap:5px}
41
+ .chip.on{--k:var(--accent);--k-fg:var(--accent-fg);background:var(--accent-bg)}
42
+ .chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg);background:var(--sev-warning-bg)}
43
+ .pl-card-body{padding:var(--s4)}
44
+ .tbl td,.tbl th{vertical-align:top}
45
+ /* a narrow table scrolls inside its .tscroll instead of breaking names mid-word */
46
+ .plan .tbl{min-width:480px}
47
+ .plan .tbl td:first-child code{white-space:nowrap}
48
+ .footer{display:flex;align-items:center;gap:var(--s4);padding:var(--s4);border-top:1px solid var(--border);background:var(--card)}
49
+ .footer a{color:var(--fg)}
50
+ .logo-link{display:inline-flex}
51
+ .f-links{display:flex;flex-wrap:wrap;gap:var(--s2) var(--s4)}
52
+ .footer .f-meta{margin-left:auto}
53
+ @media (max-width:719.98px){
54
+ :root{--top:44px}
55
+ .top{gap:var(--s2);padding:0 var(--s3)}
56
+ .top .l-lockup,.top-site .eyebrow,.byline{display:none}
57
+ .top .l-mark{display:block}
58
+ .plan{padding:var(--s4) var(--s4) var(--s6)}
59
+ .footer{flex-wrap:wrap}
60
+ }
61
+ @media print{.top{display:none}.plan{padding:0}}
19
62
  `;
20
63
 
21
- const flag = (label, cls = "") => `<span class="flag ${cls}">${e(label)}</span>`;
64
+ const flag = (label, cls = "") => `<span class="chip ${cls}">${e(label)}</span>`;
22
65
 
23
66
  function paramsTable(event) {
24
67
  const params = Object.entries(event.parameters ?? {});
@@ -27,13 +70,14 @@ function paramsTable(event) {
27
70
  const rows = params
28
71
  .map(([name, p]) => `<tr><td><code>${e(name)}</code>${p?.custom ? ` ${flag("custom")}` : ""}</td><td>${required.has(name) ? flag("required", "req") : `<span class="muted">optional</span>`}</td><td class="mono">${text(p?.example)}</td><td>${text(p?.description)}</td></tr>`)
29
72
  .join("");
30
- return `<div class="tscroll"><table class="dt"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
73
+ return `<div class="tscroll"><table class="tbl"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
31
74
  }
32
75
 
33
76
  function eventCard(event) {
34
77
  const trigger = event.trigger ?? {};
78
+ const platform = event.platform ?? "ga4";
35
79
  return `<div class="pl-event">
36
- <div class="pl-head"><span class="ptag">${e(platformLabel(event.platform ?? "ga4"))}</span><code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
80
+ <div class="pl-head">${chip("plat", platform, plat(platform, 13, platformLabel(platform)))}<code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
37
81
  <p class="pl-desc">${text(event.description)}</p>
38
82
  <p class="pl-trigger"><b>Trigger</b> · page: ${text(trigger.page)} · element: ${text(trigger.element)}</p>
39
83
  ${paramsTable(event)}
@@ -42,12 +86,12 @@ ${paramsTable(event)}
42
86
 
43
87
  function userPropertiesCard(props) {
44
88
  const body = props.length
45
- ? `<div class="tscroll"><table class="dt"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
89
+ ? `<div class="pl-card-body"><div class="tscroll"><table class="tbl"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
46
90
  .map((p) => (typeof p === "string" ? { name: p } : p))
47
91
  .map((p) => `<tr><td><code>${e(p.name)}</code></td><td class="mono">${text(p.example)}</td><td>${text(p.description)}</td></tr>`)
48
- .join("")}</tbody></table></div>`
49
- : `<p class="muted xs">No user properties.</p>`;
50
- return `<section class="card"><div class="card-h"><div class="card-t">User properties</div></div><div class="card-c">${body}</div></section>`;
92
+ .join("")}</tbody></table></div></div>`
93
+ : `<p class="pl-empty muted xs">No user properties.</p>`;
94
+ return `<section class="card"><div class="pl-card-h"><h2>User properties</h2></div>${body}</section>`;
51
95
  }
52
96
 
53
97
  /** A single self-contained, readable page for a measurement plan. Respects branding false like the report. */
@@ -58,19 +102,21 @@ export function renderPlanHtml(plan, { id = "", generatedAt = "", branding = tru
58
102
  const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai-tag-debugger ${VERSION}`].filter(Boolean).join(" · ");
59
103
  const consent = plan.consent_mode ? flag("Consent mode on", "on") : flag("Consent mode off");
60
104
  const footer = branding
61
- ? `<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><div><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><div class="f-meta">v${VERSION}</div></div>`
62
- : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">v${VERSION}</div></div>`;
105
+ ? `<a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 40, "logo-s")}</a><div class="f-links xs"><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><p class="f-meta xs muted">v${VERSION}</p>`
106
+ : `<p class="xs">AI Tag Debugger</p><p class="f-meta xs muted">v${VERSION}</p>`;
107
+ const body = `<header class="top"><span class="brand">${logo("atd-lockup", "AI Tag Debugger", 22, "l-lockup")}<img class="l-mark" src="${LOGOS["atd-icon"]}" alt="AI Tag Debugger" height="24"></span><span class="top-site"><span class="eyebrow">Measurement plan</span>${plan.site ? `<b>${e(plan.site)}</b>` : ""}</span><div class="top-end">${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 16, "logo-h")}</a>` : ""}</div></header>
108
+ <main class="plan">
109
+ <div class="plan-title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="muted xs">${e(meta)}</div><div class="chips">${consent}</div></div>
110
+ <section class="card"><div class="pl-card-h"><h2>Events</h2><p class="pl-card-d">What should be tracked, where it fires and which parameters it carries.</p></div>${events.length ? events.map(eventCard).join("") : `<p class="pl-empty muted xs">No events.</p>`}</section>
111
+ ${userPropertiesCard(plan.user_properties ?? [])}
112
+ </main>
113
+ <footer class="footer">${footer}</footer>`;
63
114
  return `<!doctype html>
64
115
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
116
+ <meta name="color-scheme" content="light dark">
65
117
  <title>${e(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
66
- <style>${CSS}${PLAN_CSS}</style></head><body>
67
- <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
68
- <header class="wrap title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="meta">${e(meta)}</div></header>
69
- <main class="wrap" style="padding-top:24px;padding-bottom:64px"><div class="stack">
70
- <div>${consent}</div>
71
- <section class="card"><div class="card-h"><div class="card-t">Events</div><div class="card-d">What should be tracked, where it fires and which parameters it carries.</div></div><div class="card-c tight">${events.length ? events.map(eventCard).join("") : `<p class="muted xs" style="padding:12px">No events.</p>`}</div></section>
72
- ${userPropertiesCard(plan.user_properties ?? [])}
73
- </div></main>
74
- <footer class="footer">${footer}</footer>
118
+ <style>${TOKENS}\n${COMPONENTS}\n${PRODUCT}\n${PLAN_CSS}</style></head><body>
119
+ ${sprite(body)}
120
+ ${body}
75
121
  </body></html>`;
76
122
  }
@@ -0,0 +1,123 @@
1
+ // What the Markdown and the HTML renderers share: the version, escaping, the grouped findings, platform names and
2
+ // order, the plan-check lines and the verdict sentence. src/report.js and src/html/*.js both import from here and
3
+ // this module imports neither, so the two renderers never import each other.
4
+ import { readFileSync } from "node:fs";
5
+ import { groupFindings } from "./checks.js";
6
+ import { messages } from "./i18n.js";
7
+ import { PROVIDERS } from "./parsers.js";
8
+
9
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+
11
+ const SEVERITIES = ["broken", "risk", "warning", "info"];
12
+
13
+ /** The summary as { headline, points, also_check }. Older plain-text summaries become a headline-only paragraph. */
14
+ function summaryParts(summary) {
15
+ if (!summary) return null;
16
+ if (typeof summary === "string") return { text: summary, points: [], also_check: [] };
17
+ return {
18
+ headline: String(summary.headline ?? ""),
19
+ 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) })),
20
+ also_check: (Array.isArray(summary.also_check) ? summary.also_check : []).map(String).filter(Boolean),
21
+ };
22
+ }
23
+
24
+ /** Fix steps of an explanation; older explanations had one "fix" string. */
25
+ const fixSteps = (e) => (Array.isArray(e?.fix_steps) && e.fix_steps.length ? e.fix_steps.map(String) : e?.fix ? [String(e.fix)] : []);
26
+
27
+ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
28
+
29
+ function attachExplanations(findings, explained) {
30
+ const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
31
+ // A finding a baseline accepted carries `accepted: true`; the grouped card is accepted when every place it covers is.
32
+ const key = (f) => [f.severity, f.check, f.platform, f.event, f.detail].join("|");
33
+ const accepted = new Map();
34
+ for (const f of findings) if (f.severity !== "ok") accepted.set(key(f), (accepted.get(key(f)) ?? true) && f.accepted === true);
35
+ return groupFindings(findings).map((f, i) => ({ ...f, explanation: byIndex.get(i), ...(accepted.get(key(f)) ? { accepted: true } : {}) }));
36
+ }
37
+
38
+ const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2 };
39
+
40
+ const seconds = (run, t) => `+${((t - run.startedAt) / 1000).toFixed(1)}s`;
41
+ const isConsentEntry = (value) => Array.isArray(value) && value[0] === "consent";
42
+
43
+ const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
44
+
45
+ const isProblem = (f) => f.severity in SEVERITY_RANK;
46
+ const placeKey = (w) => `${w.journey}|${w.viewport}`;
47
+ const uniqPlaces = (where) => [...new Map(where.map((w) => [placeKey(w), w])).values()];
48
+ /** The problem the verdict is about: the one that costs the score most, else the most severe and widespread. */
49
+ function mdLead(rows, metrics) {
50
+ const top = metrics.score?.deductions?.[0];
51
+ const problems = rows.filter(isProblem);
52
+ return (top && problems.find((f) => f.check === top.check && f.severity === top.severity && (f.platform ?? null) === top.platform && (f.event ?? null) === top.event)) ?? problems[0] ?? null;
53
+ }
54
+
55
+ /** " on mobile" when the lead problem is on one viewport of several, " on checkout" for one journey of several, " on every journey" when it is everywhere. */
56
+ function mdScope(m, runs, lead) {
57
+ if (!lead) return "";
58
+ const places = uniqPlaces(lead.where);
59
+ if (runs.length > 1 && places.length === runs.length) return m.ui.md.everywhere;
60
+ const viewports = new Set(places.map((w) => w.viewport));
61
+ const journeys = new Set(places.map((w) => w.journey));
62
+ if (viewports.size === 1 && new Set(runs.map((r) => r.viewport)).size > 1) return m.ui.md.onViewport(String([...viewports][0]));
63
+ if (journeys.size === 1 && new Set(runs.map((r) => r.journeyId)).size > 1) return m.ui.md.onJourney(String([...journeys][0]));
64
+ return "";
65
+ }
66
+
67
+ /**
68
+ * The verdict sentence for the title, with no model, keyed the same way as metrics.verdict.headline. It names
69
+ * the lead problem and where it is ("purchase tracking is broken on mobile"): the counts sit next to the
70
+ * headline on every surface, so the count-based sentences in `verdict.headline` are left to the HTML verdict block.
71
+ */
72
+ function mdHeadline(m, runs, rows, metrics) {
73
+ const v = metrics.verdict;
74
+ const lead = mdLead(rows, metrics);
75
+ const what = lead ? (lead.event ? m.ui.md.tracking(String(lead.event)) : m.ui.md.setup(String(lead.check).replace(/_/g, " "))) : "";
76
+ const scope = mdScope(m, runs, lead);
77
+ const incomplete = runs.find((r) => !r.completed);
78
+ const ctx = { key: v.headline, what, scope, journey: incomplete ? String(incomplete.journeyId) : "", events: metrics.coverage?.checkable ?? 0, journeys: runs.length, counts: v.counts, lead };
79
+ const fallback = m.ui.md.headline[v.headline] ?? m.ui.md.headline.attention_nothing_tested;
80
+ if (v.headline === "fail_incomplete") return fallback(ctx.journey);
81
+ if (v.headline === "pass") return fallback(ctx.events, ctx.journeys);
82
+ if (v.headline === "attention_no_hits" || v.headline === "attention_nothing_tested") return fallback();
83
+ return fallback(what, scope);
84
+ }
85
+
86
+ /**
87
+ * The verdict as the CLI, the alert subject and the Markdown title say it: { status, statusText, headline,
88
+ * score }, in the report's language and with no Markdown in it. `metrics` is computeMetrics' result.
89
+ */
90
+ export function verdictHeadline({ findings = [], runs = [], metrics, lang = "en" }) {
91
+ const m = messages(lang);
92
+ const rows = groupFindings(findings).sort((a, b) => SEVERITY_ORDER_MD[a.severity] - SEVERITY_ORDER_MD[b.severity] || b.where.length - a.where.length);
93
+ return { status: metrics.verdict.status, statusText: m.verdict.status[metrics.verdict.status], headline: mdHeadline(m, runs, rows, metrics), score: metrics.score?.score ?? null };
94
+ }
95
+
96
+ const SEVERITY_ORDER_MD = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
97
+
98
+ const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
99
+ export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
100
+ const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
101
+
102
+ // Platform keys go into class names, ids and attribute selectors.
103
+ const safeKey = (k) => String(k ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || "other";
104
+ const sortPlatforms = (keys) => [...keys].sort((a, b) => platformRank(a) - platformRank(b) || platformLabel(a).localeCompare(platformLabel(b)));
105
+ const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
106
+ const coverageLines = (m, pc) => [
107
+ m.ui.coverageLine(pc.coverage),
108
+ ...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
109
+ ];
110
+ const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
111
+ const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
112
+
113
+ const planChangeItems = (pc) => [
114
+ ...pc.added.map((c) => `+ ${changeLabel(c)}`),
115
+ ...pc.removed.map((c) => `− ${changeLabel(c)}`),
116
+ ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
117
+ ];
118
+
119
+ export {
120
+ SEVERITIES, summaryParts, fixSteps, attachExplanations, SEVERITY_RANK, SEVERITY_ORDER_MD, seconds, isConsentEntry, routeList,
121
+ isProblem, placeKey, uniqPlaces, mdHeadline, PLATFORM_ORDER, platformRank, safeKey, sortPlatforms, changeLabel,
122
+ coverageLines, pcSort, BUCKET_ORDER, planChangeItems,
123
+ };