@cparkerwebm/webmonterey 1.0.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 +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- package/template/workflows/ci.yml +52 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The files `webm new` writes into a fresh site.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes a domain and returns a map of path -> contents, so the whole scaffold is testable
|
|
5
|
+
* without touching a disk. `new.ts` is the thin layer that writes them.
|
|
6
|
+
*
|
|
7
|
+
* WHAT IS DELIBERATELY ABSENT: any component. The package ships none, so a new site starts with
|
|
8
|
+
* an empty registry and an empty block union. That is not a stub awaiting code - it is the
|
|
9
|
+
* decision. Generation 1 shipped framework chrome and every client overrode it.
|
|
10
|
+
*/
|
|
11
|
+
import { resourceNames } from './slug.ts';
|
|
12
|
+
import { MCP_NAMES, mcpConfig } from './mcp.ts';
|
|
13
|
+
|
|
14
|
+
export interface ScaffoldOptions {
|
|
15
|
+
domain: string;
|
|
16
|
+
/** Display name. Falls back to CHANGEME, which `go-live` refuses to launch with. */
|
|
17
|
+
client?: string;
|
|
18
|
+
/** GitHub org for the repo. */
|
|
19
|
+
org?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Where a staging deployment's mail goes. `webm new` fills it from `git config user.email`;
|
|
22
|
+
* the package itself carries no inbox, because a default address in a public package means a
|
|
23
|
+
* stranger's staging site mails the author. Empty is allowed here and fails `webm doctor`.
|
|
24
|
+
*/
|
|
25
|
+
stagingEmail?: string;
|
|
26
|
+
packageVersion: string;
|
|
27
|
+
/**
|
|
28
|
+
* Today, as YYYY-MM-DD. REQUIRED, and deliberately not defaulted.
|
|
29
|
+
*
|
|
30
|
+
* It becomes `compatibility_date` in wrangler.jsonc, which pins Workers runtime behavior. This
|
|
31
|
+
* used to be a hardcoded constant with a comment saying the caller would overwrite it. No
|
|
32
|
+
* caller did, and the failure is the worst kind there is: a compatibility_date a few months
|
|
33
|
+
* stale builds without a warning and renders EVERY PAGE as the literal string
|
|
34
|
+
* "[object Object]". 15 bytes, no error, exit code 0.
|
|
35
|
+
*
|
|
36
|
+
* A required parameter is the fix - a constant can be forgotten, an argument cannot.
|
|
37
|
+
*/
|
|
38
|
+
today: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Shift a YYYY-MM-DD date by whole days, staying in UTC so a local zone cannot roll it over. */
|
|
42
|
+
function shiftDays(date: string, days: number): string {
|
|
43
|
+
const shifted = new Date(`${date}T00:00:00Z`);
|
|
44
|
+
shifted.setUTCDate(shifted.getUTCDate() + days);
|
|
45
|
+
return shifted.toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function scaffold(options: ScaffoldOptions): Record<string, string> {
|
|
49
|
+
const { domain, packageVersion } = options;
|
|
50
|
+
const n = resourceNames(domain);
|
|
51
|
+
const client = options.client ?? 'CHANGEME';
|
|
52
|
+
const org = options.org ?? 'webmonterey';
|
|
53
|
+
const { today } = options;
|
|
54
|
+
|
|
55
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
|
|
56
|
+
throw new Error(`scaffold: today must be YYYY-MM-DD, got ${JSON.stringify(today)}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* COMPATIBILITY DATE IS TODAY MINUS A MARGIN, AND THE MARGIN IS THE WHOLE POINT.
|
|
61
|
+
*
|
|
62
|
+
* A compatibility_date NEWER than the runtime bundled with the installed wrangler is refused
|
|
63
|
+
* outright - miniflare throws ERR_FUTURE_COMPATIBILITY_DATE and workerd reports "requires
|
|
64
|
+
* compatibility date X, but the newest date supported by this server binary is Y". The site
|
|
65
|
+
* simply does not build, from the moment it is scaffolded.
|
|
66
|
+
*
|
|
67
|
+
* Writing today's date walks straight into that for anyone whose wrangler is a few weeks old,
|
|
68
|
+
* which is most people most of the time - a lockfile, an offline install, a CI image. Two
|
|
69
|
+
* client sites hit exactly this and it was diagnosed twice, independently.
|
|
70
|
+
*
|
|
71
|
+
* A fortnight is comfortably more than Cloudflare's release cadence and costs nothing: an
|
|
72
|
+
* older compatibility_date only means slightly more conservative runtime behavior, and the
|
|
73
|
+
* doctor's staleness check does not even warn until 90 days. The failure this avoids is total;
|
|
74
|
+
* the cost of avoiding it is unmeasurable.
|
|
75
|
+
*
|
|
76
|
+
* NOT clamped against the installed workerd, deliberately: `webm new` runs before the site has
|
|
77
|
+
* a node_modules to inspect, and probing the CLI's own tree would bind the scaffold to however
|
|
78
|
+
* the package happened to be installed.
|
|
79
|
+
*/
|
|
80
|
+
const RUNTIME_LAG_DAYS = 14;
|
|
81
|
+
const compatibilityDate = shiftDays(today, -RUNTIME_LAG_DAYS);
|
|
82
|
+
|
|
83
|
+
const files: Record<string, string> = {};
|
|
84
|
+
|
|
85
|
+
files['package.json'] =
|
|
86
|
+
JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
name: n.worker,
|
|
89
|
+
private: true,
|
|
90
|
+
type: 'module',
|
|
91
|
+
engines: { node: '>=22.18.0' },
|
|
92
|
+
scripts: {
|
|
93
|
+
/*
|
|
94
|
+
* preinstall runs BEFORE node_modules exists, so it cannot call a bin from the package.
|
|
95
|
+
* scripts/check-node.mjs is on disk for exactly that reason and `webm sync` refreshes it.
|
|
96
|
+
*/
|
|
97
|
+
preinstall: 'node scripts/check-node.mjs',
|
|
98
|
+
postinstall: 'webm sync',
|
|
99
|
+
dev: 'wrangler types && astro dev',
|
|
100
|
+
build: 'wrangler types && astro check && astro build',
|
|
101
|
+
preview: 'astro preview',
|
|
102
|
+
check: 'wrangler types && astro check',
|
|
103
|
+
doctor: 'webm doctor',
|
|
104
|
+
format: 'prettier --write .',
|
|
105
|
+
/*
|
|
106
|
+
* Component tests. --import scripts/test-hooks.mjs is what lets a test import './thing'
|
|
107
|
+
* without an extension and a .json without an import attribute, the way Astro and Vite
|
|
108
|
+
* do - so a test file reads like the app code it covers.
|
|
109
|
+
*/
|
|
110
|
+
test: 'node --import ./scripts/test-hooks.mjs --test "src/**/*.test.ts"',
|
|
111
|
+
},
|
|
112
|
+
dependencies: { '@cparkerwebm/webmonterey': `^${packageVersion}`, astro: '^7.1.6' },
|
|
113
|
+
devDependencies: {
|
|
114
|
+
'@astrojs/check': '^0.9.10',
|
|
115
|
+
prettier: '^3.9.6',
|
|
116
|
+
typescript: '^6.0.3',
|
|
117
|
+
wrangler: '^4.118.0',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
null,
|
|
121
|
+
2,
|
|
122
|
+
) + '\n';
|
|
123
|
+
|
|
124
|
+
files['astro.config.mjs'] =
|
|
125
|
+
`// @ts-check\n` +
|
|
126
|
+
`import { defineConfig } from 'astro/config';\n` +
|
|
127
|
+
`import webmonterey, { adapter } from '@cparkerwebm/webmonterey';\n\n` +
|
|
128
|
+
`/*\n` +
|
|
129
|
+
` * Two lines of configuration. Everything else - imageService, the sitemap, deriving site\n` +
|
|
130
|
+
` * from webmonterey.json, the noindex routes, the virtual modules - is decided inside the\n` +
|
|
131
|
+
` * integration, so a fix propagates on npm update rather than never.\n` +
|
|
132
|
+
` *\n` +
|
|
133
|
+
` * THE ADAPTER IS NAMED HERE AND NOT SET BY THE INTEGRATION. An adapter registered through\n` +
|
|
134
|
+
` * updateConfig does not run its own hooks, and the build then fails on the first on-demand\n` +
|
|
135
|
+
` * route with an error naming nothing you wrote. adapter() is the package's own, already\n` +
|
|
136
|
+
` * configured - the site takes no direct dependency on @astrojs/cloudflare.\n` +
|
|
137
|
+
` */\n` +
|
|
138
|
+
`export default defineConfig({\n` +
|
|
139
|
+
` adapter: adapter(),\n` +
|
|
140
|
+
` integrations: [webmonterey()],\n` +
|
|
141
|
+
`});\n`;
|
|
142
|
+
|
|
143
|
+
files['webmonterey.json'] =
|
|
144
|
+
JSON.stringify(
|
|
145
|
+
{
|
|
146
|
+
'//': 'Site identity. READ BY THE BUILD - editing this changes output, it is not documentation.',
|
|
147
|
+
client,
|
|
148
|
+
domain,
|
|
149
|
+
repo: `${org}/${n.repo}`,
|
|
150
|
+
worker: n.worker,
|
|
151
|
+
slug: n.slug,
|
|
152
|
+
launched: null,
|
|
153
|
+
'//environment':
|
|
154
|
+
"What this deployment is FOR. 'staging' redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production'. Anything served from workers.dev is treated as staging regardless, so a branch preview of a live site is covered too.",
|
|
155
|
+
environment: 'staging',
|
|
156
|
+
'//stagingEmail':
|
|
157
|
+
'Where staging email goes instead of its real recipients. REQUIRED while environment is staging - a staging site with nowhere to send refuses to send rather than guessing. webm doctor checks.',
|
|
158
|
+
stagingEmail: options.stagingEmail ?? '',
|
|
159
|
+
'//gtmId':
|
|
160
|
+
'Google Tag Manager container, GTM-XXXXXXX. PUBLIC by design - it is in the source of every page - so it is tracked here rather than in .env, which is gitignored and therefore absent on Workers Builds: a container id set only in the environment works locally and silently loads nothing in production. PUBLIC_GTM_ID in the environment overrides this, for pointing a branch at a different container. Leave empty and no GTM renders at all.',
|
|
161
|
+
gtmId: '',
|
|
162
|
+
'//locale':
|
|
163
|
+
'IANA zone and locale for every client-facing date. D1 stores UTC, so nothing reaches a person without passing through these. There is no Pacific/LA.',
|
|
164
|
+
timeZone: 'America/Los_Angeles',
|
|
165
|
+
locale: 'en-US',
|
|
166
|
+
'//organization':
|
|
167
|
+
'Feeds the JSON-LD graph. EVERY empty field is omitted from output - never guess one, because wrong opening hours in structured data is worse than none.',
|
|
168
|
+
organization: {
|
|
169
|
+
type: 'Organization',
|
|
170
|
+
legalName: '',
|
|
171
|
+
telephone: '',
|
|
172
|
+
email: '',
|
|
173
|
+
streetAddress: '',
|
|
174
|
+
addressLocality: '',
|
|
175
|
+
addressRegion: '',
|
|
176
|
+
postalCode: '',
|
|
177
|
+
addressCountry: '',
|
|
178
|
+
sameAs: [],
|
|
179
|
+
},
|
|
180
|
+
'//features':
|
|
181
|
+
'Technical switches for what is WIRED on this site - not a commercial plan. Nothing here reads a tier. `platform` is reserved and inert until the platform mail relay ships.',
|
|
182
|
+
features: { compliance: true, d1: false, turnstile: false, platform: false },
|
|
183
|
+
'//app':
|
|
184
|
+
'The web app namespace, reserved on every site. The folder is ALWAYS src/pages/webapp/; `path` is the public URL segment - set `portal`, `members` or `account` for a client whose customers log in, and the framework rewrites it onto the folder. Everything else derives from this field. Every page under the folder must be `prerender = false`.',
|
|
185
|
+
app: { enabled: false, path: 'webapp', label: 'Portal' },
|
|
186
|
+
},
|
|
187
|
+
null,
|
|
188
|
+
2,
|
|
189
|
+
) + '\n';
|
|
190
|
+
|
|
191
|
+
files['design.json'] =
|
|
192
|
+
JSON.stringify(
|
|
193
|
+
{
|
|
194
|
+
$schema: './node_modules/@cparkerwebm/webmonterey/schema/design.json',
|
|
195
|
+
version: 1,
|
|
196
|
+
brand: {
|
|
197
|
+
name: client === 'CHANGEME' ? '' : client,
|
|
198
|
+
voice: '',
|
|
199
|
+
rules: [],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
null,
|
|
203
|
+
2,
|
|
204
|
+
) + '\n';
|
|
205
|
+
|
|
206
|
+
files['wrangler.jsonc'] =
|
|
207
|
+
`{\n` +
|
|
208
|
+
` "$schema": "./node_modules/wrangler/config-schema.json",\n\n` +
|
|
209
|
+
` // MUST match the Worker name in the Cloudflare dashboard, or Workers Builds fails.\n` +
|
|
210
|
+
` "name": "${n.worker}",\n\n` +
|
|
211
|
+
` // The date this Worker was created, set a fortnight behind the scaffold so it cannot be newer
|
|
212
|
+
// than the runtime the installed wrangler bundles - a compatibility_date in the future of that
|
|
213
|
+
// binary refuses to build at all. Do not bump it casually; it pins runtime behavior.
|
|
214
|
+
// Do not let it go stale either: a date a few months behind the installed workerd renders
|
|
215
|
+
// every page as "[object Object]", with no error and a successful build. \`webm doctor\` checks.\n` +
|
|
216
|
+
` "compatibility_date": "${compatibilityDate}",\n` +
|
|
217
|
+
` "compatibility_flags": ["nodejs_compat"],\n\n` +
|
|
218
|
+
` // Astro's adapter builds static assets into dist/client, NOT dist.\n` +
|
|
219
|
+
` "assets": {\n` +
|
|
220
|
+
` "directory": "./dist/client",\n` +
|
|
221
|
+
` "not_found_handling": "404-page",\n\n` +
|
|
222
|
+
` /*\n` +
|
|
223
|
+
` * NOT REDUNDANT. Do not remove an entry because the path "already works" in curl.\n` +
|
|
224
|
+
` *\n` +
|
|
225
|
+
` * "404-page" intercepts NAVIGATION requests that match no static asset and serves\n` +
|
|
226
|
+
` * 404.html, so they never reach the Worker. Every \`prerender = false\` route is such a\n` +
|
|
227
|
+
` * path. The interception keys off \`Sec-Fetch-Dest: document\`, which a browser sends and\n` +
|
|
228
|
+
` * curl does not - so the same URL returns 200 to curl and the 404 page to Chrome.\n` +
|
|
229
|
+
` *\n` +
|
|
230
|
+
` * >> ADD EVERY ROUTE YOU GIVE \`prerender = false\`, IN BOTH SLASH FORMS. <<\n` +
|
|
231
|
+
` * \`npx webm doctor\` checks this.\n` +
|
|
232
|
+
` */\n` +
|
|
233
|
+
` "run_worker_first": ["/_actions/*"]\n` +
|
|
234
|
+
` },\n\n` +
|
|
235
|
+
` "observability": { "enabled": true },\n\n` +
|
|
236
|
+
` // Set BOTH explicitly. Toggling previews in the dashboard without updating this file\n` +
|
|
237
|
+
` // silently reverts it on the next deploy.\n` +
|
|
238
|
+
` "workers_dev": true,\n` +
|
|
239
|
+
` "preview_urls": true\n` +
|
|
240
|
+
`}\n`;
|
|
241
|
+
|
|
242
|
+
files['tsconfig.json'] =
|
|
243
|
+
JSON.stringify(
|
|
244
|
+
{
|
|
245
|
+
extends: 'astro/tsconfigs/strict',
|
|
246
|
+
include: ['.astro/types.d.ts', '**/*', 'worker-configuration.d.ts'],
|
|
247
|
+
exclude: ['dist', 'node_modules'],
|
|
248
|
+
},
|
|
249
|
+
null,
|
|
250
|
+
2,
|
|
251
|
+
) + '\n';
|
|
252
|
+
|
|
253
|
+
files['.claude/settings.json'] =
|
|
254
|
+
JSON.stringify(
|
|
255
|
+
{
|
|
256
|
+
'//': `Project settings for ${n.repo}.`,
|
|
257
|
+
'//mcp':
|
|
258
|
+
'A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.',
|
|
259
|
+
includeCoAuthoredBy: false,
|
|
260
|
+
enabledMcpjsonServers: MCP_NAMES,
|
|
261
|
+
permissions: {
|
|
262
|
+
deny: [
|
|
263
|
+
'Read(**/.dev.vars)',
|
|
264
|
+
'Read(**/.dev.vars.*)',
|
|
265
|
+
'Read(**/.env)',
|
|
266
|
+
'Read(**/.env.*)',
|
|
267
|
+
'Read(**/*.pem)',
|
|
268
|
+
'Read(**/*.key)',
|
|
269
|
+
'Read(**/.npmrc)',
|
|
270
|
+
'Edit(**/.dev.vars)',
|
|
271
|
+
'Edit(**/.env)',
|
|
272
|
+
'Write(**/.dev.vars)',
|
|
273
|
+
'Write(**/.env)',
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
null,
|
|
278
|
+
2,
|
|
279
|
+
) + '\n';
|
|
280
|
+
|
|
281
|
+
files['.mcp.json'] = JSON.stringify(mcpConfig(), null, 2) + '\n';
|
|
282
|
+
|
|
283
|
+
files['src/actions/index.ts'] =
|
|
284
|
+
`/*\n` +
|
|
285
|
+
` * Form handlers.\n` +
|
|
286
|
+
` *\n` +
|
|
287
|
+
` * \`src/actions/index.ts\` is a FIXED path - Astro looks nowhere else - so this file has to live\n` +
|
|
288
|
+
` * in the client repo. What it contains is a re-export, and that is the whole point: the pipeline\n` +
|
|
289
|
+
` * itself is package-owned, so a fix to validation, Turnstile handling, the D1 write or an email\n` +
|
|
290
|
+
` * template reaches this site on npm update rather than never.\n` +
|
|
291
|
+
` *\n` +
|
|
292
|
+
` * The order inside it is load-bearing: validate, verify Turnstile, write to D1, notify, then\n` +
|
|
293
|
+
` * autorespond. Storing before sending means a Mailgun failure leaves an enquiry with\n` +
|
|
294
|
+
` * notified_at NULL rather than losing it.\n` +
|
|
295
|
+
` *\n` +
|
|
296
|
+
` * Forms are defined in src/forms/*.json, one per form, and the filename is the form id. There\n` +
|
|
297
|
+
` * is no registry to keep in step - adding src/forms/quote.json is the whole job.\n` +
|
|
298
|
+
` *\n` +
|
|
299
|
+
` * TO CUSTOMIZE: wrap rather than fork. Import the package's server, spread it, and add your own\n` +
|
|
300
|
+
` * action beside it. Copying the pipeline in here is how a site stops receiving fixes.\n` +
|
|
301
|
+
` */\n` +
|
|
302
|
+
`export { server } from '@cparkerwebm/webmonterey/actions';\n`;
|
|
303
|
+
|
|
304
|
+
files['src/components/registry.ts'] =
|
|
305
|
+
`import type { AstroComponentFactory } from 'astro/runtime/server/index.js';\n\n` +
|
|
306
|
+
`/*\n` +
|
|
307
|
+
` * THE BLOCK REGISTRY - client-owned, because every visible component is. The package ships\n` +
|
|
308
|
+
` * none.\n` +
|
|
309
|
+
` *\n` +
|
|
310
|
+
` * Maps a block \`type\` in page JSON to the component that renders it. Adding a component means\n` +
|
|
311
|
+
` * THREE things: the folder, an entry here, and its schema joining the union in\n` +
|
|
312
|
+
` * src/content.config.ts.\n` +
|
|
313
|
+
` *\n` +
|
|
314
|
+
` * FORGETTING THIS FILE IS THE MOST COMMON BUG. Nothing errors - the build succeeds and the\n` +
|
|
315
|
+
` * block renders as nothing. Miss the union instead and valid JSON fails to build.\n` +
|
|
316
|
+
` */\n` +
|
|
317
|
+
`export const blocks: Record<string, AstroComponentFactory> = {};\n\n` +
|
|
318
|
+
`export const registeredTypes = (): string[] => Object.keys(blocks);\n`;
|
|
319
|
+
|
|
320
|
+
files['src/content.config.ts'] =
|
|
321
|
+
`import { webmontereyCollections } from '@cparkerwebm/webmonterey/content';\n\n` +
|
|
322
|
+
`/*\n` +
|
|
323
|
+
` * The block union is what makes a typo in page JSON a BUILD ERROR rather than a blank space.\n` +
|
|
324
|
+
` * Add each component's schema as you build it:\n` +
|
|
325
|
+
` *\n` +
|
|
326
|
+
` * import { schema as content000001 } from './components/content/content-000001/schema.ts';\n` +
|
|
327
|
+
` * export const collections = webmontereyCollections([content000001]);\n` +
|
|
328
|
+
` *\n` +
|
|
329
|
+
` * A union needs at least one member, so this stays commented until the first component exists.\n` +
|
|
330
|
+
` */\n` +
|
|
331
|
+
`export const collections = webmontereyCollections([]);\n`;
|
|
332
|
+
|
|
333
|
+
files['src/styles/custom/_index.css'] =
|
|
334
|
+
`/*\n` +
|
|
335
|
+
` * The client override seam.\n` +
|
|
336
|
+
` *\n` +
|
|
337
|
+
` * webm.components.custom beats webm.components.core at identical specificity, so a rule here\n` +
|
|
338
|
+
` * lands without !important and stays legible as an override. webm.overrides is the last word.\n` +
|
|
339
|
+
` *\n` +
|
|
340
|
+
` * To retheme, change the TOKEN in design.json rather than the rule - one declaration cascades\n` +
|
|
341
|
+
` * everywhere. Reach for this file when the token system genuinely does not express something.\n` +
|
|
342
|
+
` *\n` +
|
|
343
|
+
` * NOTHING IMPORTS THIS FILE FROM THIS REPO. The package's base layout pulls it in through a\n` +
|
|
344
|
+
` * virtual module, so it ships automatically and there is no entry point here to keep in step.\n` +
|
|
345
|
+
` */\n\n` +
|
|
346
|
+
`@layer webm.components.custom {\n}\n\n` +
|
|
347
|
+
`@layer webm.overrides {\n}\n`;
|
|
348
|
+
|
|
349
|
+
files['src/content/pages/home.json'] =
|
|
350
|
+
JSON.stringify(
|
|
351
|
+
{
|
|
352
|
+
title: client === 'CHANGEME' ? 'Home' : client,
|
|
353
|
+
showTitle: true,
|
|
354
|
+
description: '',
|
|
355
|
+
blocks: [],
|
|
356
|
+
},
|
|
357
|
+
null,
|
|
358
|
+
2,
|
|
359
|
+
) + '\n';
|
|
360
|
+
|
|
361
|
+
/*
|
|
362
|
+
* The manifest, filled in rather than left on CHANGEME. Generation 2 shipped placeholders here
|
|
363
|
+
* and they reached production on more than one site - a manifest is not a page, so nobody looks
|
|
364
|
+
* at it, and "CHANGEME" only ever surfaces in an install prompt on someone's phone.
|
|
365
|
+
*/
|
|
366
|
+
files['public/site.webmanifest'] =
|
|
367
|
+
JSON.stringify(
|
|
368
|
+
{
|
|
369
|
+
name: client === 'CHANGEME' ? domain : client,
|
|
370
|
+
short_name: client === 'CHANGEME' ? domain : client,
|
|
371
|
+
icons: [
|
|
372
|
+
{ src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
|
|
373
|
+
{
|
|
374
|
+
src: '/android-chrome-512x512.png',
|
|
375
|
+
sizes: '512x512',
|
|
376
|
+
type: 'image/png',
|
|
377
|
+
purpose: 'any maskable',
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
theme_color: '#006abe',
|
|
381
|
+
background_color: '#ffffff',
|
|
382
|
+
display: 'standalone',
|
|
383
|
+
start_url: '/',
|
|
384
|
+
},
|
|
385
|
+
null,
|
|
386
|
+
2,
|
|
387
|
+
) + '\n';
|
|
388
|
+
|
|
389
|
+
files['.gitignore'] =
|
|
390
|
+
`node_modules/\ndist/\n.astro/\n.wrangler/\nworker-configuration.d.ts\n.DS_Store\n\n` +
|
|
391
|
+
`# Secrets. Never committed, never uploaded - wrangler secret put is the only path.\n` +
|
|
392
|
+
`.dev.vars\n.dev.vars.*\n.env\n.env.*\n\n` +
|
|
393
|
+
`# Refused by preinstall; here as belt and braces.\npnpm-lock.yaml\nyarn.lock\nbun.lock*\n`;
|
|
394
|
+
|
|
395
|
+
files['.dev.vars.example'] =
|
|
396
|
+
`# Copy to .dev.vars for local development. NEVER commit .dev.vars.\n` +
|
|
397
|
+
`# Anything here must also exist as a real Worker secret - \`wrangler secret put\`.\n` +
|
|
398
|
+
`# Record each one in the password manager as you create it: wrangler cannot read a secret back.\n\n` +
|
|
399
|
+
`# TURNSTILE_SECRET_KEY=\n` +
|
|
400
|
+
`# MAILGUN_API_KEY=\n` +
|
|
401
|
+
`# MAILGUN_DOMAIN=\n`;
|
|
402
|
+
|
|
403
|
+
files['.nvmrc'] = '24\n';
|
|
404
|
+
|
|
405
|
+
files['README.md'] =
|
|
406
|
+
`# ${client === 'CHANGEME' ? n.repo : client}\n\n` +
|
|
407
|
+
`${domain} — built on [@cparkerwebm/webmonterey](https://github.com/cparkerwebm/webmonterey).\n\n` +
|
|
408
|
+
`## Commands\n\n` +
|
|
409
|
+
`| Command | Runs |\n| --- | --- |\n` +
|
|
410
|
+
`| \`npm run dev\` | local dev server |\n` +
|
|
411
|
+
`| \`npm run preview\` | **a real build on real workerd** — use this, not dev, for anything touching styles, routes or wrangler.jsonc |\n` +
|
|
412
|
+
`| \`npm run check\` | types and content schema |\n` +
|
|
413
|
+
`| \`npx webm doctor\` | the things that fail silently |\n\n` +
|
|
414
|
+
`## Cloudflare\n\n` +
|
|
415
|
+
`| | |\n| --- | --- |\n` +
|
|
416
|
+
`| Worker | \`${n.worker}\` |\n| D1 | \`${n.d1}\` |\n| R2 media | \`${n.r2Media}\` |\n\n` +
|
|
417
|
+
`The repo is named for the domain; Cloudflare resources use the slug, with no TLD, so a\n` +
|
|
418
|
+
`preview hostname never embeds a domain Chrome could mistake for a lookalike.\n\n` +
|
|
419
|
+
`## Deploying\n\n` +
|
|
420
|
+
`Push to deploy. A \`wrangler deploy\` from a laptop creates a version no build produced, so\n` +
|
|
421
|
+
`history stops describing what is live and the next push reverts it.\n`;
|
|
422
|
+
|
|
423
|
+
return files;
|
|
424
|
+
}
|
package/src/cli/seed.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Files a site needs ON DISK that the package cannot supply from node_modules.
|
|
3
|
+
*
|
|
4
|
+
* These are the SEED tier - see the header of sync.ts for the other two. Written once by
|
|
5
|
+
* `webm new`, then the client's outright. Nothing here is ever refreshed by an upgrade, because
|
|
6
|
+
* every one of them is a starting point somebody is expected to edit:
|
|
7
|
+
*
|
|
8
|
+
* public/_headers the CSP grows a host for every third party this client adds
|
|
9
|
+
* public/favicon*, og replaced with the client's marks at launch
|
|
10
|
+
* src/assets/images/ the placeholder logo, likewise
|
|
11
|
+
* src/forms/contact.json recipients, fields and autoresponse copy are per-client
|
|
12
|
+
* CLAUDE.md, CONTENT.md guidance a client repo may add to
|
|
13
|
+
* .editorconfig etc editor and formatter settings
|
|
14
|
+
*
|
|
15
|
+
* WHY public/ CANNOT COME FROM THE PACKAGE. Astro copies public/ verbatim from the site root
|
|
16
|
+
* into the build output. There is no hook that contributes to it and no way to point it at a
|
|
17
|
+
* dependency, so a favicon that lives only in node_modules is a favicon that never ships.
|
|
18
|
+
*/
|
|
19
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, relative } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Where each part of `template/` lands in a site. */
|
|
23
|
+
const SEEDS: ReadonlyArray<{ from: string; to: string }> = [
|
|
24
|
+
{ from: 'site', to: '.' },
|
|
25
|
+
{ from: 'public', to: 'public' },
|
|
26
|
+
{ from: 'assets', to: 'src/assets/images' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function walk(dir: string): string[] {
|
|
30
|
+
if (!existsSync(dir)) return [];
|
|
31
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
32
|
+
if (
|
|
33
|
+
e.name.startsWith('.') &&
|
|
34
|
+
e.name !== '.editorconfig' &&
|
|
35
|
+
e.name !== '.prettierrc.json' &&
|
|
36
|
+
e.name !== '.prettierignore'
|
|
37
|
+
) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const full = join(dir, e.name);
|
|
41
|
+
return e.isDirectory() ? walk(full) : [full];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Copy the seed tree into a site.
|
|
47
|
+
*
|
|
48
|
+
* NEVER OVERWRITES. The scaffold runs first and its output wins: where both produce a file, the
|
|
49
|
+
* scaffold's is the one with this client's domain substituted into it, and a blind copy here
|
|
50
|
+
* would replace it with the generic template. Returns what it actually wrote.
|
|
51
|
+
*/
|
|
52
|
+
export function seed(packageRoot: string, siteRoot: string): string[] {
|
|
53
|
+
const written: string[] = [];
|
|
54
|
+
|
|
55
|
+
for (const { from, to } of SEEDS) {
|
|
56
|
+
const source = join(packageRoot, 'template', from);
|
|
57
|
+
for (const file of walk(source)) {
|
|
58
|
+
const rel = relative(source, file);
|
|
59
|
+
const dest = join(siteRoot, to === '.' ? rel : join(to, rel));
|
|
60
|
+
if (existsSync(dest)) continue;
|
|
61
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
62
|
+
cpSync(file, dest);
|
|
63
|
+
written.push(relative(siteRoot, dest));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return written.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every directory the scaffold creates but leaves empty.
|
|
72
|
+
*
|
|
73
|
+
* Git does not track directories, so an empty one vanishes on the next clone and the structure
|
|
74
|
+
* stops being self-describing - somebody adding their first form has to know that `src/forms/`
|
|
75
|
+
* is where it goes rather than seeing the folder sitting there.
|
|
76
|
+
*
|
|
77
|
+
* Walked rather than listed, because a hardcoded list goes stale the moment the scaffold grows a
|
|
78
|
+
* directory and nothing points that out.
|
|
79
|
+
*/
|
|
80
|
+
export function gitkeepEmptyDirs(siteRoot: string, dirs: readonly string[]): string[] {
|
|
81
|
+
const kept: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
const full = join(siteRoot, dir);
|
|
85
|
+
mkdirSync(full, { recursive: true });
|
|
86
|
+
if (readdirSync(full).length > 0) continue;
|
|
87
|
+
writeFileSync(join(full, '.gitkeep'), '');
|
|
88
|
+
kept.push(join(dir, '.gitkeep'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Anything nested that came out empty too - src/components/* above all. */
|
|
92
|
+
const walkDirs = (d: string): void => {
|
|
93
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
94
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
95
|
+
const full = join(d, e.name);
|
|
96
|
+
if (readdirSync(full).length === 0) {
|
|
97
|
+
writeFileSync(join(full, '.gitkeep'), '');
|
|
98
|
+
kept.push(join(relative(siteRoot, full), '.gitkeep'));
|
|
99
|
+
} else {
|
|
100
|
+
walkDirs(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
walkDirs(siteRoot);
|
|
105
|
+
|
|
106
|
+
return [...new Set(kept)].sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The directories a site has before anyone adds anything to them.
|
|
111
|
+
*
|
|
112
|
+
* `src/pages/` is here and matters most: the package injects `/[...slug]`, `/robots.txt`,
|
|
113
|
+
* `/webm` and `/404`, so a site needs no page files at all - but Astro still expects the
|
|
114
|
+
* directory, and a site author needs somewhere obvious to put a bespoke route.
|
|
115
|
+
*/
|
|
116
|
+
export const SCAFFOLD_DIRS: readonly string[] = [
|
|
117
|
+
'migrations',
|
|
118
|
+
'public',
|
|
119
|
+
'src/assets/fonts',
|
|
120
|
+
'src/assets/icons',
|
|
121
|
+
'src/assets/images',
|
|
122
|
+
'src/components/asides',
|
|
123
|
+
'src/components/content',
|
|
124
|
+
'src/components/general',
|
|
125
|
+
'src/components/interfaces',
|
|
126
|
+
'src/components/regions',
|
|
127
|
+
'src/content/pages',
|
|
128
|
+
'src/forms',
|
|
129
|
+
'src/pages',
|
|
130
|
+
'src/pages/webapp',
|
|
131
|
+
'src/scripts',
|
|
132
|
+
'src/styles/custom',
|
|
133
|
+
];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DomainError, normalizeDomain, repoName, resourceNames, slugFor } from './slug.ts';
|
|
4
|
+
|
|
5
|
+
test('a domain is normalized from whatever was pasted', () => {
|
|
6
|
+
for (const input of [
|
|
7
|
+
'example.com',
|
|
8
|
+
'https://example.com',
|
|
9
|
+
'www.example.com',
|
|
10
|
+
'HTTPS://WWW.Example.com/about',
|
|
11
|
+
'example.com.',
|
|
12
|
+
]) {
|
|
13
|
+
assert.equal(normalizeDomain(input), 'example.com', input);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('something that is not a domain is refused, naming what was expected', () => {
|
|
18
|
+
assert.throws(() => normalizeDomain('acme'), DomainError);
|
|
19
|
+
assert.throws(() => normalizeDomain(''), DomainError);
|
|
20
|
+
assert.throws(() => normalizeDomain('exa mple.com'), DomainError);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('the repo keeps the full domain, dots to UNDERSCORES', () => {
|
|
24
|
+
assert.equal(repoName('autire.com'), 'autire_com');
|
|
25
|
+
assert.equal(repoName('friendsofthemarinalibrary.org'), 'friendsofthemarinalibrary_org');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the three names are all different, and each has a job', () => {
|
|
29
|
+
// repo is unambiguous about the site; slug drops the TLD so Chrome does not flag preview
|
|
30
|
+
// hostnames; worker is the slug prefixed.
|
|
31
|
+
const n = resourceNames('autire.com');
|
|
32
|
+
assert.equal(n.repo, 'autire_com');
|
|
33
|
+
assert.equal(n.slug, 'autire');
|
|
34
|
+
assert.equal(n.worker, 'webm-autire');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the slug drops the TLD - this is what stops Chrome flagging preview links', () => {
|
|
38
|
+
// webm-autire-com contains autire-com, which reads as a domain. webm-autire does not.
|
|
39
|
+
assert.equal(slugFor('autire.com'), 'autire');
|
|
40
|
+
assert.equal(slugFor('stevenglaze.com'), 'stevenglaze');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a two-part public suffix drops both labels', () => {
|
|
44
|
+
assert.equal(slugFor('example.co.uk'), 'example');
|
|
45
|
+
assert.equal(slugFor('example.com.au'), 'example');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a subdomain is kept - two of ours could differ only by it', () => {
|
|
49
|
+
assert.equal(slugFor('shop.example.com'), 'shop-example');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a single-label result never comes back empty', () => {
|
|
53
|
+
assert.ok(slugFor('a.com').length > 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('every Cloudflare name derives from one domain, and the repo differs on purpose', () => {
|
|
57
|
+
assert.deepEqual(resourceNames('autire.com'), {
|
|
58
|
+
slug: 'autire',
|
|
59
|
+
repo: 'autire_com',
|
|
60
|
+
worker: 'webm-autire',
|
|
61
|
+
d1: 'webm-autire-db',
|
|
62
|
+
r2Media: 'webm-autire-media',
|
|
63
|
+
r2App: 'webm-autire-app',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('two clients on the same name under different TLDs collide, which the caller must handle', () => {
|
|
68
|
+
// clients.slug carries a unique constraint; `webm new` picks the next free form and records it.
|
|
69
|
+
assert.equal(slugFor('autire.com'), slugFor('autire.org'));
|
|
70
|
+
});
|