@getvitops/create 0.8.2 → 0.9.0-alpha.1

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.
@@ -23,8 +23,14 @@
23
23
  }
24
24
  },
25
25
  "roles": {
26
- "neutral": { "hue": "ink", "kind": "surface" },
27
- "surface": { "hue": "ink", "kind": "surface" },
26
+ "neutral": {
27
+ "hue": "ink",
28
+ "kind": "surface"
29
+ },
30
+ "surface": {
31
+ "hue": "ink",
32
+ "kind": "surface"
33
+ },
28
34
  "ui-primary": "brand",
29
35
  "brand-primary": "brand",
30
36
  "info": "sky",
@@ -93,6 +99,13 @@
93
99
  "br": "0.5rem",
94
100
  "p": "1rem",
95
101
  "fs": "var(--fs-default)"
102
+ },
103
+ "label": {
104
+ "ds": "var(--ds-default)",
105
+ "b": "none",
106
+ "br": "0.25rem",
107
+ "p": "0.25em 0.5em",
108
+ "fs": "0.75rem"
96
109
  }
97
110
  },
98
111
  "z": {
@@ -126,7 +139,8 @@
126
139
  "ring": true
127
140
  }
128
141
  },
129
- "roles": []
142
+ "roles": ["success", "danger", "warning", "info"],
143
+ "class": "btn"
130
144
  },
131
145
  "card": {
132
146
  "group": "panel",
@@ -140,6 +154,51 @@
140
154
  },
141
155
  "states": {},
142
156
  "roles": []
157
+ },
158
+ "badge": {
159
+ "group": "label",
160
+ "class": "badge",
161
+ "default_role": "brand-primary",
162
+ "overrides": {
163
+ "p": "0.2em 0.6em",
164
+ "br": "999px",
165
+ "fs": "0.85em"
166
+ },
167
+ "base": {
168
+ "display": "inline-block",
169
+ "padding": "var(--p-badge-group)",
170
+ "border-radius": "var(--br-badge-group)",
171
+ "font-size": "var(--fs-badge-group)",
172
+ "font-weight": "600",
173
+ "color": "var(--color-text-on-brand-primary)"
174
+ },
175
+ "states": {},
176
+ "roles": ["success", "danger", "warning", "info"]
177
+ },
178
+ "tag": {
179
+ "group": "label",
180
+ "class": "tag",
181
+ "default_role": "neutral",
182
+ "overrides": {
183
+ "p": "0.25em 0.6em",
184
+ "b": "1px solid currentColor",
185
+ "fs": "0.85em"
186
+ },
187
+ "base": {
188
+ "display": "inline-flex",
189
+ "align-items": "center",
190
+ "gap": "0.25em",
191
+ "padding": "var(--p-tag-group)",
192
+ "border": "var(--b-tag-group)",
193
+ "border-radius": "var(--br-tag-group)",
194
+ "font-size": "var(--fs-tag-group)",
195
+ "font-weight": "500",
196
+ "line-height": "1.2",
197
+ "white-space": "nowrap",
198
+ "color": "var(--color-text-neutral-bold)"
199
+ },
200
+ "states": {},
201
+ "roles": ["success", "danger", "warning", "info"]
143
202
  }
144
203
  }
145
204
  },
@@ -168,6 +227,46 @@
168
227
  "size": "var(--text-m)",
169
228
  "weight": 400,
170
229
  "line-height": "1.55"
230
+ },
231
+ "title": {
232
+ "family": "display",
233
+ "size": "var(--text-2xl)",
234
+ "weight": 600,
235
+ "line-height": "1.15",
236
+ "tracking": "-0.025em",
237
+ "text-wrap": "balance"
238
+ },
239
+ "lead": {
240
+ "family": "sans",
241
+ "size": "var(--text-l)",
242
+ "weight": 400,
243
+ "line-height": "1.5",
244
+ "text-wrap": "pretty"
245
+ },
246
+ "caption": {
247
+ "family": "sans",
248
+ "size": "var(--text-s)",
249
+ "weight": 400,
250
+ "line-height": "1.3",
251
+ "text-wrap": "pretty",
252
+ "color": "var(--color-text-neutral-muted)"
253
+ },
254
+ "eyebrow": {
255
+ "family": "sans",
256
+ "size": "var(--text-s)",
257
+ "weight": 600,
258
+ "line-height": "1.3",
259
+ "tracking": "0.08em",
260
+ "text-transform": "uppercase",
261
+ "text-wrap": "balance"
262
+ },
263
+ "footnote": {
264
+ "family": "sans",
265
+ "size": "var(--text-xs)",
266
+ "weight": 400,
267
+ "line-height": "1.4",
268
+ "text-wrap": "pretty",
269
+ "color": "var(--color-text-neutral-muted)"
171
270
  }
