@analitikisler/ai-tag-debugger 0.13.0 → 0.15.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/notify.js ADDED
@@ -0,0 +1,108 @@
1
+ // Alerts after a saved run: the "since last run" diff, sent to Slack and/or by email.
2
+ // Set in the config:
3
+ // "notify": { "slack": "https://hooks.slack.com/services/…", "email": "team@example.com" }
4
+ // "slack": true reads the webhook from SLACK_WEBHOOK_URL. Email goes through the SMTP server in
5
+ // SMTP_URL (smtps://user:pass@host:465 or smtp://host:25), from SMTP_FROM (default: the "to" address).
6
+ // Nothing is sent when there is no earlier run or nothing changed.
7
+ import { connect as tlsConnect } from "node:tls";
8
+ import { connect as netConnect } from "node:net";
9
+ import { changesLines } from "./changes.js";
10
+ import { messages } from "./i18n.js";
11
+
12
+ /** True when there is an earlier run and something was fixed, broke or took a new route since. */
13
+ export const hasChanges = (ch) => Boolean(ch?.previous && (ch.fixed.length || ch.new.length || ch.routes?.length));
14
+
15
+ const list = (v) => (Array.isArray(v) ? v : v ? [v] : []);
16
+
17
+ /** The alert's subject and plain-text body. changes carries the run id as `run` (see sinceLastRun). */
18
+ export function alertMessage({ site, changes, lang = "en", reportPath = null }) {
19
+ const subject = `${site}: ${messages(lang).ui.changesLine(changes)}`;
20
+ const body = [`${site}`, ...changesLines(changes, lang), ...(reportPath ? ["", reportPath] : [])].join("\n");
21
+ return { subject, body };
22
+ }
23
+
24
+ /**
25
+ * Sends the diff to every channel in `notify`. Never throws: returns { sent: [...], errors: [...] }.
26
+ * @param {{ notify: any, site: string, changes: any, lang?: string, reportPath?: string,
27
+ * env?: Record<string, string | undefined>, fetchImpl?: typeof fetch, smtp?: (url: string, mail: any) => Promise<void> }} opts
28
+ */
29
+ export async function sendAlerts({ notify, site, changes, lang = "en", reportPath = null, env = process.env, fetchImpl = fetch, smtp = sendSmtp }) {
30
+ const sent = [];
31
+ const errors = [];
32
+ if (!notify || !hasChanges(changes)) return { sent, errors };
33
+ const { subject, body } = alertMessage({ site, changes, lang, reportPath });
34
+ const slack = notify.slack === true ? env.SLACK_WEBHOOK_URL : notify.slack;
35
+ if (slack) {
36
+ try {
37
+ const res = await fetchImpl(slack, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: body }) });
38
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
39
+ sent.push("slack");
40
+ } catch (err) {
41
+ errors.push(`Slack: ${err.message}`);
42
+ }
43
+ } else if (notify.slack === true) errors.push("Slack: SLACK_WEBHOOK_URL is not set");
44
+ const to = list(notify.email);
45
+ if (to.length) {
46
+ try {
47
+ if (!env.SMTP_URL) throw new Error("SMTP_URL is not set");
48
+ await smtp(env.SMTP_URL, { from: env.SMTP_FROM || to[0], to, subject, text: body });
49
+ sent.push("email");
50
+ } catch (err) {
51
+ errors.push(`Email: ${err.message}`);
52
+ }
53
+ }
54
+ return { sent, errors };
55
+ }
56
+
57
+ /** A minimal SMTP client: smtps:// (TLS from the start) or smtp:// (plain), AUTH LOGIN when the URL has a user. */
58
+ export function sendSmtp(url, { from, to, subject, text }) {
59
+ const u = new URL(url);
60
+ const secure = u.protocol === "smtps:";
61
+ const port = Number(u.port || (secure ? 465 : 25));
62
+ const socket = secure ? tlsConnect({ host: u.hostname, port, servername: u.hostname }) : netConnect({ host: u.hostname, port });
63
+ const b64 = (s) => Buffer.from(s).toString("base64");
64
+ const clean = (s) => String(s).replace(/[\r\n]+/g, " ");
65
+ const data = [
66
+ `From: ${clean(from)}`,
67
+ `To: ${to.map(clean).join(", ")}`,
68
+ `Subject: =?UTF-8?B?${b64(clean(subject))}?=`,
69
+ "MIME-Version: 1.0",
70
+ "Content-Type: text/plain; charset=UTF-8",
71
+ "Content-Transfer-Encoding: base64",
72
+ "",
73
+ b64(text).replace(/.{76}/g, "$&\r\n"),
74
+ ".",
75
+ ].join("\r\n");
76
+ const steps = [
77
+ null, // greeting
78
+ `EHLO ${clean(u.hostname)}`,
79
+ ...(u.username ? ["AUTH LOGIN", b64(decodeURIComponent(u.username)), b64(decodeURIComponent(u.password))] : []),
80
+ `MAIL FROM:<${clean(from)}>`,
81
+ ...to.map((t) => `RCPT TO:<${clean(t)}>`),
82
+ "DATA",
83
+ data,
84
+ "QUIT",
85
+ ];
86
+ return new Promise((resolve, reject) => {
87
+ let buf = "";
88
+ let i = 0;
89
+ socket.setTimeout(20000, () => socket.destroy(new Error("SMTP timed out")));
90
+ socket.on("error", reject);
91
+ socket.on("data", (chunk) => {
92
+ buf += chunk;
93
+ // A reply is complete at a line "NNN text" (a dash after the code means more lines follow).
94
+ const lines = buf.split("\r\n");
95
+ const last = lines.findLastIndex((l) => /^\d{3} /.test(l));
96
+ if (last < 0) return;
97
+ const reply = lines[last];
98
+ buf = lines.slice(last + 1).join("\r\n");
99
+ if (!/^[23]/.test(reply)) return socket.destroy(new Error(`SMTP said: ${reply}`));
100
+ i += 1;
101
+ if (i < steps.length) socket.write(`${steps[i]}\r\n`);
102
+ else {
103
+ socket.end();
104
+ resolve();
105
+ }
106
+ });
107
+ });
108
+ }
package/src/providers.js CHANGED
@@ -8,11 +8,14 @@
8
8
  // Tools use one neutral shape: { name, description, schema } where schema is JSON Schema.
