@getvitops/create 0.8.2 → 0.9.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -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 +212 -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 +126 -6
- package/templates/emdash/site.json +54 -1
- package/templates/emdash/src/cloudflare-workers.d.ts +9 -0
- package/templates/emdash/src/layouts/Layout.astro +22 -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
|
@@ -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": "
|
|
47
|
-
"url": "/
|
|
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": "
|
|
63
|
-
"url": "/
|
|
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
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
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
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
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.
|