@analitikisler/ai-tag-debugger 0.16.0 → 0.17.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/schema.js ADDED
@@ -0,0 +1,190 @@
1
+ // The shape of the config file and the measurement plan, checked as soon as they are read. A typo
2
+ // such as "viewport" or a step action "clik" then fails at once with the field named, instead of
3
+ // deep in a run or by silently doing nothing. Unknown keys are allowed (they only get a warning),
4
+ // so an existing config with an extra harmless key still runs.
5
+ import { z } from "zod";
6
+ import { VIEWPORTS } from "./browser.js";
7
+ import { LANGUAGES } from "./i18n.js";
8
+ import { PROVIDERS as PLATFORMS } from "./parsers.js";
9
+ import { PROVIDERS as AI_PROVIDERS } from "./providers.js";
10
+
11
+ const text = z.string();
12
+ const flag = z.boolean();
13
+ const names = z.array(z.string());
14
+
15
+ export const STEP_ACTIONS = ["goto", "click", "fill", "select", "scroll", "wait"];
16
+
17
+ export const STEP_SCHEMA = z.looseObject({
18
+ action: z.enum(STEP_ACTIONS),
19
+ selector: text.optional(),
20
+ ref: z.int().optional(),
21
+ text: text.optional(),
22
+ value: z.union([text, z.number()], { error: "must be a text or a number" }).optional(),
23
+ url: text.optional(),
24
+ reason: text.optional(),
25
+ accepts_consent: flag.optional(),
26
+ rejects_consent: flag.optional(),
27
+ optional: flag.optional(),
28
+ });
29
+
30
+ export const JOURNEY_SCHEMA = z.looseObject({
31
+ id: text.min(1),
32
+ goal: text.optional(),
33
+ start: text.optional(),
34
+ steps: z.array(STEP_SCHEMA).optional(),
35
+ incomplete: text.optional(),
36
+ });
37
+
38
+ export const PLAN_EVENT_SCHEMA = z.looseObject({
39
+ // The checks lowercase the platform, so "GA4" and "Meta" in a plan keep working as they did.
40
+ platform: z.string().transform((v) => v.toLowerCase()).pipe(z.enum(Object.keys(PLATFORMS))),
41
+ name: text.min(1),
42
+ aliases: names.optional(),
43
+ required_params: names.optional(),
44
+ journeys: names.optional(),
45
+ allow_multiple: flag.optional(),
46
+ custom: flag.optional(),
47
+ description: text.nullish(),
48
+ // Documentation fields the checks ignore: a sentence or an object are both fine.
49
+ trigger: z.union([text, z.looseObject({ page: text.nullish(), element: text.nullish() })]).nullish(),
50
+ parameters: z.record(text, z.looseObject({ custom: flag.optional(), description: text.nullish(), example: z.unknown().optional() })).optional(),
51
+ seen: flag.optional(),
52
+ });
53
+
54
+ export const PLAN_SCHEMA = z.looseObject({
55
+ site: text.optional(),
56
+ consent_mode: flag.optional(),
57
+ learned: flag.optional(),
58
+ pages: z.array(z.unknown()).optional(),
59
+ events: z.array(PLAN_EVENT_SCHEMA),
60
+ user_properties: z.array(z.union([text, z.looseObject({ name: text.min(1), description: text.nullish(), example: z.unknown().optional() })], { error: 'must be a name or { "name", "description", "example" }' })).optional(),
61
+ });
62
+
63
+ export const CONFIG_SCHEMA = z.looseObject({
64
+ site: text.optional(),
65
+ // Null means "not set" for these, as it always did: the loaders treat null and absent alike.
66
+ plan: z.union([text, z.looseObject({})], { error: "must be a file path or a plan object" }).nullish(),
67
+ gtm_container: text.nullish(),
68
+ viewports: z.array(z.enum(Object.keys(VIEWPORTS))).optional(),
69
+ journeys: z.array(JOURNEY_SCHEMA).optional(),
70
+ allowed_hosts: names.optional(),
71
+ providers: z.union([names, text], { error: 'must be a list of platforms, e.g. ["ga4", "meta"]' }).optional(),
72
+ detect_providers: flag.optional(),
73
+ campaign_checks: flag.optional(),
74
+ block_hits: flag.optional(),
75
+ consent_scenarios: flag.optional(),
76
+ keep_bodies: flag.optional(),
77
+ branding: flag.optional(),
78
+ // The range itself is checked by parseConcurrency, which gives the exact message for the CLI flag too.
79
+ concurrency: z.union([z.number(), text], { error: "must be a whole number from 1 to 8" }).optional(),
80
+ baseline: text.optional(),
81
+ language: z.enum(Object.keys(LANGUAGES)).nullish(),
82
+ report: z.looseObject({ name: text.nullish(), logo: text.nullish(), color: text.nullish(), pdf: flag.optional() }).nullish(),
83
+ provider: z.enum(Object.keys(AI_PROVIDERS)).optional(),
84
+ model: text.optional(),
85
+ base_url: text.optional(),
86
+ notify: z.looseObject({
87
+ slack: z.union([flag, text], { error: "must be a webhook URL, or true to read SLACK_WEBHOOK_URL" }).optional(),
88
+ email: z.union([text, names], { error: "must be an address or a list of addresses" }).optional(),
89
+ }).optional(),
90
+ });
91
+
92
+ /** Checks a config object. Returns { value, warnings }; throws when a field is wrong. */
93
+ export function validateConfig(config, { file } = {}) {
94
+ return validate(CONFIG_SCHEMA, config, { what: "config", file });
95
+ }
96
+
97
+ /**
98
+ * Checks a measurement plan. Returns { value, warnings }; throws when a field is wrong.
99
+ * at: the path the plan sits at inside a bigger file, e.g. "plan" for a plan written inline in the config.
100
+ */
101
+ export function validatePlan(plan, { file, at } = {}) {
102
+ return validate(PLAN_SCHEMA, plan, { what: "plan", file, at });
103
+ }
104
+
105
+ function validate(schema, input, { what, file, at }) {
106
+ const where = file ? ` in ${file}` : "";
107
+ const result = schema.safeParse(input);
108
+ if (!result.success) {
109
+ const lines = result.error.issues.map((issue) => ` ${pathOf(at, issue.path) || what}: ${describe(issue, input)}`);
110
+ throw new Error(`Invalid ${what}${where}:\n${lines.join("\n")}`);
111
+ }
112
+ const unknown = [];
113
+ unknownKeys(schema, input, [], unknown);
114
+ const warnings = unknown.map(({ path, suggestion }) => `Unknown key ${quote(pathOf(at, path))}${where} is ignored.${suggestion ? ` Did you mean ${quote(suggestion)}?` : ""}`);
115
+ return { value: result.data, warnings };
116
+ }
117
+
118
+ const quote = (s) => `"${s}"`;
119
+
120
+ /** "journeys[1].steps[0].action" for the path ["journeys", 1, "steps", 0, "action"]. */
121
+ const pathOf = (at, path) => path.reduce((s, key) => (typeof key === "number" ? `${s}[${key}]` : s ? `${s}.${key}` : key), at ?? "");
122
+
123
+ const valueAt = (input, path) => path.reduce((v, key) => v?.[key], input);
124
+
125
+ const MAX_SHOWN = 60;
126
+ const show = (value) => {
127
+ const json = JSON.stringify(value) ?? String(value);
128
+ return json.length > MAX_SHOWN ? `${json.slice(0, MAX_SHOWN)}…` : json;
129
+ };
130
+
131
+ const KINDS = { array: "a list", object: "an object", string: "a text", boolean: "true or false", number: "a number", int: "a whole number" };
132
+
133
+ function describe(issue, input) {
134
+ const value = valueAt(input, issue.path);
135
+ if (value === undefined) return "is required";
136
+ const got = `(got ${show(value)})`;
137
+ switch (issue.code) {
138
+ case "invalid_value":
139
+ return `must be one of ${issue.values.join(", ")} ${got}`;
140
+ case "invalid_type":
141
+ return `must be ${KINDS[issue.expected] ?? issue.expected} ${got}`;
142
+ case "too_small":
143
+ return issue.origin === "string" ? "must not be empty" : `${issue.message} ${got}`;
144
+ default:
145
+ // Unions and custom checks carry their own plain-language message.
146
+ return `${issue.message} ${got}`;
147
+ }
148
+ }
149
+
150
+ // Walks the value next to the schema and lists the keys no schema object knows about. Unions and
151
+ // records are not entered: their keys are free-form, or (an inline plan) checked on their own.
152
+ function unknownKeys(schema, value, path, out) {
153
+ const s = unwrap(schema);
154
+ if (s.def.type === "array" && Array.isArray(value)) value.forEach((item, i) => unknownKeys(s.def.element, item, [...path, i], out));
155
+ if (s.def.type !== "object" || !value || typeof value !== "object" || Array.isArray(value)) return;
156
+ const known = Object.keys(s.shape);
157
+ for (const key of Object.keys(value)) {
158
+ if (known.includes(key)) unknownKeys(s.shape[key], value[key], [...path, key], out);
159
+ else out.push({ path: [...path, key], suggestion: closest(key, known) });
160
+ }
161
+ }
162
+
163
+ function unwrap(schema) {
164
+ let s = schema;
165
+ while (["optional", "nullable"].includes(s.def.type)) s = s.def.innerType;
166
+ return s;
167
+ }
168
+
169
+ const MAX_DISTANCE = 2;
170
+
171
+ /** The known key within two edits of the unknown one (a typo or a missing letter), or null. */
172
+ export function closest(key, known) {
173
+ let best = null;
174
+ for (const candidate of known) {
175
+ const d = editDistance(key.toLowerCase(), candidate.toLowerCase());
176
+ if (d <= MAX_DISTANCE && (best === null || d < best.d)) best = { d, candidate };
177
+ }
178
+ return best?.candidate ?? null;
179
+ }
180
+
181
+ function editDistance(a, b) {
182
+ if (Math.abs(a.length - b.length) > MAX_DISTANCE) return MAX_DISTANCE + 1;
183
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
184
+ for (let i = 1; i <= a.length; i++) {
185
+ const row = [i];
186
+ for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
187
+ prev = row;
188
+ }
189
+ return prev[b.length];
190
+ }
package/src/sheet.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { readFile, writeFile, stat } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { PROVIDERS as PLATFORMS } from "./parsers.js";
7
+ import { validatePlan } from "./schema.js";
7
8
 
