@getvitops/create 0.8.2 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @getvitops/create
2
2
 
3
+ ## 0.9.0-alpha.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ **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 }`.
10
+
11
+ **`@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.
12
+
13
+ **`@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`.
14
+
15
+ **`@getvitops/astro`:** `virtual:getvitops/head` gains `turnstileSiteKey` (read from `site.security.turnstile.siteKey`) so blocks can render the widget. No other change.
16
+
17
+ **`@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.
18
+
19
+ **`@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`.
20
+
21
+ **`@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`.
22
+
23
+ - fec1b4b: The EmDash template's two build scripts are now CLI commands, so fixes reach existing sites with `vp update` instead of a re-copy.
24
+
25
+ - `vitops emdash prepare-db` migrates and seeds the local D1 the build prerenders from (was `scripts/prepare-local-db.mjs`). The start and stop calls time out after 5 minutes and it prints progress.
26
+ - `vitops emdash check-prerender [dir]` fails the build if a prerendered page is not a real page (was `scripts/check-prerender.mjs`).
27
+
28
+ **Migration for a site scaffolded earlier:** delete `scripts/prepare-local-db.mjs` and `scripts/check-prerender.mjs`, then in `package.json` set `"prepare-local-db": "vitops emdash prepare-db"`, `"build": "astro build && vitops emdash check-prerender"`, and use the same two commands in place of `node scripts/…` inside `deploy` and `deploy:dev`. A site that never had the scripts needs only the `package.json` entries.
29
+
30
+ - 224a731: **`@getvitops/astro` (no CSS change; all formats unaffected)**
31
+
32
+ - **`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.
33
+ - **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.
34
+ - **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.
35
+
36
+ **`@getvitops/create` (templates; existing scaffolded sites are not changed)**
37
+
38
+ - **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).
39
+ - **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.
40
+ - **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.
41
+ - **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.
42
+ - **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.
43
+ - 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`.
44
+ - Label vocabulary: `badge` is a static label, `tag` is static or dynamic; `chip`/`pill` are not used.
45
+
46
+ **`@getvitops/cli`**: the bundled `vitops-design-system` skill points at `vp exec vitops docs <topic>`.
47
+
48
+ No consumer-facing change in `@getvitops/core`, `@getvitops/generator`, `@getvitops/utils` or `@getvitops/vite`.
49
+
50
+ - 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.
51
+
52
+ - `<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).
53
+ - `<SiteFooter sitemap legal name>` renders `footer-sitemap` + `footer-legal` + a copyright line.
54
+ - `@getvitops/astro/components/sitenav.ts` exports the pure helpers (`loadNavMenus`, `selectTiers`, ...).
55
+ - 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.
56
+ - `@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.
57
+ - `@getvitops/generator`: `TIERS.sitenav` now lists the two Astro components.
58
+
59
+ - 7a4241e: EmDash template: make the database a build prerenders from valid before it builds, and fail the build when it isn't. The template runs `migrations: { runtime: 'check' }`, so a build against a local D1 with pending migrations prerendered EmDash's one-line "Database migrations are required" response into every page while still succeeding. A fresh CI runner, a first deploy, a `SKIP_PULL_CONTENT=1` build and an upgrade deploy all hit it.
60
+
61
+ - **New `scripts/prepare-local-db.mjs`** (`vpr prepare-local-db`): starts `astro dev` once, calls EmDash's `dev-bypass` setup (which migrates and applies the seed's sample content), checks the home page renders, and stops it. Writes only the disposable `.wrangler/state`. Run after `pull-content` by `vpr deploy`, `vpr deploy:dev` and the reusable deploy workflow. `SKIP_PREPARE_LOCAL_DB=1` skips it.
62
+ - **New `scripts/check-prerender.mjs`**, part of `vpr build` and both deploy scripts: fails when any prerendered page is not an HTML document with a title, or carries the pending-migrations message.
63
+ - **Existing sites:** add the two scripts, the `prepare-local-db` script entry, and `&& node scripts/check-prerender.mjs` after `astro build`; add a "Prepare local database" step before each Build step in a copied deploy workflow. Run `vpr prepare-local-db` once before a first local `vpr build`.
64
+
65
+ No consumer-facing change in the other toolchain packages.
66
+
67
+ ### Patch Changes
68
+
69
+ - 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).
70
+
71
+ - **generator** (`tailwind` format only): `design-manifest.json` is now emitted alongside `tailwind.css`. No change to the CSS.
72
+ - **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 />`.
73
+ - **vite**: `editorManifestDir` also writes `vitops/editor.css`, from a throwaway `css`-format pass on tailwind sites.
74
+ - **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({ … })`.
75
+ - **create**: the EmDash template sets `editorManifest: true`. Existing sites: add it to `vitops({ … })` in `astro.config.mjs`.
76
+
77
+ No consumer-facing change in the other toolchain packages.
78
+
3
79
  ## 0.8.2
