@cparkerwebm/webmonterey 1.2.0 → 1.4.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.
@@ -0,0 +1,106 @@
1
+ /*
2
+ * The Claude Code project settings a client repo carries, and the one list of what they deny.
3
+ *
4
+ * WHY A MODULE, like mcp.ts. Three places have to agree about these rules: the scaffold writes
5
+ * them into a new site, `webm sync` merges any that are missing into an existing site on every
6
+ * install, and the tests assert both against this list rather than a literal that falls one
7
+ * behind. `.claude/settings.json` is otherwise the site's own - a client adds allow rules to it -
8
+ * so it is MERGED, never replaced: the package's deny rules are added when absent and everything
9
+ * else in the file is left exactly as found.
10
+ *
11
+ * WHAT THE DENY RULES ENFORCE. Two things a session in a client repo must never do, made
12
+ * mechanical rather than advisory:
13
+ *
14
+ * - read a secret. .env, .dev.vars, keys, the npm config.
15
+ * - edit the package. Rule 12 of the site's CLAUDE.md: a session in a client repo never edits
16
+ * the package, not in node_modules and not in its checkout. The deliverable for an upstream
17
+ * problem is a description of the fix, run later in the package repo.
18
+ *
19
+ * THE SYNTAX, verified against code.claude.com/docs/en/permissions rather than recalled:
20
+ *
21
+ * - Read and Edit rules take gitignore patterns. As a DENY rule, `Edit(**\/node_modules/**)`
22
+ * matches a node_modules directory at any depth under the working directory.
23
+ * - `Edit` rules apply to every built-in tool that edits files - Edit, Write, MultiEdit and
24
+ * NotebookEdit. A `Write(...)` rule is accepted, never consulted, and warned about at
25
+ * startup, which is why none appears here and why the two that 1.2.0 scaffolded are removed.
26
+ * - A `Read` deny also blocks Edit and Write on the same path. The Edit rules for the secret
27
+ * files are therefore redundant, and kept: they say what is meant.
28
+ * - THERE IS NO PATTERN FOR "ANY PATH OUTSIDE THE PROJECT". A rule names a path - `//absolute`,
29
+ * `~/home-relative`, `/project-relative`, or cwd-relative - and nothing expresses the
30
+ * complement of one. The package checkout sitting beside a client repo on the same machine
31
+ * cannot be denied by rule without hardcoding where it is, and a public package does not
32
+ * know. Rule 12 in CLAUDE.md carries that half in prose.
33
+ */
34
+
35
+ import { MCP_NAMES } from './mcp.ts';
36
+
37
+ /** Every rule a site's settings must deny. Order is the order they are written. */
38
+ export const DENY_RULES: readonly string[] = [
39
+ 'Read(**/.dev.vars)',
40
+ 'Read(**/.dev.vars.*)',
41
+ 'Read(**/.env)',
42
+ 'Read(**/.env.*)',
43
+ 'Read(**/*.pem)',
44
+ 'Read(**/*.key)',
45
+ 'Read(**/.npmrc)',
46
+ 'Edit(**/.dev.vars)',
47
+ 'Edit(**/.env)',
48
+ 'Edit(**/node_modules/**)',
49
+ ];
50
+
51
+ /**
52
+ * Rules an earlier scaffold wrote that Claude Code never consults and warns about at startup.
53
+ * Removed on sync, by exact string, so a rule the site wrote itself is never touched.
54
+ */
55
+ export const STALE_RULES: readonly string[] = ['Write(**/.dev.vars)', 'Write(**/.env)'];
56
+
57
+ const PERMISSIONS_NOTE =
58
+ 'The deny list is package-managed: `webm sync` adds any rule that is missing on every ' +
59
+ 'install and leaves everything else in this file alone. Edit(**/node_modules/**) is rule 12 ' +
60
+ 'of CLAUDE.md made mechanical - a session in this repo never edits the package.';
61
+
62
+ /** The whole file, for a new site. */
63
+ export function projectSettings(repo: string): Record<string, unknown> {
64
+ return {
65
+ '//': `Project settings for ${repo}.`,
66
+ '//mcp':
67
+ 'A server declared in .mcp.json is INERT until approved on each machine. Without this line the rules that say consult the Astro and MDN docs before using an API would depend on whoever cloned the repo happening to hit Approve.',
68
+ includeCoAuthoredBy: false,
69
+ enabledMcpjsonServers: [...MCP_NAMES],
70
+ '//permissions': PERMISSIONS_NOTE,
71
+ permissions: { deny: [...DENY_RULES] },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * An existing settings file with the package's deny rules present and the stale ones gone.
77
+ *
78
+ * IDEMPOTENT, and additive everywhere else: a second pass reports nothing and changes nothing,
79
+ * and a rule the site added itself - an allow list, a deny of its own - survives untouched. The
80
+ * site's own rules keep their order; the package's are appended in DENY_RULES order.
81
+ */
82
+ export function withDenyRules(settings: Record<string, unknown>): {
83
+ settings: Record<string, unknown>;
84
+ added: string[];
85
+ removed: string[];
86
+ } {
87
+ const permissions =
88
+ settings.permissions && typeof settings.permissions === 'object'
89
+ ? { ...(settings.permissions as Record<string, unknown>) }
90
+ : {};
91
+ const current = Array.isArray(permissions.deny)
92
+ ? (permissions.deny as unknown[]).filter((r): r is string => typeof r === 'string')
93
+ : [];
94
+
95
+ const removed = current.filter((r) => STALE_RULES.includes(r));
96
+ const kept = current.filter((r) => !STALE_RULES.includes(r));
97
+ const added = DENY_RULES.filter((r) => !kept.includes(r));
98
+ if (!added.length && !removed.length) return { settings, added, removed };
99
+
100
+ permissions.deny = [...kept, ...added];
101
+ return {
102
+ settings: { ...settings, '//permissions': PERMISSIONS_NOTE, permissions },
103
+ added,
104
+ removed,
105
+ };
106
+ }
@@ -11,6 +11,7 @@ import {
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { sync } from './sync.ts';
14
+ import { DENY_RULES, STALE_RULES } from './settings.ts';
14
15
 
15
16
  const site = () => mkdtempSync(join(tmpdir(), 'webm-sync-'));
16
17
 
@@ -43,6 +44,7 @@ test('the package ships exactly the skills the release claims', () => {
43
44
  'start',
44
45
  'traps',
45
46
  'upgrade',
47
+ 'webmaster',
46
48
  ]);
47
49
  });
48
50
 
@@ -135,3 +137,63 @@ test('the migrations README rides along, so the --remote trap is documented in t
135
137
  sync(dir);
136
138
  assert.match(readFileSync(join(dir, 'migrations', 'README.md'), 'utf8'), /--remote/);
137
139
  });
