@cparkerwebm/webmonterey 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +6 -0
  3. package/dist/webm.mjs +445 -61
  4. package/package.json +5 -3
  5. package/skills/launch/SKILL.md +57 -9
  6. package/skills/start/SKILL.md +66 -12
  7. package/skills/traps/SKILL.md +17 -0
  8. package/src/assets/opengraph-webmaster.png +0 -0
  9. package/src/cli/audit.test.ts +120 -0
  10. package/src/cli/audit.ts +323 -0
  11. package/src/cli/checks.test.ts +46 -6
  12. package/src/cli/checks.ts +52 -12
  13. package/src/cli/doctor.ts +78 -2
  14. package/src/cli/scaffold.test.ts +9 -0
  15. package/src/cli/scaffold.ts +12 -30
  16. package/src/cli/settings.ts +106 -0
  17. package/src/cli/sync.test.ts +61 -0
  18. package/src/cli/sync.ts +64 -1
  19. package/src/emails/footer.ts +3 -3
  20. package/src/includes/cloudflare/r2/media.ts +1 -1
  21. package/src/includes/webmonterey/config.test.ts +51 -0
  22. package/src/includes/webmonterey/config.ts +49 -0
  23. package/src/includes/webmonterey/copy-defaults.ts +20 -0
  24. package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
  25. package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
  26. package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
  27. package/src/integration/index.ts +91 -4
  28. package/src/integration/virtual.d.ts +19 -1
  29. package/src/layouts/base.astro +31 -8
  30. package/src/package.test.ts +20 -0
  31. package/src/pages/robots.txt.ts +14 -0
  32. package/src/pages/webmaster-og.png.ts +31 -0
  33. package/src/pages/webmaster.astro +121 -0
  34. package/template/public/opengraph.png +0 -0
  35. package/template/scripts/test-hooks.mjs +1 -1
  36. package/template/site/CLAUDE.md +25 -2
  37. package/src/includes/webmonterey/credits/Credit.astro +0 -80
  38. package/src/includes/webmonterey/credits/credit.test.ts +0 -111
  39. package/src/includes/webmonterey/credits/credit.ts +0 -59
  40. package/template/public/open-graph.png +0 -0
  41. /package/template/assets/{open-graph.png → opengraph.png} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.1.0",
3
+ "version": "1.3.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
 
@@ -171,13 +211,21 @@ In one change:
171
211
  "launched": "YYYY-MM-DD"
172
212
  ```
173
213
 
214
+ **This flip is also what makes the site indexable, so it happens here and not before.** While
215
+ `environment` says `staging`, every build is a preview - every page noindex with no canonical,
216
+ no sitemap, robots.txt disallowing everything, no Google Tag Manager - on every hostname, `main`
217
+ included. Flip it before the custom domain is live and the `workers.dev` copy is what gets
218
+ indexed.
219
+
174
220
  Until `environment` flips, every message the site sends is redirected to `stagingEmail` - correct
175
221
  right up to the moment the domain is attached and wrong immediately after: the form keeps saying
176
222
  thank you, the client's inbox stays empty, and the first anyone hears of it is a customer asking
177
- why nobody called back. `webm doctor` fails a launched site still declared staging, which is why
178
- both fields change together.
223
+ why nobody called back. And a launched site left on `staging` is invisible to search: every page
224
+ noindex, no sitemap, `Disallow: /`. `webm doctor` fails a launched site still declared staging,
225
+ which is why both fields change together.
179
226
 
180
- Anything served from `workers.dev` is still treated as staging whatever this says, so branch
181
- previews of the live site keep redirecting. That is deliberate.
227
+ Anything served from `workers.dev` is still treated as staging for MAIL whatever this says, and
228
+ any branch other than `main` still builds as a preview, so branch previews of the live site keep
229
+ redirecting and stay out of the index. That is deliberate.
182
230
 
183
231
  Commit, push, and confirm the deploy. Then run `npx webm doctor` one last time: zero failures.
@@ -97,27 +97,81 @@ like a bot does.
97
97
  **Every route with `export const prerender = false` goes in `run_worker_first`, in both slash
98
98
  forms.** Miss one and it returns 200 to curl and a 404 page to Chrome. `webm doctor` checks.
99
99
 
100
- ## 5. Workers Builds
100
+ ## 5. Create the Worker - once, from the laptop
101
101
 
102
- Connect the repo in the Cloudflare dashboard: **Workers & Pages Create Import a repository**.
103
- The Worker name must be the slug exactly as `wrangler.jsonc` has it - Workers Builds fails on a
104
- mismatch.
102
+ Guarded and re-runnable: skip this step when the Worker already exists.
105
103
 
106
- No build variables are needed. **Push to deploy from then on** - a `wrangler deploy` from a
107
- laptop creates a version no build produced, so history stops describing what is live, and the
108
- next push reverts it.
104
+ ```sh
105
+ npx wrangler deployments list --name <slug> # a list: it exists. "does not exist [code: 10007]": create it
106
+ ```
107
+
108
+ The name is `name` in `wrangler.jsonc`. When there is nothing:
109
+
110
+ ```sh
111
+ npm run build # a real build; `dev` proves nothing here
112
+ npx wrangler deploy
113
+ ```
114
+
115
+ **This is the ONLY laptop deploy a site ever gets.** Before Workers Builds is connected, one
116
+ deploy is exactly how the Worker comes into existence. After it is connected (step 6) a laptop
117
+ deploy is the mistake: a version no build produced, which the next push reverts. This skill used
118
+ to stop here and say to create the Worker in the dashboard by hand, and on one site nobody did -
119
+ the result was a repo, a D1 database and nothing serving. `webm doctor` now warns on that.
120
+
121
+ The deploy provisions something `wrangler.jsonc` does not name, and that is expected: the Astro
122
+ Cloudflare adapter adds a `SESSION` KV binding, which wrangler auto-provisions as
123
+ `<slug>-session`. Do not add it to the config by hand.
124
+
125
+ Then verify. Wait a minute first - a brand-new Worker can return `error code: 1042` on valid
126
+ paths for about that long. In a real browser with the console open, load
127
+ `https://<slug>.<account>.workers.dev`: the home page renders, the console is clean. Then check
128
+ one on-demand route with a document-style request, if the site has one yet - a fresh scaffold's
129
+ only Worker route is the form action, which is POST-only:
130
+
131
+ ```sh
132
+ curl -sI -H 'Sec-Fetch-Dest: document' https://<slug>.<account>.workers.dev/<on-demand-route>
133
+ ```
134
+
135
+ The asset router keys off that header and plain curl does not send it, so a route that is 200 to
136
+ `curl` and 404 here is missing from `run_worker_first` (step 4).
137
+
138
+ ## 6. Connect the repo
109
139
 
