@cparkerwebm/webmonterey 1.2.0 → 1.4.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 CHANGED
@@ -11,6 +11,108 @@ build. See `/webm:upgrade`.
11
11
 
12
12
  ---
13
13
 
14
+ ## 1.4.0 — 2026-09-04
15
+
16
+ ### Added
17
+
18
+ - **A site can lay out the `/webmaster` page itself.** Export `webmasterPage` from
19
+ `src/components/registry.ts` - an Astro component receiving
20
+ `{ title, description, intro, body }` - and the page renders it in place of the built-in
21
+ `<h1>` and stack of paragraphs. `intro` and each `body` entry are HTML, rendered with
22
+ `set:html` - the agency link is already in `intro`; `title` and `description` are text. The route, the copy, the document title, the meta
23
+ description, the share image and the agency JSON-LD stay the package's, so the page says the
24
+ same thing on every site and looks like the site it is on. Until now a site whose legal pages
25
+ used a richer document layout got a `/webmaster` that looked like a different site, and adding
26
+ its own `src/pages/webmaster.astro` collided with the injected route. The component carries no
27
+ copy of its own: the words are still `copy.webmaster` in `webmonterey.json`.
28
+
29
+ **`/webm:webmaster` is the skill**, materialized on the next `webm sync` - it carries a
30
+ complete reference component, the registry line and the checks to run on the built page, so a
31
+ site session has something to copy rather than a description. `/webm:new-component` sends a
32
+ session there the moment a site gains its document block, and `/webm:start` and
33
+ `/webm:launch` both point at it, so on a new site it happens in sequence; on an existing site
34
+ the upgrade lists it as a new skill.
35
+ The props are exported as `WebmasterPageProps` from
36
+ `@cparkerwebm/webmonterey/webmonterey/webmaster`.
37
+
38
+ **Nothing changes on a site that does not export it.** The built-in layout, `pageHeader`
39
+ seam included, renders as before, and examples/minimal asserts the whole `<main>` as a literal
40
+ so it cannot drift.
41
+
42
+ ### Changed
43
+
44
+ - **The `/webmaster` copy.** "This custom website was designed, built and managed by
45
+ WebMonterey, a webmaster service in Monterey, California. WebMonterey handles the hosting,
46
+ security, strategy and ongoing care of the site…" - _custom_ added, _maintenance_ dropped,
47
+ _updates_ is now _strategy_ - and the contact paragraph is bold. `copy.webmaster.intro` and
48
+ `copy.webmaster.body` now take the same inline subset as page prose (`**bold**`, `_italic_`,
49
+ `[text](/url)`), which is how the bold is expressed. A site overriding `copy.webmaster` in
50
+ `webmonterey.json` keeps its own words; one that does not gets these. The agency's
51
+ description in the page's JSON-LD drops _maintenance_ too.
52
+
53
+ ### Fixed
54
+
55
+ - **"managed byWebMonterey".** The built-in `/webmaster` intro had no space before the agency
56
+ link - Astro drops the whitespace between an expression and an element on separate lines. It
57
+ reads "managed by WebMonterey" now.
58
+
59
+ ---
60
+
61
+ ## 1.3.0 — 2026-09-02
62
+
63
+ ### Changed
64
+
65
+ - **A staging site is a preview build everywhere.** `environment: "staging"` in webmonterey.json
66
+ now makes every build a preview — every page noindex with no canonical, no sitemap, `robots.txt`
67
+ `Disallow: /`, no Google Tag Manager — on every hostname and in every build: a feature branch,
68
+ `main` on Workers Builds, a laptop. Until now only a non-production branch was a preview, so a
69
+ site that had not launched was crawlable on its `workers.dev` URL the moment `main` deployed.
70
+ The branch rule stays: a feature branch of a launched site is still a preview. An unset
71
+ `environment` is production, as everywhere else, so a site predating the field builds as before.
72
+ The decision is one pure function, `isPreviewBuild`; the build log says which signal made a
73
+ build a preview, and `virtual:webm/build` carries it as `reason`.
74
+
75
+ **A launched site whose webmonterey.json still says `"staging"` disappears from search after
76
+ this update:** every page goes noindex and the sitemap is gone. `/webm:launch` sets
77
+ `"environment": "production"` and `launched` together; `webm doctor` already fails a launched
78
+ site still declared staging, and its message now names this consequence. Flipping `environment`
79
+ is what makes a site indexable, so it must not happen before the custom domain is live —
80
+ `/webm:start` and `/webm:launch` both say so.
81
+
82
+ - **`/webm:start` creates the Worker.** Step 5 is now one deploy from the laptop — `npm run build
83
+ && npx wrangler deploy`, guarded by `wrangler deployments list` so a re-run skips it — and step 6
84
+ connects the repo to the Worker that now exists (Settings → Builds), a smaller dashboard step
85
+ than importing a repository and typing the Worker name in by hand. The skill used to stop and
86
+ ask for the Worker to be made in the dashboard; on one site it was not, and the result was a
87
+ repo, a database and nothing serving. That laptop deploy is the only one a site ever gets: once
88
+ the repo is connected, a laptop deploy is a version no build produced, which the next push
89
+ reverts. The adapter's auto-provisioned `<slug>-session` KV namespace is expected and stays out
90
+ of wrangler.jsonc. The scaffolded README's deploy note says the same.
91
+
92
+ ### Added
93
+
94
+ - **`webm doctor` checks that the Worker exists.** It asks wrangler for the deployments of the
95
+ Worker named in wrangler.jsonc and warns when there are none — the silent failure above. It skips
96
+ with a note when wrangler is not installed or not logged in, so CI and a fresh laptop are not
97
+ failed for being unable to ask.
98
+
99
+ - **Hard rule 12 in a site's CLAUDE.md: a session in a client repo never edits the package.** Not
100
+ in `node_modules`, and not in the package's checkout when it sits on the same machine. The
101
+ deliverable for an upstream problem is a description of the fix — what, where, expected
102
+ behaviour, how to verify — as a prompt for a session opened in the package repo; the site takes
103
+ the fix with `npm update`. Enforced as far as Claude Code's rules reach: `.claude/settings.json`
104
+ denies `Edit(**/node_modules/**)`, and **`webm sync` now merges the package's deny rules into a
105
+ site's settings on every install** — adding what is missing, leaving the site's own rules alone,
106
+ and dropping the two `Write(...)` rules 1.2.0 scaffolded, which Claude Code never consults and
107
+ warns about at startup. The docs have no rule syntax for "any path outside this project", so
108
+ that half stays prose.
109
+
110
+ **On an existing site:** the settings arrive with the next install. `CLAUDE.md` is the site's
111
+ own file, so copy rule 12 in from
112
+ `node_modules/@cparkerwebm/webmonterey/template/site/CLAUDE.md`.
113
+
114
+ ---
115
+
14
116
  ## 1.2.0 — 2026-09-02