4
80
 
5
81
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/create",
3
- "version": "0.8.2",
3
+ "version": "0.9.0-alpha.0",
4
4
  "description": "Project templates for the Vitops design system — scaffold wired-up sites with `vp create @getvitops`.",
5
5
  "homepage": "https://docs.vitops.ca",
6
6
  "bugs": "https://github.com/getvitops/design-system/issues",
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "devDependencies": {
22
- "@getvitops/generator": "8.2.0"
22
+ "@getvitops/generator": "8.3.0-alpha.0"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=24"
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: custom-blocks
3
+ description: Decide whether a page section needs a new EmDash block type, and if so how to name, build, version and retire it. Use before creating, renaming or deleting any block type, any `src/components/blocks/*` renderer, or when asked to "add a block", "make an editable section" or "add a component the editors can place".
4
+ ---
5
+
6
+ # Custom blocks
7
+
8
+ Pages are composed from **blocks** so non-technical editors can rearrange and edit
9
+ them in the CMS. A new block type is the most expensive way to get a section onto a
10
+ page: it adds a content-model entry, a renderer, a field schema and a migration
11
+ burden, and **a block type cannot be renamed or deleted** once pages use it (see
12
+ "Lifecycle"). So work down this list and stop at the first rung that fits.
13
+
14
+ ## 1. Decision rule
15
+
16
+ 1. **Compose what exists.** The `vitops.*` blocks from `@getvitops/emdash` (cards,
17
+ carousel, gallery, accordion, details, banner, hero, marquee, image-compare,
18
+ copy button, action link, …) resolve inside any block type's `portableText` field,
19
+ so "a section with a heading, intro and then a row of cards" is a section block
20
+ whose body holds `vitops.cards` — not a new "cards section" type. Check what is
21
+ installed first: `vp exec vitops docs components` lists every block the toolchain
22
+ ships and which tier provides it.
23
+ 2. **Extend an existing block** if one is close. An extra option (a `variant`
24
+ select, an optional slot) on a block that already exists is a version bump on that
25
+ block, not a new type. Prefer this when the new thing is a variation of the same
26
+ content shape.
27
+ 3. **Site-local block type** only when the content shape is specific to this site
28
+ (a bespoke dashboard mock, an A-versus-B argument with hard-coded figures). Keep
29
+ the renderer in `src/components/blocks/` and the type name prefixed for the site
30
+ (see "Naming"). Code-owned widgets expose only a caption field; the numbers live in
31
+ code, not in the CMS.
32
+ 4. **Upstream it** when a second site would plausibly want it. Don't build a
33
+ generic block here and quietly fork it: raise it against the toolchain
34
+ (`getvitops/design-system`) with the shape you used, so every site gets it. Until
35
+ it ships, keep the local version and mark it `local (watch)` in a comment, so it
36
+ is easy to retire when the real one lands.
37
+
38
+ A form, a section container, a stats row, a logo grid, a CTA band and a steps list
39
+ are all _generic_ — expect to find them upstream or to upstream them, not to keep
40
+ them local.
41
+
42
+ ## 2. Naming: check the framework's vocabulary first
43
+
44
+ Block types are permanent, so the name has to be right the first time. Before you
45
+ name one:
46
+
47
+ - Run `vp exec vitops docs components` and `vp exec vitops docs classes`. If the
48
+ framework already has a pattern by that name, **use its meaning**: `badge` is a
49
+ _static_ label; `tag` is a label that may be static or dynamic (editable,
50
+ dismissable). Never `chip` or `pill`. A block called `chips` that renders badges
51
+ cost one site a new type, a content migration and a permanently retired type.
52
+ - Prefix site-local types with a short site code (`acme_section`), never `vitops.`
53
+ — that namespace belongs to the toolchain.
54
+ - Name the thing, not its look: `section`, `steps`, `logos`; not `green-box`.
55
+
56
+ ## 3. Field and renderer conventions
57
+
58
+ - **Colour: `tone` plus optional `color`.** `tone` takes a **role** name from
59
+ `site.json`'s `colors.roles` (preferred), falling back to a semantic name
60
+ (`info` `success` `warning` `danger`); `color` is the escape hatch for a palette
61
+ hue/step and should be rare — a site that sets it on every item is telling you a
62
+ role is missing. A select field can only list static options, so either list the
63
+ roles you actually use or take a validated string. Resolve it in one shared helper
64
+ (`src/components/blocks/tone.ts`) rather than per block.
65
+ - **Class names cannot be built from field values.** Tailwind only emits classes it
66
+ can read in source, so `` `bg-${tone}` `` produces nothing. Resolve a tone to CSS
67
+ custom properties (or a lookup table of whole class names) instead.
68
+ - **Links are `url` fields**, not `string`: EmDash's `url` type accepts site-relative
69
+ paths. (`string` is a mistake that needs a breaking version to undo.)
70
+ - **Repeater sub-fields are scalar-only** (no `portableText`, no links inside text),
71
+ so a checklist is one newline-separated `text` field. Don't fight it; rich item
72
+ bodies need EmDash to lift that limit.
73
+ - **Blocks don't nest.** A "section containing stats" is the section's own body
74
+ holding `vitops.*` blocks, or a flat sequence of sibling blocks; per-section
75
+ backdrops don't carry across siblings.
76
+ - **Reach for framework classes** (`centered`, `region`, `rhythm`, `subgrid`,
77
+ `cluster`, `font-*`, `card`, `badge`, `tag`) in the renderer; run
78
+ `vpr lint:design` — hand-written layout CSS in a block is the signal that the
79
+ framework is missing a pattern worth upstreaming.
80
+ - A card title is a heading: render a real `<h2>`–`<h4>` with a level prop, not a
81
+ styled `<p>`.
82
+
83
+ ## 4. Lifecycle
84
+
85
+ - **Create** with the schema tools (the `schema_*` actions on the site's MCP server,
86
+ `/_emdash/api/mcp`) or the admin; commit the matching renderer and the
87
+ `emdash export-seed` output so a fresh environment gets the same model.
88
+ - **A breaking change is a new version**, not an edit: add the version, migrate the
89
+ entries that use the old one, then activate it. Two things the tooling does not say:
90
+ `schema_activate_block_type_version`'s `expectedFingerprint` is the fingerprint of
91
+ the **currently active** version, not the one you are activating (a mismatch is a
92
+ `CONFLICT`), and `schema_update_block_type` **replaces the whole `fields` list**, so
93
+ send every field, not just the changed one.
94
+ - **Migrating content:** `emdash content update` has no `--migrate-blocks` flag; the
95
+ REST route `PUT /_emdash/api/content/<collection>/<id>` with
96
+ `{ data, _rev, migrateBlocks: true }` does (it takes Portable Text arrays; the CLI
97
+ and MCP take Markdown). Updating a published entry stages a **draft** — a separate
98
+ `content publish` is needed before the live page changes, and (because pages are
99
+ prerendered) a rebuild after that.
100
+ - **Retiring is not deleting.** A type that was renamed or superseded stays in the
101
+ database and the seed permanently; keep its renderer mapping old values so existing
102
+ content still renders, mark it retired, and stop offering it to editors.
103
+ - **Typed fields:** `emdash types` does not type `blocks` fields (they come out as
104
+ `unknown[]`); hand-write a value type per renderer until EmDash generates it.
@@ -7,14 +7,14 @@ description: Work the client-onboarding checklist (SETUP.html) autonomously —
7
7
 
8
8
  `SETUP.html` is the canonical checklist — this skill adds only sequencing,
9
9
  gates, and where to stop. It does not restate the steps; read `SETUP.html`
10
- (or `npx vitops setup --help`) for what each stage actually does and why.
10
+ (or `vp exec vitops setup --help`) for what each stage actually does and why.
11
11
 
12
12
  ## Steps
13
13
 
14
14
  1. **Preview the whole sequence first.**
15
15
 
16
16
  ```bash
17
- npx vitops setup all --dry
17
+ vp exec vitops setup all --dry
18
18
  ```
19
19
 
20
20
  Read the plan before doing anything. It names every step it would take and
@@ -24,10 +24,10 @@ gates, and where to stop. It does not restate the steps; read `SETUP.html`
24
24
  2. **Run it.**
25
25
 
26
26
  ```bash
27
- npx vitops setup all
27
+ vp exec vitops setup all
28
28
  ```
29
29
 
30
- Re-run `npx vitops setup --check` after each leg rather than retrying on
30
+ Re-run `vp exec vitops setup --check` after each leg rather than retrying on
31
31
  your own judgment — a step already done resolves to a no-op skip, so a
32
32
  second run is safe, and `--check` tells you exactly what still needs doing
33
33
  instead of you guessing from output.
@@ -31,10 +31,13 @@ export PATH="$NODE24_BIN:$PATH"
31
31
  echo "export PATH=\"$NODE24_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE"
32
32
 
33
33
  # --- Dependencies (pnpm, pinned via package.json > packageManager) -----------
34
+ # pnpm is the bootstrap here: `vp` (Vite+) is a devDependency, so it only exists
35
+ # once this install has run. After that, use `vp install` / `vpr <script>` /
36
+ # `vp exec <bin>` — see AGENTS.md "Commands".
34
37
  cd "$CLAUDE_PROJECT_DIR"
35
38
  export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
36
39
  corepack enable
37
40
  # `install` (not `--frozen-lockfile`) so the warmed container cache is reusable.
38
41
  pnpm install
39
42
 
40
- echo "Ready: Node $(node --version), pnpm $(pnpm --version)"
43
+ echo "Ready: Node $(node --version), pnpm $(pnpm --version), vp $(./node_modules/.bin/vp --version 2>/dev/null | head -1 || echo 'not installed')"
@@ -8,12 +8,23 @@
8
8
  # Deploys via the vitops toolchain's reusable EmDash deploy workflow — see
9
9
  # https://github.com/getvitops/design-system/blob/main/.github/workflows/site-deploy-emdash.yml
10
10
  #
11
+ # Also runs on a `repository_dispatch` of type `emdash-publish`: pages are
12
+ # prerendered, so a publish in the EmDash admin only goes live after a rebuild,
13
+ # and an EmDash `content:afterPublish` hook POSTs that event (AGENTS.md,
14
+ # "Publish-triggered rebuilds"). A dispatch runs this file from the DEFAULT branch
15
+ # (`dev`), so `ref` pins the build to `main` — production's code, not dev's. It
16
+ # still goes through the `production` environment, so it still waits for the
17
+ # reviewers' approval; relax that rule if editors' publishes should go live
18
+ # unattended.
19
+ #
11
20
  # Required repo secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
12
21
  name: Deploy prod
13
22
 
14
23
  on:
15
24
  push:
16
25
  branches: [main]
26
+ repository_dispatch:
27
+ types: [emdash-publish]
17
28
 
18
29
  concurrency:
19
30
  group: deploy-prod
@@ -25,6 +36,7 @@ jobs:
25
36
  with:
26
37
  environment: production
27
38
  is-dev: false
39
+ ref: ${{ github.event_name == 'repository_dispatch' && 'main' || '' }}
28
40
  # Set this to your production URL once known.
29
41
  health-check-url: ''
30
42
  secrets:
@@ -1,5 +1,5 @@
1
- This is an EmDash CMS website (Astro SSR on Cloudflare Workers, D1 + R2)
2
- styled by the Vitops design system.
1
+ This is an EmDash CMS website (Astro on Cloudflare Workers, D1 + R2 — public pages
2
+ prerendered, the Worker serving the admin and API) styled by the Vitops design system.
3
3
 
4
4
  ## Ground rules
5
5
 
@@ -21,11 +21,11 @@ styled by the Vitops design system.
21
21
  `@getvitops/emdash` are merged in automatically).
