@getvitops/create 0.9.0-alpha.1 → 0.9.0-alpha.2

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,43 @@
1
1
  # @getvitops/create
2
2
 
3
+ ## 0.9.0-alpha.2
4
+
5
+ ### Minor Changes
6
+
7
+ - 0a47856: Build correctness and gaps from the second upstream round.
8
+
9
+ - **`@getvitops/emdash`: fixes a circular import that emptied the plugin block map in the static build.** `Section.astro` and `WidgetArea.astro` imported `emdash/ui` statically, which imports `virtual:emdash/block-components`, which is `@getvitops/emdash/astro` again. Depending on import order, every `vitops_*` block inside a section body was dropped from prerendered pages with only a `PortableText [components.type] is missing …` warning. Both now import it lazily, and a test fails if any renderer imports `emdash/ui` statically. No code change needed in sites.
10
+ - **`@getvitops/cli`: `vitops emdash build`** runs `astro build` and then `check-prerender`, and fails when the build reports a block type with no component (reported once per type). The template's `build`, `deploy` and `deploy:dev` scripts use it; existing sites replace `astro build && vitops emdash check-prerender` with `vitops emdash build`. `check-prerender` also warns (fails with `--strict`) on a page without exactly one `<h1>`.
11
+ - **`@getvitops/emdash`: `vitops_hero` and `vitops_section` heading level** accepts `h1`–`h6` and `p` (a heading that is styled but not in the outline). The hero defaults to `h1`; set the level to `2` on a page whose layout already renders an `h1` (the template's seed does).
12
+ - **`@getvitops/emdash`: `vitops_logos`** shows an entry's name as text (`.logos__name`) when it has no image. `@getvitops/core` adds the rule (all formats).
13
+ - **`@getvitops/emdash`: an empty content widget** (no text, no blocks) no longer renders its title, and an area whose widgets are all empty renders nothing.
14
+ - **`@getvitops/create`:** the template's `pages` collection gets `urlPattern: "/{slug}"` (the sitemap listed `/pages/<slug>`, which 404s; existing sites change it under Content Types), and `.gitignore` ignores only `src/styles/tailwind.css` and `tokens.json`, so site CSS kept in `src/styles/` is committed.
15
+ - **`@getvitops/emdash`: per-item colour.** The rows of `vitops_cards`, `vitops_stats`, `vitops_steps` and the hero's `cards` take their own `tone` / `color` (blank inherits the block's), so one block can hold a pine item and an amber one. Hero cards also take an `eyebrow` and a button (`ctaLabel` / `ctaHref`; with a button the card is no longer a stretched link).
16
+ - **`@getvitops/generator` / `@getvitops/emdash`: solid fills read.** The generator emits `--color-<hue>-<step>-on` for every step of every hue, the foreground for a fill at that step picked by the same APCA rule as `text-on-<role>` (css and tailwind formats, a plain `:root`, not `@theme`). A block fill chosen by `color` (hue and step) uses it instead of a fixed white..
17
+ - **`@getvitops/core`: `.counter__value.sr-only`** hides itself, so a build that never emitted the `.sr-only` utility no longer shows a counting figure twice (all formats).
18
+ - **`@getvitops/astro` / `@getvitops/core`: `SiteFooter` groups.** A top-level item of the `footer-sitemap` menu with children becomes a column (its label is the heading, its children the links, one level only); childless items stay in the flat list. Each column is a named `<nav>`.
19
+ - **`@getvitops/astro`: planned links and header buttons.** A menu item with `draft` in its CSS classes stays in the menu but is not rendered (at every level, in the header and the footer). `<SiteNav>` has an `actions` slot for header buttons that are not menu items, and an item with `cta` in its CSS classes renders as a button.
20
+ - **`@getvitops/cli`: `check-prerender` warnings** (fail with `--strict`): links to pages the build did not produce, a form with no delivery address (no `site.notifications.email`, location `email` or `site.crm`), and form fields missing from every `site.templates` form entry (the generated privacy policy reads those). It reads `./site.json`, or `--site=<path>`. `vitops setup … --dry-run` is accepted as `--dry`.
21
+ - **`@getvitops/emdash` / `@getvitops/astro`: "tick any number" form field** (`vitops_form` field kind `checkboxes`): a fieldset of checkboxes sharing one name. The conversion route joins repeated values (`IT, Marketing`) instead of keeping the last. The "required" toggle is not applied to the group (HTML cannot require one of several).
22
+ - **`@getvitops/core`: size hooks.** `.steps__title`, `.stats__figure` and `.card__title` read `--steps-title-size` (default one step below `heading`), `--stats-figure-size` and `--card-title-size`, set on the block through its `style` field. They set the `--heading-fs` / `--display-fs` properties the type-role utilities already read, so no extra class or prop is needed (all formats).
23
+ - **`@getvitops/create`:** the template's `site.json` lists its contact form under `site.templates`, so the privacy policy inventory and the new check agree.
24
+ - **`@getvitops/emdash`: `theme` on every block, on widget areas and on your own markup.** A `theme` select (`<role>` tinted, `<role>:solid` filled, populated from the site's roles by a new `themes/list` route) is a common field on every `vitops_*` block, applied to the block's own root with the role's computed readable text. `<VitopsWidgetArea theme="…">` takes the same, and `themeAttrs('surface')` gives `{ class, style }` for a layout `<body>`. `vitops_section`'s existing `theme` now goes through the same path.
25
+ - **`@getvitops/cli` / `@getvitops/emdash`: pages with a dropped block fail the build.** `vitops_section` and widget areas emit an invisible `<template data-vitops-unrendered="…">` for any body block with no renderer, and `check-prerender` fails the page naming the types (nested bodies only; `vitops emdash build` also scans the build log, which covers top-level bodies). A regression test imports the plugin entry first with a mock of the real virtual module, so the old import cycle fails it.
26
+ - **`@getvitops/emdash`: `formTemplateFromBlock(block)`** turns a saved `vitops_form` value into a `site.templates` form entry (names as submitted, a `checkboxes` field as a `checkbox` with options), to paste into `site.json` so the generated privacy policy lists what the form collects. The build cannot read the CMS database, so this is run by hand; `check-prerender` warns when fields are missing.
27
+ - **`@getvitops/utils`: `hasFormDelivery`** (`@getvitops/utils/notify`): the one test for "has this form anywhere to go", used by `check-prerender`.
28
+ - **`@getvitops/emdash`:** a `color` hue tint now flips with the colour scheme (`light-dark()`); hero option cards sit at `h2` under an `h1` hero; menu items also accept `hidden: true` (same as the `draft` class); `vitops docs blocks` documents `theme`, the per-row colours, the `--<role>-fs` type-size hook and the form checks.
29
+ - **`@getvitops/create`:** the template redirects `/home` to `/`, and the guides use `vp exec` instead of `vpx`.
30
+
31
+ ### Patch Changes
32
+
33
+ - 0a47856: **Breaking: every block type is now `vitops_<name>`** (`vitops_cards`, `vitops_hero`, `vitops_actionLink`, …), where the Portable Text blocks were `vitops.<name>` and the section block type was already `vitops_section`. EmDash validates a block type's slug as `[a-z][a-z0-9_]*`, so `vitops.section` was never possible and the toolkit had two styles for one kind of thing. Underscore is the one form valid everywhere, so it is now the only one.
34
+
35
+ Migration: in stored content and in `seed/seed.json`, rename `_type` values from `vitops.<name>` to `vitops_<name>`; in `PortableText` `components`, rename the keys the same way; the `blocks: { include, exclude }` option takes full type names, so rename those too. `vt_*` names in a site are that site's own types and are unaffected. A search-and-replace of `vitops.(actionLink|imageCompare|copyButton|banner|details|carousel|gallery|cards|accordion|hero|marquee|logos|steps|stats|callout|entries|pricing|embed|form|reviews|nav|badges|widgetArea)` with `vitops_$1` covers it. `vitops emdash build` reports any block left on the old name as having no component.
36
+
37
+ - 0a47856: New package `@getvitops/skills`: agent skills for sites built with the toolchain (`custom-blocks`, `content-and-deploys`, `contact-form`, `csp`). The emdash template depends on it and runs `skills experimental_sync` on `prepare`, which links them into `.agents/skills/` and `.claude/skills/`, so they update with `vp update` instead of being copied at scaffold time. Existing sites: `vp add @getvitops/skills && vp add -D skills`, set `prepare` to `vp config && skills experimental_sync -y -a claude-code --no-frozen-lockfile`, gitignore the four linked names, and delete the site's own copies of those four skills (the sync never replaces a directory it did not create).
38
+
39
+ The emdash template's `AGENTS.md` is shortened to the always-relevant rules. Deploy and content mechanics (where each kind of data is the truth, prerendering, publish-triggered rebuilds, refreshing dev), the contact form, and the CSP move into those skills. Corrects the nesting guidance: a block type with a `portableText` field (such as `vitops_section`) holds other blocks, while `vitops_*` Portable Text blocks do not. Adds guidance for agents to record reusable toolkit gaps in `UPSTREAM.md`. The generated stylesheet is no longer described as always `tailwind.css`.
40
+
3
41
  ## 0.9.0-alpha.1
4
42
 
5
43
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/create",
3
- "version": "0.9.0-alpha.1",
3
+ "version": "0.9.0-alpha.2",
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.3.0-alpha.1"
22
+ "@getvitops/generator": "8.3.0-alpha.2"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=24"
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # Also runs on a `repository_dispatch` of type `emdash-publish`: pages are
12
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,
13
+ # and an EmDash `content:afterPublish` hook POSTs that event (.agents/skills/content-and-deploys,
14
14
  # "Publish-triggered rebuilds"). A dispatch runs this file from the DEFAULT branch
15
15
  # (`dev`), so `ref` pins the build to `main` — production's code, not dev's. It
16
16
  # still goes through the `production` environment, so it still waits for the
@@ -3,363 +3,179 @@ prerendered, the Worker serving the admin and API) styled by the Vitops design s
3
3
 