110
- ## 6. Verify the first deploy
140
+ In the dashboard, on the Worker that now exists: **Settings → Builds → connect `<org>/<slug>`**,
141
+ production branch `main`, no build variables. Connecting a repo to an EXISTING Worker is the
142
+ smaller step - importing a repository and typing the Worker name in by hand is where the name
143
+ mismatch Workers Builds fails on comes from.
111
144
 
112
- Wait a minute after the build reports success - a brand-new Worker can return `error code:
113
- 1042` on valid paths for about that long. Then, in a real browser with the console open, load
114
- the `workers.dev` URL. The home page renders, the console is clean.
145
+ **Push to deploy from then on.** The first push supersedes the laptop version. A `wrangler
146
+ deploy` from a laptop after this point creates a version no build produced, so history stops
147
+ describing what is live and the next push reverts it.
115
148
 
116
- ## 7. Hand over
149
+ Confirm it took:
150
+
151
+ ```sh
152
+ npx wrangler deployments list --name <slug>
153
+ ```
154
+
155
+ The newest deployment's Source is no longer `Upload`.
156
+
157
+ ## 7. Verify the first push
158
+
159
+ Wait a minute after the build reports success - the 1042 window again - then load the
160
+ `workers.dev` URL in a real browser with the console open. The home page renders, the console is
161
+ clean.
162
+
163
+ ## 8. Hand over
117
164
 
118
165
  Workers Builds comments the preview URL on every PR - that is the client's review link. Preview
119
166
  hostnames use the slug, so Chrome's lookalike warning should not appear; if it does, it is a
120
167
  URL-shape false positive and **Ignore is safe**.
121
168
 
169
+ A preview build is safe to hand out: every page is noindex, there is no sitemap, robots.txt
170
+ disallows everything, analytics does not load, and mail is redirected to `stagingEmail`. The
171
+ client can click anything. **Every build of this site is a preview while `environment` is
172
+ `staging`** - `main` and the laptop included - so nothing is indexable before `/webm:launch`
173
+ flips it, and that flip is what makes the site indexable. On a launched site the production
174
+ branch is `main`; anything else previews.
175
+
122
176
  Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
123
177
  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.
@@ -320,6 +329,14 @@ divert every enquiry the day a site answers on `www.`.
320
329
  nightly sweep — the check that reads a hostname cannot see a cron at all. This is why the switch
321
330
  is config rather than something derived from the URL.
322
331
 
332
+ **A staging site is a preview build everywhere, and flipping `environment` is what makes it
333
+ indexable.** On `staging` every build — a branch, `main`, a laptop — is noindex on every page with
334
+ no canonical, no sitemap, `Disallow: /` and no Google Tag Manager. So a site that has not launched
335
+ cannot be indexed on its `workers.dev` URL, and a launched site left on `staging` after 1.3.0
336
+ drops out of search with no symptom on the page. `webm doctor` fails the second; `/webm:launch`
337
+ flips the switch only once the custom domain is live. A branch other than `main` previews
338
+ regardless of the switch.
339
+
323
340
  **Check the client's existing DMARC before adding a sending subdomain.** A DMARC record on
324
341
  `example.com` applies to its subdomains by default. If the client publishes `p=reject` and DKIM on
325
342
  the new subdomain is not right, **every message vanishes** — no bounce, no error, nothing in the
@@ -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
+ });