@analitikisler/ai-tag-debugger 0.9.0 → 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.
- package/CHANGELOG.md +20 -0
- package/README.md +158 -46
- package/README.tr.md +297 -185
- package/package.json +5 -2
- package/src/agent.js +1 -1
- package/src/audit.js +27 -13
- package/src/checks.js +56 -1
- package/src/cli.js +104 -73
- package/src/discover.js +148 -0
- package/src/help.js +104 -0
- package/src/i18n.js +12 -0
- package/src/journeys.js +87 -8
- package/src/mcp.js +52 -11
- package/src/plan-html.js +76 -0
- package/src/plan.js +14 -0
- package/src/providers.js +1 -1
- package/src/report.js +34 -10
- package/src/sheet.js +321 -0
- package/src/workspace.js +77 -0
package/src/help.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { LANGUAGES } from "./i18n.js";
|
|
2
|
+
import { PROVIDERS } from "./providers.js";
|
|
3
|
+
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
4
|
+
|
|
5
|
+
const journeys = Object.keys(JOURNEY_PRESETS).join(", ");
|
|
6
|
+
|
|
7
|
+
const MAIN = `aitd: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
8
|
+
|
|
9
|
+
Usage: aitd <command> [options]
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
plan Draft a measurement plan: what should be tracked
|
|
13
|
+
run Audit what a site actually sends, and compare it to a plan
|
|
14
|
+
replay Re-check saved sites offline, without AI
|
|
15
|
+
mcp Start the MCP server for Claude, ChatGPT and other chat apps
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
aitd plan --site https://example.com
|
|
19
|
+
aitd run --site https://example.com --against latest
|
|
20
|
+
aitd run --site https://example.com --journey purchase
|
|
21
|
+
|
|
22
|
+
Run "aitd <command> --help" for that command's options. -h, --help -v, --version`;
|
|
23
|
+
|
|
24
|
+
const RUN = `Usage: aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>")
|
|
25
|
+
|
|
26
|
+
Audit what a site actually sends while an AI agent walks its journeys. Needs an AI key
|
|
27
|
+
(${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai.
|
|
28
|
+
|
|
29
|
+
What to test (at least one):
|
|
30
|
+
--against <plan> Check against a plan: latest, first, a plan ID or a plan file or folder
|
|
31
|
+
--journey <id> A ready-made journey: ${journeys} (repeatable)
|
|
32
|
+
--goal "<text>" A journey in plain language (repeatable)
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--site <url> The site to audit
|
|
36
|
+
--config <file> Take the site and journeys from a config file
|
|
37
|
+
--events <names> Only check these events, e.g. purchase,add_to_cart
|
|
38
|
+
--lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
|
|
39
|
+
--headed Show the browser window
|
|
40
|
+
--fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
|
|
41
|
+
|
|
42
|
+
AI: --provider <name> --model <id> (default: whichever API key is set)
|
|
43
|
+
|
|
44
|
+
Advanced:
|
|
45
|
+
--max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
|
|
46
|
+
--scripted --no-ai --save-site <dir> --save-steps <file> (see the README)`;
|
|
47
|
+
|
|
48
|
+
const PLAN = `Usage: aitd plan --site <url>
|
|
49
|
+
aitd plan list --site <url>
|
|
50
|
+
aitd plan import <file> --site <url>
|
|
51
|
+
aitd plan export <plan> --site <url>
|
|
52
|
+
|
|
53
|
+
Draft a measurement plan: what should be tracked. The AI explores one page of each type
|
|
54
|
+
and proposes events, parameters and user properties. Plans are saved in
|
|
55
|
+
aitd/<domain>/plans/<id>/. Needs an AI key (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}).
|
|
56
|
+
|
|
57
|
+
Commands:
|
|
58
|
+
(none) Draft a new plan from the site
|
|
59
|
+
list List the site's plan IDs, oldest first
|
|
60
|
+
import <file> Save a plan from a spreadsheet (xlsx or csv) as a new plan
|
|
61
|
+
export <plan> Write a plan to a spreadsheet to edit; <plan> is latest, first, an ID or a path
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--site <url> The site (required)
|
|
65
|
+
--headed Show the browser window
|
|
66
|
+
|
|
67
|
+
AI: --provider <name> --model <id> (default: whichever API key is set)
|
|
68
|
+
|
|
69
|
+
Advanced:
|
|
70
|
+
--max-steps <n> --data-dir <dir> --format <xlsx|csv> (see the README)`;
|
|
71
|
+
|
|
72
|
+
const REPLAY = `Usage: aitd replay <dir>... [--update]
|
|
73
|
+
|
|
74
|
+
Audit sites saved with "aitd run --save-site <dir>" again, offline and without AI, and
|
|
75
|
+
list findings that changed. Exits with code 1 when any did.
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--update Make this replay's findings the expected ones (check the changes first)
|
|
79
|
+
|
|
80
|
+
Advanced:
|
|
81
|
+
--out <dir> Where to write the reports (default: <dir>/last-replay)`;
|
|
82
|
+
|
|
83
|
+
const MCP = `Usage: aitd mcp [--http] [--port <n>]
|
|
84
|
+
|
|
85
|
+
Start the MCP server for Claude, ChatGPT, Gemini and other chat apps. It talks over
|
|
86
|
+
stdio unless you pass --http.
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--http Serve over Streamable HTTP, for apps that connect to a URL
|
|
90
|
+
--port <n> HTTP port (default 8787)
|
|
91
|
+
|
|
92
|
+
Advanced:
|
|
93
|
+
--host <addr> HTTP host (default 127.0.0.1)
|
|
94
|
+
--token <secret> Secret URL path segment, at least 16 characters (default: random)
|
|
95
|
+
--allow-private-network Over HTTP, let audits open localhost and private addresses
|
|
96
|
+
--reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
|
|
97
|
+
--public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
|
|
98
|
+
|
|
99
|
+
const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP };
|
|
100
|
+
|
|
101
|
+
/** Help text for a command, or the top-level help when the command is unknown or missing. */
|
|
102
|
+
export function helpText(command) {
|
|
103
|
+
return HELP[command] ?? MAIN;
|
|
104
|
+
}
|
package/src/i18n.js
CHANGED
|
@@ -85,6 +85,12 @@ const en = {
|
|
|
85
85
|
tabScope: "Scope and plan",
|
|
86
86
|
sections: "Report sections",
|
|
87
87
|
headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
|
|
88
|
+
checkedAgainst: (id) => `Checked against plan ${id}`,
|
|
89
|
+
planVsSite: "Plan vs. site",
|
|
90
|
+
planVsSiteLead: "Each planned event, and what the site actually sent",
|
|
91
|
+
coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
|
|
92
|
+
bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
|
|
93
|
+
bucketCol: "Result",
|
|
88
94
|
summaryLead: "AI summary, most important first",
|
|
89
95
|
noSummary: "Run without --no-ai for an AI summary of the findings.",
|
|
90
96
|
seeAllFindings: "See all findings →",
|
|
@@ -232,6 +238,12 @@ const tr = {
|
|
|
232
238
|
tabScope: "Kapsam ve plan",
|
|
233
239
|
sections: "Rapor bölümleri",
|
|
234
240
|
headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
|
|
241
|
+
checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
|
|
242
|
+
planVsSite: "Plan ve site",
|
|
243
|
+
planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
|
|
244
|
+
coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
|
|
245
|
+
bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
|
|
246
|
+
bucketCol: "Sonuç",
|
|
235
247
|
summaryLead: "Yapay zekâ özeti, öncelik sırasıyla",
|
|
236
248
|
noSummary: "Bulguların yapay zekâ özeti için --no-ai olmadan çalıştırın.",
|
|
237
249
|
seeAllFindings: "Tüm bulguları gör →",
|
package/src/journeys.js
CHANGED
|
@@ -64,11 +64,13 @@ export function presetFor(name) {
|
|
|
64
64
|
* Adds a journey's preset events to a plan (a copy): events the plan already
|
|
65
65
|
* has are expected on this journey too; missing ones are added for this journey.
|
|
66
66
|
*/
|
|
67
|
-
export function withPresetEvents(plan, journeyId, preset) {
|
|
67
|
+
export function withPresetEvents(plan, journeyId, preset, { addMissing = true } = {}) {
|
|
68
68
|
const events = plan.events.map((e) => ({ ...e }));
|
|
69
69
|
for (const pe of preset.events) {
|
|
70
|
-
const existing = events.find((e) => e.platform === pe.platform && e.name === pe.name);
|
|
71
|
-
if (!existing)
|
|
70
|
+
const existing = events.find((e) => String(e.platform).toLowerCase() === pe.platform && e.name === pe.name);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
if (addMissing) events.push({ ...pe, journeys: [journeyId] });
|
|
73
|
+
}
|
|
72
74
|
else if (existing.journeys && !existing.journeys.includes(journeyId)) existing.journeys = [...existing.journeys, journeyId];
|
|
73
75
|
}
|
|
74
76
|
return { ...plan, events };
|
|
@@ -80,10 +82,20 @@ export function withPresetEvents(plan, journeyId, preset) {
|
|
|
80
82
|
* picks: journey ids from the config or preset names. goals: journeys described in plain language.
|
|
81
83
|
* events: only check these event names.
|
|
82
84
|
*/
|
|
83
|
-
export function planRun({ config = {}, plan, picks = [], goals = [], events = [] }) {
|
|
84
|
-
// Copied, because the loops below add journeys to events.
|
|
85
|
-
let runPlan = { ...(plan ?? { events: [] }), events: (plan?.events ?? []).map((e) => ({ ...e })) };
|
|
85
|
+
export function planRun({ config = {}, plan, picks = [], goals = [], events = [], against = false }) {
|
|
86
86
|
const configJourneys = config.journeys ?? [];
|
|
87
|
+
// --against alone: the plan says what to walk.
|
|
88
|
+
if (against && !picks.length && !goals.length && !configJourneys.length) {
|
|
89
|
+
const derived = journeysFromPlan(plan);
|
|
90
|
+
return { journeys: derived.journeys, plan: onlyEvents(derived.plan, events) };
|
|
91
|
+
}
|
|
92
|
+
// --against with --journey or --goal: only plan events expected on those flows are checked;
|
|
93
|
+
// the rest are expected nowhere, so they are not checked rather than reported missing.
|
|
94
|
+
const scoped = against && (picks.length || goals.length);
|
|
95
|
+
let runPlan = {
|
|
96
|
+
...(plan ?? { events: [] }),
|
|
97
|
+
events: (plan?.events ?? []).map((e) => (scoped ? { ...e, journeys: (e.journeys ?? []).filter((j) => picks.includes(j)) } : { ...e })),
|
|
98
|
+
};
|
|
87
99
|
const journeys = [];
|
|
88
100
|
|
|
89
101
|
for (const pick of picks) {
|
|
@@ -99,13 +111,13 @@ export function planRun({ config = {}, plan, picks = [], goals = [], events = []
|
|
|
99
111
|
}
|
|
100
112
|
if (journeys.some((j) => j.id === preset.id)) continue;
|
|
101
113
|
journeys.push({ id: preset.id, goal: preset.goal });
|
|
102
|
-
runPlan = withPresetEvents(runPlan, preset.id, preset);
|
|
114
|
+
runPlan = withPresetEvents(runPlan, preset.id, preset, { addMissing: !scoped });
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
goals.forEach((goal, i) => {
|
|
106
118
|
const id = goals.length > 1 ? `custom-${i + 1}` : "custom";
|
|
107
119
|
journeys.push({ id, goal });
|
|
108
|
-
runPlan = expectEvents(runPlan, id, events);
|
|
120
|
+
runPlan = expectEvents(runPlan, id, scoped ? [...events, ...eventsNamedIn(goal, runPlan)] : events);
|
|
109
121
|
});
|
|
110
122
|
|
|
111
123
|
if (!picks.length && !goals.length) journeys.push(...configJourneys);
|
|
@@ -140,3 +152,70 @@ export function filterFindings(findings, events = []) {
|
|
|
140
152
|
const wanted = new Set(events.map((e) => e.toLowerCase()));
|
|
141
153
|
return findings.filter((f) => (f.event ? wanted.has(f.event.toLowerCase()) : f.check !== "unplanned_events"));
|
|
142
154
|
}
|
|
155
|
+
|
|
156
|
+
const words = (text) => String(text ?? "").toLowerCase().replace(/[_-]+/g, " ");
|
|
157
|
+
|
|
158
|
+
/** Plan events a plain-language goal mentions, by event name ("add to cart" finds add_to_cart) or "<trigger page> page". */
|
|
159
|
+
export function eventsNamedIn(goal, plan) {
|
|
160
|
+
const text = words(goal);
|
|
161
|
+
return plan.events
|
|
162
|
+
.filter((e) => text.includes(words(e.name)) || (e.trigger?.page && text.includes(`${words(e.trigger.page)} page`)))
|
|
163
|
+
.map((e) => e.name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Which ready-made journey covers a group of plan events, from the standard GA4 shop and lead events in it. */
|
|
167
|
+
const PRESET_BY_EVENT = [
|
|
168
|
+
["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
|
|
169
|
+
["add_to_cart", ["add_to_cart", "view_cart"]],
|
|
170
|
+
["sign_up", ["sign_up"]],
|
|
171
|
+
["lead", ["generate_lead"]],
|
|
172
|
+
["search", ["search", "view_search_results"]],
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
export const MAX_PLAN_JOURNEYS = 6;
|
|
176
|
+
|
|
177
|
+
const slug = (text) => words(text).trim().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "page";
|
|
178
|
+
|
|
179
|
+
function goalFor(page, events) {
|
|
180
|
+
const actions = [...new Set(events.map((e) => e.trigger?.element).filter(Boolean))];
|
|
181
|
+
const where = page ? `go to a ${page} page` : "browse the site";
|
|
182
|
+
const doing = actions.length ? ` and use ${actions.map((a) => `"${a}"`).join(", ")}` : "";
|
|
183
|
+
return `Accept cookies, ${where}${doing}. Never enter real payment details.`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Journeys for `run --against` used alone: the plan's events grouped by the page they are triggered on
|
|
188
|
+
* (trigger.page), at most six, each with one goal built from the plan's trigger text (standard shop events
|
|
189
|
+
* reuse the ready-made journeys) and the group's events expected on it. Returns the journeys and the plan
|
|
190
|
+
* with each event's "journeys" set.
|
|
191
|
+
*/
|
|
192
|
+
export function journeysFromPlan(plan, max = MAX_PLAN_JOURNEYS) {
|
|
193
|
+
const groups = new Map();
|
|
194
|
+
for (const e of plan?.events ?? []) {
|
|
195
|
+
const page = e.trigger?.page ? String(e.trigger.page).trim() : "";
|
|
196
|
+
if (!groups.has(page)) groups.set(page, []);
|
|
197
|
+
groups.get(page).push(e);
|
|
198
|
+
}
|
|
199
|
+
// The biggest groups get their own journey; the rest share the last one.
|
|
200
|
+
let list = [...groups].map(([page, evs]) => ({ page, events: evs })).sort((a, b) => b.events.length - a.events.length);
|
|
201
|
+
if (list.length > max) {
|
|
202
|
+
const rest = list.slice(max - 1);
|
|
203
|
+
list = [...list.slice(0, max - 1), { page: rest.map((g) => g.page).filter(Boolean).join(", "), events: rest.flatMap((g) => g.events), merged: rest }];
|
|
204
|
+
}
|
|
205
|
+
const journeys = [];
|
|
206
|
+
const ids = new Map();
|
|
207
|
+
for (const group of list) {
|
|
208
|
+
const names = new Set(group.events.map((e) => e.name));
|
|
209
|
+
const presetId = PRESET_BY_EVENT.find(([, evs]) => evs.some((n) => names.has(n)))?.[0];
|
|
210
|
+
const base = presetId && !journeys.some((j) => j.id === presetId) ? presetId : slug(group.page);
|
|
211
|
+
let id = base;
|
|
212
|
+
for (let n = 2; journeys.some((j) => j.id === id); n++) id = `${base}_${n}`;
|
|
213
|
+
const goal = presetId === id ? JOURNEY_PRESETS[presetId].goal : group.merged ? group.merged.map((g) => goalFor(g.page, g.events)).join(" Then ") : goalFor(group.page, group.events);
|
|
214
|
+
journeys.push({ id, goal, planEvents: group.events.map((e) => e.name) });
|
|
215
|
+
for (const e of group.events) ids.set(e, id);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
journeys,
|
|
219
|
+
plan: { ...plan, events: (plan?.events ?? []).map((e) => ({ ...e, journeys: [ids.get(e)] })) },
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -20,6 +20,7 @@ import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
|
|
|
20
20
|
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
21
21
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
22
22
|
import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
23
|
+
import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
|
|
23
24
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
24
25
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
25
26
|
|
|
@@ -33,7 +34,7 @@ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracki
|
|
|
33
34
|
|
|
34
35
|
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
|
|
35
36
|
|
|
36
|
-
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit with the
|
|
37
|
+
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
37
38
|
|
|
38
39
|
While browsing:
|
|
39
40
|
${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
|
|
@@ -100,10 +101,27 @@ async function readPlan(plan, allowFiles) {
|
|
|
100
101
|
return readJson(trimmed);
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** finish_audit refuses a bad plan without closing the audit, so the captured run is not lost. */
|
|
105
|
+
const STILL_OPEN = "The audit is still open: call finish_audit again with a corrected plan (or none).";
|
|
106
|
+
|
|
107
|
+
const AGAINST_DOC = "A saved plan to check against: latest (or last), first, a plan ID such as 2026-10-16_0627, or on a local server a path to a plan file or folder. Plans are the ones saved by `aitd plan --site <url>`.";
|
|
108
|
+
|
|
109
|
+
/** Finds the saved plan named by `against`. Over HTTP only latest, first and plan IDs are allowed, never paths. */
|
|
110
|
+
async function loadAgainst(ref, site, opts) {
|
|
111
|
+
if (!opts.allowFiles && !/^(latest|last|first|\d{4}-\d{2}-\d{2}_\d{4}(\d{2})?)$/.test(ref.trim())) {
|
|
112
|
+
throw new Error("Over HTTP, against takes latest, first or a plan ID, not a path.");
|
|
113
|
+
}
|
|
114
|
+
const found = await resolvePlan(ref, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site });
|
|
115
|
+
const plan = await readJson(found.file);
|
|
116
|
+
if (!Array.isArray(plan?.events)) throw new Error(`${found.file} is not a plan: it needs an "events" list.`);
|
|
117
|
+
return { found, plan };
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
/**
|
|
104
|
-
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string }} opts
|
|
121
|
+
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string, dataDir?: string }} opts
|
|
105
122
|
* allowFiles: local (stdio) servers may read config and plan files; HTTP servers may not.
|
|
106
123
|
* blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
|
|
124
|
+
* dataDir: where saved plans live (default ./aitd), used by `against`.
|
|
107
125
|
* reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
|
|
108
126
|
*/
|
|
109
127
|
export function buildServer(opts) {
|
|
@@ -214,7 +232,7 @@ export function buildServer(opts) {
|
|
|
214
232
|
{
|
|
215
233
|
title: "Finish and check the audit",
|
|
216
234
|
description:
|
|
217
|
-
"Closes the browser, checks every captured hit against the
|
|
235
|
+
"Closes the browser, checks every captured hit against the plan (missing events, missing parameters, duplicates, hits before consent, Consent Mode defaults, emails sent in plain text, unplanned events) and writes the report with the event timeline. Returns the findings. The plan is what should be tracked: pass a saved one with against, or write one inline with measurement_plan.",
|
|
218
236
|
inputSchema: {
|
|
219
237
|
audit_id: z.string(),
|
|
220
238
|
completed: z.boolean().describe("Whether the journey reached its goal."),
|
|
@@ -223,21 +241,28 @@ export function buildServer(opts) {
|
|
|
223
241
|
.string()
|
|
224
242
|
.optional()
|
|
225
243
|
.describe(
|
|
226
|
-
'The
|
|
244
|
+
'The plan (what should be tracked) as JSON text, e.g. {"consent_mode": true, "events": [{"platform": "ga4", "name": "purchase", "required_params": ["transaction_id", "value", "currency"]}]}. On a local server this can also be a file path. Without a plan, a ready-made journey checks its own events; otherwise only consent, duplicate and personal data checks run.',
|
|
227
245
|
),
|
|
246
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from this audit. Use either against or measurement_plan, not both.`),
|
|
228
247
|
events: z.array(z.string()).optional().describe("Only check these events, e.g. [\"purchase\", \"add_to_cart\"]. On your own journey, these events are also expected to fire."),
|
|
229
248
|
language: z.enum(Object.keys(LANGUAGES)).optional().describe("Report language. Default en."),
|
|
230
249
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
231
250
|
},
|
|
232
251
|
},
|
|
233
|
-
async ({ audit_id, completed, note, measurement_plan, events = [], language, branding }) => {
|
|
252
|
+
async ({ audit_id, completed, note, measurement_plan, against, events = [], language, branding }) => {
|
|
234
253
|
const audit = getAudit(audit_id);
|
|
254
|
+
if (against && measurement_plan) return fail(`Use either against or measurement_plan, not both. ${STILL_OPEN}`);
|
|
235
255
|
let plan;
|
|
256
|
+
let againstPlan = null;
|
|
236
257
|
try {
|
|
237
|
-
|
|
258
|
+
if (against) {
|
|
259
|
+
const loaded = await loadAgainst(against, audit.site, opts);
|
|
260
|
+
plan = loaded.plan;
|
|
261
|
+
againstPlan = { ...loaded.found, label: loaded.found.id ?? path.relative(process.cwd(), loaded.found.file) };
|
|
262
|
+
} else plan = await readPlan(measurement_plan, opts.allowFiles);
|
|
238
263
|
if (!Array.isArray(plan.events)) throw new Error('The plan needs an "events" list.');
|
|
239
264
|
} catch (err) {
|
|
240
|
-
return fail(`Could not read the
|
|
265
|
+
return fail(`Could not read the plan: ${err.message} ${STILL_OPEN}`);
|
|
241
266
|
}
|
|
242
267
|
if (audit.preset) plan = withPresetEvents(plan, audit.journey, audit.preset);
|
|
243
268
|
else plan = expectEvents(plan, audit.journey, events);
|
|
@@ -251,10 +276,11 @@ export function buildServer(opts) {
|
|
|
251
276
|
const grouped = groupFindings(findings);
|
|
252
277
|
const counts = summarize(grouped);
|
|
253
278
|
const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
|
|
254
|
-
const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, branding: branding !== false });
|
|
279
|
+
const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
|
|
255
280
|
const m = messages(lang);
|
|
256
281
|
const lines = [
|
|
257
282
|
m.ui.counts(counts),
|
|
283
|
+
...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
|
|
258
284
|
"",
|
|
259
285
|
...grouped.filter((f) => f.severity !== "ok").map((f) => `- ${m.severity[f.severity]}: ${f.detail}${f.evidence ? ` (captured: ${f.evidence})` : ""} [${f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; ")}]`),
|
|
260
286
|
"",
|
|
@@ -278,9 +304,10 @@ export function buildServer(opts) {
|
|
|
278
304
|
{
|
|
279
305
|
title: "Run a configured audit",
|
|
280
306
|
description:
|
|
281
|
-
"Runs every journey in a ai-tag-debugger config file. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY).
|
|
307
|
+
"Runs every journey in a ai-tag-debugger config file and writes the report. With against, the run is checked against a saved plan and, if the config has no journeys, walks the journeys derived from that plan. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY). Returns the findings.",
|
|
282
308
|
inputSchema: {
|
|
283
309
|
config: z.string().describe("Path to the config file, e.g. ai-tag-debugger.config.json"),
|
|
310
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from the config.`),
|
|
284
311
|
scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
|
|
285
312
|
language: z.enum(Object.keys(LANGUAGES)).optional(),
|
|
286
313
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
@@ -290,12 +317,26 @@ export function buildServer(opts) {
|
|
|
290
317
|
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
291
318
|
},
|
|
292
319
|
},
|
|
293
|
-
async ({ config: configFile, scripted, language, branding, providers }) => {
|
|
320
|
+
async ({ config: configFile, against, scripted, language, branding, providers }) => {
|
|
294
321
|
const loaded = await loadConfig(configFile);
|
|
322
|
+
let againstPlan = null;
|
|
323
|
+
if (against) {
|
|
324
|
+
try {
|
|
325
|
+
const found = await loadAgainst(against, loaded.config.site, opts);
|
|
326
|
+
if (!loaded.config.site) {
|
|
327
|
+
if (!found.plan.site) throw new Error(`${found.found.file} has no "site" and the config has none either.`);
|
|
328
|
+
loaded.config = { ...loaded.config, site: found.plan.site };
|
|
329
|
+
}
|
|
330
|
+
loaded.plan = found.plan;
|
|
331
|
+
againstPlan = found.found;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return fail(`Could not read the plan: ${err.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
295
336
|
const replay = scripted !== false;
|
|
296
337
|
let result;
|
|
297
338
|
try {
|
|
298
|
-
result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
|
|
339
|
+
result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, against: againstPlan, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
|
|
299
340
|
} catch (err) {
|
|
300
341
|
// The CLI wording (--scripted --no-ai) means nothing to a chat app, so say it in this tool's terms.
|
|
301
342
|
if (err.code !== "NO_API_KEY") throw err;
|
package/src/plan-html.js
ADDED
|
@@ -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
|
|
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/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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
123
|
+
export const escapeHtml = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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>
|