@cparkerwebm/webmonterey 1.3.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.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,53 @@ build. See `/webm:upgrade`.
11
11
 
12
12
  ---
13
13
 
14
+ ## 1.4.0 — 2026-09-04
15
+
16
+ ### Added
17
+
18
+ - **A site can lay out the `/webmaster` page itself.** Export `webmasterPage` from
19
+ `src/components/registry.ts` - an Astro component receiving
20
+ `{ title, description, intro, body }` - and the page renders it in place of the built-in
21
+ `<h1>` and stack of paragraphs. `intro` and each `body` entry are HTML, rendered with
22
+ `set:html` - the agency link is already in `intro`; `title` and `description` are text. The route, the copy, the document title, the meta
23
+ description, the share image and the agency JSON-LD stay the package's, so the page says the
24
+ same thing on every site and looks like the site it is on. Until now a site whose legal pages
25
+ used a richer document layout got a `/webmaster` that looked like a different site, and adding
26
+ its own `src/pages/webmaster.astro` collided with the injected route. The component carries no
27
+ copy of its own: the words are still `copy.webmaster` in `webmonterey.json`.
28
+
29
+ **`/webm:webmaster` is the skill**, materialized on the next `webm sync` - it carries a
30
+ complete reference component, the registry line and the checks to run on the built page, so a
31
+ site session has something to copy rather than a description. `/webm:new-component` sends a
32
+ session there the moment a site gains its document block, and `/webm:start` and
33
+ `/webm:launch` both point at it, so on a new site it happens in sequence; on an existing site
34
+ the upgrade lists it as a new skill.
35
+ The props are exported as `WebmasterPageProps` from
36
+ `@cparkerwebm/webmonterey/webmonterey/webmaster`.
37
+
38
+ **Nothing changes on a site that does not export it.** The built-in layout, `pageHeader`
39
+ seam included, renders as before, and examples/minimal asserts the whole `<main>` as a literal
40
+ so it cannot drift.
41
+
42
+ ### Changed
43
+
44
+ - **The `/webmaster` copy.** "This custom website was designed, built and managed by
45
+ WebMonterey, a webmaster service in Monterey, California. WebMonterey handles the hosting,
46
+ security, strategy and ongoing care of the site…" - _custom_ added, _maintenance_ dropped,
47
+ _updates_ is now _strategy_ - and the contact paragraph is bold. `copy.webmaster.intro` and
48
+ `copy.webmaster.body` now take the same inline subset as page prose (`**bold**`, `_italic_`,
49
+ `[text](/url)`), which is how the bold is expressed. A site overriding `copy.webmaster` in
50
+ `webmonterey.json` keeps its own words; one that does not gets these. The agency's
51
+ description in the page's JSON-LD drops _maintenance_ too.
52
+
53
+ ### Fixed
54
+
55
+ - **"managed byWebMonterey".** The built-in `/webmaster` intro had no space before the agency
56
+ link - Astro drops the whitespace between an expression and an element on separate lines. It
57
+ reads "managed by WebMonterey" now.
58
+
59
+ ---
60
+
14
61
  ## 1.3.0 — 2026-09-02
15
62
 
16
63
  ### Changed
package/README.md CHANGED
@@ -20,6 +20,16 @@ the reset, the Cloudflare includes, the consent system, the form pipeline, the r
20
20
  It ships **zero visible components**. Every block a visitor sees is built per client, in that
21
21
  client's repo. Every default has a documented way to opt out of it.
22
22
 
23
+ A site hands the package its components through `src/components/registry.ts`: `blocks` for the
24
+ block router, `header`, `footer` and `panels` for the chrome, `pageHeader` in place of the
25
+ router's plain `<h1>`, `structuredData` for the site's JSON-LD, and `webmasterPage` for the body
26
+ of the `/webmaster` page. That last one is the seam for a site whose document pages have a richer
27
+ layout than a heading and a stack of paragraphs: the component receives the merged copy
28
+ (`{ title, description, intro, body }`; `intro` and `body` are HTML, the agency link already in
29
+ `intro`) and lays it out; the route, the words, the `<head>`, the share image and the agency graph
30
+ stay the package's. It carries no copy of its own - the words are overridden through
31
+ `copy.webmaster` in `webmonterey.json`, not in the component.
32
+
23
33
  **The package is edited in this repo, and only here.** A session in a client site that finds a
