@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
@@ -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
 
@@ -124,8 +149,8 @@ Public values - the Turnstile site key, a GTM container id - are not secrets. Th
124
149
  ## 7. D1, if the site uses it
125
150
 
126
151
  ```sh
127
- npx wrangler d1 migrations list webm-<slug>-db --remote
128
- npx wrangler d1 migrations apply webm-<slug>-db --remote
152
+ npx wrangler d1 migrations list <slug> --remote
153
+ npx wrangler d1 migrations apply <slug> --remote
129
154
  ```
130
155
 
131
156
  `--remote` is the step people forget. Local migrations do nothing in production, and local and
@@ -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
 
@@ -35,19 +35,25 @@ cd <domain_with_underscores>
35
35
  ```
36
36
 
37
37
  It writes the identity files, a working contact form, a home page, the fleet skills, and runs
38
- `git init` and `npm install`. Nothing outside the directory is touched.
38
+ `git init` and `npm install`. Nothing outside the directory is touched. The GitHub owner comes
39
+ from `git config webm.org` (set once per machine) or `--org`; the staging inbox from
40
+ `git config webm.stagingEmail`, falling back to `user.email`.
39
41
 
40
- **Three names, and they differ on purpose:**
42
+ **One name, everywhere.** The domain minus its TLD is the GitHub repo, the Worker, the D1
43
+ database, the R2 bucket and any KV namespace:
41
44
 
42
- | | Example | |
43
- | ----------- | ---------------------------------------------------- | ---------------------------- |
44
- | GitHub repo | `webmonterey/autire_com` | Full domain, **underscores** |
45
- | Slug | `autire` | Domain minus the TLD |
46
- | Cloudflare | `webm-autire`, `webm-autire-db`, `webm-autire-media` | The slug, prefixed |
45
+ | Domain | Name |
46
+ | ------------------ | -------------- |
47
+ | `example.com` | `example` |
48
+ | `shop.example.com` | `shop-example` |
49
+ | `example.co.uk` | `example` |
47
50
 
48
- The slug drops the TLD because a Worker named `webm-autire-com` puts `autire-com` into every
49
- preview hostname, and Chrome's lookalike check then warns the client the site looks fake.
50
- `autire.com` and `autire.org` both want `autire` - check the slug is free before committing.
51
+ The TLD is dropped because a Worker named `example-com` puts `example-com` into every preview
52
+ hostname, and Chrome's lookalike check then warns the client the site looks fake. A second
53
+ resource of one kind for the same client takes a purpose suffix: `example-portal`.
54
+
55
+ `example.com` and `example.org` both want `example`. Check the name is free in the account
56
+ before committing; the second one gets a name chosen by you, not by the tool.
51
57
 
52
58
  ## 2. Create the GitHub repo
53
59
 
@@ -79,9 +85,9 @@ Create only what the site needs. A marketing site with a contact form needs D1;
79
85
  R2 until someone has a video.
80
86
 
81
87
  ```sh
82
- npx wrangler d1 create webm-<slug>-db --update-config # writes the binding into wrangler.jsonc
83
- npx wrangler d1 migrations apply webm-<slug>-db --local
84
- npx wrangler r2 bucket create webm-<slug>-media # only if media is going to R2
88
+ npx wrangler d1 create <slug> --update-config # writes the binding into wrangler.jsonc
89
+ npx wrangler d1 migrations apply <slug> --local
90
+ npx wrangler r2 bucket create <slug> # only if media is going to R2
85
91
  ```
86
92
 
87
93
  Set `features.d1: true` once the binding exists. `features.turnstile` waits for `/webm:launch`,
@@ -94,8 +100,8 @@ forms.** Miss one and it returns 200 to curl and a 404 page to Chrome. `webm doc
94
100
  ## 5. Workers Builds
95
101
 
96
102
  Connect the repo in the Cloudflare dashboard: **Workers & Pages → Create → Import a repository**.
97
- The Worker name must be `webm-<slug>` exactly - Workers Builds fails on a mismatch with
98
- `wrangler.jsonc`.
103
+ The Worker name must be the slug exactly as `wrangler.jsonc` has it - Workers Builds fails on a
104
+ mismatch.
99
105
 
100
106
  No build variables are needed. **Push to deploy from then on** - a `wrangler deploy` from a
101
107
  laptop creates a version no build produced, so history stops describing what is live, and the
@@ -113,5 +119,9 @@ Workers Builds comments the preview URL on every PR - that is the client's revie
113
119
  hostnames use the slug, so Chrome's lookalike warning should not appear; if it does, it is a
114
120
  URL-shape false positive and **Ignore is safe**.
115
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
+
116
126
  Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
117
127
  content-complete and approved on a preview.
