@getvitops/generator 8.1.0 → 8.2.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +797 -20
  2. package/assets/css/patterns/banner.css +20 -0
  3. package/assets/css/patterns/copy.css +5 -0
  4. package/assets/css/patterns/forms.css +5 -0
  5. package/assets/css/patterns/marquee.css +16 -0
  6. package/assets/css/patterns/splitter.css +11 -0
  7. package/assets/css/patterns/table.css +10 -0
  8. package/assets/js/consent.js +1 -1
  9. package/assets/js/deferred.js +1 -1
  10. package/assets/js/editor.js +2 -2
  11. package/assets/js/elements.js +5 -5
  12. package/assets/js/polyfills/adoptedStyleSheets.js +1 -1
  13. package/assets/js/polyfills/css-anchor-positioning-fn.js +4 -4
  14. package/assets/js/polyfills/global.esm.js +1 -1
  15. package/assets/js/polyfills/invokers-polyfill.js +1 -1
  16. package/assets/js/polyfills/popover.js +2 -2
  17. package/assets/js/polyfills/webcomponents-bundle.js +9 -9
  18. package/config.schema.json +464 -228
  19. package/dist/config-BfcWKH-5.mjs +1743 -0
  20. package/dist/config-BfcWKH-5.mjs.map +1 -0
  21. package/dist/config-Bhg3Xv3D.mjs +1774 -0
  22. package/dist/config-Bhg3Xv3D.mjs.map +1 -0
  23. package/dist/config.d.mts +2431 -0
  24. package/dist/config.d.mts.map +1 -0
  25. package/dist/config.mjs +3 -0
  26. package/dist/icons-sprite-CQ5Zj4go.d.mts +265 -0
  27. package/dist/icons-sprite-CQ5Zj4go.d.mts.map +1 -0
  28. package/dist/icons-sprite.d.mts +2 -0
  29. package/dist/icons-sprite.mjs +128 -0
  30. package/dist/icons-sprite.mjs.map +1 -0
  31. package/dist/index.d.mts +73 -2885
  32. package/dist/index.d.mts.map +1 -1
  33. package/dist/index.mjs +144 -2422
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/schema-BV2n2phj.d.mts +291 -0
  36. package/dist/schema-BV2n2phj.d.mts.map +1 -0
  37. package/dist/tokens-BzBbTeTi.d.mts +225 -0
  38. package/dist/tokens-BzBbTeTi.d.mts.map +1 -0
  39. package/dist/tokens-C1RJwFeG.mjs +695 -0
  40. package/dist/tokens-C1RJwFeG.mjs.map +1 -0
  41. package/dist/tokens.d.mts +2 -0
  42. package/dist/tokens.mjs +3 -0
  43. package/package.json +19 -6
  44. package/schema.json +12 -28