22
22
  - **Building pages from patterns:** the editor's insert menu offers the `vitops.*` blocks —
23
23
  link/button, image compare, copy snippet, banner, disclosure, accordion, hero, carousel, gallery,
24
- cards, marquee (`npx vitops docs blocks` lists them with every field). Repeating content (cards, gallery images, accordion items, carousel slides) is
24
+ cards, marquee (`vp exec vitops docs blocks` lists them with every field). Repeating content (cards, gallery images, accordion items, carousel slides) is
25
25
  a repeater field. A block cannot contain other blocks, and a `vitops.*` block inside EmDash's
26
26
  built-in `columns` does not render — for layout (a section, a split, a hero) use a `blocks`
27
27
  field, or hand-write the page template with `@getvitops/astro` components
28
- (`Subgrid`, `Cards`, `Carousel`, …). `npx vitops docs components` lists every pattern, which
28
+ (`Subgrid`, `Cards`, `Carousel`, …). `vp exec vitops docs components` lists every pattern, which
29
29
  tiers provide it, and which block renders it. Override a block's rendering per site with
30
30
  `<PortableText components={{ type: { 'vitops.cards': MyCards } }} />`; hide one with
31
31
  `vitopsEmdash({ blocks: { exclude: [...] } })` in `astro.config.mjs`.
