@getvitops/create 0.8.2 → 0.9.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,6 +35,17 @@
35
35
  {
36
36
  "name": "primary",
37
37
  "label": "Primary Navigation",
38
+ "items": [
39
+ {
40
+ "type": "custom",
41
+ "label": "Contact",
42
+ "url": "/contact"
43
+ }
44
+ ]
45
+ },
46
+ {
47
+ "name": "footer-sitemap",
48
+ "label": "Footer: Site Map",
38
49
  "items": [
39
50
  {
40
51
  "type": "custom",
@@ -43,15 +54,20 @@
43
54
  },
44
55
  {
45
56
  "type": "custom",
46
- "label": "About",
47
- "url": "/about"
57
+ "label": "Contact",
58
+ "url": "/contact"
48
59
  }
49
60
  ]
50
61
  },
51
62
  {
52
- "name": "footer",
53
- "label": "Footer",
63
+ "name": "footer-legal",
64
+ "label": "Footer: Legal",
54
65
  "items": [
66
+ {
67
+ "type": "custom",
68
+ "label": "Privacy Policy",
69
+ "url": "/privacy"
70
+ },
55
71
  {
56
72
  "type": "custom",
57
73
  "label": "Terms & Conditions",
@@ -59,8 +75,8 @@
59
75
  },
60
76
  {
61
77
  "type": "custom",
62
- "label": "Privacy Policy",
63
- "url": "/privacy"
78
+ "label": "Cookie Notice",
79
+ "url": "/cookies"
64
80
  }
65
81
  ]
66
82
  }
@@ -436,6 +452,110 @@
436
452
  }
437
453
  ]
438
454
  }
455
+ },
456
+ {
457
+ "id": "contact",
458
+ "slug": "contact",
459
+ "status": "published",
460
+ "data": {
461
+ "title": "Contact",
462
+ "content": [
463
+ {
464
+ "_type": "vitops.banner",
465
+ "_key": "contact-placeholder",
466
+ "message": "Placeholder text — replace with your contact details before launch.",
467
+ "tone": "warning",
468
+ "dismissible": false
469
+ },
470
+ {
471
+ "_type": "block",
472
+ "_key": "contact-body",
473
+ "style": "normal",
474
+ "markDefs": [],
475
+ "children": [
476
+ {
477
+ "_type": "span",
478
+ "_key": "contact-body-1",
479
+ "text": "Tell visitors how to reach you: an email address, a phone number, a street address or a form. This page exists so the Contact link in the header and footer resolves; edit it in the EmDash admin.",
480
+ "marks": []
481
+ }
482
+ ]
483
+ },
484
+ {
485
+ "_type": "vitops.form",
486
+ "_key": "contact-form",
487
+ "heading": "Send us a message",
488
+ "level": "2",
489
+ "fields": [
490
+ { "label": "Your name", "name": "name", "type": "text", "required": true },
491
+ { "label": "Email", "name": "email", "type": "email", "required": true },
492
+ { "label": "Phone", "name": "phone", "type": "tel", "required": false },
493
+ { "label": "Message", "name": "message", "type": "textarea", "required": true }
494
+ ],
495
+ "submitLabel": "Send message",
496
+ "successMessage": "Thanks. Your message has been sent and we will reply soon."
497
+ }
498
+ ]
499
+ }
500
+ },
501
+ {
502
+ "id": "cookies",
503
+ "slug": "cookies",
504
+ "status": "published",
505
+ "data": {
506
+ "title": "Cookie Notice",
507
+ "content": [
508
+ {
509
+ "_type": "vitops.banner",
510
+ "_key": "cookies-placeholder",
511
+ "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.",
512
+ "tone": "warning",
513
+ "dismissible": false
514
+ },
515
+ {
516
+ "_type": "block",
517
+ "_key": "cookies-intro",
518
+ "style": "normal",
519
+ "markDefs": [],
520
+ "children": [
521
+ {
522
+ "_type": "span",
523
+ "_key": "cookies-intro-1",
524
+ "text": "This site uses a small number of cookies. Essential cookies keep the site working; others are only set if you choose to allow them.",
525
+ "marks": []
526
+ }
527
+ ]
528
+ },
529
+ {
530
+ "_type": "block",
531
+ "_key": "cookies-h",
532
+ "style": "h2",
533
+ "markDefs": [],
534
+ "children": [
535
+ {
536
+ "_type": "span",
537
+ "_key": "cookies-h-1",
538
+ "text": "What cookies we use",
539
+ "marks": []
540
+ }
541
+ ]
542
+ },
543
+ {
544
+ "_type": "block",
545
+ "_key": "cookies-body",
546
+ "style": "normal",
547
+ "markDefs": [],
548
+ "children": [
549
+ {
550
+ "_type": "span",
551
+ "_key": "cookies-body-1",
552
+ "text": "List each cookie, who sets it and why. Replace this paragraph with the real list before launch.",
553
+ "marks": []
554
+ }
555
+ ]
556
+ }
557
+ ]
558
+ }
439
559
  }
440
560
  ]
441
561
  }