4
4
  ## Ground rules
5
5
 
6
- - `site.json` is the single source of truth — organization + site facts, and
7
- the design system (colors, type, spacing, patterns, animations) under
8
- `designSystem.themes.default`. Never edit `src/styles/tailwind.css` — it is
9
- generated from `site.json` by the `vitops()` integration on every dev/build
10
- and is gitignored. (There is no separate `design-system.json`: a scaffolded
11
- config can't merely reference a sibling file, so the token set lives inside
12
- `site.json` — see `AGENTS.md`'s "Client onboarding" section in the toolchain
13
- repo for why.)
14
- - `src/assets/placeholder/` holds neutral wireframe stand-ins (square,
15
- portrait, landscape; SVG + PNG) for layout work before real imagery
16
- exists. Delete the directory once real assets land — it's separate from
17
- `src/assets/logo.svg`, the actual brand asset.
18
- - Content lives in the EmDash database, not in the repo. Query it with
19
- `getEmDashCollection` / `getEmDashEntry` from `emdash`; render Portable Text
20
- with `<PortableText />` from `emdash/ui` (the Vitops blocks from
21
- `@getvitops/emdash` are merged in automatically).
22
- - **Building pages from patterns:** the editor's insert menu offers the `vitops.*` blocks —
23
- link/button, image compare, copy snippet, banner, disclosure, accordion, hero, carousel, gallery,
24
- cards, marquee (`vp exec vitops docs blocks` lists them with every field). Repeating content (cards, gallery images, accordion items, carousel slides) is
25
- a repeater field. A block cannot contain other blocks, and a `vitops.*` block inside EmDash's
26
- built-in `columns` does not render — for layout (a section, a split, a hero) use a `blocks`
27
- field, or hand-write the page template with `@getvitops/astro` components
28
- (`Subgrid`, `Cards`, `Carousel`, …). `vp exec vitops docs components` lists every pattern, which
29
- tiers provide it, and which block renders it. Override a block's rendering per site with
30
- `<PortableText components={{ type: { 'vitops.cards': MyCards } }} />`; hide one with
31
- `vitopsEmdash({ blocks: { exclude: [...] } })` in `astro.config.mjs`.
32
- - **Escape hatches on every block.** Each `vitops.*` block (and the section type) has an
33
- `anchor` (becomes the `id`, for `#links`), `className` (extra classes — with the tailwind format
34
- only ones the site already uses) and `style` (CSS custom properties only, e.g. `--p-card: 2rem`;
35
- anything else is dropped). `vitops_section` also has a `theme`: one pick for a tinted or solid
36
- backdrop with readable text. Set `--header-offset` to the sticky header's height so anchors
37
- clear it.
38
- - **`vitops audit`** needs optional packages and a browser, checked before it starts a server:
6
+ - `site.json` is the single source of truth: organization + site facts, and the design system
7
+ under `designSystem.themes.default`. Never edit the generated stylesheet
8
+ (`src/styles/tailwind.css` with the default `tailwind` format; `css.format` in
9
+ `astro.config.mjs` picks it). It is rebuilt from `site.json` on every dev/build and gitignored.
10
+ Only `src/styles/tailwind.css` and `tokens.json` are ignored: put your own CSS in a separate file
11
+ (for example `src/site.css`, imported from the layout) and commit it.
12
+ - Content lives in the EmDash database, not the repo. Read it with `getEmDashCollection` /
13
+ `getEmDashEntry` from `emdash`; render Portable Text with `<PortableText />` from `emdash/ui`
14
+ (the `vitops_*` blocks from `@getvitops/emdash` are merged in).
15
+ - **Where truth lives:** content (entries, media, menus) is **production's**, edited there; code,
16
+ `site.json` and schema are **git's** (`dev` → `main`). The dev database is a disposable copy
17
+ refreshed from prod, never promoted back. See `.agents/skills/content-and-deploys/SKILL.md`.
18
+ - `seed/seed.json` applies only to an empty database. To change a running site's content model, use
19
+ the admin (`/_emdash/admin`) or its MCP server (`/_emdash/api/mcp`).
20
+ - Hosting comes from `vitopsHosting()` in `astro.config.mjs` — never hard-code an adapter or
21
+ d1/r2/sqlite. Switch with `vitopsHosting({ target })` or the `HOSTING` env var.
22
+ - Keep the Worker cron trigger in `wrangler.jsonc`: it publishes content scheduled in the admin.
23
+ - `src/assets/placeholder/` is wireframe stand-in imagery; delete it once real assets land.
24
+ - Prefer framework classes (utilities, patterns, type roles like `font-heading`) over bespoke CSS.
25
+ `vp exec vitops agents` appends the full class reference to this file.
26
+ - `SETUP.html` is the onboarding checklist (`.agents/skills/setup/`). Tick a box only for a step you
27
+ completed, never for a human-only one (the Cloudflare API token, domain cutover, legal documents).
28
+
29
+ ## Building pages
30
+
31
+ - The editor's insert menu offers the `vitops_*` blocks. `vp exec vitops docs blocks` lists them
32
+ with every field; `vp exec vitops docs components` lists every pattern, which tiers provide it
33
+ and which block renders it.
34
+ - **Nesting.** A block type with a `portableText` field, such as `vitops_section`, holds other
35
+ blocks. A `vitops_*` block holds only scalar fields and repeaters (cards, gallery images,
36
+ accordion items, slides), so it can't. EmDash's built-in `columns` doesn't render `vitops_*`
37
+ children. For layout, nest in a section, use a `blocks` field, or hand-write the template with
38
+ `@getvitops/astro` components (`Subgrid`, `Cards`, `Carousel`, …).
39
+ - **Section, block or widget area?** Copy-and-edit content is an EmDash Section. The same content
40
+ everywhere (announcement bar, pre-footer call to action, sidebar, footer columns) is a **widget
41
+ area**: define it under Widgets in the admin and render it with `<VitopsWidgetArea name="…" />`
42
+ from `@getvitops/emdash/astro` in the layout, or in page content with the `vitops_widgetArea`
43
+ block. Page-specific content is blocks. An area can't hold another area, and prerendered pages
44
+ show an edit after the next deploy (like menus).
45
+ - **Escape hatches on every block:** `theme` (a backdrop from the site's colour roles, `surface` or
46
+ `brand-primary:solid`, with the readable text for it picked by the design system, so contrast is
47
+ not the editor's judgement), `anchor` (the `id`, for `#links`), `className` (extra classes; under
48
+ the tailwind format only ones the site already uses) and `style` (CSS custom properties only,
49
+ e.g. `--p-card: 2rem`; anything else is dropped). The same backdrop is available on a widget area
50
+ (`<VitopsWidgetArea theme="…">`) and on your own markup (`themeAttrs('surface')` from
51
+ `@getvitops/emdash/astro`, spread on the layout's `<body>`). How a role looks (flat, raised,
52
+ glass) is the site's own role styling in `site.json`. Set `--header-offset` to the sticky
53
+ header's height so anchors clear it.
54
+ - Override a block per site with `<PortableText components={{ type: { 'vitops_cards': MyCards } }} />`;
55
+ hide one with `vitopsEmdash({ blocks: { exclude: [...] } })`.
56
+ - **One `<h1>` per page.** The template's page routes render the title as an `<h1>`, and a hero's
57
+ heading level defaults to `h1`, so set a hero's level to `2` on those pages (or let the hero own
58
+ the `h1`). Use `p` for a display heading that is not part of the outline. `vitops emdash build`
59
+ warns on any page without exactly one `<h1>`.
60
+ - Labels: `badge` is static (status, count, category); `tag` may be editable or dismissable. Don't
61
+ invent `chip` or `pill`. The button class is `btn`.
62
+ - **Before adding a block type** or a `src/components/blocks/*` renderer, read
63
+ `.agents/skills/custom-blocks/SKILL.md`. Types can't be renamed or deleted once pages use them.
64
+
65
+ ## Layout foundations — the answer, not a starting point
66
+
67
+ | About to write… | Write instead |
68
+ | ------------------------------------------------------- | --------------- |
69
+ | a `wrap`/`wrapper`/`container`/`inner` class | **`centered`** |
70
+ | `max-width` + `margin-inline: auto` | **`centered`** |
71
+ | `padding-block` on a section | **`region`** |
72
+ | margins between headings/paragraphs/lists | **`rhythm`** |
73
+ | `display: grid` + `grid-template-columns: repeat(n, …)` | **`subgrid`** |
74
+ | `repeat(auto-fit, minmax(…))` | **`grid-auto`** |
75
+ | `display: flex` + `gap` for a row of controls | **`cluster`** |
76
+
77
+ `centered` is a grid of _named tracks_: a child widens itself with `breakout`/`spotlight`/
78
+ `fullbleed` without the parent knowing, which a bespoke `.wrap` can't do.
79
+
80
+ - **More than one card means `subgrid`**, so headings and CTAs align across the set:
81
+ `<ul class="subgrid subgrid-cols-3" role="list">` with `<li class="card subgrid-card">`, or
82
+ `<Subgrid />` (renders the slot verbatim; author the `<li>`s).
83
+ - **A card that is a link:** `stretched-link` inside it is zero-JS but kills text selection;
84
+ `<Cards>` keeps selection and falls back to the same link with no JS. Never both. Never
85
+ `<li><a class="card">`: the `li` is the grid item, so the tranches don't align.
86
+ - `vpr lint:design` reports all of these and the pre-commit hook fails on them. Fix the class, not
87
+ the CSS.
88
+
89
+ ## Rendering and the contact form
90
+
91
+ - Every public page is **prerendered**; the Worker handles `/_emdash/*` and `src/pages/api/track.ts`.
92
+ An admin publish goes live after a rebuild. Run `vpr prepare-local-db` before a first local
93
+ `vpr build`. Details, previews and server islands: `.agents/skills/content-and-deploys/SKILL.md`.
94
+ - The contact form posts to `/api/track`. Set `site.notifications.email` before launch or the route
95
+ refuses posts, and mirror form fields in `site.templates`. Details:
96
+ `.agents/skills/contact-form/SKILL.md`.
97
+ - `public/_headers` ships a Report-Only CSP. Read `.agents/skills/csp/SKILL.md` before enforcing it
98
+ or adding embeds.
99
+ - `vitops audit` needs optional packages and a browser, checked before it starts a server:
39
100
  `vp add -D playwright @axe-core/playwright lighthouse && vp exec playwright install chromium`.
