@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
@@ -217,7 +217,7 @@ test('a site with components but no credit import warns', () => {
217
217
  const ctx = base({
218
218
  components: new Map([['src/components/regions/footer/footer.astro', '<footer>hi</footer>']]),
219
219
  });
220
- const result = runCheck('agency-credit', ctx);
220
+ const result = runCheck('webmaster-credit', ctx);
221
221
  assert.equal(result.status, 'warn');
222
222
  assert.match(result.detail!, /footer component/);
223
223
  });
@@ -227,15 +227,15 @@ test('a footer that imports the credit passes', () => {
227
227
  components: new Map([
228
228
  [
229
229
  'src/components/regions/footer/footer.astro',
230
- `import Credit from '@cparkerwebm/webmonterey/webmonterey/credits/Credit.astro';`,
230
+ `import Webmaster from '@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro';`,
231
231
  ],
232
232
  ]),
233
233
  });
234
- assert.equal(runCheck('agency-credit', ctx).status, 'pass');
234
+ assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
235
235
  });
236
236
 
237
237
  test('a site with no components yet is not nagged', () => {
238
- assert.equal(runCheck('agency-credit', base()).status, 'pass');
238
+ assert.equal(runCheck('webmaster-credit', base()).status, 'pass');
239
239
  });
240
240
 
241
241
  test('a comment explaining a trap does not trip the check that enforces it', () => {
@@ -274,7 +274,7 @@ test("WebMonterey's own site is not asked to credit itself", () => {
274
274
  site: { client: 'WebMonterey', domain: 'webmonterey.com' },
275
275
  components: new Map([['src/components/regions/footer/footer.astro', '<footer>x</footer>']]),
276
276
  });
277
- assert.equal(runCheck('agency-credit', ctx).status, 'pass');
277
+ assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
278
278
  });
279
279
 
