@getvitops/utils 4.1.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,114 @@
1
1
  # @getvitops/utils
2
2
 
3
+ ## 6.0.0
4
+
5
+ **No consumer-facing change in this package.** The major is the toolchain's shared version, bumped
6
+ in lockstep for the carousel rework in `@getvitops/core` / `generator` / `astro`. Nothing exported
7
+ here changed, and no code that imports `@getvitops/utils` needs editing.
8
+
9
+ ## 5.0.0
10
+
11
+ ### Major Changes
12
+
13
+ - **Dangling token references are now a build failure.** `validate()` resolves every `var(--…)` a
14
+ config authors — in `patterns.defaults` / `radii` / `groups` / `items.*.{base,overrides,states}`,
15
+ `typography.roles` and `shadows` — against the tokens that config actually emits. A reference to a
16
+ token that does not exist used to validate, generate, minify and ship, then resolve to nothing in
17
+ the browser: one downstream config shipped a `.cta` whose `color` fell back to `inherit` on a brand
18
+ fill (unreadable text on every filled button), and two of its four dead references had been dead for
19
+ several releases with nothing ever reporting them.
20
+
21
+ The check is deliberately anchored to the namespaces the generator owns — `--color-*`, `--shadow-*`,
22
+ `--z-tier-*`, `--surface-glass`, `--overlay` — the same discipline `vitops lint` follows. A pattern
23
+ may legitimately reference a framework token from `@getvitops/core` or a hook of your own, and
24
+ enumerating "every valid token" would make those false positives. A reference carrying a fallback
25
+ (`var(--x, 0.25rem)`) is never flagged: that is you saying what happens when the token is absent.
26
+ Errors name the config path and suggest the nearest token that does exist.
27
+
28
+ Two related diagnostics ship with it:
29
+ - **Pre-1.0 colour-grammar references are reported as one rename table**, not as a dozen separate
30
+ "no such token" errors — the shape the 3.0 flat-config detector uses, applied to the 1.0 colour
31
+ change. It is built from your own role names, and it says to apply the renames _simultaneously_,
32
+ because several of them rotate (`--surface-bg` → `--color-bg-surface-muted`,
33
+ `--surface-bg-bold` → `--color-bg-surface`) and sequential replacement compounds them.
34
+ - **A surface-shaped role declared chromatic now warns.** A bare hue string is shorthand for
35
+ `kind: "chromatic"`, which emits no bare `--color-bg-<role>` and no `.bg-<role>` — so writing
36
+ `"surface": "ink"` silently removed `--color-bg-surface` and the 36 references the framework's own
37
+ CSS makes to it. Relatedly, `--surface-glass`, `--overlay` and `.glass` are no longer emitted when
38
+ `surface` is chromatic: they read `--color-bg-surface`, so emitting them pointed at nothing.
39
+
40
+ **`vitops lint --fix` is now the migration tool.** It reads `var(--…)` out of CSS and `<style>`
41
+ blocks (not just `class` attributes) and rewrites pre-1.0 token references in a **single pass**, so
42
+ the rotating renames cannot compound. Nothing else is rewritten — every other finding it reports is
43
+ a judgement call. Run it after a major bump.
44
+
45
+ **`--help` works on every subcommand.** `vitops lint --help` used to exit non-zero with
46
+ `Unknown option '--help'`; every leaf subcommand parsed its own argv strictly and none declared the
47
+ option. It is now answered before dispatch, so no command can forget it, and a drift guard fails the
48
+ build if a command has no documented options. (`validate`'s options were genuinely undocumented and
49
+ now are.)
50
+
51
+ **The Astro integration reads `site.tracking` and `site.legal.cookieConsent`.** These drive the
52
+ generated cookie notice, while `vitops({ consent, tracking })` drove the runtime — two hand-synced
53
+ declarations of the same fact with nothing comparing them, so a site could ship a notice naming
54
+ categories its banner never offered, or disclosing an `_ac` cookie no capture script ever wrote, and
55
+ the build was clean. The config blocks are now defaults (as `css` / `fonts` / `favicon` / `ads`
56
+ already were) and a genuine contradiction — `true` against `false` on the same fact — **fails the
57
+ build**, naming both sides. An absent option is not a contradiction; it just takes the config's
58
+ value.
59
+
60
+ Also fixed, each from a downstream report:
61
+ - **`@getvitops/astro/tracking`** re-exports `@getvitops/utils/tracking` (plus `TRACKING_ENDPOINT`),
62
+ so the documented conversion flow works with one install. Under strict pnpm app code cannot
63
+ resolve a transitive dependency, and the obvious workaround — importing the same symbols from the
64
+ package index — is the one that drags the integration's Node builtins toward a Worker bundle. This
65
+ entry and `./routes` are both clean.
66
+ - **`createConversionRoute`'s example was broken on every supported Astro.** It read the binding off
67
+ `locals.runtime.env`, removed in Astro v6, while the package peers on `>= 7` — and because the
68
+ throw precedes the response, Astro re-reads the request and reports a misleading "Body has already
69
+ been used". It also called a `toNotifyContext()` helper that does not exist. The example now uses
70
+ `import { env } from 'cloudflare:workers'` and a plain `NotifyContext`.
71
+ - **`/api/track` is exported as `TRACKING_ENDPOINT`**, and the integration warns at build when
72
+ tracking is on and no route answers it. It was a bare literal inside the inlined capture script:
73
+ name the route file anything else and every `tel:` beacon 404s, conversions vanish, and nothing
74
+ errors anywhere.
75
+ - **`describeEvent` no longer reports "from Unknown"** for forms that don't have a field literally
76
+ named `name`. It now falls back through `first_name`/`last_name`, `full_name`, and finally the
77
+ email address.
78
+ - **`sendEmail` takes `timeoutMs` (default 10s).** `binding.send()` had no deadline, so one hung
79
+ attempt hung the request that produced it — indefinitely, in the one module otherwise built on
80
+ always saying why. A timeout is retried, like any other transient failure.
81
+ - **`vitops search notify` warns when the sitemap's URLs aren't on the configured origin.** A 404
82
+ fails loudly; a well-formed sitemap belonging to someone else did not — a route collision served a
83
+ valid document listing another site's pages and the run reported a healthy submission. Surfaces at
84
+ `--dry`.
85
+ - **`require()` warns when the page has no `<wc-consent>`.** The demand can never be granted, so
86
+ whatever waited on it never runs — silently. Hit by rendering `<Tracking />` on pages where
87
+ `<CookieConsent />` wasn't.
88
+ - **`vitops agents` no longer writes a path it guessed.** It used to warn and then write "tokens
89
+ live in `design-system.json`" into a project whose tokens are in `company.json`, with the four
90
+ emitted `vitops generate` commands hard-coded to match — copy-paste-broken in exactly the projects
91
+ that most need the block to be right. It now finds the config by shape, interpolates the resolved
92
+ path into every emitted command, and fails rather than guessing when there is nothing to name.
93
+ - **`vitops search setup --dry` and `vitops ads setup --dry` run without credentials.** A dry run
94
+ that mutates nothing should not demand the token to mutate; with none set it plans from scratch
95
+ and says so. (`--check` still needs them — drift is a comparison against live state.) Both now
96
+ also mention that the Cloudflare token needs `Zone:Read` alongside `Zone:DNS:Edit` — the
97
+ zone-by-name lookup uses it, and its absence read as "the zone isn't in this account".
98
+ - **`vitops legal` names the files it wrote and flags new ones.** Enabling `cookieConsent` silently
99
+ produced a `cookie-notice` document that then needs a page, a route, a link and a `policyUrl`; the
100
+ command said only "3 files". The review reminder is also printed on the stdout path, where it was
101
+ missing entirely.
102
+ - **`vitops init` writes a versioned `$schema`** — `./node_modules/@getvitops/generator/schema.json`,
103
+ following wrangler — instead of an unpinned unpkg URL that resolved to whatever was newest, so an
104
+ editor validated a pinned project against a schema it wasn't built with.
105
+ - **`favicon.backgroundColor` and `favicon.name` are described accurately.** `backgroundColor` is
106
+ not manifest-only: it fills the opaque `apple-touch-icon.png` and `icon-mask.png`, which are
107
+ written whether or not a manifest is. `name` documents that it and `themeColor` **together** are
108
+ what make a site installable. The missing-background warning now also fires for a dark opaque mark,
109
+ not only a transparent source — `apple-touch-icon` insets the logo and fills the surround either
110
+ way.
111
+
3
112
  ## 4.1.0
4
113
 
5
114
  ### Minor Changes
@@ -1,4 +1,4 @@
1
- import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BW-Iyi-B.mjs";
1
+ import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
2
2
 
3
3
  //#region src/ads/providers.ts
4
4
  /** `gtag` is shared by every Google tag; the id shape (`AW-…`) is what differs. */