15
117
 
16
118
  ### Added
package/README.md CHANGED
@@ -20,6 +20,22 @@ the reset, the Cloudflare includes, the consent system, the form pipeline, the r
20
20
  It ships **zero visible components**. Every block a visitor sees is built per client, in that
21
21
  client's repo. Every default has a documented way to opt out of it.
22
22
 
23
+ A site hands the package its components through `src/components/registry.ts`: `blocks` for the
24
+ block router, `header`, `footer` and `panels` for the chrome, `pageHeader` in place of the
25
+ router's plain `<h1>`, `structuredData` for the site's JSON-LD, and `webmasterPage` for the body
26
+ of the `/webmaster` page. That last one is the seam for a site whose document pages have a richer
27
+ layout than a heading and a stack of paragraphs: the component receives the merged copy
28
+ (`{ title, description, intro, body }`; `intro` and `body` are HTML, the agency link already in
29
+ `intro`) and lays it out; the route, the words, the `<head>`, the share image and the agency graph
30
+ stay the package's. It carries no copy of its own - the words are overridden through
31
+ `copy.webmaster` in `webmonterey.json`, not in the component.
32
+
33
+ **The package is edited in this repo, and only here.** A session in a client site that finds a
34
+ package bug does not reach into `node_modules` or into this checkout; its deliverable is a
35
+ description of the fix, run later in a session opened here, and the site takes the result with
36
+ `npm update`. That is rule 12 of the site's `CLAUDE.md`, with an `Edit` deny on `node_modules`
37
+ behind it that `webm sync` keeps in place. The inverse holds: nothing here edits a client site.
38
+
23
39
  ## Creating a client site