172
271
  },
173
272
  "headings": {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The Worker's bindings, as `src/pages/api/track.ts` reads them. Deliberately loose: the route
3
+ * looks each secret up by name and checks it is a string, so the real binding types (which depend
4
+ * on what `wrangler.jsonc` declares) are not needed to typecheck it. Replace with the output of
5
+ * `wrangler types` if you want them.
6
+ */
7
+ declare module 'cloudflare:workers' {
8
+ export const env: Record<string, unknown>;
9
+ }
@@ -3,7 +3,11 @@ import VitopsHead from '@getvitops/astro/Head.astro';
3
3
  import { resolveOrganizationName } from '@getvitops/astro/routes';
4
4
  import { getMenu, getSiteSettings } from 'emdash';
5
5
  import { createPublicPageContext } from 'emdash/page';
6
+ import SiteFooter from '@getvitops/astro/components/SiteFooter.astro';
7
+ import SiteNav from '@getvitops/astro/components/SiteNav.astro';
8
+ import { loadNavMenus } from '@getvitops/astro/components/sitenav.ts';
6
9
  import { EmDashHead } from 'emdash/ui';
10
+ import { VitopsWidgetArea } from '@getvitops/emdash/astro';
7
11
  import config from '../../site.json' with { type: 'json' };
8
12
  // Design-system CSS, layout-scoped (vitops css.inject: false): only pages
9
13
  // rendered through this layout get it — keeps it out of /_emdash/admin, while
@@ -42,18 +46,18 @@ const pageCtx = createPublicPageContext({
42
46
  siteName: siteTitle,
43
47
  });
44
48
 
45
- // Editor-managed navigation (seeded as "primary" and "footer"; edit under
46
- // Menus in the admin). Falls back to a bare home link if the menu is deleted.
47
- const menu = await getMenu('primary');
48
- const navLinks = menu?.items?.length
49
- ? menu.items.map((item) => ({ label: item.label, href: item.url ?? '/' }))
50
- : [{ label: 'Home', href: '/' }];
51
- const footerMenu = await getMenu('footer');
52
- const footerLinks = (footerMenu?.items ?? []).map((item) => ({
53
- label: item.label,
54
- href: item.url ?? '/',
55
- }));
56
- const year = new Date().getFullYear();
49
+ // Editor-managed navigation, read from EmDash menus (edit under Menus in the admin):
50
+ // `primary` header nav (the logo is the home link, so it holds no Home item).
51
+ // Nested items become dropdowns; an item's `titleAttr` is its description.
52
+ // `primary-mobile` OPTIONAL — a simpler depth-1 nav that replaces `primary` below `md`.
53
+ // `primary-mega` OPTIONAL — a mega menu that replaces `primary` from `xl`.
54
+ // `footer-sitemap`, `footer-legal` footer columns.
55
+ // Variants need no code change: create the menu and <SiteNav> picks it up. A missing menu
56
+ // renders nothing rather than failing (a fresh DB before the seed has applied).
57
+ const nav = await loadNavMenus(getMenu);
58
+ const footerSitemap = await getMenu('footer-sitemap');
59
+ const footerLegal = await getMenu('footer-legal');
60
+ const orgName = config.organization?.name || siteTitle;
57
61
  ---
58
62
 
59
63
  <!doctype html>
@@ -69,23 +73,18 @@ const year = new Date().getFullYear();
69
73
  <VitopsHead />
70
74
  </head>
71
75
  <body>
76
+ <!-- Widget areas (Widgets in the admin): the same content wherever they appear. -->
77
+ <VitopsWidgetArea name="announcement" class="centered" />
72
78
  <header class="navbar-sticky centered">
73
79
  <div class="navbar">
74
80
  <a class="brand flex items-center" href="/" aria-label={`${siteTitle} home`}>
75
81
  <span class="brand-mark" set:html={logo} aria-hidden="true" />
76
82
  <span class="font-heading">{siteTitle}</span>
77
83
  </a>
78
- <nav aria-label="Primary">
79
- <ul class="nav-items justify-center">
80
- {
81
- navLinks.map((link) => (
82
- <li>
83
- <a href={link.href}>{link.label}</a>
84
- </li>
85
- ))
86
- }
87
- </ul>
88
- </nav>
84
+ <div class="header-end flex items-center">
85
+ <SiteNav {...nav} />
86
+ <wc-color-scheme-toggle></wc-color-scheme-toggle>
87
+ </div>
89
88
  </div>
90
89
  </header>
91
90
 
@@ -93,24 +92,9 @@ const year = new Date().getFullYear();
93
92
  <slot />
94
93
  </main>
95
94
 
96
- <footer class="centered">
97
- {
98
- footerLinks.length > 0 && (
99
- <nav aria-label="Footer">
100
- <ul class="nav-items justify-center">
101
- {footerLinks.map((link) => (
102
- <li>
103
- <a class="font-caption" href={link.href}>
104
- {link.label}
105
- </a>
106
- </li>
107
- ))}
108
- </ul>
109
- </nav>
110
- )
111
- }
112
- <p class="font-caption text-neutral-muted">© {year} {siteTitle}</p>
113
- </footer>
95
+ <VitopsWidgetArea name="pre-footer" class="centered" />
96
+
97
+ <SiteFooter class="centered" sitemap={footerSitemap} legal={footerLegal} name={orgName} />
114
98
 
115
99
  <style>
116
100
  .brand {
@@ -129,10 +113,8 @@ const year = new Date().getFullYear();
129
113
  block-size: 64px;
130
114
  }
131
115
 
132
- footer {
133
- margin-block-start: var(--space-2xl);
134
- padding-block: var(--space-l);
135
- border-block-start: 1px solid var(--color-border-neutral);
116
+ .header-end {
117
+ gap: var(--space-s, 0.75rem);
136
118
  }
137
119
  </style>
138
120
  </body>
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  import Layout from '../layouts/Layout.astro';
3
3
 
4
- // No per-request data — safe to prerender per AGENTS.md's "Prerender by
5
- // default" rule. [...slug].astro reaches this via Astro.rewrite('/404'),
6
- // which resolves a prerendered destination the same as a server-rendered one.
4
+ // Prerendered to /404.html. Astro serves it, with a 404 status, for any URL no
5
+ // route matches — including every slug not in [...slug].astro's getStaticPaths.
6
+ // Nothing rewrites to this page: rewriting an on-demand route to a prerendered one
7
+ // is what used to turn unknown URLs into 500s.
7
8
  export const prerender = true;
8
9
  ---
9
10
 
@@ -1,23 +1,33 @@
1
1
  ---
2
- import { getEmDashEntry } from 'emdash';
2
+ import { getEmDashCollection } from 'emdash';
3
3
  import { PortableText } from 'emdash/ui';
4
4
  import Layout from '../layouts/Layout.astro';
5
5
 
6
- // Catch-all: any entry in the `pages` collection renders at its slug.
6
+ // Prerendered: every published entry in `pages` becomes a static HTML file at
7
+ // its slug, written at build time and served as a static asset. The build reads
8
+ // the content database — `scripts/pull-content.mjs` loads production's into the
9
+ // local build database first (it is part of `deploy`/`deploy:dev`) — so an edit
10
+ // in the admin goes live on the next rebuild, which a publish webhook triggers
11
+ // (see AGENTS.md "Rendering and freshness").
7
12
  //
8
- // Deliberately NOT prerendered, unlike AGENTS.md's general "prerender by
9
- // default" rule — see index.astro's comment for why: a live EmDash publish
10
- // should reach visitors without a redeploy. A page that's known ahead of
11
- // time and doesn't need that (rarely edited, or fine to lag a build) can
12
- // move to its own prerendered route with `getStaticPaths()` over the
13
- // `pages` collection instead of relying on this catch-all.
14
- const { slug } = Astro.params;
15
- const { entry: page } = slug ? await getEmDashEntry('pages', slug) : { entry: null };
13
+ // There is no `Astro.rewrite('/404')` here and no on-demand fallback: a URL that
14
+ // isn't in the list below simply has no route, so Astro serves the prerendered
15
+ // `404.astro` with a real 404 status. (The previous version rewrote an
16
+ // on-demand route to the prerendered `/404`, which Astro refuses — every
17
+ // unknown URL was a 500.)
18
+ export const prerender = true;
16
19
 
17
- if (!page) {
18
- return Astro.rewrite('/404');
20
+ export async function getStaticPaths() {
21
+ const { entries, error } = await getEmDashCollection('pages');
22
+ // Fail the build rather than ship a site with no pages.
23
+ if (error) throw error;
24
+ return entries
25
+ // `home` is served by index.astro at `/`.
26
+ .filter((entry) => entry.id !== 'home')
27
+ .map((entry) => ({ params: { slug: entry.id }, props: { page: entry } }));
19
28
  }
20
29
 
30
+ const { page } = Astro.props;
21
31
  // `page.data` is typed `unknown` until `.emdash/types.ts` is generated from a
22
32
  // running site (`pnpm dev`, then `emdash types`), so a fresh scaffold would fail
23
33
  // `astro check`; the cast keeps it passing both before and after, where the
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The site's one on-demand endpoint: contact-form posts (the `vitops.form` block) and the
3
+ * conversion beacons (`<Tracking />`, `tel:`/`mailto:` clicks) all arrive here.
4
+ *
5
+ * Every public page is prerendered (see AGENTS.md, "Rendering and freshness"); this route sets
6
+ * `prerender = false` because it has to run on each request. It is the one exception, and the
7
+ * Cloudflare adapter handles it in the Worker beside the EmDash admin.
8
+ *
9
+ * Credentials are functions of the request, read from the Worker's bindings and never from
10
+ * `site.json`:
11
+ * VITOPS_TURNSTILE_SECRET created by `vitops setup turnstile`
12
+ * EMAIL the `send_email` binding in wrangler.jsonc
13
+ * VITOPS_HUBSPOT_ACCESS_TOKEN or VITOPS_ZOHO_* / VITOPS_JOBBER_*, when `site.crm` is set
14
+ *
15
+ * Where a submission goes: `site.notifications.email` (or the primary location's email) and,
16
+ * when `site.crm` is set, the CRM. The email is the durability floor: a failed CRM write never
17
+ * fails the request and is stated in the email. With neither configured the post is refused,
18
+ * because accepting a message nobody receives is worse than an error page.
19
+ */
20
+ import { env } from 'cloudflare:workers';
21
+ import {
22
+ createConversionRoute,
23
+ toNotifyContext,
24
+ type RateLimitStore,
25
+ } from '@getvitops/astro/routes';
26
+ import type { APIRoute } from 'astro';
27
+ import config from '../../../site.json' with { type: 'json' };
28
+
29
+ export const prerender = false;
30
+
31
+ type Site = Parameters<typeof toNotifyContext>[0];
32
+ const site = config as unknown as Site;
33
+
34
+ /**
35
+ * Challenge every form post that carries no token when a Turnstile key exists. `false` keeps a
36
+ * no-JavaScript visitor able to submit, at the price that a bot can simply omit the token and be
37
+ * held only by the honeypot, origin and rate checks. A form that gets spam should keep this true.
38
+ */
39
+ const TURNSTILE_REQUIRED = true;
40
+
41
+ const bindings: Record<string, unknown> = env;
42
+ const secret = (name: string): string | undefined => {
43
+ const v = bindings[name];
44
+ return typeof v === 'string' && v ? v : undefined;
45
+ };
46
+
47
+ /**
48
+ * One store for the life of the isolate. `createConversionRoute` builds its default store per
49
+ * call, and this handler builds a route per request (the redirect depends on the form's page),
50
+ * so the default would never see a second hit. Per-isolate only; a fleet-wide limit needs a KV
51
+ * or Durable Object store behind the same one-method interface.
52
+ */
53
+ const hits = new Map<string, number>();
54
+ const rateLimitStore: RateLimitStore = {
55
+ hit(id, at) {
56
+ const prev = hits.get(id);
57
+ hits.set(id, at);
58
+ if (hits.size > 5000) for (const [k, t] of hits) if (at - t > 60_000) hits.delete(k);
59
+ return prev === undefined ? null : at - prev;
60
+ },
61
+ };
62
+
63
+ /** A same-site path or nothing: `_next` comes from the request, so it must never be an open redirect. */
64
+ function safeNext(input: unknown): string | null {
65
+ const v = typeof input === 'string' ? input : '';
66
+ if (!v.startsWith('/') || v.startsWith('//') || v.includes('\\') || v.length > 200) return null;
67
+ return /[\u0000-\u001f\u007f#?]/.test(v) ? null : v;
68
+ }
69
+
70
+ function crmCredentials() {
71
+ const crm = site.site.crm;
72
+ if (!crm) return undefined;
73
+ switch (crm.provider) {
74
+ case 'hubspot': {
75
+ const accessToken = secret('VITOPS_HUBSPOT_ACCESS_TOKEN');
76
+ return accessToken ? { provider: 'hubspot' as const, hubspot: { accessToken } } : undefined;
77
+ }
78
+ case 'bigin': {
79
+ const clientId = secret('VITOPS_ZOHO_CLIENT_ID');
80
+ const clientSecret = secret('VITOPS_ZOHO_CLIENT_SECRET');
81
+ const refreshToken = secret('VITOPS_ZOHO_REFRESH_TOKEN');
82
+ return clientId && clientSecret && refreshToken
83
+ ? { provider: 'bigin' as const, bigin: { clientId, clientSecret, refreshToken } }
84
+ : undefined;
85
+ }
86
+ case 'jobber': {
87
+ const clientId = secret('VITOPS_JOBBER_CLIENT_ID');
88
+ const clientSecret = secret('VITOPS_JOBBER_CLIENT_SECRET');
89
+ const refreshToken = secret('VITOPS_JOBBER_REFRESH_TOKEN');
90
+ return clientId && clientSecret && refreshToken
91
+ ? { provider: 'jobber' as const, jobber: { clientId, clientSecret, refreshToken } }
92
+ : undefined;
93
+ }
94
+ }
95
+ }
96
+
97
+ export const POST: APIRoute = async ({ request, locals }) => {
98
+ const context = toNotifyContext(site);
99
+ const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/.test(
100
+ request.headers.get('content-type') ?? '',
101
+ );
102
+
103
+ // Where a no-JS visitor lands: the page the form was on, with the fragment that reveals the
104
+ // right notice. A JSON/beacon caller gets a JSON response and never reaches these.
105
+ let next: string | null = null;
106
+ if (isForm) {
107
+ try {
108
+ next = safeNext((await request.clone().formData()).get('_next'));
109
+ } catch {
110
+ // A body that will not parse is the route's own 400; the redirect falls back to the root.
111
+ }
112
+ }
113
+ const page = next ?? '/';
114
+ const sent = `${page}?sent=1#form-sent`;
115
+ const failed = `${page}?error=1#form-error`;
116
+
117
+ // Nothing to deliver to: refuse rather than accept and lose the message.
118
+ if (
119
+ isForm &&
120
+ !context.notifications?.email &&
121
+ !context.locationEmails?.length &&
122
+ !site.site.crm
123
+ ) {
124
+ console.error(
125
+ '[vitops] form post refused: set site.notifications.email (or a location email, or site.crm) in site.json',
126
+ );
127
+ return Response.redirect(new URL(failed, request.url), 303);
128
+ }
129
+
130
+ // A site key means the page renders the widget, so a missing secret would mean a check that
131
+ // nothing enforces. Fail closed.
132
+ const siteKey = site.site.security?.turnstile?.siteKey;
133
+ const turnstileSecret = secret('VITOPS_TURNSTILE_SECRET');
134
+ if (isForm && siteKey && !turnstileSecret) {
135
+ console.error(
136
+ '[vitops] form post refused: site.security.turnstile.siteKey is set but the VITOPS_TURNSTILE_SECRET Worker secret is not (run `vitops setup turnstile`)',
137
+ );
138
+ return Response.redirect(new URL(failed, request.url), 303);
139
+ }
140
+
141
+ const handle = createConversionRoute({
142
+ context,
143
+ binding: () => bindings['EMAIL'] as never,
144
+ ...(site.site.crm
145
+ ? { crm: { config: site.site.crm, credentials: () => crmCredentials() } }
146
+ : {}),
147
+ ...(siteKey && turnstileSecret
148
+ ? { turnstile: { secretKey: turnstileSecret, required: TURNSTILE_REQUIRED } }
149
+ : {}),
150
+ // The request's own origin: a cross-site form post carries a different `Origin` and is
151
+ // refused. Set `site.domains.canonical` and this follows it instead.
152
+ ...(context.canonical ? {} : { allowedOrigin: new URL(request.url).origin }),
153
+ rateLimit: { store: rateLimitStore },
154
+ redirectTo: sent,
155
+ errorRedirectTo: failed,
156
+ });
157
+ return handle({ request, locals });
158
+ };
@@ -3,16 +3,17 @@ import { getEmDashEntry } from 'emdash';
3
3
  import { PortableText } from 'emdash/ui';
4
4
  import Layout from '../layouts/Layout.astro';
5
5
 
6
- // The seeded home page (seed/seed.json → pages/home). Edit it in the admin;
7
- // the Vitops blocks from @getvitops/emdash render automatically — EmDash
8
- // merges the plugin's block components into <PortableText>.
6
+ // Prerendered, like every page in this template: written to static HTML at build
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 —
10
+ // EmDash merges the plugin's block components into <PortableText>.
9
11
  //
10
- // Deliberately NOT prerendered, unlike AGENTS.md's general "prerender by
11
- // default" rule: this is the one route where the tradeoff that rule names
12
- // (prerendered = fast, but reflects the database as of the last build) is
13
- // unacceptable — the whole point of editing in EmDash is that a publish
14
- // shows up without a redeploy. Add `getStaticPaths()` + `prerender = true`
15
- // here only if this site's home page genuinely doesn't need that.
12
+ // Anything on a page that must be fresh per request (a stock count, a signed-in
13
+ // greeting) goes in a server island — `<Thing server:defer />` — which stays out
14
+ // of this static HTML and loads after it.
15
+ export const prerender = true;
16
+
16
17
  const { entry: page } = await getEmDashEntry('pages', 'home');
17
18
 
18
19
  // `page.data` is typed `unknown` until `.emdash/types.ts` is generated from a
@@ -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.
@@ -14,7 +14,7 @@ as Cloudflare Workers static assets (no server runtime, no database).
14
14
  portrait, landscape; SVG + PNG) for layout work before real imagery
15
15
  exists. Delete the directory once real assets land.
16
16
  - Prefer framework CSS classes (utilities, patterns, type roles like
17
- `font-heading`) over bespoke CSS. Run `npx vitops agents` to append the
17
+ `font-heading`) over bespoke CSS. Run `vp exec vitops agents` to append the
18
18
  full generated class-vocabulary reference to this file.
19
19
  - **Layout foundations — these are the answer, not a starting point.** Six
20
20
  substitutions carry the structure of nearly every page:
@@ -47,18 +47,72 @@ as Cloudflare Workers static assets (no server runtime, no database).
47
47
  PR with auto-merge; merging deploys prod. Only run it when the user
48
48
  explicitly asks to promote/ship to production.
49
49
 
50
+ ## Labels: badge and tag
51
+
52
+ Two label patterns, both enabled in `site.json`'s `patterns.items`: **`badge`** is a
53
+ static label (a status, count or category) and **`tag`** is a label that may be
54
+ static _or_ dynamic (editable, dismissable, in a `tag-list`). Don't invent a third
55
+ name — `chip` and `pill` are not part of this vocabulary. The button pattern's class
56
+ is `btn` (`<a class="btn">` is styled, not just a bare `<button>`).
57
+
58
+ ## Content Security Policy
59
+
60
+ `public/_headers` ships a **`Content-Security-Policy-Report-Only`** header for every
61
+ page (they are all static assets): violations are logged to the browser console and
62
+ nothing is blocked, so it can't break a page. Its `script-src` is `'self'` plus the
63
+ SHA-256 of each inline script `<Head />` emits (the colour-scheme bootstrap and the
64
+ consent-gate stub), with no `'unsafe-inline'`. `contentSecurityPolicy()` and
65
+ `inlineScriptHashes()` from `@getvitops/astro` produce the same strings.
66
+
67
+ Before enforcing it (rename the header to `Content-Security-Policy`), browse the pages
68
+ with the console open: add the origins of any third-party tag the consent gate
69
+ activates to `script-src` / `connect-src`, any embed to `frame-src`, and hash any
70
+ inline script you add yourself. Hashes rather than nonces because a static page has no
71
+ per-request response to carry one.
72
+
50
73
  ## Commands
51
74
 
52
- - `pnpm dev` — local dev
53
- - `pnpm typecheck` — `astro check`
54
- - `pnpm run build:css` — regenerate the framework CSS from `site.json`
55
- - `pnpm deploy` — build + `wrangler deploy` to prod
56
- - `pnpm run deploy:dev` — build + deploy the isolated dev worker
57
- - `pnpm run init:github` — one-time: create + push the GitHub repo (main +
58
- dev, dev default, branch protection + environment policy). Only run when
59
- the user asks to set up GitHub.
60
- - `pnpm run init:hosting` — one-time: read the Cloudflare account id and set
61
- it as a repo secret. Only run when the user asks for it.
62
- - `npx vitops validate site.json` — schema-check the site config
63
- - `npx vitops lint -i site.json -f css` — find framework classes in
64
- source that resolve to nothing under this format
75
+ This is a **Vite+** project: use `vp` (and its `vpr` shorthand), not `pnpm`/`npm`/`npx`.
76
+ The package manager underneath is pnpm, but `vp` picks it up for you.
77
+
78
+ | To… | Run |
79
+ | ---------------------------------------- | ---------------------------------------------------- |
80
+ | install dependencies | `vp install` |
81
+ | add / remove / update a package | `vp add <pkg>` · `vp remove <pkg>` · `vp update` |
82
+ | run a `package.json` script | `vpr <script>` (alias for `vp run <script>`) |
83
+ | run a locally installed binary | `vp exec <bin>` (e.g. `vp exec vitops docs classes`) |
84
+ | run a one-off package without installing | `vp dlx <pkg>` |
85
+ | format + lint + typecheck (files) | `vp check` |
86
+ | typecheck Astro (`.astro` files) | `vpr typecheck` |
87
+
88
+ **Scripts that wrap Astro must go through `vpr`.** `vp dev` / `vp build` run Vite
89
+ directly and skip Astro, so use `vpr dev`, `vpr build`, `vpr preview`.
90
+
91
+ **`vpr dev` returns immediately.** `astro dev` now starts the server in the
92
+ background. Check it with `vp exec astro dev status`, read its output with
93
+ `vp exec astro dev logs --follow`, and stop it with `vp exec astro dev stop`; the
94
+ site is at <http://localhost:4321>. Don't wait on it as if it were a foreground
95
+ process.
96
+
97
+ | Script | What it does |
98
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
99
+ | `vpr dev` | local dev |
100
+ | `vpr build:css` | regenerate the framework CSS from `site.json` |
101
+ | `vpr deploy` | build + `wrangler deploy` to prod |
102
+ | `vpr deploy:dev` | build + deploy the isolated dev worker |
103
+ | `vpr init:github` | one-time: create + push the GitHub repo (main + dev, dev default, branch protection + environment policy). Only when the user asks. |
104
+ | `vpr init:hosting` | one-time: read the Cloudflare account id and set it as a repo secret. Only when the user asks. |
105
+ | `vp exec vitops validate site.json` | schema-check the site config |
106
+ | `vp exec vitops lint -i site.json -f css` | find framework classes in source that resolve to nothing under this format |
107
+
108
+ **Release flow.** A site is not a published package, so there is no changeset or
109
+ version bump: ship by committing to `dev` (auto-deploys the dev worker) and then
110
+ running the **promote** workflow. `vp` has no `vp change` / `vp pm change` command
111
+ (checked against vp 1.5.0); for a _package_ it does have `vp pm version`,
112
+ `vp pm publish` and `vp pm stage`.
113
+
114
+ **Naming the project.** `vp create` takes the package name from the target
115
+ directory and invents a random slug when none is given, so pass one:
116
+ `vp create @getvitops:static --directory <client-slug>`. `wrangler.jsonc`'s `name`
117
+ is separate and still says what the template shipped — rename it to match before
118
+ the first deploy.