140
+
141
+ test('the package deny rules are MERGED into .claude/settings.json, never replacing it', () => {
142
+ const dir = site();
143
+ mkdirSync(join(dir, '.claude'), { recursive: true });
144
+ const path = join(dir, '.claude/settings.json');
145
+ writeFileSync(
146
+ path,
147
+ JSON.stringify({
148
+ '//': 'theirs',
149
+ enabledMcpjsonServers: ['astro-docs'],
150
+ permissions: {
151
+ allow: ['Bash(npm run *)'],
152
+ deny: ['Read(**/.env)', 'Write(**/.env)', 'Edit(/secrets/**)'],
153
+ },
154
+ }),
155
+ );
156
+
157
+ const first = sync(dir);
158
+ const written = JSON.parse(readFileSync(path, 'utf8'));
159
+ assert.ok(first.settings.added.includes('Edit(**/node_modules/**)'), 'rule 12 lands');
160
+ assert.deepEqual(first.settings.removed, ['Write(**/.env)'], 'the rule Claude Code warns about');
161
+ assert.equal(written['//'], 'theirs');
162
+ assert.deepEqual(written.enabledMcpjsonServers, ['astro-docs'], 'not touched by this');
163
+ assert.deepEqual(
164
+ written.permissions.allow,
165
+ ['Bash(npm run *)'],
166
+ "the site's allow list survives",
167
+ );
168
+ assert.ok(written.permissions.deny.includes('Edit(/secrets/**)'), "the site's own deny survives");
169
+ for (const rule of DENY_RULES) assert.ok(written.permissions.deny.includes(rule), rule);
170
+ for (const rule of STALE_RULES) assert.ok(!written.permissions.deny.includes(rule), rule);
171
+ assert.equal(
172
+ written.permissions.deny.filter((r: string) => r === 'Read(**/.env)').length,
173
+ 1,
174
+ 'a rule already there is not doubled',
175
+ );
176
+
177
+ /* Idempotent: the second pass reports nothing and rewrites nothing. */
178
+ const before = readFileSync(path, 'utf8');
179
+ const second = sync(dir);
180
+ assert.deepEqual(second.settings, { added: [], removed: [], created: false, skipped: null });
181
+ assert.equal(readFileSync(path, 'utf8'), before);
182
+ });
183
+
184
+ test('a site with no settings file gets the scaffold defaults', () => {
185
+ const dir = site();
186
+ const result = sync(dir);
187
+ assert.equal(result.settings.created, true);
188
+ const written = JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'));
189
+ assert.deepEqual(written.permissions.deny, [...DENY_RULES]);
190
+ });
191
+
192
+ test('a settings file that will not parse is left alone and reported', () => {
193
+ const dir = site();
194
+ mkdirSync(join(dir, '.claude'), { recursive: true });
195
+ writeFileSync(join(dir, '.claude/settings.json'), '{ not json');
196
+ const result = sync(dir);
197
+ assert.ok(result.settings.skipped);
198
+ assert.equal(readFileSync(join(dir, '.claude/settings.json'), 'utf8'), '{ not json');
199
+ });
package/src/cli/sync.ts CHANGED
@@ -26,6 +26,9 @@
26
26
  * a real D1 database must NEVER change - SQLite has already run