24
40
 
25
41
  ```sh
package/dist/webm.mjs CHANGED
@@ -196,6 +196,53 @@ var init_mcp = __esm({
196
196
  }
197
197
  });
198
198
 
199
+ // src/cli/settings.ts
200
+ function projectSettings(repo) {
201
+ return {
202
+ "//": `Project settings for ${repo}.`,
203
+ "//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
204
+ includeCoAuthoredBy: false,
205
+ enabledMcpjsonServers: [...MCP_NAMES],
206
+ "//permissions": PERMISSIONS_NOTE,
207
+ permissions: { deny: [...DENY_RULES] }
208
+ };
209
+ }
210
+ function withDenyRules(settings) {
211
+ const permissions = settings.permissions && typeof settings.permissions === "object" ? { ...settings.permissions } : {};
212
+ const current = Array.isArray(permissions.deny) ? permissions.deny.filter((r) => typeof r === "string") : [];
213
+ const removed = current.filter((r) => STALE_RULES.includes(r));
214
+ const kept = current.filter((r) => !STALE_RULES.includes(r));
215
+ const added = DENY_RULES.filter((r) => !kept.includes(r));
216
+ if (!added.length && !removed.length) return { settings, added, removed };
217
+ permissions.deny = [...kept, ...added];
218
+ return {
219
+ settings: { ...settings, "//permissions": PERMISSIONS_NOTE, permissions },
220
+ added,
221
+ removed
222
+ };
223
+ }
224
+ var DENY_RULES, STALE_RULES, PERMISSIONS_NOTE;
225
+ var init_settings = __esm({
226
+ "src/cli/settings.ts"() {
227
+ "use strict";
228
+ init_mcp();
229
+ DENY_RULES = [
230
+ "Read(**/.dev.vars)",
231
+ "Read(**/.dev.vars.*)",
232
+ "Read(**/.env)",
233
+ "Read(**/.env.*)",
234
+ "Read(**/*.pem)",
235
+ "Read(**/*.key)",
236
+ "Read(**/.npmrc)",
237
+ "Edit(**/.dev.vars)",
238
+ "Edit(**/.env)",
239
+ "Edit(**/node_modules/**)"
240
+ ];
241
+ STALE_RULES = ["Write(**/.dev.vars)", "Write(**/.env)"];
242
+ PERMISSIONS_NOTE = "The deny list is package-managed: `webm sync` adds any rule that is missing on every install and leaves everything else in this file alone. Edit(**/node_modules/**) is rule 12 of CLAUDE.md made mechanical - a session in this repo never edits the package.";
243
+ }
244
+ });
245
+
199
246
  // src/cli/scaffold.ts
200
247
  function shiftDays(date, days) {
201
248
  const shifted = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
@@ -279,7 +326,7 @@ export default defineConfig({
279
326
  worker: n.worker,
280
327
  slug: n.slug,
281
328
  launched: null,
282
- "//environment": "What this deployment is FOR. 'staging' redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production'. Anything served from workers.dev is treated as staging regardless, so a branch preview of a live site is covered too.",
329
+ "//environment": "What this deployment is FOR. 'staging' makes every build a PREVIEW - every page noindex with no canonical, no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, main included, so a site that has not launched cannot be indexed before it exists; and it redirects EVERY email the site sends to stagingEmail below, so testing a form on a preview cannot reach the client's real contacts. A new site starts here; /webm:launch flips it to 'production' once the custom domain is live, and that flip is what makes the site indexable. A branch other than main is a preview regardless, and anything served from workers.dev redirects its mail regardless, so a branch preview of a live site is covered too.",
283
330
  environment: "staging",
284
331
  "//stagingEmail": "Where staging email goes instead of its real recipients. REQUIRED while environment is staging - a staging site with nowhere to send refuses to send rather than guessing. webm doctor checks.",
285
332
  stagingEmail: options.stagingEmail ?? "",
@@ -372,31 +419,7 @@ export default defineConfig({
372
419
  null,
373
420
  2
374
421
  ) + "\n";
375
- files[".claude/settings.json"] = JSON.stringify(
376
- {
377
- "//": `Project settings for ${n.repo}.`,
378
- "//mcp": "A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.",
379
- includeCoAuthoredBy: false,
380
- enabledMcpjsonServers: MCP_NAMES,
381
- permissions: {
382
- deny: [
383
- "Read(**/.dev.vars)",
384
- "Read(**/.dev.vars.*)",
385
- "Read(**/.env)",
386
- "Read(**/.env.*)",
387
- "Read(**/*.pem)",
388
- "Read(**/*.key)",
389
- "Read(**/.npmrc)",
390
- "Edit(**/.dev.vars)",
391
- "Edit(**/.env)",
392
- "Write(**/.dev.vars)",
393
- "Write(**/.env)"
394
- ]
395
- }
396
- },
397
- null,
398
- 2
399
- ) + "\n";
422
+ files[".claude/settings.json"] = JSON.stringify(projectSettings(n.repo), null, 2) + "\n";
400
423
  files[".mcp.json"] = JSON.stringify(mcpConfig(), null, 2) + "\n";
401
424
  files["src/actions/index.ts"] = `/*
402
425
  * Form handlers.
@@ -550,8 +573,11 @@ suffix - \`${n.slug}-portal\`.
550
573
 
551
574
  ## Deploying
552
575
 
553
- Push to deploy. A \`wrangler deploy\` from a laptop creates a version no build produced, so
554
- history stops describing what is live and the next push reverts it.
576
+ The Worker is created ONCE from a laptop - \`npm run build && npx wrangler deploy\` - and the
577
+ repo is then connected to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start does
578
+ both. From then on, push to deploy: a \`wrangler deploy\` from a laptop after that creates a
579
+ version no build produced, so history stops describing what is live and the next push
580
+ reverts it.
555
581
  `;
556
582
  return files;
557
583
  }
@@ -560,6 +586,7 @@ var init_scaffold = __esm({
560
586
  "use strict";
561
587
  init_slug();
562
588
  init_mcp();
589
+ init_settings();
563
590
  }
564
591
  });
565
592
 
@@ -578,7 +605,7 @@ import {
578
605
  writeFileSync,
579
606
  readdirSync
580
607
  } from "node:fs";
581
- import { basename, join as join2 } from "node:path";
608
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
582
609
  function syncDir(source, target) {
583
610
  if (!existsSync2(source)) return [];
584
611
  mkdirSync(target, { recursive: true });
@@ -604,6 +631,30 @@ function addMissing(source, target) {
604
631
  }
605
632
  return added.sort();
606
633
  }
634
+ function ensureSettings(siteRoot) {
635
+ const path = join2(siteRoot, ".claude", "settings.json");
636
+ if (!existsSync2(path)) {
637
+ mkdirSync(dirname2(path), { recursive: true });
638
+ writeFileSync(path, JSON.stringify(projectSettings(basename(siteRoot)), null, 2) + "\n");
639
+ return { added: [...DENY_RULES], removed: [], created: true, skipped: null };
640
+ }
641
+ let parsed;
642
+ try {
643
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
644
+ } catch {
645
+ return {
646
+ added: [],
647
+ removed: [],
648
+ created: false,
649
+ skipped: ".claude/settings.json is not valid JSON, so it was left alone"
650
+ };
651
+ }
652
+ const { settings, added, removed } = withDenyRules(parsed);
653
+ if (added.length || removed.length) {
654
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
655
+ }
656
+ return { added, removed, created: false, skipped: null };
657
+ }
607
658
  function listSkills(dir) {
608
659
  if (!existsSync2(dir)) return [];
609
660
  return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync2(join2(dir, e.name, "SKILL.md"))).map((e) => e.name).sort();
@@ -660,7 +711,8 @@ function sync(siteRoot) {
660
711
  * which is how code depending on an unpublished package reached main without a word.
661
712
  */