8
9
  export const EVENT_COLUMNS = ["event", "platform", "parameter", "required", "example", "trigger", "description"];
9
10
  // Optional columns that keep the rest of a plan on a round trip.
@@ -141,7 +142,10 @@ export function rowsToPlan({ events: eventRows = [], user_properties: upRows = n
141
142
  user_properties.push({ name: r.name, description: r.description ?? "", example: r.example ?? "" });
142
143
  }
143
144
  }
144
- return { plan: { ...(site ? { site } : {}), consent_mode: true, events, user_properties }, warnings };
145
+ const plan = { ...(site ? { site } : {}), consent_mode: true, events, user_properties };
146
+ // The same check every plan gets when it is read, so a bad import fails here, not in a later run.
147
+ validatePlan(plan);
148
+ return { plan, warnings };
145
149
  }
146
150
 
147
151
  const toCells = (objs, columns) => [columns, ...objs.map((o) => columns.map((c) => o[c] ?? ""))];
@@ -156,7 +160,7 @@ export const toCsv = (rows) => `${rows.map((r) => r.map(csvCell).join(",")).join
156
160
 
157
161
  /** Parses CSV text (RFC 4180, comma or semicolon separated) into an array of cell arrays. */
158
162
  export function parseCsv(text) {
159
- text = text.replace(/^/, "");
163
+ text = text.replace(/^\uFEFF/, "");
160
164
  const firstLine = text.slice(0, text.search(/\r?\n|$/));
161
165
  const sep = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ",";
162
166
  const rows = [];
@@ -200,6 +204,7 @@ async function loadFflate() {
200
204
  }
201
205
  }
202
206
 
207
+ // eslint-disable-next-line no-control-regex -- the characters XML forbids are stripped on purpose.
203
208
  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, "");
204
209
  const xmlUnescape = (s) => s.replace(/&(lt|gt|quot|apos|amp|#\d+|#x[0-9a-f]+);/gi, (_, e) => {
205
210
  const named = { lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" }[e.toLowerCase()];
@@ -284,6 +289,7 @@ export async function readPlanSheet(file, { site } = {}) {
284
289
  const ext = path.extname(file).toLowerCase();
285
290
  if (!(await exists(file))) throw new Error(`No file at ${file}.`);
286
291
  let tabs;
292
+ let upFile; // Only a CSV import has one, but the warning below names it either way.
287
293
  if (ext === ".xlsx") {
288
294
  const sheets = await readXlsx(await readFile(file));
289
295
  const find = (n) => Object.entries(sheets).find(([k]) => k.trim().toLowerCase() === n)?.[1];
@@ -294,7 +300,7 @@ export async function readPlanSheet(file, { site } = {}) {
294
300
  // Google Sheets downloads each tab as "<Document> - <tab>.csv"; the sibling file keeps that prefix.
295
301
  const base = path.basename(file);
296
302
  const prefix = base.match(/^(.*?)events\.csv$/i)?.[1] ?? "";
297
- const upFile = path.join(path.dirname(file), `${prefix}user_properties.csv`);
303
+ upFile = path.join(path.dirname(file), `${prefix}user_properties.csv`);
298
304
  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.`);