27
27
  * it and wrangler tracks it by name. Later versions add 0002,
28
28
  * they do not rewrite 0001.
29
+ * MERGE .claude/settings.json The package's deny rules are added when absent and the file
30
+ * is otherwise left as found - a client's allow list is theirs.
31
+ * See cli/settings.ts for the rules and why they are rules.
29
32
  * SEED public/, content Written once by `webm new` and then the client's outright.
30
33
  * Not handled here at all - see cli/scaffold.ts.
31
34
  *
@@ -46,8 +49,9 @@ import {
46
49
  writeFileSync,
47
50
  readdirSync,
48
51
  } from 'node:fs';
49
- import { basename, join } from 'node:path';
52
+ import { basename, dirname, join } from 'node:path';
50
53
  import { PACKAGE_ROOT, packageVersion } from './package-root.ts';
54
+ import { DENY_RULES, projectSettings, withDenyRules } from './settings.ts';
51
55
 
52
56
  /**
53
57
  * The namespace. Rule 5's prefix: the CLI is `webm`, the tokens are --webm-*, the classes are
@@ -68,6 +72,11 @@ interface SyncResult {
68
72
  workflows: string[];
69
73
  /** Migrations copied because the site did not have them. Never includes an existing file. */
70
74
  migrations: string[];
75
+ /**
76
+ * What changed in .claude/settings.json: deny rules added, stale ones removed, the file
77
+ * created when there was none, or why it was left alone.
78
+ */
79
+ settings: { added: string[]; removed: string[]; created: boolean; skipped: string | null };
71
80
  }
72
81
 