9
9
 
10
10
  export const PROVIDERS = {
11
- anthropic: { label: "Anthropic (Claude)", env: "ANTHROPIC_API_KEY", defaultModel: "claude-opus-5-5" },
12
- openai: { label: "OpenAI", env: "OPENAI_API_KEY", defaultModel: "gpt-5.5" },
13
- gemini: { label: "Google Gemini", env: "GEMINI_API_KEY", defaultModel: "gemini-pro-latest" },
11
+ // cheapModel is what `--model cheap` picks: a small, inexpensive model for routine CI runs.
12
+ anthropic: { label: "Anthropic (Claude)", env: "ANTHROPIC_API_KEY", defaultModel: "claude-opus-5-5", cheapModel: "claude-haiku-5-5" },
13
+ openai: { label: "OpenAI", env: "OPENAI_API_KEY", defaultModel: "gpt-5.5", cheapModel: "gpt-5.5-mini" },
14
+ gemini: { label: "Google Gemini", env: "GEMINI_API_KEY", defaultModel: "gemini-pro-latest", cheapModel: "gemini-flash-latest" },
14
15
  // Vercel AI Gateway: one key, any model as "<provider>/<model>". OpenAI-compatible API.
15
- vercel: { label: "Vercel AI Gateway", env: "AI_GATEWAY_API_KEY", defaultModel: "anthropic/claude-opus-5.5" },
16
+ vercel: { label: "Vercel AI Gateway", env: "AI_GATEWAY_API_KEY", defaultModel: "anthropic/claude-opus-5.5", cheapModel: "anthropic/claude-haiku-5.5" },
17
+ // Cloudflare Workers AI: open models on Cloudflare's network, billed per use. OpenAI-compatible API.
18
+ cloudflare: { label: "Cloudflare Workers AI", env: "CLOUDFLARE_API_TOKEN", defaultModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", cheapModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
16
19
  // Any API that speaks the OpenAI chat completions protocol. Needs a base URL and a model.
17
20
  "openai-compatible": { label: "OpenAI-compatible API", env: "OPENAI_COMPATIBLE_API_KEY", defaultModel: null },
18
21
  };
@@ -25,16 +28,17 @@ export function resolveProviderName(name, env = process.env) {
25
28
  }
26
29
  if (env.OPENAI_COMPATIBLE_BASE_URL) return "openai-compatible";
27
30
  if (env.AI_GATEWAY_API_KEY) return "vercel";
31
+ if (env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID) return "cloudflare";
28
32
  if (env.ANTHROPIC_API_KEY) return "anthropic";
29
33
  if (env.OPENAI_API_KEY) return "openai";
30
34
  if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) return "gemini";