299
305
  tabs = { events: parseCsv(await readFile(file, "utf8")), user_properties: (await exists(upFile)) ? parseCsv(await readFile(upFile, "utf8")) : null };
300
306
  } else {
@@ -313,8 +319,8 @@ export async function writePlanSheet(plan, dir, { format = "xlsx" } = {}) {
313
319
  const tabs = { events: toCells(events, eventColumns), user_properties: toCells(user_properties, USER_PROPERTY_COLUMNS) };
314
320
  if (format === "csv") {
315
321
  const files = [path.join(dir, "events.csv"), path.join(dir, "user_properties.csv")];
316
- await writeFile(files[0], `${toCsv(tabs.events)}`);
317
- await writeFile(files[1], `${toCsv(tabs.user_properties)}`);
322
+ await writeFile(files[0], `\uFEFF${toCsv(tabs.events)}`);
323
+ await writeFile(files[1], `\uFEFF${toCsv(tabs.user_properties)}`);
318
324
  return files;
319
325
  }
320
326
  if (format !== "xlsx") throw new Error(`--format must be xlsx or csv (found "${format}").`);
package/src/whitelabel.js CHANGED
@@ -43,16 +43,45 @@ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
43
43
  const toHex = (c) => `#${c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
44
44
  const mix = (hex, other, amount) => toHex(rgb(hex).map((v, i) => v + (other[i] - v) * amount));
45
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. */
46
+ // WCAG relative luminance and contrast ratio, so the link colour is checked instead of guessed.
47
+ const luminance = (hex) => {
48
+ const [r, g, b] = rgb(hex).map((v) => v / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
49
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
50
+ };
51
+ export const contrast = (a, b) => {
52
+ const [h, l] = [luminance(a), luminance(b)].sort((x, y) => y - x);
53
+ return (h + 0.05) / (l + 0.05);
54
+ };
55
+ const MIN_TEXT_CONTRAST = 4.5;
56
+ // The card surfaces of visual-system.md §1, which the link colour must read on.
57
+ const LIGHT_CARD = "#ffffff";
58
+ const DARK_CARD = "#141927";
59
+
60
+ /** The colour mixed step by step towards `toward` until it reads at 4.5:1 on `surface`; the input when it already does. */
61
+ function readable(hex, toward, surface) {
62
+ let c = hex;
63
+ for (let i = 0; i < 20 && contrast(c, surface) < MIN_TEXT_CONTRAST; i++) c = mix(c, toward, 0.1);
64
+ return c;
65
+ }
66
+
67
+ /**
68
+ * CSS that recolors the report's accent: the active tab rule, focus rings, links and the step plates (visual-system.md §9).
69
+ * The light theme gets a link colour darkened until it reads on a white card, the dark theme one lightened until it
70
+ * reads on the dark card, so a pale brand colour never gives unreadable links. The dark set is written under both dark
71
+ * selectors the report uses, so a forced light theme on a dark OS keeps the light accent.
72
+ */
47
73
  export function brandCss(color) {
48
74
  if (!color) return "";
49
75
  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}}}`;
76
+ const light = `--accent:${color};--accent-fg:${readable(mix(color, black, 0.2), black, LIGHT_CARD)};--accent-bg:${mix(color, white, 0.9)}`;
77
+ const dark = `--accent:${mix(color, white, 0.2)};--accent-fg:${readable(mix(color, white, 0.5), white, DARK_CARD)};--accent-bg:${mix(color, [10, 14, 26], 0.75)}`;
78
+ return `:root{${light}}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${dark}}}:root[data-theme="dark"]{${dark}}`;
53
79
  }
54
80
 
55
- /** Prints an HTML report to a PDF with Chromium. The print styles open every tab, so the PDF holds the whole report. */
81
+ // A forwarded PDF loses its file name, so every page carries the document title and the page number (Chromium fills the spans).
82
+ const PDF_FOOTER = '<div style="width:100%;font:9px system-ui,sans-serif;color:#4b5567;padding:0 10mm;display:flex;justify-content:space-between"><span class="title"></span><span><span class="pageNumber"></span> / <span class="totalPages"></span></span></div>';
83
+
84
+ /** Prints an HTML report to a PDF with Chromium. The print styles stack every tab and open every details, so the PDF holds the whole report. */
56
85
  export async function writePdf(htmlPath, pdfPath) {
57
86
  const launch = { headless: true, env: browserEnv() };
58
87
  let browser;
@@ -69,7 +98,7 @@ export async function writePdf(htmlPath, pdfPath) {
69
98
  const page = await context.newPage();
70
99
  await page.goto(new URL(`file://${path.resolve(htmlPath)}`).href);
71
100
  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" } });
101
+ await page.pdf({ path: pdfPath, format: "A4", printBackground: true, displayHeaderFooter: true, headerTemplate: "<span></span>", footerTemplate: PDF_FOOTER, margin: { top: "14mm", bottom: "16mm", left: "10mm", right: "10mm" } });
73
102
  } finally {
74
103
  await browser.close();
75
104
  }