@cparkerwebm/webmonterey 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +6 -0
  3. package/dist/webm.mjs +445 -61
  4. package/package.json +5 -3
  5. package/skills/launch/SKILL.md +57 -9
  6. package/skills/start/SKILL.md +66 -12
  7. package/skills/traps/SKILL.md +17 -0
  8. package/src/assets/opengraph-webmaster.png +0 -0
  9. package/src/cli/audit.test.ts +120 -0
  10. package/src/cli/audit.ts +323 -0
  11. package/src/cli/checks.test.ts +46 -6
  12. package/src/cli/checks.ts +52 -12
  13. package/src/cli/doctor.ts +78 -2
  14. package/src/cli/scaffold.test.ts +9 -0
  15. package/src/cli/scaffold.ts +12 -30
  16. package/src/cli/settings.ts +106 -0
  17. package/src/cli/sync.test.ts +61 -0
  18. package/src/cli/sync.ts +64 -1
  19. package/src/emails/footer.ts +3 -3
  20. package/src/includes/cloudflare/r2/media.ts +1 -1
  21. package/src/includes/webmonterey/config.test.ts +51 -0
  22. package/src/includes/webmonterey/config.ts +49 -0
  23. package/src/includes/webmonterey/copy-defaults.ts +20 -0
  24. package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
  25. package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
  26. package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
  27. package/src/integration/index.ts +91 -4
  28. package/src/integration/virtual.d.ts +19 -1
  29. package/src/layouts/base.astro +31 -8
  30. package/src/package.test.ts +20 -0
  31. package/src/pages/robots.txt.ts +14 -0
  32. package/src/pages/webmaster-og.png.ts +31 -0
  33. package/src/pages/webmaster.astro +121 -0
  34. package/template/public/opengraph.png +0 -0
  35. package/template/scripts/test-hooks.mjs +1 -1
  36. package/template/site/CLAUDE.md +25 -2
  37. package/src/includes/webmonterey/credits/Credit.astro +0 -80
  38. package/src/includes/webmonterey/credits/credit.test.ts +0 -111
  39. package/src/includes/webmonterey/credits/credit.ts +0 -59
  40. package/template/public/open-graph.png +0 -0
  41. /package/template/assets/{open-graph.png → opengraph.png} +0 -0
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);
@@ -1955,17 +2021,17 @@ var init_checks = __esm({
1955
2021
  }
1956
2022
  },
1957
2023
  {
1958
- id: "agency-credit",
1959
- title: "Something renders the agency credit",
1960
- silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
2024
+ id: "webmaster-credit",
2025
+ title: "Something renders the webmaster credit",
2026
+ silentAs: 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
1961
2027
  run(ctx) {
1962
2028
  if (ctx.components.size === 0) return pass;
1963
2029
  if (ctx.site.domain === "webmonterey.com") return pass;
1964
2030
  for (const src of ctx.components.values()) {
1965
- if (/webmonterey\/credits/.test(stripComments(src))) return pass;
2031
+ if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
1966
2032
  }
1967
2033
  return warn(
1968
- "no component imports @cparkerwebm/webmonterey/webmonterey/credits/Credit.astro. The footer component is where it goes."
2034
+ "no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. The footer component is where it goes; it links to the /webmaster page."
1969
2035
  );
1970
2036
  }
1971
2037
  },
@@ -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,25 +2170,27 @@ __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);
2087
2180
  }
2088
2181
  function readTree(root, dir, exts) {
2089
2182
  const out = /* @__PURE__ */ new Map();
2090
- const walk3 = (d) => {
2183
+ const walk4 = (d) => {
2091
2184
  if (!existsSync8(d)) return;
2092
2185
  for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
2093
2186
  const full = join7(d, entry2.name);
2094
- if (entry2.isDirectory()) walk3(full);
2187
+ if (entry2.isDirectory()) walk4(full);
2095
2188
  else if (exts.some((e) => entry2.name.endsWith(e))) {
2096
2189
  out.set(relative3(root, full), readFileSync7(full, "utf8"));
2097
2190
  }
2098
2191
  }
2099
2192
  };
2100
- walk3(join7(root, dir));
2193
+ walk4(join7(root, dir));
2101
2194
  return out;
2102
2195
  }
2103
2196
  function placeholderFiles(siteRoot) {
@@ -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),
@@ -2213,6 +2356,243 @@ var init_doctor = __esm({
2213
2356
  }
2214
2357
  });
2215
2358
 