73
82
  /**
@@ -119,6 +128,46 @@ function addMissing(source: string, target: string): string[] {
119
128
  return added.sort();
120
129
  }
121
130
 
131
+ /**
132
+ * MERGE. The package's deny rules into the site's settings, and nothing else.
133
+ *
134
+ * `.claude/settings.json` is the site's: a client adds allow rules to it, and a full replace would
135
+ * throw those away on the next install. So the file is read, the rules in cli/settings.ts are
136
+ * added where absent, the two rules Claude Code warns about at startup are dropped, and the file
137
+ * is written back only when that changed something - a second pass is a no-op. A file that will
138
+ * not parse is left exactly as it is and reported: Claude Code cannot read it either, and
139
+ * rewriting it would hide that.
140
+ *
141
+ * Created from the scaffold's defaults when there is none, so a site that predates the file gets
142
+ * the rules on its next install rather than never.
143
+ */
144
+ function ensureSettings(siteRoot: string): SyncResult['settings'] {
145
+ const path = join(siteRoot, '.claude', 'settings.json');
146
+ if (!existsSync(path)) {
147
+ mkdirSync(dirname(path), { recursive: true });
148
+ writeFileSync(path, JSON.stringify(projectSettings(basename(siteRoot)), null, 2) + '\n');
149
+ return { added: [...DENY_RULES], removed: [], created: true, skipped: null };
150
+ }
151
+
152
+ let parsed: Record<string, unknown>;
153
+ try {
154
+ parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
155
+ } catch {
156
+ return {
157
+ added: [],
158
+ removed: [],
159
+ created: false,
160
+ skipped: '.claude/settings.json is not valid JSON, so it was left alone',
161
+ };
162
+ }
163
+
164
+ const { settings, added, removed } = withDenyRules(parsed);
165
+ if (added.length || removed.length) {
166
+ writeFileSync(path, JSON.stringify(settings, null, 2) + '\n');
167
+ }
168
+ return { added, removed, created: false, skipped: null };
169
+ }
170
+
122
171
  function listSkills(dir: string): string[] {
123
172
  if (!existsSync(dir)) return [];
124
173
  return readdirSync(dir, { withFileTypes: true })
@@ -207,6 +256,7 @@ export function sync(siteRoot: string): SyncResult {
207
256
  */
208
257
  workflows: syncDir(join(template, 'workflows'), join(siteRoot, '.github/workflows')),
209
258
  migrations: addMissing(join(template, 'migrations'), join(siteRoot, 'migrations')),
259
+ settings: ensureSettings(siteRoot),
210
260
  };
211
261
  }
212
262
 