40
101
  Its page list is the built sitemap, then the seed's menus.
41
- - **Section, block or widget area?** Copy-and-edit content is an EmDash Section. The same content
42
- everywhere (an announcement bar, a pre-footer call to action, a sidebar, footer columns) is a
43
- **widget area**: define it under Widgets in the admin, render it with
44
- `<VitopsWidgetArea name="…" />` from `@getvitops/emdash/astro` in the layout, or place it in
45
- page content with the `vitops.widgetArea` block. The layout renders `announcement` and
46
- `pre-footer`. Page-specific content is blocks. An area cannot hold another area, and prerendered
47
- pages show an edit after the next deploy (like menus).
48
- - `seed/seed.json` only applies against an empty database: structure
49
- (collections, menus, settings) on first boot; the `content` entries only
50
- when the setup wizard's "include sample content" option is chosen. Changing
51
- the seed does not migrate an existing site — use the admin
52
- (`/_emdash/admin`) or the site's MCP server (`/_emdash/api/mcp`) to change
53
- the content model of a running site.
54
- - Prefer framework CSS classes (utilities, patterns, type roles like
55
- `font-heading`) over bespoke CSS. Run `vp exec vitops agents` to append the
56
- full generated class-vocabulary reference to this file.
57
- - **Layout foundations — these are the answer, not a starting point.** Six
58
- substitutions carry the structure of nearly every page:
59
-
60
- | About to write… | Write instead |
61
- | ------------------------------------------------------- | --------------- |
62
- | a `wrap`/`wrapper`/`container`/`inner` class | **`centered`** |
63
- | `max-width` + `margin-inline: auto` | **`centered`** |
64
- | `padding-block` on a section | **`region`** |
65
- | margins between headings/paragraphs/lists | **`rhythm`** |
66
- | `display: grid` + `grid-template-columns: repeat(n, …)` | **`subgrid`** |
67
- | `repeat(auto-fit, minmax(…))` | **`grid-auto`** |
68
- | `display: flex` + `gap` for a row of controls | **`cluster`** |
69
-
70
- `centered` is a grid of _named tracks_, not a max-width box — a child widens
71
- itself with `breakout`/`spotlight`/`fullbleed` without the parent knowing, which
72
- is the thing a bespoke `.wrap` can never do. **More than one card means
73
- `subgrid`**: a plain grid aligns the outer boxes but not the tranches inside
74
- them, so headings and CTAs land at different heights across the set. Write
75
- `<ul class="subgrid subgrid-cols-3" role="list">` with
76
- `<li class="card subgrid-card">` items, or `<Subgrid />` (which renders the slot
77
- verbatim — author the `<li>`s yourself; it adds `role="list"` for you, and a
78
- hand-written one needs it because a marker-less `<ul>` stops being announced as a
79
- list in Safari).
80
-
81
- **When the whole card is a link the card is still the item**, and pick by whether
82
- the card's text must stay selectable: `stretched-link` on a link inside it is
83
- zero-JS but kills text selection, while `<Cards>` (which wraps the list in
84
- `<wc-cards>`) keeps selection and falls back to that same link with no JS. Never
85
- both — the overlay wins and the JS never runs. Never `<li><a class="card">`: the
86
- `li` is the grid item, so the anchor's tranches never align and it does not fill
87
- the cell; it renders fine, which is why it keeps getting written.
88
-
89
- `vpr lint:design` reports all of these, and the pre-commit hook fails on them
90
- — the fix is the class, not a justification for the CSS.
91
-
92
- - **Rendering and freshness: everything is prerendered.** `output: 'server'` is
93
- still required (the admin, media/API routes and scheduled publishing need a
94
- Worker), but every public page — `index.astro`, `[...slug].astro`, `404.astro` —
95
- has `export const prerender = true`, and `[...slug].astro` lists the published
96
- `pages` entries in `getStaticPaths()`. The HTML is written at build time and
97
- served as a static asset; the Worker only handles `/_emdash/*` and anything
98
- you deliberately leave on-demand. A route with no `prerender` export is
99
- re-rendered on every request, so don't leave it off by accident.
100
- - **Where build-time content comes from.** The Cloudflare adapter prerenders
101
- against a _local_ D1 (`.wrangler/state`), never the deployed one — on a CI
102
- runner that is an empty database, which would bake "No home page yet" into
103
- every page. `scripts/pull-content.mjs` (the first step of `vpr deploy` and
104
- `vpr deploy:dev`) exports production's D1 and loads it locally first. It
105
- fails closed: a failed export aborts the deploy rather than publishing an
106
- empty site (the one exception is a database that doesn't exist yet, the first
107
- deploy, which builds from the seed). `SKIP_PULL_CONTENT=1` skips it.
108
- - **The local database must be migrated before the build.** The template runs
109
- `migrations: { runtime: 'check' }`, so a build against a local database with
110
- pending migrations prerenders EmDash's one-line "Database migrations are
111
- required" response into every page — and still succeeds. That is what a fresh
112
- runner, a first deploy, a `SKIP_PULL_CONTENT=1` build and an **upgrade** deploy
113
- (the pull restores production's older schema, and `emdash migrate` only runs
114
- after the build) would all publish. `vitops emdash prepare-db` (`vpr prepare-local-db`, run after the
115
- pull by `vpr deploy`, `vpr deploy:dev` and the reusable workflow) starts `astro
116
- dev` once, calls EmDash's `dev-bypass` setup (which migrates and applies the
117
- seed's sample content) and stops it; `vitops emdash check-prerender` (part of
118
- `vpr build`) then fails the build if any prerendered page is not a real HTML page
119
- with a title. Run `vpr prepare-local-db` by hand before a first local `vpr build`.
120
- It only writes the disposable `.wrangler/state`; the dev administrator it creates
121
- is never deployed.
122
- - **Freshness is a rebuild.** Production is the truth for content, and a page
123
- reflects it as of the last build. Publishing in the admin therefore has to
124
- trigger one: the deploy workflows listen for a `repository_dispatch` event of
125
- type `emdash-publish` (see "Publish-triggered rebuilds" below).
126
- - **Drafts and previews.** A prerendered page cannot show an unpublished
127
- draft, so an admin "Preview" link shows the last published build. If previews
128
- matter, add a dedicated on-demand route for them rather than removing
129
- `prerender` from the public ones.
130
- - **Dynamic parts are server islands.** Anything that must be fresh per request
131
- goes in its own component rendered with `server:defer` inside the prerendered
132
- page; the surrounding HTML stays static. Keep islands rare — each is a Worker
133
- request after load.
134
- - **Unknown URLs.** There is no `Astro.rewrite('/404')`: a slug not listed by
135
- `getStaticPaths()` has no route, so Astro serves the prerendered `404.html`
136
- with a real 404 status. Rewriting an on-demand route to a prerendered one
137
- fails (500), so don't reintroduce it.
138
- - **The contact form is the one on-demand route.** The `vitops.form` block posts a plain
139
- `<form method="post">` to `src/pages/api/track.ts` (`prerender = false`, built with
140
- `createConversionRoute()`), which also answers the conversion beacons. The block works with
141
- no JavaScript: the route answers a form post with a 303 back to the page plus `?sent=1#form-sent`
142
- or `?error=1#form-error`, and the block's two notices are revealed by that fragment. Keep one
143
- form per page. Where submissions go: `site.notifications.email` (or the primary location's
144
- `email`) and `site.crm` when set. **With neither, the route refuses the post** rather than
145
- accept a message nobody receives, so set `site.notifications.email` before launch and run
146
- `vitops email setup` for its sender domain. A failed CRM write never fails the request; it is
147
- stated in the notification email, which is the durability floor.
148
- - **Spam protection**: a honeypot field, the route's origin / size / per-IP rate checks, and
149
- Cloudflare Turnstile once `vitops setup turnstile` has put the public key in
150
- `site.security.turnstile.siteKey` and saved the Worker secret `VITOPS_TURNSTILE_SECRET`
151
- (never in `site.json`). With a key set the route fails closed if the secret is missing.
152
- `TURNSTILE_REQUIRED` in the route (default `true`) makes a token-less post a refusal, which
153
- means the form needs JavaScript; set it `false` to keep a no-JS submit working and accept
154
- that a bot can then skip the token. Turnstile is category `necessary` (it protects the form
155
- being sent), loads only when a visitor first uses the form, and its origin is allowed in the
156
- Report-Only CSP in `public/_headers`.
157
- - **Privacy policy inventory.** `vitops legal` derives "what personal information we collect"
158
- from `site.templates` entries of type `form`, not from the editor's block, so a form added in
159
- the admin is invisible to it. When you add or change form fields, mirror them in a
160
- `site.templates` form entry in `site.json`, or the generated policy under-reports.
161
- - A field named `phone` submits as `tel` (the route reserves `phone` and `event`).
162
- - The per-IP rate limit is per Worker isolate; add a KV-backed `RateLimitStore` if you need a
163
- fleet-wide one.
164
- - The Worker cron trigger in `wrangler.jsonc` drives scheduled publishing —
165
- don't remove it.
166
- - Hosting (adapter + EmDash database/storage) comes from `vitopsHosting()`
167
- (`@getvitops/emdash`) in astro.config.mjs — never hard-code an adapter or
168
- d1/r2/sqlite there. Switch targets via `vitopsHosting({ target })` or the
169
- `HOSTING` env var (see README "Hosting"); the `node` target needs
170
- `@astrojs/node` + `better-sqlite3` installed first.
171
- - **`SETUP.html` is the client-onboarding checklist.** When working it via the
172
- `.agents/skills/setup/` skill, tick a box only for a step you actually
173
- completed — never on the operator's behalf for a step marked human-only
174
- (the Cloudflare API token, the domain cutover, publishing legal documents).
175
102
 