31
35
  return "anthropic";
32
36
  }
33
37
 
34
- const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY or AI_GATEWAY_API_KEY for Vercel AI Gateway (or OPENAI_COMPATIBLE_BASE_URL for an OpenAI-compatible API), or pick one with --provider. To run without any AI model, replay saved steps with --no-ai.`;
38
+ const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, AI_GATEWAY_API_KEY for Vercel AI Gateway, or CLOUDFLARE_API_TOKEN with CLOUDFLARE_ACCOUNT_ID for Cloudflare Workers AI (or OPENAI_COMPATIBLE_BASE_URL for an OpenAI-compatible API), or pick one with --provider. To run without any AI model, replay saved steps with --no-ai.`;
35
39
 
36
40
  function hasApiKey(env) {
37
- return Boolean(env.OPENAI_COMPATIBLE_BASE_URL || env.AI_GATEWAY_API_KEY || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
41
+ return Boolean(env.OPENAI_COMPATIBLE_BASE_URL || env.AI_GATEWAY_API_KEY || (env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID) || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
38
42
  }
39
43
 
40
44
  /**
@@ -57,9 +61,9 @@ async function buildProvider(name, opts, env) {
57
61
  if (!opts.client && !opts.name && !opts.baseUrl && !hasApiKey(env)) throw Object.assign(new Error(NO_API_KEY), { code: "NO_API_KEY" });
58
62
  if (name === "openai-compatible") {
59
63
  const baseURL = opts.baseUrl ?? env.OPENAI_COMPATIBLE_BASE_URL;
60
- const model = opts.model ?? env.OPENAI_COMPATIBLE_MODEL;
64
+ const model = compatibleModel(opts.model, env);
61
65
  if (!baseURL) throw new Error("openai-compatible needs the API address: --base-url or OPENAI_COMPATIBLE_BASE_URL, e.g. https://api.deepseek.com");
62
- if (!model) throw new Error("openai-compatible needs a model id: --model or OPENAI_COMPATIBLE_MODEL.");
66
+ if (!model) throw new Error("openai-compatible needs a model id: --model or OPENAI_COMPATIBLE_MODEL (\"cheap\" has no meaning here, since the API's models are unknown).");
63
67
  // Local servers such as Ollama need no key, but the SDK wants a non-empty one.
64
68
  const apiKey = env.OPENAI_COMPATIBLE_API_KEY || "not-needed";
65
69
  const client = opts.client ?? new (await import("openai")).default({ baseURL, apiKey });
@@ -67,9 +71,15 @@ async function buildProvider(name, opts, env) {
67
71
  }
68
72
  if (name === "vercel") {
69
73
  const client = opts.client ?? new (await import("openai")).default({ baseURL: VERCEL_GATEWAY, apiKey: env.AI_GATEWAY_API_KEY });
70
- return { ...openaiProvider(client, opts.model ?? PROVIDERS.vercel.defaultModel, { compatible: true }), name: "vercel" };
74
+ return { ...openaiProvider(client, pickModel("vercel", opts.model), { compatible: true }), name: "vercel" };
71
75
  }
72
- const model = opts.model ?? PROVIDERS[name].defaultModel;
76
+ if (name === "cloudflare") {
77
+ const account = env.CLOUDFLARE_ACCOUNT_ID;
78
+ if (!opts.client && !(account && env.CLOUDFLARE_API_TOKEN)) throw new Error("Cloudflare Workers AI needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.");
79
+ const client = opts.client ?? new (await import("openai")).default({ baseURL: `${CLOUDFLARE_API}/${account}/ai/v1`, apiKey: env.CLOUDFLARE_API_TOKEN });
80
+ return { ...openaiProvider(client, pickModel("cloudflare", opts.model), { compatible: true }), name: "cloudflare" };
81
+ }
82
+ const model = pickModel(name, opts.model);
73
83
  const gateway = gatewayOptions(name, env);
74
84
  const provider = await builtInProvider(name, model, gateway, opts.client, env);
75
85
  // Shown in the "Using ..." line so a run says whether its calls went through the gateway.
@@ -87,9 +97,22 @@ async function builtInProvider(name, model, gateway, client, env) {
87
97
  }
88
98
 
89
99
  const VERCEL_GATEWAY = "https://ai-gateway.vercel.sh/v1";
100
+ const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4/accounts";
101
+
102
+ /** An openai-compatible API has no known cheap model, so `cheap` falls back to OPENAI_COMPATIBLE_MODEL. */
103
+ function compatibleModel(model, env) {
104
+ return (model === "cheap" ? undefined : model) ?? env.OPENAI_COMPATIBLE_MODEL;
105
+ }
106
+
107
+ /** The model to use: an explicit id, `cheap` for the provider's small CI model, else the default. */
108
+ export function pickModel(name, model) {
109
+ if (model === "cheap") return PROVIDERS[name]?.cheapModel ?? PROVIDERS[name]?.defaultModel ?? model;
110
+ return model ?? PROVIDERS[name].defaultModel;
111
+ }
90
112
 
91
113
  const KEY_PROVIDERS = [
92
114
  ["vercel", (env) => env.AI_GATEWAY_API_KEY],
115
+ ["cloudflare", (env) => env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID],
93
116
  ["anthropic", (env) => env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN],
94
117
  ["openai", (env) => env.OPENAI_API_KEY],
95
118
  ["gemini", (env) => env.GEMINI_API_KEY || env.GOOGLE_API_KEY],
@@ -101,9 +124,9 @@ const KEY_PROVIDERS = [
101
124
  * "AI: anthropic (claude-opus-5-5) via Cloudflare AI Gateway · also set: gemini (pick with --provider)".
102
125
  */
103
126
  export function aiStatus(opts = {}, env = process.env) {
104
- if (!opts.name && !KEY_PROVIDERS.some(([, has]) => has(env))) return "AI: no key set (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY or AI_GATEWAY_API_KEY)";
127
+ if (!opts.name && !KEY_PROVIDERS.some(([, has]) => has(env))) return "AI: no key set (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, AI_GATEWAY_API_KEY or CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID)";
105
128
  const name = resolveProviderName(opts.name, env);
106
- const model = opts.model ?? (name === "openai-compatible" ? env.OPENAI_COMPATIBLE_MODEL : PROVIDERS[name].defaultModel) ?? "no model set";
129
+ const model = (name === "openai-compatible" ? compatibleModel(opts.model, env) : pickModel(name, opts.model)) ?? "no model set";
107
130
  const via = name !== "vercel" && gatewayOptions(name, env).baseURL ? " via Cloudflare AI Gateway" : "";
108
131
  return `AI: ${name} (${model})${via}${alsoSet(name, env)}`;
109
132
  }
package/src/redact.js CHANGED
@@ -22,7 +22,7 @@ const SHARED_SECOND_LEVELS = new Set(["com", "co", "org", "net", "gov", "edu", "
22
22
  const SHARED_SUFFIXES = ["myshopify.com", "github.io", "vercel.app", "pages.dev", "netlify.app", "herokuapp.com", "web.app", "firebaseapp.com", "appspot.com", "wixsite.com", "blogspot.com", "azurewebsites.net", "cloudfront.net", "workers.dev"];
23
23
 
24
24
  /** "www.shop.example.com.tr" -> "example.com.tr", "a.shop.myshopify.com" -> "shop.myshopify.com": the part one owner controls. */
25
- function siteOf(host) {
25
+ export function siteOf(host) {
26
26
  const labels = String(host).toLowerCase().replace(/\.$/, "").split(".");
27
27
  const name = labels.join(".");
28
28
  const shared = SHARED_SUFFIXES.find((s) => name === s || name.endsWith(`.${s}`));
package/src/report.js CHANGED
@@ -1,9 +1,11 @@
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";
8
10
  import { readFileSync } from "node:fs";
9
11
 
@@ -152,19 +154,27 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
152
154
 
153
155
  const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
154
156
 
155
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
157
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
156
158
  const m = messages(lang);
157
159
  const rows = attachExplanations(findings, explained);
158
160
  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)}**`, ""];
161
+ 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
162
  if (changes) lines.push(...changesMd(m, changes));
161
163
  if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
164
+ lines.push(...inventoryMd(m, runs));
162
165
  const sum = summaryParts(explained?.summary);
163
166
  if (sum) {
164
167
  lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
165
168
  if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
166
169
  if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
167
170
  }
171
+ if (consentScenarios) {
172
+ const { cols, rows: tagRows } = consentTable(consentScenarios);
173
+ lines.push(`## ${m.ui.scenarios}`, "", mdText(m.ui.scenariosLead(consentScenarios.cmp)), "");
174
+ lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
175
+ 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(" | ")} |`);
176
+ lines.push("");
177
+ }
168
178
  lines.push(`## ${m.ui.findings}`, "");
169
179
  for (const f of rows.filter((r) => r.severity !== "ok")) {
170
180
  lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
@@ -558,10 +568,20 @@ function journeyPanel(m, lang, findings, run, ri, flagged) {
558
568
  ${consentCard(m, run)}${body}</article>`;
559
569
  }
560
570
 
561
- function journeysTab(m, lang, findings, runs, flagged) {
562
- if (!runs.length) return `<p class="empty">${m.ui.noSteps}</p>`;
571
+ /** Consent scenarios: one row per tag, one column per consent choice, hit counts after the choice. */
572
+ function scenariosCard(m, scenarios) {
573
+ if (!scenarios) return "";
574
+ const { cols, rows } = consentTable(scenarios);
575
+ const head = `<tr><th>${m.ui.scenarioTag}</th>${cols.map((c) => `<th>${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
576
+ 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("");
577
+ const notRun = cols.filter((c) => c !== "before" && !scenarios.states[c]?.via).map((c) => m.ui.scenarioCols[c]);
578
+ 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>`;
579
+ }
580
+
581
+ function journeysTab(m, lang, findings, runs, flagged, scenarios = null) {
582
+ if (!runs.length) return `${scenariosCard(m, scenarios)}<p class="empty">${m.ui.noSteps}</p>`;
563
583
  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")}`;
584
+ 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
585
  }
566
586
 
567
587
  /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
@@ -704,6 +724,12 @@ const coverageLines = (m, pc) => [
704
724
  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
725
  const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
706
726
 
727
+ const planChangeItems = (pc) => [
728
+ ...pc.added.map((c) => `+ ${changeLabel(c)}`),
729
+ ...pc.removed.map((c) => `− ${changeLabel(c)}`),
730
+ ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
731
+ ];
732
+
707
733
  /** "Changed since last run": one headline, then what was fixed and what is new. */
708
734
  const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
709
735
  const changeItems = (m, ch) => [
@@ -761,6 +787,30 @@ function coverageCard(m, plan, runs) {
761
787
  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
788
  }
763
789
 
790
+ const PAGES_SHOWN = 5;
791
+
792
+ function inventoryMd(m, runs) {
793
+ const inv = buildInventory(runs);
794
+ const lines = [`## ${m.ui.inventoryTitle}`, ""];
795
+ if (!inv.vendors.length) return [...lines, m.ui.noInventory, ""];
796
+ lines.push(`| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|");
797
+ for (const v of inv.vendors) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", "))} |`);
798
+ if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
799
+ lines.push("");
800
+ return lines;
801
+ }
802
+
803
+ function inventoryCard(m, runs) {
804
+ const inv = buildInventory(runs);
805
+ const head = `<div class="card-h"><div class="card-t">${m.ui.inventoryTitle}</div><div class="card-d">${m.ui.inventoryLead}</div></div>`;
806
+ if (!inv.vendors.length) return `<div class="card" id="inventory">${head}<div class="card-c"><p class="muted">${m.ui.noInventory}</p></div></div>`;
807
+ const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
808
+ 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>` : ""}`;
809
+ 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("");
810
+ 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>` : "";
811
+ 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>`;
812
+ }
813
+
764
814
  function providersCard(m, runs) {
765
815
  const provs = checkedProviders(runs);
766
816
  const skipped = skippedTotals(runs);
@@ -858,6 +908,7 @@ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px
858
908
  .jconsent{margin-bottom:16px}
859
909
  .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
860
910
  .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
911
+ .title .plan-changes ul{margin:4px 0 0;padding-left:18px}
861
912
  .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
862
913
  .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
863
914
  .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,7 +1126,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
1075
1126
  @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
1127
  `;
1077
1128
 
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 }) {
1129
+ 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 }) {
1079
1130
  const m = messages(lang);
1080
1131
  const rows = attachExplanations(findings, explained);
1081
1132
  const counts = summarize(rows);
@@ -1096,15 +1147,15 @@ ${navCss(runs)}</style></head><body>
1096
1147
  <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>
1097
1148
  <header class="wrap title">
1098
1149
  <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>` : ""}
1150
+ <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
1151
  </header>
1101
1152
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1102
1153
  <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
1154
  <main class="wrap">
1104
1155
  <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
1156
  <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>
1157
+ <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged, consentScenarios)}</section>
1158
+ <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
1159
  </main>
1109
1160
  <footer class="footer">${footer}</footer>
1110
1161
  </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