@getvitops/astro 8.2.0-alpha.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 (52) hide show
  1. package/CHANGELOG.md +485 -26
  2. package/dist/config-adapters-CbTywgEY.mjs +71 -0
  3. package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
  4. package/dist/exports/tracking.d.mts +1 -1
  5. package/dist/exports/tracking.mjs +1 -1
  6. package/dist/index.d.mts +33 -43
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +90 -18
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +0 -2
  11. package/dist/middleware.d.mts.map +1 -1
  12. package/dist/middleware.mjs +1 -2
  13. package/dist/routes/conversion.d.mts +71 -15
  14. package/dist/routes/conversion.d.mts.map +1 -1
  15. package/dist/routes/conversion.mjs +79 -68
  16. package/dist/routes/conversion.mjs.map +1 -1
  17. package/dist/{tracking-M2PqlMpw.mjs → tracking-Cb3A0BrC.mjs} +2 -3
  18. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  19. package/dist/{tracking-BNKKBqOe.d.mts → tracking-D2xOzjFD.d.mts} +5 -25
  20. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  21. package/package.json +7 -6
  22. package/src/ads.ts +15 -22
  23. package/src/components/CookieConsent.astro +2 -1
  24. package/src/components/ReviewCardBody.astro +5 -2
  25. package/src/schemas/Article.astro +2 -1
  26. package/src/schemas/BookActions.astro +2 -1
  27. package/src/schemas/Breadcrumb.astro +2 -1
  28. package/src/schemas/Carousel.astro +2 -1
  29. package/src/schemas/CourseList.astro +2 -1
  30. package/src/schemas/Dataset.astro +2 -1
  31. package/src/schemas/DiscussionForum.astro +2 -1
  32. package/src/schemas/EducationQA.astro +2 -1
  33. package/src/schemas/EmployerAggregateRating.astro +2 -1
  34. package/src/schemas/Event.astro +2 -1
  35. package/src/schemas/FAQ.astro +2 -1
  36. package/src/schemas/FactCheck.astro +2 -1
  37. package/src/schemas/ImageMetadata.astro +2 -1
  38. package/src/schemas/JobPosting.astro +2 -1
  39. package/src/schemas/LocalBusiness.astro +2 -1
  40. package/src/schemas/MathSolver.astro +2 -1
  41. package/src/schemas/MovieCarousel.astro +2 -1
  42. package/src/schemas/Organization.astro +2 -1
  43. package/src/schemas/Product.astro +2 -1
  44. package/src/schemas/ProfilePage.astro +2 -1
  45. package/src/schemas/QAPage.astro +2 -1
  46. package/src/schemas/Recipe.astro +2 -1
  47. package/src/schemas/Review.astro +2 -1
  48. package/src/schemas/SoftwareApp.astro +2 -1
  49. package/src/seo.ts +6 -5
  50. package/src/tracking.ts +5 -9
  51. package/dist/tracking-BNKKBqOe.d.mts.map +0 -1
  52. package/dist/tracking-M2PqlMpw.mjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,472 @@
