@getvitops/astro 8.2.0-alpha.0 → 8.3.0-alpha.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 (61) hide show
  1. package/CHANGELOG.md +584 -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 +83 -43
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +351 -60
  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 +18 -6
  22. package/src/ads.ts +15 -22
  23. package/src/components/CookieConsent.astro +2 -1
  24. package/src/components/Entries.astro +58 -0
  25. package/src/components/Head.astro +6 -37
  26. package/src/components/ReviewCardBody.astro +16 -2
  27. package/src/components/SiteFooter.astro +79 -0
  28. package/src/components/SiteNav.astro +163 -0
  29. package/src/components/SiteNavList.astro +73 -0
  30. package/src/components/sitenav.ts +260 -0
  31. package/src/inline-scripts.ts +53 -0
  32. package/src/json-ld.ts +19 -0
  33. package/src/schemas/Article.astro +2 -1
  34. package/src/schemas/BookActions.astro +2 -1
  35. package/src/schemas/Breadcrumb.astro +2 -1
  36. package/src/schemas/Carousel.astro +2 -1
  37. package/src/schemas/CourseList.astro +2 -1
  38. package/src/schemas/Dataset.astro +2 -1
  39. package/src/schemas/DiscussionForum.astro +2 -1
  40. package/src/schemas/EducationQA.astro +2 -1
  41. package/src/schemas/EmployerAggregateRating.astro +2 -1
  42. package/src/schemas/Event.astro +2 -1
  43. package/src/schemas/FAQ.astro +2 -1
  44. package/src/schemas/FactCheck.astro +2 -1
  45. package/src/schemas/ImageMetadata.astro +2 -1
  46. package/src/schemas/JobPosting.astro +2 -1
  47. package/src/schemas/LocalBusiness.astro +2 -1
  48. package/src/schemas/MathSolver.astro +2 -1
  49. package/src/schemas/MovieCarousel.astro +2 -1
  50. package/src/schemas/Organization.astro +2 -1
  51. package/src/schemas/Product.astro +2 -1
  52. package/src/schemas/ProfilePage.astro +2 -1
  53. package/src/schemas/QAPage.astro +2 -1
  54. package/src/schemas/Recipe.astro +2 -1
  55. package/src/schemas/Review.astro +2 -1
  56. package/src/schemas/SoftwareApp.astro +2 -1
  57. package/src/seo.ts +6 -5
  58. package/src/tracking.ts +5 -9
  59. package/virtual.d.ts +17 -0
  60. package/dist/tracking-BNKKBqOe.d.mts.map +0 -1
  61. package/dist/tracking-M2PqlMpw.mjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,571 @@
1
1
  # @getvitops/astro
2
2
 
