@cparkerwebm/webmonterey 1.0.0 → 1.2.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 (42) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +6 -1
  3. package/dist/webm.mjs +290 -48
  4. package/package.json +5 -3
  5. package/skills/launch/SKILL.md +47 -7
  6. package/skills/start/SKILL.md +25 -15
  7. package/skills/traps/SKILL.md +11 -2
  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 +6 -6
  12. package/src/cli/checks.ts +9 -7
  13. package/src/cli/new.ts +34 -17
  14. package/src/cli/scaffold.test.ts +27 -20
  15. package/src/cli/scaffold.ts +8 -7
  16. package/src/cli/slug.test.ts +25 -28
  17. package/src/cli/slug.ts +31 -36
  18. package/src/cli/sync.ts +1 -1
  19. package/src/emails/footer.ts +3 -3
  20. package/src/includes/cloudflare/r2/README.md +3 -3
  21. package/src/includes/cloudflare/r2/media.ts +4 -4
  22. package/src/includes/webmonterey/config.test.ts +1 -1
  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 +66 -3
  28. package/src/integration/virtual.d.ts +16 -1
  29. package/src/layouts/base.astro +28 -8
  30. package/src/package.test.ts +20 -0
  31. package/src/pages/robots.txt.ts +12 -0
  32. package/src/pages/webmaster-og.png.ts +31 -0
  33. package/src/pages/webmaster.astro +121 -0
  34. package/template/migrations/README.md +1 -1
  35. package/template/public/opengraph.png +0 -0
  36. package/template/scripts/test-hooks.mjs +1 -1
  37. package/template/site/CLAUDE.md +6 -2
  38. package/src/includes/webmonterey/credits/Credit.astro +0 -80
  39. package/src/includes/webmonterey/credits/credit.test.ts +0 -111
  40. package/src/includes/webmonterey/credits/credit.ts +0 -59
  41. package/template/public/open-graph.png +0 -0
  42. /package/template/assets/{open-graph.png → opengraph.png} +0 -0
package/CHANGELOG.md CHANGED
@@ -11,6 +11,83 @@ build. See `/webm:upgrade`.
11
11
 
12
12
  ---
13
13
 