1
1
  # @getvitops/astro
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
+ - 89265af: **New package: `@getvitops/config`** — the site-config provider blocks (`site.crm`, `site.ads`,
57
+ `site.reviews`, `site.seo.indexing`, `site.searchConsole`, `site.notifications`, `site.tracking`,
58
+ `site.security`, `site.deployment`) as zod schemas and inferred types, with the closed provider lists
59
+ (`AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`) on a zod-free `./providers` entry. It joins the
60
+ `fixed` group.
61
+
62
+ Why: `@getvitops/utils` holds the provider code but could not import its configuration types — the schema
63
+ lived in `@getvitops/generator`, a layer above — so it kept a hand-copied mirror beside each module,
64
+ an adapter converting into it, and a test holding the copy to the schema. The mirrors are gone.
65
+
66
+ - **`@getvitops/generator`**: `AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`, `AdProvider` and
67
+ `CrmProviderConfig` are re-exported unchanged. No import path changes.
68
+ - **`@getvitops/utils`**: the config types in `./crm`, `./ads`, `./indexing`, `./onboarding`, `./reviews`,
69
+ `./tracking` and `./notify` (`CrmConfig`, `HubSpotConfig`, `BiginConfig`, `JobberConfig`,
70
+ `IndexNowConfig`, `SearchConsoleConfig`, `ReviewsConfig`, `ReviewLocationConfig`, `TrackingConfig`,
71
+ `NotificationsConfig`, `EmailChannelConfig`, `DomainSetup`, `AdPropertySetup`) are now inferred from the
72
+ schema. Same names, same fields, but **optional fields now follow zod's inference**, so a hand-written
73
+ object literal typed against them may need `| undefined` handled. `AdPropertySetup` gains the fields the
74
+ schema always had (`conversionActionId`, `reporting`).
75
+ - **`@getvitops/astro`**: `GetvitopsAdProperty` and `GetvitopsTrackingOptions` are aliases of the schema
76
+ types. `toCrmConfig()` and `toGoogleAdsConfig()` are **deprecated** — they returned their argument's
77
+ field unchanged; pass `config.site.crm` / `config.site.ads?.google` directly.
78
+ - **`@getvitops/cli`**: `vitops ads` now passes `conversionActionId` and `reporting` through to the planner;
79
+ it previously copied a hand-picked subset of the block and dropped them.
80
+
81
+ Formats reached: none — no CSS change.
82
+
83
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
84
+
85
+ Client sites need form submissions to land in the client's CRM. The options available before this
86
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
87
+ white-labelled against this design system, nothing can operate on the submission before it leaves
88
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
89
+ was lost and no conversion notification was planned.
90
+
91
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
92
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
93
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
94
+ option, and are never config. A failed CRM write never fails the request and is folded into the
95
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
96
+
97
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
98
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
99
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
100
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
101
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
102
+ email, phone, company), not just a display name.
103
+
104
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
105
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
106
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
107
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
108
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
109
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
110
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
111
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
112
+ is set once its lookup query has been verified against a live account (defaults `false` pending
113
+ that verification — see `jobber.ts`'s docblock).
114
+
115
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
116
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
117
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
118
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
119
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
120
+ and Jobber have no such API, so both always print a manual reminder).
121
+
122
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
123
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
124
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
125
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
126
+ States as a processing location too, so the policy always asserts a cross-border transfer for
127
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
128
+
129
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
130
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
131
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
132
+
133
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
134
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
135
+ tailwind outputs are unaffected.
136
+
137
+ - 8d8aad7: **New: server-side Google Ads offline conversion upload.** Set
138
+ `site.ads.google.reporting: "server"` and a matching
139
+ `conversionActionId` (a numeric id or full
140
+ `customers/…/conversionActions/…` resource name — **not** the same value
141
+ as `conversionLabel`, which is the opaque label the web tag uses and
142
+ which the Ads API's upload endpoint doesn't accept) and
143
+ `createConversionRoute`'s new `googleAdsOffline` option uploads the
144
+ conversion via `ConversionUploadService.UploadClickConversions` instead
145
+ of relying on the visitor's browser to fire `gtag.js`. An action with no
146
+ matching `conversionActionId` keeps firing client-side regardless of
147
+ `reporting`, so turning it on before the id is filled in never silently
148
+ drops a conversion. `@getvitops/astro/routes` exports a matching
149
+ `toGoogleAdsConfig(config)` adapter, alongside `toCrmConfig`.
150
+
151
+ Structurally typed against Google's documented REST shape, not verified
152
+ against a live Ads account — no developer-token/MCC access exists
153
+ anywhere in this toolchain's own setup to test against. Wrapped so a
154
+ wrong guess degrades to a stated failure reason (folded into the
155
+ conversion email's "Delivery" section, same as a failed CRM write) rather
156
+ than breaking, the same caveat `@getvitops/utils/crm`'s Jobber executor
157
+ already carries for its own unverified surfaces.
158
+
159
+ `vitops ads lint` now flags a `reporting: "server"` property whose
160
+ configured actions have no matching `conversionActionId` — not a
161
+ failure, since that action keeps firing client-side, but worth surfacing
162
+ rather than leaving someone to wonder why nothing changed.
163
+
164
+ Not built: `vitops ads provision` (upserting conversion actions via
165
+ `ConversionActionService` and printing back both the label and the
166
+ resource id) — same credential blocker, and deliberately left unscoped
167
+ until there's a live account to verify the request/response shape
168
+ against.
169
+
170
+ - 0574388: Fire a Google Ads conversion on a tel:/mailto: click, and fix a rate-limit bug on the conversion
171
+ route that could silently drop one of the two.
172
+
173
+ `site.ads.google.conversionLabel` used to be accepted by the config schema and typed all the way
174
+ through to the rendered tag, but nothing actually read it — no `gtag('event', 'conversion', …)`
175
+ was ever emitted. When set, the click tracker `<Tracking />` inlines now fires the conversion at
176
+ the moment a visitor taps a `tel:` or `mailto:` link, using the `AW-…/label` target Google Ads
177
+ expects. A `conversionLabel` that can't safely land inside the inline bootstrap script (the same
178
+ check `pixelId`/`accountId` already get) is dropped with a warning rather than firing something
179
+ malformed — the ad tag itself still loads.
180
+
181
+ Also: `createConversionRoute()`'s rate limiter previously keyed on IP address alone, so a call
182
+ beacon and an email-click beacon from the same visitor, seconds apart, competed for one window —
183
+ the second could 429 and vanish with no error anywhere. It's now keyed on IP _and_ event type, so
184
+ distinct conversion types never starve each other; repeats of the same type are still throttled
185
+ exactly as before.
186
+
187
+ No config changes — a site with `site.ads.google.conversionLabel` already set picks this up
188
+ automatically on the next build.
189
+
190
+ - 2a0a559: The Astro integration now warns when `site.locales`/`site.defaultLocale` disagree with
191
+ `astro.config.mjs`'s own `i18n` block — naming which locale codes are missing from each side, and
192
+ when the two `defaultLocale`s differ. EmDash reads its locale list straight from `i18n`, while our
193
+ own SEO/legal code reads `site.locales`, so the two had no way to notice they'd drifted apart. A
194
+ warning, not a build error — like the existing ads/analytics/consent consistency checks, either
195
+ side can legitimately be configured without the other, so only a genuine disagreement is flagged.
196
+ - 0574388: Track `mailto:` link clicks as conversions, alongside the existing `tel:` link tracking.
197
+
198
+ Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
199
+ invisible to attribution — a visitor who emailed the business instead of calling generated no
200
+ conversion event at all. `<Tracking />`'s inlined script now listens for both, sending `{ event:
201
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
202
+ }`.
203
+
204
+ `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
205
+ `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
206
+ CRM checks (there are no form fields to validate or map), a 204 response, and a notification email
207
+ subject reading `[Email Click] Visitor emailing ...`.
208
+
209
+ No config changes and no new options — a site with `<Tracking />` already mounted picks this up
210
+ automatically the next time it rebuilds.
211
+
212
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
213
+ `{ call?, email?, form? }` — instead of one bare string shared by every
214
+ interaction. A bare string still works (back-compat, treated as `call`), but a
215
+ call and an email click used to report as the **same** Google Ads conversion
216
+ action, indistinguishable in the account; each action now gets its own
217
+ `send_to` target.
218
+
219
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
220
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
221
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
222
+ `form` Google Ads conversion action client-side (mirroring the existing
223
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
224
+ one label). Idempotent per form element. There's deliberately no server-side
225
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
226
+ redirect with no script to fire from, and a real server-side Google Ads
227
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
228
+ access), tracked separately in the vNext backlog.
229
+
230
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
231
+ raw `event` field.** It used to key on whatever string a client sent
232
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
233
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
234
+ were ever recognized, and anything else got its own fresh bucket. The key now
235
+ uses the server's own classification (`call` | `email` | `form`), closing that
236
+ bypass without changing the existing call-vs-email-don't-compete behavior.
237
+
238
+ **New: `site.notifications.actionUrl`** — an optional action-link template
239
+ added to the conversion-notification email as an "Actions" section.
240
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
241
+ etc.); the section is omitted when the event has no click ID to interpolate,
242
+ rather than emailing a broken link.
243
+
244
+ - e5bc937: **Security: JSON-LD can no longer break out of its `<script>` tag.** All 24 `schemas/*.astro`
245
+ components inlined `JSON.stringify(jsonLd)` as-is, so a review, FAQ answer or forum post containing
246
+ `</script><script>…` ran as script on the page. They now go through `jsonLdScript()`, which escapes
247
+ `<`, `>`, `&`, U+2028 and U+2029 (still valid JSON — consumers and search engines parse the same
248
+ document). Sites rendering any user-supplied text through a schema component should upgrade promptly.
249
+
250
+ **`createConversionRoute()` hardening** — behaviour changes:
251
+
252
+ - The body-size cap is now enforced on the stream itself, not only on `Content-Length`; a chunked
253
+ request with no header is refused with 413 once it passes `maxBodyBytes`.
254
+ - **Breaking for non-Cloudflare hosts:** `X-Forwarded-For` is no longer used as the rate-limit key by
255
+ default, since off Cloudflare a client can set it freely and mint a new bucket per request. Pass
256
+ `trustForwardedFor: true` when running behind a proxy that overwrites it. Without
257
+ `CF-Connecting-IP` or that opt-in there is no IP and rate limiting is skipped. No change on
258
+ Cloudflare Workers.
259
+
260
+ `@getvitops/emdash`: the link/button block (`vitops.actionLink`) drops hrefs with a scheme other
261
+ than `http`, `https`, `mailto` or `tel` (e.g. `javascript:`, `data:`) and renders nothing for them.
262
+
263
+ Formats reached: none — no CSS change. Other packages in the `fixed` group: No consumer-facing
264
+ change in this package.
265
+
266
+ - 7fd5f8f: **`site.seo` is now the single source of site-level SEO defaults** (ADR-0002). Until now `site.seo`
267
+ declared `titleTemplate`, verification tokens, `openGraph` and `twitter` that nothing read, while
268
+ `<Seo />` took a second, differently-shaped vocabulary through `vitops({ seo })` that no other stack
269
+ could see.
270
+
271
+ - `SeoSchema` moves to `@getvitops/config` and gains what only the integration had: `siteName`,
272
+ `defaultTitle`, `defaultDescription`, `openGraph.localeAlternates` and `openGraph.image` as
273
+ `{ src, alt, width, height, type }`. Text fields are localisable.
274
+ - `@getvitops/astro` exports `resolveSeoDefaults(config, { locale, env })`. The integration reads
275
+ `site.seo` from the site config it already loads; `siteName` falls back to `organization.name`, and
276
+ the environment's `robots` wins over `site.seo.robots`, the same fact `vitops robots` and
277
+ `search notify` read.
278
+ - `vitops({ seo })` keeps working unchanged and is now an **override** merged over the config, field by
279
+ field (`openGraph` and `twitter` merge key by key).
280
+ - **Removed from the config schema** (nothing read them): `descriptionTemplate` → use
281
+ `defaultDescription`; `openGraph.siteName`/`title`/`description` and
282
+ `twitter.title`/`description`/`image` → set per page on `<Seo />`, or the site-wide values above.
283
+ A config that still carries one now fails `vitops validate` with an unrecognised-key error.
284
+ - `openGraph.type` is now `website | article | profile`.
285
+
286
+ - 3bbb8c3: Add `toNotifyContext(config, locale?)` and `toCrmConfig(config)`, exported from
287
+ `@getvitops/astro/routes` beside `createConversionRoute()`. Builds the `context`/`crm.config`
288
+ options straight from a resolved `Config` instead of hand-writing a `NotifyContext` literal —
289
+ `conversion.ts`'s own docblock used to state this helper "does not exist."
290
+
291
+ `organization.name` is localizable; `toNotifyContext` resolves it for a given locale (default
292
+ `site.defaultLocale`), falling back to `legalName` when neither locale nor default resolves to
293
+ anything. `locationEmails` is ordered primary location first, then every other location with an
294
+ `email` set, in `organization.locations`' own key order.
295
+
296
+ ### Patch Changes
297
+
298
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
299
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
300
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
301
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
302
+ offered, now usable by the client directly, no dev server required. An edit is visible
303
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
304
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
305
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
306
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
307
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
308
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
309
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
310
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
311
+
312
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
313
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
314
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
315
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
316
+ plugin-install time.
317
+
318
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
319
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
320
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
321
+ script delivery). No change if you don't use `designSystemEditor`.
322
+
323
+ Also new, supporting the above:
324
+
325
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
326
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
327
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
328
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
329
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
330
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
331
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
332
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
333
+ `site.deployment.repo`/`branch` config fields.
334
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
335
+ wrapper over GitHub's Contents API.
336
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
337
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
338
+ route instead of the dev-only Vite endpoint.
339
+
340
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
341
+ both projects' GA releases.
342
+
343
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
344
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
345
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
346
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
347
+ code here needed to change, only the declared range.
348
+
349
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
350
+ the version bump:
351
+
352
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
353
+ `css.format: 'tailwind'` without installing either, which threw on build.
354
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
355
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
356
+ the build never generated.
357
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
358
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
359
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
360
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
361
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
362
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
363
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
364
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
365
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
366
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
367
+ Uncomment it once scaffolded into its own repo.
368
+
369
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
370
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
371
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
372
+ now names the bad key instead of reporting a bare "Invalid input").
373
+
374
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
375
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
376
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
377
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
378
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
379
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
380
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
381
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
382
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
383
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
384
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
385
+ evolving default) and making the template a real pnpm workspace member (would change how its
386
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
387
+
388
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
389
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
390
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
391
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
392
+ compose with vite-plus's own `defineConfig`). A type-level shim
393
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
394
+ runtime `vp test` failure for this one file is still open.
395
+
396
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
397
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
398
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
399
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
400
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
401
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
402
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
403
+ switching `<Icon />` to import from there.
404
+ - 0f8c018: **New: `resolveOrganizationName(config, locale?)`**, exported from `@getvitops/astro/routes`
405
+ alongside `toNotifyContext`/`toCrmConfig` — the same `organization.name` → `legalName` →
406
+ `undefined` fallback chain `toNotifyContext` already used for its `organizationName` field,
407
+ now usable standalone anywhere a build-time-only display name is needed (favicon generation,
408
+ a scaffold's `astro.config.mjs`, a `Layout.astro` fallback). `toNotifyContext` is unchanged —
409
+ it now delegates to this helper internally.
410
+
411
+ The emdash scaffold uses it to remove a three-way duplicated `'My EmDash Site'` string:
412
+ `astro.config.mjs`'s favicon `name` and `Layout.astro`'s title fallback both previously
413
+ hardcoded it independently of `site.json`'s `organization.name`. EmDash's own
414
+ `getSiteSettings().title` (set in the admin) still wins at render time when it's set — it's a
415
+ runtime-only fact that can't be read at build time at all — this only replaces the two
416
+ hardcoded _fallback_ strings.
417
+
418
+ - 7f4f50e: `@getvitops/utils/url` exports `safeHref()` (a Worker-safe scheme allowlist: http, https, mailto, tel and relative), shared by the EmDash blocks and the Astro components. It now also guards the EmDash image-compare block, the review avatar and the cookie-notice policy link. `nodesToHtml`/`toHtml` throw on invalid tag or attribute names and on `on*` event-handler attributes, and drop `href`/`src`-style values that fail `safeHref`. EmDash cards link text now names the card for screen readers.
419
+ - 2ef2d41: **`resolveOrganizationName()` accepts a plain `import site from './site.json'`.** It was typed
420
+ against the full `Config`, which a JSON import cannot satisfy — TypeScript widens its literals
421
+ (`colors.utilities` becomes `string[]`, not the schema's union) — so every scaffolded site's
422
+ `Layout.astro` failed `astro check`. It now takes `OrganizationNameSource`, the slice it actually
423
+ reads (`organization.name` / `legalName`, `site.defaultLocale`); a resolved `Config` still
424
+ satisfies it, so nothing that compiled before stops compiling.
425
+
426
+ **Both templates now typecheck and lint clean on a fresh scaffold**, found by the new `scaffold`
427
+ CI job (it installs each template against this checkout's packed packages):
428
+
429
+ - `site.json` declares `neutral` and `surface` as `{ "hue": "ink", "kind": "surface" }`. As bare
430
+ hue strings they were chromatic roles, which emit no bare `bg-neutral` and no
431
+ `text-neutral-muted`, so the template's own footer class resolved to nothing and `vitops lint
432
+ --strict` (run by the template's pre-commit hook) failed. **Existing sites are unaffected** —
433
+ this changes only what a new scaffold starts with; if your own `site.json` has the same two
434
+ lines and uses `text-neutral-*` or `bg-surface*`, change them the same way.
435
+ - The `emdash` template's `index.astro` and `[...slug].astro` cast `page.data.title`, which is
436
+ `unknown` until `emdash types` has run against a live site, so `astro check` passed only after
437
+ the first `pnpm dev`.
438
+
439
+ No consumer-facing change in `@getvitops/core`, `@getvitops/generator`, `@getvitops/utils`,
440
+ `@getvitops/cli` or `@getvitops/vite`.
441
+
442
+ - fdc3f7e: `<wc-marquee>` adds a "Pause animation" button beside the track (WCAG 2.2.2), and the marquee also pauses while focus is inside it (`data-paused` and `:focus-within`). Pattern CSS: all formats. `<wc-consent>` moves focus into the banner when a visitor opens it from a settings control and returns it on close; an unprompted banner still doesn't take focus. `<wc-image-compare>`'s before/after panes are `role="group"` so their labels are announced. The conversion route format-checks `phone`/`email` beacon payloads and imports the Worker-safe `@getvitops/utils/content` instead of the utils root barrel.
443
+ - Updated dependencies [65a13cc]
444
+ - Updated dependencies [67e2088]
445
+ - Updated dependencies [89265af]
446
+ - Updated dependencies [0574388]
447
+ - Updated dependencies [c3c1bcb]
448
+ - Updated dependencies [0574388]
449
+ - Updated dependencies [734ab60]
450
+ - Updated dependencies [1a8231d]
451
+ - Updated dependencies [779d22e]
452
+ - Updated dependencies [0574388]
453
+ - Updated dependencies [8d8aad7]
454
+ - Updated dependencies [0574388]
455
+ - Updated dependencies [1a3e811]
456
+ - Updated dependencies [0574388]
457
+ - Updated dependencies [dde3df3]
458
+ - Updated dependencies [d9f213b]
459
+ - Updated dependencies [7f4f50e]
460
+ - Updated dependencies [7fd5f8f]
461
+ - Updated dependencies [ceb8310]
462
+ - Updated dependencies [88b465a]
463
+ - Updated dependencies [fdc3f7e]
464
+ - @getvitops/generator@8.2.0
465
+ - @getvitops/config@8.2.0
466
+ - @getvitops/utils@8.2.0
467
+ - @getvitops/core@8.2.0
468
+ - @getvitops/vite@8.2.0
469
+
3
470
  ## 8.2.0-alpha.0
4
471
 
5
472
  ### Minor Changes
@@ -63,7 +530,7 @@
63
530
 
64
531
  This adds one server-side path for all three CRMs, hung off the conversion route that already
65
532
  exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
66
- timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
533
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
67
534
  option, and are never config. A failed CRM write never fails the request and is folded into the
68
535
  notification email's new "Delivery" section, so a broken CRM token is never silent.
69
536
 
@@ -138,8 +605,8 @@ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the e
138
605
  Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
139
606
  invisible to attribution — a visitor who emailed the business instead of calling generated no
140
607
  conversion event at all. `<Tracking />`'s inlined script now listens for both, sending `{ event:
141
- 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
142
- }`.
608
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
609
+ }`.
143
610
 
144
611
  `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
145
612
  `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
@@ -194,8 +661,8 @@ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the e
194
661
  ### Patch Changes
195
662
 
196
663
  - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
197
- designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
198
- './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
664
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
665
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
199
666
  `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
200
667
  offered, now usable by the client directly, no dev server required. An edit is visible
201
668
  site-wide immediately (a runtime `:root` override, served via a new plugin API route and
@@ -256,7 +723,7 @@ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import c
256
723
  needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
257
724
  which only a human can set — run `emdash migrate --status` against the real deployed database,
258
725
  review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
259
- <value>`. Deploys fail clearly on the migration-apply step until it's set.
726
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
260
727
  - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
261
728
  not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
262
729
  errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
@@ -598,7 +1065,7 @@ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import c
598
1065
  created, with `patterns/counter.css`'s own reduced-motion query as a second belt.
599
1066
  - The accessible name stays the final value for the whole animation: `.counter__value` is toggled
600
1067
  to the framework's `.sr-only` utility while running, never hidden — `display: none`/`visibility:
601
- hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
1068
+ hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
602
1069
  screen reader must never be read ~60 intermediate numbers. The animated text lives in a separate
603
1070
  `aria-hidden` presentation span it never reaches.
604
1071
  - `data-from` (default `0`), `data-duration` (ms, default `1200`), `data-easing` (`linear` |
@@ -1202,28 +1669,20 @@ hidden` would pull it out of the accessible-name computation along with the visu
1202
1669
  {
1203
1670
  "designSystem": {
1204
1671
  "themes": {
1205
- "default": {
1206
- /* … */
1207
- },
1672
+ "default": {/* … */},
1208
1673
  },