package/CHANGELOG.md CHANGED
@@ -1,5 +1,785 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 8.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 65a13cc: **New: `site.experiments`** — runtime, per-visitor A/B content experiments for a page that
8
+ must stay at one URL (unlike `site.abTesting`'s existing build-time variant merge, which is
9
+ for a full design-system/CSS-level test via two manual deploys — `site.experiments` is the
10
+ opposite granularity: one page, one cookie, content branched in the page itself). Keyed by
11
+ an open experiment name, so more than one can run at once, each with its own consent
12
+ category, cookie name/lifetime, split ratio and pair of variant names.
13
+
14
+ **New `vitopsAbTesting()` middleware** (`@getvitops/astro/middleware`) — the first Astro
15
+ middleware this toolchain ships. Wire it into your own `src/middleware.ts`:
16
+
17
+ ```ts
18
+ import { vitopsAbTesting } from '@getvitops/astro/middleware';
19
+ import config from '../site.json' with { type: 'json' };
20
+
21
+ export const onRequest = vitopsAbTesting({
22
+ experiments: config.site.experiments ?? {},
23
+ });
24
+ ```
25
+
26
+ It assigns and cookie-pins a visitor to one of an experiment's two variants on first visit,
27
+ by weighted random (`splitRatio`) — and never touches an already-set cookie, so a recognized
28
+ query parameter named after the cookie itself (`?_ab_<key>=<variant>`) reliably forces that
29
+ variant, even overriding an existing assignment. Combined with `splitRatio: 0`, this is a
30
+ blue/green-style "100% on control, but a shared link reveals the other variant" mode with no
31
+ special-cased logic.
32
+
33
+ A page reads its own experiment's cookie (`Astro.cookies.get(cookieName)?.value`) and
34
+ renders whichever of its two hand-written blocks matches — there's no config-schema
35
+ representation of "the variant content," which is what keeps this lightweight regardless of
36
+ how many experiments are configured.
37
+
38
+ **`<Analytics />` gained a reporting hook**: each configured provider's bootstrap now
39
+ attaches an enabled experiment's assignment to the real pageview via that provider's own
40
+ mechanism — Plausible via `plausible.init({customProperties})`, Matomo via
41
+ `setCustomDimension` (needs a new `site.analytics.matomo.abVariantDimensions` entry — the
42
+ dimension must exist in your Matomo instance first), GA4 via a runtime-merged
43
+ `gtag('config', …)` call. A no-op when `site.experiments` is unset — zero behavior change
44
+ for every existing site.
45
+
46
+ Also new: `experimentDimensionWarnings(site)` (`@getvitops/generator`) — a build-time warning
47
+ (not a hard error) when an enabled experiment's site also configures Matomo but has no
48
+ matching `abVariantDimensions` entry.
49
+
50
+ **Not included in this pass, deliberately**: a mechanism for a fully prerendered
51
+ (`output: 'static'`) page — this needs a genuinely separate Cloudflare Worker Route + two
52
+ pre-built static variants, real but bounded follow-up work. Everything above applies to an
53
+ on-demand-rendered page (the current EmDash templates' default), which needs zero new
54
+ deploy infrastructure.
55
+
56
+ - 67e2088: **Config validation now rejects unknown keys instead of silently stripping them.** Every
57
+ object in `ConfigSchema` (the `Config` type — `organization`/`site` and everything nested under
58
+ them) switched from `z.object` to `z.strictObject`, matching what the published JSON Schema has
59
+ always asserted (`additionalProperties: false` at ~95 points) but the runtime validator never
60
+ actually enforced. A typo'd field (`site.ntoifications`, an add-on key on the wrong nesting
61
+ level, …) now fails `validateConfig()`/`vitops validate` with an `unrecognized_keys` error naming
62
+ the bad key, instead of validating successfully and doing nothing. `site.icons` stays
63
+ deliberately loose (`z.looseObject`, unchanged) — its per-set keys are open-ended by design.
64
+
65
+ **Breaking for any config carrying a field that isn't in the schema** — this is the point of the
66
+ change, but if a real config was relying on the old silent-strip behavior (an extra key kept
67
+ around for another tool to read), move it under a block the schema already permits `z.unknown()`
68
+ on (`site.cloudflare`, `site.tags`, `site.postTypes`) or open an issue.
69
+
70
+ Also:
71
+
72
+ - Added `configVersion` (optional, currently `1`) to `Config` — the document's own SHAPE
73
+ version, distinct from this package's release version (which is CSS-driven and bumps on every
74
+ design-system change regardless of `Config`'s shape). `defaultSiteConfig()` stamps it.
75
+ Exported as `CONFIG_VERSION`.
76
+ - Re-exported several `Config`-related types/values that were defined but missing from the
77
+ package root: `SiteReviews`, `SiteReviewLocation`, `SiteSearchConsoleSetup`,
78
+ `CrmProviderConfig`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`.
79
+ - Added a `@getvitops/generator/config` subpath export — `ConfigSchema`, `Config`,
80
+ `validateConfig`, `resolveConfig`, `isConfig`, `resolveInput` and friends, on their own, with no
81
+ `node:fs`/`node:crypto`/lightningcss in the import graph (verified: `publint` clean, and the
82
+ built chunk's own imports are `@zod/mini` + `@getvitops/utils`'s Worker-safe `ads`/`tracking`/
83
+ `color` subpaths only). This is what makes `Config` bundleable in a Cloudflare Worker — the
84
+ EmDash plugin's own admin route being the motivating case — without pulling in code that only
85
+ ever runs at build time.
86
+
87
+ - 89265af: **New package: `@getvitops/config`** — the site-config provider blocks (`site.crm`, `site.ads`,
88
+ `site.reviews`, `site.seo.indexing`, `site.searchConsole`, `site.notifications`, `site.tracking`,
89
+ `site.security`, `site.deployment`) as zod schemas and inferred types, with the closed provider lists
90
+ (`AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`) on a zod-free `./providers` entry. It joins the
91
+ `fixed` group.
92
+
93
+ Why: `@getvitops/utils` holds the provider code but could not import its configuration types — the schema
94
+ lived in `@getvitops/generator`, a layer above — so it kept a hand-copied mirror beside each module,
95
+ an adapter converting into it, and a test holding the copy to the schema. The mirrors are gone.
96
+
97
+ - **`@getvitops/generator`**: `AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`, `AdProvider` and
98
+ `CrmProviderConfig` are re-exported unchanged. No import path changes.
99
+ - **`@getvitops/utils`**: the config types in `./crm`, `./ads`, `./indexing`, `./onboarding`, `./reviews`,
100
+ `./tracking` and `./notify` (`CrmConfig`, `HubSpotConfig`, `BiginConfig`, `JobberConfig`,
101
+ `IndexNowConfig`, `SearchConsoleConfig`, `ReviewsConfig`, `ReviewLocationConfig`, `TrackingConfig`,
102
+ `NotificationsConfig`, `EmailChannelConfig`, `DomainSetup`, `AdPropertySetup`) are now inferred from the
103
+ schema. Same names, same fields, but **optional fields now follow zod's inference**, so a hand-written
104
+ object literal typed against them may need `| undefined` handled. `AdPropertySetup` gains the fields the
105
+ schema always had (`conversionActionId`, `reporting`).
106
+ - **`@getvitops/astro`**: `GetvitopsAdProperty` and `GetvitopsTrackingOptions` are aliases of the schema
107
+ types. `toCrmConfig()` and `toGoogleAdsConfig()` are **deprecated** — they returned their argument's
108
+ field unchanged; pass `config.site.crm` / `config.site.ads?.google` directly.
109
+ - **`@getvitops/cli`**: `vitops ads` now passes `conversionActionId` and `reporting` through to the planner;
110
+ it previously copied a hand-picked subset of the block and dropped them.
111
+
112
+ Formats reached: none — no CSS change.
113
+
114
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
115
+
116
+ Client sites need form submissions to land in the client's CRM. The options available before this
117
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
118
+ white-labelled against this design system, nothing can operate on the submission before it leaves
119
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
120
+ was lost and no conversion notification was planned.
121
+
122
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
123
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
124
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
125
+ option, and are never config. A failed CRM write never fails the request and is folded into the
126
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
127
+
128
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
129
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
130
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
131
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
132
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
133
+ email, phone, company), not just a display name.
134
+
135
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
136
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
137
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
138
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
139
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
140
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
141
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
142
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
143
+ is set once its lookup query has been verified against a live account (defaults `false` pending
144
+ that verification — see `jobber.ts`'s docblock).
145
+
146
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
147
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
148
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
149
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
150
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
151
+ and Jobber have no such API, so both always print a manual reminder).
152
+
153
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
154
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
155
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
156
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
157
+ States as a processing location too, so the policy always asserts a cross-border transfer for
158
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
159
+
160
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
161
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
162
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
163
+
164
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
165
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
166
+ tailwind outputs are unaffected.
167
+
168
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
169
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
170
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
171
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
172
+ offered, now usable by the client directly, no dev server required. An edit is visible
173
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
174
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
175
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
176
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
177
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
178
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
179
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
180
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
181
+
182
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
183
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
184
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
185
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
186
+ plugin-install time.
187
+
188
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
189
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
190
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
191
+ script delivery). No change if you don't use `designSystemEditor`.
192
+
193
+ Also new, supporting the above:
194
+
195
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
196
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
197
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
198
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
199
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
200
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
201
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
202
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
203
+ `site.deployment.repo`/`branch` config fields.
204
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
205
+ wrapper over GitHub's Contents API.
206
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
207
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
208
+ route instead of the dev-only Vite endpoint.
209
+
210
+ - 0574388: Add `vitops email setup|check` — connect `site.notifications.email.from` to whether its domain is
211
+ actually onboarded onto an outbound email provider.
212
+
213
+ Before this, nothing checked that. `EMAIL.send()` throws `E_SENDER_NOT_VERIFIED` at runtime with no
214
+ build or deploy-time warning — the failure mode is silent and production-only.
215
+
216
+ `vitops email setup [domain]` onboards a sending domain (currently Cloudflare Email Sending only)
217
+ and polls DNS until its SPF/DKIM/DMARC records resolve or a timeout is reached; `vitops email
218
+ check` is the non-blocking deploy guard both `@getvitops/create` templates' `deploy`/`deploy:dev`
219
+ scripts now run first (`--strict` makes it a real gate; opt out per-site with
220
+ `site.notifications.email.verifySender: false`, or globally with `--skip` /
221
+ `VITOPS_SKIP_EMAIL_CHECK`).
222
+
223
+ Two things worth knowing before running it:
224
+
225
+ - **The onboarded entity is an exact name, and it is usually a subdomain**
226
+ (`send.yourdomain.com`), never the apex your `from` address names. When the exact domain isn't
227
+ onboarded but a sibling in the same zone already is, the command reports that as the fix — send
228
+ from the onboarded subdomain — rather than silently trying to onboard the apex, which rewrites
229
+ `_dmarc.<domain>` and can tighten DMARC policy on every other mail your domain sends.
230
+ - Candidates come from `site.notifications.email.from` (or its derived `noreply@<domains
231
+ .canonical>` default) first; a best-effort scan of `--src` only supplements it, and never guesses
232
+ at a computed `from` value.
233
+
234
+ New `@getvitops/utils/email` subpath and `site.notifications.email.verifySender` config field.
235
+ Needs `CLOUDFLARE_API_TOKEN` with an Email Sending scope in addition to the `Zone:Read` other
236
+ commands already use.
237
+
238
+ - 1a8231d: **EmDash blocks are now a tier of the component manifest, and there are four more of them.**
239
+
240
+ `@getvitops/emdash` adds `vitops.gallery`, `vitops.cards`, `vitops.accordion` and `vitops.marquee`
241
+ (each a thin adapter over the matching `@getvitops/astro` component), and `vitops.carousel` now edits
242
+ its slides as a **Slides repeater** instead of two newline-separated fields. Blocks saved before this
243
+ keep rendering: with no `slides`, the carousel still reads `images`/`alts`, and both legacy fields stay
244
+ in the editor, labelled as legacy. The editor's field type now admits `repeater` and `media_picker`.
245
+
246
+ - **New `blocks` plugin option** — `vitopsEmdash({ blocks: { include, exclude, extra } })`. `include` is
247
+ an allowlist, `exclude` drops blocks, `extra` adds the site's own **editor** definitions (the site
248
+ supplies their renderers via `<PortableText components={{ type }}>`, the same route that overrides a
249
+ `vitops.*` renderer). An unknown name or a colliding `type` throws at plugin construction.
250
+ - Link and image URLs in blocks with a scheme other than `http`, `https`, `mailto` or `tel` are
251
+ dropped (see the security entry).
252
+
253
+ `@getvitops/generator`: `TierEntry` gains an optional `emdash: string[]` (block `_type`s) and `Tier`
254
+ gains `'emdash'`, so `tierPatterns('emdash')` projects it. **Breaking only for code that exhaustively
255
+ switches on `Tier` or builds a `Record<Tier, …>`** — add an `emdash` case. The agent doc
256
+ `vitops docs components` gains an "EmDash blocks" table and the docsite a `components/emdash` page. The
257
+ guard that every block and every `emdash` entry agree lives in `@getvitops/emdash`'s `blocks.test.ts`,
258
+ because the generator cannot import the plugin.
259
+
260
+ `@getvitops/create`: the EmDash template's agent guidance explains the blocks, their limits (a block
261
+ cannot contain blocks; `vitops.*` blocks do not render inside EmDash's built-in `columns`) and the
262
+ `blocks` option.
263
+
264
+ Formats reached: none — no CSS change. Other packages in the `fixed` group: No consumer-facing change
265
+ in this package.
266
+
267
+ - 8d8aad7: **New: server-side Google Ads offline conversion upload.** Set
268
+ `site.ads.google.reporting: "server"` and a matching
269
+ `conversionActionId` (a numeric id or full
270
+ `customers/…/conversionActions/…` resource name — **not** the same value
271
+ as `conversionLabel`, which is the opaque label the web tag uses and
272
+ which the Ads API's upload endpoint doesn't accept) and
273
+ `createConversionRoute`'s new `googleAdsOffline` option uploads the
274
+ conversion via `ConversionUploadService.UploadClickConversions` instead
275
+ of relying on the visitor's browser to fire `gtag.js`. An action with no
276
+ matching `conversionActionId` keeps firing client-side regardless of
277
+ `reporting`, so turning it on before the id is filled in never silently
278
+ drops a conversion. `@getvitops/astro/routes` exports a matching
279
+ `toGoogleAdsConfig(config)` adapter, alongside `toCrmConfig`.
280
+
281
+ Structurally typed against Google's documented REST shape, not verified
282
+ against a live Ads account — no developer-token/MCC access exists
283
+ anywhere in this toolchain's own setup to test against. Wrapped so a
284
+ wrong guess degrades to a stated failure reason (folded into the
285
+ conversion email's "Delivery" section, same as a failed CRM write) rather
286
+ than breaking, the same caveat `@getvitops/utils/crm`'s Jobber executor
287
+ already carries for its own unverified surfaces.
288
+
289
+ `vitops ads lint` now flags a `reporting: "server"` property whose
290
+ configured actions have no matching `conversionActionId` — not a
291
+ failure, since that action keeps firing client-side, but worth surfacing
292
+ rather than leaving someone to wonder why nothing changed.
293
+
294
+ Not built: `vitops ads provision` (upserting conversion actions via
295
+ `ConversionActionService` and printing back both the label and the
296
+ resource id) — same credential blocker, and deliberately left unscoped
297
+ until there's a live account to verify the request/response shape
298
+ against.
299
+
300
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
301
+ `{ call?, email?, form? }` — instead of one bare string shared by every
302
+ interaction. A bare string still works (back-compat, treated as `call`), but a
303
+ call and an email click used to report as the **same** Google Ads conversion
304
+ action, indistinguishable in the account; each action now gets its own
305
+ `send_to` target.
306
+
307
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
308
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
309
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
310
+ `form` Google Ads conversion action client-side (mirroring the existing
311
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
312
+ one label). Idempotent per form element. There's deliberately no server-side
313
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
314
+ redirect with no script to fire from, and a real server-side Google Ads
315
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
316
+ access), tracked separately in the vNext backlog.
317
+
318
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
319
+ raw `event` field.** It used to key on whatever string a client sent
320
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
321
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
322
+ were ever recognized, and anything else got its own fresh bucket. The key now
323
+ uses the server's own classification (`call` | `email` | `form`), closing that
324
+ bypass without changing the existing call-vs-email-don't-compete behavior.
325
+
326
+ **New: `site.notifications.actionUrl`** — an optional action-link template
327
+ added to the conversion-notification email as an "Actions" section.
328
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
329
+ etc.); the section is omitted when the event has no click ID to interpolate,
330
+ rather than emailing a broken link.
331
+
332
+ - d9f213b: **New: `site.plan`** — records a commercial plan/add-on entitlement (`{ key, addons? }`),
333
+ distinct from a section's own `enabled` flag: `enabled` answers "does this site do X at all"
334
+ (a capability switch); `plan` answers "is this client entitled to turn X on" (a commercial
335
+ switch one layer above). Absence means unrestricted, matching every other opt-in block in
336
+ `Config`.
337
+
338
+ `PLAN_CAPABILITIES` (new export) is the small, hand-authored table of which add-on gates
339
+ which `Config` field — today: `ab_testing` gates `site.abTesting.enabled`, any
340
+ `environments.*.variant`, **and any enabled `site.experiments` entry** (that last one was
341
+ missed in this feature's first pass and fixed before release — found while planning this
342
+ toolchain's downstream adoption, not by a consumer hitting it), `server_side_tracking` gates
343
+ `site.tracking.enabled`. `validateConfig` now rejects a config that turns on a gated feature
344
+ without listing the owning add-on in `site.plan.addons`, naming the add-on in the error.
345
+
346
+ **Breaking**: an existing config using `site.abTesting.enabled`/`environments.*.variant` or
347
+ `site.tracking.enabled` now needs a matching `site.plan.addons` entry, or `validateConfig`
348
+ rejects it. Add `site.plan: { key: '<your-plan>', addons: ['ab_testing'] }` (and/or
349
+ `'server_side_tracking'`) to a config already using either feature.
350
+
351
+ - 7fd5f8f: **`site.seo` is now the single source of site-level SEO defaults** (ADR-0002). Until now `site.seo`
352
+ declared `titleTemplate`, verification tokens, `openGraph` and `twitter` that nothing read, while
353
+ `<Seo />` took a second, differently-shaped vocabulary through `vitops({ seo })` that no other stack
354
+ could see.
355
+
356
+ - `SeoSchema` moves to `@getvitops/config` and gains what only the integration had: `siteName`,
357
+ `defaultTitle`, `defaultDescription`, `openGraph.localeAlternates` and `openGraph.image` as
358
+ `{ src, alt, width, height, type }`. Text fields are localisable.
359
+ - `@getvitops/astro` exports `resolveSeoDefaults(config, { locale, env })`. The integration reads
360
+ `site.seo` from the site config it already loads; `siteName` falls back to `organization.name`, and
361
+ the environment's `robots` wins over `site.seo.robots`, the same fact `vitops robots` and
362
+ `search notify` read.
363
+ - `vitops({ seo })` keeps working unchanged and is now an **override** merged over the config, field by
364
+ field (`openGraph` and `twitter` merge key by key).
365
+ - **Removed from the config schema** (nothing read them): `descriptionTemplate` → use
366
+ `defaultDescription`; `openGraph.siteName`/`title`/`description` and
367
+ `twitter.title`/`description`/`image` → set per page on `<Seo />`, or the site-wide values above.
368
+ A config that still carries one now fails `vitops validate` with an unrecognised-key error.
369
+ - `openGraph.type` is now `website | article | profile`.
370
+
371
+ ### Patch Changes
372
+
373
+ - 779d22e: Add a `vitops.hero` EmDash block (eyebrow, heading, text, two links, optional image; centered or split layout) built on the `.centered`/`.split` layout primitives. The EmDash template's home page now seeds one of each of hero, cards, accordion and marquee. `@getvitops/generator`: the `split` pattern lists the new block in `TIERS`.
374
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
375
+ both projects' GA releases.
376
+
377
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
378
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
379
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
380
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
381
+ code here needed to change, only the declared range.
382
+
383
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
384
+ the version bump:
385
+
386
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
387
+ `css.format: 'tailwind'` without installing either, which threw on build.
388
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
389
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
390
+ the build never generated.
391
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
392
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
393
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
394
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
395
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
396
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
397
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
398
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
399
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
400
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
401
+ Uncomment it once scaffolded into its own repo.
402
+
403
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
404
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
405
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
406
+ now names the bad key instead of reporting a bare "Invalid input").
407
+
408
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
409
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
410
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
411
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
412
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
413
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
414
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
415
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
416
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
417
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
418
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
419
+ evolving default) and making the template a real pnpm workspace member (would change how its
420
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
421
+
422
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
423
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
424
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
425
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
426
+ compose with vite-plus's own `defineConfig`). A type-level shim
427
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
428
+ runtime `vp test` failure for this one file is still open.
429
+
430
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
431
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
432
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
433
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
434
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
435
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
436
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
437
+ switching `<Icon />` to import from there.
438
+ - Updated dependencies [89265af]
439
+ - Updated dependencies [0574388]
440
+ - Updated dependencies [c3c1bcb]
441
+ - Updated dependencies [0574388]
442
+ - Updated dependencies [0574388]
443
+ - Updated dependencies [8d8aad7]
444
+ - Updated dependencies [0574388]
445
+ - Updated dependencies [0574388]
446
+ - Updated dependencies [dde3df3]
447
+ - Updated dependencies [7f4f50e]
448
+ - Updated dependencies [7fd5f8f]
449
+ - Updated dependencies [ceb8310]
450
+ - @getvitops/config@8.2.0
451
+ - @getvitops/utils@8.2.0
452
+
453
+ ## 8.2.0-alpha.0
454
+
455
+ ### Minor Changes
456
+
457
+ - 65a13cc: **New: `site.experiments`** — runtime, per-visitor A/B content experiments for a page that
458
+ must stay at one URL (unlike `site.abTesting`'s existing build-time variant merge, which is
459
+ for a full design-system/CSS-level test via two manual deploys — `site.experiments` is the
460
+ opposite granularity: one page, one cookie, content branched in the page itself). Keyed by
461
+ an open experiment name, so more than one can run at once, each with its own consent
462
+ category, cookie name/lifetime, split ratio and pair of variant names.
463
+
464
+ **New `vitopsAbTesting()` middleware** (`@getvitops/astro/middleware`) — the first Astro
465
+ middleware this toolchain ships. Wire it into your own `src/middleware.ts`:
466
+
467
+ ```ts
468
+ import { vitopsAbTesting } from '@getvitops/astro/middleware';
469
+ import config from '../site.json' with { type: 'json' };
470
+
471
+ export const onRequest = vitopsAbTesting({
472
+ experiments: config.site.experiments ?? {},
473
+ });
474
+ ```
475
+
476
+ It assigns and cookie-pins a visitor to one of an experiment's two variants on first visit,
477
+ by weighted random (`splitRatio`) — and never touches an already-set cookie, so a recognized
478
+ query parameter named after the cookie itself (`?_ab_<key>=<variant>`) reliably forces that
479
+ variant, even overriding an existing assignment. Combined with `splitRatio: 0`, this is a
480
+ blue/green-style "100% on control, but a shared link reveals the other variant" mode with no
481
+ special-cased logic.
482
+
483
+ A page reads its own experiment's cookie (`Astro.cookies.get(cookieName)?.value`) and
484
+ renders whichever of its two hand-written blocks matches — there's no config-schema
485
+ representation of "the variant content," which is what keeps this lightweight regardless of
486
+ how many experiments are configured.
487
+
488
+ **`<Analytics />` gained a reporting hook**: each configured provider's bootstrap now
489
+ attaches an enabled experiment's assignment to the real pageview via that provider's own
490
+ mechanism — Plausible via `plausible.init({customProperties})`, Matomo via
491
+ `setCustomDimension` (needs a new `site.analytics.matomo.abVariantDimensions` entry — the
492
+ dimension must exist in your Matomo instance first), GA4 via a runtime-merged
493
+ `gtag('config', …)` call. A no-op when `site.experiments` is unset — zero behavior change
494
+ for every existing site.
495
+
496
+ Also new: `experimentDimensionWarnings(site)` (`@getvitops/generator`) — a build-time warning
497
+ (not a hard error) when an enabled experiment's site also configures Matomo but has no
498
+ matching `abVariantDimensions` entry.
499
+
500
+ **Not included in this pass, deliberately**: a mechanism for a fully prerendered
501
+ (`output: 'static'`) page — this needs a genuinely separate Cloudflare Worker Route + two
502
+ pre-built static variants, real but bounded follow-up work. Everything above applies to an
503
+ on-demand-rendered page (the current EmDash templates' default), which needs zero new
504
+ deploy infrastructure.
505
+
506
+ - 67e2088: **Config validation now rejects unknown keys instead of silently stripping them.** Every
507
+ object in `ConfigSchema` (the `Config` type — `organization`/`site` and everything nested under
508
+ them) switched from `z.object` to `z.strictObject`, matching what the published JSON Schema has
509
+ always asserted (`additionalProperties: false` at ~95 points) but the runtime validator never
510
+ actually enforced. A typo'd field (`site.ntoifications`, an add-on key on the wrong nesting
511
+ level, …) now fails `validateConfig()`/`vitops validate` with an `unrecognized_keys` error naming
512
+ the bad key, instead of validating successfully and doing nothing. `site.icons` stays
513
+ deliberately loose (`z.looseObject`, unchanged) — its per-set keys are open-ended by design.
514
+
515
+ **Breaking for any config carrying a field that isn't in the schema** — this is the point of the
516
+ change, but if a real config was relying on the old silent-strip behavior (an extra key kept
517
+ around for another tool to read), move it under a block the schema already permits `z.unknown()`
518
+ on (`site.cloudflare`, `site.tags`, `site.postTypes`) or open an issue.
519
+
520
+ Also:
521
+ - Added `configVersion` (optional, currently `1`) to `Config` — the document's own SHAPE
522
+ version, distinct from this package's release version (which is CSS-driven and bumps on every
523
+ design-system change regardless of `Config`'s shape). `defaultSiteConfig()` stamps it.
524
+ Exported as `CONFIG_VERSION`.
525
+ - Re-exported several `Config`-related types/values that were defined but missing from the
526
+ package root: `SiteReviews`, `SiteReviewLocation`, `SiteSearchConsoleSetup`,
527
+ `CrmProviderConfig`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`.
528
+ - Added a `@getvitops/generator/config` subpath export — `ConfigSchema`, `Config`,
529
+ `validateConfig`, `resolveConfig`, `isConfig`, `resolveInput` and friends, on their own, with no
530
+ `node:fs`/`node:crypto`/lightningcss in the import graph (verified: `publint` clean, and the
531
+ built chunk's own imports are `@zod/mini` + `@getvitops/utils`'s Worker-safe `ads`/`tracking`/
532
+ `color` subpaths only). This is what makes `Config` bundleable in a Cloudflare Worker — the
533
+ EmDash plugin's own admin route being the motivating case — without pulling in code that only
534
+ ever runs at build time.
535
+
536
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
537
+
538
+ Client sites need form submissions to land in the client's CRM. The options available before this
539
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
540
+ white-labelled against this design system, nothing can operate on the submission before it leaves
541
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
542
+ was lost and no conversion notification was planned.
543
+
544
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
545
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
546
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
547
+ option, and are never config. A failed CRM write never fails the request and is folded into the
548
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
549
+
550
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
551
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
552
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
553
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
554
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
555
+ email, phone, company), not just a display name.
556
+
557
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
558
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
559
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
560
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
561
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
562
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
563
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
564
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
565
+ is set once its lookup query has been verified against a live account (defaults `false` pending
566
+ that verification — see `jobber.ts`'s docblock).
567
+
568
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
569
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
570
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
571
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
572
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
573
+ and Jobber have no such API, so both always print a manual reminder).
574
+
575
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
576
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
577
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
578
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
579
+ States as a processing location too, so the policy always asserts a cross-border transfer for
580
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
581
+
582
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
583
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
584
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
585
+
586
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
587
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
588
+ tailwind outputs are unaffected.
589
+
590
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
591
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
592
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
593
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
594
+ offered, now usable by the client directly, no dev server required. An edit is visible
595
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
596
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
597
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
598
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
599
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
600
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
601
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
602
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
603
+
604
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
605
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
606
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
607
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
608
+ plugin-install time.
609
+
610
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
611
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
612
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
613
+ script delivery). No change if you don't use `designSystemEditor`.
614
+
615
+ Also new, supporting the above:
616
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
617
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
618
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
619
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
620
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
621
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
622
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
623
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
624
+ `site.deployment.repo`/`branch` config fields.
625
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
626
+ wrapper over GitHub's Contents API.
627
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
628
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
629
+ route instead of the dev-only Vite endpoint.
630
+
631
+ - 0574388: Add `vitops email setup|check` — connect `site.notifications.email.from` to whether its domain is
632
+ actually onboarded onto an outbound email provider.
633
+
634
+ Before this, nothing checked that. `EMAIL.send()` throws `E_SENDER_NOT_VERIFIED` at runtime with no
635
+ build or deploy-time warning — the failure mode is silent and production-only.
636
+
637
+ `vitops email setup [domain]` onboards a sending domain (currently Cloudflare Email Sending only)
638
+ and polls DNS until its SPF/DKIM/DMARC records resolve or a timeout is reached; `vitops email
639
+ check` is the non-blocking deploy guard both `@getvitops/create` templates' `deploy`/`deploy:dev`
640
+ scripts now run first (`--strict` makes it a real gate; opt out per-site with
641
+ `site.notifications.email.verifySender: false`, or globally with `--skip` /
642
+ `VITOPS_SKIP_EMAIL_CHECK`).
643
+
644
+ Two things worth knowing before running it:
645
+ - **The onboarded entity is an exact name, and it is usually a subdomain**
646
+ (`send.yourdomain.com`), never the apex your `from` address names. When the exact domain isn't
647
+ onboarded but a sibling in the same zone already is, the command reports that as the fix — send
648
+ from the onboarded subdomain — rather than silently trying to onboard the apex, which rewrites
649
+ `_dmarc.<domain>` and can tighten DMARC policy on every other mail your domain sends.
650
+ - Candidates come from `site.notifications.email.from` (or its derived `noreply@<domains
651
+ .canonical>` default) first; a best-effort scan of `--src` only supplements it, and never guesses
652
+ at a computed `from` value.
653
+
654
+ New `@getvitops/utils/email` subpath and `site.notifications.email.verifySender` config field.
655
+ Needs `CLOUDFLARE_API_TOKEN` with an Email Sending scope in addition to the `Zone:Read` other
656
+ commands already use.
657
+
658
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
659
+ `{ call?, email?, form? }` — instead of one bare string shared by every
660
+ interaction. A bare string still works (back-compat, treated as `call`), but a
661
+ call and an email click used to report as the **same** Google Ads conversion
662
+ action, indistinguishable in the account; each action now gets its own
663
+ `send_to` target.
664
+
665
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
666
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
667
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
668
+ `form` Google Ads conversion action client-side (mirroring the existing
669
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
670
+ one label). Idempotent per form element. There's deliberately no server-side
671
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
672
+ redirect with no script to fire from, and a real server-side Google Ads
673
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
674
+ access), tracked separately in the vNext backlog.
675
+
676
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
677
+ raw `event` field.** It used to key on whatever string a client sent
678
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
679
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
680
+ were ever recognized, and anything else got its own fresh bucket. The key now
681
+ uses the server's own classification (`call` | `email` | `form`), closing that
682
+ bypass without changing the existing call-vs-email-don't-compete behavior.
683
+
684
+ **New: `site.notifications.actionUrl`** — an optional action-link template
685
+ added to the conversion-notification email as an "Actions" section.
686
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
687
+ etc.); the section is omitted when the event has no click ID to interpolate,
688
+ rather than emailing a broken link.
689
+
690
+ - d9f213b: **New: `site.plan`** — records a commercial plan/add-on entitlement (`{ key, addons? }`),
691
+ distinct from a section's own `enabled` flag: `enabled` answers "does this site do X at all"
692
+ (a capability switch); `plan` answers "is this client entitled to turn X on" (a commercial
693
+ switch one layer above). Absence means unrestricted, matching every other opt-in block in
694
+ `Config`.
695
+
696
+ `PLAN_CAPABILITIES` (new export) is the small, hand-authored table of which add-on gates
697
+ which `Config` field — today: `ab_testing` gates `site.abTesting.enabled`, any
698
+ `environments.*.variant`, **and any enabled `site.experiments` entry** (that last one was
699
+ missed in this feature's first pass and fixed before release — found while planning this
700
+ toolchain's downstream adoption, not by a consumer hitting it), `server_side_tracking` gates
701
+ `site.tracking.enabled`. `validateConfig` now rejects a config that turns on a gated feature
702
+ without listing the owning add-on in `site.plan.addons`, naming the add-on in the error.
703
+
704
+ **Breaking**: an existing config using `site.abTesting.enabled`/`environments.*.variant` or
705
+ `site.tracking.enabled` now needs a matching `site.plan.addons` entry, or `validateConfig`
706
+ rejects it. Add `site.plan: { key: '<your-plan>', addons: ['ab_testing'] }` (and/or
707
+ `'server_side_tracking'`) to a config already using either feature.
708
+
709
+ ### Patch Changes
710
+
711
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
712
+ both projects' GA releases.
713
+
714
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
715
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
716
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
717
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
718
+ code here needed to change, only the declared range.
719
+
720
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
721
+ the version bump:
722
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
723
+ `css.format: 'tailwind'` without installing either, which threw on build.
724
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
725
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
726
+ the build never generated.
727
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
728
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
729
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
730
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
731
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
732
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
733
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
734
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
735
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
736
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
737
+ Uncomment it once scaffolded into its own repo.
738
+
739
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
740
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
741
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
742
+ now names the bad key instead of reporting a bare "Invalid input").
743
+
744
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
745
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
746
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
747
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
748
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
749
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
750
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
751
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
752
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
753
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
754
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
755
+ evolving default) and making the template a real pnpm workspace member (would change how its
756
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
757
+
758
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
759
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
760
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
761
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
762
+ compose with vite-plus's own `defineConfig`). A type-level shim
763
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
764
+ runtime `vp test` failure for this one file is still open.
765
+
766
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
767
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
768
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
769
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
770
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
771
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
772
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
773
+ switching `<Icon />` to import from there.
774
+ - Updated dependencies [0574388]
775
+ - Updated dependencies [c3c1bcb]
776
+ - Updated dependencies [0574388]
777
+ - Updated dependencies [0574388]
778
+ - Updated dependencies [0574388]
779
+ - Updated dependencies [0574388]
780
+ - Updated dependencies [dde3df3]
781
+ - @getvitops/utils@8.2.0-alpha.0
782
+
3
783
  ## 8.1.0
4
784
 
5
785
  ### Minor Changes
@@ -45,7 +825,6 @@
45
825
 
46
826
  **Breaking (`css`/`bricks` and `tailwind` formats — `.lightbox-dialog`'s markup
47
827
  contract changed):**
48
-
49
828
  - `<Gallery />` now emits ONE `<dialog class="lightbox-dialog">` per gallery,
50
829
  wrapping a `<Carousel />`, instead of one dialog per image. A `.gallery__trigger`
51
830
  is now a plain `<a href="#slide-id">`, not a `command="show-modal"` button.
@@ -86,7 +865,6 @@
86
865
  ### Minor Changes
87
866
 
88
867
  - Add Google Business Profile reviews: fetch, cache, render.
89
-
90
868
  - **`vitops reviews`** (new CLI command) fetches a client's GBP reviews (legacy v4 API — the only
91
869
  one that has them) and writes them to `.vitops/reviews.json`, meant to be committed so a build
