@analitikisler/ai-tag-debugger 0.9.0 → 0.11.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 +27 -0
- package/README.md +195 -46
- package/README.tr.md +334 -185
- package/package.json +5 -2
- package/src/agent.js +1 -1
- package/src/ask.js +123 -0
- package/src/audit.js +35 -13
- package/src/changes.js +66 -0
- package/src/checks.js +56 -1
- package/src/cli.js +176 -74
- package/src/discover.js +185 -0
- package/src/help.js +107 -0
- package/src/i18n.js +24 -0
- package/src/journeys.js +87 -8
- package/src/mcp.js +105 -11
- package/src/plan-html.js +76 -0
- package/src/plan.js +14 -0
- package/src/providers.js +1 -1
- package/src/report.js +59 -10
- package/src/sheet.js +321 -0
- package/src/workspace.js +77 -0
package/src/i18n.js
CHANGED
|
@@ -85,6 +85,18 @@ 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
|
+
changesTitle: "Changed since last run",
|
|
91
|
+
changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
|
|
92
|
+
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
|
|
93
|
+
changeFixed: "Fixed",
|
|
94
|
+
changeNew: "New",
|
|
95
|
+
noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
|
|
96
|
+
planVsSiteLead: "Each planned event, and what the site actually sent",
|
|
97
|
+
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` : ""}`,
|
|
98
|
+
bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
|
|
99
|
+
bucketCol: "Result",
|
|
88
100
|
summaryLead: "AI summary, most important first",
|
|
89
101
|
noSummary: "Run without --no-ai for an AI summary of the findings.",
|
|
90
102
|
seeAllFindings: "See all findings →",
|
|
@@ -232,6 +244,18 @@ const tr = {
|
|
|
232
244
|
tabScope: "Kapsam ve plan",
|
|
233
245
|
sections: "Rapor bölümleri",
|
|
234
246
|
headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
|
|
247
|
+
checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
|
|
248
|
+
planVsSite: "Plan ve site",
|
|
249
|
+
changesTitle: "Son çalıştırmadan bu yana değişenler",
|
|
250
|
+
changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
|
|
251
|
+
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
|
|
252
|
+
changeFixed: "Düzeldi",
|
|
253
|
+
changeNew: "Yeni",
|
|
254
|
+
noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
|
|
255
|
+
planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
|
|
256
|
+
coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
|
|
257
|
+
bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
|
|
258
|
+
bucketCol: "Sonuç",
|
|
235
259
|
summaryLead: "Yapay zekâ özeti, öncelik sırasıyla",
|
|
236
260
|
noSummary: "Bulguların yapay zekâ özeti için --no-ai olmadan çalıştırın.",
|
|
237
261
|
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,10 @@ 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";
|
|
24
|
+
import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
25
|
+
import { createProvider } from "./providers.js";
|
|
26
|
+
import { savePlan } from "./plan.js";
|
|
23
27
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
24
28
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
25
29
|
|
|
@@ -31,9 +35,11 @@ const MIN_TOKEN_LENGTH = 16;
|
|
|
31
35
|
|
|
32
36
|
const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracking (GA4, Google Ads, Meta Pixel, TikTok Pixel, Consent Mode).
|
|
33
37
|
|
|
38
|
+
Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
|
|
39
|
+
|
|
34
40
|
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
41
|
|
|
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
|
|
42
|
+
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
43
|
|
|
38
44
|
While browsing:
|
|
39
45
|
${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
|
|
@@ -100,11 +106,29 @@ async function readPlan(plan, allowFiles) {
|
|
|
100
106
|
return readJson(trimmed);
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/** finish_audit refuses a bad plan without closing the audit, so the captured run is not lost. */
|
|
110
|
+
const STILL_OPEN = "The audit is still open: call finish_audit again with a corrected plan (or none).";
|
|
111
|
+
|
|
112
|
+
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>`.";
|
|
113
|
+
|
|
114
|
+
/** Finds the saved plan named by `against`. Over HTTP only latest, first and plan IDs are allowed, never paths. */
|
|
115
|
+
async function loadAgainst(ref, site, opts) {
|
|
116
|
+
if (!opts.allowFiles && !/^(latest|last|first|\d{4}-\d{2}-\d{2}_\d{4}(\d{2})?)$/.test(ref.trim())) {
|
|
117
|
+
throw new Error("Over HTTP, against takes latest, first or a plan ID, not a path.");
|
|
118
|
+
}
|
|
119
|
+
const found = await resolvePlan(ref, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site });
|
|
120
|
+
const plan = await readJson(found.file);
|
|
121
|
+
if (!Array.isArray(plan?.events)) throw new Error(`${found.file} is not a plan: it needs an "events" list.`);
|
|
122
|
+
return { found, plan };
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
/**
|
|
104
|
-
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string }} opts
|
|
126
|
+
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string, dataDir?: string }} opts
|
|
105
127
|
* allowFiles: local (stdio) servers may read config and plan files; HTTP servers may not.
|
|
106
128
|
* blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
|
|
129
|
+
* dataDir: where saved plans live (default ./aitd), used by `against`.
|
|
107
130
|
* reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
|
|
131
|
+
* provider: an AI provider for make_plan (default: the one configured on this computer; tests pass a fake).
|
|
108
132
|
*/
|
|
109
133
|
export function buildServer(opts) {
|
|
110
134
|
const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
@@ -214,7 +238,7 @@ export function buildServer(opts) {
|
|
|
214
238
|
{
|
|
215
239
|
title: "Finish and check the audit",
|
|
216
240
|
description:
|
|
217
|
-
"Closes the browser, checks every captured hit against the
|
|
241
|
+
"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
242
|
inputSchema: {
|
|
219
243
|
audit_id: z.string(),
|
|
220
244
|
completed: z.boolean().describe("Whether the journey reached its goal."),
|
|
@@ -223,21 +247,28 @@ export function buildServer(opts) {
|
|
|
223
247
|
.string()
|
|
224
248
|
.optional()
|
|
225
249
|
.describe(
|
|
226
|
-
'The
|
|
250
|
+
'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
251
|
),
|
|
252
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from this audit. Use either against or measurement_plan, not both.`),
|
|
228
253
|
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
254
|
language: z.enum(Object.keys(LANGUAGES)).optional().describe("Report language. Default en."),
|
|
230
255
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
231
256
|
},
|
|
232
257
|
},
|
|
233
|
-
async ({ audit_id, completed, note, measurement_plan, events = [], language, branding }) => {
|
|
258
|
+
async ({ audit_id, completed, note, measurement_plan, against, events = [], language, branding }) => {
|
|
234
259
|
const audit = getAudit(audit_id);
|
|
260
|
+
if (against && measurement_plan) return fail(`Use either against or measurement_plan, not both. ${STILL_OPEN}`);
|
|
235
261
|
let plan;
|
|
262
|
+
let againstPlan = null;
|
|
236
263
|
try {
|
|
237
|
-
|
|
264
|
+
if (against) {
|
|
265
|
+
const loaded = await loadAgainst(against, audit.site, opts);
|
|
266
|
+
plan = loaded.plan;
|
|
267
|
+
againstPlan = { ...loaded.found, label: loaded.found.id ?? path.relative(process.cwd(), loaded.found.file) };
|
|
268
|
+
} else plan = await readPlan(measurement_plan, opts.allowFiles);
|
|
238
269
|
if (!Array.isArray(plan.events)) throw new Error('The plan needs an "events" list.');
|
|
239
270
|
} catch (err) {
|
|
240
|
-
return fail(`Could not read the
|
|
271
|
+
return fail(`Could not read the plan: ${err.message} ${STILL_OPEN}`);
|
|
241
272
|
}
|
|
242
273
|
if (audit.preset) plan = withPresetEvents(plan, audit.journey, audit.preset);
|
|
243
274
|
else plan = expectEvents(plan, audit.journey, events);
|
|
@@ -251,10 +282,11 @@ export function buildServer(opts) {
|
|
|
251
282
|
const grouped = groupFindings(findings);
|
|
252
283
|
const counts = summarize(grouped);
|
|
253
284
|
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 });
|
|
285
|
+
const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
|
|
255
286
|
const m = messages(lang);
|
|
256
287
|
const lines = [
|
|
257
288
|
m.ui.counts(counts),
|
|
289
|
+
...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
|
|
258
290
|
"",
|
|
259
291
|
...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
292
|
"",
|
|
@@ -272,15 +304,63 @@ export function buildServer(opts) {
|
|
|
272
304
|
},
|
|
273
305
|
);
|
|
274
306
|
|
|
307
|
+
server.registerTool(
|
|
308
|
+
"make_plan",
|
|
309
|
+
{
|
|
310
|
+
title: "Draft a tracking plan",
|
|
311
|
+
description:
|
|
312
|
+
"Explores a website and drafts its measurement plan: the events, parameters and user properties it should track. Same as `aitd plan --site`. The plan is saved (use its ID as against in finish_audit to check an audit against it) and the events are returned. Uses the AI provider configured on the computer running this server (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) and takes a few minutes. To see what the site sends today, run an audit instead.",
|
|
313
|
+
inputSchema: {
|
|
314
|
+
site: z.string().describe("The site to plan, e.g. https://www.example.com/"),
|
|
315
|
+
topic: z.enum(Object.keys(TOPICS)).optional().describe(`Limit the plan to one kind of event: ${Object.keys(TOPICS).join(", ")}. Default: the whole plan.`),
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
async ({ site, topic }) => {
|
|
319
|
+
let url;
|
|
320
|
+
try {
|
|
321
|
+
url = new URL(site);
|
|
322
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
323
|
+
} catch {
|
|
324
|
+
return fail("site must be an http or https URL.");
|
|
325
|
+
}
|
|
326
|
+
if (opts.blockPrivate) {
|
|
327
|
+
const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
|
|
328
|
+
if (refused) return fail(`${refused} Over HTTP, plans are limited to public websites.`);
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
checkTopic(topic);
|
|
332
|
+
const provider = opts.provider ?? (await createProvider());
|
|
333
|
+
const { plan } = await discoverPlan({ site: url.href, topic, provider, blockPrivate: opts.blockPrivate });
|
|
334
|
+
const { id, dir } = await savePlan(plan, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site: url.href });
|
|
335
|
+
const custom = plan.events.filter((e) => e.custom).length;
|
|
336
|
+
return text(
|
|
337
|
+
[
|
|
338
|
+
`Saved plan ${id}${topic ? ` (${topic} only)` : ""}: ${plan.events.length} events (${custom} custom) and ${plan.user_properties.length} user properties${opts.allowFiles ? ` in ${dir}` : ""}.`,
|
|
339
|
+
"",
|
|
340
|
+
...plan.events.map((e) => `- ${e.platform} ${e.name}${e.custom ? " (custom)" : ""}${e.trigger?.page ? `, on ${e.trigger.page}` : ""}${e.required_params?.length ? `, params: ${e.required_params.join(", ")}` : ""}${e.description ? `: ${e.description}` : ""}`),
|
|
341
|
+
...(plan.user_properties.length ? ["", `User properties: ${plan.user_properties.map((p) => p.name).join(", ")}`] : []),
|
|
342
|
+
"",
|
|
343
|
+
`Check a site against it with an audit: finish_audit with against "${id}".`,
|
|
344
|
+
].join("\n"),
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// The CLI wording (--no-ai) means nothing to a chat app, so say it in this tool's terms.
|
|
348
|
+
if (err.code === "NO_API_KEY") return fail("No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so it can't draft a plan. Set one and restart the server, or write the plan yourself from what an audit shows and pass it to finish_audit as measurement_plan.");
|
|
349
|
+
return fail(`Could not draft the plan: ${err.message}`);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
275
354
|
if (opts.allowFiles) {
|
|
276
355
|
server.registerTool(
|
|
277
356
|
"run_audit",
|
|
278
357
|
{
|
|
279
358
|
title: "Run a configured audit",
|
|
280
359
|
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).
|
|
360
|
+
"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
361
|
inputSchema: {
|
|
283
362
|
config: z.string().describe("Path to the config file, e.g. ai-tag-debugger.config.json"),
|
|
363
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from the config.`),
|
|
284
364
|
scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
|
|
285
365
|
language: z.enum(Object.keys(LANGUAGES)).optional(),
|
|
286
366
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
@@ -290,12 +370,26 @@ export function buildServer(opts) {
|
|
|
290
370
|
.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
371
|
},
|
|
292
372
|
},
|
|
293
|
-
async ({ config: configFile, scripted, language, branding, providers }) => {
|
|
373
|
+
async ({ config: configFile, against, scripted, language, branding, providers }) => {
|
|
294
374
|
const loaded = await loadConfig(configFile);
|
|
375
|
+
let againstPlan = null;
|
|
376
|
+
if (against) {
|
|
377
|
+
try {
|
|
378
|
+
const found = await loadAgainst(against, loaded.config.site, opts);
|
|
379
|
+
if (!loaded.config.site) {
|
|
380
|
+
if (!found.plan.site) throw new Error(`${found.found.file} has no "site" and the config has none either.`);
|
|
381
|
+
loaded.config = { ...loaded.config, site: found.plan.site };
|
|
382
|
+
}
|
|
383
|
+
loaded.plan = found.plan;
|
|
384
|
+
againstPlan = found.found;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return fail(`Could not read the plan: ${err.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
295
389
|
const replay = scripted !== false;
|
|
296
390
|
let result;
|
|
297
391
|
try {
|
|
298
|
-
result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
|
|
392
|
+
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
393
|
} catch (err) {
|
|
300
394
|
// The CLI wording (--scripted --no-ai) means nothing to a chat app, so say it in this tool's terms.
|
|
301
395
|
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,13 @@ 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, changes = 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 (changes) lines.push(...changesMd(m, changes));
|
|
158
|
+
if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
|
|
157
159
|
const sum = summaryParts(explained?.summary);
|
|
158
160
|
if (sum) {
|
|
159
161
|
lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
|
|
@@ -193,7 +195,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
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());
|
|
198
|
+
export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
197
199
|
const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
|
|
198
200
|
|
|
199
201
|
// 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 +457,7 @@ const sevIcon = (sev) => ICON[sev === "broken" ? "broken" : sev === "info" ? "in
|
|
|
455
457
|
const sec = (name, label) => `<div class="sec-h">${ic(ICON[name], 14)}<span>${label}</span></div>`;
|
|
456
458
|
const badge = (sev, text) => `<span class="badge ${sev}">${escapeHtml(text)}</span>`;
|
|
457
459
|
/** 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>`;
|
|
460
|
+
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
461
|
|
|
460
462
|
/** 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
463
|
function whereTarget(runs, w, f) {
|
|
@@ -610,6 +612,52 @@ ${provs.length ? `<div class="card"><div class="card-h"><div class="card-t">${m.
|
|
|
610
612
|
}
|
|
611
613
|
|
|
612
614
|
/** Each planned event × each journey: sent, not sent, or not expected there (or its provider not checked). */
|
|
615
|
+
const BUCKET_SEV = { implemented: "ok", missing: "broken", missing_params: "broken", wrong_values: "warning", unplanned: "info", not_checked: "muted" };
|
|
616
|
+
const coverageLines = (m, pc) => [
|
|
617
|
+
m.ui.coverageLine(pc.coverage),
|
|
618
|
+
...sortPlatforms(Object.keys(pc.coverage.platforms)).map((p) => `${platformLabel(p)}: ${m.ui.coverageLine(pc.coverage.platforms[p])}`),
|
|
619
|
+
];
|
|
620
|
+
const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
|
|
621
|
+
const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
|
|
622
|
+
|
|
623
|
+
/** "Changed since last run": one headline, then what was fixed and what is new. */
|
|
624
|
+
const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
|
|
625
|
+
const changeItems = (m, ch) => [
|
|
626
|
+
...ch.fixed.map((c) => ({ sev: "ok", tag: m.ui.changeFixed, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.was]})` })),
|
|
627
|
+
...ch.new.map((c) => ({ sev: "broken", tag: m.ui.changeNew, text: `${changeLabel(c)} (${ch.mode === "findings" ? c.detail : m.ui.bucket[c.bucket]})` })),
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
function changesMd(m, ch) {
|
|
631
|
+
const lines = [`## ${m.ui.changesTitle}`, ""];
|
|
632
|
+
if (!ch.previous) return [...lines, m.ui.noPreviousRun, ""];
|
|
633
|
+
lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
|
|
634
|
+
const items = changeItems(m, ch);
|
|
635
|
+
if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
|
|
636
|
+
return lines;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function changesCard(m, ch) {
|
|
640
|
+
const head = `<div class="card-h"><div class="card-t">${m.ui.changesTitle}</div>${ch.previous ? `<div class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</div>` : ""}</div>`;
|
|
641
|
+
if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
|
|
642
|
+
const items = changeItems(m, ch);
|
|
643
|
+
return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}</div></div>`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function planVsSiteMd(m, pc) {
|
|
647
|
+
const [overall, ...perPlatform] = coverageLines(m, pc);
|
|
648
|
+
const lines = [`## ${m.ui.planVsSite}`, "", `**${mdText(overall)}**`, "", ...perPlatform.map((l) => `- ${mdText(l)}`), ...(perPlatform.length ? [""] : [])];
|
|
649
|
+
lines.push(`| ${m.ui.event} | ${m.ui.bucketCol} | |`, "|---|---|---|");
|
|
650
|
+
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, "\\|")} |`);
|
|
651
|
+
lines.push("");
|
|
652
|
+
return lines;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function planVsSiteCard(m, pc) {
|
|
656
|
+
const [overall, ...perPlatform] = coverageLines(m, pc);
|
|
657
|
+
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("");
|
|
658
|
+
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>`;
|
|
659
|
+
}
|
|
660
|
+
|
|
613
661
|
function coverageCard(m, plan, runs) {
|
|
614
662
|
const head = `<div class="card-h"><div class="card-t">${m.ui.planCoverage}</div><div class="card-d">${m.ui.planCoverageLead}</div></div>`;
|
|
615
663
|
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 +727,7 @@ function navCss(runs) {
|
|
|
679
727
|
return out.join("\n");
|
|
680
728
|
}
|
|
681
729
|
|
|
682
|
-
const CSS = `
|
|
730
|
+
export const CSS = `
|
|
683
731
|
:root{
|
|
684
732
|
--bg:#FFFFFF;--page:#F7F9FC;--fg:#0A0E1A;--muted:#5A6480;--muted-bg:#F1F4F9;--border:#E3E7EF;--input:#E3E7EF;
|
|
685
733
|
--accent:#4F8EF7;--accent-fg:#2F6FDB;--accent-bg:#EBF1FE;--orange:#FF6D33;
|
|
@@ -754,6 +802,7 @@ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
|
|
|
754
802
|
.fsr{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
|
|
755
803
|
.sevfilter label{padding:4px 10px;border-radius:4px;font-weight:500;font-size:12px;color:var(--muted);cursor:pointer}
|
|
756
804
|
.filter-host{position:relative}
|
|
805
|
+
.changes{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px}
|
|
757
806
|
.flist{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
|
758
807
|
.finding{--sev:var(--warn-dot);--sev-fg:var(--warn);--sev-bg:var(--warn-bg);background:var(--bg);border:1px solid var(--border);border-left:4px solid var(--sev);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
|
759
808
|
.finding.sev-broken{--sev:var(--danger-dot);--sev-fg:var(--danger);--sev-bg:var(--danger-bg)}
|
|
@@ -916,7 +965,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
|
|
|
916
965
|
@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
966
|
`;
|
|
918
967
|
|
|
919
|
-
export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true }) {
|
|
968
|
+
export function renderHtml({ title, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
|
|
920
969
|
const m = messages(lang);
|
|
921
970
|
const rows = attachExplanations(findings, explained);
|
|
922
971
|
const counts = summarize(rows);
|
|
@@ -938,12 +987,12 @@ ${navCss(runs)}</style></head><body>
|
|
|
938
987
|
<header class="wrap title">
|
|
939
988
|
<div class="eyebrow">${m.ui.eyebrow}</div>
|
|
940
989
|
<h1>${escapeHtml(title)}</h1>
|
|
941
|
-
<div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div
|
|
990
|
+
<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
991
|
</header>
|
|
943
992
|
<nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
|
|
944
993
|
<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
994
|
<main class="wrap">
|
|
946
|
-
<section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${overviewTab(m, findings, rows, counts, explained, runs)}</section>
|
|
995
|
+
<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)}</section>
|
|
947
996
|
<section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows)}</section>
|
|
948
997
|
<section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
|
|
949
998
|
<section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
|