@@ -1 +1 @@
1
- {"version":3,"file":"cloudflare-BiK61Oq4.d.mts","names":[],"sources":["../src/onboarding/cloudflare.ts"],"mappings":";;iBA8BsB,UAAA,CACpB,KAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,MAAA;EAAiB,OAAA;AAAA;;iBAkBrC,WAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,QAAA;EAAoB,OAAA;AAAA;;AAvBI;AAkBlE;;iBAsBsB,aAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA"}
1
+ {"version":3,"file":"cloudflare-BiK61Oq4.d.mts","names":[],"sources":["../src/onboarding/cloudflare.ts"],"mappings":";;iBA8BsB,UAAA,CACpB,KAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,MAAA;EAAiB,OAAA;AAAA;;iBAsBrC,WAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,QAAA;EAAoB,OAAA;AAAA;;AA3BI;AAsBlE;;iBAsBsB,aAAA,CACpB,KAAA,UACA,MAAA,UACA,MAAA,UACA,OAAA,UACA,SAAA,UAAkB,KAAA,GACjB,OAAO;EAAG,EAAA;EAAa,MAAA;EAAgB,OAAA;AAAA"}
@@ -33,7 +33,7 @@ async function findZoneId(token, domain, fetchImpl = fetch) {
33
33
  if (!zone) return {
34
34
  ok: false,
35
35
  status: res.status,
36
- message: `no Cloudflare zone named "${domain}" (is it in this account, and does the token cover it?)`
36
+ message: `no Cloudflare zone named "${domain}" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The "Edit zone DNS" template covers both.)`
37
37
  };
38
38
  return {
39
39
  ok: true,
@@ -89,4 +89,4 @@ async function createApexTxt(token, zoneId, domain, content, fetchImpl = fetch)
89
89
 
90
90
  //#endregion
91
91
  export { findZoneId as n, listApexTxt as r, createApexTxt as t };
92
- //# sourceMappingURL=cloudflare-BW-Iyi-B.mjs.map
92
+ //# sourceMappingURL=cloudflare-CD8vwrgj.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloudflare-CD8vwrgj.mjs","names":[],"sources":["../src/onboarding/cloudflare.ts"],"sourcesContent":["/**\n * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (!zone)\n return {\n ok: false,\n status: res.status,\n // Name the scope: this lookup is a GET /zones?name=, which needs\n // `Zone:Read` — a token carrying only `Zone:DNS:Edit` reaches here and\n // reads as \"the zone isn't in this account\", which sends you to the\n // wrong dashboard.\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token carry Zone:Read as well as Zone:DNS:Edit? The \"Edit zone DNS\" template covers both.)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EAKZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC"}
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;EAaA;;AAAe;AAsDjB;;;;;;;;AAAqE;EAtDnE,eAAA;AAAA;;;;;;iBAsDoB,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO;AAAA,UAqGpD,WAAA;EACf,GAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EAIf;EAFA,MAAA;EAII;EAFJ,QAAA;EASc;EAPd,IAAA;AAAA;;;;;iBAOc,YAAA,CAAa,IAAA,GAAM,iBAAA,GAAyB,WAAW;AAAA,UAWtD,kBAAA;EACf,IAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EAHA;EAKA,IAAA;AAAA;;iBAIc,eAAA,CAAgB,IAAA,EAAM,kBAAA,GAAqB,MAAM;;iBAuB3C,oBAAA,CAAqB,GAAA,UAAa,IAAA,EAAM,kBAAA,GAAqB,OAAO"}
1
+ {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;EAaA;;AAAe;AA0EjB;;;;;;;;AAAqE;EA1EnE,eAAA;AAAA;;;;;;iBA0EoB,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO;AAAA,UA4GpD,WAAA;EACf,GAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EAIf;EAFA,MAAA;EAII;EAFJ,QAAA;EASc;EAPd,IAAA;AAAA;;;;;iBAOc,YAAA,CAAa,IAAA,GAAM,iBAAA,GAAyB,WAAW;AAAA,UAWtD,kBAAA;EACf,IAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EAHA;EAKA,IAAA;AAAA;;iBAIc,eAAA,CAAgB,IAAA,EAAM,kBAAA,GAAqB,MAAM;;iBAuB3C,oBAAA,CAAqB,GAAA,UAAa,IAAA,EAAM,kBAAA,GAAqB,OAAO"}
package/dist/favicon.mjs CHANGED
@@ -56,6 +56,25 @@ async function hasTransparency(sharp, source) {
56
56
  }
57
57
  }
58
58
  /**
59
+ * Is the mark dark enough to disappear against a white default?
60
+ *
61
+ * Transparency is not the only way to lose a logo. `apple-touch-icon` insets the
62
+ * mark into a larger canvas and fills the surround with the mask background, so
63
+ * an **opaque** dark logo still gets a white frame — and the transparency-only
64
+ * check said nothing about it. Mean channel luminance over the rasterised
65
+ * source is a coarse signal, which is the right amount for a warning.
66
+ */
67
+ async function isDarkMark(sharp, source) {
68
+ try {
69
+ const { channels } = await sharp(source, { density: 384 }).stats();
70
+ const rgb = channels.slice(0, 3);
71
+ if (!rgb.length) return false;
72
+ return rgb.reduce((sum, c) => sum + c.mean, 0) / rgb.length < 128;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ /**
59
78
  * Generate the standard favicon set from an SVG/PNG source into `outputDir`:
60
79
  * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.
61
80
  * Returns the list of written file paths.
@@ -104,7 +123,11 @@ async function generateFavicons(opts) {
104
123
  if (lowResSource) icoSources.unshift(out("icon-16.png"));
105
124
  await writeFile(out("favicon.ico"), await pngToIco(icoSources));
106
125
  written.push(out("favicon.ico"));
107
- if (opts.backgroundColor == null && await hasTransparency(sharp, source)) console.warn(`favicon: ${source} has transparency, and the maskable outputs (icon-mask.png, apple-touch-icon.png) must be opaque — the OS crops them to its own shape. Defaulting their background to #ffffff; set backgroundColor to choose.`);
126
+ if (opts.backgroundColor == null) {
127
+ const transparent = await hasTransparency(sharp, source);
128
+ const dark = transparent ? false : await isDarkMark(sharp, source);
129
+ if (transparent || dark) console.warn(`favicon: ${source} ${transparent ? "has transparency" : "is a dark mark"}, and the maskable outputs (icon-mask.png, apple-touch-icon.png) are composited onto an opaque background — the OS crops them to its own shape. Defaulting that background to #ffffff; set backgroundColor to choose. (It is not manifest-only: these two files are written whether or not a web manifest is.)`);
130
+ }
108
131
  await sharp(out("icon-512.png")).resize(140, 140, {
109
132
  fit: "contain",
110
133
  background: maskBg
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.mjs","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["/**\n * Favicon generation — rasterize a source SVG/PNG into the standard favicon set.\n *\n * Ported from the repo's `lib/favicon.ts` so the logic lives in one place instead\n * of being copied per client. `sharp` + `png-to-ico` are loaded lazily (dynamic\n * import) so merely importing this module is cheap; the native `sharp` binary is\n * only touched when `generateFavicons` actually runs. `oxipng` (lossless PNG\n * crush) is used when present on PATH and skipped otherwise — it is optional.\n */\nimport { mkdirSync } from 'node:fs';\nimport { copyFile, writeFile } from 'node:fs/promises';\nimport { promisify } from 'node:util';\nimport cp from 'node:child_process';\nimport { join } from 'node:path';\n\nconst exec = promisify(cp.exec);\n\nexport interface FaviconOptions {\n /** Source image — an SVG or PNG. */\n source: string;\n /** Optional simplified source for the 16px icon. */\n lowResSource?: string;\n /** Directory to write into (default: current directory). */\n outputDir?: string;\n /** Run `oxipng` on the larger PNGs when available (default: true). */\n optimize?: boolean;\n /**\n * Background composited under the two MASKABLE outputs — `icon-mask.png` and\n * `apple-touch-icon.png` (default `#ffffff`, matching the web manifest's\n * `background_color`). The other outputs keep the source's transparency.\n *\n * Maskable means the OS crops the image to its own shape and expects full\n * bleed, so transparency there is not \"no background\", it is whatever the\n * launcher happens to composite onto — usually black. iOS discards alpha on the\n * apple-touch-icon outright. Since both files sit a deliberately-sized logo on a\n * larger canvas (the maskable safe zone), leaving that canvas transparent turned\n * ~36% of one and ~40% of the other into a black frame on the home screen.\n */\n backgroundColor?: string;\n}\n\n/** `#rgb` / `#rrggbb` → a sharp background. Anything else is rejected loudly. */\nfunction parseBackground(color: string): { r: number; g: number; b: number; alpha: number } {\n const hex = color.trim().replace(/^#/, '');\n const full =\n hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex.length === 6 ? hex : null;\n if (full == null || !/^[0-9a-f]{6}$/i.test(full))\n throw new Error(\n `favicon: backgroundColor must be a hex colour like \"#ffffff\" or \"#fff\" (got ${JSON.stringify(color)}).`,\n );\n const n = Number.parseInt(full, 16);\n return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, alpha: 1 };\n}\n\nlet warnedNoOxipng = false;\nasync function optimizePng(file: string, enabled: boolean): Promise<void> {\n if (!enabled) return;\n try {\n await exec(`oxipng -o 3 \"${file}\"`);\n } catch {\n // oxipng missing or failed — lossless crush is best-effort, so skip quietly.\n if (!warnedNoOxipng) {\n warnedNoOxipng = true;\n console.warn('favicon: oxipng not available — skipping lossless PNG optimization.');\n }\n }\n}\n\n/**\n * Does the source carry any transparency at all?\n *\n * Best-effort and deliberately quiet on failure — this only decides whether to\n * print a warning, so a probe that throws must not take the build down with it.\n * `stats().isOpaque` is sharp's own answer and covers the SVG case too, since it\n * rasterises first.\n */\nasync function hasTransparency(\n sharp: typeof import('sharp').default,\n source: string,\n): Promise<boolean> {\n try {\n return !(await sharp(source, { density: 384 }).stats()).isOpaque;\n } catch {\n return false;\n }\n}\n\n/**\n * Generate the standard favicon set from an SVG/PNG source into `outputDir`:\n * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.\n * Returns the list of written file paths.\n */\nexport async function generateFavicons(opts: FaviconOptions): Promise<string[]> {\n const { source, lowResSource } = opts;\n if (!source) throw new Error('favicon: no source path provided');\n if (!source.endsWith('.svg') && !source.endsWith('.png'))\n throw new Error('favicon: source must be an SVG or PNG file');\n\n const dir = (opts.outputDir ?? '.').replace(/\\/+$/, '');\n const optimize = opts.optimize ?? true;\n // Same default as the web manifest's `background_color`. The two disagreeing is\n // the actual bug: `backgroundColor` reached the manifest and never the raster.\n const maskBg = parseBackground(opts.backgroundColor ?? '#ffffff');\n mkdirSync(dir, { recursive: true });\n const out = (name: string) => join(dir, name);\n\n const written: string[] = [];\n\n // Modern browsers prefer a scalable SVG favicon (and it supports dark-mode via\n // CSS media queries) — ship the source verbatim as favicon.svg when it is one.\n if (source.endsWith('.svg')) {\n await copyFile(source, out('favicon.svg'));\n written.push(out('favicon.svg'));\n }\n\n // Lazy-load the native/heavy deps only when actually generating.\n const [{ default: sharp }, { default: pngToIco }] = await Promise.all([\n import('sharp'),\n import('png-to-ico'),\n ]);\n\n // Rasterize the main source at each size. sharp reads SVG natively via libvips;\n // rendering at high density first keeps small-size edges crisp (ignored for PNG).\n await Promise.all(\n [32, 192, 512].map((size) =>\n sharp(source, { density: 384 })\n .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out(`icon-${size}.png`))\n .then(() => written.push(out(`icon-${size}.png`))),\n ),\n );\n\n // Optional low-res 16px variant.\n if (lowResSource) {\n await sharp(lowResSource, { density: 384 })\n .resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out('icon-16.png'));\n written.push(out('icon-16.png'));\n }\n\n // favicon.ico — sharp can't encode .ico, so png-to-ico packs the PNGs into the\n // multi-resolution container. Browsers pick the best size inside the file.\n const icoSources = [out('icon-32.png')];\n if (lowResSource) icoSources.unshift(out('icon-16.png'));\n await writeFile(out('favicon.ico'), await pngToIco(icoSources));\n written.push(out('favicon.ico'));\n\n // The two MASKABLE outputs below are composited onto an opaque background: the\n // OS crops them to its own shape, so transparency reads as a black frame rather\n // than as \"no background\". Warn when we're inventing that colour rather than\n // being told it, since white is a guess that a dark logo would lose against.\n if (opts.backgroundColor == null && (await hasTransparency(sharp, source))) {\n console.warn(\n `favicon: ${source} has transparency, and the maskable outputs (icon-mask.png, ` +\n 'apple-touch-icon.png) must be opaque — the OS crops them to its own shape. ' +\n 'Defaulting their background to #ffffff; set backgroundColor to choose.',\n );\n }\n\n // apple-touch-icon: 140x140 logo centered on a 180x180 canvas.\n await sharp(out('icon-512.png'))\n .resize(140, 140, { fit: 'contain', background: maskBg })\n .extend({ top: 20, bottom: 20, left: 20, right: 20, background: maskBg })\n .flatten({ background: maskBg }) // iOS discards alpha; do it ourselves so we pick the colour\n .png()\n .toFile(out('apple-touch-icon.png'));\n written.push(out('apple-touch-icon.png'));\n\n // icon-mask (maskable): 409x409 logo centered on a 512x512 canvas. The 409/512\n // inset IS the maskable safe zone — the geometry was always right, only the\n // fill was wrong.\n await sharp(out('icon-512.png'))\n .resize(409, 409, { fit: 'contain', background: maskBg })\n .extend({ top: 52, bottom: 51, left: 52, right: 51, background: maskBg })\n .flatten({ background: maskBg })\n .png()\n .toFile(out('icon-mask.png'));\n written.push(out('icon-mask.png'));\n\n // Lossless crush of the larger PNGs (best-effort; needs oxipng on PATH).\n await Promise.all(\n ['icon-192.png', 'icon-512.png', 'apple-touch-icon.png', 'icon-mask.png'].map((image) =>\n optimizePng(out(image), optimize),\n ),\n );\n\n return written;\n}\n\n// ── Head tags + web manifest (the \"tag layer\" over the generated image files) ──\n\nexport interface FaviconLink {\n rel: string;\n href: string;\n type?: string;\n sizes?: string;\n}\n\nexport interface FaviconTagOptions {\n /** Whether a favicon.svg was emitted (source was an SVG). */\n hasSvg?: boolean;\n /** Include the `<link rel=\"manifest\">` (default true). */\n manifest?: boolean;\n /** URL base the favicon files are served from (default '' → site root). */\n base?: string;\n}\n\n/**\n * The ordered `<link>` data matching what `generateFavicons` writes. Pure — no\n * I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.\n */\nexport function faviconLinks(opts: FaviconTagOptions = {}): FaviconLink[] {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const href = (f: string) => `${base}/${f}`;\n const links: FaviconLink[] = [{ rel: 'icon', href: href('favicon.ico'), sizes: '32x32' }];\n if (opts.hasSvg) links.push({ rel: 'icon', type: 'image/svg+xml', href: href('favicon.svg') });\n links.push({ rel: 'icon', type: 'image/png', sizes: '32x32', href: href('icon-32.png') });\n links.push({ rel: 'apple-touch-icon', href: href('apple-touch-icon.png') });\n if (opts.manifest !== false) links.push({ rel: 'manifest', href: href('site.webmanifest') });\n return links;\n}\n\nexport interface WebManifestOptions {\n name: string;\n shortName?: string;\n themeColor?: string;\n backgroundColor?: string;\n /** URL base the icon files are served from (default '' → site root). */\n base?: string;\n}\n\n/** Build the web-app-manifest object (icons match `generateFavicons` output). */\nexport function faviconManifest(opts: WebManifestOptions): Record<string, unknown> {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const src = (f: string) => `${base}/${f}`;\n return {\n name: opts.name,\n short_name: opts.shortName ?? opts.name,\n // `purpose` is stated on all three rather than left to default. An entry with\n // no purpose is implicitly \"any\", so omitting it worked — but with a maskable\n // present and nothing else claiming \"any\", some launchers picked the maskable\n // for slots that wanted a plain icon, i.e. the one with the safe-zone inset.\n icons: [\n { src: src('icon-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' },\n { src: src('icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' },\n { src: src('icon-mask.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },\n ],\n theme_color: opts.themeColor ?? '#ffffff',\n background_color: opts.backgroundColor ?? '#ffffff',\n display: 'standalone',\n start_url: '/',\n };\n}\n\n/** Write `site.webmanifest` into `dir`. Returns the written path. */\nexport async function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string> {\n const path = join(dir.replace(/\\/+$/, ''), 'site.webmanifest');\n await writeFile(path, `${JSON.stringify(faviconManifest(opts), null, 2)}\\n`);\n return path;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"favicon.mjs","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["/**\n * Favicon generation — rasterize a source SVG/PNG into the standard favicon set.\n *\n * Ported from the repo's `lib/favicon.ts` so the logic lives in one place instead\n * of being copied per client. `sharp` + `png-to-ico` are loaded lazily (dynamic\n * import) so merely importing this module is cheap; the native `sharp` binary is\n * only touched when `generateFavicons` actually runs. `oxipng` (lossless PNG\n * crush) is used when present on PATH and skipped otherwise — it is optional.\n */\nimport { mkdirSync } from 'node:fs';\nimport { copyFile, writeFile } from 'node:fs/promises';\nimport { promisify } from 'node:util';\nimport cp from 'node:child_process';\nimport { join } from 'node:path';\n\nconst exec = promisify(cp.exec);\n\nexport interface FaviconOptions {\n /** Source image — an SVG or PNG. */\n source: string;\n /** Optional simplified source for the 16px icon. */\n lowResSource?: string;\n /** Directory to write into (default: current directory). */\n outputDir?: string;\n /** Run `oxipng` on the larger PNGs when available (default: true). */\n optimize?: boolean;\n /**\n * Background composited under the two MASKABLE outputs — `icon-mask.png` and\n * `apple-touch-icon.png` (default `#ffffff`, matching the web manifest's\n * `background_color`). The other outputs keep the source's transparency.\n *\n * Maskable means the OS crops the image to its own shape and expects full\n * bleed, so transparency there is not \"no background\", it is whatever the\n * launcher happens to composite onto — usually black. iOS discards alpha on the\n * apple-touch-icon outright. Since both files sit a deliberately-sized logo on a\n * larger canvas (the maskable safe zone), leaving that canvas transparent turned\n * ~36% of one and ~40% of the other into a black frame on the home screen.\n */\n backgroundColor?: string;\n}\n\n/** `#rgb` / `#rrggbb` → a sharp background. Anything else is rejected loudly. */\nfunction parseBackground(color: string): { r: number; g: number; b: number; alpha: number } {\n const hex = color.trim().replace(/^#/, '');\n const full =\n hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex.length === 6 ? hex : null;\n if (full == null || !/^[0-9a-f]{6}$/i.test(full))\n throw new Error(\n `favicon: backgroundColor must be a hex colour like \"#ffffff\" or \"#fff\" (got ${JSON.stringify(color)}).`,\n );\n const n = Number.parseInt(full, 16);\n return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, alpha: 1 };\n}\n\nlet warnedNoOxipng = false;\nasync function optimizePng(file: string, enabled: boolean): Promise<void> {\n if (!enabled) return;\n try {\n await exec(`oxipng -o 3 \"${file}\"`);\n } catch {\n // oxipng missing or failed — lossless crush is best-effort, so skip quietly.\n if (!warnedNoOxipng) {\n warnedNoOxipng = true;\n console.warn('favicon: oxipng not available — skipping lossless PNG optimization.');\n }\n }\n}\n\n/**\n * Does the source carry any transparency at all?\n *\n * Best-effort and deliberately quiet on failure — this only decides whether to\n * print a warning, so a probe that throws must not take the build down with it.\n * `stats().isOpaque` is sharp's own answer and covers the SVG case too, since it\n * rasterises first.\n */\nasync function hasTransparency(\n sharp: typeof import('sharp').default,\n source: string,\n): Promise<boolean> {\n try {\n return !(await sharp(source, { density: 384 }).stats()).isOpaque;\n } catch {\n return false;\n }\n}\n\n/**\n * Is the mark dark enough to disappear against a white default?\n *\n * Transparency is not the only way to lose a logo. `apple-touch-icon` insets the\n * mark into a larger canvas and fills the surround with the mask background, so\n * an **opaque** dark logo still gets a white frame — and the transparency-only\n * check said nothing about it. Mean channel luminance over the rasterised\n * source is a coarse signal, which is the right amount for a warning.\n */\nasync function isDarkMark(sharp: typeof import('sharp').default, source: string): Promise<boolean> {\n try {\n const { channels } = await sharp(source, { density: 384 }).stats();\n const rgb = channels.slice(0, 3);\n if (!rgb.length) return false;\n return rgb.reduce((sum, c) => sum + c.mean, 0) / rgb.length < 128;\n } catch {\n return false;\n }\n}\n\n/**\n * Generate the standard favicon set from an SVG/PNG source into `outputDir`:\n * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.\n * Returns the list of written file paths.\n */\nexport async function generateFavicons(opts: FaviconOptions): Promise<string[]> {\n const { source, lowResSource } = opts;\n if (!source) throw new Error('favicon: no source path provided');\n if (!source.endsWith('.svg') && !source.endsWith('.png'))\n throw new Error('favicon: source must be an SVG or PNG file');\n\n const dir = (opts.outputDir ?? '.').replace(/\\/+$/, '');\n const optimize = opts.optimize ?? true;\n // Same default as the web manifest's `background_color`. The two disagreeing is\n // the actual bug: `backgroundColor` reached the manifest and never the raster.\n const maskBg = parseBackground(opts.backgroundColor ?? '#ffffff');\n mkdirSync(dir, { recursive: true });\n const out = (name: string) => join(dir, name);\n\n const written: string[] = [];\n\n // Modern browsers prefer a scalable SVG favicon (and it supports dark-mode via\n // CSS media queries) — ship the source verbatim as favicon.svg when it is one.\n if (source.endsWith('.svg')) {\n await copyFile(source, out('favicon.svg'));\n written.push(out('favicon.svg'));\n }\n\n // Lazy-load the native/heavy deps only when actually generating.\n const [{ default: sharp }, { default: pngToIco }] = await Promise.all([\n import('sharp'),\n import('png-to-ico'),\n ]);\n\n // Rasterize the main source at each size. sharp reads SVG natively via libvips;\n // rendering at high density first keeps small-size edges crisp (ignored for PNG).\n await Promise.all(\n [32, 192, 512].map((size) =>\n sharp(source, { density: 384 })\n .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out(`icon-${size}.png`))\n .then(() => written.push(out(`icon-${size}.png`))),\n ),\n );\n\n // Optional low-res 16px variant.\n if (lowResSource) {\n await sharp(lowResSource, { density: 384 })\n .resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out('icon-16.png'));\n written.push(out('icon-16.png'));\n }\n\n // favicon.ico — sharp can't encode .ico, so png-to-ico packs the PNGs into the\n // multi-resolution container. Browsers pick the best size inside the file.\n const icoSources = [out('icon-32.png')];\n if (lowResSource) icoSources.unshift(out('icon-16.png'));\n await writeFile(out('favicon.ico'), await pngToIco(icoSources));\n written.push(out('favicon.ico'));\n\n // The two MASKABLE outputs below are composited onto an opaque background: the\n // OS crops them to its own shape, so transparency reads as a black frame rather\n // than as \"no background\". Warn when we're inventing that colour rather than\n // being told it, since white is a guess that a dark logo would lose against.\n if (opts.backgroundColor == null) {\n const transparent = await hasTransparency(sharp, source);\n // Even an opaque source is affected: apple-touch-icon insets the mark and\n // fills the surround, so a dark logo gets a white frame it never asked for.\n const dark = transparent ? false : await isDarkMark(sharp, source);\n if (transparent || dark)\n console.warn(\n `favicon: ${source} ${transparent ? 'has transparency' : 'is a dark mark'}, and the ` +\n 'maskable outputs (icon-mask.png, apple-touch-icon.png) are composited onto an opaque ' +\n 'background — the OS crops them to its own shape. Defaulting that background to ' +\n '#ffffff; set backgroundColor to choose. (It is not manifest-only: these two files ' +\n 'are written whether or not a web manifest is.)',\n );\n }\n\n // apple-touch-icon: 140x140 logo centered on a 180x180 canvas.\n await sharp(out('icon-512.png'))\n .resize(140, 140, { fit: 'contain', background: maskBg })\n .extend({ top: 20, bottom: 20, left: 20, right: 20, background: maskBg })\n .flatten({ background: maskBg }) // iOS discards alpha; do it ourselves so we pick the colour\n .png()\n .toFile(out('apple-touch-icon.png'));\n written.push(out('apple-touch-icon.png'));\n\n // icon-mask (maskable): 409x409 logo centered on a 512x512 canvas. The 409/512\n // inset IS the maskable safe zone — the geometry was always right, only the\n // fill was wrong.\n await sharp(out('icon-512.png'))\n .resize(409, 409, { fit: 'contain', background: maskBg })\n .extend({ top: 52, bottom: 51, left: 52, right: 51, background: maskBg })\n .flatten({ background: maskBg })\n .png()\n .toFile(out('icon-mask.png'));\n written.push(out('icon-mask.png'));\n\n // Lossless crush of the larger PNGs (best-effort; needs oxipng on PATH).\n await Promise.all(\n ['icon-192.png', 'icon-512.png', 'apple-touch-icon.png', 'icon-mask.png'].map((image) =>\n optimizePng(out(image), optimize),\n ),\n );\n\n return written;\n}\n\n// ── Head tags + web manifest (the \"tag layer\" over the generated image files) ──\n\nexport interface FaviconLink {\n rel: string;\n href: string;\n type?: string;\n sizes?: string;\n}\n\nexport interface FaviconTagOptions {\n /** Whether a favicon.svg was emitted (source was an SVG). */\n hasSvg?: boolean;\n /** Include the `<link rel=\"manifest\">` (default true). */\n manifest?: boolean;\n /** URL base the favicon files are served from (default '' → site root). */\n base?: string;\n}\n\n/**\n * The ordered `<link>` data matching what `generateFavicons` writes. Pure — no\n * I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.\n */\nexport function faviconLinks(opts: FaviconTagOptions = {}): FaviconLink[] {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const href = (f: string) => `${base}/${f}`;\n const links: FaviconLink[] = [{ rel: 'icon', href: href('favicon.ico'), sizes: '32x32' }];\n if (opts.hasSvg) links.push({ rel: 'icon', type: 'image/svg+xml', href: href('favicon.svg') });\n links.push({ rel: 'icon', type: 'image/png', sizes: '32x32', href: href('icon-32.png') });\n links.push({ rel: 'apple-touch-icon', href: href('apple-touch-icon.png') });\n if (opts.manifest !== false) links.push({ rel: 'manifest', href: href('site.webmanifest') });\n return links;\n}\n\nexport interface WebManifestOptions {\n name: string;\n shortName?: string;\n themeColor?: string;\n backgroundColor?: string;\n /** URL base the icon files are served from (default '' → site root). */\n base?: string;\n}\n\n/** Build the web-app-manifest object (icons match `generateFavicons` output). */\nexport function faviconManifest(opts: WebManifestOptions): Record<string, unknown> {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const src = (f: string) => `${base}/${f}`;\n return {\n name: opts.name,\n short_name: opts.shortName ?? opts.name,\n // `purpose` is stated on all three rather than left to default. An entry with\n // no purpose is implicitly \"any\", so omitting it worked — but with a maskable\n // present and nothing else claiming \"any\", some launchers picked the maskable\n // for slots that wanted a plain icon, i.e. the one with the safe-zone inset.\n icons: [\n { src: src('icon-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' },\n { src: src('icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' },\n { src: src('icon-mask.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },\n ],\n theme_color: opts.themeColor ?? '#ffffff',\n background_color: opts.backgroundColor ?? '#ffffff',\n display: 'standalone',\n start_url: '/',\n };\n}\n\n/** Write `site.webmanifest` into `dir`. Returns the written path. */\nexport async function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string> {\n const path = join(dir.replace(/\\/+$/, ''), 'site.webmanifest');\n await writeFile(path, `${JSON.stringify(faviconManifest(opts), null, 2)}\\n`);\n return path;\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/indexing/types.ts","../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/indexing/types.ts","../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"mappings":"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"}
@@ -104,6 +104,11 @@ function plan(input) {
104
104
  urls = diff(current, previous);
105
105
  reason = "diff";
106
106
  }
107
+ if (config.canonical && urls.length) {
108
+ const own = hostOf(`${trimSlash(config.canonical)}/`);
109
+ const foreign = [...new Set(urls.map(hostOf).filter((h) => !!h && h !== own))];
110
+ if (foreign.length) notes.push(`sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ${foreign.join(", ")} rather than ${own} — check that ${sitemapUrl} is this site's own sitemap and not another route answering that path.`);
111
+ }
107
112
  if (reason === "diff" || reason === "cold") {
108
113
  const withLastmod = current.filter((e) => e.lastmod).length;
109
114
  if (current.length > 0 && withLastmod === 0) notes.push("no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.");
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n /** The sitemap as it stands now. */\n current: SitemapEntry[];\n /** The previous run's state, if any. */\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&amp;b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&apos;/g, \"'\")\n // `&amp;` last: decoding it first would let `&amp;lt;` collapse to `<`.\n .replace(/&amp;/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { SitemapEntry, Snapshot } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as Snapshot;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\\n`);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport { googleAccessToken, SCOPES, type ServiceAccount } from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n token: string,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n token: string,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/indexing/plan.ts","../../src/indexing/sitemap.ts","../../src/indexing/snapshot.ts","../../src/indexing/indexnow.ts","../../src/indexing/gsc.ts"],"sourcesContent":["/**\n * Every decision `vitops search notify` makes, as one pure function.\n *\n * Pure in the load-bearing sense: no `fetch`, no `fs`, no clock. Everything the\n * command could get wrong — submitting a staging URL, submitting nothing because a\n * cache was cold, submitting a URL IndexNow will reject for the wrong host — is\n * decided here and asserted in `plan.test.ts`. The I/O modules beside this one do\n * what they are told and nothing else.\n *\n * That split is the same one `@getvitops/core`'s consent store makes against its\n * DOM wiring, for the same reason: the consequential logic has to be testable\n * without the environment it runs in.\n */\nimport type { IndexingConfig, SitemapEntry, Snapshot } from './types.ts';\n\nexport const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';\n\n/**\n * IndexNow's own cap per submission. Batches larger than this are split rather\n * than truncated — a silently dropped tail is the failure mode this command is\n * meant to eliminate, not reproduce.\n */\nexport const INDEXNOW_BATCH = 10_000;\n\n/** Why the URL set is what it is. */\nexport type UrlReason = 'explicit' | 'all' | 'cold' | 'diff';\n\n/** A channel's decision: on, or off with a stated reason. */\nexport interface ChannelPlan {\n enabled: boolean;\n /** Why it's off. Present iff `enabled` is false. */\n skip?: string;\n}\n\nexport interface IndexNowPlan extends ChannelPlan {\n endpoint: string;\n keyLocation?: string | undefined;\n key?: string | undefined;\n host?: string | undefined;\n /** URL batches, each within `INDEXNOW_BATCH`. */\n batches: string[][];\n}\n\nexport interface SearchConsolePlan extends ChannelPlan {\n siteUrl?: string | undefined;\n}\n\nexport interface IndexingPlan {\n /** Set when the whole run is refused. Nothing is submitted. */\n blocked?: string;\n /** Always computed; `undefined` when nothing in the config resolves to one. */\n sitemapUrl: string | undefined;\n /** URLs that changed and should be submitted. */\n urls: string[];\n reason: UrlReason;\n /** No usable previous snapshot — `urls` is everything. */\n cold: boolean;\n indexNow: IndexNowPlan;\n searchConsole: SearchConsolePlan;\n /** URLs `--check` will inspect. */\n check: string[];\n /** Things the operator needs to read. Printed by `--dry` and by a real run. */\n notes: string[];\n}\n\nexport interface PlanInput {\n config: IndexingConfig;\n /** The sitemap as it stands now. */\n current: SitemapEntry[];\n /** The previous run's state, if any. */\n previous?: Snapshot | undefined;\n /** `--urls`: submit exactly these, skipping the diff. */\n explicitUrls?: string[] | undefined;\n /** `--all`: submit every URL in the sitemap. */\n all?: boolean | undefined;\n}\n\nconst trimSlash = (s: string) => s.replace(/\\/+$/, '');\n\nfunction hostOf(url: string): string | undefined {\n try {\n return new URL(url).host;\n } catch {\n return undefined;\n }\n}\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n const out: T[][] = [];\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));\n return out;\n}\n\n/**\n * Resolve the sitemap URL.\n *\n * `sitemap-index.xml` is @astrojs/sitemap's default filename and is what\n * `vitops({ sitemap })` registers, so it is the right default here. It resolves to\n * a flat `<urlset>` on a small site, which `parseSitemap` handles by reading the\n * root element rather than the name.\n */\nexport function resolveSitemapUrl(config: IndexingConfig): string | undefined {\n if (config.sitemapUrl) return config.sitemapUrl;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/sitemap-index.xml`;\n}\n\n/** Resolve the IndexNow key file URL. */\nexport function resolveKeyLocation(config: IndexingConfig): string | undefined {\n const indexNow = config.indexNow;\n if (!indexNow) return undefined;\n if (indexNow.keyLocation) return indexNow.keyLocation;\n if (!config.canonical) return undefined;\n return `${trimSlash(config.canonical)}/${indexNow.key}.txt`;\n}\n\n/** Which URLs differ from the snapshot. */\nfunction diff(current: SitemapEntry[], previous: Snapshot): string[] {\n const changed: string[] = [];\n for (const e of current) {\n const before = previous.entries[e.loc];\n // Absent from the snapshot → new. Present with a different lastmod → updated.\n // A removed URL is deliberately not reported: there is nothing to submit for a\n // page that no longer exists, and IndexNow has no deletion semantics.\n if (before === undefined || before !== (e.lastmod ?? '')) changed.push(e.loc);\n }\n return changed;\n}\n\n/**\n * Decide what this run should do.\n *\n * Ordering of the URL-selection rules matters and is not arbitrary: an explicit\n * `--urls` is an operator overriding the tool, `--all` is an operator overriding\n * the diff, a cold snapshot is the tool having no basis to narrow, and the diff is\n * the normal path. Each is reported in `reason` so `--dry` explains itself.\n */\nexport function plan(input: PlanInput): IndexingPlan {\n const { config, current, previous, explicitUrls, all } = input;\n const notes: string[] = [];\n\n const sitemapUrl = resolveSitemapUrl(config);\n const check = config.priorityUrls ?? [];\n\n /*\n * The robots gate, before anything else.\n *\n * `environments.<env>.robots` already states whether a deployment is meant to be\n * indexed. Submitting a `noindex` environment's URLs to IndexNow is not a\n * no-op that the directive later undoes — it publishes the staging host to\n * several search engines and their AI crawlers, and asks them to come look. The\n * config knows the answer, so the tool must not need to be told twice.\n */\n if (config.robots && /\\bnoindex\\b/i.test(config.robots)) {\n return {\n blocked: `environment robots policy is \"${config.robots}\" — refusing to ask search engines to index an environment marked noindex`,\n sitemapUrl,\n urls: [],\n reason: 'diff',\n cold: false,\n indexNow: { enabled: false, skip: 'blocked', endpoint: INDEXNOW_ENDPOINT, batches: [] },\n searchConsole: { enabled: false, skip: 'blocked' },\n check: [],\n notes,\n };\n }\n\n const cold = !explicitUrls && !all && !previous;\n\n let urls: string[];\n let reason: UrlReason;\n if (explicitUrls?.length) {\n urls = explicitUrls;\n reason = 'explicit';\n } else if (all) {\n urls = current.map((e) => e.loc);\n reason = 'all';\n } else if (!previous) {\n urls = current.map((e) => e.loc);\n reason = 'cold';\n notes.push(\n `no previous snapshot — treating all ${urls.length} URLs as changed. Persist ${'`.vitops/`'} between runs (a CI cache) so later runs submit only what moved.`,\n );\n } else {\n urls = diff(current, previous);\n reason = 'diff';\n }\n\n /*\n * The foreign-sitemap check.\n *\n * A 404 fails loudly; a sitemap that is well-formed and *someone else's* does\n * not. A downstream site's sitemap route collided with its CMS's, so the URL\n * returned 200 with a valid document listing pages that were not theirs, and\n * the run reported a healthy cold submission. Nothing downstream can catch\n * this — the URLs parse, the diff works, the submission succeeds.\n *\n * Compared against the resolved environment origin rather than the canonical,\n * for the same reason `toIndexingConfig` derives the origin that way: on a\n * staging run the canonical IS the foreign host.\n */\n if (config.canonical && urls.length) {\n const own = hostOf(`${trimSlash(config.canonical)}/`);\n const foreign = [...new Set(urls.map(hostOf).filter((h): h is string => !!h && h !== own))];\n if (foreign.length)\n notes.push(\n `sitemap contains ${urls.length - urls.filter((u) => hostOf(u) === own).length} URL(s) on ` +\n `${foreign.join(', ')} rather than ${own} — check that ${sitemapUrl} is this site's own ` +\n `sitemap and not another route answering that path.`,\n );\n }\n\n /*\n * The lastmod warning.\n *\n * Without per-URL lastmod the diff can still see pages appear and disappear, but\n * it cannot see one change — so an edited page is never resubmitted and the\n * command quietly does less than it appears to. Worth a note every run, because\n * the output otherwise looks identical to the healthy case.\n */\n if (reason === 'diff' || reason === 'cold') {\n const withLastmod = current.filter((e) => e.lastmod).length;\n if (current.length > 0 && withLastmod === 0)\n notes.push(\n 'no <lastmod> in the sitemap — only added/removed pages can be detected, never an edit. Wire `gitLastmod()` into your sitemap options, or pass --all.',\n );\n else if (withLastmod < current.length)\n notes.push(\n `${current.length - withLastmod} of ${current.length} sitemap entries have no <lastmod> — edits to those pages cannot be detected.`,\n );\n }\n\n // ── IndexNow ────────────────────────────────────────────────────────────────\n const keyLocation = resolveKeyLocation(config);\n const keyHost = keyLocation ? hostOf(keyLocation) : undefined;\n let indexNow: IndexNowPlan;\n if (!config.indexNow) {\n indexNow = {\n enabled: false,\n skip: 'not configured (seo.indexing.indexNow)',\n endpoint: INDEXNOW_ENDPOINT,\n batches: [],\n };\n } else if (!keyLocation) {\n indexNow = {\n enabled: false,\n skip: 'no keyLocation and no domains.canonical to derive one',\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n key: config.indexNow.key,\n batches: [],\n };\n } else {\n /*\n * IndexNow requires every URL in a submission to share the key file's host.\n * A mixed batch is rejected whole, so the off-host URLs are dropped here and\n * named — silently sending them would fail the on-host ones too.\n */\n const onHost = urls.filter((u) => hostOf(u) === keyHost);\n const offHost = urls.length - onHost.length;\n if (offHost > 0)\n notes.push(\n `IndexNow: dropped ${offHost} URL(s) not on ${keyHost} — a submission must match its key file's host.`,\n );\n indexNow = {\n enabled: onHost.length > 0,\n ...(onHost.length === 0 ? { skip: 'no URLs to submit' } : {}),\n endpoint: config.indexNow.endpoint ?? INDEXNOW_ENDPOINT,\n keyLocation,\n key: config.indexNow.key,\n ...(keyHost ? { host: keyHost } : {}),\n batches: chunk(onHost, INDEXNOW_BATCH),\n };\n }\n\n // ── Search Console ──────────────────────────────────────────────────────────\n // Independent of `urls`: resubmitting the sitemap is what tells Google to\n // re-read it, and it is worth doing whenever the sitemap was rebuilt — there is\n // no per-URL submission to gate on, because Google exposes none.\n let searchConsole: SearchConsolePlan;\n if (!config.searchConsole) {\n searchConsole = { enabled: false, skip: 'not configured (seo.indexing.searchConsole)' };\n } else if (config.searchConsole.resubmitSitemap === false) {\n searchConsole = {\n enabled: false,\n skip: 'resubmitSitemap is false',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else if (!sitemapUrl) {\n searchConsole = {\n enabled: false,\n skip: 'no sitemapUrl and no domains.canonical to derive one',\n siteUrl: config.searchConsole.siteUrl,\n };\n } else {\n searchConsole = { enabled: true, siteUrl: config.searchConsole.siteUrl };\n }\n\n return {\n sitemapUrl,\n urls,\n reason,\n cold,\n indexNow,\n searchConsole,\n check,\n notes,\n };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: IndexingPlan): string {\n const lines: string[] = [];\n if (p.blocked) {\n lines.push(`✗ blocked — ${p.blocked}`);\n return lines.join('\\n');\n }\n lines.push(`sitemap ${p.sitemapUrl ?? '(none)'}`);\n lines.push(`urls ${p.urls.length} (${p.reason})`);\n\n lines.push(\n p.indexNow.enabled\n ? `IndexNow ${p.indexNow.batches.reduce((n, b) => n + b.length, 0)} URL(s) → ${p.indexNow.endpoint}`\n : `IndexNow skipped — ${p.indexNow.skip}`,\n );\n lines.push(\n p.searchConsole.enabled\n ? `Search Console resubmit sitemap → ${p.searchConsole.siteUrl}`\n : `Search Console skipped — ${p.searchConsole.skip}`,\n );\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n","/**\n * Sitemap parsing — enough of it, and no more.\n *\n * Hand-rolled rather than pulled from an XML library. A sitemap is a closed,\n * trivially-shaped document (`<urlset>` of `<url>`, or `<sitemapindex>` of\n * `<sitemap>`, each carrying `<loc>` and optionally `<lastmod>`), and this package\n * installs into every consumer project — a parser dependency for two element names\n * is not a trade worth making. The same reasoning the legal renderer applies to its\n * markdown subset: exactly as capable as the documents it has to read.\n */\nimport type { SitemapEntry } from './types.ts';\n\n/** What a sitemap document turned out to be. */\nexport interface ParsedSitemap {\n /** `<url>` entries, when this was a `<urlset>`. */\n entries: SitemapEntry[];\n /** Child sitemap URLs, when this was a `<sitemapindex>`. */\n sitemaps: string[];\n}\n\n/**\n * The five predefined XML entities, plus numeric character references.\n *\n * Not cosmetic: a sitemap URL carrying a query string is written `?a=1&amp;b=2`,\n * and submitting the un-decoded form means submitting a URL the site does not\n * serve — which IndexNow accepts and then silently fails to crawl.\n */\nfunction decodeXml(s: string): string {\n return (\n s\n .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))\n .replace(/&#(\\d+);/g, (_, d) => String.fromCodePoint(Number.parseInt(d, 10)))\n .replace(/&lt;/g, '<')\n .replace(/&gt;/g, '>')\n .replace(/&quot;/g, '\"')\n .replace(/&apos;/g, \"'\")\n // `&amp;` last: decoding it first would let `&amp;lt;` collapse to `<`.\n .replace(/&amp;/g, '&')\n );\n}\n\nconst tag = (block: string, name: string): string | undefined => {\n const m = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'i').exec(block);\n return m ? decodeXml(m[1]!.trim()) : undefined;\n};\n\nfunction blocks(xml: string, name: string): string[] {\n const out: string[] = [];\n const re = new RegExp(`<${name}(?:\\\\s[^>]*)?>([\\\\s\\\\S]*?)</${name}>`, 'gi');\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml))) out.push(m[1]!);\n return out;\n}\n\n/**\n * Parse a sitemap or sitemap index.\n *\n * Which one it is comes from the root element, not from the filename — an\n * `sitemap-index.xml` that is actually a flat `<urlset>` is common enough\n * (@astrojs/sitemap emits an index only past `entryLimit`) that keying on the name\n * would misread the single-file case as \"no URLs\".\n */\nexport function parseSitemap(xml: string): ParsedSitemap {\n if (/<sitemapindex[\\s>]/i.test(xml))\n return {\n entries: [],\n sitemaps: blocks(xml, 'sitemap')\n .map((b) => tag(b, 'loc'))\n .filter((l): l is string => !!l),\n };\n\n const entries: SitemapEntry[] = [];\n for (const b of blocks(xml, 'url')) {\n const loc = tag(b, 'loc');\n if (!loc) continue; // `<loc>` is required; an entry without one is unusable\n const lastmod = tag(b, 'lastmod');\n entries.push(lastmod ? { loc, lastmod } : { loc });\n }\n return { entries, sitemaps: [] };\n}\n\n/** Reads a sitemap from a URL or a local path. */\nexport type SitemapReader = (source: string) => Promise<string>;\n\n/**\n * Resolve a sitemap to its full entry list, following an index one level down.\n *\n * One level is the whole spec — the sitemaps protocol forbids an index pointing at\n * another index — so recursing further would only chase a malformed document.\n * Child fetches run sequentially: a large site's index can list dozens of files,\n * and hammering the consumer's own origin to save a second is a poor trade.\n */\nexport async function collectEntries(\n source: string,\n read: SitemapReader,\n): Promise<{ entries: SitemapEntry[]; sources: string[] }> {\n const root = parseSitemap(await read(source));\n if (root.sitemaps.length === 0) return { entries: root.entries, sources: [source] };\n\n const entries: SitemapEntry[] = [];\n const sources: string[] = [];\n for (const child of root.sitemaps) {\n const parsed = parseSitemap(await read(child));\n entries.push(...parsed.entries);\n sources.push(child);\n }\n return { entries, sources };\n}\n","/**\n * The previous run's sitemap state — what makes \"only submit what changed\"\n * possible at all.\n *\n * A plain JSON file rather than anything cleverer, because the thing reading it is\n * usually a CI job restoring a cache directory. It must survive being absent\n * (first run, cold cache) without pretending nothing changed — `readSnapshot`\n * returns `undefined` there and `plan()` treats that as \"submit everything, and\n * say so\".\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname } from 'node:path';\nimport type { SitemapEntry, Snapshot } from './types.ts';\n\n/** Default location, relative to the consumer's project root. */\nexport const SNAPSHOT_PATH = '.vitops/sitemap-snapshot.json';\n\n/**\n * Read a snapshot, or `undefined` when there isn't a usable one.\n *\n * A corrupt or wrong-version file reads as absent, not as empty. The distinction\n * matters: \"absent\" submits everything, whereas an empty entry map would look like\n * a site that legitimately has no URLs and submit nothing — the silent-success\n * failure this whole command exists to remove.\n */\nexport function readSnapshot(path: string): Snapshot | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const raw = JSON.parse(readFileSync(path, 'utf8')) as Snapshot;\n if (raw?.version !== 1 || typeof raw.entries !== 'object' || raw.entries === null)\n return undefined;\n return raw;\n } catch {\n return undefined;\n }\n}\n\n/** Build a snapshot from the entries just read. */\nexport function toSnapshot(sitemapUrl: string, entries: SitemapEntry[], takenAt: string): Snapshot {\n const map: Record<string, string> = {};\n for (const e of entries) map[e.loc] = e.lastmod ?? '';\n return { version: 1, sitemapUrl, takenAt, entries: map };\n}\n\n/**\n * Write the snapshot, creating its directory.\n *\n * Call this only after a submission actually succeeded. Writing it eagerly would\n * record URLs as notified that never were, and because the next run diffs against\n * it, those URLs would never be submitted again — a single transient 503 would\n * silently drop a page from every future run.\n */\nexport function writeSnapshot(path: string, snapshot: Snapshot): void {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, `${JSON.stringify(snapshot, null, 2)}\\n`);\n}\n","/**\n * IndexNow submission.\n *\n * Honoured by Bing, Yandex, Naver, Seznam and Yep — and, through Bing's index, by\n * the AI crawlers built on it. **Not** by Google, which has never adopted the\n * protocol despite testing it; nothing here should be described to a consumer as\n * affecting Google results.\n *\n * Participating engines share submissions with each other, so one endpoint is\n * normally the whole integration.\n */\nimport { randomBytes } from 'node:crypto';\nimport type { IndexNowPlan } from './plan.ts';\n\n/** Generate a key. 32 hex characters, the conventional shape. */\nexport function newKey(): string {\n return randomBytes(16).toString('hex');\n}\n\n/** The body of the key file served at `keyLocation`: the key, and nothing else. */\nexport const keyFileContents = (key: string): string => `${key}\\n`;\n\nexport interface SubmitResult {\n ok: boolean;\n status: number;\n urls: number;\n /** The endpoint's own explanation, when it gave one. */\n message?: string;\n}\n\n/**\n * How the endpoint's status codes actually read.\n *\n * `403` and `422` are the two that matter and they are easy to misdiagnose: both\n * mean the key file is wrong, not that the URLs are.\n */\nfunction explain(status: number): string | undefined {\n switch (status) {\n case 200:\n case 202:\n return undefined;\n case 400:\n return 'bad request — malformed URL list';\n case 403:\n return 'key file not found or its contents do not match the key';\n case 422:\n return 'a submitted URL does not belong to the key file host, or the key does not match';\n case 429:\n return 'rate limited — too many submissions';\n default:\n return `unexpected status ${status}`;\n }\n}\n\n/**\n * Verify the key file is live and correct before submitting.\n *\n * Worth a request of its own because of how IndexNow fails: a submission with an\n * unreachable key file returns `403`, but one with a *reachable* key file whose\n * contents are stale is accepted with `202` and then silently discarded. Checking\n * first is the only way to tell \"submitted\" from \"submitted and ignored\".\n */\nexport async function verifyKeyFile(\n keyLocation: string,\n key: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; reason?: string }> {\n let res: Response;\n try {\n res = await fetchImpl(keyLocation, { method: 'GET' });\n } catch (e) {\n return { ok: false, reason: `could not fetch ${keyLocation}: ${(e as Error).message}` };\n }\n if (!res.ok) return { ok: false, reason: `${keyLocation} returned ${res.status}` };\n const body = (await res.text()).trim();\n if (body !== key)\n return {\n ok: false,\n reason: `${keyLocation} does not contain the configured key (found ${body.length} chars)`,\n };\n return { ok: true };\n}\n\n/**\n * Submit one batch. Callers iterate `plan.batches` — the split is the planner's\n * job, so that what gets sent is decided in the pure layer and visible in `--dry`.\n */\nexport async function submitBatch(\n plan: IndexNowPlan,\n urls: string[],\n fetchImpl: typeof fetch = fetch,\n): Promise<SubmitResult> {\n const body = {\n host: plan.host,\n key: plan.key,\n keyLocation: plan.keyLocation,\n urlList: urls,\n };\n const res = await fetchImpl(plan.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json; charset=utf-8' },\n body: JSON.stringify(body),\n });\n const message = explain(res.status);\n return { ok: res.ok, status: res.status, urls: urls.length, ...(message ? { message } : {}) };\n}\n","/**\n * Google Search Console — the two things its API will actually do.\n *\n * Worth stating plainly, because the gap between what people expect and what\n * exists is the whole reason this module is small: **there is no \"request\n * indexing\" endpoint.** The button in the Search Console UI is not exposed in the\n * API or anywhere else, and the sitemap ping endpoint was removed in 2023. What\n * remains is `sitemaps.submit` (write) and `urlInspection` (read).\n *\n * The Indexing API is deliberately not wired here. It is scoped to `JobPosting`\n * and `BroadcastEvent`; it accepts other URLs and discards them, and using it for\n * general pages violates its terms with the consumer's own GCP project on the\n * line. A toolchain should not ship that as a documented path.\n *\n * Auth is minted by hand rather than through `googleapis`, which would be an\n * enormous dependency in a CLI that installs into every consumer project, for two\n * endpoints. The exchange itself lives in `../google/token.ts` — it is shared with\n * the onboarding module, which needs the same token from a different grant.\n */\nimport { googleAccessToken, SCOPES, type ServiceAccount } from '../google/token.ts';\n\nexport type { ServiceAccount } from '../google/token.ts';\n\n/**\n * Parse a service-account credential from its two supported sources.\n *\n * `VITOPS_GSC_SERVICE_ACCOUNT` holds the JSON inline (what a CI secret store\n * gives you); `GOOGLE_APPLICATION_CREDENTIALS` holds a path (Google's own\n * convention, and what a local `gcloud` setup already has). Supporting both means\n * neither environment has to be reshaped to fit the tool.\n */\nexport function parseServiceAccount(raw: string): ServiceAccount {\n let parsed: Partial<ServiceAccount>;\n try {\n parsed = JSON.parse(raw) as Partial<ServiceAccount>;\n } catch {\n throw new Error('service account is not valid JSON');\n }\n if (!parsed.client_email || !parsed.private_key)\n throw new Error('service account JSON is missing client_email or private_key');\n return { client_email: parsed.client_email, private_key: parsed.private_key };\n}\n\n/**\n * Exchange a service-account key for an access token (RS256 JWT bearer flow).\n *\n * `now` is a parameter so this is testable without mocking the clock.\n */\nexport function serviceAccountToken(\n account: ServiceAccount,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n return googleAccessToken(\n { kind: 'service-account', account, scope: SCOPES.webmasters },\n fetchImpl,\n now,\n );\n}\n\nconst encodeSite = (siteUrl: string) => encodeURIComponent(siteUrl);\n\n/**\n * Re-submit a sitemap — the automated form of the manual resubmit in the UI.\n *\n * A `403` here almost always means `siteUrl` doesn't match the property exactly\n * (`sc-domain:acme.ca` vs `https://acme.ca/`) or the service account isn't an\n * owner of it, rather than that the sitemap is bad.\n */\nexport async function submitSitemap(\n token: string,\n siteUrl: string,\n sitemapUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeSite(siteUrl)}/sitemaps/${encodeURIComponent(sitemapUrl)}`;\n const res = await fetchImpl(url, {\n method: 'PUT',\n headers: { authorization: `Bearer ${token}` },\n });\n if (res.ok) return { ok: true, status: res.status };\n const detail =\n res.status === 403\n ? `403 — check that siteUrl \"${siteUrl}\" matches the Search Console property exactly and that the service account is an owner of it`\n : `${res.status}: ${await res.text()}`;\n return { ok: false, status: res.status, message: detail };\n}\n\n/** What the URL Inspection API said about one page. */\nexport interface InspectionResult {\n url: string;\n /** Google's verdict, e.g. `PASS`, `NEUTRAL`, `FAIL`. */\n verdict?: string;\n /** e.g. `INDEXING_ALLOWED`, `BLOCKED_BY_ROBOTS_TXT`. */\n coverageState?: string;\n lastCrawlTime?: string;\n /** True when Google reports the page as indexed. */\n indexed: boolean;\n error?: string;\n}\n\n/**\n * Inspect one URL. **Read-only** — this reports what Google did, it cannot ask for\n * anything. Quota is 2000 queries/day per property, which is why the caller works\n * from an explicit `priorityUrls` list rather than the whole sitemap.\n */\nexport async function inspectUrl(\n token: string,\n siteUrl: string,\n inspectionUrl: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<InspectionResult> {\n const res = await fetchImpl(\n 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',\n {\n method: 'POST',\n headers: {\n authorization: `Bearer ${token}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify({ inspectionUrl, siteUrl }),\n },\n );\n if (!res.ok)\n return { url: inspectionUrl, indexed: false, error: `${res.status}: ${await res.text()}` };\n\n const body = (await res.json()) as {\n inspectionResult?: {\n indexStatusResult?: { verdict?: string; coverageState?: string; lastCrawlTime?: string };\n };\n };\n const r = body.inspectionResult?.indexStatusResult ?? {};\n return {\n url: inspectionUrl,\n ...(r.verdict ? { verdict: r.verdict } : {}),\n ...(r.coverageState ? { coverageState: r.coverageState } : {}),\n ...(r.lastCrawlTime ? { lastCrawlTime: r.lastCrawlTime } : {}),\n // `PASS` is the only verdict that means indexed. `NEUTRAL` covers \"discovered,\n // not indexed\" and \"excluded by canonical\" — both are *not indexed*, and\n // reading them as success would make --check pass on the exact pages it exists\n // to catch.\n indexed: r.verdict === 'PASS',\n };\n}\n"],"mappings":"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"}
@@ -160,6 +160,19 @@ declare function renderEmail(event: ConversionEvent): RenderedMessage;
160
160
  interface SendEmailOptions {
161
161
  /** Attempts for a retryable failure (default 3). */
162
162
  attempts?: number;
163
+ /**
164
+ * Per-attempt ceiling in milliseconds (default 10000; `0` disables).
165
+ *
166
+ * `binding.send()` is a network call with no deadline of its own, so without
167
+ * this one hung attempt hangs the request that produced it — forever, in the
168
+ * one module otherwise built on always saying why. A conversion endpoint
169
+ * cannot be the thing that makes a form submission time out, so the bound
170
+ * belongs here rather than in every consumer's wrapper.
171
+ *
172
+ * A timeout is treated as retryable: it is a transient condition by
173
+ * definition, unlike `E_SENDER_NOT_VERIFIED` and friends.
174
+ */
175
+ timeoutMs?: number;
163
176
  /** Injected for tests; real callers let it default to a real delay. */
164
177
  sleep?: (ms: number) => Promise<void>;
165
178
  }
@@ -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/index.ts"],"mappings":";;;;;;AAeA;;;;;;;;AAGM;AASN;;UAZiB,eAAA;EACf,OAAA;EACA,IAAA;EACA,IAAA;AAAA;;;;;AAsBO;AAGT;UAhBiB,kBAAA;EACf,QAAA;EAiBA;EAfA,EAAA;EAsBe;EApBf,IAAA;;EAEA,QAAA;EACA,OAAA;EAkBgB;;;;EAbhB,OAAA;AAAA;AAAA,UAGe,mBAAA;EAuBW;EArB1B,KAAA,YAAiB,kBAAkB;AAAA;AAwB/B;AAAA,UAjBW,aAAA;EACf,aAAA,GAAgB,mBAAmB;;EAEnC,SAAA;EAiBiC;EAfjC,gBAAA;EAiBA;;;;EAZA,cAAA;AAAA;AAgBO;AAAA,UAZQ,WAAA;EACf,OAAA;;EAEA,IAAI;AAAA;AAAA,UAGW,SAAA,SAAkB,WAAW;EAC5C,QAAA;EACA,EAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,gBAAA;EACf,KAAA,EAAO,SAAS;EAUhB;EARA,KAAA;AAAA;AAUI;AAAA,UANW,aAAA;EACf,OAAA;EACA,IAAA;EAuBW;EArBX,MAAA;EAeE;EAbF,IAAA;AAAA;;;;;;;;;UAWe,YAAA;EACf,IAAA,CAAK,OAAA;IACH,EAAA;IACA,IAAA;MAAiB,KAAA;MAAe,IAAA;IAAA;IAChC,OAAA;IACA,IAAA;IACA,IAAA;IACA,OAAA;EAAA,IACE,OAAO;AAAA;;;;cC/FA,qBAAA;;iBAGG,qBAAA,CACd,KAAA,WAAgB,kBAAA,eACf,kBAAkB;;;;ADiBZ;AAGT;;;iBCRgB,gBAAA,CACd,OAAA,EAAS,kBAAA,EACT,GAAA,EAAK,aAAa;ADQiB;AAOrC;;;;;;AAPqC,iBCKrB,aAAA,CAAc,OAAA,EAAS,kBAAA,EAAoB,GAAA,EAAK,aAAa;;;;ADY7D;AAIhB;;iBCGgB,iBAAA,CAAkB,GAAA,EAAK,aAAA,GAAgB,gBAAgB;;;;UC9B7D,OAAA;EACR,OAAA;EACA,KAAK;AAAA;;;;;AFvBD;AASN;iBEuBgB,aAAA,CAAc,KAAA,EAAO,eAAA;EAAoB,OAAA;EAAiB,QAAA,EAAU,OAAO;AAAA;;iBAwD3E,WAAA,CAAY,KAAA,EAAO,eAAA,GAAkB,eAAe;;;UChEnD,gBAAA;EHPf;EGSA,QAAA;EHJO;EGMP,KAAA,IAAS,EAAA,aAAe,OAAO;AAAA;;;;AHDI;AAOrC;;;;iBGOsB,SAAA,CACpB,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,OAAA,EAAS,YAAA,EACT,OAAA,GAAS,gBAAA,GACR,OAAA,CAAQ,aAAA;;;;;;AHFK;AAIhB;iBG4CgB,QAAA,CACd,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,GAAA,IAAM,IAAA,oBACL,aAAA;;;UCjFc,UAAA;EJmBa;;;;EId5B,KAAA,GAAQ,YAAY;AAAA;AAAA,UAGL,YAAA;EACf,OAAA,EAAS,aAAa;EJoBR;EIlBd,SAAA;AAAA;;;;AJyBI;AAGN;;;iBIlBsB,MAAA,CACpB,KAAA,EAAO,eAAA,EACP,GAAA,EAAK,aAAA,EACL,IAAA,GAAM,UAAA,GACL,OAAA,CAAQ,YAAA"}
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/index.ts"],"mappings":";;;;;;AAeA;;;;;;;;AAGM;AASN;;UAZiB,eAAA;EACf,OAAA;EACA,IAAA;EACA,IAAA;AAAA;;;;;AAsBO;AAGT;UAhBiB,kBAAA;EACf,QAAA;EAiBA;EAfA,EAAA;EAsBe;EApBf,IAAA;;EAEA,QAAA;EACA,OAAA;EAkBgB;;;;EAbhB,OAAA;AAAA;AAAA,UAGe,mBAAA;EAuBW;EArB1B,KAAA,YAAiB,kBAAkB;AAAA;AAwB/B;AAAA,UAjBW,aAAA;EACf,aAAA,GAAgB,mBAAmB;;EAEnC,SAAA;EAiBiC;EAfjC,gBAAA;EAiBA;;;;EAZA,cAAA;AAAA;AAgBO;AAAA,UAZQ,WAAA;EACf,OAAA;;EAEA,IAAI;AAAA;AAAA,UAGW,SAAA,SAAkB,WAAW;EAC5C,QAAA;EACA,EAAA;EACA,IAAA;EACA,QAAA;EACA,OAAA;EACA,OAAA;AAAA;AAAA,UAGe,gBAAA;EACf,KAAA,EAAO,SAAS;EAUhB;EARA,KAAA;AAAA;AAUI;AAAA,UANW,aAAA;EACf,OAAA;EACA,IAAA;EAuBW;EArBX,MAAA;EAeE;EAbF,IAAA;AAAA;;;;;;;;;UAWe,YAAA;EACf,IAAA,CAAK,OAAA;IACH,EAAA;IACA,IAAA;MAAiB,KAAA;MAAe,IAAA;IAAA;IAChC,OAAA;IACA,IAAA;IACA,IAAA;IACA,OAAA;EAAA,IACE,OAAO;AAAA;;;;cC/FA,qBAAA;;iBAGG,qBAAA,CACd,KAAA,WAAgB,kBAAA,eACf,kBAAkB;;;;ADiBZ;AAGT;;;iBCRgB,gBAAA,CACd,OAAA,EAAS,kBAAA,EACT,GAAA,EAAK,aAAa;ADQiB;AAOrC;;;;;;AAPqC,iBCKrB,aAAA,CAAc,OAAA,EAAS,kBAAA,EAAoB,GAAA,EAAK,aAAa;;;;ADY7D;AAIhB;;iBCGgB,iBAAA,CAAkB,GAAA,EAAK,aAAA,GAAgB,gBAAgB;;;;UC9B7D,OAAA;EACR,OAAA;EACA,KAAK;AAAA;;;;;AFvBD;AASN;iBEiDgB,aAAA,CAAc,KAAA,EAAO,eAAA;EAAoB,OAAA;EAAiB,QAAA,EAAU,OAAO;AAAA;;iBAwD3E,WAAA,CAAY,KAAA,EAAO,eAAA,GAAkB,eAAe;;;UC1FnD,gBAAA;EHPf;EGSA,QAAA;EHJO;AAAA;AAGT;;;;AAEqC;AAOrC;;;;;EGKE,SAAA;EHFA;EGIA,KAAA,IAAS,EAAA,aAAe,OAAO;AAAA;;AHGjB;AAIhB;;;;AAGM;AAGN;iBG6BsB,SAAA,CACpB,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,OAAA,EAAS,YAAA,EACT,OAAA,GAAS,gBAAA,GACR,OAAA,CAAQ,aAAA;;;;;;;;iBAkDK,QAAA,CACd,IAAA,EAAM,SAAA,EACN,OAAA,EAAS,eAAA,EACT,GAAA,IAAM,IAAA,oBACL,aAAA;;;UC/Hc,UAAA;EJmBa;;;;EId5B,KAAA,GAAQ,YAAY;AAAA;AAAA,UAGL,YAAA;EACf,OAAA,EAAS,aAAa;EJoBR;EIlBd,SAAA;AAAA;;;;AJyBI;AAGN;;;iBIlBsB,MAAA,CACpB,KAAA,EAAO,eAAA,EACP,GAAA,EAAK,aAAA,EACL,IAAA,GAAM,UAAA,GACL,OAAA,CAAQ,YAAA"}
@@ -21,6 +21,30 @@ function messageOf(error) {
21
21
  return error instanceof Error ? error.message : String(error);
22
22
  }
23
23
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
24
+ /** Marker for a send that exceeded its deadline, so the retry rule can see it. */
25
+ const TIMEOUT_CODE = "E_SEND_TIMEOUT";
26
+ /**
27
+ * Race a send against its deadline.
28
+ *
29
+ * The timer is always cleared — a pending `setTimeout` keeps a Worker's event
30
+ * loop alive past the response, which turns a bounded send into an unbounded
31
+ * invocation and bills for the difference.
32
+ */
33
+ async function withTimeout(work, ms) {
34
+ if (ms <= 0) return work;
35
+ let timer;
36
+ try {
37
+ return await Promise.race([work, new Promise((_, reject) => {
38
+ timer = setTimeout(() => {
39
+ const err = /* @__PURE__ */ new Error(`Email send exceeded ${ms}ms.`);
40
+ err.code = TIMEOUT_CODE;
41
+ reject(err);
42
+ }, ms);
43
+ })]);
44
+ } finally {
45
+ if (timer !== void 0) clearTimeout(timer);
46
+ }
47
+ }
24
48
  /**
25
49
  * Send one rendered message according to a plan.
26
50
  *
@@ -37,8 +61,9 @@ async function sendEmail(plan, message, binding, options = {}) {
37
61
  };
38
62
  const attempts = Math.max(1, options.attempts ?? 3);
39
63
  const sleep = options.sleep ?? wait;
64
+ const timeoutMs = options.timeoutMs ?? 1e4;
40
65
  for (let attempt = 1; attempt <= attempts; attempt++) try {
41
- await binding.send({
66
+ await withTimeout(binding.send({
42
67
  to: plan.to,
43
68
  from: plan.fromName ? {
44
69
  email: plan.from,
@@ -48,14 +73,14 @@ async function sendEmail(plan, message, binding, options = {}) {
48
73
  text: message.text,
49
74
  html: message.html,
50
75
  ...plan.replyTo ? { replyTo: plan.replyTo } : {}
51
- });
76
+ }), timeoutMs);
52
77
  return {
53
78
  channel: "email",
54
79
  sent: true
55
80
  };
56
81
  } catch (error) {
57
82
  const code = codeOf(error);
58
- if (attempt === attempts || !code || !RETRYABLE.has(code)) return {
83
+ if (attempt === attempts || !code || !(RETRYABLE.has(code) || code === TIMEOUT_CODE)) return {
59
84
  channel: "email",
60
85
  sent: false,
61
86
  reason: messageOf(error),
@@ -218,6 +243,30 @@ function escapeHtml(value) {
218
243
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
219
244
  }
220
245
  /**
246
+ * Who submitted the form, for the subject line.
247
+ *
248
+ * `formData['name']` alone is the shape a form has when someone writes it that
249
+ * way, and most don't: a downstream site had `first_name`/`last_name` on five of
250
+ * six forms, so every notification read "from Unknown" and the renderer could
251
+ * not be adopted at all. The cascade is ordered most-specific first, and falls
252
+ * back to the email rather than to nothing — an address identifies the person
253
+ * even when no name field exists.
254
+ */
255
+ function submitterName(formData) {
256
+ const get = (...keys) => {
257
+ for (const k of keys) {
258
+ const v = formData[k]?.trim();
259
+ if (v) return v;
260
+ }
261
+ };
262
+ const full = get("name", "full_name", "fullName", "fullname", "your-name");
263
+ if (full) return full;
264
+ const first = get("first_name", "firstName", "firstname", "given_name");
265
+ const last = get("last_name", "lastName", "lastname", "family_name", "surname");
266
+ if (first || last) return [first, last].filter(Boolean).join(" ");
267
+ return get("email", "email_address", "emailAddress") ?? "Unknown";
268
+ }
269
+ /**
221
270
  * The event as sections — the shared structure behind both renderings.
222
271
  *
223
272
  * Extracted so the text and HTML versions cannot disagree about content. They
@@ -230,7 +279,7 @@ function describeEvent(event) {
230
279
  const contact = [];
231
280
  let subject;
232
281
  if (event.type === "form" && event.formData) {
233
- subject = `[Contact Form] New submission from ${event.formData["name"] ?? "Unknown"}`;
282
+ subject = `[Contact Form] New submission from ${submitterName(event.formData)}`;
234
283
  for (const [key, value] of Object.entries(event.formData)) if (!OMIT_FIELDS.has(key) && value) contact.push([label(key), value]);
235
284
  } else {
236
285
  const phone = event.phone ?? "unknown";
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/notify/email.ts","../../src/notify/plan.ts","../../src/notify/render.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 /** 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/**\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\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n await 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 return { channel: 'email', sent: true };\n } catch (error) {\n const code = codeOf(error);\n if (attempt === attempts || !code || !RETRYABLE.has(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 { 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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\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 * 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 = event.formData['name'] ?? 'Unknown';\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 {\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(event: ConversionEvent): RenderedMessage {\n const { subject, sections } = describeEvent(event);\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map(([k, v]) => `${k}: ${v}`)].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 ([k, v]) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(k)}</td>` +\n `<td style=\"padding:2px 0\">${escapeHtml(v)}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\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 * ---\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): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event);\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 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/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 { 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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;');\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 * 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 const get = (...keys: string[]): string | undefined => {\n for (const k of keys) {\n const v = formData[k]?.trim();\n if (v) return v;\n }\n return undefined;\n };\n const full = get('name', 'full_name', 'fullName', 'fullname', 'your-name');\n if (full) return full;\n const first = get('first_name', 'firstName', 'firstname', 'given_name');\n const last = get('last_name', 'lastName', 'lastname', 'family_name', 'surname');\n if (first || last) return [first, last].filter(Boolean).join(' ');\n return get('email', 'email_address', 'emailAddress') ?? 'Unknown';\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 {\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(event: ConversionEvent): RenderedMessage {\n const { subject, sections } = describeEvent(event);\n\n const text = sections\n .map((s) => [`--- ${s.heading} ---`, ...s.lines.map(([k, v]) => `${k}: ${v}`)].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 ([k, v]) =>\n `<tr><td style=\"padding:2px 12px 2px 0;color:#666;vertical-align:top\">${escapeHtml(k)}</td>` +\n `<td style=\"padding:2px 0\">${escapeHtml(v)}</td></tr>`,\n )\n .join('')}</table>`,\n )\n .join('');\n\n return { subject, text, html };\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 * ---\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): Promise<NotifyResult> {\n const plan = planNotifications(ctx);\n const results: ChannelResult[] = [];\n\n if (plan.email.enabled) {\n const message = renderEmail(event);\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 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,5 +1,5 @@
1
1
  import { n as googleAccessToken } from "../token-BV7tPHh2.mjs";
2
- import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-BW-Iyi-B.mjs";
2
+ import { n as findZoneId, r as listApexTxt, t as createApexTxt } from "../cloudflare-CD8vwrgj.mjs";
3
3
 
4
4
  //#region src/onboarding/plan.ts
5
5
  /** The `sc-domain:` property identifier for a bare domain. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/utils",
3
- "version": "4.1.0",
3
+ "version": "6.0.0",
4
4
  "description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -1 +0,0 @@
1
- {"version":3,"file":"cloudflare-BW-Iyi-B.mjs","names":[],"sources":["../src/onboarding/cloudflare.ts"],"sourcesContent":["/**\n * Cloudflare DNS — the one thing this command writes.\n *\n * Scoped to exactly what onboarding needs and nothing more: find a zone by name,\n * read the apex TXT records, and *create* the verification TXT when it is missing.\n * There is deliberately no update and no delete — the command's contract is that it\n * never removes a DNS record, so the destructive verbs are simply absent rather\n * than guarded.\n *\n * The token is a `Zone:DNS:Edit` credential from the environment\n * (`CLOUDFLARE_API_TOKEN`); listing a zone by name also needs `Zone:Read`, which\n * Cloudflare's standard \"Edit zone DNS\" token template already bundles. As with the\n * indexing executors, `fetchImpl` is injected so the request shape is testable\n * without the network, and every call returns a structured result rather than\n * throwing — the caller decides what a failure means per domain.\n */\nconst API = 'https://api.cloudflare.com/client/v4';\n\ninterface CfEnvelope<T> {\n success: boolean;\n errors?: { code: number; message: string }[];\n result?: T;\n}\n\nconst cfError = (status: number, body: CfEnvelope<unknown>): string => {\n const first = body.errors?.[0];\n return first ? `${first.code} ${first.message}` : `HTTP ${status}`;\n};\n\n/** Find the zone id for a domain (`GET /zones?name=`). */\nexport async function findZoneId(\n token: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; zoneId?: string; message?: string }> {\n const res = await fetchImpl(`${API}/zones?name=${encodeURIComponent(domain)}`, {\n headers: { authorization: `Bearer ${token}` },\n });\n const body = (await res.json()) as CfEnvelope<{ id: string; name: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n const zone = body.result?.[0];\n if (!zone)\n return {\n ok: false,\n status: res.status,\n message: `no Cloudflare zone named \"${domain}\" (is it in this account, and does the token cover it?)`,\n };\n return { ok: true, status: res.status, zoneId: zone.id };\n}\n\n/** List the apex TXT record contents for a zone. */\nexport async function listApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; contents: string[]; message?: string }> {\n const res = await fetchImpl(\n `${API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(domain)}`,\n { headers: { authorization: `Bearer ${token}` } },\n );\n const body = (await res.json()) as CfEnvelope<{ content: string }[]>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, contents: [], message: cfError(res.status, body) };\n // Cloudflare returns TXT content quoted; normalise so an exact compare works.\n const contents = (body.result ?? []).map((r) => r.content.replace(/^\"|\"$/g, ''));\n return { ok: true, status: res.status, contents };\n}\n\n/**\n * Create an apex TXT record. **Create only** — never called when one already\n * exists (the planner decides that), and there is no sibling that edits or removes.\n */\nexport async function createApexTxt(\n token: string,\n zoneId: string,\n domain: string,\n content: string,\n fetchImpl: typeof fetch = fetch,\n): Promise<{ ok: boolean; status: number; message?: string }> {\n const res = await fetchImpl(`${API}/zones/${zoneId}/dns_records`, {\n method: 'POST',\n headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },\n body: JSON.stringify({ type: 'TXT', name: domain, content }),\n });\n const body = (await res.json()) as CfEnvelope<{ id: string }>;\n if (!res.ok || !body.success)\n return { ok: false, status: res.status, message: cfError(res.status, body) };\n return { ok: true, status: res.status };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,MAAM,MAAM;AAQZ,MAAM,WAAW,QAAgB,SAAsC;CACrE,MAAM,QAAQ,KAAK,SAAS;CAC5B,OAAO,QAAQ,GAAG,MAAM,KAAK,GAAG,MAAM,YAAY,QAAQ;AAC5D;;AAGA,eAAsB,WACpB,OACA,QACA,YAA0B,OACmD;CAC7E,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,cAAc,mBAAmB,MAAM,KAAK,EAC7E,SAAS,EAAE,eAAe,UAAU,QAAQ,EAC9C,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,MAAM,OAAO,KAAK,SAAS;CAC3B,IAAI,CAAC,MACH,OAAO;EACL,IAAI;EACJ,QAAQ,IAAI;EACZ,SAAS,6BAA6B,OAAO;CAC/C;CACF,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ,QAAQ,KAAK;CAAG;AACzD;;AAGA,eAAsB,YACpB,OACA,QACA,QACA,YAA0B,OACsD;CAChF,MAAM,MAAM,MAAM,UAChB,GAAG,IAAI,SAAS,OAAO,6BAA6B,mBAAmB,MAAM,KAC7E,EAAE,SAAS,EAAE,eAAe,UAAU,QAAQ,EAAE,CAClD;CACA,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,UAAU,CAAC;EAAG,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAE3F,MAAM,YAAY,KAAK,UAAU,CAAC,EAAC,CAAE,KAAK,MAAM,EAAE,QAAQ,QAAQ,UAAU,EAAE,CAAC;CAC/E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;EAAQ;CAAS;AAClD;;;;;AAMA,eAAsB,cACpB,OACA,QACA,QACA,SACA,YAA0B,OACkC;CAC5D,MAAM,MAAM,MAAM,UAAU,GAAG,IAAI,SAAS,OAAO,eAAe;EAChE,QAAQ;EACR,SAAS;GAAE,eAAe,UAAU;GAAS,gBAAgB;EAAmB;EAChF,MAAM,KAAK,UAAU;GAAE,MAAM;GAAO,MAAM;GAAQ;EAAQ,CAAC;CAC7D,CAAC;CACD,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,IAAI,MAAM,CAAC,KAAK,SACnB,OAAO;EAAE,IAAI;EAAO,QAAQ,IAAI;EAAQ,SAAS,QAAQ,IAAI,QAAQ,IAAI;CAAE;CAC7E,OAAO;EAAE,IAAI;EAAM,QAAQ,IAAI;CAAO;AACxC"}