1209
1674
  },
1210
1675
  "organization": {
1211
1676
  "name": "Acme",
1212
1677
  "contact": "hq",
1213
1678
  "locations": {
1214
- "hq": {
1215
- /* … */
1216
- },
1679
+ "hq": {/* … */},
1217
1680
  },
1218
1681
  },
1219
1682
  "site": {
1220
1683
  "defaultLocale": "en",
1221
- "domains": {
1222
- /* … */
1223
- },
1224
- "analytics": {
1225
- /* … */
1226
- },
1684
+ "domains": {/* … */},
1685
+ "analytics": {/* … */},
1227
1686
  },
1228
1687
  }
1229
1688
  ```
@@ -1317,7 +1776,7 @@ hidden` would pull it out of the accessible-name computation along with the visu
1317
1776
  `animate-view` / `animate-scroll` / `animate-trigger` / `transition`, and every effect
1318
1777
  gained an `open-<fx>` state variant. Overlays now state _where they start_
1319
1778
  (`--translate-x-from`) instead of owning a keyframe: `class="drawer drawer--right
1320
- open-fade-in"` composes slide and fade. Applied at zero specificity with identity
1779
+ open-fade-in"` composes slide and fade. Applied at zero specificity with identity
1321
1780
  defaults, so a popover that sets no effect vars is unchanged.