14
+ ## 1.2.0 — 2026-09-02
15
+
16
+ ### Added
17
+
18
+ - **Every site has a `/webmaster` page.** Injected by the integration, rendered with the site's
19
+ own chrome, indexable and in the sitemap. It says who designed, built and manages the site and
20
+ who to contact when something is wrong, carries its own share image (served from the package at
21
+ `/webmaster/og.png`, so a redesign reaches every site on `npm update`) and its own structured
22
+ data — a `WebPage` about the agency's `Organization`, using the same `@id` the agency's own site
23
+ declares. Every word is overridable through `copy.webmaster`. Off switch: `webmaster: false` on
24
+ the integration.
25
+
26
+ - **`webm audit`** — the pre-launch checks only a build can answer: images with no `alt`
27
+ attribute (an explicit `alt=""` is fine), internal links that land on no page or Worker route,
28
+ and a sitemap that is missing, unadvertised, or lists a page that was not built. External links
29
+ are probed and reported as warnings. `/webm:launch` runs it and says how to act on each finding.
30
+
31
+ - **Branch previews are a different build.** On any Workers Builds branch other than the
32
+ production one (`main`, or `productionBranch` on the integration), every page is noindex with
33
+ no canonical, there is no sitemap, `robots.txt` disallows everything, Google Tag Manager does
34
+ not load, and `/webmaster` emits no graph. Detected from the `WORKERS_CI_BRANCH` variable
35
+ Workers Builds injects; a local build is production. Mail on previews was already redirected by
36
+ the `workers.dev` hostname. Nothing to do on a site.
37
+
38
+ - **`/webm:launch` asks two questions out loud** before the flip: is Google Tag Manager
39
+ configured and published for this site, and has the launch annotation been added in Google
40
+ Analytics. Neither is knowable from the repo, so the skill waits for the answer.
41
+
42
+ ### Changed
43
+
44
+ - **The default share image is `public/opengraph.png`** (was `open-graph.png`). `webm new` seeds
45
+ the WebMonterey artwork there - the same image the `/webmaster` page uses - and a client replaces
46
+ it with their own; `webm doctor` flags the seed until they do. **On an existing site:** rename
47
+ `public/open-graph.png` to `public/opengraph.png`, or every page's `og:image` points at a file
48
+ that is not there.
49
+
50
+ - **The footer credit is an internal link.** `Powered by WebMonterey` now goes to the site's own
51
+ `/webmaster` page instead of leaving the site; that page carries the one outbound link, with
52
+ the UTM parameters (`utm_campaign=webmaster`). The email footer keeps the outbound link — an
53
+ email cannot usefully point at a page on the site it is about.
54
+
55
+ **On an existing site:** the import moves —
56
+ `@cparkerwebm/webmonterey/webmonterey/credits/Credit.astro` is now
57
+ `@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro`, and the module
58
+ `webmonterey/credits` is `webmonterey/webmaster`. The doctor check is `webmaster-credit`. The
59
+ site's `structuredData` component is not rendered on `/webmaster`; the page emits its own.
60
+
61
+ ---
62
+
63
+ ## 1.1.0 — 2026-09-02
64
+
65
+ ### Changed
66
+
67
+ - **One name, everywhere.** `webm new` now derives a single name from the domain — `example.com`
68
+ becomes `example` — and uses it for the GitHub repo, the Worker, the D1 database, the R2 bucket
69
+ and any KV namespace. The `webm-` prefix and the `-db` / `-media` suffixes are gone from cloud
70
+ resources: Cloudflare scopes every one of those names to the account, so in an account that holds
71
+ nothing but client sites a prefix said nothing, and one-of-each needs no suffix. The TLD is still
72
+ dropped, for the same reason as before — it keeps a domain out of preview hostnames, which is
73
+ what trips Chrome's lookalike warning.
74
+
75
+ **Existing sites are untouched.** Every name is read from the site's own `webmonterey.json` and
76
+ `wrangler.jsonc`; nothing renames a resource that exists. A rebuild onto the new convention is a
77
+ new repo and new resources beside the old, tested in full, then a domain cutover — which is the
78
+ clean way to do it anyway.
79
+
80
+ - **No agency defaults left in the package.** `webm new` reads the GitHub owner from
81
+ `git config webm.org` and the staging inbox from `git config webm.stagingEmail` (falling back
82
+ to `user.email`); `--org` and `--staging-email` override for one run. Without an owner it refuses
83
+ and says how to set one. Set it once per machine:
84
+
85
+ ```sh
86
+ git config --global webm.org <your-github-owner>
87
+ ```
88
+
89
+ ---
90
+
14
91
  ## 1.0.0 — 2026-09-02
15
92
 
16
93
  The first public release, on npmjs. This is the framework as it stands after two private
package/README.md CHANGED
@@ -23,11 +23,16 @@ client's repo. Every default has a documented way to opt out of it.
23
23
  ## Creating a client site
24
24
 
25
25
  ```sh
26
+ git config --global webm.org <your-github-owner> # once per machine
26
27
  npx @cparkerwebm/webmonterey new example.com --client="Example Co"
27
- cd example_com
28
+ cd example
28
29
  claude # then /webm:start
29
30
  ```
30
31
 
32
+ One name everywhere: `example.com` becomes `example` — the repo, the Worker, the D1 database and
33
+ the R2 bucket. The domain minus its TLD is the one shape every resource accepts, and it keeps a
34
+ domain out of preview hostnames, which is what trips Chrome's lookalike warning.
35
+
31
36
  `webm new` writes the identity and design files, a working contact form, a home page and the
32
37
  fleet skills, then runs `git init` and `npm install`. It touches nothing outside the directory.
