@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.
- package/CHANGELOG.md +77 -0
- package/README.md +6 -1
- package/dist/webm.mjs +290 -48
- package/package.json +5 -3
- package/skills/launch/SKILL.md +47 -7
- package/skills/start/SKILL.md +25 -15
- package/skills/traps/SKILL.md +11 -2
- package/src/assets/opengraph-webmaster.png +0 -0
- package/src/cli/audit.test.ts +120 -0
- package/src/cli/audit.ts +323 -0
- package/src/cli/checks.test.ts +6 -6
- package/src/cli/checks.ts +9 -7
- package/src/cli/new.ts +34 -17
- package/src/cli/scaffold.test.ts +27 -20
- package/src/cli/scaffold.ts +8 -7
- package/src/cli/slug.test.ts +25 -28
- package/src/cli/slug.ts +31 -36
- package/src/cli/sync.ts +1 -1
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/README.md +3 -3
- package/src/includes/cloudflare/r2/media.ts +4 -4
- package/src/includes/webmonterey/config.test.ts +1 -1
- package/src/includes/webmonterey/copy-defaults.ts +20 -0
- package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
- package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
- package/src/integration/index.ts +66 -3
- package/src/integration/virtual.d.ts +16 -1
- package/src/layouts/base.astro +28 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +12 -0
- package/src/pages/webmaster-og.png.ts +31 -0
- package/src/pages/webmaster.astro +121 -0
- package/template/migrations/README.md +1 -1
- package/template/public/opengraph.png +0 -0
- package/template/scripts/test-hooks.mjs +1 -1
- package/template/site/CLAUDE.md +6 -2
- package/src/includes/webmonterey/credits/Credit.astro +0 -80
- package/src/includes/webmonterey/credits/credit.test.ts +0 -111
- package/src/includes/webmonterey/credits/credit.ts +0 -59
- package/template/public/open-graph.png +0 -0
- /package/template/assets/{open-graph.png → opengraph.png} +0 -0
package/skills/launch/SKILL.md
CHANGED
|
@@ -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
|
|
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 `
|
|
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
|
|
128
|
-
npx wrangler d1 migrations apply
|
|
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
|
|
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.
|
|
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
|
|
package/skills/start/SKILL.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
-
| |
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
45
|
+
| Domain | Name |
|
|
46
|
+
| ------------------ | -------------- |
|
|
47
|
+
| `example.com` | `example` |
|
|
48
|
+
| `shop.example.com` | `shop-example` |
|
|
49
|
+
| `example.co.uk` | `example` |
|
|
47
50
|
|
|
48
|
-
The
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
83
|
-
npx wrangler d1 migrations apply
|
|
84
|
-
npx wrangler r2 bucket create
|
|
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
|
|
98
|
-
|
|
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.
|
package/skills/traps/SKILL.md
CHANGED
|
@@ -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 `
|
|
126
|
-
|
|
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.
|
|
Binary file
|
|
@@ -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
|
+
});
|
package/src/cli/audit.ts
ADDED
|
@@ -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
|
+
}
|