2359
+ // src/cli/audit.ts
2360
+ var audit_exports = {};
2361
+ __export(audit_exports, {
2362
+ attr: () => attr,
2363
+ audit: () => audit,
2364
+ auditSitemap: () => auditSitemap,
2365
+ hasAttr: () => hasAttr,
2366
+ imagesWithoutAlt: () => imagesWithoutAlt,
2367
+ links: () => links,
2368
+ locs: () => locs,
2369
+ resolves: () => resolves,
2370
+ run: () => run6
2371
+ });
2372
+ import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync8, statSync as statSync3 } from "node:fs";
2373
+ import { join as join8, relative as relative4, resolve as resolve3 } from "node:path";
2374
+ function attr(tag, name) {
2375
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
2376
+ if (!m) return null;
2377
+ return m[1] ?? m[2] ?? m[3] ?? "";
2378
+ }
2379
+ function hasAttr(tag, name) {
2380
+ return new RegExp(`\\s${name}(?=[\\s=>/])`, "i").test(tag);
2381
+ }
2382
+ function imagesWithoutAlt(html) {
2383
+ const out = [];
2384
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
2385
+ const tag = m[0];
2386
+ if (hasAttr(tag, "alt")) continue;
2387
+ out.push(attr(tag, "src") ?? "(no src)");
2388
+ }
2389
+ return out;
2390
+ }
2391
+ function links(html, origin) {
2392
+ const internal = /* @__PURE__ */ new Set();
2393
+ const external = /* @__PURE__ */ new Set();
2394
+ for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
2395
+ const href = attr(m[0], "href");
2396
+ if (!href || SKIP_SCHEMES.test(href)) continue;
2397
+ if (/^https?:\/\//i.test(href)) {
2398
+ if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || "/");
2399
+ else external.add(href);
2400
+ continue;
2401
+ }
2402
+ if (href.startsWith("//")) {
2403
+ external.add(`https:${href}`);
2404
+ continue;
2405
+ }
2406
+ internal.add(href);
2407
+ }
2408
+ return { internal: [...internal], external: [...external] };
2409
+ }
2410
+ function resolves(href, input) {
2411
+ let path = href.split("#")[0].split("?")[0];
2412
+ try {
2413
+ path = decodeURIComponent(path);
2414
+ } catch {
2415
+ }
2416
+ if (!path.startsWith("/")) return true;
2417
+ if (path === "/") return input.exists("index.html");
2418
+ const bare = path.replace(/\/$/, "");
2419
+ const rel = bare.slice(1);
2420
+ if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
2421
+ return true;
2422
+ }
2423
+ return input.workerFirst.some(
2424
+ (entry2) => entry2.endsWith("/*") ? bare === entry2.slice(0, -2) || bare.startsWith(entry2.slice(0, -1)) : entry2 === bare || entry2 === `${bare}/`
2425
+ );
2426
+ }
2427
+ function locs(xml) {
2428
+ return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]);
2429
+ }
2430
+ function auditSitemap(input) {
2431
+ const problems = [];
2432
+ const index = input.read("sitemap-index.xml");
2433
+ if (!index) {
2434
+ return {
2435
+ problems: [
2436
+ input.origin ? "sitemap-index.xml was not built" : "no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL"
2437
+ ],
2438
+ urls: 0
2439
+ };
2440
+ }
2441
+ const robots = input.read("robots.txt") ?? "";
2442
+ if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push("robots.txt has no Sitemap: line");
2443
+ let urls = 0;
2444
+ for (const childUrl of locs(index)) {
2445
+ const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, "");
2446
+ const child = input.read(childRel);
2447
+ if (!child) {
2448
+ problems.push(`${childRel} is listed in the index and was not built`);
2449
+ continue;
2450
+ }
2451
+ for (const url of locs(child)) {
2452
+ urls++;
2453
+ if (input.origin && !url.startsWith(input.origin)) {
2454
+ problems.push(`${url} is not on ${input.origin}`);
2455
+ continue;
2456
+ }
2457
+ const path = url.replace(/^https?:\/\/[^/]+/, "") || "/";
2458
+ if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
2459
+ }
2460
+ }
2461
+ if (urls === 0) problems.push("the sitemap lists no URLs");
2462
+ return { problems, urls };
2463
+ }
2464
+ function audit(input) {
2465
+ const missingAlt = [];
2466
+ const brokenInternal = [];
2467
+ const external = /* @__PURE__ */ new Set();
2468
+ for (const [page, html] of input.pages) {
2469
+ for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
2470
+ const found = links(html, input.origin);
2471
+ for (const href of found.internal) {
2472
+ if (!resolves(href, input)) brokenInternal.push({ page, href });
2473
+ }
2474
+ for (const url of found.external) external.add(url);
2475
+ }
2476
+ return {
2477
+ missingAlt,
2478
+ brokenInternal,
2479
+ external: [...external].sort(),
2480
+ sitemap: auditSitemap(input)
2481
+ };
2482
+ }
2483
+ function walk3(dir) {
2484
+ if (!existsSync9(dir)) return [];
2485
+ return readdirSync7(dir, { withFileTypes: true }).flatMap((e) => {
2486
+ const full = join8(dir, e.name);
2487
+ return e.isDirectory() ? walk3(full) : [full];
2488
+ });
2489
+ }
2490
+ function parseJsonc2(source) {
2491
+ const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
2492
+ return JSON.parse(stripped);
2493
+ }
2494
+ async function probe(urls) {
2495
+ const bad = [];
2496
+ const queue = [...urls];
2497
+ const worker = async () => {
2498
+ for (let url = queue.shift(); url; url = queue.shift()) {
2499
+ try {
2500
+ let res = await fetch(url, {
2501
+ method: "HEAD",
2502
+ redirect: "follow",
2503
+ signal: AbortSignal.timeout(8e3)
2504
+ });
2505
+ if (res.status === 405 || res.status === 403) {
2506
+ res = await fetch(url, {
2507
+ method: "GET",
2508
+ redirect: "follow",
2509
+ signal: AbortSignal.timeout(8e3)
2510
+ });
2511
+ }
2512
+ if (res.status >= 400) bad.push({ url, status: String(res.status) });
2513
+ } catch (error) {
2514
+ bad.push({ url, status: error instanceof Error ? error.name : "error" });
2515
+ }
2516
+ }
2517
+ };
2518
+ await Promise.all(Array.from({ length: 6 }, worker));
2519
+ return bad.sort((a, b) => a.url.localeCompare(b.url));
2520
+ }
2521
+ async function run6(argv2) {
2522
+ const dist = resolve3(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2523
+ const noExternal = argv2.includes("--no-external");
2524
+ if (!existsSync9(join8(dist, "index.html"))) {
2525
+ console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
2526
+ return 1;
2527
+ }
2528
+ const siteRoot = process.cwd();
2529
+ const files = walk3(dist);
2530
+ const rels = new Set(files.map((f) => relative4(dist, f)));
2531
+ const pages = new Map(
2532
+ files.filter((f) => f.endsWith(".html")).map((f) => [relative4(dist, f), readFileSync8(f, "utf8")])
2533
+ );
2534
+ const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join8(siteRoot, f)).find(existsSync9);
2535
+ const wrangler = wranglerPath ? parseJsonc2(readFileSync8(wranglerPath, "utf8")) : null;
2536
+ const sitePath = join8(siteRoot, "webmonterey.json");
2537
+ const site = existsSync9(sitePath) ? JSON.parse(readFileSync8(sitePath, "utf8")) : {};
2538
+ const origin = site.domain && site.domain !== "CHANGEME" ? `https://${site.domain}` : void 0;
2539
+ const report = audit({
2540
+ pages,
2541
+ exists: (rel) => rels.has(rel),
2542
+ read: (rel) => rels.has(rel) && statSync3(join8(dist, rel)).isFile() ? readFileSync8(join8(dist, rel), "utf8") : null,
2543
+ workerFirst: wrangler?.assets?.run_worker_first ?? [],
2544
+ origin
2545
+ });
2546
+ let failed = 0;
2547
+ const section = (ok, title) => console.log(`${ok ? " ok " : "FAIL "} ${title}`);
2548
+ section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
2549
+ for (const { page, src } of report.missingAlt)
2550
+ console.log(` ${page}: <img src="${src}"> has no alt attribute`);
2551
+ if (report.missingAlt.length) {
2552
+ failed++;
2553
+ console.log(
2554
+ ` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`
2555
+ );
2556
+ }
2557
+ section(
2558
+ report.brokenInternal.length === 0,
2559
+ "Every internal link lands on a page or a Worker route"
2560
+ );
2561
+ for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
2562
+ if (report.brokenInternal.length) failed++;
2563
+ section(
2564
+ report.sitemap.problems.length === 0,
2565
+ `The sitemap is complete and advertised (${report.sitemap.urls} URLs)`
2566
+ );
2567
+ for (const p of report.sitemap.problems) console.log(` ${p}`);
2568
+ if (report.sitemap.problems.length) failed++;
2569
+ if (noExternal) {
2570
+ console.log(` -- ${report.external.length} external links not probed (--no-external)`);
2571
+ } else if (report.external.length) {
2572
+ const bad = await probe(report.external);
2573
+ console.log(
2574
+ `${bad.length ? "warn " : " ok "} ${report.external.length} external links respond (${bad.length} did not)`
2575
+ );
2576
+ for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
2577
+ if (bad.length)
2578
+ console.log(
2579
+ ` Open each in a browser before deciding it is broken - many sites refuse bots.`
2580
+ );
2581
+ } else {
2582
+ console.log(" ok no external links");
2583
+ }
2584
+ console.log(`
2585
+ ${failed === 0 ? "audit clean" : `${failed} check(s) failed`}`);
2586
+ return failed ? 1 : 0;
2587
+ }
2588
+ var SKIP_SCHEMES;
2589
+ var init_audit = __esm({
2590
+ "src/cli/audit.ts"() {
2591
+ "use strict";
2592
+ SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
2593
+ }
2594
+ });
2595
+
2216
2596
  // src/cli/codemods.ts
