@getvitops/utils 8.3.0-alpha.0 → 8.3.0-alpha.2
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 +63 -0
- package/dist/audit/index.d.mts +25 -1
- package/dist/audit/index.d.mts.map +1 -1
- package/dist/audit/index.mjs +67 -4
- package/dist/audit/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +8 -0
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +11 -1
- package/dist/notify/index.mjs.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# @getvitops/utils
|
|
2
2
|
|
|
3
|
+
## 8.3.0-alpha.2
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0a47856: Build correctness and gaps from the second upstream round.
|
|
8
|
+
|
|
9
|
+
- **`@getvitops/emdash`: fixes a circular import that emptied the plugin block map in the static build.** `Section.astro` and `WidgetArea.astro` imported `emdash/ui` statically, which imports `virtual:emdash/block-components`, which is `@getvitops/emdash/astro` again. Depending on import order, every `vitops_*` block inside a section body was dropped from prerendered pages with only a `PortableText [components.type] is missing …` warning. Both now import it lazily, and a test fails if any renderer imports `emdash/ui` statically. No code change needed in sites.
|
|
10
|
+
- **`@getvitops/cli`: `vitops emdash build`** runs `astro build` and then `check-prerender`, and fails when the build reports a block type with no component (reported once per type). The template's `build`, `deploy` and `deploy:dev` scripts use it; existing sites replace `astro build && vitops emdash check-prerender` with `vitops emdash build`. `check-prerender` also warns (fails with `--strict`) on a page without exactly one `<h1>`.
|
|
11
|
+
- **`@getvitops/emdash`: `vitops_hero` and `vitops_section` heading level** accepts `h1`–`h6` and `p` (a heading that is styled but not in the outline). The hero defaults to `h1`; set the level to `2` on a page whose layout already renders an `h1` (the template's seed does).
|
|
12
|
+
- **`@getvitops/emdash`: `vitops_logos`** shows an entry's name as text (`.logos__name`) when it has no image. `@getvitops/core` adds the rule (all formats).
|
|
13
|
+
- **`@getvitops/emdash`: an empty content widget** (no text, no blocks) no longer renders its title, and an area whose widgets are all empty renders nothing.
|
|
14
|
+
- **`@getvitops/create`:** the template's `pages` collection gets `urlPattern: "/{slug}"` (the sitemap listed `/pages/<slug>`, which 404s; existing sites change it under Content Types), and `.gitignore` ignores only `src/styles/tailwind.css` and `tokens.json`, so site CSS kept in `src/styles/` is committed.
|
|
15
|
+
- **`@getvitops/emdash`: per-item colour.** The rows of `vitops_cards`, `vitops_stats`, `vitops_steps` and the hero's `cards` take their own `tone` / `color` (blank inherits the block's), so one block can hold a pine item and an amber one. Hero cards also take an `eyebrow` and a button (`ctaLabel` / `ctaHref`; with a button the card is no longer a stretched link).
|
|
16
|
+
- **`@getvitops/generator` / `@getvitops/emdash`: solid fills read.** The generator emits `--color-<hue>-<step>-on` for every step of every hue, the foreground for a fill at that step picked by the same APCA rule as `text-on-<role>` (css and tailwind formats, a plain `:root`, not `@theme`). A block fill chosen by `color` (hue and step) uses it instead of a fixed white..
|
|
17
|
+
- **`@getvitops/core`: `.counter__value.sr-only`** hides itself, so a build that never emitted the `.sr-only` utility no longer shows a counting figure twice (all formats).
|
|
18
|
+
- **`@getvitops/astro` / `@getvitops/core`: `SiteFooter` groups.** A top-level item of the `footer-sitemap` menu with children becomes a column (its label is the heading, its children the links, one level only); childless items stay in the flat list. Each column is a named `<nav>`.
|
|
19
|
+
- **`@getvitops/astro`: planned links and header buttons.** A menu item with `draft` in its CSS classes stays in the menu but is not rendered (at every level, in the header and the footer). `<SiteNav>` has an `actions` slot for header buttons that are not menu items, and an item with `cta` in its CSS classes renders as a button.
|
|
20
|
+
- **`@getvitops/cli`: `check-prerender` warnings** (fail with `--strict`): links to pages the build did not produce, a form with no delivery address (no `site.notifications.email`, location `email` or `site.crm`), and form fields missing from every `site.templates` form entry (the generated privacy policy reads those). It reads `./site.json`, or `--site=<path>`. `vitops setup … --dry-run` is accepted as `--dry`.
|
|
21
|
+
- **`@getvitops/emdash` / `@getvitops/astro`: "tick any number" form field** (`vitops_form` field kind `checkboxes`): a fieldset of checkboxes sharing one name. The conversion route joins repeated values (`IT, Marketing`) instead of keeping the last. The "required" toggle is not applied to the group (HTML cannot require one of several).
|
|
22
|
+
- **`@getvitops/core`: size hooks.** `.steps__title`, `.stats__figure` and `.card__title` read `--steps-title-size` (default one step below `heading`), `--stats-figure-size` and `--card-title-size`, set on the block through its `style` field. They set the `--heading-fs` / `--display-fs` properties the type-role utilities already read, so no extra class or prop is needed (all formats).
|
|
23
|
+
- **`@getvitops/create`:** the template's `site.json` lists its contact form under `site.templates`, so the privacy policy inventory and the new check agree.
|
|
24
|
+
- **`@getvitops/emdash`: `theme` on every block, on widget areas and on your own markup.** A `theme` select (`<role>` tinted, `<role>:solid` filled, populated from the site's roles by a new `themes/list` route) is a common field on every `vitops_*` block, applied to the block's own root with the role's computed readable text. `<VitopsWidgetArea theme="…">` takes the same, and `themeAttrs('surface')` gives `{ class, style }` for a layout `<body>`. `vitops_section`'s existing `theme` now goes through the same path.
|
|
25
|
+
- **`@getvitops/cli` / `@getvitops/emdash`: pages with a dropped block fail the build.** `vitops_section` and widget areas emit an invisible `<template data-vitops-unrendered="…">` for any body block with no renderer, and `check-prerender` fails the page naming the types (nested bodies only; `vitops emdash build` also scans the build log, which covers top-level bodies). A regression test imports the plugin entry first with a mock of the real virtual module, so the old import cycle fails it.
|
|
26
|
+
- **`@getvitops/emdash`: `formTemplateFromBlock(block)`** turns a saved `vitops_form` value into a `site.templates` form entry (names as submitted, a `checkboxes` field as a `checkbox` with options), to paste into `site.json` so the generated privacy policy lists what the form collects. The build cannot read the CMS database, so this is run by hand; `check-prerender` warns when fields are missing.
|
|
27
|
+
- **`@getvitops/utils`: `hasFormDelivery`** (`@getvitops/utils/notify`): the one test for "has this form anywhere to go", used by `check-prerender`.
|
|
28
|
+
- **`@getvitops/emdash`:** a `color` hue tint now flips with the colour scheme (`light-dark()`); hero option cards sit at `h2` under an `h1` hero; menu items also accept `hidden: true` (same as the `draft` class); `vitops docs blocks` documents `theme`, the per-row colours, the `--<role>-fs` type-size hook and the form checks.
|
|
29
|
+
- **`@getvitops/create`:** the template redirects `/home` to `/`, and the guides use `vp exec` instead of `vpx`.
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- @getvitops/config@8.3.0-alpha.2
|
|
34
|
+
|
|
35
|
+
## 8.3.0-alpha.1
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- 24c568b: Escape hatches on every block, a section theme, widget areas, a badges block, Tailwind scanning of package renderers, and a sturdier `vitops audit`.
|
|
40
|
+
|
|
41
|
+
**@getvitops/emdash**
|
|
42
|
+
|
|
43
|
+
- Every `vitops.*` block and `vitops_section` gains three fields: `anchor` (rendered as the `id`, so `#contact` works), `className` (`class_name` on the section type: block-type slugs are lowercase) and `style`. `style` takes CSS custom properties only (`--p-card: 2rem`); any other declaration, and any value containing `url(`, `@`, `{`, `}` or a backslash, is dropped. With the `tailwind` format a class typed in the CMS only works if the site already uses it. They are applied to the block's own outermost element (the grid `<ul>` for `vitops.cards`), never an extra wrapper, so a block's markup is the same whether or not an editor fills them in.
|
|
44
|
+
- `vitops_section` gains a `theme` select: a role, or `<role>:solid`, with readable text chosen by the generator's contrast rules. It overrides `tone`/`color`/`solid`, which stay for existing content. Other site roles still go through `tone`.
|
|
45
|
+
- New `vitops.badges` block (a row of `.badge` or `.tag` labels, each with an optional tone, icon and link) and `vitops.widgetArea` block, plus `<VitopsWidgetArea name level />` from `@getvitops/emdash/astro`. A widget area is content defined once under Widgets and shown wherever it is placed. An area cannot hold another area, and an area containing a `core:*` component widget is rendered by EmDash's own renderer.
|
|
46
|
+
- Existing sites: re-run the block-type seed only on a new database; on an existing one add the `theme`, `anchor`, `className` and `style` fields to the `vitops_section` block type in the admin.
|
|
47
|
+
|
|
48
|
+
**@getvitops/create**: the emdash template seeds `announcement` and `pre-footer` widget areas, renders them in `Layout.astro`, and documents when to use a Section, a block or a widget area. Sites scaffolded earlier add the two `<VitopsWidgetArea>` lines and the `widgetAreas` seed entries by hand.
|
|
49
|
+
|
|
50
|
+
**@getvitops/generator / astro / vite** (`tailwind` format only): the Astro integration now adds `@source` lines for `@getvitops/astro` and `@getvitops/emdash`, because Tailwind v4 does not scan `node_modules`; utilities used only by their renderers (`font-display`, `font-caption`, `font-footnote`) were never emitted and the page silently rendered at body size. New `sources` option on `generate()` and the Vite plugin. A site's `src/package-classes.txt` workaround can be deleted. css and bricks: no change.
|
|
51
|
+
|
|
52
|
+
**@getvitops/cli**: `vitops emdash check-prerender` now also warns when a page uses a type-role class (`font-display`, …) the built CSS has no rule for; `--strict` makes it fail. `vitops audit` checks for its optional packages and a Chromium binary before starting the preview server (so a failed run no longer leaves port 4398 busy), stops the server on Ctrl+C and termination, and takes its page list from the built sitemap, then the menus; the guessed defaults (`/privacy`, `/contact`, …) are used only when neither exists.
|
|
53
|
+
|
|
54
|
+
**@getvitops/utils**: the audit helpers behind the above (`preflight`, `fetchSitemapPaths`, `sitemapLocs`, `pathsFromSitemapLocs`).
|
|
55
|
+
|
|
56
|
+
**@getvitops/core** (all formats): new `.block-anchor` rule gives an anchored block `scroll-margin-top: var(--header-offset, 5rem)`; set `--header-offset` to a sticky header's height. New `widget-area.css` partial. No other consumer-facing change.
|
|
57
|
+
|
|
58
|
+
The other fixed-group packages: no consumer-facing change beyond the above.
|
|
59
|
+
|
|
60
|
+
**Also fixed in the emdash template** (`site.json`): it defined only the `display`, `heading` and `body` type roles while the blocks use `title`, `eyebrow`, `lead`, `caption` and `footnote`, so those rendered at body size with no rule behind them. The roles are added with the reference design system's specs. A site scaffolded earlier adds the same five entries under `designSystem.themes.default.typography.roles`; `vitops emdash check-prerender` now names any that are missing.
|
|
61
|
+
|
|
62
|
+
### Patch Changes
|
|
63
|
+
|
|
64
|
+
- @getvitops/config@8.3.0-alpha.1
|
|
65
|
+
|
|
3
66
|
## 8.3.0-alpha.0
|
|
4
67
|
|
|
5
68
|
### Minor Changes
|
package/dist/audit/index.d.mts
CHANGED
|
@@ -100,12 +100,25 @@ export declare function isAuditMenu(name: string | undefined): boolean;
|
|
|
100
100
|
export declare function pathsFromMenus(menus: MenuLike[]): string[];
|
|
101
101
|
/** Same, from an EmDash seed file's parsed JSON (`{ menus: [...] }`). */
|
|
102
102
|
export declare function pathsFromSeed(seed: unknown): string[];
|
|
103
|
+
/** `<loc>` values in a sitemap or sitemap index, in document order. */
|
|
104
|
+
export declare function sitemapLocs(xml: string): string[];
|
|
105
|
+
/**
|
|
106
|
+
* The page paths in a sitemap's `<loc>`s. The origin is dropped: a built sitemap names the
|
|
107
|
+
* production host, and the audit reads the same paths from whatever server it was pointed at.
|
|
108
|
+
*/
|
|
109
|
+
export declare function pathsFromSitemapLocs(locs: string[]): string[];
|
|
103
110
|
interface PagePlanInput {
|
|
111
|
+
/** Paths from the built sitemap. */
|
|
112
|
+
sitemapPaths?: string[];
|
|
104
113
|
/** Paths found in menus. */
|
|
105
114
|
menuPaths?: string[];
|
|
106
115
|
/** Paths from `--pages`. */
|
|
107
116
|
extra?: string[];
|
|
108
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Default true, but only used when the sitemap and the menus found nothing: guessing
|
|
119
|
+
* `/privacy`, `/contact`… is the fallback, not an addition to a list the site already gave.
|
|
120
|
+
* `--no-defaults` for a site that wants an exact list.
|
|
121
|
+
*/
|
|
109
122
|
includeDefaults?: boolean;
|
|
110
123
|
}
|
|
111
124
|
/** The pages to try, `/` always first, unique, in a stable order. */
|
|
@@ -146,6 +159,17 @@ export declare function renderMarkdown(report: AuditReport): string;
|
|
|
146
159
|
export declare function reportFiles(report: AuditReport): Record<string, string>;
|
|
147
160
|
/** One-line-per-failure console summary. */
|
|
148
161
|
export declare function formatSummary(report: AuditReport): string;
|
|
162
|
+
/**
|
|
163
|
+
* Check, before any server starts, that everything the audit needs is there: the three optional
|
|
164
|
+
* packages and a Chromium binary. A missing browser used to surface only after the preview
|
|
165
|
+
* server was up, and the failed launch left it running.
|
|
166
|
+
*/
|
|
167
|
+
export declare function preflight(): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Page paths from the served site's sitemap (`/sitemap-index.xml`, then `/sitemap.xml`), following
|
|
170
|
+
* an index one level to its child sitemaps. Empty when the site serves none.
|
|
171
|
+
*/
|
|
172
|
+
export declare function fetchSitemapPaths(baseUrl: string): Promise<string[]>;
|
|
149
173
|
/** A free TCP port, or `true` for "this specific one is free". */
|
|
150
174
|
export declare function isPortFree(port: number): Promise<boolean>;
|
|
151
175
|
/** Run a command to completion, inheriting stdio. Rejects on a non-zero exit. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/audit/types.ts","../../src/audit/plan.ts","../../src/audit/run.ts"],"mappings":";KAEY;UAEK;;EAEf;;EAEA;;EAEA;;EAEA,iBAAiB;;UAGF;EACf;;EAEA,QAAQ;EACR;EACA;;EAEA;;EAEA;;UAGe;;EAEf;EACA;EACA;;EAEA;IACE;IACA;IACA;;;UAIa;EACf;EACA;;KAGU;UAEK;EACf;EACA;EACA,QAAQ;EACR;;EAEA;EACA,UAAU;EACV,aAAa;EACb,UAAU;;KAGA;UAEK;EACf;EACA;EACA,YAAY;EACZ,SAAS;EACT;EACA,OAAO;;EAEP;;EAEA;EACA;;qBCrDW,iBAAiB;;;;;;qBAYjB;;qBAGA;qBAEA;qBAKA;;;;;UAgBI;EACf;EACA,QAAQ;;UAEO;EACf;EACA,WAAW;EACX,QAAQ;;;;;;;;wBASM,cAAc;;wBAsBd,YAAY;;wBAKZ,eAAe,OAAO;;wBAOtB,cAAc;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/audit/types.ts","../../src/audit/plan.ts","../../src/audit/run.ts"],"mappings":";KAEY;UAEK;;EAEf;;EAEA;;EAEA;;EAEA,iBAAiB;;UAGF;EACf;;EAEA,QAAQ;EACR;EACA;;EAEA;;EAEA;;UAGe;;EAEf;EACA;EACA;;EAEA;IACE;IACA;IACA;;;UAIa;EACf;EACA;;KAGU;UAEK;EACf;EACA;EACA,QAAQ;EACR;;EAEA;EACA,UAAU;EACV,aAAa;EACb,UAAU;;KAGA;UAEK;EACf;EACA;EACA,YAAY;EACZ,SAAS;EACT;EACA,OAAO;;EAEP;;EAEA;EACA;;qBCrDW,iBAAiB;;;;;;qBAYjB;;qBAGA;qBAEA;qBAKA;;;;;UAgBI;EACf;EACA,QAAQ;;UAEO;EACf;EACA,WAAW;EACX,QAAQ;;;;;;;;wBASM,cAAc;;wBAsBd,YAAY;;wBAKZ,eAAe,OAAO;;wBAOtB,cAAc;;wBAMd,YAAY;;;;;wBAaZ,qBAAqB;UAcpB;;EAEf;;EAEA;;EAEA;;;;;;EAMA;;;wBAIc,UAAU,QAAO;;;;;;wBAuBjB,eACd,cACA;EACG;EAAyC;;;wBAgB9B,gBAAgB,oCAAoC;;wBAKpD,aAAa,eAAe;;wBAe5B,aACd,SAAS,yBACT,YAAY,4BACZ,SAAS,eACR;;wBAyCa,cACd,cACA,aACA,gCACA;EAAW;EAA6B;IACvC;wBAmBa,YACd,cACA,aACA,oBACA,SAAS,aACT,YAAY,gBACZ,SAAS,eACR;wBAYa,YAAY;EAC1B;EACA;EACA,YAAY;EACZ,SAAS;EACT,OAAO;IACL;;wBAiBY,SAAS;wBAOT,mBAAmB,MAAM,YAAY,SAAS;wBAyC9C,eAAe,QAAQ;;wBAiCvB,YAAY,QAAQ,cAAc;;wBAgBlC,cAAc,QAAQ;;;;;;wBCjXhB,aAAa;;;;;wBAoBb,kBAAkB,kBAAkB;;wBAyB1C,WAAW,eAAe;;wBAoB1B,WAAW,aAAa,gBAAgB,cAAc;UAUrD;EACf;EACA;;;;;;;;;;wBAWoB,aAAa;EACjC;EACA;EACA;IACE,QAAQ;UA+FK;EACf;EACA;EACA,SAAS;EACT,YAAY;EACZ,cAAc;;;wBAIM,SAAS,MAAM,aAAa,QAAQ"}
|
package/dist/audit/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { createServer } from "node:net";
|
|
3
4
|
|
|
@@ -77,6 +78,25 @@ function pathsFromSeed(seed) {
|
|
|
77
78
|
const menus = seed?.menus;
|
|
78
79
|
return Array.isArray(menus) ? pathsFromMenus(menus) : [];
|
|
79
80
|
}
|
|
81
|
+
/** `<loc>` values in a sitemap or sitemap index, in document order. */
|
|
82
|
+
function sitemapLocs(xml) {
|
|
83
|
+
return [...xml.matchAll(/<loc>\s*([^<\s][^<]*?)\s*<\/loc>/gi)].map((m) => m[1].replace(/&/g, "&"));
|
|
84
|
+
}
|
|
85
|
+
/** A `<sitemapindex>` lists other sitemaps, not pages. */
|
|
86
|
+
const isSitemapIndex = (xml) => /<sitemapindex[\s>]/i.test(xml);
|
|
87
|
+
/**
|
|
88
|
+
* The page paths in a sitemap's `<loc>`s. The origin is dropped: a built sitemap names the
|
|
89
|
+
* production host, and the audit reads the same paths from whatever server it was pointed at.
|
|
90
|
+
*/
|
|
91
|
+
function pathsFromSitemapLocs(locs) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const loc of locs) try {
|
|
94
|
+
const { pathname } = new URL(loc);
|
|
95
|
+
const path = normalizePath(pathname);
|
|
96
|
+
if (path && !/\.(xml|txt)$/i.test(pathname)) out.push(path);
|
|
97
|
+
} catch {}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
80
100
|
/** The pages to try, `/` always first, unique, in a stable order. */
|
|
81
101
|
function planPages(input = {}) {
|
|
82
102
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -88,7 +108,9 @@ function planPages(input = {}) {
|
|
|
88
108
|
}
|
|
89
109
|
};
|
|
90
110
|
add("/");
|
|
91
|
-
|
|
111
|
+
const discovered = (input.sitemapPaths?.length ?? 0) + (input.menuPaths?.length ?? 0) > 0;
|
|
112
|
+
if (input.includeDefaults !== false && !discovered) for (const p of DEFAULT_PAGES) add(p);
|
|
113
|
+
for (const p of input.sitemapPaths ?? []) add(p);
|
|
92
114
|
for (const p of input.menuPaths ?? []) add(p);
|
|
93
115
|
for (const p of input.extra ?? []) add(normalizePath(p));
|
|
94
116
|
return out;
|
|
@@ -296,7 +318,7 @@ function formatSummary(report) {
|
|
|
296
318
|
* over that port — so one browser binary serves both and nothing here needs Chrome
|
|
297
319
|
* installed system-wide.
|
|
298
320
|
*/
|
|
299
|
-
const INSTALL = "
|
|
321
|
+
const INSTALL = "vp add -D playwright @axe-core/playwright lighthouse && vp exec playwright install chromium";
|
|
300
322
|
/** Load an optional dependency or throw an error that says how to get it. */
|
|
301
323
|
async function loadOptional(name) {
|
|
302
324
|
try {
|
|
@@ -310,6 +332,47 @@ async function loadOptional(name) {
|
|
|
310
332
|
throw err;
|
|
311
333
|
}
|
|
312
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Check, before any server starts, that everything the audit needs is there: the three optional
|
|
337
|
+
* packages and a Chromium binary. A missing browser used to surface only after the preview
|
|
338
|
+
* server was up, and the failed launch left it running.
|
|
339
|
+
*/
|
|
340
|
+
async function preflight() {
|
|
341
|
+
const pw = await loadOptional("playwright");
|
|
342
|
+
await loadOptional("@axe-core/playwright");
|
|
343
|
+
await loadOptional("lighthouse");
|
|
344
|
+
let exe = "";
|
|
345
|
+
try {
|
|
346
|
+
exe = pw.chromium.executablePath();
|
|
347
|
+
} catch {}
|
|
348
|
+
if (!exe || !existsSync(exe)) throw new Error("Chromium is not installed for Playwright.\n Install: vp exec playwright install chromium");
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Page paths from the served site's sitemap (`/sitemap-index.xml`, then `/sitemap.xml`), following
|
|
352
|
+
* an index one level to its child sitemaps. Empty when the site serves none.
|
|
353
|
+
*/
|
|
354
|
+
async function fetchSitemapPaths(baseUrl) {
|
|
355
|
+
const get = async (path) => {
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(new URL(path, baseUrl), { redirect: "follow" });
|
|
358
|
+
return res.ok ? await res.text() : void 0;
|
|
359
|
+
} catch {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
for (const root of ["/sitemap-index.xml", "/sitemap.xml"]) {
|
|
364
|
+
const xml = await get(root);
|
|
365
|
+
if (!xml) continue;
|
|
366
|
+
if (!isSitemapIndex(xml)) return pathsFromSitemapLocs(sitemapLocs(xml));
|
|
367
|
+
const out = [];
|
|
368
|
+
for (const loc of sitemapLocs(xml)) {
|
|
369
|
+
const child = await get(new URL(loc).pathname);
|
|
370
|
+
if (child) out.push(...pathsFromSitemapLocs(sitemapLocs(child)));
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
313
376
|
/** A free TCP port, or `true` for "this specific one is free". */
|
|
314
377
|
function isPortFree(port) {
|
|
315
378
|
return new Promise((resolve) => {
|
|
@@ -438,7 +501,7 @@ async function runAudit(opts) {
|
|
|
438
501
|
args: [`--remote-debugging-port=${debugPort}`]
|
|
439
502
|
});
|
|
440
503
|
} catch (err) {
|
|
441
|
-
throw new Error(`could not launch Chromium: ${err.message}\n Install a browser:
|
|
504
|
+
throw new Error(`could not launch Chromium: ${err.message}\n Install a browser: vp exec playwright install chromium`, { cause: err });
|
|
442
505
|
}
|
|
443
506
|
const results = [];
|
|
444
507
|
try {
|
|
@@ -505,5 +568,5 @@ async function runAudit(opts) {
|
|
|
505
568
|
return results;
|
|
506
569
|
}
|
|
507
570
|
|
|
508
|
-
export { AXE_TAGS, DEFAULT_BUDGETS, DEFAULT_PAGES, DISCLAIMER, MANUAL_CHECKS, auditedPage, buildReport, classifyStatus, evaluatePage, formatSummary, isAuditMenu, isPortFree, normalizeImpact, normalizePath, pageSlug, parseImpacts, pathsFromMenus, pathsFromSeed, planPages, renderMarkdown, renderPageMarkdown, reportFiles, runAudit, runCommand, startPreview, unauditedPage };
|
|
571
|
+
export { AXE_TAGS, DEFAULT_BUDGETS, DEFAULT_PAGES, DISCLAIMER, MANUAL_CHECKS, auditedPage, buildReport, classifyStatus, evaluatePage, fetchSitemapPaths, formatSummary, isAuditMenu, isPortFree, normalizeImpact, normalizePath, pageSlug, parseImpacts, pathsFromMenus, pathsFromSeed, pathsFromSitemapLocs, planPages, preflight, renderMarkdown, renderPageMarkdown, reportFiles, runAudit, runCommand, sitemapLocs, startPreview, unauditedPage };
|
|
509
572
|
//# sourceMappingURL=index.mjs.map
|
package/dist/audit/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/audit/plan.ts","../../src/audit/run.ts"],"sourcesContent":["/**\n * `vitops audit` — the pure half. Decides which pages to audit, whether a response\n * status means \"skip\" or \"broken\", whether a result breaches a budget, and how it\n * reads. Touches no network, no browser, no filesystem and no clock (the caller\n * passes `generatedAt`) — the same split `indexing/plan.ts` and `ads/plan.ts` make,\n * for the same reason: `plan.test.ts` can assert every consequential case, and the\n * executor (`run.ts`) decides nothing.\n */\nimport type {\n AuditBudgets,\n AuditReport,\n AxeImpact,\n AxeViolation,\n BudgetFailure,\n FormFactor,\n PageMetrics,\n PageResult,\n} from './types.ts';\n\nexport const DEFAULT_BUDGETS: AuditBudgets = {\n lcpMs: 2500,\n cls: 0.1,\n tbtMs: 200,\n blockingImpacts: ['serious', 'critical'],\n};\n\n/**\n * `wcag2a`/`wcag2aa` are what AODA (WCAG 2.0 AA) asks for; `wcag21a`/`wcag21aa` add the\n * 2.1 criteria we target. `wcag21aa` alone covers only the *new AA* criteria, not the\n * new level-A ones, so both 2.1 tags are listed.\n */\nexport const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];\n\n/** Pages tried on every run. `/` is required; the rest are skipped when they 404. */\nexport const DEFAULT_PAGES = ['/', '/privacy', '/terms', '/cookies', '/contact'];\n\nexport const DISCLAIMER =\n 'A pass means no automated violations were found by axe-core and the performance budgets held. ' +\n 'It is NOT an AODA / WCAG conformance certification: automated tools detect only a minority of ' +\n 'WCAG failures. The manual checks below are still required.';\n\nexport const MANUAL_CHECKS = [\n 'Keyboard order: tab through every page; order matches the visual and reading order, nothing is skipped or trapped.',\n 'Focus visibility: a clear focus indicator on every interactive element, including in forced-colors / high-contrast mode.',\n 'Screen reader pass: headings, landmarks, forms, dropdowns and the drawer menu read sensibly (NVDA or JAWS on Windows, VoiceOver on macOS/iOS).',\n 'Reduced motion: with prefers-reduced-motion on, no essential information depends on animation and nothing auto-plays.',\n '200% zoom (and 400% reflow): no loss of content or function, no horizontal scrolling for text.',\n 'Colour and contrast of imagery, gradients and text over images (axe cannot evaluate these).',\n 'Meaningful alternative text, captions and transcripts for media.',\n];\n\n// ── Pages ───────────────────────────────────────────────────────────────────\n\n/**\n * A menu-ish thing: EmDash seed (`items`), `getMenu()` (`items`, items with `children`),\n * or either shape nested. Only `name`, `url` and children are read.\n */\nexport interface MenuLike {\n name?: string;\n items?: MenuItemLike[];\n}\nexport interface MenuItemLike {\n url?: string | null;\n children?: MenuItemLike[];\n items?: MenuItemLike[];\n}\n\n/**\n * Normalise to a site-relative path, or `undefined` for anything that is not an\n * auditable page of this site: external URLs, hash-only links, `mailto:`/`tel:`,\n * protocol-relative URLs, file downloads (anything with an extension) and the CMS\n * admin. Query and hash are dropped; a trailing slash is too (except the root).\n */\nexport function normalizePath(url: string | null | undefined): string | undefined {\n if (!url) return undefined;\n const u = url.trim();\n if (!u.startsWith('/') || u.startsWith('//')) return undefined;\n const bare = u.split(/[?#]/)[0] ?? '';\n const path = bare.length > 1 ? bare.replace(/\\/+$/, '') : bare;\n if (!path) return undefined;\n if (/\\.[a-z0-9]{1,6}$/i.test(path)) return undefined;\n if (path === '/_emdash' || path.startsWith('/_emdash/')) return undefined;\n return path;\n}\n\nfunction walk(items: MenuItemLike[] | undefined, out: string[]): void {\n for (const item of items ?? []) {\n const p = normalizePath(item.url);\n if (p) out.push(p);\n walk(item.children, out);\n walk(item.items, out);\n }\n}\n\n/** Is this one of the menus whose links are pages worth auditing? */\nexport function isAuditMenu(name: string | undefined): boolean {\n return !!name && (name.startsWith('primary') || name.startsWith('footer'));\n}\n\n/** Paths from the `primary*` / `footer*` menus (`footer-legal`, `primary-mobile`, …). */\nexport function pathsFromMenus(menus: MenuLike[]): string[] {\n const out: string[] = [];\n for (const m of menus) if (isAuditMenu(m.name)) walk(m.items, out);\n return out;\n}\n\n/** Same, from an EmDash seed file's parsed JSON (`{ menus: [...] }`). */\nexport function pathsFromSeed(seed: unknown): string[] {\n const menus = (seed as { menus?: unknown } | null)?.menus;\n return Array.isArray(menus) ? pathsFromMenus(menus as MenuLike[]) : [];\n}\n\nexport interface PagePlanInput {\n /** Paths found in menus. */\n menuPaths?: string[];\n /** Paths from `--pages`. */\n extra?: string[];\n /** Default true. `--no-defaults` for a site that wants an exact list. */\n includeDefaults?: boolean;\n}\n\n/** The pages to try, `/` always first, unique, in a stable order. */\nexport function planPages(input: PagePlanInput = {}): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n const add = (p: string | undefined) => {\n if (p && !seen.has(p)) {\n seen.add(p);\n out.push(p);\n }\n };\n add('/');\n if (input.includeDefaults !== false) for (const p of DEFAULT_PAGES) add(p);\n for (const p of input.menuPaths ?? []) add(p);\n for (const p of input.extra ?? []) add(normalizePath(p));\n return out;\n}\n\n/**\n * What an HTTP status means for a candidate page. 404/410 → skipped (a single-page\n * site has no `/contact`, and that is fine) — except `/`, which must exist. Any other\n * non-2xx is an error: a 500 on `/privacy` is a defect, not an absent page.\n */\nexport function classifyStatus(\n path: string,\n httpStatus: number | undefined,\n): { status: 'audited' | 'skipped' | 'error'; reason?: string } {\n if (httpStatus === undefined) return { status: 'error', reason: 'no response' };\n if (httpStatus >= 200 && httpStatus < 300) return { status: 'audited' };\n if (httpStatus === 404 || httpStatus === 410) {\n return path === '/'\n ? { status: 'error', reason: `/ returned ${httpStatus} — the home page must exist` }\n : { status: 'skipped', reason: `${httpStatus} — page does not exist on this site` };\n }\n return { status: 'error', reason: `HTTP ${httpStatus}` };\n}\n\n// ── Budgets ─────────────────────────────────────────────────────────────────\n\nconst IMPACT_RANK: Record<AxeImpact, number> = { minor: 0, moderate: 1, serious: 2, critical: 3 };\n\n/** axe can report a null impact; treat it as `moderate`, never silently drop it. */\nexport function normalizeImpact(impact: string | null | undefined): AxeImpact {\n return impact && impact in IMPACT_RANK ? (impact as AxeImpact) : 'moderate';\n}\n\n/** `serious,critical` → impacts, rejecting typos so a budget cannot silently be empty. */\nexport function parseImpacts(list: string): AxeImpact[] {\n const parts = list\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n for (const p of parts)\n if (!(p in IMPACT_RANK))\n throw new Error(\n `unknown axe impact \"${p}\" (expected: minor | moderate | serious | critical)`,\n );\n if (parts.length === 0) throw new Error('at least one axe impact is required');\n return parts as AxeImpact[];\n}\n\n/** Everything that makes one audited page fail. Empty = the page passes. */\nexport function evaluatePage(\n metrics: PageMetrics | undefined,\n violations: AxeViolation[] | undefined,\n budgets: AuditBudgets,\n): BudgetFailure[] {\n const failures: BudgetFailure[] = [];\n\n const check = (\n kind: 'lcp' | 'cls' | 'tbt',\n value: number | undefined,\n limit: number,\n label: string,\n unit: string,\n ) => {\n if (value === undefined || Number.isNaN(value)) {\n // A metric Lighthouse could not compute must not pass by default.\n failures.push({ kind: 'metric-missing', message: `${label} could not be measured` });\n } else if (value > limit) {\n failures.push({\n kind,\n message: `${label} ${fmt(value, unit)} exceeds the budget of ${fmt(limit, unit)}`,\n });\n }\n };\n check('lcp', metrics?.lcpMs, budgets.lcpMs, 'LCP', 'ms');\n check('cls', metrics?.cls, budgets.cls, 'CLS', '');\n check('tbt', metrics?.tbtMs, budgets.tbtMs, 'TBT', 'ms');\n\n for (const v of violations ?? []) {\n if (budgets.blockingImpacts.includes(v.impact)) {\n failures.push({\n kind: 'axe',\n message: `axe ${v.impact}: ${v.id} — ${v.help} (${v.nodeCount} node${v.nodeCount === 1 ? '' : 's'})`,\n });\n }\n }\n return failures;\n}\n\nfunction fmt(n: number, unit: string): string {\n const v = unit === 'ms' ? Math.round(n) : Number(n.toFixed(3));\n return unit ? `${v} ${unit}` : String(v);\n}\n\n/** Build a `PageResult` for a page that was not audited (skipped or error). */\nexport function unauditedPage(\n path: string,\n url: string,\n httpStatus: number | undefined,\n verdict: { status: 'skipped' | 'error'; reason?: string },\n): PageResult {\n return {\n path,\n url,\n status: verdict.status,\n ...(httpStatus !== undefined ? { httpStatus } : {}),\n ...(verdict.reason ? { reason: verdict.reason } : {}),\n failures:\n verdict.status === 'error'\n ? [\n {\n kind: path === '/' ? 'required-page-missing' : 'page-error',\n message: verdict.reason ?? 'page error',\n },\n ]\n : [],\n };\n}\n\nexport function auditedPage(\n path: string,\n url: string,\n httpStatus: number,\n metrics: PageMetrics,\n violations: AxeViolation[],\n budgets: AuditBudgets,\n): PageResult {\n return {\n path,\n url,\n status: 'audited',\n httpStatus,\n metrics,\n violations,\n failures: evaluatePage(metrics, violations, budgets),\n };\n}\n\nexport function buildReport(input: {\n generatedAt: string;\n baseUrl: string;\n formFactor: FormFactor;\n budgets: AuditBudgets;\n pages: PageResult[];\n}): AuditReport {\n return {\n generatedAt: input.generatedAt,\n baseUrl: input.baseUrl,\n formFactor: input.formFactor,\n budgets: input.budgets,\n axeTags: AXE_TAGS,\n pages: input.pages,\n passed: input.pages.every((p) => p.failures.length === 0),\n disclaimer: DISCLAIMER,\n manualChecks: MANUAL_CHECKS,\n };\n}\n\n// ── Rendering ───────────────────────────────────────────────────────────────\n\n/** `/` → `home`, `/a/b` → `a-b`: a safe per-page filename. */\nexport function pageSlug(path: string): string {\n const s = path.replace(/^\\/+|\\/+$/g, '').replace(/[^a-zA-Z0-9]+/g, '-');\n return s || 'home';\n}\n\nconst dash = (v: number | undefined, f: (n: number) => string) => (v === undefined ? '—' : f(v));\n\nexport function renderPageMarkdown(page: PageResult, budgets: AuditBudgets): string {\n const lines: string[] = [];\n const verdict =\n page.status === 'skipped' ? 'SKIPPED' : page.failures.length === 0 ? 'PASS' : 'FAIL';\n lines.push(`### \\`${page.path}\\` — ${verdict}`);\n lines.push('');\n if (page.status !== 'audited') {\n lines.push(page.reason ?? page.status);\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n return lines.join('\\n');\n }\n const m = page.metrics;\n lines.push('| Metric | Value | Budget |', '| --- | --- | --- |');\n lines.push(\n `| LCP | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${budgets.lcpMs} ms |`,\n `| CLS | ${dash(m?.cls, (n) => n.toFixed(3))} | ${budgets.cls} |`,\n `| TBT | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${budgets.tbtMs} ms |`,\n `| Performance | ${dash(m?.scores.performance, String)} | — |`,\n `| SEO | ${dash(m?.scores.seo, String)} | — |`,\n `| Best practices | ${dash(m?.scores.bestPractices, String)} | — |`,\n );\n lines.push('');\n const v = page.violations ?? [];\n if (v.length === 0) lines.push('axe: no violations.');\n else {\n lines.push(`axe: ${v.length} violated rule${v.length === 1 ? '' : 's'}.`, '');\n for (const x of [...v].sort((a, b) => IMPACT_RANK[b.impact] - IMPACT_RANK[a.impact])) {\n const blocking = budgets.blockingImpacts.includes(x.impact) ? ' (blocking)' : '';\n lines.push(\n `- **${x.impact}**${blocking} \\`${x.id}\\` — ${x.help} · ${x.nodeCount} node${x.nodeCount === 1 ? '' : 's'} · [rule](${x.helpUrl})`,\n );\n for (const t of x.targets) lines.push(` - \\`${t}\\``);\n }\n }\n if (page.failures.length) {\n lines.push('', 'Failures:');\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n }\n return lines.join('\\n');\n}\n\nexport function renderMarkdown(report: AuditReport): string {\n const lines: string[] = [];\n lines.push('# Performance and accessibility audit', '');\n lines.push(\n `**${report.passed ? 'PASS' : 'FAIL'}** · ${report.baseUrl} · ${report.formFactor} · ${report.generatedAt}`,\n '',\n );\n lines.push(`> ${report.disclaimer}`, '');\n lines.push(\n `Budgets: LCP < ${report.budgets.lcpMs} ms, CLS < ${report.budgets.cls}, TBT < ${report.budgets.tbtMs} ms, ` +\n `no axe ${report.budgets.blockingImpacts.join(' or ')} violations. axe tags: ${report.axeTags.join(', ')}.`,\n '',\n );\n lines.push(\n '| Page | Result | LCP | CLS | TBT | Perf | SEO | BP |',\n '| --- | --- | --- | --- | --- | --- | --- | --- |',\n );\n for (const p of report.pages) {\n const m = p.metrics;\n const verdict = p.status === 'skipped' ? 'skipped' : p.failures.length ? 'FAIL' : 'pass';\n lines.push(\n `| \\`${p.path}\\` | ${verdict} | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.cls, (n) => n.toFixed(3))} | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.scores.performance, String)} | ${dash(m?.scores.seo, String)} | ${dash(m?.scores.bestPractices, String)} |`,\n );\n }\n lines.push('');\n for (const p of report.pages) lines.push(renderPageMarkdown(p, report.budgets), '');\n lines.push('## Manual checks still required', '');\n for (const c of report.manualChecks) lines.push(`- [ ] ${c}`);\n lines.push('');\n return lines.join('\\n');\n}\n\n/** Files to write, relative to the output dir: JSON + markdown overall and per page. */\nexport function reportFiles(report: AuditReport): Record<string, string> {\n const files: Record<string, string> = {\n 'report.json': `${JSON.stringify(report, null, 2)}\\n`,\n 'report.md': renderMarkdown(report),\n };\n for (const p of report.pages) {\n if (p.status === 'skipped') continue;\n const slug = pageSlug(p.path);\n files[`pages/${slug}.json`] = `${JSON.stringify(p, null, 2)}\\n`;\n files[`pages/${slug}.md`] =\n `${renderPageMarkdown(p, report.budgets)}\\n\\n> ${report.disclaimer}\\n`;\n }\n return files;\n}\n\n/** One-line-per-failure console summary. */\nexport function formatSummary(report: AuditReport): string {\n const lines: string[] = [];\n for (const p of report.pages) {\n if (p.status === 'skipped') lines.push(` – ${p.path} skipped (${p.reason})`);\n else if (p.failures.length === 0) lines.push(` ✓ ${p.path}`);\n else {\n lines.push(` ✖ ${p.path}`);\n for (const f of p.failures) lines.push(` ${f.message}`);\n }\n }\n lines.push('', report.passed ? '✓ no automated violations' : '✖ audit failed');\n lines.push('Note: a pass is NOT AODA certification — see the manual checks in report.md.');\n return lines.join('\\n');\n}\n","/**\n * `vitops audit` — the I/O half. Starts (or attaches to) a server, drives Chromium,\n * runs axe and Lighthouse, and hands the raw numbers to `plan.ts`. It decides nothing:\n * which pages, what a status means and whether a number breaches a budget are all\n * `plan.ts`'s.\n *\n * `playwright`, `@axe-core/playwright` and `lighthouse` are OPTIONAL peer dependencies,\n * loaded lazily. They are heavy (a browser driver and ~100 MB of Lighthouse) and a\n * consumer who never runs `vitops audit` must not pay for them. A missing one fails\n * with the install command, not a bare `Cannot find package`.\n *\n * Lighthouse runs on the SAME Chromium Playwright launched — Playwright opens it with\n * `--remote-debugging-port`, axe drives it through Playwright, and Lighthouse attaches\n * over that port — so one browser binary serves both and nothing here needs Chrome\n * installed system-wide.\n */\nimport { spawn, type ChildProcess } from 'node:child_process';\nimport { createServer } from 'node:net';\nimport { AXE_TAGS, auditedPage, classifyStatus, normalizeImpact, unauditedPage } from './plan.ts';\nimport type { AuditBudgets, AxeViolation, FormFactor, PageMetrics, PageResult } from './types.ts';\n\nconst INSTALL =\n 'npm i -D playwright @axe-core/playwright lighthouse && npx playwright install chromium';\n\n/** Load an optional dependency or throw an error that says how to get it. */\nasync function loadOptional<T>(name: string): Promise<T> {\n try {\n // A variable specifier keeps bundlers and the type checker from resolving it —\n // that is the whole point of \"optional\".\n return (await import(/* @vite-ignore */ name)) as T;\n } catch (err) {\n const code = (err as { code?: string }).code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND')\n throw new Error(\n `vitops audit needs \"${name}\", which is an optional dependency and is not installed.\\n` +\n ` Install: ${INSTALL}`,\n { cause: err },\n );\n throw err;\n }\n}\n\n/** A free TCP port, or `true` for \"this specific one is free\". */\nexport function isPortFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once('error', () => resolve(false));\n s.listen(port, '127.0.0.1', () => s.close(() => resolve(true)));\n });\n}\n\nfunction freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const s = createServer();\n s.once('error', reject);\n s.listen(0, '127.0.0.1', () => {\n const { port } = s.address() as { port: number };\n s.close(() => resolve(port));\n });\n });\n}\n\n/** Run a command to completion, inheriting stdio. Rejects on a non-zero exit. */\nexport function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });\n child.once('error', reject);\n child.once('exit', (code) =>\n code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`)),\n );\n });\n}\n\nexport interface PreviewServer {\n baseUrl: string;\n stop(): void;\n}\n\n/**\n * `astro preview` on a dedicated port.\n *\n * Astro's preview server silently moves to the next port when its own is taken, and\n * a readiness probe would then happily attach to whatever owns the original one — the\n * failure `site/playwright.config.ts` documents. So the port is checked free first and\n * the run aborts if it is not.\n */\nexport async function startPreview(opts: {\n cwd: string;\n port: number;\n timeoutMs?: number;\n}): Promise<PreviewServer> {\n if (!(await isPortFree(opts.port)))\n throw new Error(\n `port ${opts.port} is already in use — refusing to audit whatever is listening there. ` +\n `Pass --port <free port>, or --url to audit a server you started yourself.`,\n );\n const baseUrl = `http://127.0.0.1:${opts.port}`;\n const child: ChildProcess = spawn(\n 'npx',\n ['--no-install', 'astro', 'preview', '--host', '127.0.0.1', '--port', String(opts.port)],\n { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' },\n );\n let log = '';\n child.stdout?.on('data', (d) => (log += String(d)));\n child.stderr?.on('data', (d) => (log += String(d)));\n let exited: number | null | undefined;\n child.once('exit', (code) => (exited = code));\n\n const stop = () => {\n try {\n if (child.pid && process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');\n else child.kill('SIGTERM');\n } catch {\n /* already gone */\n }\n };\n\n const deadline = Date.now() + (opts.timeoutMs ?? 60_000);\n while (Date.now() < deadline) {\n if (exited !== undefined) {\n throw new Error(\n `astro preview exited early (code ${exited}). Did you build first? Pass --build.\\n${log.trim()}`,\n );\n }\n try {\n await fetch(baseUrl, { redirect: 'manual' });\n return { baseUrl, stop };\n } catch {\n await new Promise((r) => setTimeout(r, 250));\n }\n }\n stop();\n throw new Error(`astro preview did not answer on ${baseUrl} within the timeout.\\n${log.trim()}`);\n}\n\n// ── Browser tooling (structural types: these modules are optional) ───────────\n\ninterface AxeResult {\n violations: Array<{\n id: string;\n impact?: string | null;\n help: string;\n helpUrl: string;\n nodes: Array<{ target: unknown[] }>;\n }>;\n}\ninterface LighthouseAudits {\n [id: string]: { numericValue?: number } | undefined;\n}\ninterface LighthouseRun {\n lhr: {\n audits: LighthouseAudits;\n categories: Record<string, { score: number | null } | undefined>;\n };\n}\n\nfunction toViolations(res: AxeResult): AxeViolation[] {\n return res.violations.map((v) => ({\n id: v.id,\n impact: normalizeImpact(v.impact),\n help: v.help,\n helpUrl: v.helpUrl,\n nodeCount: v.nodes.length,\n targets: v.nodes.slice(0, 3).map((n) => String(n.target.flat().join(' '))),\n }));\n}\n\nfunction toMetrics(run: LighthouseRun): PageMetrics {\n const a = run.lhr.audits;\n const score = (id: string) => {\n const s = run.lhr.categories[id]?.score;\n return s == null ? undefined : Math.round(s * 100);\n };\n return {\n lcpMs: a['largest-contentful-paint']?.numericValue,\n cls: a['cumulative-layout-shift']?.numericValue,\n tbtMs: a['total-blocking-time']?.numericValue,\n scores: {\n performance: score('performance'),\n seo: score('seo'),\n bestPractices: score('best-practices'),\n },\n };\n}\n\nexport interface RunOptions {\n baseUrl: string;\n paths: string[];\n budgets: AuditBudgets;\n formFactor: FormFactor;\n onProgress?: (message: string) => void;\n}\n\n/** Audit each path. Never throws for a bad PAGE — only for missing tooling/browser. */\nexport async function runAudit(opts: RunOptions): Promise<PageResult[]> {\n const log = opts.onProgress ?? (() => {});\n const [pw, axe, lh] = await Promise.all([\n loadOptional<{\n chromium: {\n launch(o: { headless: boolean; args: string[] }): Promise<{\n newContext(): Promise<{\n newPage(): Promise<{\n goto(u: string, o: { waitUntil: string }): Promise<unknown>;\n }>;\n close(): Promise<void>;\n }>;\n close(): Promise<void>;\n }>;\n };\n }>('playwright'),\n loadOptional<{\n AxeBuilder: new (o: { page: unknown }) => {\n withTags(t: string[]): { analyze(): Promise<AxeResult> };\n };\n }>('@axe-core/playwright'),\n loadOptional<{\n default: (\n url: string,\n flags: Record<string, unknown>,\n config?: unknown,\n ) => Promise<LighthouseRun | undefined>;\n }>('lighthouse'),\n ]);\n\n const debugPort = await freePort();\n let browser;\n try {\n browser = await pw.chromium.launch({\n headless: true,\n args: [`--remote-debugging-port=${debugPort}`],\n });\n } catch (err) {\n throw new Error(\n `could not launch Chromium: ${(err as Error).message}\\n Install a browser: npx playwright install chromium`,\n { cause: err },\n );\n }\n\n const results: PageResult[] = [];\n try {\n for (const path of opts.paths) {\n const url = new URL(path, opts.baseUrl).href;\n let httpStatus: number | undefined;\n try {\n httpStatus = (await fetch(url, { redirect: 'follow' })).status;\n } catch {\n httpStatus = undefined;\n }\n const verdict = classifyStatus(path, httpStatus);\n if (verdict.status !== 'audited') {\n log(`${path}: ${verdict.status} (${verdict.reason})`);\n results.push(unauditedPage(path, url, httpStatus, verdict as never));\n continue;\n }\n\n log(`${path}: axe`);\n // axe-playwright refuses a page that did not come from `newContext()`.\n const context = await browser.newContext();\n const page = await context.newPage();\n let violations: AxeViolation[];\n try {\n await page.goto(url, { waitUntil: 'load' });\n violations = toViolations(await new axe.AxeBuilder({ page }).withTags(AXE_TAGS).analyze());\n } finally {\n await context.close();\n }\n\n log(`${path}: lighthouse`);\n const flags: Record<string, unknown> = {\n port: debugPort,\n output: 'json',\n logLevel: 'error',\n onlyCategories: ['performance', 'seo', 'best-practices'],\n formFactor: opts.formFactor,\n ...(opts.formFactor === 'desktop'\n ? {\n screenEmulation: {\n mobile: false,\n width: 1350,\n height: 940,\n deviceScaleFactor: 1,\n disabled: false,\n },\n throttling: {\n rttMs: 40,\n throughputKbps: 10 * 1024,\n cpuSlowdownMultiplier: 1,\n requestLatencyMs: 0,\n downloadThroughputKbps: 0,\n uploadThroughputKbps: 0,\n },\n }\n : {}),\n };\n const run = await lh.default(url, flags);\n const metrics: PageMetrics = run ? toMetrics(run) : { scores: {} };\n results.push(auditedPage(path, url, httpStatus!, metrics, violations, opts.budgets));\n }\n } finally {\n await browser.close();\n }\n return results;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/audit/plan.ts","../../src/audit/run.ts"],"sourcesContent":["/**\n * `vitops audit` — the pure half. Decides which pages to audit, whether a response\n * status means \"skip\" or \"broken\", whether a result breaches a budget, and how it\n * reads. Touches no network, no browser, no filesystem and no clock (the caller\n * passes `generatedAt`) — the same split `indexing/plan.ts` and `ads/plan.ts` make,\n * for the same reason: `plan.test.ts` can assert every consequential case, and the\n * executor (`run.ts`) decides nothing.\n */\nimport type {\n AuditBudgets,\n AuditReport,\n AxeImpact,\n AxeViolation,\n BudgetFailure,\n FormFactor,\n PageMetrics,\n PageResult,\n} from './types.ts';\n\nexport const DEFAULT_BUDGETS: AuditBudgets = {\n lcpMs: 2500,\n cls: 0.1,\n tbtMs: 200,\n blockingImpacts: ['serious', 'critical'],\n};\n\n/**\n * `wcag2a`/`wcag2aa` are what AODA (WCAG 2.0 AA) asks for; `wcag21a`/`wcag21aa` add the\n * 2.1 criteria we target. `wcag21aa` alone covers only the *new AA* criteria, not the\n * new level-A ones, so both 2.1 tags are listed.\n */\nexport const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];\n\n/** Pages tried on every run. `/` is required; the rest are skipped when they 404. */\nexport const DEFAULT_PAGES = ['/', '/privacy', '/terms', '/cookies', '/contact'];\n\nexport const DISCLAIMER =\n 'A pass means no automated violations were found by axe-core and the performance budgets held. ' +\n 'It is NOT an AODA / WCAG conformance certification: automated tools detect only a minority of ' +\n 'WCAG failures. The manual checks below are still required.';\n\nexport const MANUAL_CHECKS = [\n 'Keyboard order: tab through every page; order matches the visual and reading order, nothing is skipped or trapped.',\n 'Focus visibility: a clear focus indicator on every interactive element, including in forced-colors / high-contrast mode.',\n 'Screen reader pass: headings, landmarks, forms, dropdowns and the drawer menu read sensibly (NVDA or JAWS on Windows, VoiceOver on macOS/iOS).',\n 'Reduced motion: with prefers-reduced-motion on, no essential information depends on animation and nothing auto-plays.',\n '200% zoom (and 400% reflow): no loss of content or function, no horizontal scrolling for text.',\n 'Colour and contrast of imagery, gradients and text over images (axe cannot evaluate these).',\n 'Meaningful alternative text, captions and transcripts for media.',\n];\n\n// ── Pages ───────────────────────────────────────────────────────────────────\n\n/**\n * A menu-ish thing: EmDash seed (`items`), `getMenu()` (`items`, items with `children`),\n * or either shape nested. Only `name`, `url` and children are read.\n */\nexport interface MenuLike {\n name?: string;\n items?: MenuItemLike[];\n}\nexport interface MenuItemLike {\n url?: string | null;\n children?: MenuItemLike[];\n items?: MenuItemLike[];\n}\n\n/**\n * Normalise to a site-relative path, or `undefined` for anything that is not an\n * auditable page of this site: external URLs, hash-only links, `mailto:`/`tel:`,\n * protocol-relative URLs, file downloads (anything with an extension) and the CMS\n * admin. Query and hash are dropped; a trailing slash is too (except the root).\n */\nexport function normalizePath(url: string | null | undefined): string | undefined {\n if (!url) return undefined;\n const u = url.trim();\n if (!u.startsWith('/') || u.startsWith('//')) return undefined;\n const bare = u.split(/[?#]/)[0] ?? '';\n const path = bare.length > 1 ? bare.replace(/\\/+$/, '') : bare;\n if (!path) return undefined;\n if (/\\.[a-z0-9]{1,6}$/i.test(path)) return undefined;\n if (path === '/_emdash' || path.startsWith('/_emdash/')) return undefined;\n return path;\n}\n\nfunction walk(items: MenuItemLike[] | undefined, out: string[]): void {\n for (const item of items ?? []) {\n const p = normalizePath(item.url);\n if (p) out.push(p);\n walk(item.children, out);\n walk(item.items, out);\n }\n}\n\n/** Is this one of the menus whose links are pages worth auditing? */\nexport function isAuditMenu(name: string | undefined): boolean {\n return !!name && (name.startsWith('primary') || name.startsWith('footer'));\n}\n\n/** Paths from the `primary*` / `footer*` menus (`footer-legal`, `primary-mobile`, …). */\nexport function pathsFromMenus(menus: MenuLike[]): string[] {\n const out: string[] = [];\n for (const m of menus) if (isAuditMenu(m.name)) walk(m.items, out);\n return out;\n}\n\n/** Same, from an EmDash seed file's parsed JSON (`{ menus: [...] }`). */\nexport function pathsFromSeed(seed: unknown): string[] {\n const menus = (seed as { menus?: unknown } | null)?.menus;\n return Array.isArray(menus) ? pathsFromMenus(menus as MenuLike[]) : [];\n}\n\n/** `<loc>` values in a sitemap or sitemap index, in document order. */\nexport function sitemapLocs(xml: string): string[] {\n return [...xml.matchAll(/<loc>\\s*([^<\\s][^<]*?)\\s*<\\/loc>/gi)].map((m) =>\n (m[1] as string).replace(/&/g, '&'),\n );\n}\n\n/** A `<sitemapindex>` lists other sitemaps, not pages. */\nexport const isSitemapIndex = (xml: string) => /<sitemapindex[\\s>]/i.test(xml);\n\n/**\n * The page paths in a sitemap's `<loc>`s. The origin is dropped: a built sitemap names the\n * production host, and the audit reads the same paths from whatever server it was pointed at.\n */\nexport function pathsFromSitemapLocs(locs: string[]): string[] {\n const out: string[] = [];\n for (const loc of locs) {\n try {\n const { pathname } = new URL(loc);\n const path = normalizePath(pathname);\n if (path && !/\\.(xml|txt)$/i.test(pathname)) out.push(path);\n } catch {\n /* not a URL */\n }\n }\n return out;\n}\n\nexport interface PagePlanInput {\n /** Paths from the built sitemap. */\n sitemapPaths?: string[];\n /** Paths found in menus. */\n menuPaths?: string[];\n /** Paths from `--pages`. */\n extra?: string[];\n /**\n * Default true, but only used when the sitemap and the menus found nothing: guessing\n * `/privacy`, `/contact`… is the fallback, not an addition to a list the site already gave.\n * `--no-defaults` for a site that wants an exact list.\n */\n includeDefaults?: boolean;\n}\n\n/** The pages to try, `/` always first, unique, in a stable order. */\nexport function planPages(input: PagePlanInput = {}): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n const add = (p: string | undefined) => {\n if (p && !seen.has(p)) {\n seen.add(p);\n out.push(p);\n }\n };\n add('/');\n const discovered = (input.sitemapPaths?.length ?? 0) + (input.menuPaths?.length ?? 0) > 0;\n if (input.includeDefaults !== false && !discovered) for (const p of DEFAULT_PAGES) add(p);\n for (const p of input.sitemapPaths ?? []) add(p);\n for (const p of input.menuPaths ?? []) add(p);\n for (const p of input.extra ?? []) add(normalizePath(p));\n return out;\n}\n\n/**\n * What an HTTP status means for a candidate page. 404/410 → skipped (a single-page\n * site has no `/contact`, and that is fine) — except `/`, which must exist. Any other\n * non-2xx is an error: a 500 on `/privacy` is a defect, not an absent page.\n */\nexport function classifyStatus(\n path: string,\n httpStatus: number | undefined,\n): { status: 'audited' | 'skipped' | 'error'; reason?: string } {\n if (httpStatus === undefined) return { status: 'error', reason: 'no response' };\n if (httpStatus >= 200 && httpStatus < 300) return { status: 'audited' };\n if (httpStatus === 404 || httpStatus === 410) {\n return path === '/'\n ? { status: 'error', reason: `/ returned ${httpStatus} — the home page must exist` }\n : { status: 'skipped', reason: `${httpStatus} — page does not exist on this site` };\n }\n return { status: 'error', reason: `HTTP ${httpStatus}` };\n}\n\n// ── Budgets ─────────────────────────────────────────────────────────────────\n\nconst IMPACT_RANK: Record<AxeImpact, number> = { minor: 0, moderate: 1, serious: 2, critical: 3 };\n\n/** axe can report a null impact; treat it as `moderate`, never silently drop it. */\nexport function normalizeImpact(impact: string | null | undefined): AxeImpact {\n return impact && impact in IMPACT_RANK ? (impact as AxeImpact) : 'moderate';\n}\n\n/** `serious,critical` → impacts, rejecting typos so a budget cannot silently be empty. */\nexport function parseImpacts(list: string): AxeImpact[] {\n const parts = list\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n for (const p of parts)\n if (!(p in IMPACT_RANK))\n throw new Error(\n `unknown axe impact \"${p}\" (expected: minor | moderate | serious | critical)`,\n );\n if (parts.length === 0) throw new Error('at least one axe impact is required');\n return parts as AxeImpact[];\n}\n\n/** Everything that makes one audited page fail. Empty = the page passes. */\nexport function evaluatePage(\n metrics: PageMetrics | undefined,\n violations: AxeViolation[] | undefined,\n budgets: AuditBudgets,\n): BudgetFailure[] {\n const failures: BudgetFailure[] = [];\n\n const check = (\n kind: 'lcp' | 'cls' | 'tbt',\n value: number | undefined,\n limit: number,\n label: string,\n unit: string,\n ) => {\n if (value === undefined || Number.isNaN(value)) {\n // A metric Lighthouse could not compute must not pass by default.\n failures.push({ kind: 'metric-missing', message: `${label} could not be measured` });\n } else if (value > limit) {\n failures.push({\n kind,\n message: `${label} ${fmt(value, unit)} exceeds the budget of ${fmt(limit, unit)}`,\n });\n }\n };\n check('lcp', metrics?.lcpMs, budgets.lcpMs, 'LCP', 'ms');\n check('cls', metrics?.cls, budgets.cls, 'CLS', '');\n check('tbt', metrics?.tbtMs, budgets.tbtMs, 'TBT', 'ms');\n\n for (const v of violations ?? []) {\n if (budgets.blockingImpacts.includes(v.impact)) {\n failures.push({\n kind: 'axe',\n message: `axe ${v.impact}: ${v.id} — ${v.help} (${v.nodeCount} node${v.nodeCount === 1 ? '' : 's'})`,\n });\n }\n }\n return failures;\n}\n\nfunction fmt(n: number, unit: string): string {\n const v = unit === 'ms' ? Math.round(n) : Number(n.toFixed(3));\n return unit ? `${v} ${unit}` : String(v);\n}\n\n/** Build a `PageResult` for a page that was not audited (skipped or error). */\nexport function unauditedPage(\n path: string,\n url: string,\n httpStatus: number | undefined,\n verdict: { status: 'skipped' | 'error'; reason?: string },\n): PageResult {\n return {\n path,\n url,\n status: verdict.status,\n ...(httpStatus !== undefined ? { httpStatus } : {}),\n ...(verdict.reason ? { reason: verdict.reason } : {}),\n failures:\n verdict.status === 'error'\n ? [\n {\n kind: path === '/' ? 'required-page-missing' : 'page-error',\n message: verdict.reason ?? 'page error',\n },\n ]\n : [],\n };\n}\n\nexport function auditedPage(\n path: string,\n url: string,\n httpStatus: number,\n metrics: PageMetrics,\n violations: AxeViolation[],\n budgets: AuditBudgets,\n): PageResult {\n return {\n path,\n url,\n status: 'audited',\n httpStatus,\n metrics,\n violations,\n failures: evaluatePage(metrics, violations, budgets),\n };\n}\n\nexport function buildReport(input: {\n generatedAt: string;\n baseUrl: string;\n formFactor: FormFactor;\n budgets: AuditBudgets;\n pages: PageResult[];\n}): AuditReport {\n return {\n generatedAt: input.generatedAt,\n baseUrl: input.baseUrl,\n formFactor: input.formFactor,\n budgets: input.budgets,\n axeTags: AXE_TAGS,\n pages: input.pages,\n passed: input.pages.every((p) => p.failures.length === 0),\n disclaimer: DISCLAIMER,\n manualChecks: MANUAL_CHECKS,\n };\n}\n\n// ── Rendering ───────────────────────────────────────────────────────────────\n\n/** `/` → `home`, `/a/b` → `a-b`: a safe per-page filename. */\nexport function pageSlug(path: string): string {\n const s = path.replace(/^\\/+|\\/+$/g, '').replace(/[^a-zA-Z0-9]+/g, '-');\n return s || 'home';\n}\n\nconst dash = (v: number | undefined, f: (n: number) => string) => (v === undefined ? '—' : f(v));\n\nexport function renderPageMarkdown(page: PageResult, budgets: AuditBudgets): string {\n const lines: string[] = [];\n const verdict =\n page.status === 'skipped' ? 'SKIPPED' : page.failures.length === 0 ? 'PASS' : 'FAIL';\n lines.push(`### \\`${page.path}\\` — ${verdict}`);\n lines.push('');\n if (page.status !== 'audited') {\n lines.push(page.reason ?? page.status);\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n return lines.join('\\n');\n }\n const m = page.metrics;\n lines.push('| Metric | Value | Budget |', '| --- | --- | --- |');\n lines.push(\n `| LCP | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${budgets.lcpMs} ms |`,\n `| CLS | ${dash(m?.cls, (n) => n.toFixed(3))} | ${budgets.cls} |`,\n `| TBT | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${budgets.tbtMs} ms |`,\n `| Performance | ${dash(m?.scores.performance, String)} | — |`,\n `| SEO | ${dash(m?.scores.seo, String)} | — |`,\n `| Best practices | ${dash(m?.scores.bestPractices, String)} | — |`,\n );\n lines.push('');\n const v = page.violations ?? [];\n if (v.length === 0) lines.push('axe: no violations.');\n else {\n lines.push(`axe: ${v.length} violated rule${v.length === 1 ? '' : 's'}.`, '');\n for (const x of [...v].sort((a, b) => IMPACT_RANK[b.impact] - IMPACT_RANK[a.impact])) {\n const blocking = budgets.blockingImpacts.includes(x.impact) ? ' (blocking)' : '';\n lines.push(\n `- **${x.impact}**${blocking} \\`${x.id}\\` — ${x.help} · ${x.nodeCount} node${x.nodeCount === 1 ? '' : 's'} · [rule](${x.helpUrl})`,\n );\n for (const t of x.targets) lines.push(` - \\`${t}\\``);\n }\n }\n if (page.failures.length) {\n lines.push('', 'Failures:');\n for (const f of page.failures) lines.push(`- ✖ ${f.message}`);\n }\n return lines.join('\\n');\n}\n\nexport function renderMarkdown(report: AuditReport): string {\n const lines: string[] = [];\n lines.push('# Performance and accessibility audit', '');\n lines.push(\n `**${report.passed ? 'PASS' : 'FAIL'}** · ${report.baseUrl} · ${report.formFactor} · ${report.generatedAt}`,\n '',\n );\n lines.push(`> ${report.disclaimer}`, '');\n lines.push(\n `Budgets: LCP < ${report.budgets.lcpMs} ms, CLS < ${report.budgets.cls}, TBT < ${report.budgets.tbtMs} ms, ` +\n `no axe ${report.budgets.blockingImpacts.join(' or ')} violations. axe tags: ${report.axeTags.join(', ')}.`,\n '',\n );\n lines.push(\n '| Page | Result | LCP | CLS | TBT | Perf | SEO | BP |',\n '| --- | --- | --- | --- | --- | --- | --- | --- |',\n );\n for (const p of report.pages) {\n const m = p.metrics;\n const verdict = p.status === 'skipped' ? 'skipped' : p.failures.length ? 'FAIL' : 'pass';\n lines.push(\n `| \\`${p.path}\\` | ${verdict} | ${dash(m?.lcpMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.cls, (n) => n.toFixed(3))} | ${dash(m?.tbtMs, (n) => `${Math.round(n)} ms`)} | ${dash(m?.scores.performance, String)} | ${dash(m?.scores.seo, String)} | ${dash(m?.scores.bestPractices, String)} |`,\n );\n }\n lines.push('');\n for (const p of report.pages) lines.push(renderPageMarkdown(p, report.budgets), '');\n lines.push('## Manual checks still required', '');\n for (const c of report.manualChecks) lines.push(`- [ ] ${c}`);\n lines.push('');\n return lines.join('\\n');\n}\n\n/** Files to write, relative to the output dir: JSON + markdown overall and per page. */\nexport function reportFiles(report: AuditReport): Record<string, string> {\n const files: Record<string, string> = {\n 'report.json': `${JSON.stringify(report, null, 2)}\\n`,\n 'report.md': renderMarkdown(report),\n };\n for (const p of report.pages) {\n if (p.status === 'skipped') continue;\n const slug = pageSlug(p.path);\n files[`pages/${slug}.json`] = `${JSON.stringify(p, null, 2)}\\n`;\n files[`pages/${slug}.md`] =\n `${renderPageMarkdown(p, report.budgets)}\\n\\n> ${report.disclaimer}\\n`;\n }\n return files;\n}\n\n/** One-line-per-failure console summary. */\nexport function formatSummary(report: AuditReport): string {\n const lines: string[] = [];\n for (const p of report.pages) {\n if (p.status === 'skipped') lines.push(` – ${p.path} skipped (${p.reason})`);\n else if (p.failures.length === 0) lines.push(` ✓ ${p.path}`);\n else {\n lines.push(` ✖ ${p.path}`);\n for (const f of p.failures) lines.push(` ${f.message}`);\n }\n }\n lines.push('', report.passed ? '✓ no automated violations' : '✖ audit failed');\n lines.push('Note: a pass is NOT AODA certification — see the manual checks in report.md.');\n return lines.join('\\n');\n}\n","/**\n * `vitops audit` — the I/O half. Starts (or attaches to) a server, drives Chromium,\n * runs axe and Lighthouse, and hands the raw numbers to `plan.ts`. It decides nothing:\n * which pages, what a status means and whether a number breaches a budget are all\n * `plan.ts`'s.\n *\n * `playwright`, `@axe-core/playwright` and `lighthouse` are OPTIONAL peer dependencies,\n * loaded lazily. They are heavy (a browser driver and ~100 MB of Lighthouse) and a\n * consumer who never runs `vitops audit` must not pay for them. A missing one fails\n * with the install command, not a bare `Cannot find package`.\n *\n * Lighthouse runs on the SAME Chromium Playwright launched — Playwright opens it with\n * `--remote-debugging-port`, axe drives it through Playwright, and Lighthouse attaches\n * over that port — so one browser binary serves both and nothing here needs Chrome\n * installed system-wide.\n */\nimport { spawn, type ChildProcess } from 'node:child_process';\nimport { existsSync } from 'node:fs';\nimport { createServer } from 'node:net';\nimport {\n AXE_TAGS,\n auditedPage,\n classifyStatus,\n isSitemapIndex,\n normalizeImpact,\n pathsFromSitemapLocs,\n sitemapLocs,\n unauditedPage,\n} from './plan.ts';\nimport type { AuditBudgets, AxeViolation, FormFactor, PageMetrics, PageResult } from './types.ts';\n\nconst INSTALL =\n 'vp add -D playwright @axe-core/playwright lighthouse && vp exec playwright install chromium';\n\n/** Load an optional dependency or throw an error that says how to get it. */\nasync function loadOptional<T>(name: string): Promise<T> {\n try {\n // A variable specifier keeps bundlers and the type checker from resolving it —\n // that is the whole point of \"optional\".\n return (await import(/* @vite-ignore */ name)) as T;\n } catch (err) {\n const code = (err as { code?: string }).code;\n if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND')\n throw new Error(\n `vitops audit needs \"${name}\", which is an optional dependency and is not installed.\\n` +\n ` Install: ${INSTALL}`,\n { cause: err },\n );\n throw err;\n }\n}\n\n/**\n * Check, before any server starts, that everything the audit needs is there: the three optional\n * packages and a Chromium binary. A missing browser used to surface only after the preview\n * server was up, and the failed launch left it running.\n */\nexport async function preflight(): Promise<void> {\n const pw = await loadOptional<{ chromium: { executablePath(): string } }>('playwright');\n await loadOptional('@axe-core/playwright');\n await loadOptional('lighthouse');\n let exe = '';\n try {\n exe = pw.chromium.executablePath();\n } catch {\n /* treated as missing below */\n }\n if (!exe || !existsSync(exe))\n throw new Error(\n 'Chromium is not installed for Playwright.\\n Install: vp exec playwright install chromium',\n );\n}\n\n/**\n * Page paths from the served site's sitemap (`/sitemap-index.xml`, then `/sitemap.xml`), following\n * an index one level to its child sitemaps. Empty when the site serves none.\n */\nexport async function fetchSitemapPaths(baseUrl: string): Promise<string[]> {\n const get = async (path: string): Promise<string | undefined> => {\n try {\n const res = await fetch(new URL(path, baseUrl), { redirect: 'follow' });\n return res.ok ? await res.text() : undefined;\n } catch {\n return undefined;\n }\n };\n for (const root of ['/sitemap-index.xml', '/sitemap.xml']) {\n const xml = await get(root);\n if (!xml) continue;\n if (!isSitemapIndex(xml)) return pathsFromSitemapLocs(sitemapLocs(xml));\n const out: string[] = [];\n for (const loc of sitemapLocs(xml)) {\n // The index names the production host; read the same file from the server being audited.\n const child = await get(new URL(loc).pathname);\n if (child) out.push(...pathsFromSitemapLocs(sitemapLocs(child)));\n }\n return out;\n }\n return [];\n}\n\n/** A free TCP port, or `true` for \"this specific one is free\". */\nexport function isPortFree(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = createServer();\n s.once('error', () => resolve(false));\n s.listen(port, '127.0.0.1', () => s.close(() => resolve(true)));\n });\n}\n\nfunction freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const s = createServer();\n s.once('error', reject);\n s.listen(0, '127.0.0.1', () => {\n const { port } = s.address() as { port: number };\n s.close(() => resolve(port));\n });\n });\n}\n\n/** Run a command to completion, inheriting stdio. Rejects on a non-zero exit. */\nexport function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });\n child.once('error', reject);\n child.once('exit', (code) =>\n code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(' ')} exited with ${code}`)),\n );\n });\n}\n\nexport interface PreviewServer {\n baseUrl: string;\n stop(): void;\n}\n\n/**\n * `astro preview` on a dedicated port.\n *\n * Astro's preview server silently moves to the next port when its own is taken, and\n * a readiness probe would then happily attach to whatever owns the original one — the\n * failure `site/playwright.config.ts` documents. So the port is checked free first and\n * the run aborts if it is not.\n */\nexport async function startPreview(opts: {\n cwd: string;\n port: number;\n timeoutMs?: number;\n}): Promise<PreviewServer> {\n if (!(await isPortFree(opts.port)))\n throw new Error(\n `port ${opts.port} is already in use — refusing to audit whatever is listening there. ` +\n `Pass --port <free port>, or --url to audit a server you started yourself.`,\n );\n const baseUrl = `http://127.0.0.1:${opts.port}`;\n const child: ChildProcess = spawn(\n 'npx',\n ['--no-install', 'astro', 'preview', '--host', '127.0.0.1', '--port', String(opts.port)],\n { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' },\n );\n let log = '';\n child.stdout?.on('data', (d) => (log += String(d)));\n child.stderr?.on('data', (d) => (log += String(d)));\n let exited: number | null | undefined;\n child.once('exit', (code) => (exited = code));\n\n const stop = () => {\n try {\n if (child.pid && process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');\n else child.kill('SIGTERM');\n } catch {\n /* already gone */\n }\n };\n\n const deadline = Date.now() + (opts.timeoutMs ?? 60_000);\n while (Date.now() < deadline) {\n if (exited !== undefined) {\n throw new Error(\n `astro preview exited early (code ${exited}). Did you build first? Pass --build.\\n${log.trim()}`,\n );\n }\n try {\n await fetch(baseUrl, { redirect: 'manual' });\n return { baseUrl, stop };\n } catch {\n await new Promise((r) => setTimeout(r, 250));\n }\n }\n stop();\n throw new Error(`astro preview did not answer on ${baseUrl} within the timeout.\\n${log.trim()}`);\n}\n\n// ── Browser tooling (structural types: these modules are optional) ───────────\n\ninterface AxeResult {\n violations: Array<{\n id: string;\n impact?: string | null;\n help: string;\n helpUrl: string;\n nodes: Array<{ target: unknown[] }>;\n }>;\n}\ninterface LighthouseAudits {\n [id: string]: { numericValue?: number } | undefined;\n}\ninterface LighthouseRun {\n lhr: {\n audits: LighthouseAudits;\n categories: Record<string, { score: number | null } | undefined>;\n };\n}\n\nfunction toViolations(res: AxeResult): AxeViolation[] {\n return res.violations.map((v) => ({\n id: v.id,\n impact: normalizeImpact(v.impact),\n help: v.help,\n helpUrl: v.helpUrl,\n nodeCount: v.nodes.length,\n targets: v.nodes.slice(0, 3).map((n) => String(n.target.flat().join(' '))),\n }));\n}\n\nfunction toMetrics(run: LighthouseRun): PageMetrics {\n const a = run.lhr.audits;\n const score = (id: string) => {\n const s = run.lhr.categories[id]?.score;\n return s == null ? undefined : Math.round(s * 100);\n };\n return {\n lcpMs: a['largest-contentful-paint']?.numericValue,\n cls: a['cumulative-layout-shift']?.numericValue,\n tbtMs: a['total-blocking-time']?.numericValue,\n scores: {\n performance: score('performance'),\n seo: score('seo'),\n bestPractices: score('best-practices'),\n },\n };\n}\n\nexport interface RunOptions {\n baseUrl: string;\n paths: string[];\n budgets: AuditBudgets;\n formFactor: FormFactor;\n onProgress?: (message: string) => void;\n}\n\n/** Audit each path. Never throws for a bad PAGE — only for missing tooling/browser. */\nexport async function runAudit(opts: RunOptions): Promise<PageResult[]> {\n const log = opts.onProgress ?? (() => {});\n const [pw, axe, lh] = await Promise.all([\n loadOptional<{\n chromium: {\n launch(o: { headless: boolean; args: string[] }): Promise<{\n newContext(): Promise<{\n newPage(): Promise<{\n goto(u: string, o: { waitUntil: string }): Promise<unknown>;\n }>;\n close(): Promise<void>;\n }>;\n close(): Promise<void>;\n }>;\n };\n }>('playwright'),\n loadOptional<{\n AxeBuilder: new (o: { page: unknown }) => {\n withTags(t: string[]): { analyze(): Promise<AxeResult> };\n };\n }>('@axe-core/playwright'),\n loadOptional<{\n default: (\n url: string,\n flags: Record<string, unknown>,\n config?: unknown,\n ) => Promise<LighthouseRun | undefined>;\n }>('lighthouse'),\n ]);\n\n const debugPort = await freePort();\n let browser;\n try {\n browser = await pw.chromium.launch({\n headless: true,\n args: [`--remote-debugging-port=${debugPort}`],\n });\n } catch (err) {\n throw new Error(\n `could not launch Chromium: ${(err as Error).message}\\n Install a browser: vp exec playwright install chromium`,\n { cause: err },\n );\n }\n\n const results: PageResult[] = [];\n try {\n for (const path of opts.paths) {\n const url = new URL(path, opts.baseUrl).href;\n let httpStatus: number | undefined;\n try {\n httpStatus = (await fetch(url, { redirect: 'follow' })).status;\n } catch {\n httpStatus = undefined;\n }\n const verdict = classifyStatus(path, httpStatus);\n if (verdict.status !== 'audited') {\n log(`${path}: ${verdict.status} (${verdict.reason})`);\n results.push(unauditedPage(path, url, httpStatus, verdict as never));\n continue;\n }\n\n log(`${path}: axe`);\n // axe-playwright refuses a page that did not come from `newContext()`.\n const context = await browser.newContext();\n const page = await context.newPage();\n let violations: AxeViolation[];\n try {\n await page.goto(url, { waitUntil: 'load' });\n violations = toViolations(await new axe.AxeBuilder({ page }).withTags(AXE_TAGS).analyze());\n } finally {\n await context.close();\n }\n\n log(`${path}: lighthouse`);\n const flags: Record<string, unknown> = {\n port: debugPort,\n output: 'json',\n logLevel: 'error',\n onlyCategories: ['performance', 'seo', 'best-practices'],\n formFactor: opts.formFactor,\n ...(opts.formFactor === 'desktop'\n ? {\n screenEmulation: {\n mobile: false,\n width: 1350,\n height: 940,\n deviceScaleFactor: 1,\n disabled: false,\n },\n throttling: {\n rttMs: 40,\n throughputKbps: 10 * 1024,\n cpuSlowdownMultiplier: 1,\n requestLatencyMs: 0,\n downloadThroughputKbps: 0,\n uploadThroughputKbps: 0,\n },\n }\n : {}),\n };\n const run = await lh.default(url, flags);\n const metrics: PageMetrics = run ? toMetrics(run) : { scores: {} };\n results.push(auditedPage(path, url, httpStatus!, metrics, violations, opts.budgets));\n }\n } finally {\n await browser.close();\n }\n return results;\n}\n"],"mappings":"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"}
|
package/dist/notify/index.d.mts
CHANGED
|
@@ -109,6 +109,14 @@ export declare function resolveSender(channel: EmailChannelConfig, ctx: NotifyCo
|
|
|
109
109
|
* report the misconfiguration instead of reporting success over a no-op.
|
|
110
110
|
*/
|
|
111
111
|
export declare function planNotifications(ctx: NotifyContext): NotificationPlan;
|
|
112
|
+
/**
|
|
113
|
+
* Whether a form post has anywhere to go: a `notifications.email` channel, a location e-mail,
|
|
114
|
+
* or a CRM. With none of the three the emdash template's `/api/track` refuses the post (it
|
|
115
|
+
* would otherwise accept it and lose the message), so `vitops emdash check-prerender` asks the
|
|
116
|
+
* same question of the config before the first visitor finds out. One function so the build
|
|
117
|
+
* check and the route cannot disagree.
|
|
118
|
+
*/
|
|
119
|
+
export declare function hasFormDelivery(ctx: Pick<NotifyContext, 'notifications' | 'locationEmails'>, crm?: unknown): boolean;
|
|
112
120
|
/** One section: a heading and its lines. Empty sections are dropped by the caller. */
|
|
113
121
|
interface Section {
|
|
114
122
|
heading: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"mappings":";;;;;;;;;;;;;;;;;UAeiB;EACf;EACA;EACA;;KAQU,qBAAqB,QAAQ,YAAY;;UAGpC;EACf,gBAAgB;;EAEhB;;EAEA;;;;;EAKA;;;UAIe;EACf;;EAEA;;UAGe,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf,OAAO;;EAEP;;UAGe;EACf;EACA;;EAEA;;EAEA;;;;;;;;;;UAWe;EACf,KAAK;IACH;IACA;MAAiB;MAAe;;IAChC;IACA;IACA;IACA;MACE;;;qBCvEO;;wBAGG,sBACd,gBAAgB,iCACf;;;;;;;;wBAYa,iBACd,SAAS,oBACT,KAAK;;;;;;;;wBAaS,cAAc,SAAS,oBAAoB,KAAK;;;;;;;wBAmBhD,kBAAkB,KAAK,gBAAgB;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/notify/types.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/email.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"mappings":";;;;;;;;;;;;;;;;;UAeiB;EACf;EACA;EACA;;KAQU,qBAAqB,QAAQ,YAAY;;UAGpC;EACf,gBAAgB;;EAEhB;;EAEA;;;;;EAKA;;;UAIe;EACf;;EAEA;;UAGe,kBAAkB;EACjC;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf,OAAO;;EAEP;;UAGe;EACf;EACA;;EAEA;;EAEA;;;;;;;;;;UAWe;EACf,KAAK;IACH;IACA;MAAiB;MAAe;;IAChC;IACA;IACA;IACA;MACE;;;qBCvEO;;wBAGG,sBACd,gBAAgB,iCACf;;;;;;;;wBAYa,iBACd,SAAS,oBACT,KAAK;;;;;;;;wBAaS,cAAc,SAAS,oBAAoB,KAAK;;;;;;;wBAmBhD,kBAAkB,KAAK,gBAAgB;;;;;;;;wBA6DvC,gBACd,KAAK,KAAK,oDACV;;UC5FQ;EACR;EACA;;;;;;;;wBAyDc,cAAc,OAAO;EAAoB;EAAiB,UAAU;;;wBA4DpE,YACd,OAAO,iBACP;EAAS;EAA8B;IACtC;UCxHc;;EAEf;;;;;;;;;;;;;EAaA;;EAEA,SAAS,eAAe;;;;;;;;;;wBA0CJ,UACpB,MAAM,WACN,SAAS,iBACT,SAAS,cACT,UAAS,mBACR,QAAQ;;;;;;;;wBAkDK,SACd,MAAM,WACN,SAAS,iBACT,OAAM,wBACL;UC9Hc;;;EAGf;EACA,OAAO;;;;EAIP;;UAGe;;;EAGf;;;EAGA;;EAEA;IAAW;IAAe;;;;EAG1B;EACA;EACA;EACA;;;;;UAMe;EACf;EACA;EACA;;;;;wBAUc,6BACd,oBACA;;;;wBASc,2BAA2B;;;;;;;;;;;wBA4BrB,sBACpB,OAAO,sBACP,aAAa,sBACb,mBAAkB,QACjB,QAAQ;;;wBA6EK,+BACd,QAAQ;iBC9JO;;;;;EAKf,QAAQ;;iBAGO;EACf,SAAS;;EAET;;;;;;;;;wBAUoB,OACpB,OAAO,iBACP,KAAK,eACL,OAAM,YACN;EAAS;IACR,QAAQ"}
|
package/dist/notify/index.mjs
CHANGED
|
@@ -208,6 +208,16 @@ function planEmail(ctx) {
|
|
|
208
208
|
enabled: true
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Whether a form post has anywhere to go: a `notifications.email` channel, a location e-mail,
|
|
213
|
+
* or a CRM. With none of the three the emdash template's `/api/track` refuses the post (it
|
|
214
|
+
* would otherwise accept it and lose the message), so `vitops emdash check-prerender` asks the
|
|
215
|
+
* same question of the config before the first visitor finds out. One function so the build
|
|
216
|
+
* check and the route cannot disagree.
|
|
217
|
+
*/
|
|
218
|
+
function hasFormDelivery(ctx, crm) {
|
|
219
|
+
return Boolean(ctx.notifications?.email || ctx.locationEmails?.length || crm);
|
|
220
|
+
}
|
|
211
221
|
|
|
212
222
|
// src/notify/render.ts
|
|
213
223
|
/**
|
|
@@ -522,5 +532,5 @@ async function notify(event, ctx, deps = {}, extra = {}) {
|
|
|
522
532
|
};
|
|
523
533
|
}
|
|
524
534
|
|
|
525
|
-
export { DEFAULT_EMAIL_BINDING, conversionActionResourceName, describeEvent, describeGoogleAdsOfflineResult, logEmail, normalizeEmailChannel, notify, planNotifications, renderEmail, resolveRecipient, resolveSender, sendEmail, toGoogleConversionDateTime, uploadClickConversion };
|
|
535
|
+
export { DEFAULT_EMAIL_BINDING, conversionActionResourceName, describeEvent, describeGoogleAdsOfflineResult, hasFormDelivery, logEmail, normalizeEmailChannel, notify, planNotifications, renderEmail, resolveRecipient, resolveSender, sendEmail, toGoogleConversionDateTime, uploadClickConversion };
|
|
526
536
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * One line, already rendered both ways — a plain fact (`html` is just the\n * escaped `text`) or a link (`html` an anchor, `text` the bare href). Built at\n * the point each line is known rather than carried as a union and branched on\n * later: `describeEvent`'s lines are always plain `[label, value]` pairs (see\n * `Section` above, which `render.test.ts` destructures as such), and the one\n * line shape `renderEmail` adds itself — the \"Actions\" link — is turned into\n * this same rendered shape immediately, so the two renderers below share one\n * line format instead of branching on what kind of line each one is.\n */\ninterface RenderedLine {\n label: string;\n text: string;\n html: string;\n}\n\n/** A section as `renderEmail` assembles it — `describeEvent`'s output plus its own additions. */\ninterface RenderSection {\n heading: string;\n lines: RenderedLine[];\n}\n\nfunction factLine([label, value]: readonly [string, string]): RenderedLine {\n return { label, text: value, html: escapeHtml(value) };\n}\n\nfunction linkLine(label: string, href: string): RenderedLine {\n return { label, text: href, html: `<a href=\"${escapeHtml(href)}\">${escapeHtml(href)}</a>` };\n}\n\nfunction toRenderSection(section: Section): RenderSection {\n return { heading: section.heading, lines: section.lines.map(factLine) };\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n return identifySubmitter(formData).fullName;\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else if (event.type === 'email') {\n const email = event.email ?? 'unknown';\n subject = `[Email Click] Visitor emailing ${email}`;\n contact.push(['Email', email]);\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(\n event: ConversionEvent,\n extra: { notes?: string[] | undefined; actionUrl?: string | undefined } = {},\n): RenderedMessage {\n const { subject, sections: eventSections } = describeEvent(event);\n\n // `{clickId}` is the only placeholder — an event has no durable id nothing\n // in this toolchain could look back up (there's no store), so interpolating\n // one would be decorative. Omitted entirely when there's no click ID to\n // interpolate, rather than emailing a link with a literal `{clickId}` in it.\n const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;\n const actionSection: RenderSection | null =\n extra.actionUrl && clickId\n ? {\n heading: 'Actions',\n lines: [\n linkLine(\n 'View in ad platform',\n extra.actionUrl.replace(/\\{clickId\\}/g, encodeURIComponent(clickId.value)),\n ),\n ],\n }\n : null;\n\n // Deliberately a channel-neutral `string[]` rather than a typed CRM result —\n // this module gains no dependency on `crm`, and the seam serves any future\n // sink the same way.\n const sections: RenderSection[] = [\n ...eventSections.map(toRenderSection),\n ...(actionSection ? [actionSection] : []),\n ...(extra.notes && extra.notes.length\n ? [{ heading: 'Delivery', lines: extra.notes.map((n) => factLine(['CRM', n])) }]\n : []),\n ];\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n (l) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(l.label)}</td>` +\n `<td style=\"padding:2px 0\">${l.html}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Google Ads offline conversion upload — the server-side counterpart to the\n * client-side `gtag('event', 'conversion', …)` call `gtagBootstrap`\n * (`@getvitops/utils/ads`) wires up. Reads `site.ads.google.reporting: 'server'`\n * and a matching `conversionActionId` (see that field's own docblock for why\n * it is a different identifier from `conversionLabel`, not an alternate\n * spelling of it) and calls `ConversionUploadService.UploadClickConversions`\n * instead of relying on a visitor's browser to fire the tag.\n *\n * Same reasoning as `@getvitops/utils/crm`'s executors: credentials are a\n * structurally-typed shape the consumer's route supplies from env vars, never\n * config, and a failed upload is a `GoogleAdsUploadResult` to log or fold into\n * the notification email — never an exception, and never something that fails\n * the conversion request it's attached to. A lost ad report is not a lost\n * lead; the visitor has already submitted.\n *\n * **Structurally typed against Google's documented REST shape, not verified\n * against a live Ads account** — no developer-token/MCC access exists\n * anywhere in this toolchain's own setup to test against (the same blocker\n * `vitops ads provision` is waiting on). Same caveat, and the same reason, as\n * `@getvitops/utils/crm`'s Jobber executor: wrapped so a wrong guess about the\n * exact request/response shape degrades to a stated failure reason rather\n * than breaking. Re-verify against `googleads.googleapis.com`'s current API\n * version and `ClickConversion` message shape before depending on this in\n * production, and update this docblock with what was found.\n */\nimport { errorDetail, googleAccessToken, googleHeaders } from '../google/token.ts';\nimport type { GoogleOAuth } from '../google/token.ts';\n\n/** Bumped independently of everything else here — the Ads API deprecates\n * versions on its own rolling schedule. Last checked against Google's public\n * docs at the time this file was written; not re-verified since. */\nconst API_VERSION = 'v17';\n\nexport interface GoogleAdsCredentials {\n /** Issued against a Google Ads manager (MCC) account — a Google review, not\n * a self-service toggle. Never config. */\n developerToken: string;\n oauth: GoogleOAuth;\n /** The MCC's own (dashes-or-not) customer id — required when `customerId`\n * in the upload input is a client account managed under it, which is the\n * common case for an agency running this on a client's behalf. */\n loginCustomerId?: string | undefined;\n}\n\nexport interface GoogleAdsUploadInput {\n /** Google Ads customer id, as shown in the UI (`\"123-456-7890\"`) or bare\n * digits — either works, dashes are stripped before the call. */\n customerId: string;\n /** A numeric conversion action id, or a full\n * `customers/…/conversionActions/…` resource name. */\n conversionActionId: string;\n /** The click id captured on `_ac` — `getPrimaryClickId()`'s result. */\n clickId: { param: string; value: string };\n /** Epoch ms — converted to the Ads API's own\n * `\"yyyy-MM-dd HH:mm:ss+00:00\"` format. */\n at: number;\n conversionValue?: number | undefined;\n currencyCode?: string | undefined;\n orderId?: string | undefined;\n}\n\n/** Never thrown — an Ads API outage or a malformed request both come back as\n * `ok: false`, the same \"structured result, not an exception\" rule every\n * other executor in this toolchain follows. */\nexport interface GoogleAdsUploadResult {\n ok: boolean;\n reason?: string | undefined;\n code?: string | undefined;\n}\n\nfunction digitsOnly(id: string): string {\n return id.replace(/\\D/g, '');\n}\n\n/** A bare numeric id becomes a full resource name under `customerId`; a value\n * already shaped like one (the escape hatch, matching `txtRecord`'s `=`\n * check in `@getvitops/utils/ads`) passes through unchanged. */\nexport function conversionActionResourceName(\n customerId: string,\n conversionActionId: string,\n): string {\n if (conversionActionId.includes('customers/')) return conversionActionId;\n return `customers/${digitsOnly(customerId)}/conversionActions/${conversionActionId}`;\n}\n\n/** The Ads API's documented click-conversion datetime format, always\n * rendered in UTC — there is no per-account timezone lookup here to do\n * better, and UTC is unambiguous. */\nexport function toGoogleConversionDateTime(atMs: number): string {\n const d = new Date(atMs);\n const pad = (n: number): string => String(n).padStart(2, '0');\n return (\n `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +\n `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+00:00`\n );\n}\n\n/** The request field a click id param maps to — the API takes `gclid`/\n * `gbraid`/`wbraid` as distinct fields, not one generic \"click id\" field.\n * Anything else this toolchain's `PLATFORM_PARAMS` maps to Google (today,\n * only these three) falls back to `gclid`. */\nfunction clickIdField(param: string): 'gclid' | 'gbraid' | 'wbraid' {\n if (param === 'gbraid' || param === 'wbraid') return param;\n return 'gclid';\n}\n\n/**\n * Upload one click conversion via `ConversionUploadService.UploadClickConversions`.\n *\n * `partialFailure: true` is always sent, and a 200 response carrying\n * `partialFailureError` is treated as a failure, not a success — a single-\n * conversion batch makes \"partial\" and \"total\" failure the same case, and\n * Google Ads API responses are 200 for a partial failure by design (silently\n * treating that as success is exactly the trap `@getvitops/utils/crm`'s\n * Jobber executor documents for its own GraphQL transport).\n */\nexport async function uploadClickConversion(\n input: GoogleAdsUploadInput,\n credentials: GoogleAdsCredentials,\n fetchImpl: typeof fetch = fetch,\n): Promise<GoogleAdsUploadResult> {\n let token: string;\n try {\n token = await googleAccessToken({ kind: 'oauth', oauth: credentials.oauth }, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const customerId = digitsOnly(input.customerId);\n const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadClickConversions`;\n\n const body = {\n conversions: [\n {\n conversionAction: conversionActionResourceName(customerId, input.conversionActionId),\n [clickIdField(input.clickId.param)]: input.clickId.value,\n conversionDateTime: toGoogleConversionDateTime(input.at),\n ...(input.conversionValue != null ? { conversionValue: input.conversionValue } : {}),\n ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),\n ...(input.orderId ? { orderId: input.orderId } : {}),\n },\n ],\n partialFailure: true,\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: googleHeaders(token, {\n 'developer-token': credentials.developerToken,\n 'content-type': 'application/json',\n ...(credentials.loginCustomerId\n ? { 'login-customer-id': digitsOnly(credentials.loginCustomerId) }\n : {}),\n }),\n body: JSON.stringify(body),\n });\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (!res.ok) {\n return {\n ok: false,\n code: String(res.status),\n reason: await errorDetail(res, (status) =>\n status === 401\n ? 'Google Ads rejected the access token.'\n : status === 403\n ? 'Google Ads refused the request — check the developer token is approved and `login-customer-id` is set for an MCC-managed account.'\n : undefined,\n ),\n };\n }\n\n const json = (await res.json().catch(() => ({}))) as {\n partialFailureError?: { message?: string };\n };\n if (json.partialFailureError) {\n return {\n ok: false,\n reason: `Google Ads rejected the conversion: ${json.partialFailureError.message ?? 'no detail given'}`,\n };\n }\n\n return { ok: true };\n}\n\n/** A one-line delivery note, matching `describeCrmResult`'s shape — what\n * `conversion.ts` folds into the notification email's \"Delivery\" section. */\nexport function describeGoogleAdsOfflineResult(\n result: GoogleAdsUploadResult | null,\n): string | null {\n if (!result) return null;\n if (result.ok) return 'Reported to Google Ads (server-side)';\n return `⚠ Not reported to Google Ads: ${result.reason ?? 'unknown reason'}`;\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * `google-ads.ts` is a fourth file, added later, that does **not** follow the\n * three-file shape above: it isn't a `NotificationsConfig` channel `notify()`\n * plans and dispatches, it's a separate delivery keyed to `site.ads.google`,\n * wired in by `createConversionRoute`'s own `googleAdsOffline` option and\n * folded into the email's \"Delivery\" section exactly like `@getvitops/utils/\n * crm`'s `CrmResult` — see that module's `describeCrmResult` for the\n * precedent this follows (`describeGoogleAdsOfflineResult` here).\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n extra: { notes?: string[] | undefined } = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event, { ...extra, actionUrl: ctx.notifications?.actionUrl });\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport {\n conversionActionResourceName,\n describeGoogleAdsOfflineResult,\n toGoogleConversionDateTime,\n uploadClickConversion,\n} from './google-ads.ts';\nexport type {\n GoogleAdsCredentials,\n GoogleAdsUploadInput,\n GoogleAdsUploadResult,\n} from './google-ads.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.ts","../../src/notify/google-ads.ts","../../src/notify/index.ts"],"sourcesContent":["/**\n * The e-mail channel — Cloudflare Email Sending.\n *\n * I/O only: it executes an `EmailPlan` and decides nothing. Whether to send, to\n * whom, and from where were all settled in `plan.ts`.\n *\n * **The binding is passed in, never imported.** `@getvitops/utils` does not depend\n * on `cloudflare:workers`, so this module runs in Node and in a test with a stub,\n * and the Astro package gains no Cloudflare coupling. The consumer's route hands\n * over `env.EMAIL`.\n *\n * Uses the current Email Sending binding — a structured `send({ to, from,\n * subject, html, text })` — not the legacy `EmailMessage` + hand-built MIME path.\n * The domain in `from` must have been onboarded (`wrangler email sending enable\n * <domain>`); nothing here can check that, which is why `E_SENDER_NOT_VERIFIED`\n * is surfaced verbatim rather than collapsed into \"send failed\".\n */\nimport type { RenderedMessage } from './types.ts';\nimport type { ChannelResult, EmailBinding, EmailPlan } from './types.ts';\n\n/**\n * Provider errors worth retrying.\n *\n * Everything else is a configuration fault — an unverified sender, a malformed\n * payload, a suppressed recipient — where a retry is pure latency and the send\n * will fail identically. Retrying those also burns the daily quota.\n */\nconst RETRYABLE = new Set([\n 'E_RATE_LIMIT_EXCEEDED',\n 'E_DELIVERY_FAILED',\n 'E_INTERNAL_SERVER_ERROR',\n]);\n\nfunction codeOf(error: unknown): string | undefined {\n const code = (error as { code?: unknown } | null)?.code;\n return typeof code === 'string' ? code : undefined;\n}\n\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\nexport interface SendEmailOptions {\n /** Attempts for a retryable failure (default 3). */\n attempts?: number;\n /**\n * Per-attempt ceiling in milliseconds (default 10000; `0` disables).\n *\n * `binding.send()` is a network call with no deadline of its own, so without\n * this one hung attempt hangs the request that produced it — forever, in the\n * one module otherwise built on always saying why. A conversion endpoint\n * cannot be the thing that makes a form submission time out, so the bound\n * belongs here rather than in every consumer's wrapper.\n *\n * A timeout is treated as retryable: it is a transient condition by\n * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.\n */\n timeoutMs?: number;\n /** Injected for tests; real callers let it default to a real delay. */\n sleep?: (ms: number) => Promise<void>;\n}\n\nconst wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));\n\n/** Marker for a send that exceeded its deadline, so the retry rule can see it. */\nconst TIMEOUT_CODE = 'E_SEND_TIMEOUT';\n\n/**\n * Race a send against its deadline.\n *\n * The timer is always cleared — a pending `setTimeout` keeps a Worker's event\n * loop alive past the response, which turns a bounded send into an unbounded\n * invocation and bills for the difference.\n */\nasync function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {\n if (ms <= 0) return work;\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n work,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n const err = new Error(`Email send exceeded ${ms}ms.`) as Error & { code: string };\n err.code = TIMEOUT_CODE;\n reject(err);\n }, ms);\n }),\n ]);\n } finally {\n if (timer !== undefined) clearTimeout(timer);\n }\n}\n\n/**\n * Send one rendered message according to a plan.\n *\n * Never throws: a failed notification must not take down the request that\n * produced it. The visitor has already submitted their form, and failing their\n * response because our mail didn't go out would turn a lost notification into a\n * lost conversion. The failure is returned instead, for the caller to log.\n */\nexport async function sendEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n binding: EmailBinding,\n options: SendEmailOptions = {},\n): Promise<ChannelResult> {\n if (!plan.enabled || !plan.to || !plan.from) {\n return { channel: 'email', sent: false, reason: plan.skip ?? 'Channel not enabled.' };\n }\n\n const attempts = Math.max(1, options.attempts ?? 3);\n const sleep = options.sleep ?? wait;\n const timeoutMs = options.timeoutMs ?? 10_000;\n\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await withTimeout(\n binding.send({\n to: plan.to,\n from: plan.fromName ? { email: plan.from, name: plan.fromName } : { email: plan.from },\n subject: message.subject,\n text: message.text,\n html: message.html,\n ...(plan.replyTo ? { replyTo: plan.replyTo } : {}),\n }),\n timeoutMs,\n );\n return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) {\n return {\n channel: 'email',\n sent: false,\n reason: messageOf(error),\n ...(code ? { code } : {}),\n };\n }\n // Exponential backoff. Cloudflare's rate limit is per-account, so a burst\n // of conversions retrying in lockstep would keep colliding.\n await sleep(2 ** (attempt - 1) * 250);\n }\n }\n\n /* c8 ignore next -- the loop always returns; this satisfies the type checker. */\n return { channel: 'email', sent: false, reason: 'Exhausted attempts.' };\n}\n\n/**\n * Print the message instead of sending it.\n *\n * The dev path, for a machine with no binding. Explicit rather than a silent\n * no-op, because \"I never saw the notification\" is the symptom of both a broken\n * integration and a working one nobody could observe.\n */\nexport function logEmail(\n plan: EmailPlan,\n message: RenderedMessage,\n log: (line: string) => void = console.log,\n): ChannelResult {\n log(\n [\n '\\n========== NOTIFICATION EMAIL (not sent — no binding) ==========',\n `To: ${plan.to ?? '(unresolved)'}`,\n `From: ${plan.from ?? '(unresolved)'}`,\n `Subject: ${message.subject}`,\n '---',\n message.text,\n '================================================================\\n',\n ].join('\\n'),\n );\n return { channel: 'email', sent: false, reason: 'No binding — logged to the console.' };\n}\n","/**\n * Every decision a notification makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no binding, no clock. Whether a\n * channel fires, who it reaches, and *why* it was skipped are all decided here and\n * asserted in `plan.test.ts`; the senders beside this file do what they are told\n * and decide nothing. That is the same split `indexing/plan.ts` makes against\n * `gsc.ts`, and `@getvitops/core`'s consent store makes against its DOM wiring,\n * for the same reason: the consequential logic has to be testable without the\n * environment it runs in.\n *\n * The practical payoff is that a misconfigured site can be told exactly what will\n * and won't happen without sending anything — a silently unsent conversion\n * notification is indistinguishable from no conversion.\n */\nimport type { EmailChannelConfig, EmailPlan, NotificationPlan, NotifyContext } from './types.ts';\n\n/** Default Workers binding name for Cloudflare Email Sending. */\nexport const DEFAULT_EMAIL_BINDING = 'EMAIL';\n\n/** Normalise the `email` shorthand: a bare address means a Cloudflare send to it. */\nexport function normalizeEmailChannel(\n input: string | EmailChannelConfig | undefined,\n): EmailChannelConfig | undefined {\n if (input === undefined) return undefined;\n return typeof input === 'string' ? { provider: 'cloudflare', to: input } : input;\n}\n\n/**\n * Who the notification reaches.\n *\n * An explicit recipient wins; otherwise it falls to the site's own contact\n * addresses, primary first. This cascade is why a site that has already said\n * where it receives mail doesn't have to say it twice.\n */\nexport function resolveRecipient(\n channel: EmailChannelConfig,\n ctx: NotifyContext,\n): string | undefined {\n if (channel.to) return channel.to;\n return ctx.locationEmails?.find((e) => !!e);\n}\n\n/**\n * The From address.\n *\n * `noreply@<canonical>` by default, because the sending domain has to be one the\n * site controls — Cloudflare rejects anything else with `E_SENDER_NOT_VERIFIED`,\n * and guessing from the recipient's domain would send that failure every time.\n */\nexport function resolveSender(channel: EmailChannelConfig, ctx: NotifyContext): string | undefined {\n if (channel.from) return channel.from;\n if (!ctx.canonical) return undefined;\n let host: string;\n try {\n host = new URL(ctx.canonical).hostname;\n } catch {\n // A bare hostname is a legitimate way to write `domains.canonical`.\n host = ctx.canonical.replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '');\n }\n return host ? `noreply@${host}` : undefined;\n}\n\n/**\n * Decide what will be sent, and say why anything won't be.\n *\n * Every \"off\" carries a reason rather than being an absence, so a caller can\n * report the misconfiguration instead of reporting success over a no-op.\n */\nexport function planNotifications(ctx: NotifyContext): NotificationPlan {\n const email = planEmail(ctx);\n return { email, empty: !email.enabled };\n}\n\nfunction planEmail(ctx: NotifyContext): EmailPlan {\n const channel = normalizeEmailChannel(ctx.notifications?.email);\n const binding = channel?.binding ?? DEFAULT_EMAIL_BINDING;\n\n if (!channel) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip: 'No `notifications.email` configured.',\n };\n }\n\n const to = resolveRecipient(channel, ctx);\n if (!to) {\n return {\n provider: 'cloudflare',\n binding,\n enabled: false,\n skip:\n 'No recipient: set `notifications.email.to`, or give a location an `email` ' +\n 'so the notification has somewhere to go.',\n };\n }\n\n const from = resolveSender(channel, ctx);\n if (!from) {\n return {\n provider: 'cloudflare',\n binding,\n to,\n enabled: false,\n skip:\n 'No sender: set `notifications.email.from`, or `domains.canonical` so ' +\n '`noreply@<host>` can be derived. Cloudflare only sends from a domain you have onboarded.',\n };\n }\n\n return {\n provider: 'cloudflare',\n binding,\n to,\n from,\n fromName: channel.fromName ?? ctx.organizationName,\n replyTo: channel.replyTo,\n enabled: true,\n };\n}\n\n/**\n * Whether a form post has anywhere to go: a `notifications.email` channel, a location e-mail,\n * or a CRM. With none of the three the emdash template's `/api/track` refuses the post (it\n * would otherwise accept it and lose the message), so `vitops emdash check-prerender` asks the\n * same question of the config before the first visitor finds out. One function so the build\n * check and the route cannot disagree.\n */\nexport function hasFormDelivery(\n ctx: Pick<NotifyContext, 'notifications' | 'locationEmails'>,\n crm?: unknown,\n): boolean {\n return Boolean(ctx.notifications?.email || ctx.locationEmails?.length || crm);\n}\n","/**\n * Turning a `ConversionEvent` into something a person reads.\n *\n * Pure, and separate from both the plan and the sender, because rendering is the\n * part that differs per channel: an SMS channel will render the same event in 160\n * characters, a portal log as a row. The event stays the fact; only this file has\n * an opinion about prose.\n *\n * The layout is three sections — who got in touch, which ad sent them, and what\n * the request looked like — because that is the order the reader needs them in.\n * The contact details are the point; attribution is why it matters; the visitor\n * context is for when something looks wrong.\n */\nimport { identifySubmitter } from '../tracking/identity.ts';\nimport { getPrimaryClickId, identifyPlatform } from '../tracking/platform.ts';\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport type { RenderedMessage } from './types.ts';\n\n/**\n * Fields never worth putting in a notification.\n *\n * A honeypot is not visitor-supplied — it is bait, and reporting it as something\n * they submitted is untrue. The same rule `legal/derive.ts` applies when deriving\n * `piiCollected`, and for the same reason. Callers may strip more.\n */\nconst OMIT_FIELDS = new Set(['website', '_honeypot', 'honeypot', 'cf-turnstile-response']);\n\nfunction label(key: string): string {\n return key.charAt(0).toUpperCase() + key.slice(1).replace(/[_-]/g, ' ');\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"');\n}\n\n/** One section: a heading and its lines. Empty sections are dropped by the caller. */\ninterface Section {\n heading: string;\n lines: [string, string][];\n}\n\n/**\n * One line, already rendered both ways — a plain fact (`html` is just the\n * escaped `text`) or a link (`html` an anchor, `text` the bare href). Built at\n * the point each line is known rather than carried as a union and branched on\n * later: `describeEvent`'s lines are always plain `[label, value]` pairs (see\n * `Section` above, which `render.test.ts` destructures as such), and the one\n * line shape `renderEmail` adds itself — the \"Actions\" link — is turned into\n * this same rendered shape immediately, so the two renderers below share one\n * line format instead of branching on what kind of line each one is.\n */\ninterface RenderedLine {\n label: string;\n text: string;\n html: string;\n}\n\n/** A section as `renderEmail` assembles it — `describeEvent`'s output plus its own additions. */\ninterface RenderSection {\n heading: string;\n lines: RenderedLine[];\n}\n\nfunction factLine([label, value]: readonly [string, string]): RenderedLine {\n return { label, text: value, html: escapeHtml(value) };\n}\n\nfunction linkLine(label: string, href: string): RenderedLine {\n return { label, text: href, html: `<a href=\"${escapeHtml(href)}\">${escapeHtml(href)}</a>` };\n}\n\nfunction toRenderSection(section: Section): RenderSection {\n return { heading: section.heading, lines: section.lines.map(factLine) };\n}\n\n/**\n * Who submitted the form, for the subject line.\n *\n * `formData['name']` alone is the shape a form has when someone writes it that\n * way, and most don't: a downstream site had `first_name`/`last_name` on five of\n * six forms, so every notification read \"from Unknown\" and the renderer could\n * not be adopted at all. The cascade is ordered most-specific first, and falls\n * back to the email rather than to nothing — an address identifies the person\n * even when no name field exists.\n */\nfunction submitterName(formData: Record<string, string>): string {\n return identifySubmitter(formData).fullName;\n}\n\n/**\n * The event as sections — the shared structure behind both renderings.\n *\n * Extracted so the text and HTML versions cannot disagree about content. They\n * differ only in markup; a field added here appears in both.\n */\nexport function describeEvent(event: ConversionEvent): { subject: string; sections: Section[] } {\n const t = event.tracking;\n const platform = t ? identifyPlatform(t) : null;\n const clickId = t ? getPrimaryClickId(t) : null;\n\n const contact: [string, string][] = [];\n let subject: string;\n\n if (event.type === 'form' && event.formData) {\n const name = submitterName(event.formData);\n subject = `[Contact Form] New submission from ${name}`;\n for (const [key, value] of Object.entries(event.formData)) {\n if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);\n }\n } else if (event.type === 'email') {\n const email = event.email ?? 'unknown';\n subject = `[Email Click] Visitor emailing ${email}`;\n contact.push(['Email', email]);\n } else {\n const phone = event.phone ?? 'unknown';\n subject = `[Phone Call] Visitor calling ${phone}`;\n contact.push(['Phone', phone]);\n }\n\n const attribution: [string, string][] = [];\n if (t) {\n if (platform) attribution.push(['Platform', platform]);\n if (clickId) attribution.push([`Click ID (${clickId.param})`, clickId.value]);\n for (const key of ['utm_campaign', 'utm_source', 'utm_medium', 'utm_content', 'utm_term']) {\n const value = t[key];\n if (value) attribution.push([label(key.replace('utm_', '')), String(value)]);\n }\n if (t.ab_variant) attribution.push(['A/B variant', t.ab_variant]);\n }\n\n const context: [string, string][] = [];\n if (t?.landingPage) context.push(['Landing page', t.landingPage]);\n if (t?.referrer) context.push(['Referrer', t.referrer]);\n if (t?.ts) context.push(['First visit', new Date(t.ts).toISOString()]);\n context.push(['Received', new Date(event.at).toISOString()]);\n if (event.userAgent) context.push(['User agent', event.userAgent]);\n if (event.ip) context.push(['IP', event.ip]);\n\n const sections: Section[] = [\n { heading: 'Contact details', lines: contact },\n // Stated rather than omitted: \"we don't know where this came from\" is a\n // useful answer, and a silently missing section reads as a bug in the tool.\n {\n heading: 'Ad attribution',\n lines: attribution.length\n ? attribution\n : [['Source', 'None — visitor arrived without ad tracking']],\n },\n { heading: 'Visitor context', lines: context },\n ];\n\n return { subject, sections: sections.filter((s) => s.lines.length) };\n}\n\n/** Render for e-mail. Both halves, always — see `RenderedMessage`. */\nexport function renderEmail(\n event: ConversionEvent,\n extra: { notes?: string[] | undefined; actionUrl?: string | undefined } = {},\n): RenderedMessage {\n const { subject, sections: eventSections } = describeEvent(event);\n\n // `{clickId}` is the only placeholder — an event has no durable id nothing\n // in this toolchain could look back up (there's no store), so interpolating\n // one would be decorative. Omitted entirely when there's no click ID to\n // interpolate, rather than emailing a link with a literal `{clickId}` in it.\n const clickId = event.tracking ? getPrimaryClickId(event.tracking) : null;\n const actionSection: RenderSection | null =\n extra.actionUrl && clickId\n ? {\n heading: 'Actions',\n lines: [\n linkLine(\n 'View in ad platform',\n extra.actionUrl.replace(/\\{clickId\\}/g, encodeURIComponent(clickId.value)),\n ),\n ],\n }\n : null;\n\n // Deliberately a channel-neutral `string[]` rather than a typed CRM result —\n // this module gains no dependency on `crm`, and the seam serves any future\n // sink the same way.\n const sections: RenderSection[] = [\n ...eventSections.map(toRenderSection),\n ...(actionSection ? [actionSection] : []),\n ...(extra.notes && extra.notes.length\n ? [{ heading: 'Delivery', lines: extra.notes.map((n) => factLine(['CRM', n])) }]\n : []),\n ];\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map((l) => `${l.label}: ${l.text}`)].join('\\n'))\n .join('\\n\\n');\n\n const html = sections\n .map(\n (s) =>\n `<h2 style=\"font:600 14px system-ui;margin:24px 0 8px\">${escapeHtml(s.heading)}</h2>` +\n `<table style=\"font:14px system-ui;border-collapse:collapse\">${s.lines\n .map(\n (l) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(l.label)}</td>` +\n `<td style=\"padding:2px 0\">${l.html}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\n}\n","/**\n * Google Ads offline conversion upload — the server-side counterpart to the\n * client-side `gtag('event', 'conversion', …)` call `gtagBootstrap`\n * (`@getvitops/utils/ads`) wires up. Reads `site.ads.google.reporting: 'server'`\n * and a matching `conversionActionId` (see that field's own docblock for why\n * it is a different identifier from `conversionLabel`, not an alternate\n * spelling of it) and calls `ConversionUploadService.UploadClickConversions`\n * instead of relying on a visitor's browser to fire the tag.\n *\n * Same reasoning as `@getvitops/utils/crm`'s executors: credentials are a\n * structurally-typed shape the consumer's route supplies from env vars, never\n * config, and a failed upload is a `GoogleAdsUploadResult` to log or fold into\n * the notification email — never an exception, and never something that fails\n * the conversion request it's attached to. A lost ad report is not a lost\n * lead; the visitor has already submitted.\n *\n * **Structurally typed against Google's documented REST shape, not verified\n * against a live Ads account** — no developer-token/MCC access exists\n * anywhere in this toolchain's own setup to test against (the same blocker\n * `vitops ads provision` is waiting on). Same caveat, and the same reason, as\n * `@getvitops/utils/crm`'s Jobber executor: wrapped so a wrong guess about the\n * exact request/response shape degrades to a stated failure reason rather\n * than breaking. Re-verify against `googleads.googleapis.com`'s current API\n * version and `ClickConversion` message shape before depending on this in\n * production, and update this docblock with what was found.\n */\nimport { errorDetail, googleAccessToken, googleHeaders } from '../google/token.ts';\nimport type { GoogleOAuth } from '../google/token.ts';\n\n/** Bumped independently of everything else here — the Ads API deprecates\n * versions on its own rolling schedule. Last checked against Google's public\n * docs at the time this file was written; not re-verified since. */\nconst API_VERSION = 'v17';\n\nexport interface GoogleAdsCredentials {\n /** Issued against a Google Ads manager (MCC) account — a Google review, not\n * a self-service toggle. Never config. */\n developerToken: string;\n oauth: GoogleOAuth;\n /** The MCC's own (dashes-or-not) customer id — required when `customerId`\n * in the upload input is a client account managed under it, which is the\n * common case for an agency running this on a client's behalf. */\n loginCustomerId?: string | undefined;\n}\n\nexport interface GoogleAdsUploadInput {\n /** Google Ads customer id, as shown in the UI (`\"123-456-7890\"`) or bare\n * digits — either works, dashes are stripped before the call. */\n customerId: string;\n /** A numeric conversion action id, or a full\n * `customers/…/conversionActions/…` resource name. */\n conversionActionId: string;\n /** The click id captured on `_ac` — `getPrimaryClickId()`'s result. */\n clickId: { param: string; value: string };\n /** Epoch ms — converted to the Ads API's own\n * `\"yyyy-MM-dd HH:mm:ss+00:00\"` format. */\n at: number;\n conversionValue?: number | undefined;\n currencyCode?: string | undefined;\n orderId?: string | undefined;\n}\n\n/** Never thrown — an Ads API outage or a malformed request both come back as\n * `ok: false`, the same \"structured result, not an exception\" rule every\n * other executor in this toolchain follows. */\nexport interface GoogleAdsUploadResult {\n ok: boolean;\n reason?: string | undefined;\n code?: string | undefined;\n}\n\nfunction digitsOnly(id: string): string {\n return id.replace(/\\D/g, '');\n}\n\n/** A bare numeric id becomes a full resource name under `customerId`; a value\n * already shaped like one (the escape hatch, matching `txtRecord`'s `=`\n * check in `@getvitops/utils/ads`) passes through unchanged. */\nexport function conversionActionResourceName(\n customerId: string,\n conversionActionId: string,\n): string {\n if (conversionActionId.includes('customers/')) return conversionActionId;\n return `customers/${digitsOnly(customerId)}/conversionActions/${conversionActionId}`;\n}\n\n/** The Ads API's documented click-conversion datetime format, always\n * rendered in UTC — there is no per-account timezone lookup here to do\n * better, and UTC is unambiguous. */\nexport function toGoogleConversionDateTime(atMs: number): string {\n const d = new Date(atMs);\n const pad = (n: number): string => String(n).padStart(2, '0');\n return (\n `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +\n `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}+00:00`\n );\n}\n\n/** The request field a click id param maps to — the API takes `gclid`/\n * `gbraid`/`wbraid` as distinct fields, not one generic \"click id\" field.\n * Anything else this toolchain's `PLATFORM_PARAMS` maps to Google (today,\n * only these three) falls back to `gclid`. */\nfunction clickIdField(param: string): 'gclid' | 'gbraid' | 'wbraid' {\n if (param === 'gbraid' || param === 'wbraid') return param;\n return 'gclid';\n}\n\n/**\n * Upload one click conversion via `ConversionUploadService.UploadClickConversions`.\n *\n * `partialFailure: true` is always sent, and a 200 response carrying\n * `partialFailureError` is treated as a failure, not a success — a single-\n * conversion batch makes \"partial\" and \"total\" failure the same case, and\n * Google Ads API responses are 200 for a partial failure by design (silently\n * treating that as success is exactly the trap `@getvitops/utils/crm`'s\n * Jobber executor documents for its own GraphQL transport).\n */\nexport async function uploadClickConversion(\n input: GoogleAdsUploadInput,\n credentials: GoogleAdsCredentials,\n fetchImpl: typeof fetch = fetch,\n): Promise<GoogleAdsUploadResult> {\n let token: string;\n try {\n token = await googleAccessToken({ kind: 'oauth', oauth: credentials.oauth }, fetchImpl);\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads token exchange failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n const customerId = digitsOnly(input.customerId);\n const url = `https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadClickConversions`;\n\n const body = {\n conversions: [\n {\n conversionAction: conversionActionResourceName(customerId, input.conversionActionId),\n [clickIdField(input.clickId.param)]: input.clickId.value,\n conversionDateTime: toGoogleConversionDateTime(input.at),\n ...(input.conversionValue != null ? { conversionValue: input.conversionValue } : {}),\n ...(input.currencyCode ? { currencyCode: input.currencyCode } : {}),\n ...(input.orderId ? { orderId: input.orderId } : {}),\n },\n ],\n partialFailure: true,\n };\n\n let res: Response;\n try {\n res = await fetchImpl(url, {\n method: 'POST',\n headers: googleHeaders(token, {\n 'developer-token': credentials.developerToken,\n 'content-type': 'application/json',\n ...(credentials.loginCustomerId\n ? { 'login-customer-id': digitsOnly(credentials.loginCustomerId) }\n : {}),\n }),\n body: JSON.stringify(body),\n });\n } catch (err) {\n return {\n ok: false,\n reason: `Google Ads request failed: ${err instanceof Error ? err.message : String(err)}`,\n };\n }\n\n if (!res.ok) {\n return {\n ok: false,\n code: String(res.status),\n reason: await errorDetail(res, (status) =>\n status === 401\n ? 'Google Ads rejected the access token.'\n : status === 403\n ? 'Google Ads refused the request — check the developer token is approved and `login-customer-id` is set for an MCC-managed account.'\n : undefined,\n ),\n };\n }\n\n const json = (await res.json().catch(() => ({}))) as {\n partialFailureError?: { message?: string };\n };\n if (json.partialFailureError) {\n return {\n ok: false,\n reason: `Google Ads rejected the conversion: ${json.partialFailureError.message ?? 'no detail given'}`,\n };\n }\n\n return { ok: true };\n}\n\n/** A one-line delivery note, matching `describeCrmResult`'s shape — what\n * `conversion.ts` folds into the notification email's \"Delivery\" section. */\nexport function describeGoogleAdsOfflineResult(\n result: GoogleAdsUploadResult | null,\n): string | null {\n if (!result) return null;\n if (result.ok) return 'Reported to Google Ads (server-side)';\n return `⚠ Not reported to Google Ads: ${result.reason ?? 'unknown reason'}`;\n}\n","/**\n * Conversion notifications — plan, render, dispatch.\n *\n * The seam between \"a conversion happened\" and \"someone found out about it\".\n * A `ConversionEvent` from `@getvitops/utils/tracking` goes in; where it goes and\n * how it reads are config, not code at the call site.\n *\n * Three files, three jobs, matching `indexing/`:\n * - `plan.ts` decides everything, purely — which channels, which recipients,\n * and why anything is off.\n * - `render.ts` turns the event into prose, purely.\n * - `email.ts` executes, and decides nothing.\n *\n * `google-ads.ts` is a fourth file, added later, that does **not** follow the\n * three-file shape above: it isn't a `NotificationsConfig` channel `notify()`\n * plans and dispatches, it's a separate delivery keyed to `site.ads.google`,\n * wired in by `createConversionRoute`'s own `googleAdsOffline` option and\n * folded into the email's \"Delivery\" section exactly like `@getvitops/utils/\n * crm`'s `CrmResult` — see that module's `describeCrmResult` for the\n * precedent this follows (`describeGoogleAdsOfflineResult` here).\n *\n * ---\n *\n * **TODO — further channels.** Only `email` is implemented. The seam is\n * `NotificationsConfig` + a sender with `sendEmail`'s signature `(plan, message,\n * transport) => ChannelResult`; adding one should mean a new sender file, a new\n * branch in `planNotifications`, and a schema variant — touching neither\n * `ConversionEvent` nor the recipient cascade. Planned:\n * - `sms` — the same event rendered short. `render.ts` already separates\n * `describeEvent` from `renderEmail` for exactly this.\n * - `persist` — append to a store (D1, a portal log) the client reviews.\n * The generic `email`/`text`/`persist` helpers taking content + provider config\n * belong at that point, not before: one implemented channel is not enough to know\n * what the abstraction should be.\n */\nimport type { ConversionEvent } from '../tracking/types.ts';\nimport { logEmail, sendEmail } from './email.ts';\nimport { planNotifications } from './plan.ts';\nimport { renderEmail } from './render.ts';\nimport type { ChannelResult, EmailBinding, NotifyContext } from './types.ts';\n\nexport interface NotifyDeps {\n /**\n * The Workers `send_email` binding (`env.EMAIL`). Omit it and the notification\n * is printed instead — the dev path.\n */\n email?: EmailBinding | undefined;\n}\n\nexport interface NotifyResult {\n results: ChannelResult[];\n /** True when at least one channel delivered. */\n delivered: boolean;\n}\n\n/**\n * Notify every configured channel about a conversion.\n *\n * Channels are independent: one failing must not stop the others, and none of\n * them may throw into the request that produced the event. Every outcome —\n * delivered, skipped, failed — comes back as data for the caller to log.\n */\nexport async function notify(\n event: ConversionEvent,\n ctx: NotifyContext,\n deps: NotifyDeps = {},\n extra: { notes?: string[] | undefined } = {},\n): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event, { ...extra, actionUrl: ctx.notifications?.actionUrl });\n results.push(\n deps.email ? await sendEmail(plan.email, message, deps.email) : logEmail(plan.email, message),\n );\n } else {\n results.push({ channel: 'email', sent: false, reason: plan.email.skip ?? 'Not configured.' });\n }\n\n return { results, delivered: results.some((r) => r.sent) };\n}\n\nexport {\n DEFAULT_EMAIL_BINDING,\n hasFormDelivery,\n normalizeEmailChannel,\n planNotifications,\n resolveRecipient,\n resolveSender,\n} from './plan.ts';\nexport { describeEvent, renderEmail } from './render.ts';\nexport { logEmail, sendEmail } from './email.ts';\nexport {\n conversionActionResourceName,\n describeGoogleAdsOfflineResult,\n toGoogleConversionDateTime,\n uploadClickConversion,\n} from './google-ads.ts';\nexport type {\n GoogleAdsCredentials,\n GoogleAdsUploadInput,\n GoogleAdsUploadResult,\n} from './google-ads.ts';\nexport type {\n ChannelPlan,\n ChannelResult,\n EmailBinding,\n EmailChannelConfig,\n EmailPlan,\n NotificationPlan,\n NotificationsConfig,\n NotifyContext,\n RenderedMessage,\n} from './types.ts';\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/utils",
|
|
3
|
-
"version": "8.3.0-alpha.
|
|
3
|
+
"version": "8.3.0-alpha.2",
|
|
4
4
|
"description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
|
|
5
5
|
"homepage": "https://docs.vitops.ca",
|
|
6
6
|
"bugs": "https://github.com/getvitops/design-system/issues",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"access": "public"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
110
|
-
"@getvitops/config": "8.3.0-alpha.
|
|
110
|
+
"@getvitops/config": "8.3.0-alpha.2",
|
|
111
111
|
"colorjs.io": "^0.5.2",
|
|
112
112
|
"parse5": "^7.3.0",
|
|
113
113
|
"png-to-ico": "^3.0.2",
|