@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/CHANGELOG.md +17 -0
- package/README.md +61 -6
- package/README.tr.md +63 -6
- package/package.json +1 -1
- package/src/agent.js +16 -4
- package/src/audit.js +26 -9
- package/src/browser.js +11 -1
- package/src/campaign.js +28 -0
- package/src/changes.js +91 -3
- package/src/checks.js +28 -0
- package/src/cli.js +22 -0
- package/src/consent.js +154 -0
- package/src/help.js +2 -2
- package/src/i18n.js +40 -0
- package/src/inventory.js +69 -0
- package/src/learn.js +34 -0
- package/src/mcp.js +24 -1
- package/src/notify.js +108 -0
- package/src/providers.js +35 -12
- package/src/redact.js +1 -1
- package/src/report.js +60 -9
- package/src/sheet.js +8 -5
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
|
74
|
+
return { ...openaiProvider(client, pickModel("vercel", opts.model), { compatible: true }), name: "vercel" };
|
|
71
75
|
}
|
|
72
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
562
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|