@cparkerwebm/webmonterey 1.1.0 → 1.3.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 +104 -0
- package/README.md +6 -0
- package/dist/webm.mjs +445 -61
- package/package.json +5 -3
- package/skills/launch/SKILL.md +57 -9
- package/skills/start/SKILL.md +66 -12
- package/skills/traps/SKILL.md +17 -0
- package/src/assets/opengraph-webmaster.png +0 -0
- package/src/cli/audit.test.ts +120 -0
- package/src/cli/audit.ts +323 -0
- package/src/cli/checks.test.ts +46 -6
- package/src/cli/checks.ts +52 -12
- package/src/cli/doctor.ts +78 -2
- package/src/cli/scaffold.test.ts +9 -0
- package/src/cli/scaffold.ts +12 -30
- package/src/cli/settings.ts +106 -0
- package/src/cli/sync.test.ts +61 -0
- package/src/cli/sync.ts +64 -1
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/media.ts +1 -1
- package/src/includes/webmonterey/config.test.ts +51 -0
- package/src/includes/webmonterey/config.ts +49 -0
- package/src/includes/webmonterey/copy-defaults.ts +20 -0
- package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
- package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
- package/src/integration/index.ts +91 -4
- package/src/integration/virtual.d.ts +19 -1
- package/src/layouts/base.astro +31 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +14 -0
- package/src/pages/webmaster-og.png.ts +31 -0
- package/src/pages/webmaster.astro +121 -0
- package/template/public/opengraph.png +0 -0
- package/template/scripts/test-hooks.mjs +1 -1
- package/template/site/CLAUDE.md +25 -2
- package/src/includes/webmonterey/credits/Credit.astro +0 -80
- package/src/includes/webmonterey/credits/credit.test.ts +0 -111
- package/src/includes/webmonterey/credits/credit.ts +0 -59
- package/template/public/open-graph.png +0 -0
- /package/template/assets/{open-graph.png → opengraph.png} +0 -0
package/src/cli/audit.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `webm audit [dist/client]` - the pre-launch checks that only a BUILD can answer.
|
|
3
|
+
*
|
|
4
|
+
* `webm doctor` reads source. Some things are only visible in the output: whether every image
|
|
5
|
+
* that reached the page has alt text, whether every internal link lands on a file the build
|
|
6
|
+
* produced (or a route the Worker serves), whether the sitemap the build wrote is complete and
|
|
7
|
+
* advertised. Each is the kind of thing a launch checklist says to "check" and nobody does
|
|
8
|
+
* exhaustively by hand on a forty-page site.
|
|
9
|
+
*
|
|
10
|
+
* Pure where it can be: `audit()` takes the built files and returns findings, so the rules are
|
|
11
|
+
* tested with literal HTML. `run()` reads dist/ and, unless told not to, makes one request per
|
|
12
|
+
* unique external link - the one part that needs a network.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT DOES NOT DO: write alt text. It lists the images that have none; the launch skill is
|
|
15
|
+
* what looks at each one and writes the words. An empty `alt=""` is a valid declaration that an
|
|
16
|
+
* image is decorative and is not reported - only a MISSING attribute is.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
19
|
+
import { join, relative, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
export interface AuditInput {
|
|
22
|
+
/** Relative html path -> contents, for every page in the build. */
|
|
23
|
+
pages: Map<string, string>;
|
|
24
|
+
/** Whether a relative path exists in the build output. */
|
|
25
|
+
exists: (rel: string) => boolean;
|
|
26
|
+
/** Read a relative non-html file from the build, or null. */
|
|
27
|
+
read: (rel: string) => string | null;
|
|
28
|
+
/** wrangler.jsonc's run_worker_first, so an on-demand route is not reported as broken. */
|
|
29
|
+
workerFirst: string[];
|
|
30
|
+
/** The production origin, for sitemap and external-link decisions. Undefined while unset. */
|
|
31
|
+
origin?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuditReport {
|
|
35
|
+
missingAlt: { page: string; src: string }[];
|
|
36
|
+
brokenInternal: { page: string; href: string }[];
|
|
37
|
+
/** Unique external URLs, for the caller to probe. */
|
|
38
|
+
external: string[];
|
|
39
|
+
sitemap: { problems: string[]; urls: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The value of one attribute on a tag, or null when absent. Quoted or bare. */
|
|
43
|
+
export function attr(tag: string, name: string): string | null {
|
|
44
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
return m[1] ?? m[2] ?? m[3] ?? '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether an attribute is present at all, with or without a value. */
|
|
50
|
+
export function hasAttr(tag: string, name: string): boolean {
|
|
51
|
+
return new RegExp(`\\s${name}(?=[\\s=>/])`, 'i').test(tag);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every `<img>` on a page that declares no alt attribute at all. */
|
|
55
|
+
export function imagesWithoutAlt(html: string): string[] {
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
58
|
+
const tag = m[0];
|
|
59
|
+
if (hasAttr(tag, 'alt')) continue;
|
|
60
|
+
out.push(attr(tag, 'src') ?? '(no src)');
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
|
|
66
|
+
|
|
67
|
+
/** The hrefs on a page, split into internal paths and external URLs. */
|
|
68
|
+
export function links(html: string, origin?: string): { internal: string[]; external: string[] } {
|
|
69
|
+
const internal = new Set<string>();
|
|
70
|
+
const external = new Set<string>();
|
|
71
|
+
for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
|
|
72
|
+
const href = attr(m[0], 'href');
|
|
73
|
+
if (!href || SKIP_SCHEMES.test(href)) continue;
|
|
74
|
+
if (/^https?:\/\//i.test(href)) {
|
|
75
|
+
/* A link to the site's own origin is internal - check it as a path. */
|
|
76
|
+
if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || '/');
|
|
77
|
+
else external.add(href);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (href.startsWith('//')) {
|
|
81
|
+
external.add(`https:${href}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
internal.add(href);
|
|
85
|
+
}
|
|
86
|
+
return { internal: [...internal], external: [...external] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Does an internal href land somewhere? A file the build wrote, in any of the forms the asset
|
|
91
|
+
* router would serve it under, or a route the Worker answers.
|
|
92
|
+
*/
|
|
93
|
+
export function resolves(href: string, input: Pick<AuditInput, 'exists' | 'workerFirst'>): boolean {
|
|
94
|
+
let path = href.split('#')[0]!.split('?')[0]!;
|
|
95
|
+
try {
|
|
96
|
+
path = decodeURIComponent(path);
|
|
97
|
+
} catch {
|
|
98
|
+
/* leave it */
|
|
99
|
+
}
|
|
100
|
+
if (!path.startsWith('/')) return true; /* relative to the page; not worth a false positive */
|
|
101
|
+
if (path === '/') return input.exists('index.html');
|
|
102
|
+
|
|
103
|
+
const bare = path.replace(/\/$/, '');
|
|
104
|
+
const rel = bare.slice(1);
|
|
105
|
+
if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return input.workerFirst.some((entry) =>
|
|
109
|
+
entry.endsWith('/*')
|
|
110
|
+
? bare === entry.slice(0, -2) || bare.startsWith(entry.slice(0, -1))
|
|
111
|
+
: entry === bare || entry === `${bare}/`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `<loc>` values out of a sitemap document. */
|
|
116
|
+
export function locs(xml: string): string[] {
|
|
117
|
+
return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]!);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The sitemap the build wrote: present, advertised in robots.txt, every child present, every URL
|
|
122
|
+
* on the production origin and landing on a page.
|
|
123
|
+
*/
|
|
124
|
+
export function auditSitemap(input: AuditInput): AuditReport['sitemap'] {
|
|
125
|
+
const problems: string[] = [];
|
|
126
|
+
const index = input.read('sitemap-index.xml');
|
|
127
|
+
if (!index) {
|
|
128
|
+
return {
|
|
129
|
+
problems: [
|
|
130
|
+
input.origin
|
|
131
|
+
? 'sitemap-index.xml was not built'
|
|
132
|
+
: 'no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL',
|
|
133
|
+
],
|
|
134
|
+
urls: 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const robots = input.read('robots.txt') ?? '';
|
|
139
|
+
if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push('robots.txt has no Sitemap: line');
|
|
140
|
+
|
|
141
|
+
let urls = 0;
|
|
142
|
+
for (const childUrl of locs(index)) {
|
|
143
|
+
const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, '');
|
|
144
|
+
const child = input.read(childRel);
|
|
145
|
+
if (!child) {
|
|
146
|
+
problems.push(`${childRel} is listed in the index and was not built`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const url of locs(child)) {
|
|
150
|
+
urls++;
|
|
151
|
+
if (input.origin && !url.startsWith(input.origin)) {
|
|
152
|
+
problems.push(`${url} is not on ${input.origin}`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const path = url.replace(/^https?:\/\/[^/]+/, '') || '/';
|
|
156
|
+
if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (urls === 0) problems.push('the sitemap lists no URLs');
|
|
160
|
+
return { problems, urls };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function audit(input: AuditInput): AuditReport {
|
|
164
|
+
const missingAlt: AuditReport['missingAlt'] = [];
|
|
165
|
+
const brokenInternal: AuditReport['brokenInternal'] = [];
|
|
166
|
+
const external = new Set<string>();
|
|
167
|
+
|
|
168
|
+
for (const [page, html] of input.pages) {
|
|
169
|
+
for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
|
|
170
|
+
const found = links(html, input.origin);
|
|
171
|
+
for (const href of found.internal) {
|
|
172
|
+
if (!resolves(href, input)) brokenInternal.push({ page, href });
|
|
173
|
+
}
|
|
174
|
+
for (const url of found.external) external.add(url);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
missingAlt,
|
|
179
|
+
brokenInternal,
|
|
180
|
+
external: [...external].sort(),
|
|
181
|
+
sitemap: auditSitemap(input),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── the command ─────────────────────────────────────────────────────────────────────────── */
|
|
186
|
+
|
|
187
|
+
function walk(dir: string): string[] {
|
|
188
|
+
if (!existsSync(dir)) return [];
|
|
189
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
190
|
+
const full = join(dir, e.name);
|
|
191
|
+
return e.isDirectory() ? walk(full) : [full];
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Strip // and /* comments so JSON.parse can read a .jsonc file. */
|
|
196
|
+
function parseJsonc<T>(source: string): T {
|
|
197
|
+
const stripped = source
|
|
198
|
+
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? '' : m))
|
|
199
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
200
|
+
return JSON.parse(stripped) as T;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* One request per unique external URL. HEAD first; a 405 gets a GET, because plenty of servers
|
|
205
|
+
* refuse HEAD and answer GET. Failures are reported as warnings, not failures: many sites block
|
|
206
|
+
* anything that is not a browser, and a link that a bot cannot fetch is not necessarily broken.
|
|
207
|
+
*/
|
|
208
|
+
async function probe(urls: string[]): Promise<{ url: string; status: string }[]> {
|
|
209
|
+
const bad: { url: string; status: string }[] = [];
|
|
210
|
+
const queue = [...urls];
|
|
211
|
+
const worker = async () => {
|
|
212
|
+
for (let url = queue.shift(); url; url = queue.shift()) {
|
|
213
|
+
try {
|
|
214
|
+
let res = await fetch(url, {
|
|
215
|
+
method: 'HEAD',
|
|
216
|
+
redirect: 'follow',
|
|
217
|
+
signal: AbortSignal.timeout(8000),
|
|
218
|
+
});
|
|
219
|
+
if (res.status === 405 || res.status === 403) {
|
|
220
|
+
res = await fetch(url, {
|
|
221
|
+
method: 'GET',
|
|
222
|
+
redirect: 'follow',
|
|
223
|
+
signal: AbortSignal.timeout(8000),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (res.status >= 400) bad.push({ url, status: String(res.status) });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
bad.push({ url, status: error instanceof Error ? error.name : 'error' });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
233
|
+
return bad.sort((a, b) => a.url.localeCompare(b.url));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function run(argv: string[]): Promise<number> {
|
|
237
|
+
const dist = resolve(argv.find((a) => !a.startsWith('-')) ?? 'dist/client');
|
|
238
|
+
const noExternal = argv.includes('--no-external');
|
|
239
|
+
|
|
240
|
+
if (!existsSync(join(dist, 'index.html'))) {
|
|
241
|
+
console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const siteRoot = process.cwd();
|
|
246
|
+
const files = walk(dist);
|
|
247
|
+
const rels = new Set(files.map((f) => relative(dist, f)));
|
|
248
|
+
const pages = new Map(
|
|
249
|
+
files
|
|
250
|
+
.filter((f) => f.endsWith('.html'))
|
|
251
|
+
.map((f) => [relative(dist, f), readFileSync(f, 'utf8')]),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
|
|
255
|
+
.map((f) => join(siteRoot, f))
|
|
256
|
+
.find(existsSync);
|
|
257
|
+
const wrangler = wranglerPath
|
|
258
|
+
? parseJsonc<{ assets?: { run_worker_first?: string[] } }>(readFileSync(wranglerPath, 'utf8'))
|
|
259
|
+
: null;
|
|
260
|
+
|
|
261
|
+
const sitePath = join(siteRoot, 'webmonterey.json');
|
|
262
|
+
const site = existsSync(sitePath)
|
|
263
|
+
? (JSON.parse(readFileSync(sitePath, 'utf8')) as { domain?: string })
|
|
264
|
+
: {};
|
|
265
|
+
const origin = site.domain && site.domain !== 'CHANGEME' ? `https://${site.domain}` : undefined;
|
|
266
|
+
|
|
267
|
+
const report = audit({
|
|
268
|
+
pages,
|
|
269
|
+
exists: (rel) => rels.has(rel),
|
|
270
|
+
read: (rel) =>
|
|
271
|
+
rels.has(rel) && statSync(join(dist, rel)).isFile()
|
|
272
|
+
? readFileSync(join(dist, rel), 'utf8')
|
|
273
|
+
: null,
|
|
274
|
+
workerFirst: wrangler?.assets?.run_worker_first ?? [],
|
|
275
|
+
origin,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let failed = 0;
|
|
279
|
+
const section = (ok: boolean, title: string) => console.log(`${ok ? ' ok ' : 'FAIL '} ${title}`);
|
|
280
|
+
|
|
281
|
+
section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
|
|
282
|
+
for (const { page, src } of report.missingAlt)
|
|
283
|
+
console.log(` ${page}: <img src="${src}"> has no alt attribute`);
|
|
284
|
+
if (report.missingAlt.length) {
|
|
285
|
+
failed++;
|
|
286
|
+
console.log(
|
|
287
|
+
` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
section(
|
|
292
|
+
report.brokenInternal.length === 0,
|
|
293
|
+
'Every internal link lands on a page or a Worker route',
|
|
294
|
+
);
|
|
295
|
+
for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
|
|
296
|
+
if (report.brokenInternal.length) failed++;
|
|
297
|
+
|
|
298
|
+
section(
|
|
299
|
+
report.sitemap.problems.length === 0,
|
|
300
|
+
`The sitemap is complete and advertised (${report.sitemap.urls} URLs)`,
|
|
301
|
+
);
|
|
302
|
+
for (const p of report.sitemap.problems) console.log(` ${p}`);
|
|
303
|
+
if (report.sitemap.problems.length) failed++;
|
|
304
|
+
|
|
305
|
+
if (noExternal) {
|
|
306
|
+
console.log(` -- ${report.external.length} external links not probed (--no-external)`);
|
|
307
|
+
} else if (report.external.length) {
|
|
308
|
+
const bad = await probe(report.external);
|
|
309
|
+
console.log(
|
|
310
|
+
`${bad.length ? 'warn ' : ' ok '} ${report.external.length} external links respond (${bad.length} did not)`,
|
|
311
|
+
);
|
|
312
|
+
for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
|
|
313
|
+
if (bad.length)
|
|
314
|
+
console.log(
|
|
315
|
+
` Open each in a browser before deciding it is broken - many sites refuse bots.`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
console.log(' ok no external links');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
console.log(`\n${failed === 0 ? 'audit clean' : `${failed} check(s) failed`}`);
|
|
322
|
+
return failed ? 1 : 0;
|
|
323
|
+
}
|
package/src/cli/checks.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ const base = (over: Partial<CheckContext> = {}): CheckContext => ({
|
|
|
22
22
|
sync: { version: '1.0.0', skills: ['launch'] },
|
|
23
23
|
mcp: { declared: mcpConfig().mcpServers, enabled: [...MCP_NAMES] },
|
|
24
24
|
version: '1.0.0',
|
|
25
|
+
worker: { name: 'acme', deployments: 1, skipped: null },
|
|
25
26
|
...over,
|
|
26
27
|
});
|
|
27
28
|
|
|
@@ -217,7 +218,7 @@ test('a site with components but no credit import warns', () => {
|
|
|
217
218
|
const ctx = base({
|
|
218
219
|
components: new Map([['src/components/regions/footer/footer.astro', '<footer>hi</footer>']]),
|
|
219
220
|
});
|
|
220
|
-
const result = runCheck('
|
|
221
|
+
const result = runCheck('webmaster-credit', ctx);
|
|
221
222
|
assert.equal(result.status, 'warn');
|
|
222
223
|
assert.match(result.detail!, /footer component/);
|
|
223
224
|
});
|
|
@@ -227,15 +228,15 @@ test('a footer that imports the credit passes', () => {
|
|
|
227
228
|
components: new Map([
|
|
228
229
|
[
|
|
229
230
|
'src/components/regions/footer/footer.astro',
|
|
230
|
-
`import
|
|
231
|
+
`import Webmaster from '@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro';`,
|
|
231
232
|
],
|
|
232
233
|
]),
|
|
233
234
|
});
|
|
234
|
-
assert.equal(runCheck('
|
|
235
|
+
assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
test('a site with no components yet is not nagged', () => {
|
|
238
|
-
assert.equal(runCheck('
|
|
239
|
+
assert.equal(runCheck('webmaster-credit', base()).status, 'pass');
|
|
239
240
|
});
|
|
240
241
|
|
|
241
242
|
test('a comment explaining a trap does not trip the check that enforces it', () => {
|
|
@@ -274,7 +275,7 @@ test("WebMonterey's own site is not asked to credit itself", () => {
|
|
|
274
275
|
site: { client: 'WebMonterey', domain: 'webmonterey.com' },
|
|
275
276
|
components: new Map([['src/components/regions/footer/footer.astro', '<footer>x</footer>']]),
|
|
276
277
|
});
|
|
277
|
-
assert.equal(runCheck('
|
|
278
|
+
assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
|
|
278
279
|
});
|
|
279
280
|
|
|
280
281
|
test('a cron with no custom entrypoint FAILS, and names the fix', () => {
|
|
@@ -405,7 +406,7 @@ test("a placeholder favicon still in public/ fails - it is the agency's mark on
|
|
|
405
406
|
*/
|
|
406
407
|
const ctx = base({
|
|
407
408
|
site: { client: 'Acme', domain: 'acme.com', launched: '2026-03-01' },
|
|
408
|
-
placeholders: ['public/favicon.svg', 'public/
|
|
409
|
+
placeholders: ['public/favicon.svg', 'public/opengraph.png'],
|
|
409
410
|
});
|
|
410
411
|
const result = runCheck('placeholder-branding', ctx);
|
|
411
412
|
assert.equal(result.status, 'fail', 'a LAUNCHED site shipping the agency mark is a fault');
|
|
@@ -709,3 +710,42 @@ test('a server pointed at the wrong url is caught, and both urls are shown', ()
|
|
|
709
710
|
assert.match(r.detail!, /example\.com/);
|
|
710
711
|
assert.ok(r.detail!.includes(MCP_SERVERS.mdn.url));
|
|
711
712
|
});
|
|
713
|
+
|
|
714
|
+
/* --- the Worker exists --------------------------------------------------- */
|
|
715
|
+
|
|
716
|
+
test('a Worker with no deployment warns and says how to create it', () => {
|
|
717
|
+
/* A repo, a database and nothing serving: the failure /webm:start used to end on. */
|
|
718
|
+
const r = runCheck(
|
|
719
|
+
'worker-exists',
|
|
720
|
+
base({ worker: { name: 'acme', deployments: 0, skipped: null } }),
|
|
721
|
+
);
|
|
722
|
+
assert.equal(r.status, 'warn');
|
|
723
|
+
assert.match(r.detail!, /"acme"/);
|
|
724
|
+
assert.match(r.detail!, /wrangler deploy/);
|
|
725
|
+
assert.match(r.detail!, /Settings → Builds/);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('an unanswerable question skips with the reason, and is not a failure', () => {
|
|
729
|
+
const r = runCheck(
|
|
730
|
+
'worker-exists',
|
|
731
|
+
base({ worker: { name: 'acme', deployments: null, skipped: 'wrangler is not logged in' } }),
|
|
732
|
+
);
|
|
733
|
+
assert.equal(r.status, 'pass');
|
|
734
|
+
assert.match(r.detail!, /skipped: wrangler is not logged in/);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test('a deployed Worker passes', () => {
|
|
738
|
+
assert.equal(runCheck('worker-exists', base()).status, 'pass');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test('a launched site still declared staging fails, and the message names the search consequence', () => {
|
|
742
|
+
const r = runCheck(
|
|
743
|
+
'environment',
|
|
744
|
+
base({
|
|
745
|
+
site: { client: 'A', domain: 'a.com', environment: 'staging', launched: '2026-09-01' },
|
|
746
|
+
}),
|
|
747
|
+
);
|
|
748
|
+
assert.equal(r.status, 'fail');
|
|
749
|
+
assert.match(r.detail!, /noindex/);
|
|
750
|
+
assert.match(r.detail!, /out of search/);
|
|
751
|
+
});
|
package/src/cli/checks.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface CheckContext {
|
|
|
32
32
|
site: SiteConfig;
|
|
33
33
|
/** Parsed wrangler.jsonc, or null when absent. */
|
|
34
34
|
wrangler: {
|
|
35
|
+
name?: string;
|
|
35
36
|
assets?: { run_worker_first?: string[] };
|
|
36
37
|
compatibility_date?: string;
|
|
37
38
|
triggers?: { crons?: string[] };
|
|
@@ -73,6 +74,12 @@ export interface CheckContext {
|
|
|
73
74
|
};
|
|
74
75
|
/** The installed package version. */
|
|
75
76
|
version: string;
|
|
77
|
+
/**
|
|
78
|
+
* Whether the Worker named in wrangler.jsonc exists on the account, asked of wrangler by the
|
|
79
|
+
* doctor. `deployments` is how many it listed - null when the question was not asked, and
|
|
80
|
+
* `skipped` then says why: wrangler not installed, not logged in, no network.
|
|
81
|
+
*/
|
|
82
|
+
worker: { name: string | null; deployments: number | null; skipped: string | null };
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
export interface Check {
|
|
@@ -676,14 +683,16 @@ export const CHECKS: Check[] = [
|
|
|
676
683
|
},
|
|
677
684
|
},
|
|
678
685
|
{
|
|
679
|
-
id: '
|
|
680
|
-
title: 'Something renders the
|
|
681
|
-
silentAs:
|
|
686
|
+
id: 'webmaster-credit',
|
|
687
|
+
title: 'Something renders the webmaster credit',
|
|
688
|
+
silentAs:
|
|
689
|
+
'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
|
|
682
690
|
run(ctx) {
|
|
683
691
|
/*
|
|
684
692
|
* The package ships no footer - it ships no components at all - so the credit is imported
|
|
685
693
|
* 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.
|
|
694
|
+
* easy to simply never do, which is how live client sites ended up without it. Without it
|
|
695
|
+
* the /webmaster page the package injects is reachable from nothing.
|
|
687
696
|
*
|
|
688
697
|
* A warning, not a failure: a site mid-build has no footer yet, and failing there trains
|
|
689
698
|
* people to ignore the doctor. `/webm:launch` is where it becomes blocking.
|
|
@@ -697,11 +706,11 @@ export const CHECKS: Check[] = [
|
|
|
697
706
|
if (ctx.site.domain === 'webmonterey.com') return pass;
|
|
698
707
|
|
|
699
708
|
for (const src of ctx.components.values()) {
|
|
700
|
-
if (/webmonterey\/
|
|
709
|
+
if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
|
|
701
710
|
}
|
|
702
711
|
return warn(
|
|
703
|
-
'no component imports @cparkerwebm/webmonterey/webmonterey/
|
|
704
|
-
'The footer component is where it goes.',
|
|
712
|
+
'no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. ' +
|
|
713
|
+
'The footer component is where it goes; it links to the /webmaster page.',
|
|
705
714
|
);
|
|
706
715
|
},
|
|
707
716
|
},
|
|
@@ -743,7 +752,9 @@ export const CHECKS: Check[] = [
|
|
|
743
752
|
{
|
|
744
753
|
id: 'environment',
|
|
745
754
|
title: 'The declared environment matches where the site actually is',
|
|
746
|
-
silentAs:
|
|
755
|
+
silentAs:
|
|
756
|
+
"a launched site whose client email is still being diverted to the agency's inbox, and " +
|
|
757
|
+
'whose every page is noindex',
|
|
747
758
|
run(ctx) {
|
|
748
759
|
const declared = ctx.site.environment;
|
|
749
760
|
|
|
@@ -769,22 +780,51 @@ export const CHECKS: Check[] = [
|
|
|
769
780
|
if (declared === 'staging' && isConfigured(ctx.site.launched)) {
|
|
770
781
|
return fail(
|
|
771
782
|
`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.
|
|
773
|
-
`
|
|
783
|
+
`email it sends is being redirected away from its real recipients - and since 1.3.0 ` +
|
|
784
|
+
`every build of a staging site is a preview: noindex on every page, no canonical, ` +
|
|
785
|
+
`no sitemap, robots.txt disallowing everything. The live site is dropping out of ` +
|
|
786
|
+
`search. Set "environment": "production" in webmonterey.json.`,
|
|
774
787
|
);
|
|
775
788
|
}
|
|
776
789
|
|
|
777
790
|
if (declared !== 'staging' && !isConfigured(ctx.site.launched)) {
|
|
778
791
|
return warn(
|
|
779
792
|
`this site has no launch date but is treated as production, so testing a form will ` +
|
|
780
|
-
`email the client's real contacts
|
|
781
|
-
`webmonterey.json until /webm:launch.`,
|
|
793
|
+
`email the client's real contacts and every page is indexable on its workers.dev ` +
|
|
794
|
+
`hostname. Set "environment": "staging" in webmonterey.json until /webm:launch.`,
|
|
782
795
|
);
|
|
783
796
|
}
|
|
784
797
|
|
|
785
798
|
return pass;
|
|
786
799
|
},
|
|
787
800
|
},
|
|
801
|
+
{
|
|
802
|
+
/*
|
|
803
|
+
* THE WORKER EXISTS. /webm:start used to end with a repo, a D1 database and an instruction
|
|
804
|
+
* to create the Worker in the dashboard by hand - and on one site nobody did. Nothing local
|
|
805
|
+
* notices: the build is green, every other check here is green, and the site is a
|
|
806
|
+
* workers.dev hostname that answers nothing. The Worker is the one resource whose absence
|
|
807
|
+
* has no symptom on disk, so this asks Cloudflare through wrangler - the one thing a laptop
|
|
808
|
+
* can ask - and steps aside with a note when it cannot.
|
|
809
|
+
*/
|
|
810
|
+
id: 'worker-exists',
|
|
811
|
+
title: 'The Worker exists',
|
|
812
|
+
silentAs: 'a site with a repo, a database and nothing serving',
|
|
813
|
+
run(ctx) {
|
|
814
|
+
if (ctx.worker.skipped) return { status: 'pass', detail: `skipped: ${ctx.worker.skipped}` };
|
|
815
|
+
if (!ctx.worker.name) {
|
|
816
|
+
return warn('wrangler.jsonc names no Worker, so there is nothing to look for');
|
|
817
|
+
}
|
|
818
|
+
if (!ctx.worker.deployments) {
|
|
819
|
+
return warn(
|
|
820
|
+
`no deployment of a Worker named "${ctx.worker.name}" on this account. Create it once ` +
|
|
821
|
+
`from the laptop - npm run build && npx wrangler deploy - then connect the repo to ` +
|
|
822
|
+
`it in the dashboard (Worker → Settings → Builds). /webm:start, steps 5 and 6.`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
return pass;
|
|
826
|
+
},
|
|
827
|
+
},
|
|
788
828
|
{
|
|
789
829
|
id: 'seeded-files',
|
|
790
830
|
title: 'The files Astro copies verbatim are present',
|
package/src/cli/doctor.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Exit code is 1 on any failure, so it can gate a build or a go-live.
|
|
10
10
|
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
11
12
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
-
import {
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
13
15
|
import { CHECKS, type CheckContext } from './checks.ts';
|
|
14
16
|
import { loadSiteFiles } from '../integration/config.ts';
|
|
15
17
|
|
|
@@ -108,16 +110,90 @@ function readMcp(siteRoot: string): CheckContext['mcp'] {
|
|
|
108
110
|
};
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Ask wrangler whether the Worker exists - the one check here that leaves the machine.
|
|
115
|
+
*
|
|
116
|
+
* `wrangler deployments list --name <name> --json` is a read: it lists what is deployed and
|
|
117
|
+
* changes nothing. wrangler is resolved from the site upward, the way `npx` would find it,
|
|
118
|
+
* rather than downloaded - a doctor that installs things is not a doctor. Every way the question
|
|
119
|
+
* can go unanswered - no wrangler, not logged in, no network - is a SKIP with the reason, never
|
|
120
|
+
* a failure: the check exists to catch a missing Worker, and a laptop that cannot ask is not
|
|
121
|
+
* evidence of one.
|
|
122
|
+
*
|
|
123
|
+
* The two answers that matter are told apart by wrangler's own words: a missing Worker is
|
|
124
|
+
* "does not exist [code: 10007]"; a missing login is a request to set CLOUDFLARE_API_TOKEN, an
|
|
125
|
+
* authentication error, or a rejected token.
|
|
126
|
+
*/
|
|
127
|
+
function workerState(siteRoot: string, name: string | null | undefined): CheckContext['worker'] {
|
|
128
|
+
const worker = { name: name ?? null, deployments: null, skipped: null };
|
|
129
|
+
if (!worker.name) return worker;
|
|
130
|
+
|
|
131
|
+
/* Absolute, or createRequire refuses it - `webm doctor examples/minimal` passes a relative root. */
|
|
132
|
+
let bin: string;
|
|
133
|
+
try {
|
|
134
|
+
const require = createRequire(join(resolve(siteRoot), 'package.json'));
|
|
135
|
+
bin = join(dirname(require.resolve('wrangler/package.json')), 'bin/wrangler.js');
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
...worker,
|
|
139
|
+
skipped: 'wrangler is not installed here, so the Worker was not looked for',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const out = execFileSync(
|
|
145
|
+
process.execPath,
|
|
146
|
+
[bin, 'deployments', 'list', '--name', worker.name, '--json'],
|
|
147
|
+
{
|
|
148
|
+
cwd: siteRoot,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
timeout: 30_000,
|
|
152
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: 'false', NO_COLOR: '1' },
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
const start = out.indexOf('[');
|
|
156
|
+
const parsed: unknown = start >= 0 ? JSON.parse(out.slice(start)) : [];
|
|
157
|
+
return { ...worker, deployments: Array.isArray(parsed) ? parsed.length : 0 };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const e = error as { stdout?: string; stderr?: string; message?: string };
|
|
160
|
+
const text = `${e.stdout ?? ''}\n${e.stderr ?? ''}\n${e.message ?? ''}`;
|
|
161
|
+
if (/code: 10007\]|does not exist on your account/i.test(text)) {
|
|
162
|
+
return { ...worker, deployments: 0 };
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
/CLOUDFLARE_API_TOKEN|not (logged in|authenticated)|Authentication error|code: (10000|6111|9109)\]/i.test(
|
|
166
|
+
text,
|
|
167
|
+
)
|
|
168
|
+
) {
|
|
169
|
+
return {
|
|
170
|
+
...worker,
|
|
171
|
+
skipped:
|
|
172
|
+
'wrangler is not logged in (npx wrangler login), so whether the Worker exists was not checked',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const line = text
|
|
176
|
+
.split('\n')
|
|
177
|
+
.map((l) => l.replace(/\x1b\[[0-9;]*m/g, '').trim())
|
|
178
|
+
.find((l) => l && !l.startsWith('🪵'));
|
|
179
|
+
return { ...worker, skipped: `wrangler could not answer: ${line ?? 'no output'}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
111
183
|
export function buildContext(siteRoot: string): CheckContext {
|
|
112
184
|
const { site } = loadSiteFiles(siteRoot);
|
|
113
185
|
const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
|
|
114
186
|
.map((f) => join(siteRoot, f))
|
|
115
187
|
.find(existsSync);
|
|
116
188
|
const syncPath = join(siteRoot, '.claude/skills/webm/.webm-sync.json');
|
|
189
|
+
const wrangler: CheckContext['wrangler'] = wranglerPath
|
|
190
|
+
? parseJsonc(readFileSync(wranglerPath, 'utf8'))
|
|
191
|
+
: null;
|
|
117
192
|
|
|
118
193
|
return {
|
|
119
194
|
site,
|
|
120
|
-
wrangler
|
|
195
|
+
wrangler,
|
|
196
|
+
worker: workerState(siteRoot, wrangler?.name),
|
|
121
197
|
pages: readTree(siteRoot, 'src/pages', ['.astro', '.ts']),
|
|
122
198
|
components: readTree(siteRoot, 'src/components', ['.astro', '.ts']),
|
|
123
199
|
today: new Date().toISOString().slice(0, 10),
|
package/src/cli/scaffold.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { scaffold } from './scaffold.ts';
|
|
4
4
|
import { MCP_NAMES, mcpConfig } from './mcp.ts';
|
|
5
|
+
import { DENY_RULES } from './settings.ts';
|
|
5
6
|
|
|
6
7
|
const files = (over = {}) =>
|
|
7
8
|
scaffold({
|
|
@@ -235,3 +236,11 @@ test('the margin crosses a month and a year boundary correctly', () => {
|
|
|
235
236
|
assert.equal(at('2026-01-05'), '2025-12-22');
|
|
236
237
|
assert.equal(at('2026-03-05'), '2026-02-19', 'and February');
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
test('a session in a client repo cannot edit the package: node_modules is denied', () => {
|
|
241
|
+
const deny: string[] = json(files(), '.claude/settings.json').permissions.deny;
|
|
242
|
+
assert.deepEqual(deny, [...DENY_RULES], 'the one list in cli/settings.ts');
|
|
243
|
+
assert.ok(deny.includes('Edit(**/node_modules/**)'));
|
|
244
|
+
/* Claude Code checks Edit and Read rules only; a Write rule is ignored and warned about. */
|
|
245
|
+
assert.ok(!deny.some((r) => r.startsWith('Write(')), 'no Write rule');
|
|
246
|
+
});
|