24
34
  package bug does not reach into `node_modules` or into this checkout; its deliverable is a
25
35
  description of the fix, run later in a session opened here, and the site takes the result with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "The WebMonterey Astro framework: plumbing, design system, and Claude Code skills for client sites on Cloudflare Workers",
6
6
  "license": "MIT",
@@ -99,6 +99,11 @@ to say, and this is where it gets decided - once, with the client's real details
99
99
  4. **Validate** the built home page and one inner page with Google's Rich Results Test before
100
100
  moving on.
101
101
 
102
+ 5. **Look at `/webmaster`.** The package injects it on every site, with its own share image and
103
+ JSON-LD. If this site's document pages - privacy, terms - use a richer layout than an `<h1>`
104
+ and a stack of paragraphs, run `/webm:webmaster`: it exports a layout component from the
105
+ registry so the page looks like the site it is on, with the package's copy intact.
106
+
102
107
  ## 3. Turnstile
103
108
 
104
109
  If the site has a form: create the widget in the Cloudflare dashboard (**Turnstile → Add
@@ -83,3 +83,9 @@ npm run preview # NOT dev - preview is a real build on real workerd
83
83
 
84
84
  `dev` cannot detect CSS bundling, cascade order, or the asset router. Every trap worth catching is
85
85
  invisible in `dev` and visible in `preview`. See `/webm:traps`.
86
+
87
+ ## If this is the site's document block
88
+
89
+ A long-form block - the one the privacy and terms pages use - is also the layout the package's
90
+ `/webmaster` page should have. The moment that block exists, run `/webm:webmaster`: it exports a
91
+ layout component built on this block, so `/webmaster` stops looking like a different site.
@@ -173,5 +173,6 @@ client can click anything. **Every build of this site is a preview while `enviro
173
173
  flips it, and that flip is what makes the site indexable. On a launched site the production
174
174
  branch is `main`; anything else previews.
175
175
 
176
- Next: `/webm:new-component` for each block, then `/webm:launch` when the site is
176
+ Next: `/webm:new-component` for each block, `/webm:webmaster` once the site has its document
177
+ block so the package's `/webmaster` page shares its layout, then `/webm:launch` when the site is
177
178
  content-complete and approved on a preview.
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: webmaster
3
+ description: Make the package's /webmaster page look like this site's other document pages by exporting a `webmasterPage` layout component from the registry. Use for "the webmaster page looks different", "style /webmaster like /privacy", "webmaster page layout", "webmasterPage".
4
+ ---
5
+
6
+ # The `/webmaster` page
7
+
8
+ **The package owns the page; this site may own its shape.** `/webmaster` is injected on every
9
+ site: who built it, who to call. The route, the words, the document title, the meta description,
10
+ the share image and the agency JSON-LD are the package's, so the page says the same thing on
11
+ every site. Without help it renders as an `<h1>` (or this site's `pageHeader`) and a stack of
12
+ paragraphs - which looks like a different site the moment the site's own document pages have a
13
+ richer layout.
14
+
15
+ **Do not add `src/pages/webmaster.astro`.** It collides with the injected route, and the injected
16
+ route wins. The seam is a registry export.
17
+
18
+ ## What the component receives
19
+
20
+ ```ts
21
+ interface WebmasterPageProps {
22
+ title: string; // text - also the document title
23
+ description: string; // text - also the meta description
24
+ intro: string; // HTML - the first paragraph, agency link already in it
25
+ body: string[]; // HTML - one entry per remaining paragraph
26
+ }
27
+ ```
28
+
29
+ `intro` and `body` are already HTML: the agency link with its UTM parameters, and the copy's own
30
+ inline formatting (`**bold**`, `_italic_`, `[text](/url)`) rendered and escaped. Render them with
31
+ `set:html`. `title` and `description` are text.
32
+
33
+ **The component carries no copy of its own.** Not a heading, not a caption, not a sentence. The
34
+ words are the same on every site; when a client wants them changed, that is `copy.webmaster` in
35
+ `webmonterey.json` (`title`, `description`, `intro.before`, `intro.after`, `body[]`), never the
36
+ component.
37
+
38
+ ## 1. Find this site's document layout
39
+
40
+ Look at how the privacy or terms page is rendered - usually one long-form block under
41
+ `src/components/content/`. The webmaster page should reuse **that block's markup and classes**,
42
+ so a change to the document style reaches both.
43
+
44
+ ## 2. Write the component
45
+
46
+ `src/components/general/webmaster-page.astro`. The reference below is complete and works on any
47
+ site; replace the element names and classes with the document block's own.
48
+
49
+ ```astro
50
+ ---
51
+ /*
52
+ * The /webmaster page body. The package hands in the copy; this lays it out like the site's
53
+ * other document pages. See /webm:webmaster.
54
+ */
55
+ import type { WebmasterPageProps } from '@cparkerwebm/webmonterey/webmonterey/webmaster';
56
+
57
+ type Props = WebmasterPageProps;
58
+
59
+ const { title, intro, body } = Astro.props;
60
+ ---
61
+
62
+ <section class="webm-section" data-space="lg">
63
+ <div class="webm-container" data-width="text">
64
+ <article class="doc">
65
+ <header class="doc__head">
66
+ <h1 class="doc__title">{title}</h1>
67
+ </header>
68
+ <div class="doc__panel">
69
+ <p set:html={intro} />
70
+ {body.map((paragraph) => <p set:html={paragraph} />)}
71
+ </div>
72
+ </article>
73
+ </div>
74
+ </section>
75
+ ```
76
+
77
+ If the document block is itself a component that takes `title` and a slot or `html` prop, render
78
+ it directly rather than copying its markup:
79
+
80
+ ```astro
81
+ ---
82
+ import Doc from '../content/content-000001/content-000001.astro';
83
+ const { title, intro, body } = Astro.props;
84
+ ---
85
+
86
+ <Doc title={title}>
87
+ <p set:html={intro} />
88
+ {body.map((paragraph) => <p set:html={paragraph} />)}
89
+ </Doc>
90
+ ```
91
+
92
+ ## 3. Export it
93
+
94
+ ```ts
95
+ // src/components/registry.ts
96
+ export { default as webmasterPage } from './general/webmaster-page.astro';
97
+ ```
98
+
99
+ Not an entry in `blocks` - it is not addressable from page JSON. A named export beside
100
+ `pageHeader` and `structuredData`.
101
+
102
+ ## 4. Verify
103
+
104
+ ```sh
105
+ npm run build
106
+ ```
107
+
108
+ Then read `dist/client/webmaster/index.html`:
109
+
110
+ - the body is the new markup, and there is no `webm-stack` div left from the built-in layout
111
+ - the `<h1>` is the package's title (`Our Webmaster` unless `copy.webmaster.title` is set)
112
+ - the first paragraph has one `<a href="https://webmonterey.com/?utm_source=client…"
113
+ target="_blank" rel="noopener">` and nothing else links off-site
114
+ - `<title>`, `meta name="description"`, `og:image` (`/webmaster/og.png`) and the
115
+ `application/ld+json` block are present and unchanged from before the export
116
+
117
+ Then push and compare `/webmaster` against `/privacy` on the preview URL: same heading style,
118
+ same panel, the package's words.
@@ -435,6 +435,27 @@ test('a registry keyed by name rather than by number is understood', () => {
435
435
  assert.equal(runCheck('block-types-registered', ctx).status, 'pass');
436
436
  });
437
437
 
438
+ test('the chrome exports in a registry are not read as block types', () => {
439
+ /*
440
+ * `header`, `footer`, `pageHeader`, `structuredData` and `webmasterPage` sit in the same file
441
+ * as `blocks`, in either export form. None is a block type, and none may make the check trip
442
+ * over a registry that is otherwise complete.
443
+ */
444
+ const ctx = base({
445
+ registry: [
446
+ `import WebmasterPage from './general/webmaster-page.astro';`,
447
+ `export const blocks = { 'content-000001': C };`,
448
+ `export const registeredTypes = () => Object.keys(blocks);`,
449
+ `export { default as pageHeader } from './general/page-header.astro';`,
450
+ `export const webmasterPage = WebmasterPage;`,
451
+ ].join('\n'),
452
+ content: new Map([
453
+ ['src/content/pages/home.json', JSON.stringify({ blocks: [{ type: 'content-000001' }] })],
454
+ ]),
455
+ });
456
+ assert.equal(runCheck('block-types-registered', ctx).status, 'pass');
457
+ });
458
+
438
459
  test('a site that has not launched only gets a warning about placeholders', () => {
439
460
  /*
440
461
  * `webm new` seeds every one of these, so a freshly scaffolded site has the full set. The
@@ -44,6 +44,7 @@ test('the package ships exactly the skills the release claims', () => {
44
44
  'start',
45
45
  'traps',
46
46
  'upgrade',
47
+ 'webmaster',
47
48
  ]);
48
49
  });
49
50
 
@@ -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
+ }
@@ -387,6 +387,15 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
387
387
  * router's plain <h1>; a site that does not keeps the <h1>.
388
388
  */
389
389
  `export const pageHeader = mod.pageHeader ?? null;`,
390
+ /*
391
+ * THE /webmaster PAGE'S BODY. The route, the copy, the share image and
392
+ * the agency graph stay the package's; a site whose document pages use
393
+ * a richer layout than an <h1> and a stack of paragraphs hands over the
394
+ * layout only. Without this a site's own src/pages/webmaster.astro
395
+ * collides with the injected route, and on one site the injected route
396
+ * won.
397
+ */
398
+ `export const webmasterPage = mod.webmasterPage ?? null;`,
390
399
  /*
391
400
  * THE SITE'S JSON-LD, rendered into <head> on every route. The package
392
401
  * emits none of its own: what a business claims about itself is the
@@ -86,6 +86,17 @@ declare module 'virtual:webm/registry' {
86
86
  * every later fix to block lookup, unknown-type reporting and FAQ extraction.
87
87
  */
88
88
  export const pageHeader: AstroComponentFactory | null;
89
+ /**
90
+ * The body of the `/webmaster` page, when the site wants it laid out like its own document
91
+ * pages rather than as the package's `<h1>` and stack of paragraphs.
92
+ *
93
+ * Receives `WebmasterPageProps` from `includes/webmonterey/webmaster/webmaster.ts`:
94
+ * `{ title, description, intro, body }`, the merged `copy.webmaster` - `intro` and `body` as
95
+ * HTML through the inline prose renderer, the agency link already in `intro`. The component
96
+ * lays the words out and carries no copy of its own. The route, the `<head>`, the share image and the JSON-LD stay the package's.
97
+ * Null means the built-in layout, `pageHeader` seam included.
98
+ */
99
+ export const webmasterPage: AstroComponentFactory | null;
89
100
  /**
90
101
  * The site's JSON-LD, rendered into <head> on every indexable route. The package emits none of
91
102
  * its own; this component composes a graph from the builders in
@@ -27,17 +27,25 @@ import { copy } from '../includes/webmonterey/copy.ts';
27
27
  import {
28
28
  AGENCY,
29
29
  creditUrl,
30
+ webmasterPageProps,
30
31
  WEBMASTER_OG_PATH,
31
32
  } from '../includes/webmonterey/webmaster/webmaster.ts';
32
33
  import { domain, hasDomain } from '../includes/webmonterey/site.ts';
33
34
  import { compact, renderJsonLd } from '../includes/webmonterey/structured-data/nodes.ts';
34
- import { pageHeader } from 'virtual:webm/registry';
35
+ import { pageHeader, webmasterPage } from 'virtual:webm/registry';
35
36
  import ogImage from 'virtual:webm/webmaster-og';
36
37
  import build from 'virtual:webm/build';
37
38
 
38
39
  const PageHeader = pageHeader;
40
+ const WebmasterPage = webmasterPage;
39
41
  const text = copy.webmaster;
40
42
  const agencyHref = hasDomain ? creditUrl(domain, 'website') : AGENCY.url;
43
+ /*
44
+ * The words, resolved once for both layouts: the intro with the agency link in it, every
45
+ * paragraph through the inline prose renderer, so `**bold**` in copy.webmaster works here
46
+ * exactly as it does in page JSON.
47
+ */
48
+ const props = webmasterPageProps(text, agencyHref);
41
49
 
42
50
  /*
43
51
  * The graph needs a real origin for stable @id values, which is the same gate the canonical tag
@@ -85,37 +93,51 @@ if (Astro.site && !build.preview) {
85
93
 
86
94
  {
87
95
  /*
88
- * The site's page header if it declares one, so this page looks like every other page on the
89
- * site; the router's plain <h1> otherwise. Same fallback the block router uses.
96
+ * THE BODY IS THE SITE'S IF IT WANTS IT. A site whose document pages - privacy, terms - use a
97
+ * richer layout than an <h1> and a stack of paragraphs exports `webmasterPage` from its
98
+ * registry and gets the merged copy handed to it: the same title, description and paragraphs
99
+ * as below, with the agency link already resolved into `intro`. Everything above this
100
+ * expression - the route, the <head>, the share image, the agency graph - stays the
101
+ * package's, so the page reads the same on every site and looks like the one it is on.
90
102
  */
91
- PageHeader ? (
92
- <PageHeader title={text.title} description={text.description} />
103
+ WebmasterPage ? (
104
+ <WebmasterPage {...props} />
93
105
  ) : (
94
- <section class="webm-section" data-space="sm">
95
- <div class="webm-container" data-width="text">
96
- <h1>{text.title}</h1>
97
- </div>
98
- </section>
99
- )
100
- }
101
-
102
- <section class="webm-section" data-space="lg">
103
- <div class="webm-container" data-width="text">
104
- <div class="webm-stack">
106
+ <Fragment>
105
107
  {
106
108
  /*
107
- * The agency is named as a link in the first paragraph - the one outbound link on the
108
- * site, followed, with the UTM parameters. Opens in a new tab because it leaves the
109
- * site; noopener without noreferrer, because the referrer is the attribution.
109
+ * The site's page header if it declares one, so this page looks like every other page
110
+ * on the site; the router's plain <h1> otherwise. Same fallback the block router uses.
110
111
  */
112
+ PageHeader ? (
113
+ <PageHeader title={text.title} description={text.description} />
114
+ ) : (
115
+ <section class="webm-section" data-space="sm">
116
+ <div class="webm-container" data-width="text">
117
+ <h1>{text.title}</h1>
118
+ </div>
119
+ </section>
120
+ )
111
121
  }
112
- <p>
113
- {text.intro.before}
114
- <a href={agencyHref} target="_blank" rel="noopener">{AGENCY.name}</a>
115
- {text.intro.after}
116
- </p>
117
- {text.body.map((paragraph) => <p>{paragraph}</p>)}
118
- </div>
119
- </div>
120
- </section>
122
+
123
+ <section class="webm-section" data-space="lg">
124
+ <div class="webm-container" data-width="text">
125
+ <div class="webm-stack">
126
+ {/*
127
+ * The agency is named as a link in the first paragraph - the one outbound link
128
+ * on the site, followed, with the UTM parameters. Opens in a new tab because it
129
+ * leaves the site; noopener without noreferrer, because the referrer is the
130
+ * attribution. See introHtml in webmaster.ts - the same string a site's own
131
+ * layout receives, so the two cannot disagree about the link.
132
+ */}
133
+ <p set:html={props.intro} />
134
+ {props.body.map((paragraph) => (
135
+ <p set:html={paragraph} />
136
+ ))}
137
+ </div>
138
+ </div>
139
+ </section>
140
+ </Fragment>
141
+ )
142
+ }
121
143
  </Base>
@@ -231,7 +231,10 @@ webmonterey.json domain, client name, environment, features.
231
231
  src/
232
232
  components/ EVERY visible component. The package ships none.
233
233
  registry.ts maps a block `type` to its component. A block whose type is
234
- not here renders as nothing, silently.
234
+ not here renders as nothing, silently. Also exports the chrome:
235
+ `header`, `footer`, `panels`, `pageHeader`, `structuredData`, and
236
+ `webmasterPage` - the body of /webmaster, laid out like this
237
+ site's document pages, with the package's copy handed in.
235
238
  content/pages/*.json the words. One file per route; home.json is `/`.
236
239
  forms/*.json form definitions. Filename is the form id.
237
240
  actions/index.ts re-exports the package pipeline. Wrap to customize.
@@ -264,6 +267,7 @@ replaced wholesale. This client's own skills go beside it at `.claude/skills/<na
264
267
  | `/webm:start` | standing a new site up: repo, Cloudflare resources, first deploy |
265
268
  | `/webm:launch` | launch checklist: structured data, sending domain, secrets, DNS, cutover |
266
269
  | `/webm:upgrade` | taking a new package version |
270
+ | `/webm:webmaster` | laying out the package's `/webmaster` page like this site's own pages |
267
271
 
268
272
  Content edits: branch, edit `src/content/pages/*.json`, `npm run check`, push — the branch
269
273
  preview is the client's review link. Merge when approved. Never change a block's `type`.