1322
1781
  - **One scrim.** `--scrim` / `--scrim-filter` tokens and `.no-scrim` in `popover.css`,
1323
1782
  replacing 18 `::backdrop` blocks across seven partials. `.drawer--modeless` is aliased.
@@ -1458,7 +1917,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1458
1917
  toolchain bump. Use `--force` when you mean to re-encode.
1459
1918
 
1460
1919
  **`ffmpeg` is an external tool, not an npm dependency** — install it yourself (`brew install
1461
- ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails without it rather
1920
+ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails without it rather
1462
1921
  than skipping: a page referencing a video that was never encoded is broken, not degraded.
1463
1922
 
1464
1923
  Two defaults worth knowing about. The MP4 exists because older iOS and the in-app webviews inside
@@ -1551,8 +2010,8 @@ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails
1551
2010
  replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
1552
2011
 
1553
2012
  **Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
1554
- exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
1555
- 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
2013
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
2014
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
1556
2015
  `from` state — as the element hits the top of the viewport, which is to say they faded out while
1557
2016
  still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
1558
2017
  midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
@@ -1565,7 +2024,7 @@ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than th
1565
2024
  so one knob tunes both. If your own config has an empty `slide` base, add the same line.
1566
2025
 
1567
2026
  **Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
1568
- inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
2027
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
1569
2028
  hittable area and could never receive the hover that would reveal it. The state variants now match
1570
2029
  the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
1571
2030
  driver (`:is(.is-active, [data-active]) > .animate-trigger`):
@@ -1916,7 +2375,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1916
2375
  - bf453b0: Fixed: a site on `css.format: 'css'` failed to build because of Tailwind.
1917
2376
 
1918
2377
  `getvitops()` loads `@tailwindcss/vite` lazily, so a project that never sets `css.format:
1919
- 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
2378
+ 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
1920
2379
  analysable — so the bundler **followed** the import regardless of the branch ever running, down
1921
2380
  through `@tailwindcss/node` to `@tailwindcss/oxide`'s native `.node` binding, which it cannot parse.
1922
2381
  The build died on `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`, naming a package
@@ -2403,7 +2862,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2403
2862
  - `.chip-list__chip` and `.chip-list__chip-remove` are **removed**. A tag list is a list of tags, so
2404
2863
  its items are now the existing `.tag` / `.tag__remove` — which also means they pick up tag role
2405
2864
  variants. **Migration:** replace `<span class="chip-list__chip">x <button
2406
- class="chip-list__chip-remove">` with `<span class="tag">x <button class="tag__remove">`. Note the
2865
+ class="chip-list__chip-remove">` with `<span class="tag">x <button class="tag__remove">`. Note the
2407
2866
  items change appearance: `.tag` is outlined (border + neutral text) where the old chip was filled
2408
2867
  with `--color-surface-muted`.
2409
2868
  - The `radii.chip` primitive is **removed** from the example config; it was only ever an alias of