@cparkerwebm/webmonterey 1.1.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,55 @@ 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
+
14
63
  ## 1.1.0 — 2026-09-02
15
64
 
16
65
  ### Changed
package/dist/webm.mjs CHANGED
@@ -1955,17 +1955,17 @@ var init_checks = __esm({
1955
1955
  }
1956
1956
  },
1957
1957
  {
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',
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',
1961
1961
  run(ctx) {
1962
1962
  if (ctx.components.size === 0) return pass;
1963
1963
  if (ctx.site.domain === "webmonterey.com") return pass;
1964
1964
  for (const src of ctx.components.values()) {
1965
- if (/webmonterey\/credits/.test(stripComments(src))) return pass;
1965
+ if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
1966
1966
  }
1967
1967
  return warn(
1968
- "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."
1969
1969
  );
1970
1970
  }
1971
1971
  },
@@ -2087,17 +2087,17 @@ function parseJsonc(source) {
2087
2087
  }
2088
2088
  function readTree(root, dir, exts) {
2089
2089
  const out = /* @__PURE__ */ new Map();
2090
- const walk3 = (d) => {
2090
+ const walk4 = (d) => {
2091
2091
  if (!existsSync8(d)) return;
2092
2092
  for (const entry2 of readdirSync6(d, { withFileTypes: true })) {
2093
2093
  const full = join7(d, entry2.name);
2094
- if (entry2.isDirectory()) walk3(full);
2094
+ if (entry2.isDirectory()) walk4(full);
2095
2095
  else if (exts.some((e) => entry2.name.endsWith(e))) {
2096
2096
  out.set(relative3(root, full), readFileSync7(full, "utf8"));
2097
2097
  }
2098
2098
  }
2099
2099
  };
2100
- walk3(join7(root, dir));
2100
+ walk4(join7(root, dir));
2101
2101
  return out;
2102
2102
  }
2103
2103
  function placeholderFiles(siteRoot) {
@@ -2213,6 +2213,243 @@ var init_doctor = __esm({
2213
2213
  }
2214
2214
  });
2215
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
+
2216
2453
  // src/cli/codemods.ts
2217
2454
  function compareVersions(a, b) {
2218
2455
  const pa = a.split(".").map(Number);
@@ -2234,24 +2471,24 @@ var init_codemods = __esm({
2234
2471
  // src/cli/upgrade.ts
2235
2472
  var upgrade_exports = {};
2236
2473
  __export(upgrade_exports, {
2237
- run: () => run6
2474
+ run: () => run7
2238
2475
  });
2239
2476
  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";
2477
+ import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
2478
+ import { join as join9 } from "node:path";
2242
2479
  function git(siteRoot, args) {
2243
2480
  return execFileSync2("git", args, { cwd: siteRoot, encoding: "utf8" }).trim();
2244
2481
  }
2245
2482
  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;
2483
+ const path = join9(siteRoot, "node_modules", PACKAGE, "package.json");
2484
+ if (!existsSync10(path)) return null;
2485
+ return JSON.parse(readFileSync9(path, "utf8")).version;
2249
2486
  }
2250
- function run6(argv2) {
2487
+ function run7(argv2) {
2251
2488
  const siteRoot = process.cwd();
2252
2489
  const target = argv2.find((a) => !a.startsWith("-")) ?? "latest";
2253
2490
  const dryRun = argv2.includes("--dry-run");
2254
- if (!existsSync9(join8(siteRoot, "webmonterey.json"))) {
2491
+ if (!existsSync10(join9(siteRoot, "webmonterey.json"))) {
2255
2492
  console.error(`webm upgrade: no webmonterey.json here. Not a WebMonterey site.`);
2256
2493
  return 1;
2257
2494
  }
@@ -2317,9 +2554,9 @@ var init_upgrade = __esm({
2317
2554
 
2318
2555
  // bin/webm.mjs
2319
2556
  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"));
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"));
2323
2560
  var COMMANDS = {
2324
2561
  new: {
2325
2562
  blurb: "Scaffold a new client site from a domain",
@@ -2341,6 +2578,10 @@ var COMMANDS = {
2341
2578
  blurb: "Check this site against the traps that fail silently",
2342
2579
  load: () => Promise.resolve().then(() => (init_doctor(), doctor_exports))
2343
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
+ },
2344
2585
  upgrade: {
2345
2586
  blurb: "Move this site to a newer framework version",
2346
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.1.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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: launch
3
- description: Launch a WebMonterey client site onto its real domain - structured data, Turnstile, sending domain, production secrets, remote migrations, custom domain, verification, and the environment flip. Use for "launch the site", "point the domain at it", "we're going live", "take it out of preview".
3
+ description: Launch a WebMonterey client site onto its real domain - build audit (alt text, links, sitemap), structured data, Turnstile, sending domain, production secrets, remote migrations, custom domain, verification, analytics confirmation, and the environment flip. Use for "launch the site", "point the domain at it", "we're going live", "take it out of preview".
4
4
  ---
5
5
 
6
6
  # Launch
@@ -15,9 +15,34 @@ npx webm doctor
15
15
  npm run build
16
16
  ```
17
17
 
18
- Both clean. Doctor's warnings about placeholder artwork and the missing agency credit become
18
+ Both clean. Doctor's warnings about placeholder artwork and the missing webmaster credit become
19
19
  blocking here: replace the seeded favicons and share image with the client's own, and confirm
20
- the footer imports `Credit.astro`.
20
+ the footer imports `Webmaster.astro` - it links to the `/webmaster` page every site has, and
21
+ without the credit that page is reachable from nothing.
22
+
23
+ ## 1b. Audit the build
24
+
25
+ ```sh
26
+ npx webm audit
27
+ ```
28
+
29
+ It reads `dist/client` and reports three things a person never checks exhaustively on a
30
+ forty-page site. Fix each and rebuild until it is clean:
31
+
32
+ - **Images with no `alt` attribute.** Write the alt text yourself: open the image, read the
33
+ copy around it, and describe what it shows in that context - not what it is ("photo"), what it
34
+ says. A purely decorative image gets `alt=""`, declared explicitly. The alt lives wherever the
35
+ `<img>` is authored: the component's markup, or the block's page JSON if the schema carries it.
36
+ Do not leave one for later; a missing alt on a launched site is an accessibility failure and a
37
+ lost image-search result.
38
+ - **Broken internal links.** Every `href` must land on a built page or a route in
39
+ `run_worker_first`. A typo'd slug, a page that was renamed, a form action that moved.
40
+ - **The sitemap.** Built, advertised in `robots.txt`, every URL on the production domain and
41
+ landing on a page. After launch, fetch `https://<domain>/sitemap-index.xml` in a browser and
42
+ submit it in Search Console.
43
+
44
+ External links are probed too and reported as warnings: many sites refuse anything that is not a
45
+ browser, so open each flagged one before calling it broken.
21
46
 
22
47
  ## 2. Structured data
23
48
 
@@ -159,10 +184,25 @@ curl -H "Sec-Fetch-Dest: document" -H "Sec-Fetch-Mode: navigate" https://<domain
159
184
  That header is what the asset router branches on. Without it curl gets the real page and Chrome
160
185
  gets the 404.
161
186
 
162
- **A form test before step 10 goes to `stagingEmail`** with `[staging → …]` in the subject naming
187
+ **A form test before step 11 goes to `stagingEmail`** with `[staging → …]` in the subject naming
163
188
  who it was really for. That is the system working - check that inbox, not the client's.
164
189
 
165
- ## 10. Hand the site its email back, and record the launch
190
+ ## 10. Analytics - two confirmations, asked out loud
191
+
192
+ Neither of these can be checked from the repo, so **ask, and wait for the answer.** Do not
193
+ proceed on an assumption, and do not mark either done because the field is filled in.
194
+
195
+ 1. **"Is Google Tag Manager configured for this site?"** - meaning the container exists, its
196
+ tags are published (not just saved), and `gtmId` in `webmonterey.json` is that container. A
197
+ `gtmId` with an unpublished container loads a script that fires nothing. If the site has no
198
+ analytics by agreement, confirm that instead and move on.
199
+ 2. **"Has the launch annotation been added in Google Analytics?"** - a dated note on the
200
+ property for the launch, so the traffic change that follows has an explanation next to it
201
+ when someone looks a year from now.
202
+
203
+ Record the answers in the launch commit message.
204
+
205
+ ## 11. Hand the site its email back, and record the launch
166
206
 
167
207
  In one change:
168
208
 
@@ -119,5 +119,9 @@ Workers Builds comments the preview URL on every PR - that is the client's revie
119
119
  hostnames use the slug, so Chrome's lookalike warning should not appear; if it does, it is a
120
120
  URL-shape false positive and **Ignore is safe**.
121
121
 
122
+ A preview build is safe to hand out: every page is noindex, there is no sitemap, robots.txt
123
+ disallows everything, analytics does not load, and mail is redirected to `stagingEmail`. The
124
+ client can click anything. The production branch is `main`; anything else previews.
125
+
122
126
  Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
123
127
  content-complete and approved on a preview.
@@ -134,6 +134,15 @@ _Worker_, not a _version_, so it can only serve production, and per-version prev
134
134
  **`workers_dev: false` does NOT disable preview URLs.** Separate switches, which is why
135
135
  wrangler.jsonc sets both explicitly.
136
136
 
137
+ **A branch preview is a different build, on purpose.** Workers Builds injects
138
+ `WORKERS_CI_BRANCH`; on any branch but the production one (`main`, or the integration's
139
+ `productionBranch`) every page is noindex with no canonical, there is no sitemap, `robots.txt`
140
+ disallows everything, GTM does not load, and the `/webmaster` page emits no graph. Email is
141
+ already redirected by the `workers.dev` hostname. So a client's review link can neither be
142
+ indexed nor show up in their analytics. **A local build is not a preview** - `npm run preview`
143
+ builds production, which is what you want to inspect. To see the preview shape locally:
144
+ `WORKERS_CI_BRANCH=x npm run build`.
145
+
137
146
  **`wrangler deployments list` misleads twice.** `Source: Unknown (deployment)` appears even for
138
147
  Workers Builds deployments — it describes the author, not the origin. And the list pages at 10
139
148
  entries, so "has the count gone up?" can never become a way of waiting for a deploy.
@@ -0,0 +1,120 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { attr, audit, auditSitemap, hasAttr, imagesWithoutAlt, links, resolves } from './audit.ts';
4
+
5
+ const build = (
6
+ files: Record<string, string>,
7
+ extra: Partial<Parameters<typeof audit>[0]> = {},
8
+ ) => ({
9
+ pages: new Map(Object.entries(files).filter(([k]) => k.endsWith('.html'))),
10
+ exists: (rel: string) => rel in files,
11
+ read: (rel: string) => files[rel] ?? null,
12
+ workerFirst: ['/_actions/*'],
13
+ origin: 'https://example.com',
14
+ ...extra,
15
+ });
16
+
17
+ test('attr reads quoted and bare values, and hasAttr sees a valueless attribute', () => {
18
+ assert.equal(attr('<img src="a.png" alt="A thing">', 'alt'), 'A thing');
19
+ assert.equal(attr("<img src='a.png' alt=''>", 'alt'), '');
20
+ assert.equal(attr('<img src=a.png>', 'src'), 'a.png');
21
+ assert.equal(attr('<img src="a.png">', 'alt'), null);
22
+ assert.equal(hasAttr('<img src="a.png" alt>', 'alt'), true);
23
+ assert.equal(hasAttr('<img src="a.png" data-alt="x">', 'alt'), false, 'data-alt is not alt');
24
+ });
25
+
26
+ test('an image with no alt attribute is reported; an empty alt is a decorative declaration', () => {
27
+ const html = `<img src="/hero.jpg"><img src="/deco.svg" alt=""><img alt="Fine" src="/ok.png"><IMG SRC="/caps.png">`;
28
+ assert.deepEqual(imagesWithoutAlt(html), ['/hero.jpg', '/caps.png']);
29
+ });
30
+
31
+ test('links are split into internal paths and external URLs, skipping non-navigations', () => {
32
+ const html = `
33
+ <a href="/about">a</a> <a href="/about/">b</a> <a href="mailto:x@y.z">m</a> <a href="#top">t</a>
34
+ <a href="https://example.com/contact">own origin</a> <a href="https://other.test/x">ext</a>
35
+ <a href="//cdn.test/file">proto-relative</a> <a href="tel:+1">p</a>`;
36
+ const { internal, external } = links(html, 'https://example.com');
37
+ assert.deepEqual(internal.sort(), ['/about', '/about/', '/contact']);
38
+ assert.deepEqual(external.sort(), ['https://cdn.test/file', 'https://other.test/x']);
39
+ });
40
+
41
+ test('an internal link resolves to a built file in any of the forms the router serves', () => {
42
+ const input = build({
43
+ 'index.html': '',
44
+ 'about/index.html': '',
45
+ 'privacy.html': '',
46
+ 'a.pdf': '',
47
+ });
48
+ for (const href of [
49
+ '/',
50
+ '/about',
51
+ '/about/',
52
+ '/about#team',
53
+ '/about?x=1',
54
+ '/privacy',
55
+ '/a.pdf',
56
+ ]) {
57
+ assert.equal(resolves(href, input), true, href);
58
+ }
59
+ assert.equal(resolves('/missing', input), false);
60
+ });
61
+
62
+ test('an on-demand route in run_worker_first is not reported as broken', () => {
63
+ const input = build(
64
+ { 'index.html': '' },
65
+ { workerFirst: ['/_actions/*', '/contact', '/contact/', '/app/*'] },
66
+ );
67
+ assert.equal(resolves('/contact', input), true);
68
+ assert.equal(resolves('/contact/', input), true);
69
+ assert.equal(resolves('/app/dashboard', input), true);
70
+ assert.equal(resolves('/app', input), true);
71
+ assert.equal(resolves('/elsewhere', input), false);
72
+ });
73
+
74
+ test('the sitemap must exist, be advertised, and every URL must land', () => {
75
+ const ok = build({
76
+ 'index.html': '',
77
+ 'about/index.html': '',
78
+ 'robots.txt': 'User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap-index.xml\n',
79
+ 'sitemap-index.xml':
80
+ '<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
81
+ 'sitemap-0.xml':
82
+ '<urlset><url><loc>https://example.com/</loc></url><url><loc>https://example.com/about/</loc></url></urlset>',
83
+ });
84
+ assert.deepEqual(auditSitemap(ok), { problems: [], urls: 2 });
85
+
86
+ const bad = build({
87
+ 'index.html': '',
88
+ 'robots.txt': 'User-agent: *\n',
89
+ 'sitemap-index.xml':
90
+ '<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
91
+ 'sitemap-0.xml':
92
+ '<urlset><url><loc>https://example.com/gone/</loc></url><url><loc>https://wrong.test/</loc></url></urlset>',
93
+ });
94
+ const { problems } = auditSitemap(bad);
95
+ assert.ok(problems.some((p) => p.includes('robots.txt')));
96
+ assert.ok(problems.some((p) => p.includes('/gone/') && p.includes('no page')));
97
+ assert.ok(problems.some((p) => p.includes('wrong.test')));
98
+ });
99
+
100
+ test('a missing sitemap says why when the domain is unset', () => {
101
+ const { problems } = auditSitemap(build({ 'index.html': '' }, { origin: undefined }));
102
+ assert.match(problems[0]!, /domain/);
103
+ });
104
+
105
+ test('audit assembles every finding with the page it was found on', () => {
106
+ const report = audit(
107
+ build({
108
+ 'index.html': '<img src="/x.png"><a href="/nope">n</a><a href="https://ext.test/">e</a>',
109
+ 'about/index.html': '<img src="/y.png" alt="Y"><a href="/">home</a>',
110
+ 'robots.txt': 'Sitemap: https://example.com/sitemap-index.xml',
111
+ 'sitemap-index.xml':
112
+ '<sitemapindex><sitemap><loc>https://example.com/sitemap-0.xml</loc></sitemap></sitemapindex>',
113
+ 'sitemap-0.xml': '<urlset><url><loc>https://example.com/</loc></url></urlset>',
114
+ }),
115
+ );
116
+ assert.deepEqual(report.missingAlt, [{ page: 'index.html', src: '/x.png' }]);
117
+ assert.deepEqual(report.brokenInternal, [{ page: 'index.html', href: '/nope' }]);
118
+ assert.deepEqual(report.external, ['https://ext.test/']);
119
+ assert.deepEqual(report.sitemap.problems, []);
120
+ });