@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,229 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `webm design:extract` - turn a hand-edited tokens.css into design.json.
|
|
3
|
+
*
|
|
4
|
+
* Run once per site during a rebuild. Reads the stylesheet, diffs every declaration against the
|
|
5
|
+
* default token set, and writes only what differs - mapped to a structured field where one
|
|
6
|
+
* exists, and to `overrides` where none does.
|
|
7
|
+
*
|
|
8
|
+
* It REPORTS what it could not map rather than dropping it. A token silently lost here becomes a
|
|
9
|
+
* color that quietly reverts on a site someone already signed off, which is the failure this
|
|
10
|
+
* command exists to prevent.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { DEFAULTS } from '../design/defaults.ts';
|
|
14
|
+
import { MAPPING } from '../design/compile.ts';
|
|
15
|
+
import type { DesignSystem } from '../design/types.ts';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_VALUES = new Map(DEFAULTS.flatMap((g) => g.tokens.map((t) => [t.name, t.value])));
|
|
18
|
+
const REVERSE = new Map(MAPPING.map(([path, token]) => [token, path]));
|
|
19
|
+
|
|
20
|
+
export interface ParsedTokens {
|
|
21
|
+
/** Declarations at the top level, outside every at-rule. The site's actual token values. */
|
|
22
|
+
base: Map<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Declarations nested inside an at-rule, keyed by token, with the at-rule preludes they came
|
|
25
|
+
* from. These CANNOT be represented in design.json - it compiles a flat :root block - so they
|
|
26
|
+
* are reported and left in the stylesheet rather than folded into the base values.
|
|
27
|
+
*/
|
|
28
|
+
conditional: Map<string, string[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pull `--webm-x: value;` pairs out of a stylesheet, SEPARATING at-rule-scoped ones.
|
|
33
|
+
*
|
|
34
|
+
* THE BUG THIS REPLACES was a flat regex over the whole file, last-write-wins. tokens.css sets
|
|
35
|
+
* a token in :root and then overrides it inside `@media (prefers-reduced-motion: reduce)`, so
|
|
36
|
+
* the media value overwrote the real one and became the site's base. Converting webmonterey.com
|
|
37
|
+
* that way wrote `--webm-duration-fast: 0ms` as its base: every animation on the site disabled
|
|
38
|
+
* for every visitor, from a migration that reported success.
|
|
39
|
+
*
|
|
40
|
+
* Worse, it was invisible to the obvious check. Comparing parseTokens(original) against
|
|
41
|
+
* parseTokens(compiled) matched 238 of 238 - both sides ran the same broken parser, so the
|
|
42
|
+
* comparison only proved it was consistently wrong.
|
|
43
|
+
*
|
|
44
|
+
* Brace counting rather than a CSS parser: this reads one hand-written file with a known shape,
|
|
45
|
+
* and a dependency for it would be the larger risk.
|
|
46
|
+
*/
|
|
47
|
+
export function parseTokens(css: string): ParsedTokens {
|
|
48
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
49
|
+
const base = new Map<string, string>();
|
|
50
|
+
const conditional = new Map<string, string[]>();
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* ONLY CONDITIONAL AT-RULES COUNT. `@layer webm.tokens { :root { ... } }` is how every one of
|
|
54
|
+
* these stylesheets is written, and a layer does not make a declaration conditional - it only
|
|
55
|
+
* places it in the cascade. Treating every at-rule as conditional put the entire file inside
|
|
56
|
+
* one, so nothing was a base value and the whole extraction came back empty.
|
|
57
|
+
*
|
|
58
|
+
* @media, @supports and @container apply their contents only when the condition holds. Those
|
|
59
|
+
* are the ones design.json cannot express.
|
|
60
|
+
*/
|
|
61
|
+
const CONDITIONAL = /^@(media|supports|container)\b/;
|
|
62
|
+
|
|
63
|
+
/* The conditional at-rule preludes currently open. */
|
|
64
|
+
const atRules: string[] = [];
|
|
65
|
+
/* Brace depth at which each open at-rule started, so it can be popped on the matching close. */
|
|
66
|
+
const atRuleDepth: number[] = [];
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let buffer = '';
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < source.length; i++) {
|
|
71
|
+
const char = source[i]!;
|
|
72
|
+
|
|
73
|
+
if (char === '{') {
|
|
74
|
+
const prelude = buffer.trim();
|
|
75
|
+
if (CONDITIONAL.test(prelude)) {
|
|
76
|
+
atRules.push(prelude);
|
|
77
|
+
atRuleDepth.push(depth);
|
|
78
|
+
}
|
|
79
|
+
depth++;
|
|
80
|
+
buffer = '';
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (char === '}') {
|
|
85
|
+
depth--;
|
|
86
|
+
if (atRuleDepth.length && atRuleDepth.at(-1) === depth) {
|
|
87
|
+
atRules.pop();
|
|
88
|
+
atRuleDepth.pop();
|
|
89
|
+
}
|
|
90
|
+
buffer = '';
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (char === ';') {
|
|
95
|
+
const declaration = buffer.trim();
|
|
96
|
+
const match = /^(--webm-[\w-]+)\s*:\s*([\s\S]+)$/.exec(declaration);
|
|
97
|
+
if (match) {
|
|
98
|
+
const [, name, value] = match as unknown as [string, string, string];
|
|
99
|
+
if (atRules.length === 0) {
|
|
100
|
+
base.set(name, value.trim());
|
|
101
|
+
} else {
|
|
102
|
+
conditional.set(name, [...(conditional.get(name) ?? []), atRules.join(' / ')]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
buffer = '';
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
buffer += char;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { base, conditional };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setPath(target: Record<string, unknown>, path: readonly string[], value: string): void {
|
|
116
|
+
let node = target;
|
|
117
|
+
for (const key of path.slice(0, -1)) {
|
|
118
|
+
node[key] ??= {};
|
|
119
|
+
node = node[key] as Record<string, unknown>;
|
|
120
|
+
}
|
|
121
|
+
node[path.at(-1)!] = value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ExtractResult {
|
|
125
|
+
design: DesignSystem;
|
|
126
|
+
mapped: string[];
|
|
127
|
+
overridden: string[];
|
|
128
|
+
added: string[];
|
|
129
|
+
unchanged: number;
|
|
130
|
+
/**
|
|
131
|
+
* Tokens the stylesheet also sets inside an at-rule, with the preludes. design.json compiles a
|
|
132
|
+
* flat :root block and cannot express these, so they stay in the site's own CSS - and the
|
|
133
|
+
* command has to SAY so, because a silently dropped media override is a site that stops
|
|
134
|
+
* responding to reduced-motion or to a breakpoint with nothing to show for it.
|
|
135
|
+
*/
|
|
136
|
+
conditional: Map<string, string[]>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function extract(css: string): ExtractResult {
|
|
140
|
+
const { base: found, conditional } = parseTokens(css);
|
|
141
|
+
const design: DesignSystem = { version: 1 };
|
|
142
|
+
const mapped: string[] = [];
|
|
143
|
+
const overridden: string[] = [];
|
|
144
|
+
const added: string[] = [];
|
|
145
|
+
let unchanged = 0;
|
|
146
|
+
|
|
147
|
+
for (const [name, value] of found) {
|
|
148
|
+
const isKnown = DEFAULT_VALUES.has(name);
|
|
149
|
+
if (isKnown && DEFAULT_VALUES.get(name) === value) {
|
|
150
|
+
unchanged++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const path = REVERSE.get(name);
|
|
154
|
+
if (path) {
|
|
155
|
+
setPath(design as Record<string, unknown>, path, value);
|
|
156
|
+
mapped.push(name);
|
|
157
|
+
} else {
|
|
158
|
+
design.overrides ??= {};
|
|
159
|
+
design.overrides[name] = value;
|
|
160
|
+
(isKnown ? overridden : added).push(name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { design, mapped, overridden, added, unchanged, conditional };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function run(argv: string[]): number {
|
|
168
|
+
const input = argv[0] ?? 'src/styles/tokens.css';
|
|
169
|
+
const output = argv[1] ?? 'design.json';
|
|
170
|
+
|
|
171
|
+
if (!existsSync(input)) {
|
|
172
|
+
console.error(`webm design:extract: no such file: ${input}`);
|
|
173
|
+
console.error(` usage: webm design:extract [tokens.css] [design.json]`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
if (existsSync(output)) {
|
|
177
|
+
console.error(`webm design:extract: ${output} already exists. Move it aside first.`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = extract(readFileSync(input, 'utf8'));
|
|
182
|
+
const json = JSON.stringify(
|
|
183
|
+
{ $schema: './node_modules/@cparkerwebm/webmonterey/schema/design.json', ...result.design },
|
|
184
|
+
null,
|
|
185
|
+
2,
|
|
186
|
+
);
|
|
187
|
+
writeFileSync(output, json + '\n');
|
|
188
|
+
|
|
189
|
+
console.log(`Read ${input}`);
|
|
190
|
+
console.log(` ${result.unchanged} tokens match the defaults and were omitted`);
|
|
191
|
+
if (result.mapped.length) {
|
|
192
|
+
console.log(` ${result.mapped.length} mapped to structured fields:`);
|
|
193
|
+
for (const n of result.mapped) console.log(` ${n}`);
|
|
194
|
+
}
|
|
195
|
+
if (result.overridden.length) {
|
|
196
|
+
console.log(` ${result.overridden.length} kept as overrides (no structured field exists):`);
|
|
197
|
+
for (const n of result.overridden) console.log(` ${n}`);
|
|
198
|
+
}
|
|
199
|
+
if (result.added.length) {
|
|
200
|
+
console.log(` ${result.added.length} are site tokens, not part of the default set:`);
|
|
201
|
+
for (const n of result.added) console.log(` ${n}`);
|
|
202
|
+
}
|
|
203
|
+
console.log(`\nWrote ${output}`);
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* THE PART THAT MUST NOT BE QUIET. design.json compiles one flat :root block, so a token the
|
|
207
|
+
* stylesheet also sets inside @media cannot be carried across. Folding those values into the
|
|
208
|
+
* base is what the old parser did, and it wrote a reduced-motion 0ms as webmonterey.com's real
|
|
209
|
+
* animation duration - every animation off, for every visitor, reported as a success.
|
|
210
|
+
*/
|
|
211
|
+
if (result.conditional.size) {
|
|
212
|
+
console.log(
|
|
213
|
+
`\n${result.conditional.size} tokens are ALSO set inside an at-rule. design.json holds one\n` +
|
|
214
|
+
`flat :root block, so those conditional values are NOT in it and must stay in CSS:\n`,
|
|
215
|
+
);
|
|
216
|
+
for (const [name, rules] of result.conditional) {
|
|
217
|
+
console.log(` ${name} ${[...new Set(rules)].join(', ')}`);
|
|
218
|
+
}
|
|
219
|
+
console.log(
|
|
220
|
+
`\n Move those blocks to src/styles/custom/_index.css. Leaving them behind silently\n` +
|
|
221
|
+
` drops reduced-motion handling and every responsive token override.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
console.log(
|
|
226
|
+
`\nNext: delete ${input}, and check the compiled output matches what the site rendered before.`,
|
|
227
|
+
);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `webm doctor` - the consolidated check for everything that fails silently.
|
|
3
|
+
*
|
|
4
|
+
* Generation 2 specified these across a dozen places in CLAUDE.md, as prose a reader had to
|
|
5
|
+
* remember. Prose is a convention; this is a mechanism. Each check maps to a trap that produced a
|
|
6
|
+
* real client incident, and each failure is invisible in normal use - which is precisely why a
|
|
7
|
+
* command has to go looking.
|
|
8
|
+
*
|
|
9
|
+
* Exit code is 1 on any failure, so it can gate a build or a go-live.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, relative } from 'node:path';
|
|
13
|
+
import { CHECKS, type CheckContext } from './checks.ts';
|
|
14
|
+
import { loadSiteFiles } from '../integration/config.ts';
|
|
15
|
+
|
|
16
|
+
import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
|
|
17
|
+
|
|
18
|
+
/** Strip // and /* comments so JSON.parse can read a .jsonc file. */
|
|
19
|
+
function parseJsonc<T>(source: string): T {
|
|
20
|
+
const stripped = source
|
|
21
|
+
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? '' : m))
|
|
22
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
23
|
+
return JSON.parse(stripped) as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readTree(root: string, dir: string, exts: string[]): Map<string, string> {
|
|
27
|
+
const out = new Map<string, string>();
|
|
28
|
+
const walk = (d: string) => {
|
|
29
|
+
if (!existsSync(d)) return;
|
|
30
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
31
|
+
const full = join(d, entry.name);
|
|
32
|
+
if (entry.isDirectory()) walk(full);
|
|
33
|
+
else if (exts.some((e) => entry.name.endsWith(e))) {
|
|
34
|
+
out.set(relative(root, full), readFileSync(full, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
walk(join(root, dir));
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The custom Worker entrypoint named by wrangler `main`, if there is one.
|
|
44
|
+
*
|
|
45
|
+
* `main` is normally absent - the adapter generates the entrypoint. A site sets it only to add
|
|
46
|
+
* handlers the generated one has no room for, which in practice means `scheduled()`.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* public/ files still byte-identical to the package's seeded placeholder.
|
|
50
|
+
*
|
|
51
|
+
* Compared by bytes rather than by hash of a known list, so replacing the artwork clears it
|
|
52
|
+
* however the client's file was produced.
|
|
53
|
+
*/
|
|
54
|
+
function placeholderFiles(siteRoot: string): string[] {
|
|
55
|
+
const seeded = join(PACKAGE_ROOT, 'template', 'public');
|
|
56
|
+
if (!existsSync(seeded)) return [];
|
|
57
|
+
return readdirSync(seeded)
|
|
58
|
+
.filter((file) => {
|
|
59
|
+
const theirs = join(siteRoot, 'public', file);
|
|
60
|
+
if (!existsSync(theirs)) return false;
|
|
61
|
+
try {
|
|
62
|
+
/* Compare as base64 rather than Buffer.equals: this file typechecks without Node's
|
|
63
|
+
* Buffer typings, and the files involved are a few dozen kilobytes at most. */
|
|
64
|
+
return readFileSync(theirs, 'base64') === readFileSync(join(seeded, file), 'base64');
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.map((file) => `public/${file}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readWorkerEntry(siteRoot: string, wranglerPath: string | undefined | null): string | null {
|
|
73
|
+
if (!wranglerPath) return null;
|
|
74
|
+
const config = parseJsonc(readFileSync(wranglerPath, 'utf8')) as { main?: string };
|
|
75
|
+
if (!config.main) return null;
|
|
76
|
+
const entry = join(siteRoot, config.main);
|
|
77
|
+
return existsSync(entry) ? readFileSync(entry, 'utf8') : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function contentPageNames(root: string): string[] {
|
|
81
|
+
const dir = join(root, 'src/content/pages');
|
|
82
|
+
if (!existsSync(dir)) return [];
|
|
83
|
+
return readdirSync(dir)
|
|
84
|
+
.filter((f) => f.endsWith('.json'))
|
|
85
|
+
.map((f) => f.replace(/\.json$/, ''));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* .mcp.json and the pre-approval list out of .claude/settings.json.
|
|
90
|
+
*
|
|
91
|
+
* Both are read leniently: a repo with malformed JSON in either is a repo whose MCP setup is
|
|
92
|
+
* broken, and the check reports that as a missing server rather than the doctor throwing on a
|
|
93
|
+
* file it was only inspecting.
|
|
94
|
+
*/
|
|
95
|
+
function readMcp(siteRoot: string): CheckContext['mcp'] {
|
|
96
|
+
const read = <T>(rel: string, pick: (parsed: Record<string, unknown>) => T): T | null => {
|
|
97
|
+
const path = join(siteRoot, rel);
|
|
98
|
+
if (!existsSync(path)) return null;
|
|
99
|
+
try {
|
|
100
|
+
return pick(JSON.parse(readFileSync(path, 'utf8')));
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
declared: read('.mcp.json', (p) => (p.mcpServers ?? null) as Record<string, { url?: string }>),
|
|
107
|
+
enabled: read('.claude/settings.json', (p) => (p.enabledMcpjsonServers ?? null) as string[]),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildContext(siteRoot: string): CheckContext {
|
|
112
|
+
const { site } = loadSiteFiles(siteRoot);
|
|
113
|
+
const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
|
|
114
|
+
.map((f) => join(siteRoot, f))
|
|
115
|
+
.find(existsSync);
|
|
116
|
+
const syncPath = join(siteRoot, '.claude/skills/webm/.webm-sync.json');
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
site,
|
|
120
|
+
wrangler: wranglerPath ? parseJsonc(readFileSync(wranglerPath, 'utf8')) : null,
|
|
121
|
+
pages: readTree(siteRoot, 'src/pages', ['.astro', '.ts']),
|
|
122
|
+
components: readTree(siteRoot, 'src/components', ['.astro', '.ts']),
|
|
123
|
+
today: new Date().toISOString().slice(0, 10),
|
|
124
|
+
workerEntry: readWorkerEntry(siteRoot, wranglerPath),
|
|
125
|
+
placeholders: placeholderFiles(siteRoot),
|
|
126
|
+
contentPages: contentPageNames(siteRoot),
|
|
127
|
+
content: readTree(siteRoot, 'src/content/pages', ['.json']),
|
|
128
|
+
/*
|
|
129
|
+
* Everything else in the repo that can hold a call the compiler will not resolve for you:
|
|
130
|
+
* actions, the site's own includes and email templates, and the SQL that has to have a
|
|
131
|
+
* migration behind it. Read here rather than in each check so a check stays a pure function
|
|
132
|
+
* of its context and the tests can hand it a literal.
|
|
133
|
+
*/
|
|
134
|
+
actions: readTree(siteRoot, 'src/actions', ['.ts']),
|
|
135
|
+
includes: readTree(siteRoot, 'src/includes', ['.ts', '.astro']),
|
|
136
|
+
emails: readTree(siteRoot, 'src/emails', ['.ts']),
|
|
137
|
+
migrations: readTree(siteRoot, 'migrations', ['.sql']),
|
|
138
|
+
registry: existsSync(join(siteRoot, 'src/components/registry.ts'))
|
|
139
|
+
? readFileSync(join(siteRoot, 'src/components/registry.ts'), 'utf8')
|
|
140
|
+
: null,
|
|
141
|
+
present: Object.fromEntries(
|
|
142
|
+
[
|
|
143
|
+
'public/_headers',
|
|
144
|
+
'public/favicon.ico',
|
|
145
|
+
'public/site.webmanifest',
|
|
146
|
+
'scripts/check-node.mjs',
|
|
147
|
+
].map((f) => [f, existsSync(join(siteRoot, f))]),
|
|
148
|
+
),
|
|
149
|
+
sync: existsSync(syncPath) ? JSON.parse(readFileSync(syncPath, 'utf8')) : null,
|
|
150
|
+
mcp: readMcp(siteRoot),
|
|
151
|
+
version: packageVersion(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function run(argv: string[]): number {
|
|
156
|
+
const siteRoot = argv[0] ?? process.cwd();
|
|
157
|
+
if (!existsSync(join(siteRoot, 'webmonterey.json'))) {
|
|
158
|
+
console.error(`webm doctor: no webmonterey.json in ${siteRoot}. Not a WebMonterey site.`);
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ctx = buildContext(siteRoot);
|
|
163
|
+
let failed = 0;
|
|
164
|
+
let warned = 0;
|
|
165
|
+
|
|
166
|
+
for (const check of CHECKS) {
|
|
167
|
+
const result = check.run(ctx);
|
|
168
|
+
const mark = result.status === 'pass' ? ' ok ' : result.status === 'warn' ? 'warn ' : 'FAIL ';
|
|
169
|
+
console.log(`${mark} ${check.title}`);
|
|
170
|
+
if (result.detail) {
|
|
171
|
+
console.log(` ${result.detail}`);
|
|
172
|
+
/*
|
|
173
|
+
* The failure mode is printed with the failure. A check that only says what is wrong makes
|
|
174
|
+
* the reader guess whether it matters; saying what it looks like from outside is what turns
|
|
175
|
+
* "not listed in run_worker_first" into something worth fixing before a client sees it.
|
|
176
|
+
*/
|
|
177
|
+
if (result.status === 'fail') console.log(` fails as: ${check.silentAs}`);
|
|
178
|
+
}
|
|
179
|
+
if (result.status === 'fail') failed++;
|
|
180
|
+
if (result.status === 'warn') warned++;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(
|
|
184
|
+
`\n${CHECKS.length - failed - warned} passed, ${warned} warning${warned === 1 ? '' : 's'}, ${failed} failed`,
|
|
185
|
+
);
|
|
186
|
+
return failed > 0 ? 1 : 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { MCP_NAMES, MCP_SERVERS, mcpConfig, mcpGaps } from './mcp.ts';
|
|
5
|
+
|
|
6
|
+
const repo = new URL('../../', import.meta.url);
|
|
7
|
+
const readJson = (rel: string) => JSON.parse(readFileSync(new URL(rel, repo), 'utf8'));
|
|
8
|
+
|
|
9
|
+
test('every server is http and has an absolute https url', () => {
|
|
10
|
+
for (const [name, s] of Object.entries(MCP_SERVERS)) {
|
|
11
|
+
assert.equal(s.type, 'http', name);
|
|
12
|
+
assert.ok(s.url.startsWith('https://'), `${name} is not https`);
|
|
13
|
+
assert.ok(s.purpose.length > 0, `${name} has no stated purpose`);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('mcpConfig drops purpose - it documents our file, not the protocol', () => {
|
|
18
|
+
const entry = mcpConfig().mcpServers['astro-docs'];
|
|
19
|
+
assert.deepEqual(Object.keys(entry).sort(), ['type', 'url']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* THE POINT OF THE MODULE. Four places have to agree about these servers, and two of them are
|
|
24
|
+
* checked-in files in this repo rather than code, so nothing but a test can notice them drifting.
|
|
25
|
+
* Adding a server to mcp.ts and forgetting the repo's own .mcp.json is the exact mistake that
|
|
26
|
+
* leaves the framework being written from recall while every client site reads current docs.
|
|
27
|
+
*/
|
|
28
|
+
test("the package's own .mcp.json is what mcp.ts says it should be", () => {
|
|
29
|
+
assert.deepEqual(readJson('.mcp.json'), mcpConfig());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("the package's own settings pre-approve every server, in order", () => {
|
|
33
|
+
assert.deepEqual(readJson('.claude/settings.json').enabledMcpjsonServers, MCP_NAMES);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a fully wired repo has no gaps', () => {
|
|
37
|
+
assert.deepEqual(mcpGaps(mcpConfig().mcpServers, MCP_NAMES), {
|
|
38
|
+
undeclared: [],
|
|
39
|
+
unapproved: [],
|
|
40
|
+
wrongUrl: [],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('gaps separate not-declared from declared-but-inert, because the fixes differ', () => {
|
|
45
|
+
const gaps = mcpGaps({ 'astro-docs': { url: MCP_SERVERS['astro-docs'].url } }, []);
|
|
46
|
+
assert.deepEqual(gaps.undeclared, ['mdn', 'website-spec', 'email-spec']);
|
|
47
|
+
assert.deepEqual(gaps.unapproved, ['astro-docs']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a server pointed somewhere else is a gap even when declared and approved', () => {
|
|
51
|
+
const gaps = mcpGaps({ ...mcpConfig().mcpServers, mdn: { url: 'https://evil.test' } }, MCP_NAMES);
|
|
52
|
+
assert.deepEqual(gaps.wrongUrl, ['mdn']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('nothing at all is reported as everything undeclared rather than throwing', () => {
|
|
56
|
+
assert.deepEqual(mcpGaps(null, null).undeclared, MCP_NAMES);
|
|
57
|
+
});
|
package/src/cli/mcp.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The documentation servers every WebMonterey repo talks to, and the one list that says so.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS A MODULE AND NOT A LITERAL IN THE SCAFFOLD. Four places have to agree about these
|
|
5
|
+
* servers: the scaffold writes them into a new site, the codemod adds them to an existing one,
|
|
6
|
+
* `webm doctor` checks a site still has them, and the package's own .mcp.json declares them for
|
|
7
|
+
* this repo. Four literals is four chances to add a server in three places and spend an afternoon
|
|
8
|
+
* on why one repo answers from training data.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THEY ARE FOR. Astro ships majors faster than any model's corpus turns over, and the web
|
|
11
|
+
* platform ships behavior continuously - baseline support, a new API, a deprecation. Both
|
|
12
|
+
* servers exist so a session can read what is true today rather than recalling what was true at
|
|
13
|
+
* a training cutoff. Neither of them enforces anything on their own: they are a reference. What
|
|
14
|
+
* makes a session USE them is the rule in CLAUDE.md, and what makes a repo keep them wired is
|
|
15
|
+
* the `mcp-docs` check in doctor.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface McpServer {
|
|
19
|
+
type: 'http';
|
|
20
|
+
url: string;
|
|
21
|
+
/** Said in the file, so the next person reading .mcp.json knows what it is for. */
|
|
22
|
+
purpose: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MCP_SERVERS: Record<string, McpServer> = {
|
|
26
|
+
/*
|
|
27
|
+
* Astro's own, from docs.astro.build/en/guides/build-with-ai. Free, open source, and the
|
|
28
|
+
* documented way to give a tool current Astro knowledge.
|
|
29
|
+
*/
|
|
30
|
+
'astro-docs': {
|
|
31
|
+
type: 'http',
|
|
32
|
+
url: 'https://mcp.docs.astro.build/mcp',
|
|
33
|
+
purpose: 'Current Astro documentation. Consult before using any Astro API.',
|
|
34
|
+
},
|
|
35
|
+
/*
|
|
36
|
+
* Mozilla's, from developer.mozilla.org/en-US/mcp. Search, reference and - the part no model
|
|
37
|
+
* has current - browser compatibility data.
|
|
38
|
+
*
|
|
39
|
+
* EXPERIMENTAL, AND MOZILLA SAYS SO: it may be withdrawn at any time, and queries are logged
|
|
40
|
+
* while the experiment runs. It is a docs lookup, so nothing confidential should reach it in
|
|
41
|
+
* the first place; the rule in CLAUDE.md is what keeps it that way. If it disappears, the
|
|
42
|
+
* failure is a session that cannot reach it, not a build that breaks.
|
|
43
|
+
*/
|
|
44
|
+
mdn: {
|
|
45
|
+
type: 'http',
|
|
46
|
+
url: 'https://mcp.mdn.mozilla.net/',
|
|
47
|
+
purpose: 'MDN reference and browser-compat data. Consult before using a web platform API.',
|
|
48
|
+
},
|
|
49
|
+
/*
|
|
50
|
+
* The Website Specification and The Email Specification - Joost de Valk and contributors,
|
|
51
|
+
* MIT/CC BY. Search, per-topic pages and, the useful part here, generated checklists across
|
|
52
|
+
* SEO, accessibility, performance, privacy, security and i18n.
|
|
53
|
+
*
|
|
54
|
+
* GUIDANCE, NOT A STANDARD, WHATEVER THE NAME SAYS. These are opinionated best-practice
|
|
55
|
+
* documents, not normative specs, and they are at 0.2.0 and 0.1.0. They must never outrank MDN
|
|
56
|
+
* or the Astro docs on what an API does; they answer a different question - what a good site or
|
|
57
|
+
* a good email does. CLAUDE.md states that precedence, because a server called
|
|
58
|
+
* "specification" invites exactly the wrong assumption.
|
|
59
|
+
*/
|
|
60
|
+
'website-spec': {
|
|
61
|
+
type: 'http',
|
|
62
|
+
url: 'https://mcp.specification.website/mcp',
|
|
63
|
+
purpose: 'Site-quality guidance: SEO, accessibility, performance, privacy, security, i18n.',
|
|
64
|
+
},
|
|
65
|
+
/*
|
|
66
|
+
* Email is a SEPARATE server from the website one - the Website Specification excludes it by
|
|
67
|
+
* design. It earns its place because src/emails/ is where recall is worst: client rendering
|
|
68
|
+
* quirks and deliverability rules move, and a template that looks right in a browser is not
|
|
69
|
+
* evidence of anything.
|
|
70
|
+
*/
|
|
71
|
+
'email-spec': {
|
|
72
|
+
type: 'http',
|
|
73
|
+
url: 'https://mcp.specification.email/mcp',
|
|
74
|
+
purpose: 'Email guidance: rendering, deliverability, accessibility. For src/emails/ work.',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The server names, in the order they should appear everywhere. */
|
|
79
|
+
export const MCP_NAMES: string[] = Object.keys(MCP_SERVERS);
|
|
80
|
+
|
|
81
|
+
/** The `.mcp.json` body. `purpose` is dropped - it documents this file, not the protocol. */
|
|
82
|
+
export function mcpConfig(): { mcpServers: Record<string, { type: string; url: string }> } {
|
|
83
|
+
return {
|
|
84
|
+
mcpServers: Object.fromEntries(
|
|
85
|
+
Object.entries(MCP_SERVERS).map(([name, s]) => [name, { type: s.type, url: s.url }]),
|
|
86
|
+
),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What a repo is missing, given what it declares and what it pre-approves.
|
|
92
|
+
*
|
|
93
|
+
* TWO LISTS, BECAUSE A SERVER CAN BE WIRED AND STILL INERT. A server in .mcp.json that is not in
|
|
94
|
+
* `enabledMcpjsonServers` prompts for approval on every machine, and a rule that depends on
|
|
95
|
+
* whoever cloned the repo happening to hit Approve is not a rule. That is the failure this
|
|
96
|
+
* separates out rather than folding into one boolean.
|
|
97
|
+
*/
|
|
98
|
+
export function mcpGaps(
|
|
99
|
+
declared: Record<string, { url?: string }> | null | undefined,
|
|
100
|
+
enabled: string[] | null | undefined,
|
|
101
|
+
): { undeclared: string[]; unapproved: string[]; wrongUrl: string[] } {
|
|
102
|
+
const d = declared ?? {};
|
|
103
|
+
const e = new Set(enabled ?? []);
|
|
104
|
+
const undeclared = MCP_NAMES.filter((n) => !(n in d));
|
|
105
|
+
return {
|
|
106
|
+
undeclared,
|
|
107
|
+
unapproved: MCP_NAMES.filter((n) => n in d && !e.has(n)),
|
|
108
|
+
wrongUrl: MCP_NAMES.filter((n) => n in d && d[n]?.url !== MCP_SERVERS[n].url),
|
|
109
|
+
};
|
|
110
|
+
}
|