@analitikisler/ai-tag-debugger 0.14.0 → 0.16.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/report.js CHANGED
@@ -1,10 +1,13 @@
1
+ import { consentTable } from "./consent.js";
1
2
  import { OWNERS, ownerOf, verifyLinks } from "./verify.js";
2
3
  import { createProvider } from "./providers.js";
3
4
  import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
4
5
  import { messages } from "./i18n.js";
5
6
  import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
6
7
  import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
8
+ import { buildInventory } from "./inventory.js";
7
9
  import { LOGOS } from "./brand.js";
10
+ import { brandCss } from "./whitelabel.js";
8
11
  import { readFileSync } from "node:fs";
9
12
 
10
13
  export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -152,19 +155,27 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
152
155
 
153
156
  const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
154
157
 
155
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
158
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null }) {
156
159
  const m = messages(lang);
157
160
  const rows = attachExplanations(findings, explained);
158
161
  const counts = summarize(rows);
159
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), ...(changes?.routes?.length ? [mdText(m.ui.routeChanged(routeList(changes))), ""] : []), `**${m.ui.counts(counts)}**`, ""];
162
+ const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), ...(planChanges ? [mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : [])] : []), ...(changes?.routes?.length ? [mdText(m.ui.routeChanged(routeList(changes))), ""] : []), `**${m.ui.counts(counts)}**`, ""];
160
163
  if (changes) lines.push(...changesMd(m, changes));
161
164
  if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
165
+ lines.push(...inventoryMd(m, runs));
162
166
  const sum = summaryParts(explained?.summary);
163
167
  if (sum) {
164
168
  lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
165
169
  if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
166
170
  if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
167
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
+ }
168
179
  lines.push(`## ${m.ui.findings}`, "");
169
180
  for (const f of rows.filter((r) => r.severity !== "ok")) {
170
181
  lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
@@ -173,6 +184,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
173
184
  const vl = verifyLinks(f);
174
185
  if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
175
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}`);
176
188
  if (f.explanation) {
177
189
  if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
178
190
  lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
@@ -196,7 +208,9 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
196
208
  }
197
209
  lines.push("");
198
210
  }
199
- if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
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)_`, "");
200
214
  return lines.join("\n");
201
215
  }
202
216
 
@@ -456,6 +470,7 @@ const ICON = {
456
470
  info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
457
471
  impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
458
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"/>',
459
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"/>',
460
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"/>',
461
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"/>',
@@ -558,10 +573,20 @@ function journeyPanel(m, lang, findings, run, ri, flagged) {
558
573
  ${consentCard(m, run)}${body}</article>`;
559
574
  }
560
575
 
561
- function journeysTab(m, lang, findings, runs, flagged) {
562
- if (!runs.length) return `<p class="empty">${m.ui.noSteps}</p>`;
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>`;
563
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("");
564
- return `<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
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")}`;
565
590
  }
566
591
 
567
592
  /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
@@ -573,6 +598,18 @@ function evidenceHtml(evidence) {
573
598
  return `<pre>${escapeHtml(evidence)}</pre>`;
574
599
  }
575
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>`;
611
+ }
612
+
576
613
  function verifyHtml(m, f) {
577
614
  const links = verifyLinks(f);
578
615
  if (!links.length) return "";
@@ -601,7 +638,7 @@ function findingCard(m, runs, f, open, aiRan) {
601
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>
602
639
  ${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
603
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>` : ""}
604
- <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}` : ""}</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>
605
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>`;
606
643
  }
607
644
 
@@ -704,6 +741,12 @@ const coverageLines = (m, pc) => [
704
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));
705
742
  const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
706
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
+
707
750
  /** "Changed since last run": one headline, then what was fixed and what is new. */
708
751
  const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
709
752
  const changeItems = (m, ch) => [
@@ -761,6 +804,30 @@ function coverageCard(m, plan, runs) {
761
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>`;
762
805
  }
763
806
 
807
+ const PAGES_SHOWN = 5;
808
+
809
+ function inventoryMd(m, runs) {
810
+ const inv = buildInventory(runs);
811
+ const lines = [`## ${m.ui.inventoryTitle}`, ""];
812
+ if (!inv.vendors.length) return [...lines, m.ui.noInventory, ""];
813
+ lines.push(`| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|");
814
+ for (const v of inv.vendors) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", "))} |`);
815
+ if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
816
+ lines.push("");
817
+ return lines;
818
+ }
819
+
820
+ function inventoryCard(m, runs) {
821
+ const inv = buildInventory(runs);
822
+ const head = `<div class="card-h"><div class="card-t">${m.ui.inventoryTitle}</div><div class="card-d">${m.ui.inventoryLead}</div></div>`;
823
+ if (!inv.vendors.length) return `<div class="card" id="inventory">${head}<div class="card-c"><p class="muted">${m.ui.noInventory}</p></div></div>`;
824
+ const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
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
+
764
831
  function providersCard(m, runs) {
765
832
  const provs = checkedProviders(runs);
766
833
  const skipped = skippedTotals(runs);
@@ -839,6 +906,7 @@ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px
839
906
  .topbar{background:var(--bg);border-bottom:1px solid var(--border)}
840
907
  .topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
841
908
  .product-logo img{display:block;height:26px;width:auto}
909
+ .brand-name{font:600 16px var(--font-sans);color:var(--fg)}
842
910
  .byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
843
911
  .logo-h img{display:block;height:18px;width:auto}
844
912
  .title{margin-top:20px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}
@@ -858,6 +926,7 @@ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px
858
926
  .jconsent{margin-bottom:16px}
859
927
  .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
860
928
  .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
929
+ .title .plan-changes ul{margin:4px 0 0;padding-left:18px}
861
930
  .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
862
931
  .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
863
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}
@@ -1075,36 +1144,43 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
1075
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}}
1076
1145
  `;
1077
1146
 
1078
- export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
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 }) {
1079
1148
  const m = messages(lang);
1080
1149
  const rows = attachExplanations(findings, explained);
1081
1150
  const counts = summarize(rows);
1082
1151
  const problems = rows.filter((f) => f.severity !== "ok").length;
1083
1152
  const flagged = flaggedSteps(runs, rows);
1084
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>` : "";
1085
1161
  const footer = branding