92
870
  never needs a live Google credential. `--dry` prints the plan; `--check` diffs against the cache
@@ -121,7 +899,6 @@
121
899
  `--site` for now.)
122
900
 
123
901
  **Fixed:**
124
-
125
902
  - `@getvitops/core`'s 12 runtime deps (`lit`, both `@oddbird` polyfills, both
126
903
  `@webcomponents` packages, `colorjs.io`, etc.) were listed as `dependencies` despite
127
904
  being bundled into `dist/elements.js`/`dist/polyfills.js`/`dist/deferred.js` — a
@@ -296,7 +1073,7 @@
296
1073
  It is **required for a user credential and wrong for a service account**, which is why it is not
297
1074
  sent unconditionally. A user credential authenticates through a shared OAuth client that owns no
298
1075
  project, so Google refuses the call outright — measured: `403 "requires a quota project, which is
299
- not set by default"`. A service account already belongs to a project, and pointing it at a
1076
+ not set by default"`. A service account already belongs to a project, and pointing it at a
300
1077
  different one would newly require `serviceusage.services.use` there, turning a working CI
301
1078
  credential into a 403 for no benefit.
302
1079
 
@@ -321,7 +1098,7 @@ not set by default"`. A service account already belongs to a project, and pointi
321
1098
  **Fixed: an ADC file in `GOOGLE_APPLICATION_CREDENTIALS` no longer hard-exits.** That variable is
