@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,822 @@
1
+ /*
2
+ * The doctor's checks, as data.
3
+ *
4
+ * Every one maps to a trap that produced a real client incident, or to a decision that is
5
+ * invisible when it drifts. Nothing here is style: each failure is silent in normal use, which
6
+ * is the whole reason a command has to look for it.
7
+ *
8
+ * Pure - each check takes a context and returns a result, with no I/O of its own - so the suite
9
+ * is testable without a site on disk.
10
+ */
11
+ import {
12
+ APP_DIR,
13
+ appEnabled,
14
+ isValidTimeZone,
15
+ PLACEHOLDER,
16
+ isConfigured,
17
+ resolveAppPath,
18
+ workerFirstPaths,
19
+ } from '../includes/webmonterey/config.ts';
20
+ import type { SiteConfig } from '../includes/webmonterey/config.ts';
21
+ import { MCP_NAMES, MCP_SERVERS, mcpGaps } from './mcp.ts';
22
+
23
+ export type Status = 'pass' | 'warn' | 'fail';
24
+
25
+ export interface CheckResult {
26
+ status: Status;
27
+ /** What is wrong, and what it looks like when it goes wrong. */
28
+ detail?: string;
29
+ }
30
+
31
+ export interface CheckContext {
32
+ site: SiteConfig;
33
+ /** Parsed wrangler.jsonc, or null when absent. */
34
+ wrangler: {
35
+ assets?: { run_worker_first?: string[] };
36
+ compatibility_date?: string;
37
+ triggers?: { crons?: string[] };
38
+ main?: string;
39
+ d1_databases?: Array<{ binding?: string; database_name?: string; database_id?: string }>;
40
+ } | null;
41
+ /** Source of the custom Worker entrypoint named by wrangler `main`, or null. */
42
+ workerEntry: string | null;
43
+ /** Today, YYYY-MM-DD, for the compatibility-date check. */
44
+ today: string;
45
+ /** Route file path -> its source, for every file under src/pages/. */
46
+ pages: Map<string, string>;
47
+ /** Component file path -> its source, for every file under src/components/. */
48
+ components: Map<string, string>;
49
+ /** Page JSON basenames without extension, e.g. ['home', 'privacy']. */
50
+ contentPages: string[];
51
+ /** Page JSON path -> raw contents, for the block-type check. */
52
+ content: Map<string, string>;
53
+ /** src/actions/ source, keyed by path. Where a site's own actions are declared. */
54
+ actions: Map<string, string>;
55
+ /** src/includes/ source - the site's own modules, not the package's. */
56
+ includes: Map<string, string>;
57
+ /** src/emails/ source - client-owned templates. */
58
+ emails: Map<string, string>;
59
+ /** migrations/*.sql, keyed by path. */
60
+ migrations: Map<string, string>;
61
+ /** Source of src/components/registry.ts, or null when the site has none. */
62
+ registry: string | null;
63
+ /** Files that must exist on disk, mapped to whether they do. */
64
+ present: Record<string, boolean>;
65
+ /** public/ files that are byte-identical to the package's seeded placeholder. */
66
+ placeholders: string[];
67
+ /** Contents of .claude/skills/webm/.webm-sync.json, or null. */
68
+ sync: { version: string; skills: string[] } | null;
69
+ /** .mcp.json's servers and .claude/settings.json's pre-approval list, each null when absent. */
70
+ mcp: {
71
+ declared: Record<string, { url?: string }> | null;
72
+ enabled: string[] | null;
73
+ };
74
+ /** The installed package version. */
75
+ version: string;
76
+ }
77
+
78
+ export interface Check {
79
+ id: string;
80
+ title: string;
81
+ /** What the failure looks like from outside, so the report explains itself. */
82
+ silentAs: string;
83
+ run(ctx: CheckContext): CheckResult;
84
+ }
85
+
86
+ /**
87
+ * Source with comments removed.
88
+ *
89
+ * EVERY CHECK THAT SCANS SOURCE MUST USE THIS. Three separate checks have now fired on their own
90
+ * documentation: a scaffold test flagged the word "tier" inside a comment saying "nothing here
91
+ * reads a tier", an import rule flagged a comment explaining which import is forbidden, and this
92
+ * file's querySelector rule flagged a component comment that cites the rule by name.
93
+ *
94
+ * The pattern is not a coincidence. Good code explains its traps in prose, using the exact words
95
+ * the trap is about, so a naive substring scan is guaranteed to hit the explanation. A false
96
+ * positive here is worse than a miss: it teaches people the doctor cries wolf.
97
+ */
98
+ export function stripComments(source: string): string {
99
+ return source
100
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
101
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1')
102
+ .replace(/<!--[\s\S]*?-->/g, ' ');
103
+ }
104
+
105
+ const pass: CheckResult = { status: 'pass' };
106
+ const fail = (detail: string): CheckResult => ({ status: 'fail', detail });
107
+ const warn = (detail: string): CheckResult => ({ status: 'warn', detail });
108
+
109
+ /** Routes declaring `export const prerender = false`, as url paths. */
110
+ export function onDemandRoutes(pages: Map<string, string>): string[] {
111
+ const out: string[] = [];
112
+ for (const [file, source] of pages) {
113
+ if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue;
114
+ const route =
115
+ '/' +
116
+ file
117
+ .replace(/^.*src\/pages\//, '')
118
+ .replace(/\.(astro|ts|js|md)$/, '')
119
+ .replace(/\/index$/, '')
120
+ .replace(/^index$/, '');
121
+ out.push(route === '/' ? '/' : route);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Action names the PACKAGE's server provides. A site gets these for free by spreading it.
128
+ *
129
+ * Hard-coded rather than imported because this file must stay a pure function of its context -
130
+ * importing the action module would pull astro:actions into the CLI. One name today; when the
131
+ * package gains another, it goes here, and the test below is what notices.
132
+ */
133
+ const PACKAGE_ACTIONS = ['submitForm'];
134
+
135
+ /** `actions.<name>` used in files that actually import astro:actions. */
136
+ export function actionsCalled(sources: Map<string, string>): Map<string, string[]> {
137
+ const out = new Map<string, string[]>();
138
+ for (const [file, raw] of sources) {
139
+ /*
140
+ * ONLY FILES THAT IMPORT THE ACTIONS API. `actions` is an ordinary word - a component with
141
+ * `const actions = header.querySelector(...)` then calls `actions.getBoundingClientRect()`,
142
+ * and an array of them gets `.map`. Both look exactly like an action call to a regex. The
143
+ * import is the one signal that separates the API from a variable someone named well.
144
+ */
145
+ if (!/from\s+['"]astro:actions['"]/.test(raw)) continue;
146
+ const src = stripComments(raw);
147
+ for (const m of src.matchAll(/\bactions\.([a-zA-Z][a-zA-Z0-9]*)/g)) {
148
+ const name = m[1];
149
+ out.set(name, [...(out.get(name) ?? []), file]);
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** Names the site's own src/actions/ adds to the server, by any of the ways it can. */
156
+ export function actionsExported(actions: Map<string, string>): string[] {
157
+ const names = new Set<string>();
158
+ for (const [, raw] of actions) {
159
+ const src = stripComments(raw);
160
+ /* `foo: defineAction({...})` and `export const foo = defineAction({...})` */
161
+ for (const m of src.matchAll(/([a-zA-Z][a-zA-Z0-9]*)\s*:\s*defineAction\b/g)) names.add(m[1]);
162
+ for (const m of src.matchAll(/export\s+const\s+([a-zA-Z][a-zA-Z0-9]*)\s*=\s*defineAction\b/g))
163
+ names.add(m[1]);
164
+ /*
165
+ * The server object's own shorthand members - `export const server = { ...webm, portal }` -
166
+ * which is how a namespaced group of actions arrives. Read from the literal rather than by
167
+ * following the import, because the import may point anywhere.
168
+ */
169
+ const server = src.match(/export\s+const\s+server\s*=\s*\{([\s\S]*?)\n\}/);
170
+ if (server) {
171
+ for (const m of server[1].matchAll(/^\s*([a-zA-Z][a-zA-Z0-9]*)\s*(?:,|:)/gm)) names.add(m[1]);
172
+ }
173
+ }
174
+ return [...names];
175
+ }
176
+
177
+ /** CREATE TABLE / CREATE VIEW names declared across migrations/. */
178
+ export function tablesCreated(migrations: Map<string, string>): string[] {
179
+ const names = new Set<string>();
180
+ for (const [, raw] of migrations) {
181
+ for (const m of stripComments(raw).matchAll(
182
+ /CREATE\s+(?:TABLE|VIEW)(?:\s+IF\s+NOT\s+EXISTS)?\s+[`"\[]?([a-zA-Z_][a-zA-Z0-9_]*)/gi,
183
+ ))
184
+ names.add(m[1].toLowerCase());
185
+ }
186
+ return [...names];
187
+ }
188
+
189
+ /**
190
+ * Table names read or written in source.
191
+ *
192
+ * UPPERCASE KEYWORDS ONLY, deliberately. Lowercase `from` is an import on every line of every
193
+ * TypeScript file in the repo; SQL in this codebase is written in caps, and requiring that is
194
+ * what keeps this from reporting the whole project.
195
+ */
196
+ export function tablesReferenced(sources: Map<string, string>[]): Map<string, string[]> {
197
+ const out = new Map<string, string[]>();
198
+ for (const group of sources) {
199
+ for (const [file, raw] of group) {
200
+ for (const m of stripComments(raw).matchAll(
201
+ /\b(?:FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|JOIN)\s+[`"\[]?([a-z_][a-zA-Z0-9_]*)/g,
202
+ )) {
203
+ const name = m[1].toLowerCase();
204
+ if (name === 'sqlite_master' || name.startsWith('sqlite_')) continue;
205
+ out.set(name, [...(out.get(name) ?? []), file]);
206
+ }
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ export const CHECKS: Check[] = [
213
+ {
214
+ id: 'actions-exist',
215
+ title: 'Every action a page calls is one the server exports',
216
+ silentAs:
217
+ 'the button posts to an action that is not there - no build error, and nothing said at runtime until somebody submits',
218
+ run(ctx) {
219
+ const called = actionsCalled(new Map([...ctx.pages, ...ctx.components, ...ctx.includes]));
220
+ if (called.size === 0) return pass;
221
+
222
+ const available = new Set([...PACKAGE_ACTIONS, ...actionsExported(ctx.actions)]);
223
+ const missing = [...called].filter(([name]) => !available.has(name));
224
+ if (missing.length === 0) return pass;
225
+
226
+ return fail(
227
+ missing
228
+ .map(([name, files]) => `actions.${name} (${files[0]}) is not exported by src/actions/`)
229
+ .join('; '),
230
+ );
231
+ },
232
+ },
233
+ {
234
+ id: 'migrations-cover-tables',
235
+ title: 'Every table the code reads has a migration that creates it',
236
+ silentAs:
237
+ 'production keeps working against tables that already exist there while local dev has none - and the repo can no longer rebuild its own database',
238
+ run(ctx) {
239
+ if (!ctx.site.features?.d1) return pass;
240
+ if (ctx.migrations.size === 0) return pass;
241
+
242
+ const created = new Set(tablesCreated(ctx.migrations));
243
+ const referenced = tablesReferenced([ctx.includes, ctx.pages, ctx.components, ctx.actions]);
244
+ const missing = [...referenced].filter(([t]) => !created.has(t));
245
+ if (missing.length === 0) return pass;
246
+
247
+ return fail(
248
+ missing
249
+ .map(([t, files]) => `${t} (used in ${files[0]}) has no CREATE in migrations/`)
250
+ .join('; '),
251
+ );
252
+ },
253
+ },
254
+ {
255
+ id: 'd1-binding',
256
+ title: 'features.d1 has a database bound to write to',
257
+ silentAs:
258
+ 'the form validates, thanks the visitor and stores nothing, because the binding it writes through does not exist',
259
+ run(ctx) {
260
+ if (!ctx.site.features?.d1) return pass;
261
+ const bindings = ctx.wrangler?.d1_databases ?? [];
262
+ if (bindings.some((b) => b.binding === 'DB' && b.database_id)) return pass;
263
+ return fail(
264
+ bindings.length === 0
265
+ ? 'features.d1 is true and wrangler has no d1_databases block'
266
+ : 'features.d1 is true but no d1_databases entry binds DB with a database_id',
267
+ );
268
+ },
269
+ },
270
+ {
271
+ id: 'run-worker-first',
272
+ title: 'Every on-demand route is in run_worker_first, in both slash forms',
273
+ silentAs: '200 to curl, a 404 page in Chrome - the router branches on Sec-Fetch-Dest',
274
+ run(ctx) {
275
+ const listed = ctx.wrangler?.assets?.run_worker_first ?? [];
276
+ const missing: string[] = [];
277
+ /*
278
+ * The app folder is served at its PUBLIC path, which is what the asset router sees. A
279
+ * route file at src/pages/webapp/x.astro is reached as /portal/x when the site has named
280
+ * a path, and that is the entry run_worker_first needs.
281
+ */
282
+ const appPath = appEnabled(ctx.site) ? resolveAppPath(ctx.site) : APP_DIR;
283
+ const publicRoute = (route: string) =>
284
+ route === `/${APP_DIR}` || route.startsWith(`/${APP_DIR}/`)
285
+ ? `/${appPath}${route.slice(APP_DIR.length + 1)}`
286
+ : route;
287
+ for (const route of onDemandRoutes(ctx.pages).map(publicRoute)) {
288
+ const covered = (p: string) =>
289
+ listed.some(
290
+ (entry) => entry === p || (entry.endsWith('/*') && p.startsWith(entry.slice(0, -1))),
291
+ );
292
+ for (const form of [route, `${route}/`]) if (!covered(form)) missing.push(form);
293
+ }
294
+ return missing.length ? fail(`not listed: ${[...new Set(missing)].join(', ')}`) : pass;
295
+ },
296
+ },
297
+ {
298
+ id: 'app-namespace',
299
+ title: 'The web app namespace is wired for the path it is served at',
300
+ silentAs:
301
+ 'the app 404s in a browser and works in curl, a marketing page shadows the portal, or a portal page prerenders and never sees a binding',
302
+ run(ctx) {
303
+ const appPages = [...ctx.pages.keys()].filter((f) => f.includes(`src/pages/${APP_DIR}/`));
304
+
305
+ if (!appEnabled(ctx.site)) {
306
+ return appPages.length
307
+ ? warn(
308
+ `src/pages/${APP_DIR}/ has ${appPages.length} route(s) but app.enabled is false in ` +
309
+ `webmonterey.json, so nothing derives the noindex, sitemap exclusion or ` +
310
+ `run_worker_first entries for them. Switch it on, or move the routes.`,
311
+ )
312
+ : pass;
313
+ }
314
+
315
+ const path = resolveAppPath(ctx.site);
316
+ const problems: string[] = [];
317
+
318
+ /* Both slash forms and the wildcard, at the PUBLIC path - see workerFirstPaths. */
319
+ const listed = new Set(ctx.wrangler?.assets?.run_worker_first ?? []);
320
+ const needed = workerFirstPaths(ctx.site).filter((p) => p !== '/_actions/*');
321
+ const absent = needed.filter((p) => !listed.has(p));
322
+ if (absent.length) problems.push(`run_worker_first is missing ${absent.join(', ')}`);
323
+
324
+ /* A page JSON named like the app path would render at the same URL and one would win. */
325
+ if (ctx.contentPages.includes(path)) {
326
+ problems.push(`src/content/pages/${path}.json collides with app.path "${path}"`);
327
+ }
328
+
329
+ /* A rewrite can only reach a route the Worker renders; a prerendered app page is a file. */
330
+ const prerendered = appPages.filter(
331
+ (f) => !/export\s+const\s+prerender\s*=\s*false/.test(ctx.pages.get(f) ?? ''),
332
+ );
333
+ if (prerendered.length) {
334
+ problems.push(`not \`prerender = false\`: ${prerendered.join(', ')}`);
335
+ }
336
+
337
+ return problems.length ? fail(problems.join('; ')) : pass;
338
+ },
339
+ },
340
+ {
341
+ id: 'changeme',
342
+ title: 'No CHANGEME left in webmonterey.json',
343
+ silentAs: 'a placeholder in a credit link, an email subject, or structured data',
344
+ run(ctx) {
345
+ const stale = Object.entries(ctx.site)
346
+ .filter(([, v]) => v === PLACEHOLDER)
347
+ .map(([k]) => k);
348
+ return stale.length ? fail(`still placeholder: ${stale.join(', ')}`) : pass;
349
+ },
350
+ },
351
+ {
352
+ id: 'timezone',
353
+ title: 'timeZone is a real IANA zone',
354
+ silentAs: 'Intl.DateTimeFormat throws at runtime, in a Worker, not on your laptop',
355
+ run(ctx) {
356
+ const zone = ctx.site.timeZone;
357
+ if (!zone) return warn('unset - defaults to America/Los_Angeles');
358
+ return isValidTimeZone(zone)
359
+ ? pass
360
+ : fail(
361
+ `"${zone}" is not a zone Intl knows. There is no Pacific/LA; the Pacific/* zones are ocean locations.`,
362
+ );
363
+ },
364
+ },
365
+ {
366
+ id: 'skills-synced',
367
+ title: 'Fleet skills are materialized and match the installed version',
368
+ silentAs: 'no /webm: skills in the session, and no error saying why',
369
+ run(ctx) {
370
+ if (!ctx.sync) {
371
+ return fail(
372
+ '.claude/skills/webm/ is missing. `npm install --ignore-scripts` skips the postinstall sync silently - run `npx webm sync`.',
373
+ );
374
+ }
375
+ return ctx.sync.version === ctx.version
376
+ ? pass
377
+ : warn(`synced v${ctx.sync.version}, installed v${ctx.version} - run \`npx webm sync\``);
378
+ },
379
+ },
380
+ {
381
+ /*
382
+ * THE DOCS SERVERS, AND WHY A CHECK RATHER THAN TRUST.
383
+ *
384
+ * Astro ships majors faster than any training corpus turns over and the web platform moves
385
+ * continuously, so a session working from recall writes code against an API that changed. The
386
+ * servers are the fix; this check is what notices when a site has quietly lost them - a
387
+ * merge that dropped .mcp.json, a settings file rewritten by hand, a site scaffolded before
388
+ * the server existed. None of those announce themselves. The site keeps building, and the
389
+ * only symptom is worse code, months later, with nothing to point at.
390
+ *
391
+ * DECLARED IS NOT ENOUGH. A server missing from `enabledMcpjsonServers` prompts for approval
392
+ * on every machine, so the rule that depends on it holds only for whoever happened to hit
393
+ * Approve. That is reported separately because the fix is different.
394
+ */
395
+ id: 'mcp-docs',
396
+ title: 'The four documentation servers are wired and pre-approved',
397
+ silentAs: 'code written from training-data recall instead of current docs',
398
+ run(ctx) {
399
+ if (!ctx.mcp.declared) return fail('no .mcp.json - run `npx webm upgrade` to restore it');
400
+ const { undeclared, unapproved, wrongUrl } = mcpGaps(ctx.mcp.declared, ctx.mcp.enabled);
401
+ const problems = [
402
+ undeclared.length && `missing from .mcp.json: ${undeclared.join(', ')}`,
403
+ unapproved.length &&
404
+ `declared but not in enabledMcpjsonServers, so inert until someone approves them by ` +
405
+ `hand: ${unapproved.join(', ')}`,
406
+ wrongUrl.length &&
407
+ wrongUrl
408
+ .map(
409
+ (n) => `${n} points at ${ctx.mcp.declared?.[n]?.url}, expected ${MCP_SERVERS[n].url}`,
410
+ )
411
+ .join('; '),
412
+ ].filter(Boolean);
413
+ return problems.length
414
+ ? fail(`${problems.join('. ')}. \`npx webm upgrade\` adds what is missing, in place.`)
415
+ : pass;
416
+ },
417
+ },
418
+ {
419
+ id: 'staging-email',
420
+ title: 'A staging site has somewhere to send its mail',
421
+ silentAs: 'every form submission on the preview throws instead of arriving anywhere',
422
+ run(ctx) {
423
+ /*
424
+ * The package carries no inbox of its own - a default address in a public package would
425
+ * mean a stranger's staging site mails the author - so a staging site has to name one.
426
+ * sendEmail refuses rather than guesses, which is correct and also the kind of failure
427
+ * that only shows up when somebody submits a form on a preview.
428
+ */
429
+ if (ctx.site.environment !== 'staging') return pass;
430
+ return isConfigured(ctx.site.stagingEmail)
431
+ ? pass
432
+ : fail(
433
+ 'environment is "staging" but stagingEmail is empty. Every message this site tries ' +
434
+ 'to send will throw. Set "stagingEmail" in webmonterey.json to the inbox that ' +
435
+ 'should receive test mail.',
436
+ );
437
+ },
438
+ },
439
+ {
440
+ id: 'select-element',
441
+ title: 'No querySelector<HTMLSelectElement>',
442
+ silentAs: 'ts(2344), which reads like a typo rather than a type conflict',
443
+ run(ctx) {
444
+ const hits: string[] = [];
445
+ for (const [file, src] of [...ctx.pages, ...ctx.components]) {
446
+ if (/querySelector<\s*HTMLSelectElement/.test(stripComments(src))) hits.push(file);
447
+ }
448
+ return hits.length
449
+ ? fail(
450
+ `${hits.join(', ')} - HTMLRewriter's Element interface merges with lib.dom's, and only ` +
451
+ `HTMLSelectElement shadows remove(). Cast instead: \`as HTMLSelectElement | null\`.`,
452
+ )
453
+ : pass;
454
+ },
455
+ },
456
+ {
457
+ id: 'image-on-demand',
458
+ title: 'No <Image> or getImage on an on-demand route',
459
+ silentAs: 'a dead /_image URL, in production only - astro dev serves it happily',
460
+ run(ctx) {
461
+ const routes = new Set(onDemandRoutes(ctx.pages));
462
+ const hits: string[] = [];
463
+ for (const [file, src] of ctx.pages) {
464
+ if (!/export\s+const\s+prerender\s*=\s*false/.test(src)) continue;
465
+ const code = stripComments(src);
466
+ if (/<Image\b|getImage\s*\(/.test(code) && !/Astro\.isPrerendered/.test(code))
467
+ hits.push(file);
468
+ }
469
+ return hits.length
470
+ ? fail(
471
+ `${hits.join(', ')} - imageService: 'compile' ships no runtime endpoint. Branch on ` +
472
+ `Astro.isPrerendered and fall back to a plain <img>. (${routes.size} on-demand routes)`,
473
+ )
474
+ : pass;
475
+ },
476
+ },
477
+ {
478
+ id: 'compatibility-date',
479
+ title: 'compatibility_date is not stale',
480
+ silentAs: 'EVERY page renders as the literal text "[object Object]" - build succeeds, no error',
481
+ run(ctx) {
482
+ /*
483
+ * The worst failure found in the whole build-out, and the reason this check exists.
484
+ *
485
+ * compatibility_date pins Workers runtime behavior. Let it fall far enough behind the
486
+ * installed workerd and Astro's renderer stops producing HTML: every page becomes the
487
+ * 15-byte string "[object Object]". `astro build` prints "Complete!" and exits 0. Nothing
488
+ * anywhere says the word "compatibility".
489
+ *
490
+ * Measured, not guessed: on wrangler 4.126 / astro 7.2, a date 207 days behind was broken
491
+ * and one 148 days behind was fine. So the fail threshold is 180 - inside the range where
492
+ * breakage is demonstrated - and the warning starts at 90, which is roughly one release
493
+ * cycle of warning before that. An earlier draft of this check warned at 180 and failed at
494
+ * 365; a site that was ACTUALLY rendering [object Object] got a warning from it, which is
495
+ * the check being wrong in the only direction that matters.
496
+ */
497
+ const date = ctx.wrangler?.compatibility_date;
498
+ if (!date) return ctx.wrangler ? warn('wrangler.jsonc sets no compatibility_date') : pass;
499
+
500
+ const age = (Date.parse(ctx.today) - Date.parse(date)) / 86_400_000;
501
+ if (Number.isNaN(age)) return warn(`compatibility_date is not a date: ${date}`);
502
+
503
+ /*
504
+ * THE OTHER DIRECTION, and it is the one that bites on day one. A compatibility_date newer
505
+ * than the runtime the installed wrangler bundles is refused outright - miniflare throws
506
+ * ERR_FUTURE_COMPATIBILITY_DATE and the site does not build at all. Two client sites hit
507
+ * this and it was diagnosed twice, independently, because the check only ever looked for a
508
+ * date that was too old.
509
+ */
510
+ if (age < 0) {
511
+ return fail(
512
+ `compatibility_date is ${date}, which is in the FUTURE. The runtime bundled with your ` +
513
+ `wrangler refuses a date it does not know, so the site will not build - miniflare ` +
514
+ `reports ERR_FUTURE_COMPATIBILITY_DATE. Set it to a date at least a fortnight behind ` +
515
+ `today, which is what \`webm new\` now scaffolds.`,
516
+ );
517
+ }
518
+
519
+ if (age > 180) {
520
+ return fail(
521
+ `compatibility_date is ${date}, ${Math.round(age)} days old. Run \`npm run preview\` ` +
522
+ `and open a page: if it says "[object Object]", this is why. Bump it to today, ` +
523
+ `redeploy, and check the site still behaves - the date pins runtime behavior.`,
524
+ );
525
+ }
526
+ if (age > 90) {
527
+ return warn(
528
+ `compatibility_date is ${date}, ${Math.round(age)} days old. Not broken yet. Bump it ` +
529
+ `on the next deploy, and verify on a preview URL rather than in production.`,
530
+ );
531
+ }
532
+ return pass;
533
+ },
534
+ },
535
+ {
536
+ id: 'cron-without-handler',
537
+ title: 'A Cron Trigger has a scheduled() handler to run',
538
+ silentAs: 'the cron fires on schedule and does nothing at all, forever',
539
+ run(ctx) {
540
+ /*
541
+ * The adapter GENERATES dist/server/entry.mjs, and that entry exports fetch and nothing
542
+ * else - so a cron against the default setup invokes a handler the Worker does not have.
543
+ * Verified by building examples/minimal with a cron and firing it: /__scheduled returned
544
+ * 404 from the asset router.
545
+ *
546
+ * THE ESCAPE HATCH IS REAL AND DOCUMENTED, and this check exists to point at it rather
547
+ * than to forbid crons. Setting `main` to a source file that re-exports the adapter's own
548
+ * handler leaves room for the extra exports the generated entry has no place for:
549
+ *
550
+ * import { handle } from '@astrojs/cloudflare/handler';
551
+ * export default {
552
+ * async fetch(request, env, ctx) { return handle(request, env, ctx); },
553
+ * async scheduled(controller, env, ctx) { ctx.waitUntil(work(env)); },
554
+ * } satisfies ExportedHandler<Env>;
555
+ *
556
+ * friendsofthemarinalibrary.org has run its evening summary sweep this way since
557
+ * generation 2. The trap is only the DEFAULT, and the config half of it works perfectly:
558
+ * triggers.crons merges into the generated wrangler.json and deploys without a warning.
559
+ */
560
+ const crons = ctx.wrangler?.triggers?.crons ?? [];
561
+ if (crons.length === 0) return pass;
562
+
563
+ const main = ctx.wrangler?.main;
564
+
565
+ if (!main) {
566
+ return fail(
567
+ `wrangler.jsonc declares ${crons.length} Cron Trigger(s) (${crons.join(', ')}) but sets ` +
568
+ `no "main". The adapter's generated Worker exports only fetch, so Cloudflare invokes ` +
569
+ `a scheduled handler that does not exist and nothing reports it. Point "main" at a ` +
570
+ `source entrypoint that re-exports @astrojs/cloudflare/handler and adds scheduled().`,
571
+ );
572
+ }
573
+
574
+ /*
575
+ * The Pages-era value. It names a BUILD ARTEFACT rather than a source file and fails the
576
+ * build with "main field doesn't point to an existing file" - a different failure, but the
577
+ * same wrong idea, and it is what someone reaches for first.
578
+ */
579
+ if (/dist\//.test(main)) {
580
+ return fail(
581
+ `"main" is ${main}, which points into the build output. It must name a SOURCE ` +
582
+ `entrypoint - the adapter builds it. The Pages-era ./dist/_worker.js/index.js is the ` +
583
+ `value not to use.`,
584
+ );
585
+ }
586
+
587
+ if (ctx.workerEntry === null) {
588
+ return warn(`"main" is ${main} but that file was not found from the site root.`);
589
+ }
590
+
591
+ const source = stripComments(ctx.workerEntry);
592
+
593
+ if (!/\bscheduled\b\s*[(:]/.test(source)) {
594
+ return fail(
595
+ `${main} does not export a scheduled() handler, so the ${crons.length} declared cron(s) ` +
596
+ `have nothing to run.`,
597
+ );
598
+ }
599
+
600
+ /*
601
+ * EITHER shape is correct. `defineWorker` from the package supplies the adapter's fetch and
602
+ * is the recommended form; importing `handle` directly is the underlying mechanism and is
603
+ * what a generation-2 site wrote by hand. Accepting only the second reported every site
604
+ * using the package helper as broken - which is the check being wrong, and a false alarm
605
+ * here teaches people to skip the whole report.
606
+ */
607
+ if (!/@astrojs\/cloudflare\/handler/.test(source) && !/\bdefineWorker\b/.test(source)) {
608
+ return fail(
609
+ `${main} neither calls defineWorker nor imports handle from ` +
610
+ `@astrojs/cloudflare/handler. Replacing the generated entrypoint without one of them ` +
611
+ `means every page, action and API route on the site stops being served - the cron ` +
612
+ `would work and nothing else would.`,
613
+ );
614
+ }
615
+
616
+ return pass;
617
+ },
618
+ },
619
+ {
620
+ id: 'block-types-registered',
621
+ title: 'Every block type used in content is in the registry',
622
+ silentAs: 'the block renders as NOTHING - no error, no warning, no gap in the page',
623
+ run(ctx) {
624
+ /*
625
+ * The worst failure mode in this architecture, and the reason it gets a check rather than a
626
+ * line in CLAUDE.md. The router looks up `type` in the registry and renders what it finds;
627
+ * a miss renders nothing at all. The page returns 200, the build passes, and the section is
628
+ * simply absent - which reads as a CSS problem and gets debugged as one for an hour.
629
+ *
630
+ * Typo, a type copied from another client, or a component deleted without grepping the
631
+ * content: all three land here.
632
+ */
633
+ if (ctx.registry === null) return pass;
634
+
635
+ /*
636
+ * ANY quoted key, not just the numbered convention. The pattern used to require
637
+ * `name-000000`, which is what most of the fleet uses - and autire's blocks are named
638
+ * "hero.standard", "article.feed", so NONE of its 39 registrations matched and the check
639
+ * reported every block on the site as unregistered. A convention is not a syntax.
640
+ */
641
+ const registered = new Set(
642
+ [...ctx.registry.matchAll(/['"]([A-Za-z][\w.-]*)['"]\s*:/g)].map((m) => m[1]!),
643
+ );
644
+ /* An empty registry on a site with no content yet is the scaffold's normal state. */
645
+ if (registered.size === 0 && ctx.content.size === 0) return pass;
646
+
647
+ /*
648
+ * PARSED, and only `blocks[].type`. A regex over every "type" key in the file also catches
649
+ * types that are nothing to do with the registry: a schema.org `place.type` of "BookStore",
650
+ * a form field's `type`, an icon's. It reported a real site as broken for having a bookshop
651
+ * on a page - the fourth false positive this build-out has produced from matching text
652
+ * where it should have been reading structure.
653
+ */
654
+ const missing = new Map<string, string[]>();
655
+ for (const [file, raw] of ctx.content) {
656
+ let parsed: { blocks?: Array<{ type?: unknown }> };
657
+ try {
658
+ parsed = JSON.parse(raw) as typeof parsed;
659
+ } catch {
660
+ /* A malformed page fails the content schema with a better message than this check. */
661
+ continue;
662
+ }
663
+ for (const block of parsed.blocks ?? []) {
664
+ const type = block?.type;
665
+ if (typeof type !== 'string' || registered.has(type)) continue;
666
+ missing.set(type, [...(missing.get(type) ?? []), file]);
667
+ }
668
+ }
669
+
670
+ if (missing.size === 0) return pass;
671
+ const lines = [...missing].map(([type, files]) => `${type} (${files.join(', ')})`);
672
+ return fail(
673
+ `not in src/components/registry.ts: ${lines.join('; ')}. ` +
674
+ `Those blocks render as nothing. Either register the component or remove the block.`,
675
+ );
676
+ },
677
+ },
678
+ {
679
+ id: 'agency-credit',
680
+ title: 'Something renders the agency credit',
681
+ silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
682
+ run(ctx) {
683
+ /*
684
+ * The package ships no footer - it ships no components at all - so the credit is imported
685
+ * by whichever site component renders the footer. That is the right seam and it is also
686
+ * easy to simply never do, which is how live client sites ended up without it.
687
+ *
688
+ * A warning, not a failure: a site mid-build has no footer yet, and failing there trains
689
+ * people to ignore the doctor. `/webm:launch` is where it becomes blocking.
690
+ */
691
+ if (ctx.components.size === 0) return pass;
692
+
693
+ /*
694
+ * WebMonterey's own site does not credit itself. Checking the domain rather than adding a
695
+ * config flag: there is exactly one agency site in the fleet and it is not a preference.
696
+ */
697
+ if (ctx.site.domain === 'webmonterey.com') return pass;
698
+
699
+ for (const src of ctx.components.values()) {
700
+ if (/webmonterey\/credits/.test(stripComments(src))) return pass;
701
+ }
702
+ return warn(
703
+ 'no component imports @cparkerwebm/webmonterey/webmonterey/credits/Credit.astro. ' +
704
+ 'The footer component is where it goes.',
705
+ );
706
+ },
707
+ },
708
+ {
709
+ id: 'placeholder-branding',
710
+ title: "No placeholder mark is being served as the client's",
711
+ silentAs: "the client's site shows WebMonterey's favicon, and nobody looks at a favicon",
712
+ run(ctx) {
713
+ /*
714
+ * `webm new` seeds default favicons and a share image so a fresh site is not broken. They
715
+ * are placeholders, and a rebuild is exactly where one survives: the client had .ico and
716
+ * PNGs but no SVG, the seed supplied an SVG, and browsers prefer SVG - so the agency's own
717
+ * mark became the client's icon. Nobody looks at a favicon, which is why it would have
718
+ * stayed there.
719
+ *
720
+ * Byte-identical to what the package ships is the test. A client who genuinely wants the
721
+ * default has still made a choice, but they will not have made it by accident.
722
+ */
723
+ if (ctx.placeholders.length === 0) return pass;
724
+
725
+ const detail =
726
+ `still the package's placeholder, byte for byte: ${ctx.placeholders.join(', ')}. ` +
727
+ `Replace with the client's own artwork before launch.`;
728
+
729
+ /*
730
+ * A WARNING UNTIL THE SITE HAS LAUNCHED, and a failure after.
731
+ *
732
+ * `webm new` seeds every one of these, so a freshly scaffolded site has the full set - and
733
+ * a check that fails on the scaffold's own output is one people learn to skip. It caught
734
+ * exactly that: the end-to-end test scaffolds a site and the doctor failed it.
735
+ *
736
+ * Once `launched` is set the site is somebody's, and shipping the agency's mark on it is a
737
+ * real fault rather than an unfinished one. /webm:launch is where the warning becomes
738
+ * blocking for a site that has not launched yet.
739
+ */
740
+ return isConfigured(ctx.site.launched) ? fail(detail) : warn(detail);
741
+ },
742
+ },
743
+ {
744
+ id: 'environment',
745
+ title: 'The declared environment matches where the site actually is',
746
+ silentAs: "a launched site whose client email is still being diverted to the agency's inbox",
747
+ run(ctx) {
748
+ const declared = ctx.site.environment;
749
+
750
+ if (declared !== undefined && declared !== 'production' && declared !== 'staging') {
751
+ return fail(
752
+ `environment is "${String(declared)}"; it must be "production" or "staging". ` +
753
+ `An unrecognised value is treated as production, so this site is mailing real people.`,
754
+ );
755
+ }
756
+
757
+ /*
758
+ * BOTH DIRECTIONS ARE SILENT, which is the whole reason this check exists.
759
+ *
760
+ * Left on staging after launch, every enquiry a client should have received goes to the
761
+ * agency instead - the form says thank you, the client's inbox stays empty, and the first
762
+ * report is a customer asking why nobody called back.
763
+ *
764
+ * Left on production before launch, testing a preview mails the client's real contacts:
765
+ * the contact form notifies their own inbox and the nightly sweep mails whoever organises
766
+ * a programme. `launched` is the only signal available here for which side of that line a
767
+ * site is on, and it is the same one the placeholder-artwork check reads.
768
+ */
769
+ if (declared === 'staging' && isConfigured(ctx.site.launched)) {
770
+ return fail(
771
+ `this site launched on ${ctx.site.launched} but is still declared staging, so every ` +
772
+ `email it sends is being redirected away from its real recipients. Set ` +
773
+ `"environment": "production" in webmonterey.json.`,
774
+ );
775
+ }
776
+
777
+ if (declared !== 'staging' && !isConfigured(ctx.site.launched)) {
778
+ return warn(
779
+ `this site has no launch date but is treated as production, so testing a form will ` +
780
+ `email the client's real contacts. Set "environment": "staging" in ` +
781
+ `webmonterey.json until /webm:launch.`,
782
+ );
783
+ }
784
+
785
+ return pass;
786
+ },
787
+ },
788
+ {
789
+ id: 'seeded-files',
790
+ title: 'The files Astro copies verbatim are present',
791
+ silentAs: 'a missing favicon or an absent _headers - no build error either way',
792
+ run(ctx) {
793
+ /*
794
+ * public/ is copied verbatim into the build. Nothing validates it, so a deleted _headers
795
+ * costs the site every security header it had and the build says nothing at all.
796
+ */
797
+ const gone = Object.entries(ctx.present)
798
+ .filter(([, there]) => !there)
799
+ .map(([file]) => file);
800
+ return gone.length
801
+ ? warn(`missing: ${gone.join(', ')}. \`webm sync\` restores package-owned ones.`)
802
+ : pass;
803
+ },
804
+ },
805
+ {
806
+ id: 'literal-values',
807
+ title: 'Component CSS contains no literal colors or sizes',
808
+ silentAs: 'drift away from the token system, one component at a time',
809
+ run(ctx) {
810
+ const hits: string[] = [];
811
+ for (const [file, src] of ctx.components) {
812
+ const styles = [...src.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)]
813
+ .map((m) => m[1]!)
814
+ .join('\n');
815
+ if (!styles) continue;
816
+ const withoutVars = stripComments(styles).replace(/--webm-[\w-]+\s*:[^;]+;/g, '');
817
+ if (/#[0-9a-fA-F]{3,8}\b|\brgb a?\(|\bhsl a?\(/.test(withoutVars)) hits.push(file);
818
+ }
819
+ return hits.length ? warn(`literal colors in: ${hits.join(', ')}`) : pass;
820
+ },
821
+ },
822
+ ];