33
38
  `/webm:start` takes it from there: the GitHub repo, Cloudflare resources, Workers Builds, the
package/dist/webm.mjs CHANGED
@@ -67,9 +67,6 @@ function normalizeDomain(input) {
67
67
  }
68
68
  return cleaned;
69
69
  }
70
- function repoName(domain) {
71
- return normalizeDomain(domain).replace(/\./g, "_");
72
- }
73
70
  function slugFor(domain) {
74
71
  const clean = normalizeDomain(domain);
75
72
  const parts = clean.split(".");
@@ -78,16 +75,12 @@ function slugFor(domain) {
78
75
  const kept = parts.slice(0, Math.max(1, parts.length - drop));
79
76
  return kept.join("-");
80
77
  }
78
+ function repoName(domain) {
79
+ return slugFor(domain);
80
+ }
81
81
  function resourceNames(domain) {
82
82
  const slug = slugFor(domain);
83
- return {
84
- slug,
85
- repo: repoName(domain),
86
- worker: `webm-${slug}`,
87
- d1: `webm-${slug}-db`,
88
- r2Media: `webm-${slug}-media`,
89
- r2App: `webm-${slug}-app`
90
- };
83
+ return { slug, repo: slug, worker: slug, d1: slug, r2: slug, kv: slug };
91
84
  }
92
85
  var TWO_PART_SUFFIXES, DomainError;
93
86
  var init_slug = __esm({
@@ -213,7 +206,7 @@ function scaffold(options) {
213
206
  const { domain, packageVersion: packageVersion2 } = options;
214
207
  const n = resourceNames(domain);
215
208
  const client = options.client ?? "CHANGEME";
216
- const org = options.org ?? "webmonterey";
209
+ const { org } = options;
217
210
  const { today } = options;
218
211
  if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
219
212
  throw new Error(`scaffold: today must be YYYY-MM-DD, got ${JSON.stringify(today)}`);
@@ -549,12 +542,11 @@ ${domain} \u2014 built on [@cparkerwebm/webmonterey](https://github.com/cparkerw
549
542
 
550
543
  | | |
551
544
  | --- | --- |
552
- | Worker | \`${n.worker}\` |
553
- | D1 | \`${n.d1}\` |
554
- | R2 media | \`${n.r2Media}\` |
545
+ | Worker \xB7 D1 \xB7 R2 | \`${n.slug}\` |
555
546
 
556
- The repo is named for the domain; Cloudflare resources use the slug, with no TLD, so a
557
- preview hostname never embeds a domain Chrome could mistake for a lookalike.
547
+ One name everywhere: the domain minus its TLD. A preview hostname then never embeds a
548
+ domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose
549
+ suffix - \`${n.slug}-portal\`.
558
550
 
559
551
  ## Deploying
560
552
 
@@ -818,26 +810,33 @@ function parseArgs(argv2) {
818
810
  return {
819
811
  domain: positional[0],
820
812
  client: flag("client"),
821
- org: flag("org") ?? "webmonterey",
822
- stagingEmail: flag("staging-email"),
813
+ org: flag("org") ?? gitConfig("webm.org"),
814
+ stagingEmail: flag("staging-email") ?? gitConfig("webm.stagingEmail") ?? gitConfig("user.email"),
823
815
  into: flag("into"),
824
816
  install: !argv2.includes("--no-install")
825
817
  };
826
818
  }
827
- function gitUserEmail() {
819
+ function gitConfig(key) {
828
820
  try {
829
- return execFileSync("git", ["config", "user.email"], { encoding: "utf8" }).trim();
821
+ const value = execFileSync("git", ["config", key], { encoding: "utf8" }).trim();
822
+ return value || void 0;
830
823
  } catch {
831
- return "";
824
+ return void 0;
832
825
  }
833
826
  }
834
827
  function run2(argv2) {
835
828
  const args = parseArgs(argv2);
836
829
  if (!args.domain) {
837
830
  console.error(
838
- 'webm new <domain> [--client="Name"] [--org=webmonterey] [--staging-email=you@example.com] [--into=path] [--no-install]'
831
+ 'webm new <domain> [--client="Name"] [--org=<github-owner>] [--staging-email=you@example.com] [--into=path] [--no-install]'
832
+ );
833
+ console.error('\n webm new example.com --client="Example Co"');
834
+ return 1;
835
+ }
836
+ if (!args.org) {
837
+ console.error(
838
+ "webm new: no GitHub owner for the repo. Either pass --org=<owner> or set it once:\n\n git config --global webm.org <owner>\n"
839
839
  );
840
- console.error('\n webm new autire.com --client="Autire Technologies"');
841
840
  return 1;
842
841
  }
843
842
  let domain;
@@ -857,7 +856,7 @@ function run2(argv2) {
857
856
  domain,
858
857
  client: args.client,
859
858
  org: args.org,
860
- stagingEmail: args.stagingEmail ?? gitUserEmail(),
859
+ stagingEmail: args.stagingEmail,
861
860
  packageVersion: packageVersion(),
862
861
  /* Real today, not a constant - see ScaffoldOptions.today for what a stale one does. */
863
862
  today: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
@@ -874,12 +873,14 @@ function run2(argv2) {
874
873
  console.log(` + ${seeded.length} seeded (favicons, headers, CLAUDE.md, a contact form)`);
875
874
  console.log(` + ${kept.length} .gitkeep, so the empty directories survive a clone
876
875
  `);
877
- console.log(` repo ${args.org}/${names.repo} (keeps the domain)`);
876
+ console.log(` repo ${args.org}/${names.repo}`);
878
877
  console.log(
879
- ` worker ${names.worker} (slug - no TLD, so Chrome does not flag previews)`
878
+ ` worker \xB7 d1 \xB7 r2 ${names.slug} (the domain minus its TLD, so Chrome does not flag previews)`
879
+ );
880
+ console.log(
881
+ `
882
+ One name everywhere. If ${names.slug} is already taken in the account - ${domain.replace(/^[^.]+/, names.slug)} and another TLD both want it - pick another with --into and edit webmonterey.json.`
880
883
  );
881
- console.log(` d1 ${names.d1}`);
882
- console.log(` r2 ${names.r2Media}`);
883
884
  try {
884
885
  execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
885
886
  } catch {
@@ -1954,17 +1955,17 @@ var init_checks = __esm({
1954
1955
  }
1955
1956
  },
1956
1957
  {
1957
- id: "agency-credit",
1958
- title: "Something renders the agency credit",
1959
- silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
1958
+ id: "webmaster-credit",
1959
+ title: "Something renders the webmaster credit",
1960
+ silentAs: 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
1960
1961
  run(ctx) {
1961
1962
  if (ctx.components.size === 0) return pass;
1962
1963
  if (ctx.site.domain === "webmonterey.com") return pass;
1963
1964
  for (const src of ctx.components.values()) {
1964
- if (/webmonterey\/credits/.test(stripComments(src))) return pass;
1965
+ if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
1965
1966
  }
1966
1967
  return warn(
1967
- "no component imports @cparkerwebm/webmonterey/webmonterey/credits/Credit.astro. The footer component is where it goes."
1968
+ "no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. The footer component is where it goes; it links to the /webmaster page."
1968
1969
  );
1969
1970
  }
1970
1971
  },
@@ -2086,17 +2087,17 @@ function parseJsonc(source) {
2086
2087
  }
2087
2088
  function readTree(root, dir, exts) {
2088
2089
  const out = /* @__PURE__ */ new Map();
2089
- const walk3 = (d) => {
2090
+ const walk4 = (d) => {
2090
2091
  if (!existsSync8(d)) return;
2091
2092
  for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
2092
2093
  const full = join7(d, entry2.name);
2093
- if (entry2.isDirectory()) walk3(full);
2094
+ if (entry2.isDirectory()) walk4(full);
2094
2095
  else if (exts.some((e) => entry2.name.endsWith(e))) {
2095
2096
  out.set(relative3(root, full), readFileSync7(full, "utf8"));
2096
2097
  }
2097
2098
  }
2098
2099
  };
2099
- walk3(join7(root, dir));
2100
+ walk4(join7(root, dir));
2100
2101
  return out;
2101
2102
  }
2102
2103
  function placeholderFiles(siteRoot) {
@@ -2212,6 +2213,243 @@ var init_doctor = __esm({
2212
2213
  }
2213
2214
  });
2214
2215
 
2216
+ // src/cli/audit.ts
2217
+ var audit_exports = {};
2218
+ __export(audit_exports, {
2219
+ attr: () => attr,
2220
+ audit: () => audit,
2221
+ auditSitemap: () => auditSitemap,
2222
+ hasAttr: () => hasAttr,
2223
+ imagesWithoutAlt: () => imagesWithoutAlt,
2224
+ links: () => links,
2225
+ locs: () => locs,
2226
+ resolves: () => resolves,
2227
+ run: () => run6
2228
+ });
2229
+ 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";
2231
+ function attr(tag, name) {
2232
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
2233
+ if (!m) return null;
2234
+ return m[1] ?? m[2] ?? m[3] ?? "";
2235
+ }
2236
+ function hasAttr(tag, name) {
2237
+ return new RegExp(`\\s${name}(?=[\\s=>/])`, "i").test(tag);
2238
+ }
2239
+ function imagesWithoutAlt(html) {
2240
+ const out = [];
2241
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
2242
+ const tag = m[0];
2243
+ if (hasAttr(tag, "alt")) continue;
2244
+ out.push(attr(tag, "src") ?? "(no src)");
2245
+ }
2246
+ return out;
2247
+ }
2248
+ function links(html, origin) {
2249
+ const internal = /* @__PURE__ */ new Set();
2250
+ const external = /* @__PURE__ */ new Set();
2251
+ for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
2252
+ const href = attr(m[0], "href");
2253
+ if (!href || SKIP_SCHEMES.test(href)) continue;
2254
+ if (/^https?:\/\//i.test(href)) {
2255
+ if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || "/");
2256
+ else external.add(href);
2257
+ continue;
2258
+ }
2259
+ if (href.startsWith("//")) {
2260
+ external.add(`https:${href}`);
2261
+ continue;
2262
+ }
2263
+ internal.add(href);
2264
+ }
2265
+ return { internal: [...internal], external: [...external] };
2266
+ }
2267
+ function resolves(href, input) {
2268
+ let path = href.split("#")[0].split("?")[0];
2269
+ try {
2270
+ path = decodeURIComponent(path);
2271
+ } catch {
2272
+ }
2273
+ if (!path.startsWith("/")) return true;
2274
+ if (path === "/") return input.exists("index.html");
2275
+ const bare = path.replace(/\/$/, "");
2276
+ const rel = bare.slice(1);
2277
+ if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
2278
+ return true;
2279
+ }
2280
+ return input.workerFirst.some(
2281
+ (entry2) => entry2.endsWith("/*") ? bare === entry2.slice(0, -2) || bare.startsWith(entry2.slice(0, -1)) : entry2 === bare || entry2 === `${bare}/`
2282
+ );
2283
+ }
2284
+ function locs(xml) {
2285
+ return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]);
2286
+ }
2287
+ function auditSitemap(input) {
2288
+ const problems = [];
2289
+ const index = input.read("sitemap-index.xml");
2290
+ if (!index) {
2291
+ return {
2292
+ problems: [
2293
+ input.origin ? "sitemap-index.xml was not built" : "no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL"
2294
+ ],
2295
+ urls: 0
2296
+ };
2297
+ }
2298
+ const robots = input.read("robots.txt") ?? "";
2299
+ if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push("robots.txt has no Sitemap: line");
2300
+ let urls = 0;
2301
+ for (const childUrl of locs(index)) {
2302
+ const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, "");
2303
+ const child = input.read(childRel);
2304
+ if (!child) {
2305
+ problems.push(`${childRel} is listed in the index and was not built`);
2306
+ continue;
2307
+ }
2308
+ for (const url of locs(child)) {
2309
+ urls++;
2310
+ if (input.origin && !url.startsWith(input.origin)) {
2311
+ problems.push(`${url} is not on ${input.origin}`);
2312
+ continue;
2313
+ }
2314
+ const path = url.replace(/^https?:\/\/[^/]+/, "") || "/";
2315
+ if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
2316
+ }
2317
+ }
2318
+ if (urls === 0) problems.push("the sitemap lists no URLs");
2319
+ return { problems, urls };
2320
+ }
2321
+ function audit(input) {
2322
+ const missingAlt = [];
2323
+ const brokenInternal = [];
2324
+ const external = /* @__PURE__ */ new Set();
2325
+ for (const [page, html] of input.pages) {
2326
+ for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
2327
+ const found = links(html, input.origin);
2328
+ for (const href of found.internal) {
2329
+ if (!resolves(href, input)) brokenInternal.push({ page, href });
2330
+ }
2331
+ for (const url of found.external) external.add(url);
2332
+ }
2333
+ return {
2334
+ missingAlt,
2335
+ brokenInternal,
2336
+ external: [...external].sort(),
2337
+ sitemap: auditSitemap(input)
2338
+ };
2339
+ }
2340
+ function walk3(dir) {
2341
+ if (!existsSync9(dir)) return [];
2342
+ return readdirSync7(dir, { withFileTypes: true }).flatMap((e) => {
2343
+ const full = join8(dir, e.name);
2344
+ return e.isDirectory() ? walk3(full) : [full];
2345
+ });
2346
+ }
2347
+ function parseJsonc2(source) {
2348
+ const stripped = source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => comment ? "" : m).replace(/,(\s*[}\]])/g, "$1");
2349
+ return JSON.parse(stripped);
2350
+ }
2351
+ async function probe(urls) {
2352
+ const bad = [];
2353
+ const queue = [...urls];
2354
+ const worker = async () => {
2355
+ for (let url = queue.shift(); url; url = queue.shift()) {
2356
+ try {
2357
+ let res = await fetch(url, {
2358
+ method: "HEAD",
2359
+ redirect: "follow",
2360
+ signal: AbortSignal.timeout(8e3)
2361
+ });
2362
+ if (res.status === 405 || res.status === 403) {
2363
+ res = await fetch(url, {
2364
+ method: "GET",
2365
+ redirect: "follow",
2366
+ signal: AbortSignal.timeout(8e3)
2367
+ });
2368
+ }
2369
+ if (res.status >= 400) bad.push({ url, status: String(res.status) });
2370
+ } catch (error) {
2371
+ bad.push({ url, status: error instanceof Error ? error.name : "error" });
2372
+ }
2373
+ }
2374
+ };
2375
+ await Promise.all(Array.from({ length: 6 }, worker));
2376
+ return bad.sort((a, b) => a.url.localeCompare(b.url));
2377
+ }
2378
+ async function run6(argv2) {
2379
+ const dist = resolve2(argv2.find((a) => !a.startsWith("-")) ?? "dist/client");
2380
+ const noExternal = argv2.includes("--no-external");
2381
+ if (!existsSync9(join8(dist, "index.html"))) {
2382
+ console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
2383
+ return 1;
2384
+ }
2385
+ const siteRoot = process.cwd();
2386
+ const files = walk3(dist);
2387
+ const rels = new Set(files.map((f) => relative4(dist, f)));
2388
+ const pages = new Map(
2389
+ files.filter((f) => f.endsWith(".html")).map((f) => [relative4(dist, f), readFileSync8(f, "utf8")])
2390
+ );
2391
+ const wranglerPath = ["wrangler.jsonc", "wrangler.json"].map((f) => join8(siteRoot, f)).find(existsSync9);
2392
+ const wrangler = wranglerPath ? parseJsonc2(readFileSync8(wranglerPath, "utf8")) : null;
2393
+ const sitePath = join8(siteRoot, "webmonterey.json");
2394
+ const site = existsSync9(sitePath) ? JSON.parse(readFileSync8(sitePath, "utf8")) : {};
2395
+ const origin = site.domain && site.domain !== "CHANGEME" ? `https://${site.domain}` : void 0;
2396
+ const report = audit({
2397
+ pages,
2398
+ exists: (rel) => rels.has(rel),
2399
+ read: (rel) => rels.has(rel) && statSync3(join8(dist, rel)).isFile() ? readFileSync8(join8(dist, rel), "utf8") : null,
2400
+ workerFirst: wrangler?.assets?.run_worker_first ?? [],
2401
+ origin
2402
+ });
2403
+ let failed = 0;
2404
+ const section = (ok, title) => console.log(`${ok ? " ok " : "FAIL "} ${title}`);
2405
+ section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
2406
+ for (const { page, src } of report.missingAlt)
2407
+ console.log(` ${page}: <img src="${src}"> has no alt attribute`);
2408
+ if (report.missingAlt.length) {
2409
+ failed++;
2410
+ console.log(
2411
+ ` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`
2412
+ );
2413
+ }
2414
+ section(
2415
+ report.brokenInternal.length === 0,
2416
+ "Every internal link lands on a page or a Worker route"
2417
+ );
2418
+ for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
2419
+ if (report.brokenInternal.length) failed++;
2420
+ section(
2421
+ report.sitemap.problems.length === 0,
2422
+ `The sitemap is complete and advertised (${report.sitemap.urls} URLs)`
2423
+ );
2424
+ for (const p of report.sitemap.problems) console.log(` ${p}`);
2425
+ if (report.sitemap.problems.length) failed++;
2426
+ if (noExternal) {
2427
+ console.log(` -- ${report.external.length} external links not probed (--no-external)`);
2428
+ } else if (report.external.length) {
2429
+ const bad = await probe(report.external);
2430
+ console.log(
2431
+ `${bad.length ? "warn " : " ok "} ${report.external.length} external links respond (${bad.length} did not)`
2432
+ );
2433
+ for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
2434
+ if (bad.length)
2435
+ console.log(
2436
+ ` Open each in a browser before deciding it is broken - many sites refuse bots.`
2437
+ );
2438
+ } else {
2439
+ console.log(" ok no external links");
2440
+ }
2441
+ console.log(`
2442
+ ${failed === 0 ? "audit clean" : `${failed} check(s) failed`}`);
2443
+ return failed ? 1 : 0;
2444
+ }
2445
+ var SKIP_SCHEMES;
2446
+ var init_audit = __esm({
2447
+ "src/cli/audit.ts"() {
2448
+ "use strict";
2449
+ SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
2450
+ }
2451
+ });
2452
+
2215
2453
  // src/cli/codemods.ts
2216
2454
  function compareVersions(a, b) {
2217
2455
  const pa = a.split(".").map(Number);
@@ -2233,24 +2471,24 @@ var init_codemods = __esm({
2233
2471
  // src/cli/upgrade.ts
2234
2472
  var upgrade_exports = {};
2235
2473
  __export(upgrade_exports, {
2236
- run: () => run6
2474
+ run: () => run7
2237
2475
  });
2238
2476
  import { execFileSync as execFileSync2 } from "node:child_process";
2239
- import { existsSync as existsSync9, readFileSync as readFileSync8 } from "node:fs";
2240
- import { join as join8 } from "node:path";
2477
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
2478
+ import { join as join9 } from "node:path";
2241
2479
  function git(siteRoot, args) {
2242
2480
  return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2243
2481
  }
2244
2482
  function installedVersion(siteRoot) {
2245
- const path = join8(siteRoot, "node_modules", PACKAGE, "package.json");
2246
- if (!existsSync9(path)) return null;
2247
- return JSON.parse(readFileSync8(path, "utf8")).version;
2483
+ const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
2484
+ if (!existsSync10(path)) return null;
2485
+ return JSON.parse(readFileSync9(path, "utf8")).version;
2248
2486
  }
2249
- function run6(argv2) {
2487
+ function run7(argv2) {
2250
2488
  const siteRoot = process.cwd();
2251
2489
  const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
2252
2490
  const dryRun = argv2.includes("--dry-run");
2253
- if (!existsSync9(join8(siteRoot, "webmonterey.json"))) {
2491
+ if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
2254
2492
  console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
2255
2493
  return 1;
2256
2494
  }
@@ -2316,9 +2554,9 @@ var init_upgrade = __esm({
2316
2554
 
2317
2555
  // bin/webm.mjs
2318
2556
  init_package_root();
2319
- import { readFileSync as readFileSync9 } from "node:fs";
2320
- import { join as join9 } from "node:path";
2321
- var pkg = JSON.parse(readFileSync9(join9(PACKAGE_ROOT, "package.json"), "utf8"));
2557
+ import { readFileSync as readFileSync10 } from "node:fs";
2558
+ import { join as join10 } from "node:path";
2559
+ var pkg = JSON.parse(readFileSync10(join10(PACKAGE_ROOT, "package.json"), "utf8"));
2322
2560
  var COMMANDS = {
2323
2561
  new: {
2324
2562
  blurb: "Scaffold a new client site from a domain",
@@ -2340,6 +2578,10 @@ var COMMANDS = {
2340
2578
  blurb: "Check this site against the traps that fail silently",
2341
2579
  load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
2342
2580
  },
2581
+ audit: {
2582
+ blurb: "Check a BUILD: image alt text, broken links, the sitemap. For launch.",
2583
+ load: () => Promise.resolve().then(() => (init_audit(), audit_exports))
2584
+ },
2343
2585
  upgrade: {
2344
2586
  blurb: "Move this site to a newer framework version",
2345
2587
  load: () => Promise.resolve().then(() => (init_upgrade(), upgrade_exports))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -49,6 +49,8 @@
49
49
  "./pages/slug": "./src/pages/[...slug].astro",
50
50
  "./pages/robots": "./src/pages/robots.txt.ts",
51
51
  "./pages/webm": "./src/pages/webm.astro",
52
+ "./pages/webmaster": "./src/pages/webmaster.astro",
53
+ "./pages/webmaster-og": "./src/pages/webmaster-og.png.ts",
52
54
  "./pages/404.astro": "./src/pages/404.astro",
53
55
  "./emails": "./src/emails/index.ts",
54
56
  "./cloudflare/d1": "./src/includes/cloudflare/d1/client.ts",
@@ -64,8 +66,8 @@
64
66
  "./webmonterey/compliance/ConsentInit.astro": "./src/includes/webmonterey/compliance/ConsentInit.astro",
65
67
  "./webmonterey/forms": "./src/includes/webmonterey/forms/honeypot.ts",
66
68
  "./webmonterey/prose": "./src/includes/webmonterey/prose/inline.ts",
67
- "./webmonterey/credits": "./src/includes/webmonterey/credits/credit.ts",
68
- "./webmonterey/credits/Credit.astro": "./src/includes/webmonterey/credits/Credit.astro",
69
+ "./webmonterey/webmaster": "./src/includes/webmonterey/webmaster/webmaster.ts",
70
+ "./webmonterey/webmaster/Webmaster.astro": "./src/includes/webmonterey/webmaster/Webmaster.astro",
69
71
  "./webmonterey/scroll-top/ScrollTop.astro": "./src/includes/webmonterey/scroll-top/ScrollTop.astro",
70
72
  "./structured-data": "./src/includes/webmonterey/structured-data/nodes.ts",
71
73
  "./schema/design.json": "./schema/design.json",