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