280
280
  test('a cron with no custom entrypoint FAILS, and names the fix', () => {
@@ -405,7 +405,7 @@ test("a placeholder favicon still in public/ fails - it is the agency's mark on
405
405
  */
406
406
  const ctx = base({
407
407
  site: { client: 'Acme', domain: 'acme.com', launched: '2026-03-01' },
408
- placeholders: ['public/favicon.svg', 'public/open-graph.png'],
408
+ placeholders: ['public/favicon.svg', 'public/opengraph.png'],
409
409
  });
410
410
  const result = runCheck('placeholder-branding', ctx);
411
411
  assert.equal(result.status, 'fail', 'a LAUNCHED site shipping the agency mark is a fault');
package/src/cli/checks.ts CHANGED
@@ -676,14 +676,16 @@ export const CHECKS: Check[] = [
676
676
  },
677
677
  },
678
678
  {
679
- id: 'agency-credit',
680
- title: 'Something renders the agency credit',
681
- silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
679
+ id: 'webmaster-credit',
680
+ title: 'Something renders the webmaster credit',
681
+ silentAs:
682
+ 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
682
683
  run(ctx) {
683
684
  /*
684
685
  * The package ships no footer - it ships no components at all - so the credit is imported
685
686
  * by whichever site component renders the footer. That is the right seam and it is also
686
- * easy to simply never do, which is how live client sites ended up without it.
687
+ * easy to simply never do, which is how live client sites ended up without it. Without it
688
+ * the /webmaster page the package injects is reachable from nothing.
687
689
  *
688
690
  * A warning, not a failure: a site mid-build has no footer yet, and failing there trains
689
691
  * people to ignore the doctor. `/webm:launch` is where it becomes blocking.
@@ -697,11 +699,11 @@ export const CHECKS: Check[] = [
697
699
  if (ctx.site.domain === 'webmonterey.com') return pass;
698
700
 
699
701
  for (const src of ctx.components.values()) {
700
- if (/webmonterey\/credits/.test(stripComments(src))) return pass;
702
+ if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
701
703
  }
702
704
  return warn(
703
- 'no component imports @cparkerwebm/webmonterey/webmonterey/credits/Credit.astro. ' +
704
- 'The footer component is where it goes.',
705
+ 'no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. ' +
706
+ 'The footer component is where it goes; it links to the /webmaster page.',
705
707
  );
706
708
  },
707
709
  },
package/src/cli/new.ts CHANGED
@@ -28,26 +28,33 @@ function parseArgs(argv: string[]) {
28
28
  return {
29
29
  domain: positional[0],
30
30
  client: flag('client'),
31
- org: flag('org') ?? 'webmonterey',
32
- stagingEmail: flag('staging-email'),
31
+ org: flag('org') ?? gitConfig('webm.org'),
32
+ stagingEmail:
33
+ flag('staging-email') ?? gitConfig('webm.stagingEmail') ?? gitConfig('user.email'),
33
34
  into: flag('into'),
34
35
  install: !argv.includes('--no-install'),
35
36
  };
36
37
  }
37
38
 
38
39
  /*
39
- * WHERE A STAGING SITE'S MAIL GOES, defaulting to whoever is scaffolding it.
40
+ * WHO IS SCAFFOLDING, read from the machine rather than baked into the package.
40
41
  *
41
- * The package carries no inbox of its own: a default address baked into a public package means
42
- * a stranger's staging site mails the package author. `git config user.email` is the person at
43
- * the keyboard, which is the right default for a site they are about to test. The flag overrides
44
- * it; doctor fails a staging site that ends up with none.
42
+ * A public package carries no agency defaults: not a GitHub org, not an inbox. A default org
43
+ * would only ever be wrong for anyone else, and a default address means a stranger's staging site
44
+ * mails the package author. So both come from git config, set once per machine -
45
+ *
46
+ * git config --global webm.org webmonterey
47
+ * git config --global webm.stagingEmail dev@example.com # optional; user.email otherwise
48
+ *
49
+ * - and a flag overrides either for one run. `webm doctor` fails a staging site that ends up
50
+ * with no address.
45
51
  */
46
- function gitUserEmail(): string {
52
+ function gitConfig(key: string): string | undefined {
47
53
  try {
48
- return execFileSync('git', ['config', 'user.email'], { encoding: 'utf8' }).trim();
54
+ const value = execFileSync('git', ['config', key], { encoding: 'utf8' }).trim();
55
+ return value || undefined;
49
56
  } catch {
50
- return '';
57
+ return undefined;
51
58
  }
52
59
  }
53
60
 
@@ -56,9 +63,17 @@ export function run(argv: string[]): number {
56
63
 
57
64
  if (!args.domain) {
58
65
  console.error(
59
- 'webm new <domain> [--client="Name"] [--org=webmonterey] [--staging-email=you@example.com] [--into=path] [--no-install]',
66
+ 'webm new <domain> [--client="Name"] [--org=<github-owner>] [--staging-email=you@example.com] [--into=path] [--no-install]',
67
+ );
68
+ console.error('\n webm new example.com --client="Example Co"');
69
+ return 1;
70
+ }
71
+
72
+ if (!args.org) {
73
+ console.error(
74
+ 'webm new: no GitHub owner for the repo. Either pass --org=<owner> or set it once:\n\n' +
75
+ ' git config --global webm.org <owner>\n',
60
76
  );
61
- console.error('\n webm new autire.com --client="Autire Technologies"');
62
77
  return 1;
63
78
  }
64
79
 
@@ -86,7 +101,7 @@ export function run(argv: string[]): number {
86
101
  domain,
87
102
  client: args.client,
88
103
  org: args.org,
89
- stagingEmail: args.stagingEmail ?? gitUserEmail(),
104
+ stagingEmail: args.stagingEmail,
90
105
  packageVersion: packageVersion(),
91
106
  /* Real today, not a constant - see ScaffoldOptions.today for what a stale one does. */
92
107
  today: new Date().toISOString().slice(0, 10),
@@ -114,12 +129,14 @@ export function run(argv: string[]): number {
114
129
  console.log(`Scaffolded ${Object.keys(files).length} files into ${root}`);
115
130
  console.log(` + ${seeded.length} seeded (favicons, headers, CLAUDE.md, a contact form)`);
116
131
  console.log(` + ${kept.length} .gitkeep, so the empty directories survive a clone\n`);
117
- console.log(` repo ${args.org}/${names.repo} (keeps the domain)`);
132
+ console.log(` repo ${args.org}/${names.repo}`);
133
+ console.log(
134
+ ` worker · d1 · r2 ${names.slug} (the domain minus its TLD, so Chrome does not flag previews)`,
135
+ );
118
136
  console.log(
119
- ` worker ${names.worker} (slug - no TLD, so Chrome does not flag previews)`,
137
+ `\n One name everywhere. If ${names.slug} is already taken in the account - ${domain.replace(/^[^.]+/, names.slug)}` +
138
+ ` and another TLD both want it - pick another with --into and edit webmonterey.json.`,
120
139
  );
121
- console.log(` d1 ${names.d1}`);
122
- console.log(` r2 ${names.r2Media}`);
123
140
 
124
141
  try {
125
142
  execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: root });
@@ -7,6 +7,7 @@ const files = (over = {}) =>
7
7
  scaffold({
8
8
  domain: 'autire.com',
9
9
  client: 'Autire Technologies',
10
+ org: 'webmonterey',
10
11
  packageVersion: '1.0.0',
11
12
  today: '2026-08-26',
12
13
  ...over,
@@ -14,15 +15,15 @@ const files = (over = {}) =>
14
15
 
15
16
  const json = (f: Record<string, string>, path: string) => JSON.parse(f[path]!);
16
17
 
17
- test('three names, three jobs: repo, slug, Cloudflare', () => {
18
+ test('one name everywhere: repo, Worker, D1 and R2 are all the slug', () => {
18
19
  const f = files();
19
20
  const site = json(f, 'webmonterey.json');
20
- // Repo carries the full domain with UNDERSCORES; Cloudflare carries the slug, no TLD.
21
- assert.equal(site.repo, 'webmonterey/autire_com');
22
- assert.equal(site.worker, 'webm-autire');
21
+ assert.equal(site.repo, 'webmonterey/autire');
22
+ assert.equal(site.worker, 'autire');
23
23
  assert.equal(site.slug, 'autire');
24
- assert.match(f['wrangler.jsonc']!, /"name": "webm-autire"/);
25
- assert.match(f['README.md']!, /webm-autire-db/);
24
+ assert.match(f['wrangler.jsonc']!, /"name": "autire"/);
25
+ assert.match(f['README.md']!, /\| Worker · D1 · R2 \| `autire` \|/);
26
+ assert.equal(json(f, 'package.json').name, 'autire');
26
27
  });
27
28
 
28
29
  test('MCP is declared AND pre-approved - a declaration alone is inert', () => {
@@ -84,7 +85,7 @@ test('secrets are gitignored and the example names the password-manager habit',
84
85
  assert.match(f['.dev.vars.example']!, /password manager/);
85
86
  });
86
87
 
87
- test('an unnamed client gets CHANGEME, which go-live refuses to launch with', () => {
88
+ test('an unnamed client gets CHANGEME, which launch refuses to launch with', () => {
88
89
  const site = json(files({ client: undefined }), 'webmonterey.json');
89
90
  assert.equal(site.client, 'CHANGEME');
90
91
  });
@@ -176,20 +177,26 @@ test('compatibility_date is the date passed in, never a constant baked into the
176
177
  * scaffold.ts, which exists because a date NEWER than the installed runtime does not build at
177
178
  * all. Both directions are traps and they pull in opposite ways.
178
179
  */
179
- const config = scaffold({ domain: 'a.com', packageVersion: '1.0.0', today: '2026-08-26' })[
180
- 'wrangler.jsonc'
181
- ]!;
180
+ const config = scaffold({
181
+ domain: 'a.com',
182
+ org: 'o',
183
+ packageVersion: '1.0.0',
184
+ today: '2026-08-26',
185
+ })['wrangler.jsonc']!;
182
186
  assert.match(config, /"compatibility_date":\s*"2026-08-12"/, 'derived from it, a fortnight back');
183
187
 
184
- const other = scaffold({ domain: 'a.com', packageVersion: '1.0.0', today: '2027-03-04' })[
185
- 'wrangler.jsonc'
186
- ]!;
188
+ const other = scaffold({
189
+ domain: 'a.com',
190
+ org: 'o',
191
+ packageVersion: '1.0.0',
192
+ today: '2027-03-04',
193
+ })['wrangler.jsonc']!;
187
194
  assert.match(other, /"compatibility_date":\s*"2027-02-18"/, 'and it still tracks the argument');
188
195
  });
189
196
 
190
197
  test('a malformed date is refused rather than written into wrangler.jsonc', () => {
191
198
  assert.throws(
192
- () => scaffold({ domain: 'a.com', packageVersion: '1.0.0', today: 'today' }),
199
+ () => scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today: 'today' }),
193
200
  /YYYY-MM-DD/,
194
201
  );
195
202
  });
@@ -205,9 +212,9 @@ test('the scaffolded compatibility_date is never in the future of the installed
205
212
  * machine that just installed. The margin is for every machine that did not.
206
213
  */
207
214
  const written = (today: string) =>
208
- scaffold({ domain: 'a.com', packageVersion: '1.0.0', today })['wrangler.jsonc']!.match(
209
- /"compatibility_date":\s*"([\d-]+)"/,
210
- )![1]!;
215
+ scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today })[
216
+ 'wrangler.jsonc'
217
+ ]!.match(/"compatibility_date":\s*"([\d-]+)"/)![1]!;
211
218
 
212
219
  for (const today of ['2026-08-27', '2026-01-01', '2026-03-01', '2027-12-31']) {
213
220
  const gap = (Date.parse(today) - Date.parse(written(today))) / 86_400_000;
@@ -222,9 +229,9 @@ test('the scaffolded compatibility_date is never in the future of the installed
222
229
  test('the margin crosses a month and a year boundary correctly', () => {
223
230
  // Naive string arithmetic gets 2026-01-05 minus 14 days wrong; this is why it goes through Date.
224
231
  const at = (today: string) =>
225
- scaffold({ domain: 'a.com', packageVersion: '1.0.0', today })['wrangler.jsonc']!.match(
226
- /"compatibility_date":\s*"([\d-]+)"/,
227
- )![1]!;
232
+ scaffold({ domain: 'a.com', org: 'o', packageVersion: '1.0.0', today })[
233
+ 'wrangler.jsonc'
234
+ ]!.match(/"compatibility_date":\s*"([\d-]+)"/)![1]!;
228
235
  assert.equal(at('2026-01-05'), '2025-12-22');
229
236
  assert.equal(at('2026-03-05'), '2026-02-19', 'and February');
230
237
  });
@@ -13,10 +13,10 @@ import { MCP_NAMES, mcpConfig } from './mcp.ts';
13
13
 
14
14
  export interface ScaffoldOptions {
15
15
  domain: string;
16
- /** Display name. Falls back to CHANGEME, which `go-live` refuses to launch with. */
16
+ /** Display name. Falls back to CHANGEME, which `/webm:launch` refuses to launch with. */
17
17
  client?: string;
18
- /** GitHub org for the repo. */
19
- org?: string;
18
+ /** GitHub owner for the repo. Required - the package carries no agency default. */
19
+ org: string;
20
20
  /**
21
21
  * Where a staging deployment's mail goes. `webm new` fills it from `git config user.email`;
22
22
  * the package itself carries no inbox, because a default address in a public package means a
@@ -49,7 +49,7 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
49
49
  const { domain, packageVersion } = options;
50
50
  const n = resourceNames(domain);
51
51
  const client = options.client ?? 'CHANGEME';
52
- const org = options.org ?? 'webmonterey';
52
+ const { org } = options;
53
53
  const { today } = options;
54
54
 
55
55
  if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) {
@@ -413,9 +413,10 @@ export function scaffold(options: ScaffoldOptions): Record<string, string> {
413
413
  `| \`npx webm doctor\` | the things that fail silently |\n\n` +
414
414
  `## Cloudflare\n\n` +
415
415
  `| | |\n| --- | --- |\n` +
416
- `| Worker | \`${n.worker}\` |\n| D1 | \`${n.d1}\` |\n| R2 media | \`${n.r2Media}\` |\n\n` +
417
- `The repo is named for the domain; Cloudflare resources use the slug, with no TLD, so a\n` +
418
- `preview hostname never embeds a domain Chrome could mistake for a lookalike.\n\n` +
416
+ `| Worker · D1 · R2 | \`${n.slug}\` |\n\n` +
417
+ `One name everywhere: the domain minus its TLD. A preview hostname then never embeds a\n` +
418
+ `domain Chrome could mistake for a lookalike. A second resource of one kind takes a purpose\n` +
419
+ `suffix - \`${n.slug}-portal\`.\n\n` +
419
420
  `## Deploying\n\n` +
420
421
  `Push to deploy. A \`wrangler deploy\` from a laptop creates a version no build produced, so\n` +
421
422
  `history stops describing what is live and the next push reverts it.\n`;
@@ -20,24 +20,11 @@ test('something that is not a domain is refused, naming what was expected', () =
20
20
  assert.throws(() => normalizeDomain('exa mple.com'), DomainError);
21
21
  });
22
22
 
23
- test('the repo keeps the full domain, dots to UNDERSCORES', () => {
24
- assert.equal(repoName('autire.com'), 'autire_com');
25
- assert.equal(repoName('friendsofthemarinalibrary.org'), 'friendsofthemarinalibrary_org');
26
- });
27
-
28
- test('the three names are all different, and each has a job', () => {
29
- // repo is unambiguous about the site; slug drops the TLD so Chrome does not flag preview
30
- // hostnames; worker is the slug prefixed.
31
- const n = resourceNames('autire.com');
32
- assert.equal(n.repo, 'autire_com');
33
- assert.equal(n.slug, 'autire');
34
- assert.equal(n.worker, 'webm-autire');
35
- });
36
-
37
23
  test('the slug drops the TLD - this is what stops Chrome flagging preview links', () => {
38
- // webm-autire-com contains autire-com, which reads as a domain. webm-autire does not.
39
- assert.equal(slugFor('autire.com'), 'autire');
40
- assert.equal(slugFor('stevenglaze.com'), 'stevenglaze');
24
+ // A Worker named example-com puts example-com into every preview hostname, which Chrome reads
25
+ // as a registrable domain. `example` embeds nothing.
26
+ assert.equal(slugFor('example.com'), 'example');
27
+ assert.equal(slugFor('acme-widgets.org'), 'acme-widgets');
41
28
  });
42
29
 
43
30
  test('a two-part public suffix drops both labels', () => {
@@ -45,7 +32,7 @@ test('a two-part public suffix drops both labels', () => {
45
32
  assert.equal(slugFor('example.com.au'), 'example');
46
33
  });
47
34
 
48
- test('a subdomain is kept - two of ours could differ only by it', () => {
35
+ test('a subdomain is kept - an indexable subdomain is its own site', () => {
49
36
  assert.equal(slugFor('shop.example.com'), 'shop-example');
50
37
  });
51
38
 
@@ -53,18 +40,28 @@ test('a single-label result never comes back empty', () => {
53
40
  assert.ok(slugFor('a.com').length > 0);
54
41
  });
55
42
 
56
- test('every Cloudflare name derives from one domain, and the repo differs on purpose', () => {
57
- assert.deepEqual(resourceNames('autire.com'), {
58
- slug: 'autire',
59
- repo: 'autire_com',
60
- worker: 'webm-autire',
61
- d1: 'webm-autire-db',
62
- r2Media: 'webm-autire-media',
63
- r2App: 'webm-autire-app',
43
+ test('one name, everywhere: repo, Worker, D1, R2 and KV are all the slug', () => {
44
+ assert.equal(repoName('example.com'), 'example');
45
+ assert.deepEqual(resourceNames('example.com'), {
46
+ slug: 'example',
47
+ repo: 'example',
48
+ worker: 'example',
49
+ d1: 'example',
50
+ r2: 'example',
51
+ kv: 'example',
64
52
  });
65
53
  });
66
54
 
55
+ test('every name is valid for the strictest resource - lowercase, digits and dashes only', () => {
56
+ // R2 and Workers accept nothing else, and R2 also refuses a leading or trailing dash.
57
+ for (const domain of ['example.com', 'shop.example.com', 'acme-widgets.co.uk', 'a1.io']) {
58
+ for (const name of Object.values(resourceNames(domain))) {
59
+ assert.match(name, /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, `${domain} -> ${name}`);
60
+ }
61
+ }
62
+ });
63
+
67
64
  test('two clients on the same name under different TLDs collide, which the caller must handle', () => {
68
- // clients.slug carries a unique constraint; `webm new` picks the next free form and records it.
69
- assert.equal(slugFor('autire.com'), slugFor('autire.org'));
65
+ // `webm new` says so; the second one gets a name chosen by a person.
66
+ assert.equal(slugFor('example.com'), slugFor('example.org'));
70
67
  });
package/src/cli/slug.ts CHANGED
@@ -1,15 +1,21 @@
1
1
  /*
2
- * Turning a domain into the names everything else uses.
2
+ * Turning a domain into the ONE name everything else uses.
3
3
  *
4
- * TWO NAMES, DELIBERATELY DIFFERENT.
4
+ * example.com -> example
5
+ * shop.example.com -> shop-example
6
+ * example.co.uk -> example
5
7
  *
6
- * GitHub repo <domain_underscored> webmonterey/autire_com
7
- * Cloudflare stuff webm-<slug> webm-autire, webm-autire-db, webm-autire-media
8
+ * The GitHub repo, the Worker, the D1 database, the R2 bucket and any KV namespace all carry
9
+ * that same name. It is the domain minus its public suffix, which is the one shape valid for
10
+ * every resource at once - Workers and R2 accept only `[a-z0-9-]`, and in an agency account that
11
+ * holds nothing but client sites a prefix says nothing.
8
12
  *
9
- * The repo keeps the full domain so it is unambiguous which site it serves. Cloudflare drops the
10
- * TLD token because `webm-autire-com` contains `autire-com`, which Chrome's lookalike check reads
11
- * as a registrable domain - and every preview link then warns the client the site looks fake.
12
- * `webm-autire` has nothing in it that looks like a domain. See ARCHITECTURE.md section 5.
13
+ * The TLD is dropped for a reason beyond brevity: a Worker named `example-com` puts `example-com`
14
+ * into every preview hostname, and Chrome's lookalike-domain check reads that as a registrable
15
+ * domain and warns the client their own preview looks fake. `example` embeds nothing.
16
+ *
17
+ * A second resource of the same kind for one client takes a purpose suffix - `example-portal` -
18
+ * and is the exception, not the pattern.
13
19
  */
14
20
 
15
21
  /** Public suffixes that take two labels, so `example.co.uk` slugs to `example`. */
@@ -63,28 +69,16 @@ export function normalizeDomain(input: string): string {
63
69
  return cleaned;
64
70
  }
65
71
 
66
- /**
67
- * The GitHub repo name. Dots become UNDERSCORES: `autire.com` -> `autire_com`.
68
- *
69
- * Underscores, not dashes, and not the slug. Three names, three jobs:
70
- *
71
- * repo autire_com the full domain, unambiguous about which site this is
72
- * slug autire no TLD, because a Cloudflare Worker named webm-autire-com
73
- * embeds autire-com and Chrome reads that as a domain
74
- * worker webm-autire the slug, prefixed
75
- *
76
- * A rebuild creates a NEW repo under the underscore name beside the old dashed one, which is what
77
- * lets the old site keep serving until the cutover.
78
- */
79
- export function repoName(domain: string): string {
80
- return normalizeDomain(domain).replace(/\./g, '_');
81
- }
82
-
83
72
  /**
84
73
  * The client slug - the domain with its public suffix removed.
85
74
  *
86
- * `autire.com` -> `autire`, `example.co.uk` -> `example`. Subdomains are kept, because
87
- * `shop.example.com` and `example.com` are different clients if they are ever both ours.
75
+ * `example.com` -> `example`, `example.co.uk` -> `example`. Subdomains are kept, joined with a
76
+ * dash, because `shop.example.com` and `example.com` are different sites if they are ever both
77
+ * ours - and an indexable subdomain is always its own site.
78
+ *
79
+ * `example.com` and `example.org` slug to the same thing. That is a real collision inside one
80
+ * account, and `webm new` says so rather than silently picking - the second one gets a name
81
+ * chosen by a person.
88
82
  */
89
83
  export function slugFor(domain: string): string {
90
84
  const clean = normalizeDomain(domain);
@@ -95,15 +89,16 @@ export function slugFor(domain: string): string {
95
89
  return kept.join('-');
96
90
  }
97
91
 
98
- /** Every Cloudflare resource name for a site, from one domain. */
92
+ /** The GitHub repo name. The slug - one name, everywhere. */
93
+ export function repoName(domain: string): string {
94
+ return slugFor(domain);
95
+ }
96
+
97
+ /**
98
+ * Every resource name for a site, from one domain. They are all the slug; the fields exist so a
99
+ * caller says which resource it means, and so a purpose suffix has an obvious place to go.
100
+ */
99
101
  export function resourceNames(domain: string) {
100
102
  const slug = slugFor(domain);
101
- return {
102
- slug,
103
- repo: repoName(domain),
104
- worker: `webm-${slug}`,
105
- d1: `webm-${slug}-db`,
106
- r2Media: `webm-${slug}-media`,
107
- r2App: `webm-${slug}-app`,
108
- };
103
+ return { slug, repo: slug, worker: slug, d1: slug, r2: slug, kv: slug };
109
104
  }
package/src/cli/sync.ts CHANGED
@@ -51,7 +51,7 @@ import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
51
51
 
52
52
  /**
53
53
  * The namespace. Rule 5's prefix: the CLI is `webm`, the tokens are --webm-*, the classes are
54
- * .webm-*, the Workers are webm-<slug>. `/webm:launch` rather than `/webmonterey:launch`.
54
+ * .webm-*, the cloud resources carry one name each - the domain minus its TLD. `/webm:launch` rather than `/webmonterey:launch`.
55
55
  */
56
56
  const NAMESPACE = 'webm';
57
57
 
@@ -14,7 +14,7 @@
14
14
  * constant — and a caller that has to remember to pass the year eventually forgets, which
15
15
  * shows up as a stale copyright the following January.
16
16
  */
17
- import { CREDIT_TEXT, creditUrl } from '../includes/webmonterey/credits/credit.ts';
17
+ import { CREDIT_TEXT, creditUrl } from '../includes/webmonterey/webmaster/webmaster.ts';
18
18
  import { DEFAULT_COPY, fill } from '../includes/webmonterey/copy-defaults.ts';
19
19
 
20
20
  export interface EmailFooterInput {
@@ -75,13 +75,13 @@ export function renderFooterHtml(input: EmailFooterInput): string {
75
75
  * in a browser is the expected behavior, so the warning would be noise about something no
76
76
  * reader was surprised by. Nothing is lost that email a11y actually asks for.
77
77
  *
78
- * `title` is absent from both links ON PURPOSE, here and in Credit.astro. It is not reliably
78
+ * `title` is absent from both links ON PURPOSE, here and in Webmaster.astro. It is not reliably
79
79
  * announced by screen readers, is unreachable by keyboard and touch entirely, and either
80
80
  * duplicates the link text or competes with it for the accessible name. The link text is the
81
81
  * accessible name; that is the mechanism that works.
82
82
  *
83
83
  * `rel="noopener"` without `noreferrer`, also on purpose: the referrer IS the attribution.
84
- * Stripping it would leave only utm_content. See credit.ts.
84
+ * Stripping it would leave only utm_content. See webmaster.ts.
85
85
  */
86
86
  return ` <div style="max-width:640px;margin:0 auto;padding:24px 32px 8px;text-align:center;font-size:13px;line-height:1.6;color:#3f3f3f;">
87
87
  <p style="margin:0;">&copy; ${year} ${escapeHtml(input.client)}</p>
@@ -13,7 +13,7 @@ domain on a bucket requires the zone to be on the same account as the bucket, so
13
13
  earlier fails for the same reason `preview.<client-domain>` does.
14
14
 
15
15
  ```sh
16
- npx wrangler r2 bucket create webm-<domain-dashed>-media
16
+ npx wrangler r2 bucket create <slug> # the domain minus its TLD, same as the Worker and D1
17
17
  ```
18
18
 
19
19
  Then in the dashboard: **R2 → the bucket → Settings → Custom Domains → Connect Domain**, and
@@ -49,8 +49,8 @@ no_check_bucket = true
49
49
  every transfer fails with an error naming an operation you never asked for.
50
50
 
51
51
  ```sh
52
- rclone copy ./uploads webm-media:webm-<domain-dashed>-media --transfers 2 --progress
53
- rclone check ./uploads webm-media:webm-<domain-dashed>-media
52
+ rclone copy ./uploads webm-media:<slug> --transfers 2 --progress
53
+ rclone check ./uploads webm-media:<slug>
54
54
  ```
55
55
 
56
56
  Two things learned the hard way:
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * src/assets/ images the DESIGN uses — imported, hashed, optimised at build by sharp.
7
7
  * A logo, an icon, a hero shot. These belong in the repo.
8
- * public/ small fixed files that need a stable URL (favicons, open-graph.png).
8
+ * public/ small fixed files that need a stable URL (favicons, opengraph.png).
9
9
  * R2 (here) everything too large or too numerous to sit in git: video, audio, PDFs,
10
10
  * photo galleries, downloads, anything migrated wholesale off a WordPress
11
11
  * uploads folder.
@@ -13,9 +13,9 @@
13
13
  * The deciding question is not file size, it is "should `git clone` carry this?" A 40MB
14
14
  * showreel makes every clone slower forever and can never be optimised by the build.
15
15
  *
16
- * NAMING (rule 5):
17
- * bucket webm-<domain-dashed>-media e.g. webm-example-com-media
18
- * hostname media.<client-domain> e.g. media.example.com
16
+ * NAMING:
17
+ * bucket <slug> e.g. example - one name for every resource, see cli/slug.ts
18
+ * hostname media.<client-domain> e.g. media.example.com
19
19
  *
20
20
  * WHY A CUSTOM DOMAIN AND NOT r2.dev: Cloudflare's r2.dev subdomain is rate-limited and
21
21
  * documented as unsuitable for production. It is also a hostname the client does not own,
@@ -68,7 +68,7 @@ test('staging is decided by config OR by a workers.dev hostname, each covering t
68
68
  // A cron has no hostname, so config is the only signal it can read; a branch preview of a
69
69
  // launched site inherits `production` from main, so the hostname is the only signal there.
70
70
  assert.equal(isStagingDeployment('staging', null), true);
71
- assert.equal(isStagingDeployment('production', 'x-webm-acme.acct.workers.dev'), true);
71
+ assert.equal(isStagingDeployment('production', 'x-acme.acct.workers.dev'), true);
72
72
  assert.equal(isStagingDeployment('production', 'acme.com'), false);
73
73
  assert.equal(isStagingDeployment(undefined, 'www.acme.com'), false, 'a www variant still sends');
74
74
  assert.equal(isStagingDeployment('production', 'notworkers.dev'), false, 'label, not substring');
@@ -67,6 +67,13 @@ export interface Copy {
67
67
  /** `{domain}` is replaced with the site's domain. */
68
68
  footerNotice: string;
69
69
  };
70
+ /** The /webmaster page. `intro` wraps the agency link: `before` <a>WebMonterey</a> `after`. */
71
+ webmaster: {
72
+ title: string;
73
+ description: string;
74
+ intro: { before: string; after: string };
75
+ body: string[];
76
+ };
70
77
  }
71
78
 
72
79
  /*
@@ -125,6 +132,19 @@ export const DEFAULT_COPY: Copy = {
125
132
  reference: 'Reference: #{id}',
126
133
  footerNotice: 'This is an automated notification for your account at the {domain} website.',
127
134
  },
135
+ webmaster: {
136
+ title: 'Our Webmaster',
137
+ description:
138
+ 'This website was designed, built and managed by WebMonterey, a webmaster maintenance service in Monterey, California.',
139
+ intro: {
140
+ before: 'This website was designed, built and managed by',
141
+ after:
142
+ ', a webmaster maintenance service in Monterey, California. WebMonterey handles the hosting, security, updates and ongoing care of the site so that we can focus on what we do.',
143
+ },
144
+ body: [
145
+ "If you have a question about this website, notice something that isn't working, or have trouble using a page, please let WebMonterey know and they will take care of it.",
146
+ ],
147
+ },
128
148
  };
129
149
 
130
150
  /** Merge the site's overrides over the defaults, key by key, at any depth. */