1086
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>
1087
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>
1088
1164
  <div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
1089
- : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><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>`;
1090
1166
 
1091
1167
  return `<!doctype html>
1092
1168
  <html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1093
- <title>${escapeHtml(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
1169
+ <title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
1094
1170
  <style>${CSS}${filterCss(runs)}
1095
- ${navCss(runs)}</style></head><body>
1096
- <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
1171
+ ${navCss(runs)}${brandCss(brand?.color)}</style></head><body>
1172
+ <div class="topbar"><div class="wrap"><span class="product-logo">${productLogo}</span>${atdByline}${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
1097
1173
  <header class="wrap title">
1098
1174
  <h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
1099
- <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>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
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>` : ""}
1100
1176
  </header>
1101
1177
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1102
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>
1103
1179
  <main class="wrap">
1104
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>
1105
1181
  <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows, Boolean(explained))}</section>
1106
- <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
1107
- <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></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>
1108
1184
  </main>
1109
1185
  <footer class="footer">${footer}</footer>
1110
1186
  </body></html>`;
package/src/sheet.js CHANGED
@@ -84,7 +84,8 @@ export function rowsToPlan({ events: eventRows = [], user_properties: upRows = n
84
84
  if (!r.event) throw fail(`${at}: "event" is empty. Give the event name, e.g. purchase.`);
85
85
  const platform = (r.platform || "ga4").toLowerCase();
86
86
  if (!PLATFORMS[platform]) throw fail(`${at}: "platform" must be one of ${Object.keys(PLATFORMS).join(", ")} (found "${r.platform}").`);
87
- const required = (r.required ?? "").toLowerCase();
87
+ // Google Sheets checkboxes export as TRUE/FALSE.
88
+ const required = { true: "yes", false: "no" }[(r.required ?? "").toLowerCase()] ?? (r.required ?? "").toLowerCase();
88
89
  if (!["yes", "no", ""].includes(required)) throw fail(`${at}: "required" must be yes or no (found "${r.required}").`);
89
90
  if (required && !r.parameter) throw fail(`${at}: "required" is set but "parameter" is empty. Name the parameter, or leave "required" empty on the event's own row.`);
90
91
  const custom = (r.custom ?? "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
@@ -290,15 +291,17 @@ export async function readPlanSheet(file, { site } = {}) {
290
291
  if (!events) throw new Error(`${path.basename(file)} has no tab named "events" (found ${Object.keys(sheets).map((k) => `"${k}"`).join(", ") || "none"}). Rename the tab with the events to "events".`);
291
292
  tabs = { events, user_properties: find("user_properties") ?? null };
292
293
  } else if (ext === ".csv") {
293
- const upFile = path.join(path.dirname(file), "user_properties.csv");
294
- const isUp = path.basename(file).toLowerCase() === "user_properties.csv";
295
- if (isUp) throw new Error(`Import the events file (events.csv); ${path.basename(file)} beside it is read too.`);
294
+ // Google Sheets downloads each tab as "<Document> - <tab>.csv"; the sibling file keeps that prefix.
295
+ const base = path.basename(file);
296
+ const prefix = base.match(/^(.*?)events\.csv$/i)?.[1] ?? "";
297
+ const upFile = path.join(path.dirname(file), `${prefix}user_properties.csv`);
298
+ if (/user_properties\.csv$/i.test(base)) throw new Error(`Import the events file (${base.replace(/user_properties\.csv$/i, "events.csv")}); ${base} beside it is read too.`);
296
299
  tabs = { events: parseCsv(await readFile(file, "utf8")), user_properties: (await exists(upFile)) ? parseCsv(await readFile(upFile, "utf8")) : null };
297
300
  } else {
298
301
  throw new Error(`Import a .xlsx or .csv file (got ${path.basename(file)}).`);
299
302
  }
300
303
  const result = rowsToPlan(tabs, { site });
301
- if (!tabs.user_properties) result.warnings.push(ext === ".csv" ? "No user_properties.csv beside the file, so the plan has no user properties." : 'No "user_properties" tab, so the plan has no user properties.');
304
+ if (!tabs.user_properties) result.warnings.push(ext === ".csv" ? `No ${path.basename(upFile)} beside the file, so the plan has no user properties.` : 'No "user_properties" tab, so the plan has no user properties.');
302
305
  return result;
303
306
  }
304
307
 
@@ -0,0 +1,76 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { chromium } from "playwright";
4
+ import { browserEnv, installBrowser } from "./browser.js";
5
+
6
+ const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml" };
7
+ const MAX_LOGO_BYTES = 512 * 1024;
8
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
9
+
10
+ /**
11
+ * Reads the "report" setting: { name?, logo?, color?, pdf? }. A logo file is embedded as a data URI, so the
12
+ * report stays one file. Relative paths are read from the config file's folder.
13
+ * @returns {Promise<{ name: string | null, logo: string | null, color: string | null, pdf: boolean } | null>} null when nothing is set
14
+ */
15
+ export async function loadReportBrand(report, baseDir = process.cwd()) {
16
+ if (report === undefined || report === null) return null;
17
+ if (typeof report !== "object" || Array.isArray(report)) throw new Error('"report" must be an object, e.g. { "name": "Acme", "logo": "logo.png", "color": "#0a7d5a", "pdf": true }.');
18
+ const { name = null, logo = null, color = null, pdf = false } = report;
19
+ if (name !== null && (typeof name !== "string" || name.length > 80)) throw new Error('"report.name" must be a short text.');
20
+ if (color !== null && (typeof color !== "string" || !HEX.test(color))) throw new Error('"report.color" must be a hex color like "#0a7d5a".');
21
+ if (typeof pdf !== "boolean") throw new Error('"report.pdf" must be true or false.');
22
+ let dataUri = null;
23
+ if (logo !== null) {
24
+ if (typeof logo !== "string") throw new Error('"report.logo" must be a file path.');
25
+ const full = path.resolve(baseDir, logo);
26
+ const type = LOGO_TYPES[path.extname(full).toLowerCase()];
27
+ if (!type) throw new Error(`"report.logo" must be a ${Object.keys(LOGO_TYPES).join(", ")} file.`);
28
+ let bytes;
29
+ try { bytes = await readFile(full); } catch (err) { throw new Error(`Could not read the report logo ${full}: ${err.message}`); }
30
+ if (bytes.length > MAX_LOGO_BYTES) throw new Error(`"report.logo" is ${Math.round(bytes.length / 1024)} KB; keep it under ${MAX_LOGO_BYTES / 1024} KB.`);
31
+ dataUri = `data:${type};base64,${bytes.toString("base64")}`;
32
+ }
33
+ if (!name && !dataUri && !color && !pdf) return null;
34
+ return { name: name || null, logo: dataUri, color: color ? normalizeHex(color) : null, pdf };
35
+ }
36
+
37
+ function normalizeHex(hex) {
38
+ const h = hex.slice(1);
39
+ return `#${(h.length === 3 ? [...h].map((c) => c + c).join("") : h).toLowerCase()}`;
40
+ }
41
+
42
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
43
+ const toHex = (c) => `#${c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
44
+ const mix = (hex, other, amount) => toHex(rgb(hex).map((v, i) => v + (other[i] - v) * amount));
45
+
46
+ /** CSS that recolors the report's accent. The light theme gets a darker text color and a pale tint; the dark theme a lighter one. */
47
+ export function brandCss(color) {
48
+ if (!color) return "";
49
+ const black = [0, 0, 0], white = [255, 255, 255];
50
+ const light = `--accent:${color};--accent-fg:${mix(color, black, 0.2)};--accent-bg:${mix(color, white, 0.9)}`;
51
+ const dark = `--accent:${mix(color, white, 0.2)};--accent-fg:${mix(color, white, 0.5)};--accent-bg:${mix(color, [10, 14, 26], 0.75)}`;
52
+ return `:root{${light}}@media (prefers-color-scheme:dark){:root{${dark}}}`;
53
+ }
54
+
55
+ /** Prints an HTML report to a PDF with Chromium. The print styles open every tab, so the PDF holds the whole report. */
56
+ export async function writePdf(htmlPath, pdfPath) {
57
+ const launch = { headless: true, env: browserEnv() };
58
+ let browser;
59
+ try {
60
+ browser = await chromium.launch(launch);
61
+ } catch (err) {
62
+ if (!/Executable doesn't exist|playwright install/i.test(String(err?.message))) throw err;
63
+ await installBrowser();
64
+ browser = await chromium.launch(launch);
65
+ }
66
+ try {
67
+ // JavaScript is off: the report needs none, and the page is only ever our own file.
68
+ const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: "light" });
69
+ const page = await context.newPage();
70
+ await page.goto(new URL(`file://${path.resolve(htmlPath)}`).href);
71
+ await page.emulateMedia({ media: "print", colorScheme: "light" });
72
+ await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "14mm", bottom: "14mm", left: "10mm", right: "10mm" } });
73
+ } finally {
74
+ await browser.close();
75
+ }
76
+ }