@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.
- package/CHANGELOG.md +103 -0
- package/package.json +2 -2
- package/templates/emdash/.agents/skills/custom-blocks/SKILL.md +104 -0
- package/templates/emdash/.agents/skills/setup/SKILL.md +4 -4
- package/templates/emdash/.claude/hooks/session-start.sh +4 -1
- package/templates/emdash/.github/workflows/deploy-prod.yml +12 -0
- package/templates/emdash/AGENTS.md +228 -28
- package/templates/emdash/README.md +48 -37
- package/templates/emdash/SETUP.html +45 -27
- package/templates/emdash/_gitignore +3 -0
- package/templates/emdash/astro.config.mjs +14 -1
- package/templates/emdash/package.json +7 -5
- package/templates/emdash/public/_headers +19 -4
- package/templates/emdash/scripts/pull-content.mjs +83 -0
- package/templates/emdash/seed/seed.json +188 -6
- package/templates/emdash/site.json +102 -3
- package/templates/emdash/src/cloudflare-workers.d.ts +9 -0
- package/templates/emdash/src/layouts/Layout.astro +27 -45
- package/templates/emdash/src/pages/404.astro +4 -3
- package/templates/emdash/src/pages/[...slug].astro +22 -12
- package/templates/emdash/src/pages/api/track.ts +158 -0
- package/templates/emdash/src/pages/index.astro +10 -9
- package/templates/static/.agents/skills/setup/SKILL.md +4 -4
- package/templates/static/AGENTS.md +68 -14
- package/templates/static/README.md +7 -7
- package/templates/static/SETUP.html +45 -27
- package/templates/static/public/_headers +16 -4
- package/templates/static/site.json +54 -1
|
@@ -23,8 +23,14 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"roles": {
|
|
26
|
-
"neutral": {
|
|
27
|
-
|
|
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
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
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
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
2
|
+
import { getEmDashCollection } from 'emdash';
|
|
3
3
|
import { PortableText } from 'emdash/ui';
|
|
4
4
|
import Layout from '../layouts/Layout.astro';
|
|
5
5
|
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
vp exec vitops setup all
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Re-run `
|
|
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 `
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|