@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/CHANGELOG.md +15 -0
- package/README.md +32 -4
- package/README.tr.md +32 -4
- package/package.json +1 -1
- package/src/agent.js +6 -2
- package/src/audit.js +28 -10
- package/src/browser.js +11 -1
- package/src/campaign.js +28 -0
- package/src/changes.js +38 -1
- package/src/checks.js +82 -2
- package/src/cli.js +22 -0
- package/src/consent.js +154 -0
- package/src/gtm.js +92 -0
- package/src/i18n.js +60 -0
- package/src/inventory.js +69 -0
- package/src/learn.js +34 -0
- package/src/redact.js +1 -1
- package/src/report.js +91 -15
- package/src/sheet.js +8 -5
- package/src/whitelabel.js +76 -0
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
|
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"
|
|
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">${
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
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" ?
|
|
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
|
+
}
|