322
1099
  Google's own convention _and_ where gcloud writes ADC, so the same variable carries two kinds of
323
1100
  credential. Parsing an `authorized_user` file as a service account failed with `service account
324
- JSON is missing client_email or private_key` — while holding a credential the command already knew
1101
+ JSON is missing client_email or private_key` — while holding a credential the command already knew
325
1102
  how to use. It is now discriminated on `type`, the field Google's own libraries switch on.
326
1103
 
327
1104
  **Also fixed — `search setup --dry` and `--check` are not offline.** The docs implied otherwise. The
@@ -402,7 +1179,7 @@ JSON is missing client_email or private_key` — while holding a credential the
402
1179
  created, with `patterns/counter.css`'s own reduced-motion query as a second belt.
403
1180
  - The accessible name stays the final value for the whole animation: `.counter__value` is toggled
404
1181
  to the framework's `.sr-only` utility while running, never hidden — `display: none`/`visibility:
405
- hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
1182
+ hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
406
1183
  screen reader must never be read ~60 intermediate numbers. The animated text lives in a separate
407
1184
  `aria-hidden` presentation span it never reaches.
408
1185
  - `data-from` (default `0`), `data-duration` (ms, default `1200`), `data-easing` (`linear` |
@@ -1268,7 +2045,7 @@ hidden` would pull it out of the accessible-name computation along with the visu
1268
2045
  `animate-view` / `animate-scroll` / `animate-trigger` / `transition`, and every effect
1269
2046
  gained an `open-<fx>` state variant. Overlays now state _where they start_
1270
2047
  (`--translate-x-from`) instead of owning a keyframe: `class="drawer drawer--right
1271
- open-fade-in"` composes slide and fade. Applied at zero specificity with identity
2048
+ open-fade-in"` composes slide and fade. Applied at zero specificity with identity
1272
2049
  defaults, so a popover that sets no effect vars is unchanged.
1273
2050
  - **One scrim.** `--scrim` / `--scrim-filter` tokens and `.no-scrim` in `popover.css`,
1274
2051
  replacing 18 `::backdrop` blocks across seven partials. `.drawer--modeless` is aliased.
@@ -1339,7 +2116,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1339
2116
  Also new, and worth wiring at the same time — **`gitLastmod()` for real sitemap dates**:
1340
2117
 
1341
2118
  ```js
1342
- import vitops, { gitLastmod } from "@getvitops/astro";
2119
+ import vitops, { gitLastmod } from '@getvitops/astro';
1343
2120
  vitops({ sitemap: { serialize: await gitLastmod() } });
1344
2121
  ```
1345
2122
 
@@ -1524,8 +2301,8 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1524
2301
 
1525
2302
  ```js
1526
2303
  vitops({
1527
- analytics: { googleAnalytics: "G-XXXXXXXXXX", plausible: "acme.com" },
1528
- consent: { policyUrl: "/legal/cookies" },
2304
+ analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
2305
+ consent: { policyUrl: '/legal/cookies' },
1529
2306
  });
1530
2307
  ```
1531
2308
 
@@ -1583,8 +2360,8 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1583
2360
  replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
1584
2361
 
1585
2362
  **Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
1586
- exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
1587
- 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
2363
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
2364
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
1588
2365
  `from` state — as the element hits the top of the viewport, which is to say they faded out while
1589
2366
  still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
1590
2367
  midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
@@ -1597,7 +2374,7 @@ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than th
1597
2374
  so one knob tunes both. If your own config has an empty `slide` base, add the same line.
1598
2375
 
1599
2376
  **Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
1600
- inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
2377
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
1601
2378
  hittable area and could never receive the hover that would reveal it. The state variants now match
1602
2379
  the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
1603
2380
  driver (`:is(.is-active, [data-active]) > .animate-trigger`):
@@ -1665,7 +2442,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1665
2442
 
1666
2443
  ```js
1667
2444
  // astro.config.mjs
1668
- getvitops({ css: { input: "company.json", format: "css" } });
2445
+ getvitops({ css: { input: 'company.json', format: 'css' } });
1669
2446
  ```
1670
2447
 
1671
2448
  ```
@@ -1716,11 +2493,11 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1716
2493
  getvitops({
1717
2494
  fonts: [
1718
2495
  {
1719
- name: "League Spartan",
1720
- provider: "fontsource",
1721
- cssVariable: "--font-league-spartan",
1722
- weights: ["100 900"],
1723
- subsets: ["latin"],
2496
+ name: 'League Spartan',
2497
+ provider: 'fontsource',
2498
+ cssVariable: '--font-league-spartan',
2499
+ weights: ['100 900'],
2500
+ subsets: ['latin'],
1724
2501
  preload: true,
1725
2502
  },
1726
2503
  ],
@@ -2591,7 +3368,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2591
3368
  ```js
2592
3369
  // astro.config.mjs
2593
3370
  getvitops({
2594
- css: { input: "design-system.json", format: "css", out: "src/styles" },
3371
+ css: { input: 'design-system.json', format: 'css', out: 'src/styles' },
2595
3372
  editor: true,
2596
3373
  });
2597
3374
  ```