@@ -36,7 +36,7 @@ styled by the Vitops design system.
36
36
  (`/_emdash/admin`) or the site's MCP server (`/_emdash/api/mcp`) to change
37
37
  the content model of a running site.
38
38
  - Prefer framework CSS classes (utilities, patterns, type roles like
39
- `font-heading`) over bespoke CSS. Run `npx vitops agents` to append the
39
+ `font-heading`) over bespoke CSS. Run `vp exec vitops agents` to append the
40
40
  full generated class-vocabulary reference to this file.
41
41
  - **Layout foundations — these are the answer, not a starting point.** Six
42
42
  substitutions carry the structure of nearly every page:
@@ -70,18 +70,81 @@ styled by the Vitops design system.
70
70
  `li` is the grid item, so the anchor's tranches never align and it does not fill
71
71
  the cell; it renders fine, which is why it keeps getting written.
72
72
 
73
- `npm run lint:design` reports all of these, and the pre-commit hook fails on them
73
+ `vpr lint:design` reports all of these, and the pre-commit hook fails on them
74
74
  — the fix is the class, not a justification for the CSS.
75
75
 
76
- - **Prerender by default.** `output: 'server'` is required (the admin, media/API
77
- routes, preview and scheduled publishing need a server), so prerendering is
78
- opt-in per route: put `export const prerender = true` on every page that
79
- doesn't need per-request data, and give dynamic routes a `getStaticPaths()`
80
- that queries the collection at build time. A route with no `prerender` export
81
- is re-rendered for every visitor on every request. The cost is that a
82
- prerendered page reflects the database as of the last build, so an admin
83
- publish needs a redeploy — leave a route server-rendered only where that is
84
- unacceptable (preview, personalised content, must-be-live-in-seconds).
76
+ - **Rendering and freshness: everything is prerendered.** `output: 'server'` is
77
+ still required (the admin, media/API routes and scheduled publishing need a
78
+ Worker), but every public page — `index.astro`, `[...slug].astro`, `404.astro` —
79
+ has `export const prerender = true`, and `[...slug].astro` lists the published
80
+ `pages` entries in `getStaticPaths()`. The HTML is written at build time and
81
+ served as a static asset; the Worker only handles `/_emdash/*` and anything
82
+ you deliberately leave on-demand. A route with no `prerender` export is
83
+ re-rendered on every request, so don't leave it off by accident.
84
+ - **Where build-time content comes from.** The Cloudflare adapter prerenders
85
+ against a _local_ D1 (`.wrangler/state`), never the deployed one — on a CI
86
+ runner that is an empty database, which would bake "No home page yet" into
87
+ every page. `scripts/pull-content.mjs` (the first step of `vpr deploy` and
88
+ `vpr deploy:dev`) exports production's D1 and loads it locally first. It
89
+ fails closed: a failed export aborts the deploy rather than publishing an
90
+ empty site (the one exception is a database that doesn't exist yet, the first
91
+ deploy, which builds from the seed). `SKIP_PULL_CONTENT=1` skips it.
92
+ - **The local database must be migrated before the build.** The template runs
93
+ `migrations: { runtime: 'check' }`, so a build against a local database with
94
+ pending migrations prerenders EmDash's one-line "Database migrations are
95
+ required" response into every page — and still succeeds. That is what a fresh
96
+ runner, a first deploy, a `SKIP_PULL_CONTENT=1` build and an **upgrade** deploy
97
+ (the pull restores production's older schema, and `emdash migrate` only runs
98
+ after the build) would all publish. `vitops emdash prepare-db` (`vpr prepare-local-db`, run after the
99
+ pull by `vpr deploy`, `vpr deploy:dev` and the reusable workflow) starts `astro
100
+ dev` once, calls EmDash's `dev-bypass` setup (which migrates and applies the
101
+ seed's sample content) and stops it; `vitops emdash check-prerender` (part of
102
+ `vpr build`) then fails the build if any prerendered page is not a real HTML page
103
+ with a title. Run `vpr prepare-local-db` by hand before a first local `vpr build`.
104
+ It only writes the disposable `.wrangler/state`; the dev administrator it creates
105
+ is never deployed.
106
+ - **Freshness is a rebuild.** Production is the truth for content, and a page
107
+ reflects it as of the last build. Publishing in the admin therefore has to
108
+ trigger one: the deploy workflows listen for a `repository_dispatch` event of
109
+ type `emdash-publish` (see "Publish-triggered rebuilds" below).
110
+ - **Drafts and previews.** A prerendered page cannot show an unpublished
111
+ draft, so an admin "Preview" link shows the last published build. If previews
112
+ matter, add a dedicated on-demand route for them rather than removing
113
+ `prerender` from the public ones.
114
+ - **Dynamic parts are server islands.** Anything that must be fresh per request
115
+ goes in its own component rendered with `server:defer` inside the prerendered
116
+ page; the surrounding HTML stays static. Keep islands rare — each is a Worker
117
+ request after load.
118
+ - **Unknown URLs.** There is no `Astro.rewrite('/404')`: a slug not listed by
119
+ `getStaticPaths()` has no route, so Astro serves the prerendered `404.html`
120
+ with a real 404 status. Rewriting an on-demand route to a prerendered one
121
+ fails (500), so don't reintroduce it.
122
+ - **The contact form is the one on-demand route.** The `vitops.form` block posts a plain
123
+ `<form method="post">` to `src/pages/api/track.ts` (`prerender = false`, built with
124
+ `createConversionRoute()`), which also answers the conversion beacons. The block works with
125
+ no JavaScript: the route answers a form post with a 303 back to the page plus `?sent=1#form-sent`
126
+ or `?error=1#form-error`, and the block's two notices are revealed by that fragment. Keep one
127
+ form per page. Where submissions go: `site.notifications.email` (or the primary location's
128
+ `email`) and `site.crm` when set. **With neither, the route refuses the post** rather than
129
+ accept a message nobody receives, so set `site.notifications.email` before launch and run
130
+ `vitops email setup` for its sender domain. A failed CRM write never fails the request; it is
131
+ stated in the notification email, which is the durability floor.
132
+ - **Spam protection**: a honeypot field, the route's origin / size / per-IP rate checks, and
133
+ Cloudflare Turnstile once `vitops setup turnstile` has put the public key in
134
+ `site.security.turnstile.siteKey` and saved the Worker secret `VITOPS_TURNSTILE_SECRET`
135
+ (never in `site.json`). With a key set the route fails closed if the secret is missing.
136
+ `TURNSTILE_REQUIRED` in the route (default `true`) makes a token-less post a refusal, which
137
+ means the form needs JavaScript; set it `false` to keep a no-JS submit working and accept
138
+ that a bot can then skip the token. Turnstile is category `necessary` (it protects the form
139
+ being sent), loads only when a visitor first uses the form, and its origin is allowed in the
140
+ Report-Only CSP in `public/_headers`.
141
+ - **Privacy policy inventory.** `vitops legal` derives "what personal information we collect"
142
+ from `site.templates` entries of type `form`, not from the editor's block, so a form added in
143
+ the admin is invisible to it. When you add or change form fields, mirror them in a
144
+ `site.templates` form entry in `site.json`, or the generated policy under-reports.
145
+ - A field named `phone` submits as `tel` (the route reserves `phone` and `event`).
146
+ - The per-IP rate limit is per Worker isolate; add a KV-backed `RateLimitStore` if you need a
147
+ fleet-wide one.
85
148
  - The Worker cron trigger in `wrangler.jsonc` drives scheduled publishing —
