@cparkerwebm/webmonterey 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,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
+ }