176
103
  ## Branch model
177
104
 
178
- - Work on **`dev`** (the integration branch). Every push auto-deploys the
179
- isolated `-dev` worker (own D1/R2; `dev.<domain>` with a custom domain).
180
- - **`main`** is production and branch-protected — never push or merge to it
181
- directly. Production ships only via the manual **promote** flow: the
182
- `promote` skill (`.agents/skills/promote/SKILL.md`) dispatches
183
- `.github/workflows/promote.yml`, which opens a `dev → main` PR with
184
- auto-merge; merging deploys prod. Only run it when the user explicitly asks
185
- to promote/ship to production.
186
- - Sample legal pages (`/terms`, `/privacy`) ship as seeded placeholder content
187
- — remind the user to replace them before launch. They are deliberately short
188
- and obviously unfinished; do not "improve" them into something that reads as
189
- a finished policy, because the risk here is a plausible-looking document
190
- getting shipped unread.
191
- To replace them properly, fill in `site.json`'s `organization`/`site.legal`
192
- fields and run `vp exec vitops legal --format portable-text --out ./legal`, then
193
- paste each document's blocks into the page in the admin. That renders a real
194
- policy from what the site actually does — the analytics provider it uses,
195
- the fields its forms collect, where it deploys — rather than from guesses.
196
- It still needs review before launch.
197
-
198
- ## EmDash 1.x behaviours worth knowing
199
-
200
- - **Passkeys are bound to the hostname they were created on.** After the
201
- workers.dev → custom-domain cutover, use **Settings → General → Change
202
- domain**, then **Continue on** to add a passkey at the new address.
203
- - **HTML blocks created in the admin run JavaScript** in a sandboxed iframe
204
- (Contributor and up can add it). Astro's `security.csp` blocks their styles
205
- and scripts, so don't turn CSP on without allowing for that.
206
- - **Link entries by `entry.data.slug`, never `entry.id`** — on multilingual
207
- sites `id` carries a locale prefix.
208
- - **Submit `/sitemap.xml`** to search engines, not a per-collection sitemap;
209
- collection sitemaps now split into pages of 2,000 entries.
210
- - **A stuck D1 migration lock** is cleared with
211
- `vp exec emdash migrate --take-over-lock <id>` (confirms the target, or
212
- needs `--expected-target-fingerprint` non-interactively).
213
-
214
- ## Docs MCP servers
215
-
216
- `.mcp.json` preconfigures the Astro docs and EmDash docs MCP servers — prefer
217
- searching them over pre-trained knowledge for Astro/EmDash questions.
218
-
219
- ## Labels: badge and tag
220
-
221
- Two label patterns, both enabled in `site.json`'s `patterns.items`: **`badge`** is a
222
- static label (a status, count or category) and **`tag`** is a label that may be
223
- static _or_ dynamic (editable, dismissable, in a `tag-list`). Don't invent a third
224
- name — `chip` and `pill` are not part of this vocabulary. The button pattern's class
225
- is `btn` (`<a class="btn">` is styled, not just a bare `<button>`).
226
-
227
- ## Custom blocks
228
-
229
- Before adding a block type, a `src/components/blocks/*` renderer, or anything an
230
- editor places on a page, read `.agents/skills/custom-blocks/SKILL.md`: compose the
231
- existing `vitops.*` blocks first, extend one second, go site-local third, upstream
232
- last. Block types can't be renamed or deleted once pages use them, so check the
233
- framework's pattern names before choosing one.
234
-
235
- ## Publish-triggered rebuilds
236
-
237
- Pages are static, so a publish in the admin goes live only after a rebuild.
238
- `.github/workflows/deploy-prod.yml` also runs on a `repository_dispatch` event of
239
- type `emdash-publish` (it pins `ref` to `main`, because a dispatch runs from the
240
- default branch, `dev`). Wire an EmDash plugin hook to send it:
241
-
242
- ```jsonc
243
- // emdash-plugin.jsonc
244
- "capabilities": ["content:read", "network:request"],
245
- "allowedHosts": ["api.github.com"]
246
- ```
247
-
248
- ```ts
249
- // src/plugin.ts
250
- hooks: {
251
- 'content:afterPublish': async (_event, ctx) => {
252
- const token = await ctx.settings.get<string>('githubToken'); // an encrypted plugin setting
253
- if (!token || !ctx.http) return;
254
- await ctx.http.fetch(`https://api.github.com/repos/${OWNER}/${REPO}/dispatches`, {
255
- method: 'POST',
256
- headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json' },
257
- body: JSON.stringify({ event_type: 'emdash-publish' }),
258
- });
259
- },
260
- }
261
- ```
262
-
263
- The token is a fine-grained GitHub token with `Contents: write` on this repo only,
264
- stored as the plugin's encrypted setting (a sandboxed plugin can't read Worker
265
- environment variables), never in the repo. The hook name, capabilities and
266
- `ctx.http` / `ctx.settings` calls follow EmDash's plugin reference but this exact
267
- plugin has not been run against a live site, so treat it as a starting point. Two
268
- costs to know: the run goes through the `production` environment, so it **waits for
269
- the reviewers' approval** like any prod deploy (relax that rule if editors' publishes
270
- should go live unattended), and bursts of publishes queue behind the
271
- `deploy-prod` concurrency group rather than cancelling each other. Without the hook,
272
- a manual run of **Deploy prod** (or a push to `main`) does the same job.
273
-
274
- ## Content Security Policy
275
-
276
- `public/_headers` ships a **`Content-Security-Policy-Report-Only`** header: it logs
277
- violations to the browser console and blocks nothing, so it can't break a page. Its
278
- `script-src` is `'self'` plus the SHA-256 of each inline script `<Head />` emits (the
279
- colour-scheme bootstrap and the consent-gate stub) — no `'unsafe-inline'`. Hashes,
280
- not nonces, because prerendered pages have no per-request response to put a nonce in.
281
- `contentSecurityPolicy()` and `inlineScriptHashes()` from `@getvitops/astro` produce
282
- the same strings (`templates.test.ts` in the toolchain keeps the file in step).
283
-
284
- Know what it does not cover before you flip it to enforcing (rename the header to
285
- `Content-Security-Policy`):
286
-
287
- - **`_headers` applies to static assets only.** Worker-rendered responses — the EmDash
288
- admin (`/_emdash/*`), its API, anything left on-demand — don't get it. Setting a
289
- header there means doing it in code (e.g. Astro middleware calling
290
- `contentSecurityPolicy()`), and the admin's own inline scripts and editor HTML
291
- would need allowing; leave the admin out of an enforced policy.
292
- - **Editor-authored HTML blocks are the hard limit.** Any block that lets an editor
293
- paste raw HTML or a `<script>` (an embed, a tracking snippet) puts arbitrary inline
294
- script on the page, which a hash policy cannot name ahead of time. Enforcing a strict
295
- policy means removing such blocks or moving embeds to a consent-gated
296
- `data-consent` tag whose origin you allow explicitly.
297
- - **Third-party tags** the consent gate activates need their origins in `script-src`
298
- / `connect-src`; anything with `frame` needs `frame-src`.
299
- - **A server island** (`server:defer`) adds an inline loader script and a request to
300
- the Worker; expect a report for the loader until you hash it.
301
-
302
- Run the site, browse the pages you ship with the console open, fix or allow what is
303
- reported, and only then enforce.
304
-
305
- ## EmDash agent skills
306
-
307
- The EmDash skills (`building-emdash-site`, `creating-plugins`, `emdash-cli`,
308
- `upgrading-emdash` and the two WordPress porting skills) are installed per
309
- project, not globally, so they match the `emdash` version this repo pins. If
310
- `.agents/skills/building-emdash-site/` is missing, install them (the setup guide
311
- and `SETUP.html` have the full step):
312
-
313
- ```bash
314
- vp dlx skills add emdash-cms/skills --skill '*' -y
315
- ```
316
-
317
- Refresh them with `vp dlx skills update` after bumping `emdash` — the
318
- `upgrading-emdash` skill covers the rest of the upgrade.
105
+ - Work on **`dev`**; every push auto-deploys the isolated `-dev` worker.
106
+ - **`main`** is production and protected: never push or merge to it. It ships only through the
107
+ `promote` skill (`.agents/skills/promote/`), and only when the user explicitly asks.
108
+ - Sample legal pages (`/terms`, `/privacy`) are deliberately short, unfinished placeholders; don't
109
+ "improve" them into something that reads as a finished policy. To replace them, fill in
110
+ `site.json`'s `organization`/`site.legal`, run
111
+ `vp exec vitops legal --format portable-text --out ./legal`, and paste each document into the page
112
+ in the admin. It still needs review before launch.
113
+
114
+ ## EmDash 1.x behaviours
115
+
116
+ - Passkeys are bound to the hostname they were created on. After a domain cutover use **Settings →
117
+ General → Change domain**, then **Continue on**.
118
+ - Link entries by `entry.data.slug`, never `entry.id` (multilingual sites prefix `id` with a locale).
119
+ - Submit `/sitemap.xml` to search engines, not a per-collection sitemap.
120
+ - Clear a stuck D1 migration lock with `vp exec emdash migrate --take-over-lock <id>` (needs
121
+ `--expected-target-fingerprint` non-interactively).
122
+
123
+ ## Improving the toolkit
124
+
125
+ You are also a source of feedback for `@getvitops/*`. When you struggle with a framework built-in,
126
+ write a workaround, add bespoke CSS or a site-local block, or find a doc wrong or missing, and the
127
+ need is **not** unique to this site, record it in `UPSTREAM.md` at the repo root (create it if
128
+ absent). One entry each, with: what you tried, what failed or was missing, the workaround (file
129
+ path), the change you'd propose, and the package (`@getvitops/emdash`, `astro`, `core`, `cli`,
130
+ `create`, or docs). Mention new entries in your end-of-task summary. Don't log choices that are
131
+ specific to this site, and never patch `node_modules`.
132
+
133
+ ## Docs and skills
134
+
135
+ - `.mcp.json` preconfigures the Astro and EmDash docs MCP servers; search them before relying on
136
+ pre-trained knowledge.
137
+ - **Skills.** `@getvitops/skills` ships `custom-blocks`, `content-and-deploys`, `contact-form` and
138
+ `csp`. `skills experimental_sync` (run by `prepare`, so by `vp install`) links them into
139
+ `.agents/skills/` and `.claude/skills/`; they are gitignored and update with `vp update`. A skill
140
+ you write yourself with the same name is never replaced. `promote` and `setup` are this site's own.
141
+ - The EmDash skills (`building-emdash-site`, `creating-plugins`, `emdash-cli`, `upgrading-emdash`,
142
+ two WordPress porting skills) are installed per project to match the pinned `emdash`. If
143
+ `.agents/skills/building-emdash-site/` is missing: `vp dlx skills add emdash-cms/skills --skill '*' -y`.
144
+ Refresh with `vp dlx skills update` after bumping `emdash`.
319
145
 
320
146
  ## Commands
321
147
 
322
- This is a **Vite+** project: use `vp` (and its `vpr` shorthand), not `pnpm`/`npm`/`npx`.
323
- The package manager underneath is pnpm, but `vp` picks it up for you.
148
+ This is a **Vite+** project: use `vp` (and `vpr`), not `pnpm`/`npm`/`npx`. Scripts that wrap Astro
149
+ go through `vpr` (`vpr dev`, `vpr build`, `vpr preview`), because `vp dev`/`vp build` skip Astro.
150
+ `vpr dev` returns immediately (the server runs in the background): check it with
151
+ `vp exec astro dev status`, read `vp exec astro dev logs --follow`, stop with
152
+ `vp exec astro dev stop`; the site is at <http://localhost:4321>.
324
153
 
325
154
  | To… | Run |
326
155
  | ---------------------------------------- | ------------------------------------------------------- |
327
156
  | install dependencies | `vp install` |
328
157
  | add / remove / update a package | `vp add <pkg>` · `vp remove <pkg>` · `vp update` |
329
- | run a `package.json` script | `vpr <script>` (alias for `vp run <script>`) |
158
+ | run a `package.json` script | `vpr <script>` |
330
159
  | run a locally installed binary | `vp exec <bin>` (e.g. `vp exec vitops docs components`) |
331
160
  | run a one-off package without installing | `vp dlx <pkg>` |
332
161
  | format + lint + typecheck (files) | `vp check` |
333
162
  | typecheck Astro (`.astro` files) | `vpr typecheck` |
334
-
335
- **Scripts that wrap Astro must go through `vpr`.** `vp dev` / `vp build` run Vite
336
- directly and skip Astro, so use `vpr dev`, `vpr build`, `vpr preview`.
337
-
338
- **`vpr dev` returns immediately.** `astro dev` now starts the server in the
339
- background. Check it with `vp exec astro dev status`, read its output with
340
- `vp exec astro dev logs --follow`, and stop it with `vp exec astro dev stop`; the
341
- site is at <http://localhost:4321>. Don't wait on it as if it were a foreground
342
- process.
163
+ | design-lint the markup | `vpr lint:design` |
343
164
 
344
165
  | Script | What it does |
345
166
  | ----------------------------------- | ------------------------------------------------------------------------------------------- |
346
167
  | `vpr dev` | local dev with local D1/R2 emulation (no Cloudflare account) |
347
168
  | `vpr deploy` | pull production content, build, `wrangler deploy` prod (provisions D1/R2 on first run) |
348
169
  | `vpr deploy:dev` | same, for the isolated dev worker (needs `DEV_D1_ID`) |
349
- | `vpr pull-content` | just the content pull (see "Rendering and freshness") |
170
+ | `vpr pull-content` | just the content pull |
350
171
  | `vpr prepare-local-db` | migrate and seed the local D1 the build prerenders from |
351
172
  | `vpr init:github` | one-time: create + push the GitHub repo (main + dev, dev default). Only when the user asks. |
352
173
  | `vpr init:hosting` | one-time: provision hosting (dev D1/R2 + repo secrets). Only when the user asks. |
353
174
  | `vp exec vitops validate site.json` | schema-check the site config |
354
175
 
355
- **Release flow.** A site is not a published package, so there is no changeset or
356
- version bump: ship by committing to `dev` (auto-deploys the dev worker) and then
357
- running the **promote** workflow. `vp` has no `vp change` / `vp pm change` command
358
- (checked against vp 1.5.0); for a _package_ it does have `vp pm version`,
359
- `vp pm publish` and `vp pm stage`.
176
+ Shipping is not a package release: no changeset or version bump. Commit to `dev`, then promote.
177
+ (`vp` has no `vp change` command.)
360
178
 
361
- **Naming the project.** `vp create` takes the package name from the target
362
- directory and invents a random slug when none is given, so pass one:
363
- `vp create @getvitops:emdash --directory <client-slug>`. `wrangler.jsonc`'s
364
- `name`, D1 `database_name` and R2 `bucket_name` are separate and still say
365
- `my-emdash-site` — rename them to match before the first deploy.
179
+ `vp create` names the package after the target directory (pass `--directory <client-slug>`).
180
+ `wrangler.jsonc`'s `name`, D1 `database_name` and R2 `bucket_name` are separate and still say
181
+ `my-emdash-site`; rename them to match before the first deploy.
@@ -51,7 +51,7 @@ get the prerendered `src/pages/404.astro` with a real 404 status.
51
51
 
52
52
  **Pages are prerendered.** `vpr build` writes static HTML for every published
53
53
  `pages` entry, so an edit in the admin goes live on the next rebuild rather than
54
- per request — see AGENTS.md "Rendering and freshness" for how production content
54
+ per request — see `.agents/skills/content-and-deploys` for how production content
55
55
  reaches the build (`scripts/pull-content.mjs`) and for the publish-triggered rebuild.
56
56
  In `vpr dev` the pages read your local database, so edits appear straight away.
57
57
 
@@ -189,7 +189,7 @@ vpr build:css # regenerate the CSS manually
189
189
  | `src/pages/index.astro` | Home — prerendered from the `home` page entry |
190
190
  | `src/pages/[...slug].astro` | Prerendered once per published `pages` entry (`getStaticPaths`) |
191
191
  | `src/pages/404.astro` | Prerendered not-found page, served with a 404 status for any unmatched URL |
192
- | `public/_headers` | Report-Only CSP for the static pages (see AGENTS.md "Content Security Policy") |
192
+ | `public/_headers` | Report-Only CSP for the static pages (see `.agents/skills/csp`) |
193
193
  | `scripts/pull-content.mjs` | Loads production's D1 into the local build DB before the prerender (`deploy`/`deploy:dev`) |
194
194
  | `wrangler.jsonc` | Cloudflare bindings (D1/R2) + cron trigger |
195
195
  | `scripts/build-dev-wrangler.mjs` | Rewrites the built wrangler config for the isolated dev worker |
@@ -18,8 +18,9 @@ worker-configuration.d.ts
18
18
  # node hosting target: SQLite db + local uploads
19
19
  data/
20
20
 
21
- # generated by the vitops() integration on dev/build
22
- src/styles/
21
+ # generated by the vitops() integration on dev/build (site CSS goes in src/site.css, not here)
22
+ src/styles/tailwind.css
23
+ src/styles/tokens.json
23
24
  public/vitops/
24
25
 
25
26
  # generated by `pnpm run sop` — styles SETUP.html for a file:// open
@@ -32,3 +33,13 @@ public/vitops/
32
33
 
33
34
  # os / editor
34
35
  .DS_Store
36
+
37
+ # agent skills linked from @getvitops/skills by `skills experimental_sync` (see skills-lock.json)
38
+ .agents/skills/custom-blocks
39
+ .agents/skills/content-and-deploys
40
+ .agents/skills/contact-form
41
+ .agents/skills/csp
42
+ .claude/skills/custom-blocks
43
+ .claude/skills/content-and-deploys
44
+ .claude/skills/contact-form
45
+ .claude/skills/csp
@@ -35,6 +35,8 @@ const brandColor = colors.palette?.[roleHue('brand-primary')]?.seed ?? '#2e9b73'
35
35
  export default defineConfig({
36
36
  output: 'server',
37
37
  adapter,
38
+ // The seed's `pages` collection serves entries at `/{slug}` (urlPattern), and `home` lives at `/`.
39
+ redirects: { '/home': '/' },
38
40
  image: {
39
41
  layout: 'constrained',
40
42
  responsiveStyles: true,
@@ -0,0 +1,3 @@
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by Vitest.
3
+ # For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1,3 @@
1
+ {
2
+ "lockfileHash": "4b2ac6bd"
3
+ }
@@ -5,12 +5,12 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "astro dev",
8
- "build": "astro build && vitops emdash check-prerender",
8
+ "build": "vitops emdash build",
9
9
  "preview": "astro preview",
10
10
  "pull-content": "node scripts/pull-content.mjs",
11
11
  "prepare-local-db": "vitops emdash prepare-db",
12
- "deploy": "vitops email check && node scripts/pull-content.mjs && vitops emdash prepare-db && astro build && vitops emdash check-prerender && emdash migrate --wrangler-config wrangler.jsonc && wrangler deploy",
13
- "deploy:dev": "vitops email check && node scripts/pull-content.mjs && vitops emdash prepare-db && astro build && vitops emdash check-prerender && node scripts/build-dev-wrangler.mjs && emdash migrate --wrangler-config dist/server/wrangler.dev.json && wrangler deploy --config dist/server/wrangler.dev.json",
12
+ "deploy": "vitops email check && node scripts/pull-content.mjs && vitops emdash prepare-db && vitops emdash build && emdash migrate --wrangler-config wrangler.jsonc && wrangler deploy",
13
+ "deploy:dev": "vitops email check && node scripts/pull-content.mjs && vitops emdash prepare-db && vitops emdash build && node scripts/build-dev-wrangler.mjs && emdash migrate --wrangler-config dist/server/wrangler.dev.json && wrangler deploy --config dist/server/wrangler.dev.json",
14
14
  "init:github": "vitops setup repo",
15
15
  "init:hosting": "vitops setup hosting",
16
16
  "setup": "vitops setup all",
@@ -18,7 +18,7 @@
18
18
  "build:css": "vitops generate -i site.json -f tailwind -o src/styles",
19
19
  "lint:design": "vitops lint -i site.json -f tailwind --strict",
20
20
  "sop": "vitops generate -i site.json -f css -o .vitops/sop",
21
- "prepare": "vp config"
21
+ "prepare": "vp config && skills experimental_sync -y -a claude-code --no-frozen-lockfile"
22
22
  },
23
23
  "dependencies": {
24
24
  "@astrojs/cloudflare": "^14.0.0",
@@ -27,6 +27,7 @@
27
27
  "@getvitops/astro": "latest",
28
28
  "@getvitops/core": "latest",
29
29
  "@getvitops/emdash": "latest",
30
+ "@getvitops/skills": "latest",
30
31
  "@tailwindcss/vite": "^4.3.3",
31
32
  "astro": "^7.4.4",
32
33
  "emdash": "latest",
@@ -39,6 +40,7 @@
39
40
  "@cloudflare/workers-types": "^4.20260305.1",
40
41
  "@getvitops/cli": "latest",
41
42
  "@iconify-json/fa7-solid": "^1.2.5",
43
+ "skills": "^1.7.6",
42
44
  "typescript": "^6.0.3",
43
45
  "vite": "latest",
44
46
  "vite-plus": "latest",
@@ -1,7 +1,7 @@
1
1
  # Cloudflare static-assets response headers. They apply to files served from the
2
2
  # static asset store — the prerendered pages — and NOT to responses the Worker
3
3
  # renders itself (the EmDash admin at /_emdash/*, its API, anything left
4
- # on-demand). See AGENTS.md "Content Security Policy".
4
+ # on-demand). See .agents/skills/csp.
5
5
  #
6
6
  # REPORT-ONLY on purpose: violations are logged to the browser console and
7
7
  # nothing is blocked, so this cannot break a page. Read the console on the
@@ -6,7 +6,7 @@
6
6
  // `astro build`. The Cloudflare adapter prerenders inside a local workerd with a
7
7
  // LOCAL D1 (`.wrangler/state`), never the deployed database — so on a CI runner
8
8
  // the build would see an empty, auto-seeded database and bake "No home page yet"
9
- // into every page. Production is the truth for content (see AGENTS.md), so this
9
+ // into every page. Production is the truth for content (see .agents/skills/content-and-deploys), so this
10
10
  // copies production's D1 into the local one first.
11
11
  //
12
12
  // wrangler d1 export <db> --remote → .vitops/content.sql
@@ -15,6 +15,7 @@
15
15
  "slug": "pages",
16
16
  "label": "Pages",
17
17
  "labelSingular": "Page",
18
+ "urlPattern": "/{slug}",
18
19
  "supports": ["drafts", "revisions", "seo"],
19
20
  "fields": [
20
21
  {
@@ -112,7 +113,7 @@
112
113
  ]
113
114
  },
114
115
  {
115
- "_type": "vitops.actionLink",
116
+ "_type": "vitops_actionLink",
116
117
  "_key": "pf-cta",
117
118
  "label": "Contact us",
118
119
  "href": "/contact",
@@ -161,10 +162,11 @@
161
162
  ]
162
163
  },
163
164
  {
164
- "_type": "vitops.hero",
165
+ "_type": "vitops_hero",
165
166
  "_key": "demo-hero",
166
167
  "eyebrow": "Vitops blocks",
167
168
  "heading": "Build pages from design-system patterns",
169
+ "level": "2",
168
170
  "body": "Every block below renders a framework pattern, so it follows your tokens and works without JavaScript.",
169
171
  "primaryLabel": "Open the admin",
170
172
  "primaryHref": "/_emdash/admin",
@@ -173,7 +175,7 @@
173
175
  "layout": "centered"
174
176
  },
175
177
  {
176
- "_type": "vitops.cards",
178
+ "_type": "vitops_cards",
177
179
  "_key": "demo-cards",
178
180
  "columns": "3",
179
181
  "cards": [
@@ -197,7 +199,7 @@
197
199
  ]
198
200
  },
199
201
  {
200
- "_type": "vitops.accordion",
202
+ "_type": "vitops_accordion",
201
203
  "_key": "demo-accordion",
202
204
  "exclusive": true,
203
205
  "items": [
@@ -215,7 +217,7 @@
215
217
  ]
216
218
  },
217
219
  {
218
- "_type": "vitops.marquee",
220
+ "_type": "vitops_marquee",
219
221
  "_key": "demo-marquee",
220
222
  "pauseOnHover": true,
221
223
  "reverse": false,
@@ -239,14 +241,14 @@
239
241
  ]
240
242
  },
241
243
  {
242
- "_type": "vitops.banner",
244
+ "_type": "vitops_banner",
243
245
  "_key": "welcome-banner",
244
246
  "message": "This banner is a Vitops block — dismiss it, then edit this page in the EmDash admin.",
245
247
  "tone": "info",
246
248
  "dismissible": true
247
249
  },
248
250
  {
249
- "_type": "vitops.details",
251
+ "_type": "vitops_details",
250
252
  "_key": "next-steps",
251
253
  "summary": "Next steps",
252
254
  "body": "Adjust site.json (under designSystem.themes.default) to your brand (colors, type, patterns), replace src/assets/logo.svg, then deploy with `pnpm deploy`.",
@@ -287,7 +289,7 @@
287
289
  "title": "Terms & Conditions",
288
290
  "content": [
289
291
  {
290
- "_type": "vitops.banner",
292
+ "_type": "vitops_banner",
291
293
  "_key": "terms-placeholder",
292
294
  "message": "Placeholder text — replace with your own terms before launch. This is not legal advice.",
293
295
  "tone": "warning",
@@ -388,7 +390,7 @@
388
390
  "title": "Privacy Policy",
389
391
  "content": [
390
392
  {
391
- "_type": "vitops.banner",
393
+ "_type": "vitops_banner",
392
394
  "_key": "privacy-placeholder",
393
395
  "message": "Placeholder text — replace with your own privacy policy before launch. This is not legal advice.",
394
396
  "tone": "warning",
@@ -503,7 +505,7 @@
503
505
  "title": "Contact",
504
506
  "content": [
505
507
  {
506
- "_type": "vitops.banner",
508
+ "_type": "vitops_banner",
507
509
  "_key": "contact-placeholder",
508
510
  "message": "Placeholder text — replace with your contact details before launch.",
509
511
  "tone": "warning",
@@ -524,7 +526,7 @@
524
526
  ]
525
527
  },
526
528
  {
527
- "_type": "vitops.form",
529
+ "_type": "vitops_form",
528
530
  "_key": "contact-form",
529
531
  "heading": "Send us a message",
530
532
  "level": "2",
@@ -568,7 +570,7 @@
568
570
  "title": "Cookie Notice",
569
571
  "content": [
570
572
  {
571
- "_type": "vitops.banner",
573
+ "_type": "vitops_banner",
572
574
  "_key": "cookies-placeholder",
573
575
  "message": "Placeholder text — replace with your own cookie notice before launch. This is not legal advice. `vitops legal` can generate a draft from site.json.",
574
576
  "tone": "warning",
@@ -309,6 +309,17 @@
309
309
  },
310
310
  "site": {
311
311
  "defaultLocale": "en",
312
+ "templates": {
313
+ "contact": {
314
+ "type": "form",
315
+ "fields": [
316
+ { "name": "name", "type": "text", "required": true },
317
+ { "name": "email", "type": "email", "required": true },
318
+ { "name": "tel", "type": "tel" },
319
+ { "name": "message", "type": "textarea", "required": true }
320
+ ]
321
+ }
322
+ },
312
323
  "locales": {
313
324
  "en": {
314
325
  "name": "English"
@@ -8,7 +8,7 @@ import Layout from '../layouts/Layout.astro';
8
8
  // the content database — `scripts/pull-content.mjs` loads production's into the
9
9
  // local build database first (it is part of `deploy`/`deploy:dev`) — so an edit
10
10
  // in the admin goes live on the next rebuild, which a publish webhook triggers
11
- // (see AGENTS.md "Rendering and freshness").
11
+ // (see .agents/skills/content-and-deploys).
12
12
  //
13
13
  // There is no `Astro.rewrite('/404')` here and no on-demand fallback: a URL that
14
14
  // isn't in the list below simply has no route, so Astro serves the prerendered
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The site's one on-demand endpoint: contact-form posts (the `vitops.form` block) and the
2
+ * The site's one on-demand endpoint: contact-form posts (the `vitops_form` block) and the
3
3
  * conversion beacons (`<Tracking />`, `tel:`/`mailto:` clicks) all arrive here.
4
4
  *
5
- * Every public page is prerendered (see AGENTS.md, "Rendering and freshness"); this route sets
5
+ * Every public page is prerendered (see .agents/skills/content-and-deploys); this route sets
6
6
  * `prerender = false` because it has to run on each request. It is the one exception, and the
7
7
  * Cloudflare adapter handles it in the Worker beside the EmDash admin.
8
8
  *
@@ -5,8 +5,8 @@ import Layout from '../layouts/Layout.astro';
5
5
 
6
6
  // Prerendered, like every page in this template: written to static HTML at build
7
7
  // time from the `home` entry (seed/seed.json → pages/home). Edit it in the admin;
8
- // a publish triggers a rebuild that bakes the change in (AGENTS.md "Rendering and
9
- // freshness"). The Vitops blocks from @getvitops/emdash render automatically —
8
+ // a publish triggers a rebuild that bakes the change in (.agents/skills/content-and-deploys,
9
+ // "Rendering and freshness"). The Vitops blocks from @getvitops/emdash render automatically —
10
10
  // EmDash merges the plugin's block components into <PortableText>.
11
11
  //
12
12
  // Anything on a page that must be fresh per request (a stock count, a signed-in
@@ -70,6 +70,13 @@ activates to `script-src` / `connect-src`, any embed to `frame-src`, and hash an
70
70
  inline script you add yourself. Hashes rather than nonces because a static page has no
71
71
  per-request response to carry one.
72
72
 
73
+ ## Skills
74
+
75
+ `@getvitops/skills` ships agent skills that update with the package. `skills experimental_sync` (run
76
+ by `prepare`, so by `vp install`) links them into `.agents/skills/` and `.claude/skills/`; they are
77
+ gitignored. The set is written for EmDash sites, so `csp` is the one that applies here. A skill you
78
+ write yourself with the same name is never replaced. `setup` is this site's own.
79
+
73
80
  ## Commands
74
81
 
75
82
  This is a **Vite+** project: use `vp` (and its `vpr` shorthand), not `pnpm`/`npm`/`npx`.
@@ -25,3 +25,13 @@ public/vitops/
25
25
 
26
26
  # os / editor
27
27
  .DS_Store
28
+
29
+ # agent skills linked from @getvitops/skills by `skills experimental_sync` (see skills-lock.json)
30
+ .agents/skills/custom-blocks
31
+ .agents/skills/content-and-deploys
32
+ .agents/skills/contact-form
33
+ .agents/skills/csp
34
+ .claude/skills/custom-blocks
35
+ .claude/skills/content-and-deploys
36
+ .claude/skills/contact-form
37
+ .claude/skills/csp
@@ -16,15 +16,17 @@
16
16
  "build:css": "vitops generate -i site.json -f css -o src/styles",
17
17
  "lint:design": "vitops lint -i site.json -f css --strict",
18
18
  "sop": "vitops generate -i site.json -f css -o .vitops/sop",
19
- "prepare": "vp config"
19
+ "prepare": "vp config && skills experimental_sync -y -a claude-code --no-frozen-lockfile"
20
20
  },
21
21
  "dependencies": {
22
22
  "@getvitops/astro": "latest",
23
+ "@getvitops/skills": "latest",
23
24
  "astro": "^7.4.4"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@astrojs/check": "^0.9.12",
27
28
  "@getvitops/cli": "latest",
29
+ "skills": "^1.7.6",
28
30
  "typescript": "^6.0.3",
29
31
  "vite": "latest",
30
32
  "vite-plus": "latest",
@@ -1,104 +0,0 @@
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.