86
149
  don't remove it.
87
150
  - Hosting (adapter + EmDash database/storage) comes from `vitopsHosting()`
@@ -110,7 +173,7 @@ styled by the Vitops design system.
110
173
  a finished policy, because the risk here is a plausible-looking document
111
174
  getting shipped unread.
112
175
  To replace them properly, fill in `site.json`'s `organization`/`site.legal`
113
- fields and run `npx vitops legal --format portable-text --out ./legal`, then
176
+ fields and run `vp exec vitops legal --format portable-text --out ./legal`, then
114
177
  paste each document's blocks into the page in the admin. That renders a real
115
178
  policy from what the site actually does — the analytics provider it uses,
116
179
  the fields its forms collect, where it deploys — rather than from guesses.
@@ -129,7 +192,7 @@ styled by the Vitops design system.
129
192
  - **Submit `/sitemap.xml`** to search engines, not a per-collection sitemap;
130
193
  collection sitemaps now split into pages of 2,000 entries.
131
194
  - **A stuck D1 migration lock** is cleared with
132
- `pnpm exec emdash migrate --take-over-lock <id>` (confirms the target, or
195
+ `vp exec emdash migrate --take-over-lock <id>` (confirms the target, or
133
196
  needs `--expected-target-fingerprint` non-interactively).
134
197
 
135
198
  ## Docs MCP servers
@@ -137,6 +200,92 @@ styled by the Vitops design system.
137
200
  `.mcp.json` preconfigures the Astro docs and EmDash docs MCP servers — prefer
138
201
  searching them over pre-trained knowledge for Astro/EmDash questions.
139
202
 
203
+ ## Labels: badge and tag
204
+
205
+ Two label patterns, both enabled in `site.json`'s `patterns.items`: **`badge`** is a
206
+ static label (a status, count or category) and **`tag`** is a label that may be
207
+ static _or_ dynamic (editable, dismissable, in a `tag-list`). Don't invent a third
208
+ name — `chip` and `pill` are not part of this vocabulary. The button pattern's class
209
+ is `btn` (`<a class="btn">` is styled, not just a bare `<button>`).
210
+
211
+ ## Custom blocks
212
+
213
+ Before adding a block type, a `src/components/blocks/*` renderer, or anything an
214
+ editor places on a page, read `.agents/skills/custom-blocks/SKILL.md`: compose the
215
+ existing `vitops.*` blocks first, extend one second, go site-local third, upstream
216
+ last. Block types can't be renamed or deleted once pages use them, so check the
217
+ framework's pattern names before choosing one.
218
+
219
+ ## Publish-triggered rebuilds
220
+
221
+ Pages are static, so a publish in the admin goes live only after a rebuild.
222
+ `.github/workflows/deploy-prod.yml` also runs on a `repository_dispatch` event of
223
+ type `emdash-publish` (it pins `ref` to `main`, because a dispatch runs from the
224
+ default branch, `dev`). Wire an EmDash plugin hook to send it:
225
+
226
+ ```jsonc
227
+ // emdash-plugin.jsonc
228
+ "capabilities": ["content:read", "network:request"],
229
+ "allowedHosts": ["api.github.com"]
230
+ ```
231
+
232
+ ```ts
233
+ // src/plugin.ts
234
+ hooks: {
235
+ 'content:afterPublish': async (_event, ctx) => {
236
+ const token = await ctx.settings.get<string>('githubToken'); // an encrypted plugin setting
237
+ if (!token || !ctx.http) return;
238
+ await ctx.http.fetch(`https://api.github.com/repos/${OWNER}/${REPO}/dispatches`, {
239
+ method: 'POST',
240
+ headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
241
+ body: JSON.stringify({ event_type: 'emdash-publish' }),
242
+ });
243
+ },
244
+ }
245
+ ```
246
+
247
+ The token is a fine-grained GitHub token with `Contents: write` on this repo only,
248
+ stored as the plugin's encrypted setting (a sandboxed plugin can't read Worker
249
+ environment variables), never in the repo. The hook name, capabilities and
250
+ `ctx.http` / `ctx.settings` calls follow EmDash's plugin reference but this exact
251
+ plugin has not been run against a live site, so treat it as a starting point. Two
252
+ costs to know: the run goes through the `production` environment, so it **waits for
253
+ the reviewers' approval** like any prod deploy (relax that rule if editors' publishes
254
+ should go live unattended), and bursts of publishes queue behind the
255
+ `deploy-prod` concurrency group rather than cancelling each other. Without the hook,
256
+ a manual run of **Deploy prod** (or a push to `main`) does the same job.
257
+
258
+ ## Content Security Policy
259
+
260
+ `public/_headers` ships a **`Content-Security-Policy-Report-Only`** header: it logs
261
+ violations to the browser console and blocks nothing, so it can't break a page. Its
262
+ `script-src` is `'self'` plus the SHA-256 of each inline script `<Head />` emits (the
263
+ colour-scheme bootstrap and the consent-gate stub) — no `'unsafe-inline'`. Hashes,
264
+ not nonces, because prerendered pages have no per-request response to put a nonce in.
265
+ `contentSecurityPolicy()` and `inlineScriptHashes()` from `@getvitops/astro` produce
266
+ the same strings (`templates.test.ts` in the toolchain keeps the file in step).
267
+
268
+ Know what it does not cover before you flip it to enforcing (rename the header to
269
+ `Content-Security-Policy`):
270
+
271
+ - **`_headers` applies to static assets only.** Worker-rendered responses — the EmDash
272
+ admin (`/_emdash/*`), its API, anything left on-demand — don't get it. Setting a
273
+ header there means doing it in code (e.g. Astro middleware calling
274
+ `contentSecurityPolicy()`), and the admin's own inline scripts and editor HTML
275
+ would need allowing; leave the admin out of an enforced policy.
276
+ - **Editor-authored HTML blocks are the hard limit.** Any block that lets an editor
277
+ paste raw HTML or a `<script>` (an embed, a tracking snippet) puts arbitrary inline
278
+ script on the page, which a hash policy cannot name ahead of time. Enforcing a strict
279
+ policy means removing such blocks or moving embeds to a consent-gated
280
+ `data-consent` tag whose origin you allow explicitly.
281
+ - **Third-party tags** the consent gate activates need their origins in `script-src`
282
+ / `connect-src`; anything with `frame` needs `frame-src`.
283
+ - **A server island** (`server:defer`) adds an inline loader script and a request to
284
+ the Worker; expect a report for the loader until you hash it.
285
+
286
+ Run the site, browse the pages you ship with the console open, fix or allow what is
287
+ reported, and only then enforce.
288
+
140
289
  ## EmDash agent skills
141
290
 
142
291
  The EmDash skills (`building-emdash-site`, `creating-plugins`, `emdash-cli`,
@@ -146,20 +295,55 @@ project, not globally, so they match the `emdash` version this repo pins. If
146
295
  and `SETUP.html` have the full step):
147
296
 
148
297
  ```bash
149
- npx skills add emdash-cms/skills --skill '*' -y
298
+ vp dlx skills add emdash-cms/skills --skill '*' -y
150
299
  ```
151
300
 
152
- Refresh them with `npx skills update` after bumping `emdash` — the
301
+ Refresh them with `vp dlx skills update` after bumping `emdash` — the
153
302
  `upgrading-emdash` skill covers the rest of the upgrade.
154
303
 
155
304
  ## Commands
156
305
 
157
- - `pnpm dev` — local dev with local D1/R2 emulation (no Cloudflare account)
158
- - `pnpm typecheck` — `astro check`
159
- - `pnpm deploy` — build + `wrangler deploy` prod (provisions D1/R2 on first run)
160
- - `pnpm deploy:dev` — build + deploy the isolated dev worker (needs `DEV_D1_ID`)
161
- - `pnpm run init:github` — one-time: create + push the GitHub repo (main + dev,
162
- dev default). Only run when the user asks to set up GitHub.
163
- - `pnpm run init:hosting` — one-time: provision the hosting platform (dev
164
- D1/R2 + repo secrets on cloudflare). Only run when the user asks for it.
165
- - `npx vitops validate site.json` — schema-check the site config
306
+ This is a **Vite+** project: use `vp` (and its `vpr` shorthand), not `pnpm`/`npm`/`npx`.
307
+ The package manager underneath is pnpm, but `vp` picks it up for you.
308
+
309
+ | To… | Run |
310
+ | ---------------------------------------- | ------------------------------------------------------- |
311
+ | install dependencies | `vp install` |
312
+ | add / remove / update a package | `vp add <pkg>` · `vp remove <pkg>` · `vp update` |
313
+ | run a `package.json` script | `vpr <script>` (alias for `vp run <script>`) |
314
+ | run a locally installed binary | `vp exec <bin>` (e.g. `vp exec vitops docs components`) |
315
+ | run a one-off package without installing | `vp dlx <pkg>` |
316
+ | format + lint + typecheck (files) | `vp check` |
317
+ | typecheck Astro (`.astro` files) | `vpr typecheck` |
318
+
319
+ **Scripts that wrap Astro must go through `vpr`.** `vp dev` / `vp build` run Vite
320
+ directly and skip Astro, so use `vpr dev`, `vpr build`, `vpr preview`.
321
+
322
+ **`vpr dev` returns immediately.** `astro dev` now starts the server in the
323
+ background. Check it with `vp exec astro dev status`, read its output with
324
+ `vp exec astro dev logs --follow`, and stop it with `vp exec astro dev stop`; the
325
+ site is at <http://localhost:4321>. Don't wait on it as if it were a foreground
326
+ process.
327
+
328
+ | Script | What it does |
329
+ | ----------------------------------- | ------------------------------------------------------------------------------------------- |
330
+ | `vpr dev` | local dev with local D1/R2 emulation (no Cloudflare account) |
331
+ | `vpr deploy` | pull production content, build, `wrangler deploy` prod (provisions D1/R2 on first run) |
332
+ | `vpr deploy:dev` | same, for the isolated dev worker (needs `DEV_D1_ID`) |
333
+ | `vpr pull-content` | just the content pull (see "Rendering and freshness") |
334
+ | `vpr prepare-local-db` | migrate and seed the local D1 the build prerenders from |
335
+ | `vpr init:github` | one-time: create + push the GitHub repo (main + dev, dev default). Only when the user asks. |
336
+ | `vpr init:hosting` | one-time: provision hosting (dev D1/R2 + repo secrets). Only when the user asks. |
337
+ | `vp exec vitops validate site.json` | schema-check the site config |
338
+
339
+ **Release flow.** A site is not a published package, so there is no changeset or
340
+ version bump: ship by committing to `dev` (auto-deploys the dev worker) and then
341
+ running the **promote** workflow. `vp` has no `vp change` / `vp pm change` command
342
+ (checked against vp 1.5.0); for a _package_ it does have `vp pm version`,
343
+ `vp pm publish` and `vp pm stage`.
344
+
345
+ **Naming the project.** `vp create` takes the package name from the target
346
+ directory and invents a random slug when none is given, so pass one:
347
+ `vp create @getvitops:emdash --directory <client-slug>`. `wrangler.jsonc`'s
348
+ `name`, D1 `database_name` and R2 `bucket_name` are separate and still say
349
+ `my-emdash-site` — rename them to match before the first deploy.