@analitikisler/ai-tag-debugger 0.8.2 → 0.10.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.
@@ -0,0 +1,76 @@
1
+ import { CSS, VERSION, escapeHtml, logo, platformLabel } from "./report.js";
2
+ import { LOGOS } from "./brand.js";
3
+
4
+ const e = escapeHtml;
5
+ const text = (v) => (v === undefined || v === null || v === "" ? `<span class="muted">–</span>` : e(v));
6
+
7
+ const PLAN_CSS = `
8
+ .pl-event{padding:16px 20px}
9
+ .pl-event+.pl-event{border-top:1px solid var(--border)}
10
+ .pl-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
11
+ .pl-head code{font-size:14px}
12
+ .pl-desc{margin:6px 0 10px}
13
+ .pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
14
+ .pl-trigger b{font-weight:500;color:var(--fg)}
15
+ .flag{display:inline-flex;align-items:center;font:500 12px/1 var(--font-sans);padding:4px 8px;border-radius:4px;background:var(--muted-bg);color:var(--muted)}
16
+ .flag.on{background:var(--accent-bg);color:var(--accent-fg)}
17
+ .flag.req{background:var(--warn-bg);color:var(--warn)}
18
+ table.dt td,table.dt th{vertical-align:top}
19
+ `;
20
+
21
+ const flag = (label, cls = "") => `<span class="flag ${cls}">${e(label)}</span>`;
22
+
23
+ function paramsTable(event) {
24
+ const params = Object.entries(event.parameters ?? {});
25
+ if (!params.length) return `<p class="muted xs">No parameters.</p>`;
26
+ const required = new Set(event.required_params ?? []);
27
+ const rows = params
28
+ .map(([name, p]) => `<tr><td><code>${e(name)}</code>${p?.custom ? ` ${flag("custom")}` : ""}</td><td>${required.has(name) ? flag("required", "req") : `<span class="muted">optional</span>`}</td><td class="mono">${text(p?.example)}</td><td>${text(p?.description)}</td></tr>`)
29
+ .join("");
30
+ return `<div class="tscroll"><table class="dt"><thead><tr><th>Parameter</th><th>Required</th><th>Example</th><th>Description</th></tr></thead><tbody>${rows}</tbody></table></div>`;
31
+ }
32
+
33
+ function eventCard(event) {
34
+ const trigger = event.trigger ?? {};
35
+ return `<div class="pl-event">
36
+ <div class="pl-head"><span class="ptag">${e(platformLabel(event.platform ?? "ga4"))}</span><code>${e(event.name)}</code>${flag(event.custom ? "custom event" : "standard event", event.custom ? "on" : "")}</div>
37
+ <p class="pl-desc">${text(event.description)}</p>
38
+ <p class="pl-trigger"><b>Trigger</b> · page: ${text(trigger.page)} · element: ${text(trigger.element)}</p>
39
+ ${paramsTable(event)}
40
+ </div>`;
41
+ }
42
+
43
+ function userPropertiesCard(props) {
44
+ const body = props.length
45
+ ? `<div class="tscroll"><table class="dt"><thead><tr><th>Name</th><th>Example</th><th>Description</th></tr></thead><tbody>${props
46
+ .map((p) => (typeof p === "string" ? { name: p } : p))
47
+ .map((p) => `<tr><td><code>${e(p.name)}</code></td><td class="mono">${text(p.example)}</td><td>${text(p.description)}</td></tr>`)
48
+ .join("")}</tbody></table></div>`
49
+ : `<p class="muted xs">No user properties.</p>`;
50
+ return `<section class="card"><div class="card-h"><div class="card-t">User properties</div></div><div class="card-c">${body}</div></section>`;
51
+ }
52
+
53
+ /** A single self-contained, readable page for a measurement plan. Respects branding false like the report. */
54
+ export function renderPlanHtml(plan, { id = "", generatedAt = "", branding = true } = {}) {
55
+ const events = plan.events ?? [];
56
+ const custom = events.filter((ev) => ev.custom).length;
57
+ const title = `Measurement plan${plan.site ? `: ${plan.site}` : ""}`;
58
+ const meta = [id && `Plan ${id}`, generatedAt, `${events.length} events (${custom} custom)`, `ai-tag-debugger ${VERSION}`].filter(Boolean).join(" · ");
59
+ const consent = plan.consent_mode ? flag("Consent mode on", "on") : flag("Consent mode off");
60
+ const footer = branding
61
+ ? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a><div><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div><div class="f-meta">v${VERSION}</div></div>`
62
+ : `<div class="wrap plain"><div class="f-meta">AI Tag Debugger</div><div class="f-meta">v${VERSION}</div></div>`;
63
+ return `<!doctype html>
64
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
65
+ <title>${e(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
66
+ <style>${CSS}${PLAN_CSS}</style></head><body>
67
+ <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>by</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
68
+ <header class="wrap title"><div class="eyebrow">Measurement plan</div><h1>${e(title)}</h1><div class="meta">${e(meta)}</div></header>
69
+ <main class="wrap" style="padding-top:24px;padding-bottom:64px"><div class="stack">
70
+ <div>${consent}</div>
71
+ <section class="card"><div class="card-h"><div class="card-t">Events</div><div class="card-d">What should be tracked, where it fires and which parameters it carries.</div></div><div class="card-c tight">${events.length ? events.map(eventCard).join("") : `<p class="muted xs" style="padding:12px">No events.</p>`}</div></section>
72
+ ${userPropertiesCard(plan.user_properties ?? [])}
73
+ </div></main>
74
+ <footer class="footer">${footer}</footer>
75
+ </body></html>`;
76
+ }
package/src/plan.js ADDED
@@ -0,0 +1,14 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { renderPlanHtml } from "./plan-html.js";
4
+ import { PLAN_FILE, PLAN_HTML_FILE, newPlanDir } from "./workspace.js";
5
+
6
+ /** Saves a plan (measurement-plan.json and plan.html) in a new aitd/<domain>/plans/<id>/ folder and returns where. */
7
+ export async function savePlan(plan, { dataDir, site, now, branding = true } = {}) {
8
+ const { id, dir } = await newPlanDir(dataDir, site, now);
9
+ await mkdir(dir, { recursive: true });
10
+ const file = path.join(dir, PLAN_FILE);
11
+ await writeFile(file, `${JSON.stringify(plan, null, 2)}\n`);
12
+ await writeFile(path.join(dir, PLAN_HTML_FILE), renderPlanHtml(plan, { id, generatedAt: (now ?? new Date()).toISOString().slice(0, 16).replace("T", " "), branding }));
13
+ return { id, dir, file };
14
+ }
package/src/providers.js CHANGED
@@ -28,7 +28,7 @@ export function resolveProviderName(name, env = process.env) {
28
28
  return "anthropic";
29
29
  }
30
30
 
31
- const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (or OPENAI_COMPATIBLE_BASE_URL for an OpenAI-compatible API), or pick one with --provider. To run without any AI model, replay the fixed steps with --scripted --no-ai.`;
31
+ const NO_API_KEY = `No AI provider key found. Set one of ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY (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.`;
32
32
 
33
33
  function hasApiKey(env) {
34
34
  return Boolean(env.OPENAI_COMPATIBLE_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || env.OPENAI_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
package/src/replay.js CHANGED
@@ -24,6 +24,53 @@ export function replayableJourney(run) {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Writes the steps each journey took into journeys[].steps of a config file, so a later
29
+ * --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.
30
+ * A journey run on several viewports keeps the steps of its first one. Journeys that took no
31
+ * repeatable steps are left as they were. A config with no plan gets the run's, so ready-made
32
+ * journeys keep their expected events.
33
+ * @returns {Promise<{ saved: string[], skipped: string[] }>}
34
+ */
35
+ export async function writeSavedSteps(file, { site, plan, journeys, runs }) {
36
+ let config = {};
37
+ const text = await readFile(file, "utf8").catch((err) => {
38
+ if (err.code === "ENOENT") return null;
39
+ throw new Error(`Could not read ${file}: ${err.message}`);
40
+ });
41
+ if (text !== null) {
42
+ try {
43
+ config = JSON.parse(text);
44
+ } catch (err) {
45
+ throw new Error(`Could not read ${file}: ${err.message}`);
46
+ }
47
+ }
48
+ config.site ??= site;
49
+ if (config.plan === undefined && plan?.events?.length) config.plan = plan;
50
+ const list = [...(config.journeys ?? [])];
51
+ const saved = [];
52
+ const skipped = [];
53
+ for (const journey of journeys) {
54
+ const run = runs.find((r) => r.journeyId === journey.id);
55
+ const { viewport: _v, ...steps } = run ? replayableJourney(run) : { steps: [] };
56
+ if (!steps.steps.length && !steps.start) {
57
+ skipped.push(journey.id);
58
+ continue;
59
+ }
60
+ const i = list.findIndex((j) => j.id === journey.id);
61
+ const { start: _s, steps: _st, incomplete: _in, ...kept } = i === -1 ? { id: journey.id, ...(journey.goal ? { goal: journey.goal } : {}) } : list[i];
62
+ const entry = { ...kept, ...steps, id: journey.id };
63
+ if (i === -1) list.push(entry);
64
+ else list[i] = entry;
65
+ saved.push(journey.id);
66
+ }
67
+ if (saved.length) {
68
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
69
+ await writeFile(file, `${JSON.stringify({ ...config, journeys: list }, null, 2)}\n`);
70
+ }
71
+ return { saved, skipped };
72
+ }
73
+
27
74
  /** One line per finding, sorted: what a replay is compared on. */
28
75
  export function findingKeys(findings) {
29
76
  return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();
package/src/report.js CHANGED
@@ -6,7 +6,7 @@ import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
6
6
  import { LOGOS } from "./brand.js";
7
7
  import { readFileSync } from "node:fs";
8
8
 
9
- const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
9
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
10
 
11
11
  const EXPLAIN_SYSTEM = `You are a senior digital analytics consultant reviewing an automated tracking audit. You receive findings from deterministic checks, the measurement plan, the hits captured on each step of each journey, and optionally a Google Tag Manager container export.
12
12
 
@@ -120,7 +120,7 @@ export async function explainFindings({ findings, plan, runs, gtmContainer, prov
120
120
  }
121
121
  }
122
122
 
123
- const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
123
+ export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
124
124
 
125
125
  function attachExplanations(findings, explained) {
126
126
  const byIndex = new Map((explained?.explanations ?? []).map((e) => [e.finding, e]));
@@ -149,11 +149,12 @@ const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) =>
149
149
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
150
150
  const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
151
151
 
152
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true }) {
152
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null }) {
153
153
  const m = messages(lang);
154
154
  const rows = attachExplanations(findings, explained);
155
155
  const counts = summarize(rows);
156
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", `**${m.ui.counts(counts)}**`, ""];
156
+ const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), `**${m.ui.counts(counts)}**`, ""];
157
+ if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
157
158
  const sum = summaryParts(explained?.summary);
158
159
  if (sum) {
159
160
  lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
@@ -193,7 +194,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
193
194
  }
194
195
 
195
196
  const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "pinterest", "snapchat", "klaviyo", "hotjar"];
196
- const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
197
+ export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
197
198
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
198
199
 
199
200
  // Pixel event names and their GA4 / dataLayer counterparts, so a Meta or TikTok hit can be shown next to the push behind it.
@@ -455,7 +456,7 @@ const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "in
455
456
  const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
456
457
  const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
457
458
  /** A logo with its light and dark versions, switched by the viewer's color scheme. */
458
- const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
459
+ export const logo = (key, alt, height, cls) => `<picture class="${cls}"><source media="(prefers-color-scheme: dark)" srcset="${LOGOS[`${key}-dark`]}"><img src="${LOGOS[`${key}-light`]}" alt="${alt}" height="${height}"></picture>`;
459
460
 
460
461
  /** The journey and step a finding's place points at. Steps are named by label; when labels repeat, prefer the step where the finding's event fired. */
461
462
  function whereTarget(runs, w, f) {
@@ -610,6 +611,29 @@ ${provs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.
610
611
  }
611
612
 
612
613
  /** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
614
+ const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
615
+ const coverageLines = (m, pc) => [
616
+ m.ui.coverageLine(pc.coverage),
617
+ ...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
618
+ ];
619
+ const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
620
+ const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
621
+
622
+ function planVsSiteMd(m, pc) {
623
+ const [overall, ...perPlatform] = coverageLines(m, pc);
624
+ const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
625
+ lines.push(`| ${m.ui.event} | ${m.ui.bucketCol} | |`, "|---|---|---|");
626
+ for (const e of pcSort(pc)) lines.push(`| ${mdText(platformLabel(e.platform))} \`${mdCode(e.event)}\` | ${m.ui.bucket[e.bucket]} | ${mdText((e.details ?? []).join(" ")).replace(/\|/g, "\\|")} |`);
627
+ lines.push("");
628
+ return lines;
629
+ }
630
+
631
+ function planVsSiteCard(m, pc) {
632
+ const [overall, ...perPlatform] = coverageLines(m, pc);
633
+ const rows = pcSort(pc).map((e) => `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.event)}</code></td><td>${badge(BUCKET_SEV[e.bucket], m.ui.bucket[e.bucket])}</td><td class="muted xs">${(e.details ?? []).map(detailHtml).join("<br>")}</td></tr>`).join("");
634
+ return `<div class="card" id="plan-vs-site"><div class="card-h"><div class="card-t">${m.ui.planVsSite}</div><div class="card-d">${m.ui.planVsSiteLead}</div></div><div class="card-c"><p><strong>${escapeHtml(overall)}</strong></p>${perPlatform.length ? `<p class="muted xs">${perPlatform.map(escapeHtml).join("<br>")}</p>` : ""}<div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.event}</th><th>${m.ui.bucketCol}</th><th></th></tr></thead><tbody>${rows}</tbody></table></div></div></div>`;
635
+ }
636
+
613
637
  function coverageCard(m, plan, runs) {
614
638
  const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
615
639
  if (!plan?.events?.length || !runs.length) return `<div class="card">${head}<div class="card-c"><p class="muted">${m.ui.noPlan}</p></div></div>`;
@@ -679,7 +703,7 @@ function navCss(runs) {
679
703
  return out.join("\n");
680
704
  }
681
705
 
682
- const CSS = `
706
+ export const CSS = `
683
707
  :root{
684
708
  --bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#F1F4F9;--border:#E3E7EF;--input:#E3E7EF;
685
709
  --accent:#4F8EF7;--accent-fg:#2F6FDB;--accent-bg:#EBF1FE;--orange:#FF6D33;
@@ -916,7 +940,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
916
940
  @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}}
917
941
  `;
918
942
 
919
- export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true }) {
943
+ export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null }) {
920
944
  const m = messages(lang);
921
945
  const rows = attachExplanations(findings, explained);
922
946
  const counts = summarize(rows);
@@ -938,12 +962,12 @@ ${navCss(runs)}</style></head><body>
938
962
  <header class="wrap title">
939
963
  <div class="eyebrow">${m.ui.eyebrow}</div>
940
964
  <h1>${escapeHtml(title)}</h1>
941
- <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>
965
+ <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>` : ""}
942
966
  </header>
943
967
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
944
968
  <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>
945
969
  <main class="wrap">
946
- <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${overviewTab(m, findings, rows, counts, explained, runs)}</section>
970
+ <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs)}</section>
947
971
  <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
948
972
  <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
949
973
  <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
package/src/sheet.js ADDED
@@ -0,0 +1,321 @@
1
+ // Plan import and export as a spreadsheet: one row per event parameter on an "events" tab and
2
+ // one row per user property on a "user_properties" tab. CSV needs nothing extra; .xlsx uses the
3
+ // optional fflate package (zip), loaded only when an .xlsx file is read or written.
4
+ import { readFile, writeFile, stat } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { PROVIDERS as PLATFORMS } from "./parsers.js";
7
+
8
+ export const EVENT_COLUMNS = ["event", "platform", "parameter", "required", "example", "trigger", "description"];
9
+ // Optional columns that keep the rest of a plan on a round trip.
10
+ export const EXTRA_EVENT_COLUMNS = ["custom", "journeys"];
11
+ export const USER_PROPERTY_COLUMNS = ["name", "description", "example"];
12
+ const TRIGGER_SEP = " > ";
13
+
14
+ // ---------- plan <-> rows ----------
15
+
16
+ const yesNo = (v) => (v ? "yes" : "no");
17
+
18
+ /** The plan as the two tabs' rows: { events: object[], user_properties: object[] }. */
19
+ export function planToRows(plan) {
20
+ const events = [];
21
+ for (const ev of plan.events ?? []) {
22
+ const trigger = ev.trigger ? [ev.trigger.page, ev.trigger.element].filter(Boolean).join(TRIGGER_SEP) : "";
23
+ const params = { ...(ev.parameters ?? {}) };
24
+ for (const p of ev.required_params ?? []) params[p] ??= {};
25
+ const required = new Set(ev.required_params ?? []);
26
+ const base = { event: ev.name, platform: ev.platform ?? "ga4", custom: ev.custom ? "yes" : "", journeys: (ev.journeys ?? []).join(", ") };
27
+ const names = Object.keys(params);
28
+ // An event row (empty parameter) carries the event's own description; it is left out when the
29
+ // event has parameters and nothing to say.
30
+ if (!names.length || ev.description) events.push({ ...base, parameter: "", required: "", example: "", trigger, description: ev.description ?? "" });
31
+ for (const name of names) {
32
+ const p = params[name] ?? {};
33
+ events.push({
34
+ ...base,
35
+ parameter: name,
36
+ required: yesNo(required.has(name)),
37
+ example: p.example ?? "",
38
+ trigger,
39
+ description: p.description ?? "",
40
+ custom: [ev.custom ? "event" : "", p.custom ? "parameter" : ""].filter(Boolean).join(", "),
41
+ });
42
+ }
43
+ }
44
+ const user_properties = (plan.user_properties ?? []).map((u) => ({ name: u.name, description: u.description ?? "", example: u.example ?? "" }));
45
+ return { events, user_properties };
46
+ }
47
+
48
+ const fail = (msg) => {
49
+ const err = new Error(msg);
50
+ err.sheetError = true;
51
+ return err;
52
+ };
53
+
54
+ function headerOf(tab, rows, needed, optional, warnings) {
55
+ if (!rows.length) throw fail(`${tab}: the tab is empty. Its first row must name the columns: ${needed.join(", ")}.`);
56
+ const header = rows[0].map((h) => String(h ?? "").trim().toLowerCase());
57
+ const missing = needed.filter((c) => !header.includes(c) && !(c === "description" && tab === "events"));
58
+ if (missing.length) throw fail(`${tab}, row 1: missing column${missing.length > 1 ? "s" : ""} ${missing.map((c) => `"${c}"`).join(", ")}. The first row must name the columns: ${needed.join(", ")}.`);
59
+ const unknown = header.filter((h) => h && !needed.includes(h) && !optional.includes(h));
60
+ for (const h of unknown) warnings.push(`${tab}: column "${h}" is not used and was ignored.`);
61
+ return header;
62
+ }
63
+
64
+ function records(rows, header) {
65
+ return rows.slice(1).map((cells, i) => {
66
+ const rec = { row: i + 2 };
67
+ header.forEach((h, j) => {
68
+ if (h) rec[h] = String(cells[j] ?? "").trim();
69
+ });
70
+ return rec;
71
+ }).filter((r) => header.some((h) => h && r[h]));
72
+ }
73
+
74
+ /**
75
+ * Builds a plan from the two tabs' rows (arrays of cell arrays, header first).
76
+ * Returns { plan, warnings }. Throws an error naming the tab, row and fix.
77
+ */
78
+ export function rowsToPlan({ events: eventRows = [], user_properties: upRows = null }, { site } = {}) {
79
+ const warnings = [];
80
+ const header = headerOf("events", eventRows, EVENT_COLUMNS, EXTRA_EVENT_COLUMNS, warnings);
81
+ const byKey = new Map();
82
+ for (const r of records(eventRows, header)) {
83
+ const at = `events, row ${r.row}`;
84
+ if (!r.event) throw fail(`${at}: "event" is empty. Give the event name, e.g. purchase.`);
85
+ const platform = (r.platform || "ga4").toLowerCase();
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();
88
+ if (!["yes", "no", ""].includes(required)) throw fail(`${at}: "required" must be yes or no (found "${r.required}").`);
89
+ 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
+ const custom = (r.custom ?? "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
91
+ const okCustom = ["yes", "no", "event", "parameter"];
92
+ const bad = custom.find((c) => !okCustom.includes(c));
93
+ if (bad) throw fail(`${at}: "custom" must be yes, no, event or parameter (found "${r.custom}").`);
94
+
95
+ const key = `${platform}:${r.event}`;
96
+ let ev = byKey.get(key);
97
+ if (!ev) {
98
+ ev = { platform, name: r.event, parameters: {}, required_params: [] };
99
+ byKey.set(key, ev);
100
+ }
101
+ if (custom.includes("event") || (!r.parameter && custom.includes("yes"))) ev.custom = true;
102
+ if (r.trigger && !ev.trigger) {
103
+ const [page, ...rest] = r.trigger.split(TRIGGER_SEP);
104
+ ev.trigger = { page: page.trim(), ...(rest.length ? { element: rest.join(TRIGGER_SEP).trim() } : {}) };
105
+ }
106
+ if (r.journeys && !ev.journeys) ev.journeys = r.journeys.split(",").map((s) => s.trim()).filter(Boolean);
107
+ if (!r.parameter) {
108
+ if (r.description) ev.description = r.description;
109
+ continue;
110
+ }
111
+ if (ev.parameters[r.parameter]) throw fail(`${at}: parameter "${r.parameter}" of ${r.event} is listed twice. Keep one row per parameter.`);
112
+ ev.parameters[r.parameter] = {
113
+ ...(custom.includes("parameter") || custom.includes("yes") ? { custom: true } : {}),
114
+ ...(r.description ? { description: r.description } : {}),
115
+ ...(r.example ? { example: r.example } : {}),
116
+ };
117
+ if (required === "yes") ev.required_params.push(r.parameter);
118
+ }
119
+ if (!byKey.size) throw fail("events: no events found. Add one row per event parameter below the column names.");
120
+
121
+ const events = [...byKey.values()].map(({ platform, name, custom, description, trigger, journeys, required_params, parameters }) => ({
122
+ platform,
123
+ name,
124
+ ...(custom ? { custom: true } : {}),
125
+ ...(description ? { description } : {}),
126
+ ...(trigger ? { trigger } : {}),
127
+ ...(journeys ? { journeys } : {}),
128
+ ...(required_params.length ? { required_params } : {}),
129
+ ...(Object.keys(parameters).length ? { parameters } : {}),
130
+ }));
131
+
132
+ const user_properties = [];
133
+ if (upRows) {
134
+ const upHeader = headerOf("user_properties", upRows, USER_PROPERTY_COLUMNS, [], warnings);
135
+ const seen = new Set();
136
+ for (const r of records(upRows, upHeader)) {
137
+ if (!r.name) throw fail(`user_properties, row ${r.row}: "name" is empty. Give the user property name, e.g. customer_type.`);
138
+ if (seen.has(r.name)) throw fail(`user_properties, row ${r.row}: "${r.name}" is listed twice. Keep one row per user property.`);
139
+ seen.add(r.name);
140
+ user_properties.push({ name: r.name, description: r.description ?? "", example: r.example ?? "" });
141
+ }
142
+ }
143
+ return { plan: { ...(site ? { site } : {}), consent_mode: true, events, user_properties }, warnings };
144
+ }
145
+
146
+ const toCells = (objs, columns) => [columns, ...objs.map((o) => columns.map((c) => o[c] ?? ""))];
147
+
148
+ // ---------- CSV ----------
149
+
150
+ const csvCell = (v) => {
151
+ const s = String(v ?? "");
152
+ return /[",\r\n]/.test(s) || /^\s|\s$/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
153
+ };
154
+ export const toCsv = (rows) => `${rows.map((r) => r.map(csvCell).join(",")).join("\r\n")}\r\n`;
155
+
156
+ /** Parses CSV text (RFC 4180, comma or semicolon separated) into an array of cell arrays. */
157
+ export function parseCsv(text) {
158
+ text = text.replace(/^/, "");
159
+ const firstLine = text.slice(0, text.search(/\r?\n|$/));
160
+ const sep = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ",";
161
+ const rows = [];
162
+ let row = [];
163
+ let cell = "";
164
+ let quoted = false;
165
+ for (let i = 0; i < text.length; i++) {
166
+ const ch = text[i];
167
+ if (quoted) {
168
+ if (ch === '"' && text[i + 1] === '"') {
169
+ cell += '"';
170
+ i++;
171
+ } else if (ch === '"') quoted = false;
172
+ else cell += ch;
173
+ } else if (ch === '"') quoted = true;
174
+ else if (ch === sep) {
175
+ row.push(cell);
176
+ cell = "";
177
+ } else if (ch === "\n" || ch === "\r") {
178
+ if (ch === "\r" && text[i + 1] === "\n") i++;
179
+ row.push(cell);
180
+ rows.push(row);
181
+ row = [];
182
+ cell = "";
183
+ } else cell += ch;
184
+ }
185
+ if (cell || row.length) {
186
+ row.push(cell);
187
+ rows.push(row);
188
+ }
189
+ return rows;
190
+ }
191
+
192
+ // ---------- XLSX (minimal: inline strings out, shared or inline strings and numbers in) ----------
193
+
194
+ async function loadFflate() {
195
+ try {
196
+ return await import("fflate");
197
+ } catch {
198
+ throw new Error('Reading and writing .xlsx files needs the optional "fflate" package, which is not installed. Install it with: npm install fflate (or use CSV: --format csv for export, a .csv file for import).');
199
+ }
200
+ }
201
+
202
+ const xmlEscape = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
203
+ const xmlUnescape = (s) => s.replace(/&(lt|gt|quot|apos|amp|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
204
+ const named = { lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" }[e.toLowerCase()];
205
+ if (named) return named;
206
+ return String.fromCodePoint(e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
207
+ });
208
+ const colName = (i) => (i >= 26 ? colName(Math.floor(i / 26) - 1) : "") + String.fromCharCode(65 + (i % 26));
209
+ const colIndex = (letters) => [...letters].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
210
+
211
+ function sheetXml(rows) {
212
+ const body = rows.map((r, i) => `<row r="${i + 1}">${r.map((v, j) => (v === "" || v == null ? "" : `<c r="${colName(j)}${i + 1}" t="inlineStr"${i === 0 ? ' s="1"' : ""}><is><t xml:space="preserve">${xmlEscape(v)}</t></is></c>`)).join("")}</row>`).join("");
213
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetData>${body}</sheetData></worksheet>`;
214
+ }
215
+
216
+ /** An .xlsx file (Uint8Array) with one sheet per entry of { name: rows }. */
217
+ export async function writeXlsx(sheets) {
218
+ const { zipSync, strToU8 } = await loadFflate();
219
+ const names = Object.keys(sheets);
220
+ const files = {
221
+ "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${names.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`,
222
+ "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
223
+ "xl/workbook.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${names.map((n, i) => `<sheet name="${xmlEscape(n)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets></workbook>`,
224
+ "xl/_rels/workbook.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${names.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}<Relationship Id="rId${names.length + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`,
225
+ "xl/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="2"><xf/><xf fontId="1" applyFont="1"/></cellXfs></styleSheet>`,
226
+ };
227
+ names.forEach((n, i) => (files[`xl/worksheets/sheet${i + 1}.xml`] = sheetXml(sheets[n])));
228
+ return zipSync(Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strToU8(v)])));
229
+ }
230
+
231
+ const textOf = (xml) => [...xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>|<t(?:\s[^>]*)?\/>/g)].map((m) => xmlUnescape(m[1] ?? "")).join("");
232
+
233
+ /** Reads an .xlsx file's sheets as { name: rows } (rows are arrays of cell text). */
234
+ export async function readXlsx(bytes) {
235
+ const { unzipSync, strFromU8 } = await loadFflate();
236
+ let zip;
237
+ try {
238
+ zip = unzipSync(bytes);
239
+ } catch {
240
+ throw new Error("This is not a readable .xlsx file. Save it as an Excel workbook (.xlsx) or as CSV.");
241
+ }
242
+ const get = (p) => (zip[p] ? strFromU8(zip[p]) : null);
243
+ const workbook = get("xl/workbook.xml");
244
+ if (!workbook) throw new Error("This is not a readable .xlsx file. Save it as an Excel workbook (.xlsx) or as CSV.");
245
+ const rels = Object.fromEntries([...(get("xl/_rels/workbook.xml.rels") ?? "").matchAll(/<Relationship\b[^>]*>/g)].map((m) => [m[0].match(/Id="([^"]+)"/)?.[1], m[0].match(/Target="([^"]+)"/)?.[1]]));
246
+ const shared = [...(get("xl/sharedStrings.xml") ?? "").matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) => textOf(m[1].replace(/<rPh\b[\s\S]*?<\/rPh>/g, "")));
247
+ const sheets = {};
248
+ for (const m of workbook.matchAll(/<sheet\b[^>]*>/g)) {
249
+ const name = xmlUnescape(m[0].match(/name="([^"]*)"/)?.[1] ?? "");
250
+ const target = rels[m[0].match(/r:id="([^"]+)"/)?.[1]];
251
+ if (!target) continue;
252
+ const file = target.startsWith("/") ? target.slice(1) : path.posix.normalize(`xl/${target}`);
253
+ const xml = get(file) ?? "";
254
+ const rows = [];
255
+ for (const r of xml.matchAll(/<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g)) {
256
+ const rowNum = Number(r[1].match(/r="(\d+)"/)?.[1] ?? rows.length + 1);
257
+ const cells = [];
258
+ for (const c of (r[2] ?? "").matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
259
+ const ref = c[1].match(/r="([A-Z]+)\d+"/)?.[1];
260
+ const type = c[1].match(/t="([^"]+)"/)?.[1];
261
+ const inner = c[2] ?? "";
262
+ const v = inner.match(/<v>([\s\S]*?)<\/v>/)?.[1];
263
+ let value = "";
264
+ if (type === "s") value = shared[Number(v)] ?? "";
265
+ else if (type === "inlineStr") value = textOf(inner.match(/<is>([\s\S]*?)<\/is>/)?.[1] ?? "");
266
+ else if (type === "b") value = v === "1" ? "TRUE" : "FALSE";
267
+ else if (v != null) value = xmlUnescape(v);
268
+ cells[ref ? colIndex(ref) : cells.length] = value;
269
+ }
270
+ rows[rowNum - 1] = Array.from(cells, (x) => x ?? "");
271
+ }
272
+ sheets[name] = Array.from(rows, (x) => x ?? []);
273
+ }
274
+ return sheets;
275
+ }
276
+
277
+ // ---------- files ----------
278
+
279
+ const exists = (p) => stat(p).then(() => true, () => false);
280
+
281
+ /** Reads a plan spreadsheet (.xlsx, or events.csv with an optional user_properties.csv beside it). */
282
+ export async function readPlanSheet(file, { site } = {}) {
283
+ const ext = path.extname(file).toLowerCase();
284
+ if (!(await exists(file))) throw new Error(`No file at ${file}.`);
285
+ let tabs;
286
+ if (ext === ".xlsx") {
287
+ const sheets = await readXlsx(await readFile(file));
288
+ const find = (n) => Object.entries(sheets).find(([k]) => k.trim().toLowerCase() === n)?.[1];
289
+ const events = find("events");
290
+ 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
+ tabs = { events, user_properties: find("user_properties") ?? null };
292
+ } 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.`);
296
+ tabs = { events: parseCsv(await readFile(file, "utf8")), user_properties: (await exists(upFile)) ? parseCsv(await readFile(upFile, "utf8")) : null };
297
+ } else {
298
+ throw new Error(`Import a .xlsx or .csv file (got ${path.basename(file)}).`);
299
+ }
300
+ 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.');
302
+ return result;
303
+ }
304
+
305
+ /** Writes a plan as plan.xlsx (or events.csv and user_properties.csv) in dir; returns the files written. */
306
+ export async function writePlanSheet(plan, dir, { format = "xlsx" } = {}) {
307
+ const { events, user_properties } = planToRows(plan);
308
+ const hasExtra = (c) => events.some((r) => r[c]);
309
+ const eventColumns = [...EVENT_COLUMNS, ...EXTRA_EVENT_COLUMNS.filter(hasExtra)];
310
+ const tabs = { events: toCells(events, eventColumns), user_properties: toCells(user_properties, USER_PROPERTY_COLUMNS) };
311
+ if (format === "csv") {
312
+ const files = [path.join(dir, "events.csv"), path.join(dir, "user_properties.csv")];
313
+ await writeFile(files[0], `${toCsv(tabs.events)}`);
314
+ await writeFile(files[1], `${toCsv(tabs.user_properties)}`);
315
+ return files;
316
+ }
317
+ if (format !== "xlsx") throw new Error(`--format must be xlsx or csv (found "${format}").`);
318
+ const file = path.join(dir, "plan.xlsx");
319
+ await writeFile(file, await writeXlsx(tabs));
320
+ return [file];
321
+ }