@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,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
|
+
];
|