@@ -93,6 +93,13 @@
93
93
  "br": "0.5rem",
94
94
  "p": "1rem",
95
95
  "fs": "var(--fs-default)"
96
+ },
97
+ "label": {
98
+ "ds": "var(--ds-default)",
99
+ "b": "none",
100
+ "br": "0.25rem",
101
+ "p": "0.25em 0.5em",
102
+ "fs": "0.75rem"
96
103
  }
97
104
  },
98
105
  "z": {
@@ -126,7 +133,8 @@
126
133
  "ring": true
127
134
  }
128
135
  },
129
- "roles": []
136
+ "roles": ["success", "danger", "warning", "info"],
137
+ "class": "btn"
130
138
  },
131
139
  "card": {
132
140
  "group": "panel",
@@ -140,6 +148,51 @@
140
148
  },
141
149
  "states": {},
142
150
  "roles": []
151
+ },
152
+ "badge": {
153
+ "group": "label",
154
+ "class": "badge",
155
+ "default_role": "brand-primary",
156
+ "overrides": {
157
+ "p": "0.2em 0.6em",
158
+ "br": "999px",
159
+ "fs": "0.85em"
160
+ },
161
+ "base": {
162
+ "display": "inline-block",
163
+ "padding": "var(--p-badge-group)",
164
+ "border-radius": "var(--br-badge-group)",
165
+ "font-size": "var(--fs-badge-group)",
166
+ "font-weight": "600",
167
+ "color": "var(--color-text-on-brand-primary)"
168
+ },
169
+ "states": {},
170
+ "roles": ["success", "danger", "warning", "info"]
171
+ },
172
+ "tag": {
173
+ "group": "label",
174
+ "class": "tag",
175
+ "default_role": "neutral",
176
+ "overrides": {
177
+ "p": "0.25em 0.6em",
178
+ "b": "1px solid currentColor",
179
+ "fs": "0.85em"
180
+ },
181
+ "base": {
182
+ "display": "inline-flex",
183
+ "align-items": "center",
184
+ "gap": "0.25em",
185
+ "padding": "var(--p-tag-group)",
186
+ "border": "var(--b-tag-group)",
187
+ "border-radius": "var(--br-tag-group)",
188
+ "font-size": "var(--fs-tag-group)",
189
+ "font-weight": "500",
190
+ "line-height": "1.2",
191
+ "white-space": "nowrap",
192
+ "color": "var(--color-text-neutral-bold)"
193
+ },
194
+ "states": {},
195
+ "roles": ["success", "danger", "warning", "info"]
143
196
  }
144
197
  }
145
198
  },
@@ -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,6 +3,9 @@ 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';
7
10
  import config from '../../site.json' with { type: 'json' };
8
11
  // Design-system CSS, layout-scoped (vitops css.inject: false): only pages
@@ -42,18 +45,18 @@ const pageCtx = createPublicPageContext({
42
45
  siteName: siteTitle,
43
46
  });
44
47
 
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();
48
+ // Editor-managed navigation, read from EmDash menus (edit under Menus in the admin):
49
+ // `primary` header nav (the logo is the home link, so it holds no Home item).
50
+ // Nested items become dropdowns; an item's `titleAttr` is its description.
51
+ // `primary-mobile` OPTIONAL — a simpler depth-1 nav that replaces `primary` below `md`.
52
+ // `primary-mega` OPTIONAL — a mega menu that replaces `primary` from `xl`.
53
+ // `footer-sitemap`, `footer-legal` footer columns.
54
+ // Variants need no code change: create the menu and <SiteNav> picks it up. A missing menu
55
+ // renders nothing rather than failing (a fresh DB before the seed has applied).
56
+ const nav = await loadNavMenus(getMenu);
57
+ const footerSitemap = await getMenu('footer-sitemap');
58
+ const footerLegal = await getMenu('footer-legal');
59
+ const orgName = config.organization?.name || siteTitle;
57
60
  ---
58
61
 
59
62
  <!doctype html>
@@ -75,17 +78,10 @@ const year = new Date().getFullYear();
75
78
  <span class="brand-mark" set:html={logo} aria-hidden="true" />
76
79
  <span class="font-heading">{siteTitle}</span>
77
80
  </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>
81
+ <div class="header-end flex items-center">
82
+ <SiteNav {...nav} />
83
+ <wc-color-scheme-toggle></wc-color-scheme-toggle>
84
+ </div>
89
85
  </div>
90
86
  </header>
91
87
 
@@ -93,24 +89,7 @@ const year = new Date().getFullYear();
93
89
  <slot />
94
90
  </main>
95
91
 
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>
92
+ <SiteFooter class="centered" sitemap={footerSitemap} legal={footerLegal} name={orgName} />
114
93
 
115
94
  <style>
116
95
  .brand {
@@ -129,10 +108,8 @@ const year = new Date().getFullYear();
129
108
  block-size: 64px;
130
109
  }
131
110
 
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);
111
+ .header-end {
112
+ gap: var(--space-s, 0.75rem);
136
113
  }
137
114
  </style>
138
115
  </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.