3
+ ## 8.3.0-alpha.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 93d2067: New framework patterns and an `Entries` component for the EmDash block library.
8
+
9
+ **CSS, all formats** (css, bricks and inlined into tailwind's `components` layer; no existing class changes): `tone` (a shared tint/solid colour convention driven by `--tone-*` custom properties, plus `.accent` for a run inside a heading), `section` (head and heading-left `--split` layout), `steps` (numbered `<ol>` with a connector line), `stats` (figures and `--tiles`), `callout` (a call-to-action band), `logos` (a still partner grid) and `embed` (a consent-gated iframe frame). All are structural partials, classified `vitops.components`.
10
+
11
+ **`@getvitops/astro`:** new `Entries.astro` (`@getvitops/astro/components/Entries.astro`) emits `<wc-entries>` itself with the semantic heading + `<dl>` fallback.
12
+
13
+ **`@getvitops/generator`:** `TierEntry` gains `emdashExempt` (a reason a shipped web component has no EmDash block). `tiers.test.ts` now fails if a shipped `wc-*` has neither `emdash` nor `emdashExempt`, and the components doc and the docs site list the exemptions.
14
+
15
+ No other consumer-facing change in `@getvitops/core`, `@getvitops/utils`, `@getvitops/cli` or `@getvitops/vite` beyond the version bump.
16
+
17
+ - 161c7f3: A contact form that works end to end: `vitops setup turnstile`, `<wc-turnstile>`, the `vitops.form` EmDash block, and the route the emdash template ships to receive it.
18
+
19
+ **CSS, all formats** (no existing class changes): `forms.css` gains `.form-notice--target:not(:target)` (a message revealed only when the URL fragment names it, which is how a prerendered page shows a no-JS form's result) and `:where(wc-turnstile) { display: block }`.
20
+
21
+ **`@getvitops/core`:** new `<wc-turnstile sitekey="…">` in `elements.js`. It loads Cloudflare Turnstile on the first interaction with its form (no request to `challenges.cloudflare.com` for a visitor who only reads the page), holds an early submit until the token arrives, and leaves its slotted no-JS explanation in place until JS takes over. Turnstile is category `necessary`, so it is not behind the consent banner.
22
+
23
+ **`@getvitops/utils` / `@getvitops/cli`:** new `@getvitops/utils/turnstile` and `vitops setup turnstile [--dry] [--check] [--no-write] [--no-secret]`. It plans from the site's hostnames, creates a managed widget unless one already covers them (an existing widget is adopted, never edited, rotated or deleted), writes the public key to `site.security.turnstile.siteKey`, and pipes the one-time secret to `wrangler secret put VITOPS_TURNSTILE_SECRET` without writing it to any file. Needs `CLOUDFLARE_API_TOKEN` with **Account > Turnstile > Edit** and `CLOUDFLARE_ACCOUNT_ID`.
24
+
25
+ **`@getvitops/astro`:** `virtual:getvitops/head` gains `turnstileSiteKey` (read from `site.security.turnstile.siteKey`) so blocks can render the widget. No other change.
26
+
27
+ **`@getvitops/generator`:** `TIERS` gains a `turnstile` entry (wc-turnstile, claimed by `vitops.form`); `wc-multi-field`'s `emdashExempt` reason is reworded now the form block exists.
28
+
29
+ **`@getvitops/emdash`:** new `vitops.form` block: a field repeater (text, email, phone, long text, choose one, tick box), required flags, help text, success/error messages, optional Turnstile. It renders a semantic `<form method="post">` that works with no JavaScript, with a honeypot named per `createConversionRoute`'s defaults. A field named `phone` submits as `tel`, because the route reserves `phone` and `event`.
30
+
31
+ **`@getvitops/create`:** the emdash template ships `src/pages/api/track.ts` (`prerender = false`, `createConversionRoute()`), a seeded contact form, and `challenges.cloudflare.com` in both templates' Report-Only CSP (`script-src`, `frame-src`). The route refuses a post with nowhere to deliver and fails closed when a site key exists without `VITOPS_TURNSTILE_SECRET`. SETUP.html gains the Turnstile step. Existing sites: copy the route and the CSP change, then run `vitops setup turnstile`.
32
+
33
+ - 175d16e: **Added: `vitops.reviews` and `vitops.nav` EmDash blocks.** Non-breaking.
34
+
35
+ - **`vitops.reviews`** renders Google reviews from the committed `.vitops/reviews.json` cache (`vitops reviews`) as a grid, carousel or marquee of `<ReviewCard>`. Fields: location key, layout, columns, limit, lowest rating, order, featured ids, reviewer photos (off by default), tone/colour. Which reviews show is `selectReviews()`, the one shared decision. Review JSON-LD is not offered (Google treats self-marked-up reviews as ineligible for rich results). A new review reaches the page after `vitops reviews` is re-run, committed and the site rebuilt, not when an editor saves; with no cache file the block renders nothing (a note in `astro dev`).
36
+ - **`vitops.nav`** renders a named EmDash menu inside page content (site map, section links, footer-style columns), never the header. Fields: menu (picked from the site's menus via a new `menus/list` plugin route, with `footer-sitemap`/`footer-legal`/`primary` as the static fallback), accessible name, visible heading and level, layout `list | inline | columns | sitenav`, depth 1-3, tone/colour. It is a `<nav>` with a name that is never `Primary`, `Site map` or `Legal` (the layout's landmarks), `aria-current="page"`, no runtime JS; a missing or empty menu renders nothing.
37
+ - `@getvitops/astro`: **`virtual:getvitops/reviews`**, served by `vitops()`, is `.vitops/reviews.json` as a build-time import (an empty cache when absent or unreadable), because a Worker cannot read the file. `<ReviewCard>` now renders the review's source link (`.review__source`, `rel="nofollow noopener noreferrer"`), which the pattern already styled. **Fix:** `src/json-ld.ts` was missing from the published `files`, so any consumer importing `<ReviewCard>` (which composes `schemas/Review.astro`) failed to build with an unresolved import. `@getvitops/emdash` now needs this version of `@getvitops/astro` for the `vitops.reviews` block.
38
+ - `@getvitops/utils`: new Worker-safe subpath **`@getvitops/utils/reviews/select`** (`selectReviews` and the review types, no `node:fs`). `selectReviews` now takes only the display fields (`featured`, `minRating`, `limit`, `sort`), so a caller with no configured `locationId` can use it; existing calls are unchanged.
39
+ - `@getvitops/core`: new `.navlist` pattern in `patterns/sitenav.css` (`.navlist`, `__item`, `__link`, `__label`, `__sub`, `--inline`, `--columns`). **All formats** (css, bricks, tailwind). No existing class changes.
40
+ - `@getvitops/generator`: `TIERS.review` lists `vitops.reviews` and `TIERS.sitenav` lists `vitops.nav` (and `.navlist`), so `vitops docs components` and the docsite show them.
41
+
42
+ - 06d6112: Fix the EmDash `/design-system` admin page erroring on a tailwind site. It fetches `/vitops/design-manifest.json`, which the `tailwind` format never emitted and the integration only mirrored under `editor: true` (which also loads `editor.js` on every public page).
43
+
44
+ - **generator** (`tailwind` format only): `design-manifest.json` is now emitted alongside `tailwind.css`. No change to the CSS.
45
+ - **astro**: new `vitops({ editorManifest: true })` writes everything an outside host needs into `public/vitops/` (`design-manifest.json`, `editor.js`, a standalone `editor.css`, and `design-system-editor.html`) without loading the editor on public pages. `editor: true` still loads it from `<Head />`.
46
+ - **vite**: `editorManifestDir` also writes `vitops/editor.css`, from a throwaway `css`-format pass on tailwind sites.
47
+ - **emdash**: the `/design-system` admin page was unstyled and overflowed the admin, because the editor borrows the framework CSS and the admin never loads it. It now embeds `/vitops/design-system-editor.html` in an iframe, so the design system can't leak into the admin's styles. **Requires `editorManifest: true`** in `vitops({ … })`.
48
+ - **create**: the EmDash template sets `editorManifest: true`. Existing sites: add it to `vitops({ … })` in `astro.config.mjs`.
49
+
50
+ No consumer-facing change in the other toolchain packages.
51
+
52
+ - 224a731: **`@getvitops/astro` (no CSS change; all formats unaffected)**
53
+
54
+ - **`site.fonts` is no longer silently ignored.** `vitops()` now turns webfonts on by default when the site config (`site` option, or a `css.input` that is a site config) declares `site.fonts`, and logs that it did. Before, no `@font-face`, preload or warning was emitted unless you also passed `fonts: true`. Pass `fonts: false` to opt out. Migration: none — sites that already set `fonts` behave the same; sites that declared `site.fonts` without it will start loading those families.
55
+ - **The icon sprite is served when the site config asks for it.** `site.icons.sprite: true` made the generator write `icons.svg`, but only the `icons` integration option copied it to `public/vitops/`, so `/vitops/icons.svg` was never served. It is now mirrored whenever the site config sets it.
56
+ - **Strict CSP is achievable.** New build-time exports `inlineScriptHashes()`, `cspScriptHash()` and `contentSecurityPolicy()` give the SHA-256 hashes of the two inline scripts `<Head />` emits (colour-scheme bootstrap, consent-gate stub) and a starting policy built on them, with no `'unsafe-inline'` in `script-src`. The scripts moved from `Head.astro`'s frontmatter to `src/inline-scripts.ts` (shipped in the package) so the hash and the emitted bytes are the same constant; the rendered output is byte-identical.
57
+
58
+ **`@getvitops/create` (templates; existing scaffolded sites are not changed)**
59
+
60
+ - **emdash: every public page is prerendered.** `index.astro`, `[...slug].astro` (via `getStaticPaths` over the published `pages`) and `404.astro` set `prerender = true`. Unknown URLs now return the prerendered 404 with a real 404 status — the old `Astro.rewrite('/404')` from an on-demand route to a prerendered one was refused by Astro, so every unknown URL was a 500. `scripts/pull-content.mjs` (first step of `deploy`/`deploy:dev`) loads production's D1 into the local build database, because the Cloudflare adapter prerenders against a local D1 that is empty on CI; it fails closed. The deploy-prod workflow also runs on a `repository_dispatch` of type `emdash-publish`. Migration for an existing site: copy those pages, the script and the two `package.json` scripts, and add the trigger; previews of unpublished drafts no longer work on the public routes (see AGENTS.md).
61
+ - **emdash: seed menus are `primary` (Contact), `footer-sitemap` (Home, Contact) and `footer-legal` (Privacy Policy, Terms & Conditions, Cookie Notice)**, replacing `primary` (Home, About) and `footer` (Terms, Privacy), with placeholder `contact` and `cookies` pages so every seeded link resolves. The template Layout still reads the old menu names until it is updated; the seed only applies to an empty database, so existing sites keep their menus.
62
+ - **Both templates: `badge` and `tag` patterns (group `label`) are enabled, `button` carries `class: "btn"` so `<a class="btn">` is styled, and `neutral`/`surface` are declared as surface roles** (they were chromatic, which emitted no `.bg-neutral`/`text-neutral-muted`). css/bricks/tailwind output of a scaffolded site changes accordingly.
63
+ - **Both templates ship `public/_headers` with a `Content-Security-Policy-Report-Only` default** naming `<Head />`'s inline scripts by hash. It blocks nothing. `_headers` applies to static assets only, not Worker-rendered responses; see AGENTS.md for the EmDash admin and HTML-block trade-off.
64
+ - **Guidance is Vite+-first**: `AGENTS.md`, README, the `setup` skill, `SETUP.html` and the session hook use `vp`/`vpr`/`vp exec` instead of `pnpm`/`npx`, with one command table; `vpr dev` is documented as returning immediately. There is no `vp change`/`vp pm change` (checked against vp 1.5.0); the release flow is `dev` → promote.
65
+ - emdash ships `.agents/skills/custom-blocks/SKILL.md` (compose, extend, site-local, upstream; naming and lifecycle rules) and the favicon `themeColor`/`name` now derive from `site.json`.
66
+ - Label vocabulary: `badge` is a static label, `tag` is static or dynamic; `chip`/`pill` are not used.
67
+
68
+ **`@getvitops/cli`**: the bundled `vitops-design-system` skill points at `vp exec vitops docs <topic>`.
69
+
70
+ No consumer-facing change in `@getvitops/core`, `@getvitops/generator`, `@getvitops/utils` or `@getvitops/vite`.
71
+
72
+ - 55061e2: **Added: `<SiteNav>` and `<SiteFooter>` for Astro/EmDash sites.** Both are tier-1 markup over the existing `sitenav` pattern (no runtime JS: `<details>` accordions + Popover API drawer). Non-breaking.
73
+
74
+ - `<SiteNav {...await loadNavMenus(getMenu)} />` renders EmDash menu `primary` (the default: depth 3, dropdowns from the `md` container width, drawer below). If a `primary-mobile` menu exists it replaces the nav below `md` (depth 1); if `primary-mega` exists it replaces it from `xl` (mega panels). Adding the menu in the admin is the whole change. Only one tier is in the accessibility tree at a time. An item's `titleAttr` is its description (EmDash has no description field yet).
75
+ - `<SiteFooter sitemap legal name>` renders `footer-sitemap` + `footer-legal` + a copyright line.
76
+ - `@getvitops/astro/components/sitenav.ts` exports the pure helpers (`loadNavMenus`, `selectTiers`, ...).
77
+ - CSS (`patterns/sitenav.css`, **all formats**): tier visibility classes (`.sitenav-tier--below-*`/`--from-*`), `.sitenav--mega`, `.sitenav__desc`, `.sitefooter*`, `aria-current` styling, reduced-motion and forced-colors handling. Existing `.sitenav` rules are unchanged, so the Bricks element is unaffected.
78
+ - `@getvitops/create` (emdash template): the layout now renders header and footer through these components, with the colour-scheme toggle in the header. It reads menus `primary`, `footer-sitemap` and `footer-legal`; a template whose seed still has the old `footer` menu renders no footer links until the seed is updated.
79
+ - `@getvitops/generator`: `TIERS.sitenav` now lists the two Astro components.
80
+
81
+ ### Patch Changes
82
+
83
+ - efd4e72: Fix `astro dev` answering every route with `module is not defined` on the Cloudflare adapter. `<Icon>` imports `@getvitops/generator/icons-sprite`, which imports `@iconify/utils`; workerd's dev runner cannot load that un-prebundled, and Vite only pre-bundles what a site's own sources import, so a site that reached `<Icon>` only through a package's `.astro` file (for example the `vitops.reviews` EmDash block, which renders `<ReviewCard>`) crashed in dev while building fine. `vitops()` now adds the module to `ssr.optimizeDeps.include`. No change to builds or to output.
84
+
85
+ No consumer-facing change in the other toolchain packages.
86
+
87
+ - Updated dependencies [93d2067]
88
+ - Updated dependencies [161c7f3]
89
+ - Updated dependencies [175d16e]
90
+ - Updated dependencies [83d6f3c]
91
+ - Updated dependencies [892b204]
92
+ - Updated dependencies [06d6112]
93
+ - Updated dependencies [6297337]
94
+ - Updated dependencies [55061e2]
95
+ - Updated dependencies [b47df4d]
96
+ - @getvitops/core@8.3.0-alpha.0
97
+ - @getvitops/generator@8.3.0-alpha.0
98
+ - @getvitops/utils@8.3.0-alpha.0
99
+ - @getvitops/vite@8.3.0-alpha.0
100
+ - @getvitops/config@8.3.0-alpha.0
101
+
102
+ ## 8.2.0
103
+
104
+ ### Minor Changes
105
+
106
+ - 65a13cc: **New: `site.experiments`** — runtime, per-visitor A/B content experiments for a page that
107
+ must stay at one URL (unlike `site.abTesting`'s existing build-time variant merge, which is
108
+ for a full design-system/CSS-level test via two manual deploys — `site.experiments` is the
109
+ opposite granularity: one page, one cookie, content branched in the page itself). Keyed by
110
+ an open experiment name, so more than one can run at once, each with its own consent
111
+ category, cookie name/lifetime, split ratio and pair of variant names.
112
+
113
+ **New `vitopsAbTesting()` middleware** (`@getvitops/astro/middleware`) — the first Astro
114
+ middleware this toolchain ships. Wire it into your own `src/middleware.ts`:
115
+
116
+ ```ts
117
+ import { vitopsAbTesting } from '@getvitops/astro/middleware';
118
+ import config from '../site.json' with { type: 'json' };
119
+
120
+ export const onRequest = vitopsAbTesting({
121
+ experiments: config.site.experiments ?? {},
122
+ });
123
+ ```
124
+
125
+ It assigns and cookie-pins a visitor to one of an experiment's two variants on first visit,
126
+ by weighted random (`splitRatio`) — and never touches an already-set cookie, so a recognized
127
+ query parameter named after the cookie itself (`?_ab_<key>=<variant>`) reliably forces that
128
+ variant, even overriding an existing assignment. Combined with `splitRatio: 0`, this is a
129
+ blue/green-style "100% on control, but a shared link reveals the other variant" mode with no
130
+ special-cased logic.
131
+
132
+ A page reads its own experiment's cookie (`Astro.cookies.get(cookieName)?.value`) and
133
+ renders whichever of its two hand-written blocks matches — there's no config-schema
134
+ representation of "the variant content," which is what keeps this lightweight regardless of
135
+ how many experiments are configured.
136
+
137
+ **`<Analytics />` gained a reporting hook**: each configured provider's bootstrap now
138
+ attaches an enabled experiment's assignment to the real pageview via that provider's own
139
+ mechanism — Plausible via `plausible.init({customProperties})`, Matomo via
140
+ `setCustomDimension` (needs a new `site.analytics.matomo.abVariantDimensions` entry — the
141
+ dimension must exist in your Matomo instance first), GA4 via a runtime-merged
142
+ `gtag('config', …)` call. A no-op when `site.experiments` is unset — zero behavior change
143
+ for every existing site.
144
+
145
+ Also new: `experimentDimensionWarnings(site)` (`@getvitops/generator`) — a build-time warning
146
+ (not a hard error) when an enabled experiment's site also configures Matomo but has no
147
+ matching `abVariantDimensions` entry.
148
+
149
+ **Not included in this pass, deliberately**: a mechanism for a fully prerendered
150
+ (`output: 'static'`) page — this needs a genuinely separate Cloudflare Worker Route + two
151
+ pre-built static variants, real but bounded follow-up work. Everything above applies to an
152
+ on-demand-rendered page (the current EmDash templates' default), which needs zero new
153
+ deploy infrastructure.
154
+
155
+ - 89265af: **New package: `@getvitops/config`** — the site-config provider blocks (`site.crm`, `site.ads`,
156
+ `site.reviews`, `site.seo.indexing`, `site.searchConsole`, `site.notifications`, `site.tracking`,
157
+ `site.security`, `site.deployment`) as zod schemas and inferred types, with the closed provider lists
158
+ (`AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`) on a zod-free `./providers` entry. It joins the
159
+ `fixed` group.
160
+
161
+ Why: `@getvitops/utils` holds the provider code but could not import its configuration types — the schema
162
+ lived in `@getvitops/generator`, a layer above — so it kept a hand-copied mirror beside each module,
163
+ an adapter converting into it, and a test holding the copy to the schema. The mirrors are gone.
164
+
165
+ - **`@getvitops/generator`**: `AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`, `AdProvider` and
166
+ `CrmProviderConfig` are re-exported unchanged. No import path changes.
167
+ - **`@getvitops/utils`**: the config types in `./crm`, `./ads`, `./indexing`, `./onboarding`, `./reviews`,
168
+ `./tracking` and `./notify` (`CrmConfig`, `HubSpotConfig`, `BiginConfig`, `JobberConfig`,
169
+ `IndexNowConfig`, `SearchConsoleConfig`, `ReviewsConfig`, `ReviewLocationConfig`, `TrackingConfig`,
170
+ `NotificationsConfig`, `EmailChannelConfig`, `DomainSetup`, `AdPropertySetup`) are now inferred from the
171
+ schema. Same names, same fields, but **optional fields now follow zod's inference**, so a hand-written
172
+ object literal typed against them may need `| undefined` handled. `AdPropertySetup` gains the fields the
173
+ schema always had (`conversionActionId`, `reporting`).
174
+ - **`@getvitops/astro`**: `GetvitopsAdProperty` and `GetvitopsTrackingOptions` are aliases of the schema
175
+ types. `toCrmConfig()` and `toGoogleAdsConfig()` are **deprecated** — they returned their argument's
176
+ field unchanged; pass `config.site.crm` / `config.site.ads?.google` directly.
177
+ - **`@getvitops/cli`**: `vitops ads` now passes `conversionActionId` and `reporting` through to the planner;
178
+ it previously copied a hand-picked subset of the block and dropped them.
179
+
180
+ Formats reached: none — no CSS change.
181
+
182
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
183
+
184
+ Client sites need form submissions to land in the client's CRM. The options available before this
185
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
186
+ white-labelled against this design system, nothing can operate on the submission before it leaves
187
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
188
+ was lost and no conversion notification was planned.
189
+
190
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
191
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
192
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
193
+ option, and are never config. A failed CRM write never fails the request and is folded into the
194
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
195
+
196
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
197
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
198
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
199
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
200
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
201
+ email, phone, company), not just a display name.
202
+
203
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
204
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
205
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
206
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
207
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
208
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
209
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
210
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
211
+ is set once its lookup query has been verified against a live account (defaults `false` pending
212
+ that verification — see `jobber.ts`'s docblock).
213
+
214
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
215
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
216
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
217
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
218
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
219
+ and Jobber have no such API, so both always print a manual reminder).
220
+
221
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
222
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
223
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
224
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
225
+ States as a processing location too, so the policy always asserts a cross-border transfer for
226
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
227
+
228
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
229
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
230
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
231
+
232
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
233
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
234
+ tailwind outputs are unaffected.
235
+
236
+ - 8d8aad7: **New: server-side Google Ads offline conversion upload.** Set
237
+ `site.ads.google.reporting: "server"` and a matching
238
+ `conversionActionId` (a numeric id or full
239
+ `customers/…/conversionActions/…` resource name — **not** the same value
240
+ as `conversionLabel`, which is the opaque label the web tag uses and
241
+ which the Ads API's upload endpoint doesn't accept) and
242
+ `createConversionRoute`'s new `googleAdsOffline` option uploads the
243
+ conversion via `ConversionUploadService.UploadClickConversions` instead
244
+ of relying on the visitor's browser to fire `gtag.js`. An action with no
245
+ matching `conversionActionId` keeps firing client-side regardless of
246
+ `reporting`, so turning it on before the id is filled in never silently
247
+ drops a conversion. `@getvitops/astro/routes` exports a matching
248
+ `toGoogleAdsConfig(config)` adapter, alongside `toCrmConfig`.
249
+
250
+ Structurally typed against Google's documented REST shape, not verified
251
+ against a live Ads account — no developer-token/MCC access exists
252
+ anywhere in this toolchain's own setup to test against. Wrapped so a
253
+ wrong guess degrades to a stated failure reason (folded into the
254
+ conversion email's "Delivery" section, same as a failed CRM write) rather
255
+ than breaking, the same caveat `@getvitops/utils/crm`'s Jobber executor
256
+ already carries for its own unverified surfaces.
257
+
258
+ `vitops ads lint` now flags a `reporting: "server"` property whose
259
+ configured actions have no matching `conversionActionId` — not a
260
+ failure, since that action keeps firing client-side, but worth surfacing
261
+ rather than leaving someone to wonder why nothing changed.
262
+
263
+ Not built: `vitops ads provision` (upserting conversion actions via
264
+ `ConversionActionService` and printing back both the label and the
265
+ resource id) — same credential blocker, and deliberately left unscoped
266
+ until there's a live account to verify the request/response shape
267
+ against.
268
+
269
+ - 0574388: Fire a Google Ads conversion on a tel:/mailto: click, and fix a rate-limit bug on the conversion
270
+ route that could silently drop one of the two.
271
+
272
+ `site.ads.google.conversionLabel` used to be accepted by the config schema and typed all the way
273
+ through to the rendered tag, but nothing actually read it — no `gtag('event', 'conversion', …)`
274
+ was ever emitted. When set, the click tracker `<Tracking />` inlines now fires the conversion at
275
+ the moment a visitor taps a `tel:` or `mailto:` link, using the `AW-…/label` target Google Ads
276
+ expects. A `conversionLabel` that can't safely land inside the inline bootstrap script (the same
277
+ check `pixelId`/`accountId` already get) is dropped with a warning rather than firing something
278
+ malformed — the ad tag itself still loads.
279
+
280
+ Also: `createConversionRoute()`'s rate limiter previously keyed on IP address alone, so a call
281
+ beacon and an email-click beacon from the same visitor, seconds apart, competed for one window —
282
+ the second could 429 and vanish with no error anywhere. It's now keyed on IP _and_ event type, so
283
+ distinct conversion types never starve each other; repeats of the same type are still throttled
284
+ exactly as before.
285
+
286
+ No config changes — a site with `site.ads.google.conversionLabel` already set picks this up
287
+ automatically on the next build.
288
+
289
+ - 2a0a559: The Astro integration now warns when `site.locales`/`site.defaultLocale` disagree with
290
+ `astro.config.mjs`'s own `i18n` block — naming which locale codes are missing from each side, and
291
+ when the two `defaultLocale`s differ. EmDash reads its locale list straight from `i18n`, while our
292
+ own SEO/legal code reads `site.locales`, so the two had no way to notice they'd drifted apart. A
293
+ warning, not a build error — like the existing ads/analytics/consent consistency checks, either
294
+ side can legitimately be configured without the other, so only a genuine disagreement is flagged.
295
+ - 0574388: Track `mailto:` link clicks as conversions, alongside the existing `tel:` link tracking.
296
+
297
+ Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
298
+ invisible to attribution — a visitor who emailed the business instead of calling generated no
299
+ conversion event at all. `<Tracking />`'s inlined script now listens for both, sending `{ event:
300
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
301
+ }`.
302
+
303
+ `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
304
+ `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
305
+ CRM checks (there are no form fields to validate or map), a 204 response, and a notification email
306
+ subject reading `[Email Click] Visitor emailing ...`.
307
+
308
+ No config changes and no new options — a site with `<Tracking />` already mounted picks this up
309
+ automatically the next time it rebuilds.
310
+
311
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
312
+ `{ call?, email?, form? }` — instead of one bare string shared by every
313
+ interaction. A bare string still works (back-compat, treated as `call`), but a
314
+ call and an email click used to report as the **same** Google Ads conversion
315
+ action, indistinguishable in the account; each action now gets its own
316
+ `send_to` target.
317
+
318
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
319
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
320
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
321
+ `form` Google Ads conversion action client-side (mirroring the existing
322
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
323
+ one label). Idempotent per form element. There's deliberately no server-side
324
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
325
+ redirect with no script to fire from, and a real server-side Google Ads
326
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
327
+ access), tracked separately in the vNext backlog.
328
+
329
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
330
+ raw `event` field.** It used to key on whatever string a client sent
331
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
332
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
333
+ were ever recognized, and anything else got its own fresh bucket. The key now
334
+ uses the server's own classification (`call` | `email` | `form`), closing that
335
+ bypass without changing the existing call-vs-email-don't-compete behavior.
336
+
337
+ **New: `site.notifications.actionUrl`** — an optional action-link template
338
+ added to the conversion-notification email as an "Actions" section.
339
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
340
+ etc.); the section is omitted when the event has no click ID to interpolate,
341
+ rather than emailing a broken link.
342
+
343
+ - e5bc937: **Security: JSON-LD can no longer break out of its `<script>` tag.** All 24 `schemas/*.astro`
344
+ components inlined `JSON.stringify(jsonLd)` as-is, so a review, FAQ answer or forum post containing
345
+ `</script><script>…` ran as script on the page. They now go through `jsonLdScript()`, which escapes
346
+ `<`, `>`, `&`, U+2028 and U+2029 (still valid JSON — consumers and search engines parse the same
347
+ document). Sites rendering any user-supplied text through a schema component should upgrade promptly.
348
+
349
+ **`createConversionRoute()` hardening** — behaviour changes:
350
+
351
+ - The body-size cap is now enforced on the stream itself, not only on `Content-Length`; a chunked
352
+ request with no header is refused with 413 once it passes `maxBodyBytes`.
353
+ - **Breaking for non-Cloudflare hosts:** `X-Forwarded-For` is no longer used as the rate-limit key by
354
+ default, since off Cloudflare a client can set it freely and mint a new bucket per request. Pass
355
+ `trustForwardedFor: true` when running behind a proxy that overwrites it. Without
356
+ `CF-Connecting-IP` or that opt-in there is no IP and rate limiting is skipped. No change on
357
+ Cloudflare Workers.
358
+
359
+ `@getvitops/emdash`: the link/button block (`vitops.actionLink`) drops hrefs with a scheme other
360
+ than `http`, `https`, `mailto` or `tel` (e.g. `javascript:`, `data:`) and renders nothing for them.
361
+
362
+ Formats reached: none — no CSS change. Other packages in the `fixed` group: No consumer-facing
363
+ change in this package.
364
+
365
+ - 7fd5f8f: **`site.seo` is now the single source of site-level SEO defaults** (ADR-0002). Until now `site.seo`
366
+ declared `titleTemplate`, verification tokens, `openGraph` and `twitter` that nothing read, while
367
+ `<Seo />` took a second, differently-shaped vocabulary through `vitops({ seo })` that no other stack
368
+ could see.
369
+
370
+ - `SeoSchema` moves to `@getvitops/config` and gains what only the integration had: `siteName`,
371
+ `defaultTitle`, `defaultDescription`, `openGraph.localeAlternates` and `openGraph.image` as
372
+ `{ src, alt, width, height, type }`. Text fields are localisable.
373
+ - `@getvitops/astro` exports `resolveSeoDefaults(config, { locale, env })`. The integration reads
374
+ `site.seo` from the site config it already loads; `siteName` falls back to `organization.name`, and
375
+ the environment's `robots` wins over `site.seo.robots`, the same fact `vitops robots` and
376
+ `search notify` read.
377
+ - `vitops({ seo })` keeps working unchanged and is now an **override** merged over the config, field by
378
+ field (`openGraph` and `twitter` merge key by key).
379
+ - **Removed from the config schema** (nothing read them): `descriptionTemplate` → use
380
+ `defaultDescription`; `openGraph.siteName`/`title`/`description` and
381
+ `twitter.title`/`description`/`image` → set per page on `<Seo />`, or the site-wide values above.
382
+ A config that still carries one now fails `vitops validate` with an unrecognised-key error.
383
+ - `openGraph.type` is now `website | article | profile`.
384
+
385
+ - 3bbb8c3: Add `toNotifyContext(config, locale?)` and `toCrmConfig(config)`, exported from
386
+ `@getvitops/astro/routes` beside `createConversionRoute()`. Builds the `context`/`crm.config`
387
+ options straight from a resolved `Config` instead of hand-writing a `NotifyContext` literal —
388
+ `conversion.ts`'s own docblock used to state this helper "does not exist."
389
+
390
+ `organization.name` is localizable; `toNotifyContext` resolves it for a given locale (default
391
+ `site.defaultLocale`), falling back to `legalName` when neither locale nor default resolves to
392
+ anything. `locationEmails` is ordered primary location first, then every other location with an
393
+ `email` set, in `organization.locations`' own key order.
394
+
395
+ ### Patch Changes
396
+
397
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
398
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
399
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
400
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
401
+ offered, now usable by the client directly, no dev server required. An edit is visible
402
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
403
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
404
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
405
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
406
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
407
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
408
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
409
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
410
+
411
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
412
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
413
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
414
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
415
+ plugin-install time.
416
+
417
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
418
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
419
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
420
+ script delivery). No change if you don't use `designSystemEditor`.
421
+
422
+ Also new, supporting the above:
423
+
424
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
425
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
426
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
427
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
428
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
429
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
430
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
431
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
432
+ `site.deployment.repo`/`branch` config fields.
433
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
434
+ wrapper over GitHub's Contents API.
435
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
436
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
437
+ route instead of the dev-only Vite endpoint.
438
+
439
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
440
+ both projects' GA releases.
441
+
442
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
443
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
444
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
445
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
446
+ code here needed to change, only the declared range.
447
+
448
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
449
+ the version bump:
450
+
451
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
452
+ `css.format: 'tailwind'` without installing either, which threw on build.
453
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
454
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
455
+ the build never generated.
456
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
457
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
458
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
459
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
460
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
461
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
462
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
463
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
464
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
465
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
466
+ Uncomment it once scaffolded into its own repo.
467
+
468
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
469
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
470
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
471
+ now names the bad key instead of reporting a bare "Invalid input").
472
+
473
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
474
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
475
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
476
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
477
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
478
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
479
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
480
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
481
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
482
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
483
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
484
+ evolving default) and making the template a real pnpm workspace member (would change how its
485
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
486
+
487
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
488
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
489
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
490
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
491
+ compose with vite-plus's own `defineConfig`). A type-level shim
492
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
493
+ runtime `vp test` failure for this one file is still open.
494
+
495
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
496
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
497
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
498
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
499
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
500
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
501
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
502
+ switching `<Icon />` to import from there.
503
+ - 0f8c018: **New: `resolveOrganizationName(config, locale?)`**, exported from `@getvitops/astro/routes`
504
+ alongside `toNotifyContext`/`toCrmConfig` — the same `organization.name` → `legalName` →
505
+ `undefined` fallback chain `toNotifyContext` already used for its `organizationName` field,
506
+ now usable standalone anywhere a build-time-only display name is needed (favicon generation,
507
+ a scaffold's `astro.config.mjs`, a `Layout.astro` fallback). `toNotifyContext` is unchanged —
508
+ it now delegates to this helper internally.
509
+
510
+ The emdash scaffold uses it to remove a three-way duplicated `'My EmDash Site'` string:
511
+ `astro.config.mjs`'s favicon `name` and `Layout.astro`'s title fallback both previously
512
+ hardcoded it independently of `site.json`'s `organization.name`. EmDash's own
513
+ `getSiteSettings().title` (set in the admin) still wins at render time when it's set — it's a
514
+ runtime-only fact that can't be read at build time at all — this only replaces the two
515
+ hardcoded _fallback_ strings.
516
+
517
+ - 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.
518
+ - 2ef2d41: **`resolveOrganizationName()` accepts a plain `import site from './site.json'`.** It was typed
519
+ against the full `Config`, which a JSON import cannot satisfy — TypeScript widens its literals
520
+ (`colors.utilities` becomes `string[]`, not the schema's union) — so every scaffolded site's
521
+ `Layout.astro` failed `astro check`. It now takes `OrganizationNameSource`, the slice it actually
522
+ reads (`organization.name` / `legalName`, `site.defaultLocale`); a resolved `Config` still
523
+ satisfies it, so nothing that compiled before stops compiling.
524
+
525
+ **Both templates now typecheck and lint clean on a fresh scaffold**, found by the new `scaffold`
526
+ CI job (it installs each template against this checkout's packed packages):
527
+
528
+ - `site.json` declares `neutral` and `surface` as `{ "hue": "ink", "kind": "surface" }`. As bare
529
+ hue strings they were chromatic roles, which emit no bare `bg-neutral` and no
530
+ `text-neutral-muted`, so the template's own footer class resolved to nothing and `vitops lint
531
+ --strict` (run by the template's pre-commit hook) failed. **Existing sites are unaffected** —
532
+ this changes only what a new scaffold starts with; if your own `site.json` has the same two
533
+ lines and uses `text-neutral-*` or `bg-surface*`, change them the same way.
534
+ - The `emdash` template's `index.astro` and `[...slug].astro` cast `page.data.title`, which is
535
+ `unknown` until `emdash types` has run against a live site, so `astro check` passed only after
536
+ the first `pnpm dev`.
537
+
538
+ No consumer-facing change in `@getvitops/core`, `@getvitops/generator`, `@getvitops/utils`,
539
+ `@getvitops/cli` or `@getvitops/vite`.
540
+
541
+ - 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.
542
+ - Updated dependencies [65a13cc]
543
+ - Updated dependencies [67e2088]
544
+ - Updated dependencies [89265af]
545
+ - Updated dependencies [0574388]
546
+ - Updated dependencies [c3c1bcb]
547
+ - Updated dependencies [0574388]
548
+ - Updated dependencies [734ab60]
549
+ - Updated dependencies [1a8231d]
550
+ - Updated dependencies [779d22e]
551
+ - Updated dependencies [0574388]
552
+ - Updated dependencies [8d8aad7]
553
+ - Updated dependencies [0574388]
554
+ - Updated dependencies [1a3e811]
555
+ - Updated dependencies [0574388]
556
+ - Updated dependencies [dde3df3]
557
+ - Updated dependencies [d9f213b]
558
+ - Updated dependencies [7f4f50e]
559
+ - Updated dependencies [7fd5f8f]
560
+ - Updated dependencies [ceb8310]
561
+ - Updated dependencies [88b465a]
562
+ - Updated dependencies [fdc3f7e]
563
+ - @getvitops/generator@8.2.0
564
+ - @getvitops/config@8.2.0
565
+ - @getvitops/utils@8.2.0
566
+ - @getvitops/core@8.2.0
567
+ - @getvitops/vite@8.2.0
568
+
3
569
  ## 8.2.0-alpha.0
4
570
 
5
571
  ### Minor Changes
@@ -63,7 +629,7 @@
63
629
 
64
630
  This adds one server-side path for all three CRMs, hung off the conversion route that already
65
631
  exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
66
- timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
632
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
67
633
  option, and are never config. A failed CRM write never fails the request and is folded into the
68
634
  notification email's new "Delivery" section, so a broken CRM token is never silent.
69
635
 
@@ -138,8 +704,8 @@ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the e
138
704
  Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
139
705
  invisible to attribution — a visitor who emailed the business instead of calling generated no
140
706
  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
- }`.
707
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
708
+ }`.
143
709
 
144
710
  `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
145
711
  `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
@@ -194,8 +760,8 @@ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the e
194
760
  ### Patch Changes
195
761
 
196
762
  - 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
763
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
764
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
199
765
  `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
200
766
  offered, now usable by the client directly, no dev server required. An edit is visible
201
767
  site-wide immediately (a runtime `:root` override, served via a new plugin API route and
@@ -256,7 +822,7 @@ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import c
256
822
  needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
257
823
  which only a human can set — run `emdash migrate --status` against the real deployed database,
258
824
  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.
825
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
260
826
  - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
261
827
  not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
262
828
  errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
@@ -598,7 +1164,7 @@ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import c
598
1164
  created, with `patterns/counter.css`'s own reduced-motion query as a second belt.
599
1165
  - The accessible name stays the final value for the whole animation: `.counter__value` is toggled
600
1166
  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
1167
+ hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
602
1168
  screen reader must never be read ~60 intermediate numbers. The animated text lives in a separate
603
1169
  `aria-hidden` presentation span it never reaches.
604
1170
  - `data-from` (default `0`), `data-duration` (ms, default `1200`), `data-easing` (`linear` |
@@ -1202,28 +1768,20 @@ hidden` would pull it out of the accessible-name computation along with the visu
1202
1768
  {
1203
1769
  "designSystem": {
1204
1770
  "themes": {
1205
- "default": {
1206
- /* … */
1207
- },
1771
+ "default": {/* … */},
1208
1772
  },