@@ -122,8 +122,8 @@ wildcards, and the free tier allows 10 hostnames per widget.
122
122
 
123
123
  **Chrome's "This site looks fake" warning on preview links comes from the Worker's NAME.** Its
124
124
  lookalike-domain check flags "domains that embed other domain names within their own hostname",
125
- and a Worker called `webm-acme-com` puts `acme-com` into every preview hostname. That is why
126
- Workers are named `webm-<slug>` with the TLD dropped — `webm-acme` embeds nothing that reads as a
125
+ and a Worker called `acme-com` puts `acme-com` into every preview hostname. That is why every
126
+ resource is named by the slug with the TLD dropped — `acme` embeds nothing that reads as a
127
127
  domain. `webm new` does this; do not name a Worker after the full domain.
128
128
 
129
129
  If a warning still appears on a correctly named Worker it is a URL-shape false positive, not a
@@ -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
+ });
@@ -0,0 +1,323 @@
1
+ /*
2
+ * `webm audit [dist/client]` - the pre-launch checks that only a BUILD can answer.
3
+ *
4
+ * `webm doctor` reads source. Some things are only visible in the output: whether every image
5
+ * that reached the page has alt text, whether every internal link lands on a file the build
6
+ * produced (or a route the Worker serves), whether the sitemap the build wrote is complete and
7
+ * advertised. Each is the kind of thing a launch checklist says to "check" and nobody does
8
+ * exhaustively by hand on a forty-page site.
9
+ *
10
+ * Pure where it can be: `audit()` takes the built files and returns findings, so the rules are
11
+ * tested with literal HTML. `run()` reads dist/ and, unless told not to, makes one request per
12
+ * unique external link - the one part that needs a network.
13
+ *
14
+ * WHAT IT DOES NOT DO: write alt text. It lists the images that have none; the launch skill is
15
+ * what looks at each one and writes the words. An empty `alt=""` is a valid declaration that an
16
+ * image is decorative and is not reported - only a MISSING attribute is.
17
+ */
18
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { join, relative, resolve } from 'node:path';
20
+
21
+ export interface AuditInput {
22
+ /** Relative html path -> contents, for every page in the build. */
23
+ pages: Map<string, string>;
24
+ /** Whether a relative path exists in the build output. */
25
+ exists: (rel: string) => boolean;
26
+ /** Read a relative non-html file from the build, or null. */
27
+ read: (rel: string) => string | null;
28
+ /** wrangler.jsonc's run_worker_first, so an on-demand route is not reported as broken. */
29
+ workerFirst: string[];
30
+ /** The production origin, for sitemap and external-link decisions. Undefined while unset. */
31
+ origin?: string;
32
+ }
33
+
34
+ export interface AuditReport {
35
+ missingAlt: { page: string; src: string }[];
36
+ brokenInternal: { page: string; href: string }[];
37
+ /** Unique external URLs, for the caller to probe. */
38
+ external: string[];
39
+ sitemap: { problems: string[]; urls: number };
40
+ }
41
+
42
+ /** The value of one attribute on a tag, or null when absent. Quoted or bare. */
43
+ export function attr(tag: string, name: string): string | null {
44
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
45
+ if (!m) return null;
46
+ return m[1] ?? m[2] ?? m[3] ?? '';
47
+ }
48
+
49
+ /** Whether an attribute is present at all, with or without a value. */
50
+ export function hasAttr(tag: string, name: string): boolean {
51
+ return new RegExp(`\\s${name}(?=[\\s=>/])`, 'i').test(tag);
52
+ }
53
+
54
+ /** Every `<img>` on a page that declares no alt attribute at all. */
55
+ export function imagesWithoutAlt(html: string): string[] {
56
+ const out: string[] = [];
57
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
58
+ const tag = m[0];
59
+ if (hasAttr(tag, 'alt')) continue;
60
+ out.push(attr(tag, 'src') ?? '(no src)');
61
+ }
62
+ return out;
63
+ }
64
+
65
+ const SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
66
+
67
+ /** The hrefs on a page, split into internal paths and external URLs. */
68
+ export function links(html: string, origin?: string): { internal: string[]; external: string[] } {
69
+ const internal = new Set<string>();
70
+ const external = new Set<string>();
71
+ for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
72
+ const href = attr(m[0], 'href');
73
+ if (!href || SKIP_SCHEMES.test(href)) continue;
74
+ if (/^https?:\/\//i.test(href)) {
75
+ /* A link to the site's own origin is internal - check it as a path. */
76
+ if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || '/');
77
+ else external.add(href);
78
+ continue;
79
+ }
80
+ if (href.startsWith('//')) {
81
+ external.add(`https:${href}`);
82
+ continue;
83
+ }
84
+ internal.add(href);
85
+ }
86
+ return { internal: [...internal], external: [...external] };
87
+ }
88
+
89
+ /**
90
+ * Does an internal href land somewhere? A file the build wrote, in any of the forms the asset
91
+ * router would serve it under, or a route the Worker answers.
92
+ */
93
+ export function resolves(href: string, input: Pick<AuditInput, 'exists' | 'workerFirst'>): boolean {
94
+ let path = href.split('#')[0]!.split('?')[0]!;
95
+ try {
96
+ path = decodeURIComponent(path);
97
+ } catch {
98
+ /* leave it */
99
+ }
100
+ if (!path.startsWith('/')) return true; /* relative to the page; not worth a false positive */
101
+ if (path === '/') return input.exists('index.html');
102
+
103
+ const bare = path.replace(/\/$/, '');
104
+ const rel = bare.slice(1);
105
+ if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
106
+ return true;
107
+ }
108
+ return input.workerFirst.some((entry) =>
109
+ entry.endsWith('/*')
110
+ ? bare === entry.slice(0, -2) || bare.startsWith(entry.slice(0, -1))
111
+ : entry === bare || entry === `${bare}/`,
112
+ );
113
+ }
114
+
115
+ /** `<loc>` values out of a sitemap document. */
116
+ export function locs(xml: string): string[] {
117
+ return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]!);
118
+ }
119
+
120
+ /**
121
+ * The sitemap the build wrote: present, advertised in robots.txt, every child present, every URL
122
+ * on the production origin and landing on a page.
123
+ */
124
+ export function auditSitemap(input: AuditInput): AuditReport['sitemap'] {
125
+ const problems: string[] = [];
126
+ const index = input.read('sitemap-index.xml');
127
+ if (!index) {
128
+ return {
129
+ problems: [
130
+ input.origin
131
+ ? 'sitemap-index.xml was not built'
132
+ : 'no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL',
133
+ ],
134
+ urls: 0,
135
+ };
136
+ }
137
+
138
+ const robots = input.read('robots.txt') ?? '';
139
+ if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push('robots.txt has no Sitemap: line');
140
+
141
+ let urls = 0;
142
+ for (const childUrl of locs(index)) {
143
+ const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, '');
144
+ const child = input.read(childRel);
145
+ if (!child) {
146
+ problems.push(`${childRel} is listed in the index and was not built`);
147
+ continue;
148
+ }
149
+ for (const url of locs(child)) {
150
+ urls++;
151
+ if (input.origin && !url.startsWith(input.origin)) {
152
+ problems.push(`${url} is not on ${input.origin}`);
153
+ continue;
154
+ }
155
+ const path = url.replace(/^https?:\/\/[^/]+/, '') || '/';
156
+ if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
157
+ }
158
+ }
159
+ if (urls === 0) problems.push('the sitemap lists no URLs');
160
+ return { problems, urls };
161
+ }
162
+
163
+ export function audit(input: AuditInput): AuditReport {
164
+ const missingAlt: AuditReport['missingAlt'] = [];
165
+ const brokenInternal: AuditReport['brokenInternal'] = [];
166
+ const external = new Set<string>();
167
+
168
+ for (const [page, html] of input.pages) {
169
+ for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
170
+ const found = links(html, input.origin);
171
+ for (const href of found.internal) {
172
+ if (!resolves(href, input)) brokenInternal.push({ page, href });
173
+ }
174
+ for (const url of found.external) external.add(url);
175
+ }
176
+
177
+ return {
178
+ missingAlt,
179
+ brokenInternal,
180
+ external: [...external].sort(),
181
+ sitemap: auditSitemap(input),
182
+ };
183
+ }
184
+
185
+ /* ── the command ─────────────────────────────────────────────────────────────────────────── */
186
+
187
+ function walk(dir: string): string[] {
188
+ if (!existsSync(dir)) return [];
189
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
190
+ const full = join(dir, e.name);
191
+ return e.isDirectory() ? walk(full) : [full];
192
+ });
193
+ }
194
+
195
+ /** Strip // and /* comments so JSON.parse can read a .jsonc file. */
196
+ function parseJsonc<T>(source: string): T {
197
+ const stripped = source
198
+ .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? '' : m))
199
+ .replace(/,(\s*[}\]])/g, '$1');
200
+ return JSON.parse(stripped) as T;
201
+ }
202
+
203
+ /**
204
+ * One request per unique external URL. HEAD first; a 405 gets a GET, because plenty of servers
205
+ * refuse HEAD and answer GET. Failures are reported as warnings, not failures: many sites block
206
+ * anything that is not a browser, and a link that a bot cannot fetch is not necessarily broken.
207
+ */
208
+ async function probe(urls: string[]): Promise<{ url: string; status: string }[]> {
209
+ const bad: { url: string; status: string }[] = [];
210
+ const queue = [...urls];
211
+ const worker = async () => {
212
+ for (let url = queue.shift(); url; url = queue.shift()) {
213
+ try {
214
+ let res = await fetch(url, {
215
+ method: 'HEAD',
216
+ redirect: 'follow',
217
+ signal: AbortSignal.timeout(8000),
218
+ });
219
+ if (res.status === 405 || res.status === 403) {
220
+ res = await fetch(url, {
221
+ method: 'GET',
222
+ redirect: 'follow',
223
+ signal: AbortSignal.timeout(8000),
224
+ });
225
+ }
226
+ if (res.status >= 400) bad.push({ url, status: String(res.status) });
227
+ } catch (error) {
228
+ bad.push({ url, status: error instanceof Error ? error.name : 'error' });
229
+ }
230
+ }
231
+ };
232
+ await Promise.all(Array.from({ length: 6 }, worker));
233
+ return bad.sort((a, b) => a.url.localeCompare(b.url));
234
+ }
235
+
236
+ export async function run(argv: string[]): Promise<number> {
237
+ const dist = resolve(argv.find((a) => !a.startsWith('-')) ?? 'dist/client');
238
+ const noExternal = argv.includes('--no-external');
239
+
240
+ if (!existsSync(join(dist, 'index.html'))) {
241
+ console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
242
+ return 1;
243
+ }
244
+
245
+ const siteRoot = process.cwd();
246
+ const files = walk(dist);
247
+ const rels = new Set(files.map((f) => relative(dist, f)));
248
+ const pages = new Map(
249
+ files
250
+ .filter((f) => f.endsWith('.html'))
251
+ .map((f) => [relative(dist, f), readFileSync(f, 'utf8')]),
252
+ );
253
+
254
+ const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
255
+ .map((f) => join(siteRoot, f))
256
+ .find(existsSync);
257
+ const wrangler = wranglerPath
258
+ ? parseJsonc<{ assets?: { run_worker_first?: string[] } }>(readFileSync(wranglerPath, 'utf8'))
259
+ : null;
260
+
261
+ const sitePath = join(siteRoot, 'webmonterey.json');
262
+ const site = existsSync(sitePath)
263
+ ? (JSON.parse(readFileSync(sitePath, 'utf8')) as { domain?: string })
264
+ : {};
265
+ const origin = site.domain && site.domain !== 'CHANGEME' ? `https://${site.domain}` : undefined;
266
+
267
+ const report = audit({
268
+ pages,
269
+ exists: (rel) => rels.has(rel),
270
+ read: (rel) =>
271
+ rels.has(rel) && statSync(join(dist, rel)).isFile()
272
+ ? readFileSync(join(dist, rel), 'utf8')
273
+ : null,
274
+ workerFirst: wrangler?.assets?.run_worker_first ?? [],
275
+ origin,
276
+ });
277
+
278
+ let failed = 0;
279
+ const section = (ok: boolean, title: string) => console.log(`${ok ? ' ok ' : 'FAIL '} ${title}`);
280
+
281
+ section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
282
+ for (const { page, src } of report.missingAlt)
283
+ console.log(` ${page}: <img src="${src}"> has no alt attribute`);
284
+ if (report.missingAlt.length) {
285
+ failed++;
286
+ console.log(
287
+ ` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`,
288
+ );
289
+ }
290
+
291
+ section(
292
+ report.brokenInternal.length === 0,
293
+ 'Every internal link lands on a page or a Worker route',
294
+ );
295
+ for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
296
+ if (report.brokenInternal.length) failed++;
297
+
298
+ section(
299
+ report.sitemap.problems.length === 0,
300
+ `The sitemap is complete and advertised (${report.sitemap.urls} URLs)`,
301
+ );
302
+ for (const p of report.sitemap.problems) console.log(` ${p}`);
303
+ if (report.sitemap.problems.length) failed++;
304
+
305
+ if (noExternal) {
306
+ console.log(` -- ${report.external.length} external links not probed (--no-external)`);
307
+ } else if (report.external.length) {
308
+ const bad = await probe(report.external);
309
+ console.log(
310
+ `${bad.length ? 'warn ' : ' ok '} ${report.external.length} external links respond (${bad.length} did not)`,
311
+ );
312
+ for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
313
+ if (bad.length)
314
+ console.log(
315
+ ` Open each in a browser before deciding it is broken - many sites refuse bots.`,
316
+ );
317
+ } else {
318
+ console.log(' ok no external links');
319
+ }
320
+
321
+ console.log(`\n${failed === 0 ? 'audit clean' : `${failed} check(s) failed`}`);
322
+ return failed ? 1 : 0;
323
+ }