2217
2597
  function compareVersions(a, b) {
2218
2598
  const pa = a.split(".").map(Number);
@@ -2234,24 +2614,24 @@ var init_codemods = __esm({
2234
2614
  // src/cli/upgrade.ts
2235
2615
  var upgrade_exports = {};
2236
2616
  __export(upgrade_exports, {
2237
- run: () => run6
2617
+ run: () => run7
2238
2618
  });
2239
- import { execFileSync as execFileSync2 } from "node:child_process";
2240
- import { existsSync as existsSync9, readFileSync as readFileSync8 } from "node:fs";
2241
- import { join as join8 } from "node:path";
2619
+ import { execFileSync as execFileSync3 } from "node:child_process";
2620
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
2621
+ import { join as join9 } from "node:path";
2242
2622
  function git(siteRoot, args) {
2243
- return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2623
+ return execFileSync3("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2244
2624
  }
2245
2625
  function installedVersion(siteRoot) {
2246
- const path = join8(siteRoot, "node_modules", PACKAGE, "package.json");
2247
- if (!existsSync9(path)) return null;
2248
- return JSON.parse(readFileSync8(path, "utf8")).version;
2626
+ const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
2627
+ if (!existsSync10(path)) return null;
2628
+ return JSON.parse(readFileSync9(path, "utf8")).version;
2249
2629
  }
2250
- function run6(argv2) {
2630
+ function run7(argv2) {
2251
2631
  const siteRoot = process.cwd();
2252
2632
  const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
2253
2633
  const dryRun = argv2.includes("--dry-run");
2254
- if (!existsSync9(join8(siteRoot, "webmonterey.json"))) {
2634
+ if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
2255
2635
  console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
2256
2636
  return 1;
2257
2637
  }
@@ -2270,7 +2650,7 @@ function run6(argv2) {
2270
2650
  git(siteRoot, ["checkout", "-b", branch]);
2271
2651
  console.log(`Branched to ${branch}. Never upgrade on main.`);
2272
2652
  }
2273
- execFileSync2("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2653
+ execFileSync3("npm", ["install", `${PACKAGE}@${target}`], { cwd: siteRoot, stdio: "inherit" });
2274
2654
  const to = installedVersion(siteRoot);
2275
2655
  if (!to) {
2276
2656
  console.error("webm upgrade: install did not produce a version. Check the npm output above.");
@@ -2317,9 +2697,9 @@ var init_upgrade = __esm({
2317
2697
 
2318
2698
  // bin/webm.mjs
2319
2699
  init_package_root();
2320
- import { readFileSync as readFileSync9 } from "node:fs";
2321
- import { join as join9 } from "node:path";
2322
- var pkg = JSON.parse(readFileSync9(join9(PACKAGE_ROOT, "package.json"), "utf8"));
2700
+ import { readFileSync as readFileSync10 } from "node:fs";
2701
+ import { join as join10 } from "node:path";
2702
+ var pkg = JSON.parse(readFileSync10(join10(PACKAGE_ROOT, "package.json"), "utf8"));
2323
2703
  var COMMANDS = {
2324
2704
  new: {
2325
2705
  blurb: "Scaffold a new client site from a domain",
@@ -2341,6 +2721,10 @@ var COMMANDS = {
2341
2721
  blurb: "Check this site against the traps that fail silently",
2342
2722
  load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
2343
2723
  },
2724
+ audit: {
2725
+ blurb: "Check a BUILD: image alt text, broken links, the sitemap. For launch.",
2726
+ load: () => Promise.resolve().then(() => (init_audit(), audit_exports))
2727
+ },
2344
2728
  upgrade: {
2345
2729
  blurb: "Move this site to a newer framework version",
2346
2730
  load: () => Promise.resolve().then(() => (init_upgrade(), upgrade_exports))