@@ -245,6 +295,19 @@ export function run(argv: string[]): number {
245
295
  for (const m of result.migrations) {
246
296
  console.log(` + migrations/${m} (apply it: npx wrangler d1 migrations apply <DB> --remote)`);
247
297
  }
298
+ if (result.settings.created) {
299
+ console.log(` + .claude/settings.json`);
300
+ } else if (result.settings.skipped) {
301
+ console.log(` ${result.settings.skipped}`);
302
+ } else if (result.settings.added.length || result.settings.removed.length) {
303
+ const parts = [
304
+ result.settings.added.length && `+${result.settings.added.length} deny`,
305
+ result.settings.removed.length && `-${result.settings.removed.length} stale`,
306
+ ].filter(Boolean);
307
+ console.log(
308
+ ` .claude/settings.json: ${parts.join(', ')} (package-managed rules; yours are kept)`,
309
+ );
310
+ }
248
311
 
249
312
  if (ensureGitignored(siteRoot)) {
250
313
  console.log(` gitignored .claude/skills/${NAMESPACE}/`);
@@ -3,9 +3,11 @@ import assert from 'node:assert/strict';
3
3
  import {
4
4
  APP_DIR,
5
5
  isConfigured,
6
+ isPreviewBuild,
6
7
  isStagingDeployment,
7
8
  isValidTimeZone,
8
9
  PLACEHOLDER,
10
+ previewReason,
9
11
  resolveAppPath,
10
12
  resolveDisplayName,
11
13
  workerFirstPaths,
@@ -92,3 +94,52 @@ test('shortName falls back to the display name when unset', () => {
92
94
  assert.equal(isConfigured('FoML'), true);
93
95
  assert.equal(isConfigured(''), false);
94
96
  });
97
+
98
+ /* --- preview builds ------------------------------------------------------ */
99
+
100
+ test('a staging site is a preview with no branch at all - the laptop build', () => {
101
+ assert.equal(isPreviewBuild({ environment: 'staging', branch: null }), true);
102
+ assert.equal(previewReason({ environment: 'staging', branch: null }), 'staging');
103
+ });
104
+
105
+ test('a staging site is a preview on main - the case that was crawlable', () => {
106
+ /* autire.webmonterey.workers.dev: main on Workers Builds, environment staging, and indexable
107
+ * because only a non-production BRANCH used to be a preview. */
108
+ assert.equal(isPreviewBuild({ environment: 'staging', branch: 'main' }), true);
109
+ assert.equal(previewReason({ environment: 'staging', branch: 'main' }), 'staging');
110
+ });
111
+
112
+ test('a production site with no branch is production output', () => {
113
+ assert.equal(isPreviewBuild({ environment: 'production', branch: null }), false);
114
+ assert.equal(previewReason({ environment: 'production', branch: null }), null);
115
+ });
116
+
117
+ test('a production site on main is production output', () => {
118
+ assert.equal(isPreviewBuild({ environment: 'production', branch: 'main' }), false);
119
+ });
120
+
121
+ test('a feature branch of a production site is still a preview', () => {
122
+ /* webmonterey.json is committed, so the branch inherits production from main. The branch rule
123
+ * is what keeps a launched site's review links out of the index. */
124
+ assert.equal(isPreviewBuild({ environment: 'production', branch: 'feature/x' }), true);
125
+ assert.equal(previewReason({ environment: 'production', branch: 'feature/x' }), 'branch');
126
+ });
127
+
128
+ test('productionBranch renames which branch is production', () => {
129
+ const on = { environment: 'production', productionBranch: 'release' } as const;
130
+ assert.equal(isPreviewBuild({ ...on, branch: 'release' }), false);
131
+ assert.equal(isPreviewBuild({ ...on, branch: 'main' }), true);
132
+ });
133
+
134
+ test('staging wins over the production branch, whatever it is called', () => {
135
+ assert.equal(
136
+ previewReason({ environment: 'staging', branch: 'release', productionBranch: 'release' }),
137
+ 'staging',
138
+ );
139
+ });
140
+
141
+ test('an unset environment is production, so a site predating the field builds as before', () => {
142
+ assert.equal(isPreviewBuild({ environment: undefined, branch: null }), false);
143
+ assert.equal(isPreviewBuild({ environment: undefined, branch: 'main' }), false);
144
+ assert.equal(isPreviewBuild({ environment: undefined, branch: 'feature/x' }), true);
145
+ });
@@ -125,6 +125,14 @@ export interface SiteConfig {
125
125
  * Read it anywhere via `environment`, `isStaging` and `isProduction` from webmonterey/site.
126
126
  * The first consumer is transactional email, which redirects every recipient to `stagingEmail`
127
127
  * on a staging deployment rather than mailing the client's real contacts from a preview.
128
+ *
129
+ * THE SECOND CONSUMER IS INDEXABILITY. A staging site is a PREVIEW BUILD on every hostname and
130
+ * in every build - laptop, `main` on Workers Builds, anywhere: every page is noindex with no
131
+ * canonical, there is no sitemap, robots.txt disallows everything, and Google Tag Manager does
132
+ * not load. Until this switch existed a site that had not launched was crawlable on its
133
+ * workers.dev URL the moment `main` deployed, because only a non-production BRANCH was a
134
+ * preview. See `isPreviewBuild`. Flipping this to production is therefore what makes a site
135
+ * indexable, which is why /webm:launch does it only once the custom domain is live.
128
136
  */
129
137
  environment?: 'production' | 'staging';
130
138
 
@@ -344,3 +352,44 @@ export function isStagingDeployment(
344
352
  /* Match the label, not a substring: a client domain ending "notworkers.dev" is not a preview. */
345
353
  return Boolean(hostname && /(^|\.)workers\.dev$/i.test(hostname));
346
354
  }
355
+
356
+ /** Which signal made a build a preview, or null for production output. */
357
+ export type PreviewReason = 'staging' | 'branch' | null;
358
+
359
+ /**
360
+ * Why this build is a preview - noindex on every page, no canonical, no sitemap, robots.txt
361
+ * disallowing everything, no analytics - or null when it is production output.
362
+ *
363
+ * TWO INDEPENDENT SIGNALS, for the same reason `isStagingDeployment` has two.
364
+ *
365
+ * `environment` is what the deployment is FOR. A site that has not launched says `staging`, and
366
+ * a site that has not launched must not be indexable ANYWHERE: not on a feature branch, not on
367
+ * `main`, not from a laptop. Before this signal existed only a non-production branch was a
368
+ * preview, so `main` on Workers Builds was production output for every site that had not gone
369
+ * live yet - indexable pages with a canonical, a sitemap and `Allow: /` on a public workers.dev
370
+ * hostname. autire.webmonterey.workers.dev was crawlable that way.
371
+ *
372
+ * The branch covers the opposite case: webmonterey.json is committed, so a feature branch of a
373
+ * LAUNCHED site inherits `production` from main and would build indexable pages under a review
374
+ * URL. Workers Builds injects WORKERS_CI_BRANCH; anything other than the production branch is a
375
+ * preview whatever the config says.
376
+ *
377
+ * An unset environment is production, as it is everywhere else - see SiteConfig.environment for
378
+ * why that default is the safe one - so a site predating the field builds exactly as before.
379
+ * `branch` is null when nothing injected one, which is every laptop build.
380
+ */
381
+ export function previewReason(input: {
382
+ environment: SiteConfig['environment'] | undefined;
383
+ branch: string | null | undefined;
384
+ productionBranch?: string;
385
+ }): PreviewReason {
386
+ if (input.environment === 'staging') return 'staging';
387
+ const branch = input.branch ?? null;
388
+ if (branch !== null && branch !== (input.productionBranch ?? 'main')) return 'branch';
389
+ return null;
390
+ }
391
+
392
+ /** Whether this build is a preview. `previewReason` says which signal decided it. */
393
+ export function isPreviewBuild(input: Parameters<typeof previewReason>[0]): boolean {
394
+ return previewReason(input) !== null;
395
+ }
@@ -67,7 +67,10 @@ 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`. */
70
+ /**
71
+ * The /webmaster page. `intro` wraps the agency link: `before` <a>WebMonterey</a> `after`.
72
+ * `intro` and `body` take the inline prose subset - `**bold**`, `_italic_`, `[text](/url)`.
73
+ */
71
74
  webmaster: {
72
75
  title: string;
73
76
  description: string;
@@ -135,14 +138,15 @@ export const DEFAULT_COPY: Copy = {
135
138
  webmaster: {
136
139
  title: 'Our Webmaster',
137
140
  description:
138
- 'This website was designed, built and managed by WebMonterey, a webmaster maintenance service in Monterey, California.',
141
+ 'This custom website was designed, built and managed by WebMonterey, a webmaster service in Monterey, California.',
139
142
  intro: {
140
- before: 'This website was designed, built and managed by',
143
+ before: 'This custom website was designed, built and managed by',
141
144
  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.',
145
+ ', a webmaster service in Monterey, California. WebMonterey handles the hosting, security, strategy and ongoing care of the site so that we can focus on what we do.',
143
146
  },
147
+ /* Bold on purpose: the contact instruction is the paragraph a visitor with a problem needs. */
144
148
  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.",
149
+ "**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
150
  ],
147
151
  },
148
152
  };
@@ -2,7 +2,15 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { readFileSync } from 'node:fs';
4
4
 
5
- import { AGENCY, contentTag, CREDIT_TEXT, creditUrl, WEBMASTER_PATH } from './webmaster.ts';
5
+ import {
6
+ AGENCY,
7
+ contentTag,
8
+ CREDIT_TEXT,
9
+ creditUrl,
10
+ introHtml,
11
+ webmasterPageProps,
12
+ WEBMASTER_PATH,
13
+ } from './webmaster.ts';
6
14
 
7
15
  /*
8
16
  * Webmaster.astro read as SOURCE, because it cannot be imported here: an .astro file only
@@ -72,3 +80,55 @@ test('an internal link does not open a new tab and carries no rel', () => {
72
80
  assert.doesNotMatch(anchor(), /rel=/);
73
81
  assert.doesNotMatch(template, /opens in a new tab/);
74
82
  });
83
+
84
+ /* ── the intro paragraph, as one HTML string for a site that owns the layout ─────────────── */
85
+
86
+ test('introHtml is before, a space, the agency link, then after', () => {
87
+ const html = introHtml({ before: 'Built by', after: ', a service.' }, 'https://x.test/?a=1&b=2');
88
+ assert.equal(
89
+ html,
90
+ 'Built by <a href="https://x.test/?a=1&amp;b=2" target="_blank" rel="noopener">WebMonterey</a>, a service.',
91
+ );
92
+ });
93
+
94
+ test('introHtml escapes the copy and the href', () => {
95
+ const html = introHtml({ before: '<b>a & b</b>', after: '"q"' }, 'https://x.test/?"');
96
+ assert.ok(!html.includes('<b>'), 'copy markup must not pass through');
97
+ assert.ok(html.includes('&lt;b&gt;a &amp; b&lt;/b&gt;'));
98
+ assert.ok(html.includes('href="https://x.test/?&quot;"'));
99
+ assert.ok(html.endsWith('&quot;q&quot;'));
100
+ });
101
+
102
+ test('the copy carries the inline prose subset, in the intro and the body', () => {
103
+ const props = webmasterPageProps(
104
+ {
105
+ title: 'T',
106
+ description: 'D',
107
+ intro: { before: 'Built by', after: ', **really**.' },
108
+ body: ["**If it isn't working, say so.**", 'See [the policy](/privacy).'],
109
+ },
110
+ 'https://x.test/',
111
+ );
112
+ assert.equal(props.title, 'T');
113
+ assert.equal(props.description, 'D');
114
+ assert.ok(props.intro.endsWith('</a>, <strong>really</strong>.'));
115
+ assert.deepEqual(props.body, [
116
+ '<strong>If it isn&#39;t working, say so.</strong>',
117
+ 'See <a href="/privacy">the policy</a>.',
118
+ ]);
119
+ });
120
+
121
+ test('the built-in page renders the same intro string a site layout receives', () => {
122
+ /*
123
+ * One source for the agency link. The page used to build its own <a> in the template, which
124
+ * is how a second copy of the attributes would drift; now both layouts render introHtml.
125
+ */
126
+ const page = readFileSync(new URL('../../../pages/webmaster.astro', import.meta.url), 'utf8');
127
+ const template = page.slice(page.lastIndexOf('---'));
128
+ assert.match(template, /<p set:html=\{props\.intro\} \/>/);
129
+ assert.doesNotMatch(template, /<a\s/, 'the page must not assemble the agency link itself');
130
+ const html = introHtml({ before: '', after: '' }, 'https://x.test/');
131
+ assert.match(html, /target="_blank"/);
132
+ assert.match(html, /rel="noopener"/);
133
+ assert.doesNotMatch(html, /noreferrer/);
134
+ });
@@ -25,6 +25,8 @@
25
25
  * means a change to the name, the address or a profile is one edit.
26
26
  */
27
27
 
28
+ import { escapeHtml, renderInline } from '../prose/inline.ts';
29
+
28
30
  /** The credit wording. Rendered verbatim on the site and in email. */
29
31
  export const CREDIT_TEXT = 'Powered by WebMonterey';
30
32
 
@@ -44,7 +46,7 @@ export const AGENCY = {
44
46
  name: 'WebMonterey',
45
47
  url: 'https://webmonterey.com/',
46
48
  description:
47
- 'A webmaster maintenance service in Monterey, California: design, build, hosting, security and ongoing care for small-business websites.',
49
+ 'A webmaster service in Monterey, California: design, build, hosting, security and ongoing care for small-business websites.',
48
50
  address: { addressLocality: 'Monterey', addressRegion: 'CA', addressCountry: 'US' },
49
51
  sameAs: [
50
52
  /* The Google Business Profile, by its Knowledge Graph id - the stable form of the share link. */
@@ -87,3 +89,57 @@ export function creditUrl(domain: string, medium: CreditMedium = 'website'): str
87
89
 
88
90
  return `${AGENCY.url}?${params}`;
89
91
  }
92
+
93
+ /**
94
+ * What the site's `webmasterPage` component receives, when a site exports one from its
95
+ * registry: the merged copy, already resolved. The component lays it out and carries no copy of
96
+ * its own - the words are the package's on every site, the shape of the page is the client's.
97
+ *
98
+ * `intro` and `body` are HTML, rendered with `set:html`. They are the output of `renderInline`,
99
+ * so a paragraph in `copy.webmaster` may carry the same inline subset page prose does -
100
+ * `**bold**`, `_italic_`, `[text](/url)` - and everything else is escaped.
101
+ */
102
+ export interface WebmasterPageProps {
103
+ /** `copy.webmaster.title`, also the document title. Text. */
104
+ title: string;
105
+ /** `copy.webmaster.description`, also the meta description. Text. */
106
+ description: string;
107
+ /** The first paragraph's inner HTML, with the agency link already resolved. */
108
+ intro: string;
109
+ /** The remaining paragraphs' inner HTML, one entry per `<p>`. */
110
+ body: string[];
111
+ }
112
+
113
+ /**
114
+ * The intro paragraph's inner HTML: `before` <a>WebMonterey</a> `after`.
115
+ *
116
+ * ONE STRING, because it is the only part of the copy that is not plain text. A site taking
117
+ * over the page layout still gets the agency link exactly as the built-in page renders it - a
118
+ * followed link, opening in a new tab, `noopener` without `noreferrer` because the referrer is
119
+ * the attribution - rather than reassembling three fragments and forgetting one of the
120
+ * attributes. The built-in page renders this same string, so the two cannot drift. The space
121
+ * between `before` and the link is deliberate: Astro drops the whitespace between an expression
122
+ * and an element on separate lines, and "managed byWebMonterey" shipped.
123
+ */
124
+ export function introHtml(intro: { before: string; after: string }, href: string): string {
125
+ const link = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener">${escapeHtml(AGENCY.name)}</a>`;
126
+ return `${renderInline(intro.before)} ${link}${renderInline(intro.after)}`;
127
+ }
128
+
129
+ /** The whole prop set, from the merged copy and the attributed agency link. */
130
+ export function webmasterPageProps(
131
+ text: {
132
+ title: string;
133
+ description: string;
134
+ intro: { before: string; after: string };
135
+ body: string[];
136
+ },
137
+ href: string,
138
+ ): WebmasterPageProps {
139
+ return {
140
+ title: text.title,
141
+ description: text.description,
142
+ intro: introHtml(text.intro, href),
143
+ body: text.body.map(renderInline),
144
+ };
145
+ }