662
713
  workflows: syncDir(join2(template, "workflows"), join2(siteRoot, ".github/workflows")),
663
- migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations"))
714
+ migrations: addMissing(join2(template, "migrations"), join2(siteRoot, "migrations")),
715
+ settings: ensureSettings(siteRoot)
664
716
  };
665
717
  }
666
718
  function ensureGitignored(siteRoot) {
@@ -691,6 +743,19 @@ function run(argv2) {
691
743
  for (const m of result.migrations) {
692
744
  console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
693
745
  }
746
+ if (result.settings.created) {
747
+ console.log(` + .claude/settings.json`);
748
+ } else if (result.settings.skipped) {
749
+ console.log(` ${result.settings.skipped}`);
750
+ } else if (result.settings.added.length || result.settings.removed.length) {
751
+ const parts = [
752
+ result.settings.added.length && `+${result.settings.added.length} deny`,
753
+ result.settings.removed.length && `-${result.settings.removed.length} stale`
754
+ ].filter(Boolean);
755
+ console.log(
756
+ ` .claude/settings.json: ${parts.join(", ")} (package-managed rules; yours are kept)`
757
+ );
758
+ }
694
759
  if (ensureGitignored(siteRoot)) {
695
760
  console.log(` gitignored .claude/skills/${NAMESPACE}/`);
696
761
  }
@@ -707,13 +772,14 @@ var init_sync = __esm({
707
772
  "src/cli/sync.ts"() {
708
773
  "use strict";
709
774
  init_package_root();
775
+ init_settings();
710
776
  NAMESPACE = "webm";
711
777
  }
712
778
  });
713
779
 
714
780
  // src/cli/seed.ts
715
781
  import { cpSync as cpSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
716
- import { dirname as dirname2, join as join3, relative } from "node:path";
782
+ import { dirname as dirname3, join as join3, relative } from "node:path";
717
783
  function walk(dir) {
718
784
  if (!existsSync3(dir)) return [];
719
785
  return readdirSync2(dir, { withFileTypes: true }).flatMap((e) => {
@@ -732,7 +798,7 @@ function seed(packageRoot, siteRoot) {
732
798
  const rel = relative(source, file);
733
799
  const dest = join3(siteRoot, to === "." ? rel : join3(to, rel));
734
800
  if (existsSync3(dest)) continue;
735
- mkdirSync2(dirname2(dest), { recursive: true });
801
+ mkdirSync2(dirname3(dest), { recursive: true });
736
802
  cpSync2(file, dest);
737
803
  written.push(relative(siteRoot, dest));
738
804
  }
@@ -800,7 +866,7 @@ __export(new_exports, {
800
866
  });
801
867
  import { execFileSync } from "node:child_process";
802
868
  import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
803
- import { dirname as dirname3, join as join4, resolve } from "node:path";
869
+ import { dirname as dirname4, join as join4, resolve } from "node:path";
804
870
  function parseArgs(argv2) {
805
871
  const positional = argv2.filter((a) => !a.startsWith("--"));
806
872
  const flag = (name) => {
@@ -863,7 +929,7 @@ function run2(argv2) {
863
929
  });
864
930
  for (const [path, contents] of Object.entries(files)) {
865
931
  const full = join4(root, path);
866
- mkdirSync3(dirname3(full), { recursive: true });
932
+ mkdirSync3(dirname4(full), { recursive: true });
867
933
  writeFileSync3(full, contents);
868
934
  }
869
935
  const seeded = seed(PACKAGE_ROOT, root);
@@ -1982,7 +2048,7 @@ var init_checks = __esm({
1982
2048
  {
1983
2049
  id: "environment",
1984
2050
  title: "The declared environment matches where the site actually is",
1985
- silentAs: "a launched site whose client email is still being diverted to the agency's inbox",
2051
+ silentAs: "a launched site whose client email is still being diverted to the agency's inbox, and whose every page is noindex",
1986
2052
  run(ctx) {
1987
2053
  const declared = ctx.site.environment;
1988
2054
  if (declared !== void 0 && declared !== "production" && declared !== "staging") {
@@ -1992,12 +2058,37 @@ var init_checks = __esm({
1992
2058
  }
1993
2059
  if (declared === "staging" && isConfigured(ctx.site.launched)) {
1994
2060
  return fail(
1995
- `this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients. Set "environment": "production" in webmonterey.json.`
2061
+ `this site launched on ${ctx.site.launched} but is still declared staging, so every email it sends is being redirected away from its real recipients - and since 1.3.0 every build of a staging site is a preview: noindex on every page, no canonical, no sitemap, robots.txt disallowing everything. The live site is dropping out of search. Set "environment": "production" in webmonterey.json.`
1996
2062
  );
1997
2063
  }
1998
2064
  if (declared !== "staging" && !isConfigured(ctx.site.launched)) {
1999
2065
  return warn(
2000
- `this site has no launch date but is treated as production, so testing a form will email the client's real contacts. Set "environment": "staging" in webmonterey.json until /webm:launch.`
2066
+ `this site has no launch date but is treated as production, so testing a form will email the client's real contacts and every page is indexable on its workers.dev hostname. Set "environment": "staging" in webmonterey.json until /webm:launch.`
2067
+ );
2068
+ }
2069
+ return pass;
2070
+ }
2071
+ },
2072
+ {
2073
+ /*
2074
+ * THE WORKER EXISTS. /webm:start used to end with a repo, a D1 database and an instruction
2075
+ * to create the Worker in the dashboard by hand - and on one site nobody did. Nothing local
2076
+ * notices: the build is green, every other check here is green, and the site is a
2077
+ * workers.dev hostname that answers nothing. The Worker is the one resource whose absence
2078
+ * has no symptom on disk, so this asks Cloudflare through wrangler - the one thing a laptop
2079
+ * can ask - and steps aside with a note when it cannot.
2080
+ */
2081
+ id: "worker-exists",
2082
+ title: "The Worker exists",
2083
+ silentAs: "a site with a repo, a database and nothing serving",
2084
+ run(ctx) {
2085
+ if (ctx.worker.skipped) return { status: "pass", detail: `skipped: ${ctx.worker.skipped}` };
2086
+ if (!ctx.worker.name) {
2087
+ return warn("wrangler.jsonc names no Worker, so there is nothing to look for");
2088
+ }
2089
+ if (!ctx.worker.deployments) {
2090
+ return warn(
2091
+ `no deployment of a Worker named "${ctx.worker.name}" on this account. Create it once from the laptop - npm run build && npx wrangler deploy - then connect the repo to it in the dashboard (Worker \u2192 Settings \u2192 Builds). /webm:start, steps 5 and 6.`
2001
2092
  );
2002
2093
  }
2003
2094
  return pass;
@@ -2079,8 +2170,10 @@ __export(doctor_exports, {
2079
2170
  buildContext: () => buildContext,
2080
2171
  run: () => run5
2081
2172
  });
2173
+ import { execFileSync as execFileSync2 } from "node:child_process";
2082
2174
  import { existsSync as existsSync8, readFileSync as readFileSync7, readdirSync as readdirSync6 } from "node:fs";
2083
- import { join as join7, relative as relative3 } from "node:path";
2175
+ import { createRequire } from "node:module";
2176
+ import { dirname as dirname5, join as join7, relative as relative3, resolve as resolve2 } from "node:path";
2084
2177
  function parseJsonc(source) {
2085
2178
  const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
2086
2179
  return JSON.parse(stripped);
@@ -2140,13 +2233,63 @@ function readMcp(siteRoot) {
2140
2233
  enabled: read(".claude/settings.json", (p) => p.enabledMcpjsonServers ?? null)
2141
2234
  };
2142
2235
  }
2236
+ function workerState(siteRoot, name) {
2237
+ const worker = { name: name ?? null, deployments: null, skipped: null };
2238
+ if (!worker.name) return worker;
2239
+ let bin;
2240
+ try {
2241
+ const require2 = createRequire(join7(resolve2(siteRoot), "package.json"));
2242
+ bin = join7(dirname5(require2.resolve("wrangler/package.json")), "bin/wrangler.js");
2243
+ } catch {
2244
+ return {
2245
+ ...worker,
2246
+ skipped: "wrangler is not installed here, so the Worker was not looked for"
2247
+ };
2248
+ }
2249
+ try {
2250
+ const out = execFileSync2(
2251
+ process.execPath,
2252
+ [bin, "deployments", "list", "--name", worker.name, "--json"],
2253
+ {
2254
+ cwd: siteRoot,
2255
+ encoding: "utf8",
2256
+ stdio: ["ignore", "pipe", "pipe"],
2257
+ timeout: 3e4,
2258
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false", NO_COLOR: "1" }
2259
+ }
2260
+ );
2261
+ const start = out.indexOf("[");
2262
+ const parsed = start >= 0 ? JSON.parse(out.slice(start)) : [];
2263
+ return { ...worker, deployments: Array.isArray(parsed) ? parsed.length : 0 };
2264
+ } catch (error) {
2265
+ const e = error;
2266
+ const text = `${e.stdout ?? ""}
2267
+ ${e.stderr ?? ""}
2268
+ ${e.message ?? ""}`;
2269
+ if (/code: 10007\]|does not exist on your account/i.test(text)) {
2270
+ return { ...worker, deployments: 0 };
2271
+ }
2272
+ if (/CLOUDFLARE_API_TOKEN|not (logged in|authenticated)|Authentication error|code: (10000|6111|9109)\]/i.test(
2273
+ text
2274
+ )) {
2275
+ return {
2276
+ ...worker,
2277
+ skipped: "wrangler is not logged in (npx wrangler login), so whether the Worker exists was not checked"
2278
+ };
2279
+ }
2280
+ const line = text.split("\n").map((l) => l.replace(/\x1b\[[0-9;]*m/g, "").trim()).find((l) => l && !l.startsWith("\u{1FAB5}"));
2281
+ return { ...worker, skipped: `wrangler could not answer: ${line ?? "no output"}` };
2282
+ }
2283
+ }
2143
2284
  function buildContext(siteRoot) {
2144
2285
  const { site } = loadSiteFiles(siteRoot);
2145
2286
  const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join7(siteRoot, f)).find(existsSync8);
2146
2287
  const syncPath = join7(siteRoot, ".claude/skills/webm/.webm-sync.json");
2288
+ const wrangler = wranglerPath ? parseJsonc(readFileSync7(wranglerPath, "utf8")) : null;
2147
2289
  return {
2148
2290
  site,
2149
- wrangler: wranglerPath ? parseJsonc(readFileSync7(wranglerPath, "utf8")) : null,
2291
+ wrangler,
2292
+ worker: workerState(siteRoot, wrangler?.name),
2150
2293
  pages: readTree(siteRoot, "src/pages", [".astro", ".ts"]),
2151
2294
  components: readTree(siteRoot, "src/components", [".astro", ".ts"]),
2152
2295
  today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
@@ -2227,7 +2370,7 @@ __export(audit_exports, {
2227
2370
  run: () => run6
2228
2371
  });
2229
2372
  import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
2230
- import { join as join8, relative as relative4, resolve as resolve2 } from "node:path";
2373
+ import { join as join8, relative as relative4, resolve as resolve3 } from "node:path";
2231
2374
  function attr(tag, name) {
2232
2375
  const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
2233
2376
  if (!m) return null;
@@ -2376,7 +2519,7 @@ async function probe(urls) {
2376
2519
  return bad.sort((a, b) => a.url.localeCompare(b.url));
2377
2520
  }
2378
2521
  async function run6(argv2) {
2379
- const dist = resolve2(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2522
+ const dist = resolve3(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2380
2523
  const noExternal = argv2.includes("--no-external");
2381
2524
  if (!existsSync9(join8(dist, "index.html"))) {
2382
2525
  console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
@@ -2473,11 +2616,11 @@ var upgrade_exports = {};
2473
2616
  __export(upgrade_exports, {
2474
2617
  run: () => run7
2475
2618
  });
2476
- import { execFileSync as execFileSync2 } from "node:child_process";
2619
+ import { execFileSync as execFileSync3 } from "node:child_process";
2477
2620
  import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
2478
2621
  import { join as join9 } from "node:path";
2479
2622
  function git(siteRoot, args) {
2480
- return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2623
+ return execFileSync3("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2481
2624
  }
2482
2625
  function installedVersion(siteRoot) {
2483
2626
  const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
@@ -2507,7 +2650,7 @@ function run7(argv2) {
2507
2650
  git(siteRoot, ["checkout", "-b", branch]);
2508
2651
  console.log(`Branched to ${branch}. Never upgrade on main.`);
2509
2652
  }
2510
- execFileSync2("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2653
+ execFileSync3("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2511
2654
  const to = installedVersion(siteRoot);
2512
2655
  if (!to) {
2513
2656
  console.error("webm upgrade: install did not produce a version. Check the npm output above.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "The WebMonterey Astro framework: plumbing, design system, and Claude Code skills for client sites on Cloudflare Workers",
6
6
  "license": "MIT",
@@ -99,6 +99,11 @@ to say, and this is where it gets decided - once, with the client's real details
99
99
  4. **Validate** the built home page and one inner page with Google's Rich Results Test before
100
100
  moving on.
101
101
 
102
+ 5. **Look at `/webmaster`.** The package injects it on every site, with its own share image and
103
+ JSON-LD. If this site's document pages - privacy, terms - use a richer layout than an `<h1>`
104
+ and a stack of paragraphs, run `/webm:webmaster`: it exports a layout component from the
105
+ registry so the page looks like the site it is on, with the package's copy intact.
106
+
102
107
  ## 3. Turnstile
103
108
 
104
109
  If the site has a form: create the widget in the Cloudflare dashboard (**Turnstile → Add
@@ -211,13 +216,21 @@ In one change:
211
216
  "launched": "YYYY-MM-DD"
212
217
  ```
213
218
 
219
+ **This flip is also what makes the site indexable, so it happens here and not before.** While
220
+ `environment` says `staging`, every build is a preview - every page noindex with no canonical,
221
+ no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, `main`
222
+ included. Flip it before the custom domain is live and the `workers.dev` copy is what gets
223
+ indexed.
224
+
214
225
  Until `environment` flips, every message the site sends is redirected to `stagingEmail` - correct
215
226
  right up to the moment the domain is attached and wrong immediately after: the form keeps saying
216
227
  thank you, the client's inbox stays empty, and the first anyone hears of it is a customer asking
217
- why nobody called back. `webm doctor` fails a launched site still declared staging, which is why
218
- both fields change together.
228
+ why nobody called back. And a launched site left on `staging` is invisible to search: every page
229
+ noindex, no sitemap, `Disallow: /`. `webm doctor` fails a launched site still declared staging,
230
+ which is why both fields change together.
219
231
 
220
- Anything served from `workers.dev` is still treated as staging whatever this says, so branch
221
- previews of the live site keep redirecting. That is deliberate.
232
+ Anything served from `workers.dev` is still treated as staging for MAIL whatever this says, and
233
+ any branch other than `main` still builds as a preview, so branch previews of the live site keep
234
+ redirecting and stay out of the index. That is deliberate.
222
235
 
223
236
  Commit, push, and confirm the deploy. Then run `npx webm doctor` one last time: zero failures.
@@ -83,3 +83,9 @@ npm run preview # NOT dev - preview is a real build on real workerd
83
83
 
84
84
  `dev` cannot detect CSS bundling, cascade order, or the asset router. Every trap worth catching is
85
85
  invisible in `dev` and visible in `preview`. See `/webm:traps`.
86
+
87
+ ## If this is the site's document block
88
+
89
+ A long-form block - the one the privacy and terms pages use - is also the layout the package's
90
+ `/webmaster` page should have. The moment that block exists, run `/webm:webmaster`: it exports a
91
+ layout component built on this block, so `/webmaster` stops looking like a different site.