@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/src/cli/checks.test.ts
CHANGED
|
@@ -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('
|
|
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
|
|
230
|
+
`import Webmaster from '@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro';`,
|
|
231
231
|
],
|
|
232
232
|
]),
|
|
233
233
|
});
|
|
234
|
-
assert.equal(runCheck('
|
|
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('
|
|
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('
|
|
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/
|
|
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: '
|
|
680
|
-
title: 'Something renders the
|
|
681
|
-
silentAs:
|
|
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\/
|
|
702
|
+
if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
|
|
701
703
|
}
|
|
702
704
|
return warn(
|
|
703
|
-
'no component imports @cparkerwebm/webmonterey/webmonterey/
|
|
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') ?? '
|
|
32
|
-
stagingEmail:
|
|
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
|
-
*
|
|
40
|
+
* WHO IS SCAFFOLDING, read from the machine rather than baked into the package.
|
|
40
41
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
52
|
+
function gitConfig(key: string): string | undefined {
|
|
47
53
|
try {
|
|
48
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 });
|
package/src/cli/scaffold.test.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
21
|
-
assert.equal(site.
|
|
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": "
|
|
25
|
-
assert.match(f['README.md']!,
|
|
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
|
|
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({
|
|
180
|
-
'
|
|
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({
|
|
185
|
-
'
|
|
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 })[
|
|
209
|
-
|
|
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 })[
|
|
226
|
-
|
|
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
|
});
|
package/src/cli/scaffold.ts
CHANGED
|
@@ -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 `
|
|
16
|
+
/** Display name. Falls back to CHANGEME, which `/webm:launch` refuses to launch with. */
|
|
17
17
|
client?: string;
|
|
18
|
-
/** GitHub
|
|
19
|
-
org
|
|
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
|
|
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
|
|
417
|
-
`
|
|
418
|
-
`
|
|
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`;
|
package/src/cli/slug.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
39
|
-
|
|
40
|
-
assert.equal(slugFor('
|
|
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 -
|
|
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('
|
|
57
|
-
assert.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
69
|
-
assert.equal(slugFor('
|
|
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
|
|
2
|
+
* Turning a domain into the ONE name everything else uses.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* example.com -> example
|
|
5
|
+
* shop.example.com -> shop-example
|
|
6
|
+
* example.co.uk -> example
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
* `
|
|
87
|
-
* `shop.example.com` and `example.com` are different
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
package/src/emails/footer.ts
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
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;">© ${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
|
|
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
|
|
53
|
-
rclone check ./uploads webm-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,
|
|
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
|
|
17
|
-
* bucket
|
|
18
|
-
* hostname media.<client-domain>
|
|
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-
|
|
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. */
|