1209
1773
  },
1210
1774
  "organization": {
1211
1775
  "name": "Acme",
1212
1776
  "contact": "hq",
1213
1777
  "locations": {
1214
- "hq": {
1215
- /* … */
1216
- },
1778
+ "hq": {/* … */},
1217
1779
  },
1218
1780
  },
1219
1781
  "site": {
1220
1782
  "defaultLocale": "en",
1221
- "domains": {
1222
- /* … */
1223
- },
1224
- "analytics": {
1225
- /* … */
1226
- },
1783
+ "domains": {/* … */},
1784
+ "analytics": {/* … */},
1227
1785
  },
1228
1786
  }
1229
1787
  ```
@@ -1317,7 +1875,7 @@ hidden` would pull it out of the accessible-name computation along with the visu
1317
1875
  `animate-view` / `animate-scroll` / `animate-trigger` / `transition`, and every effect
1318
1876
  gained an `open-<fx>` state variant. Overlays now state _where they start_
1319
1877
  (`--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
1878
+ open-fade-in"` composes slide and fade. Applied at zero specificity with identity
1321
1879
  defaults, so a popover that sets no effect vars is unchanged.
1322
1880
  - **One scrim.** `--scrim` / `--scrim-filter` tokens and `.no-scrim` in `popover.css`,
1323
1881
  replacing 18 `::backdrop` blocks across seven partials. `.drawer--modeless` is aliased.
@@ -1458,7 +2016,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1458
2016
  toolchain bump. Use `--force` when you mean to re-encode.
1459
2017
 
1460
2018
  **`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
2019
+ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails without it rather
1462
2020
  than skipping: a page referencing a video that was never encoded is broken, not degraded.
1463
2021
 
1464
2022
  Two defaults worth knowing about. The MP4 exists because older iOS and the in-app webviews inside
@@ -1551,8 +2109,8 @@ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails
1551
2109
  replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
1552
2110
 
1553
2111
  **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
2112
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
2113
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
1556
2114
  `from` state — as the element hits the top of the viewport, which is to say they faded out while
1557
2115
  still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
1558
2116
  midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
@@ -1565,7 +2123,7 @@ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than th
1565
2123
  so one knob tunes both. If your own config has an empty `slide` base, add the same line.
1566
2124
 
1567
2125
  **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
2126
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
1569
2127
  hittable area and could never receive the hover that would reveal it. The state variants now match
1570
2128
  the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
1571
2129
  driver (`:is(.is-active, [data-active]) > .animate-trigger`):
@@ -1916,7 +2474,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1916
2474
  - bf453b0: Fixed: a site on `css.format: 'css'` failed to build because of Tailwind.
1917
2475
 
1918
2476
  `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
2477
+ 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
1920
2478
  analysable — so the bundler **followed** the import regardless of the branch ever running, down
1921
2479
  through `@tailwindcss/node` to `@tailwindcss/oxide`'s native `.node` binding, which it cannot parse.
1922
2480
  The build died on `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`, naming a package
@@ -2403,7 +2961,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2403
2961
  - `.chip-list__chip` and `.chip-list__chip-remove` are **removed**. A tag list is a list of tags, so
2404
2962
  its items are now the existing `.tag` / `.tag__remove` — which also means they pick up tag role
2405
2963
  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
2964
+ class="chip-list__chip-remove">` with `<span class="tag">x <button class="tag__remove">`. Note the
2407
2965
  items change appearance: `.tag` is outlined (border + neutral text) where the old chip was filled
2408
2966
  with `--color-surface-muted`.
2409
2967
  - The `radii.chip` primitive is **removed** from the example config; it was only ever an alias of