@cparkerwebm/webmonterey 1.4.0 → 1.5.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,69 @@ build. See `/webm:upgrade`.
11
11
 
12
12
  ---
13
13
 
14
+ ## 1.5.0 — 2026-09-06
15
+
16
+ ### Added
17
+
18
+ - **The `/webmaster` page names the client.** Every string in `copy.webmaster` may carry
19
+ `{client}`, filled from `client` in `webmonterey.json` when the page renders, and the default
20
+ copy now does: the meta description, the first paragraph ("This Acme Co custom website was
21
+ designed, built and managed by WebMonterey…") and the contact line ("If you have a question
22
+ about the Acme Co website…"). Until now every site in the fleet carried the same paragraph
23
+ word for word, which is duplicate content across a hundred domains; a page that says whose
24
+ site it is is not. A site overriding `copy.webmaster` keeps its own words and may use
25
+ `{client}` in them. A site with no client name yet gets the sentence without the name.
26
+
27
+ - **A "Visit WebMonterey" button on `/webmaster`.** Outline style, in the site's link colour,
28
+ radius and border width, below the paragraphs, to the same attributed agency URL the first
29
+ paragraph links to. The label is `copy.webmaster.cta`. For a site that lays the page out
30
+ itself, `WebmasterPageProps` gains `cta: { label, href }`, and `AGENCY_LINK_ATTRS` is
31
+ exported beside it so the button carries the same `target` and `rel` as the intro link
32
+ without either being typed twice. **A site that already exports `webmasterPage` does not
33
+ show the button until its component renders `cta`** - `/webm:webmaster` has the updated
34
+ reference component, one line to add.
35
+
36
+ - **`webm clean`.** Removes `node_modules/.vite`, `.astro` and `dist` - the three
37
+ directories a stale dev server is made of - and nothing else. `--dry-run` lists them. The
38
+ reset that used to be "delete some caches and restart" is one command a session in a client
39
+ repo can run without knowing what Vite is. `/webm:traps` names it.
40
+
41
+ - **The Vite pre-bundle is rebuilt on every `astro dev` start.** `optimizeDeps.force` in dev
42
+ only. A cache in `node_modules/.vite` that outlived a package update, a branch switch or an
43
+ `astro check` was the dev server 500ing on every request until someone cleared it; a few
44
+ seconds at startup buys not having that session. A cache that changes underneath a running
45
+ server is still `webm clean`.
46
+
47
+ ### Changed
48
+
49
+ - **The `/webmaster` copy, again.** "WebMonterey handles web hosting, security, strategy and
50
+ ongoing care of our site." - _web hosting_, _our site_, and the clause about focusing on what
51
+ we do is gone. The contact line reads "about the {client} website" rather than "about this
52
+ website". A site overriding `copy.webmaster` is unaffected.
53
+
54
+ - **`@astrojs/cloudflare` floor is `^14.3.0`.** It fixes a cold `astro dev` crash by
55
+ pre-bundling two of its own entrypoints, and a custom Worker entrypoint - which
56
+ `defineWorker` builds - now falls back to static assets when no route matches. The package's
57
+ own handler import is unchanged and the scheduled-handler scenario in the e2e still passes.
58
+ The package is tested on Astro 7.3.1; the peer range is still `^7.0.0` and 7.3 asks nothing
59
+ of a site. TypeScript stays on 6: TypeScript 7.0 is out, but `@astrojs/check` declares
60
+ TypeScript 5 or 6, and Astro's language tooling needs 6 until 7.1 ships a stable API.
61
+
62
+ ### Fixed
63
+
64
+ - **Astro no longer warns that `/404` is defined twice.** A site with its own
65
+ `src/pages/404.astro` got two `[router]` warnings on every build - "A static route cannot be
66
+ defined more than once … will result in a hard error in following versions of Astro" - because
67
+ the package injected its 404 regardless and relied on the site's file winning. It now looks for
68
+ the site's own 404 first, in every form Astro treats as a route (`.astro`, `.md`, `.mdx`,
69
+ `.html`, `.ts`, `.js`), and injects its page only when there is none. A site with no 404 of
70
+ its own sees no change.
71
+
72
+ Take it with `npx webm upgrade`. There is no codemod; the one thing to do by hand is the
73
+ `cta` line in a site that exports `webmasterPage`.
74
+
75
+ ---
76
+
14
77
  ## 1.4.0 — 2026-09-04
15
78
 
16
79
  ### Added
package/README.md CHANGED
@@ -25,8 +25,8 @@ block router, `header`, `footer` and `panels` for the chrome, `pageHeader` in pl
25
25
  router's plain `<h1>`, `structuredData` for the site's JSON-LD, and `webmasterPage` for the body
26
26
  of the `/webmaster` page. That last one is the seam for a site whose document pages have a richer
27
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
28
+ (`{ title, description, intro, body, cta }`; `intro` and `body` are HTML, the agency link already
29
+ in `intro`, the client's name already in the words, `cta` the outbound button) and lays it out; the route, the words, the `<head>`, the share image and the agency graph
30
30
  stay the package's. It carries no copy of its own - the words are overridden through
31
31
  `copy.webmaster` in `webmonterey.json`, not in the component.
32
32
 
package/dist/webm.mjs CHANGED
@@ -68,8 +68,8 @@ function normalizeDomain(input) {
68
68
  return cleaned;
69
69
  }
70
70
  function slugFor(domain) {
71
- const clean = normalizeDomain(domain);
72
- const parts = clean.split(".");
71
+ const clean2 = normalizeDomain(domain);
72
+ const parts = clean2.split(".");
73
73
  const lastTwo = parts.slice(-2).join(".");
74
74
  const drop = TWO_PART_SUFFIXES.has(lastTwo) ? 2 : 1;
75
75
  const kept = parts.slice(0, Math.max(1, parts.length - drop));
@@ -2695,11 +2695,53 @@ var init_upgrade = __esm({
2695
2695
  }
2696
2696
  });
2697
2697
 
2698
+ // src/cli/clean.ts
2699
+ var clean_exports = {};
2700
+ __export(clean_exports, {
2701
+ CLEAN_TARGETS: () => CLEAN_TARGETS,
2702
+ clean: () => clean,
2703
+ cleanTargets: () => cleanTargets,
2704
+ run: () => run8
2705
+ });
2706
+ import { existsSync as existsSync11, rmSync as rmSync2 } from "node:fs";
2707
+ import { join as join10, resolve as resolve4 } from "node:path";
2708
+ function cleanTargets(root) {
2709
+ return CLEAN_TARGETS.filter((t) => existsSync11(join10(root, t)));
2710
+ }
2711
+ function clean(root) {
2712
+ const present = cleanTargets(root);
2713
+ for (const t of present) rmSync2(join10(root, t), { recursive: true, force: true });
2714
+ return present;
2715
+ }
2716
+ async function run8(argv2) {
2717
+ const dryRun = argv2.includes("--dry-run");
2718
+ const root = resolve4(process.cwd());
2719
+ if (!existsSync11(join10(root, "astro.config.mjs"))) {
2720
+ console.error("webm clean: no astro.config.mjs here - run it from the site root.");
2721
+ return 1;
2722
+ }
2723
+ const targets = dryRun ? cleanTargets(root) : clean(root);
2724
+ if (targets.length === 0) {
2725
+ console.log("Nothing to clean.");
2726
+ return 0;
2727
+ }
2728
+ for (const t of targets) console.log(`${dryRun ? "would remove" : "removed"} ${t}/`);
2729
+ if (!dryRun) console.log("\nNext `npm run dev` or `npm run build` regenerates all of it.");
2730
+ return 0;
2731
+ }
2732
+ var CLEAN_TARGETS;
2733
+ var init_clean = __esm({
2734
+ "src/cli/clean.ts"() {
2735
+ "use strict";
2736
+ CLEAN_TARGETS = ["node_modules/.vite", ".astro", "dist"];
2737
+ }
2738
+ });
2739
+
2698
2740
  // bin/webm.mjs
2699
2741
  init_package_root();
2700
2742
  import { readFileSync as readFileSync10 } from "node:fs";
2701
- import { join as join10 } from "node:path";
2702
- var pkg = JSON.parse(readFileSync10(join10(PACKAGE_ROOT, "package.json"), "utf8"));
2743
+ import { join as join11 } from "node:path";
2744
+ var pkg = JSON.parse(readFileSync10(join11(PACKAGE_ROOT, "package.json"), "utf8"));
2703
2745
  var COMMANDS = {
2704
2746
  new: {
2705
2747
  blurb: "Scaffold a new client site from a domain",
@@ -2728,6 +2770,10 @@ var COMMANDS = {
2728
2770
  upgrade: {
2729
2771
  blurb: "Move this site to a newer framework version",
2730
2772
  load: () => Promise.resolve().then(() => (init_upgrade(), upgrade_exports))
2773
+ },
2774
+ clean: {
2775
+ blurb: "Remove node_modules/.vite, .astro and dist - the reset for a stale dev server",
2776
+ load: () => Promise.resolve().then(() => (init_clean(), clean_exports))
2731
2777
  }
2732
2778
  };
2733
2779
  function usage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cparkerwebm/webmonterey",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",
@@ -22,14 +22,14 @@
22
22
  },
23
23
  "//dependencies": "The integration wires these itself, so a client site installs only astro and this package.",
24
24
  "dependencies": {
25
- "@astrojs/cloudflare": "^14.1.7",
25
+ "@astrojs/cloudflare": "^14.3.0",
26
26
  "@astrojs/sitemap": "^3.7.3"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@astrojs/check": "^0.9.10",
30
- "@cloudflare/workers-types": "^5.20260826.1",
30
+ "@cloudflare/workers-types": "^5.20260906.1",
31
31
  "@types/node": "^26.1.2",
32
- "astro": "^7.1.6",
32
+ "astro": "^7.3.1",
33
33
  "esbuild": "^0.28.2",
34
34
  "prettier": "^3.9.6",
35
35
  "prettier-plugin-astro": "^0.14.1",
@@ -165,6 +165,13 @@ either direction, and nothing warns you. Code moves on push; schema moves via
165
165
  **Astro's dev server daemonizes.** It detaches and the launching command exits, which reads as a
166
166
  crash. Manage it with `astro dev status` / `logs` / `stop`.
167
167
 
168
+ **A dev server that 500s on every request, or serves last week's code, has a stale cache, not a
169
+ bug in the site.** Vite's pre-bundle in `node_modules/.vite` and Astro's cache in `.astro` both
170
+ outlive what they were built from. `astro dev stop && npx webm clean && npm run dev` is the
171
+ reset; it removes those two and `dist`, all regenerated by the next start. The package already
172
+ rebuilds the pre-bundle on every dev start, so the remaining case is a cache that changed
173
+ underneath a running server.
174
+
168
175
  ---
169
176
 
170
177
  ## TypeScript and APIs
@@ -23,12 +23,18 @@ interface WebmasterPageProps {
23
23
  description: string; // text - also the meta description
24
24
  intro: string; // HTML - the first paragraph, agency link already in it
25
25
  body: string[]; // HTML - one entry per remaining paragraph
26
+ cta: { label: string; href: string }; // the outbound button: text, and the attributed URL
26
27
  }
27
28
  ```
28
29
 
29
30
  `intro` and `body` are already HTML: the agency link with its UTM parameters, and the copy's own
30
31
  inline formatting (`**bold**`, `_italic_`, `[text](/url)`) rendered and escaped. Render them with
31
- `set:html`. `title` and `description` are text.
32
+ `set:html`. `title` and `description` are text. The client's name is already filled in wherever
33
+ the copy says `{client}`.
34
+
35
+ `cta` is the "Visit WebMonterey" button. Render it as a link in the site's outline button style,
36
+ with `AGENCY_LINK_ATTRS` (from the same import) spread on: it leaves the site, and those are the
37
+ attributes the intro link carries.
32
38
 
33
39
  **The component carries no copy of its own.** Not a heading, not a caption, not a sentence. The
34
40
  words are the same on every site; when a client wants them changed, that is `copy.webmaster` in
@@ -52,11 +58,14 @@ site; replace the element names and classes with the document block's own.
52
58
  * The /webmaster page body. The package hands in the copy; this lays it out like the site's
53
59
  * other document pages. See /webm:webmaster.
54
60
  */
55
- import type { WebmasterPageProps } from '@cparkerwebm/webmonterey/webmonterey/webmaster';
61
+ import {
62
+ AGENCY_LINK_ATTRS,
63
+ type WebmasterPageProps,
64
+ } from '@cparkerwebm/webmonterey/webmonterey/webmaster';
56
65
 
57
66
  type Props = WebmasterPageProps;
58
67
 
59
- const { title, intro, body } = Astro.props;
68
+ const { title, intro, body, cta } = Astro.props;
60
69
  ---
61
70
 
62
71
  <section class="webm-section" data-space="lg">
@@ -68,24 +77,32 @@ const { title, intro, body } = Astro.props;
68
77
  <div class="doc__panel">
69
78
  <p set:html={intro} />
70
79
  {body.map((paragraph) => <p set:html={paragraph} />)}
80
+ <p>
81
+ <a class="button button--outline" href={cta.href} {...AGENCY_LINK_ATTRS}>{cta.label}</a>
82
+ </p>
71
83
  </div>
72
84
  </article>
73
85
  </div>
74
86
  </section>
75
87
  ```
76
88
 
89
+ `button button--outline` stands for whatever this site's outline button class is - the one its
90
+ own blocks use.
91
+
77
92
  If the document block is itself a component that takes `title` and a slot or `html` prop, render
78
93
  it directly rather than copying its markup:
79
94
 
80
95
  ```astro
81
96
  ---
97
+ import { AGENCY_LINK_ATTRS } from '@cparkerwebm/webmonterey/webmonterey/webmaster';
82
98
  import Doc from '../content/content-000001/content-000001.astro';
83
- const { title, intro, body } = Astro.props;
99
+ const { title, intro, body, cta } = Astro.props;
84
100
  ---
85
101
 
86
102
  <Doc title={title}>
87
103
  <p set:html={intro} />
88
104
  {body.map((paragraph) => <p set:html={paragraph} />)}
105
+ <p><a class="button button--outline" href={cta.href} {...AGENCY_LINK_ATTRS}>{cta.label}</a></p>
89
106
  </Doc>
90
107
  ```
91
108
 
@@ -109,8 +126,10 @@ Then read `dist/client/webmaster/index.html`:
109
126
 
110
127
  - the body is the new markup, and there is no `webm-stack` div left from the built-in layout
111
128
  - 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
129
+ - the first paragraph names this client and has one
130
+ `<a href="https://webmonterey.com/?utm_source=client…" target="_blank" rel="noopener">`; the
131
+ "Visit WebMonterey" button is the only other off-site link, to the same URL with the same
132
+ attributes
114
133
  - `<title>`, `meta name="description"`, `og:image` (`/webmaster/og.png`) and the
115
134
  `application/ld+json` block are present and unchanged from before the export
116
135
 
@@ -0,0 +1,48 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { CLEAN_TARGETS, clean, cleanTargets } from './clean.ts';
7
+
8
+ const site = (dirs: string[]) => {
9
+ const root = mkdtempSync(join(tmpdir(), 'webm-clean-'));
10
+ for (const d of dirs) {
11
+ mkdirSync(join(root, d), { recursive: true });
12
+ writeFileSync(join(root, d, 'x'), '');
13
+ }
14
+ return root;
15
+ };
16
+
17
+ test('clean removes only the generated directories and reports them in order', () => {
18
+ const root = site(['dist', '.astro', 'node_modules/.vite', 'node_modules/astro', 'src/pages']);
19
+ try {
20
+ assert.deepEqual(clean(root), [...CLEAN_TARGETS]);
21
+ for (const t of CLEAN_TARGETS) assert.equal(existsSync(join(root, t)), false, t);
22
+ assert.equal(existsSync(join(root, 'node_modules/astro/x')), true, 'installed packages stay');
23
+ assert.equal(existsSync(join(root, 'src/pages/x')), true, 'source stays');
24
+ } finally {
25
+ rmSync(root, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ test('clean is idempotent: the second run finds nothing', () => {
30
+ const root = site(['dist', '.astro']);
31
+ try {
32
+ assert.deepEqual(clean(root), ['.astro', 'dist']);
33
+ assert.deepEqual(clean(root), []);
34
+ assert.deepEqual(cleanTargets(root), []);
35
+ } finally {
36
+ rmSync(root, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ test('cleanTargets reports without removing', () => {
41
+ const root = site(['dist']);
42
+ try {
43
+ assert.deepEqual(cleanTargets(root), ['dist']);
44
+ assert.equal(existsSync(join(root, 'dist/x')), true);
45
+ } finally {
46
+ rmSync(root, { recursive: true, force: true });
47
+ }
48
+ });
@@ -0,0 +1,62 @@
1
+ /*
2
+ * `webm clean` - throw away everything a build or a dev server can regenerate.
3
+ *
4
+ * WHY A COMMAND. A dev session that has gone stale - Vite serving a pre-bundle whose hash no
5
+ * longer matches, a content-layer cache that predates a schema edit, a dist/ from a branch that
6
+ * is no longer checked out - is fixed by deleting three directories, and knowing which three is
7
+ * the whole trick. Written down in a skill it is prose; here it is one word, and a session in a
8
+ * client repo can run it without knowing what Vite is.
9
+ *
10
+ * Nothing here is the site's. Every path is generated, gitignored, and rebuilt by the next
11
+ * `astro dev` or `astro build`. The three are:
12
+ *
13
+ * node_modules/.vite Vite's dependency pre-bundle. Astro dev reads it on every request and
14
+ * 500s on all of them once it is stale - see optimizeDeps in the
15
+ * integration for the mechanism.
16
+ * .astro Astro's own cache: content-layer data, generated types, the dev server's
17
+ * lock and state files. `astro dev --force` clears part of this; removing
18
+ * the directory clears all of it.
19
+ * dist The last build. Kept out of the way so `webm compare` and `astro
20
+ * preview` cannot answer from an older branch's output.
21
+ *
22
+ * `--dry-run` lists what would go and touches nothing.
23
+ */
24
+ import { existsSync, rmSync } from 'node:fs';
25
+ import { join, resolve } from 'node:path';
26
+
27
+ /** What a clean removes, relative to the site root. Order is the order they are reported. */
28
+ export const CLEAN_TARGETS = ['node_modules/.vite', '.astro', 'dist'] as const;
29
+
30
+ /** The targets that exist under `root` right now, as relative paths. */
31
+ export function cleanTargets(root: string): string[] {
32
+ return CLEAN_TARGETS.filter((t) => existsSync(join(root, t)));
33
+ }
34
+
35
+ /**
36
+ * Remove every target that exists and return what was removed. Idempotent: a second run finds
37
+ * nothing and removes nothing, which is what a repeatable reset should do.
38
+ */
39
+ export function clean(root: string): string[] {
40
+ const present = cleanTargets(root);
41
+ for (const t of present) rmSync(join(root, t), { recursive: true, force: true });
42
+ return present;
43
+ }
44
+
45
+ export async function run(argv: string[]): Promise<number> {
46
+ const dryRun = argv.includes('--dry-run');
47
+ const root = resolve(process.cwd());
48
+
49
+ if (!existsSync(join(root, 'astro.config.mjs'))) {
50
+ console.error('webm clean: no astro.config.mjs here - run it from the site root.');
51
+ return 1;
52
+ }
53
+
54
+ const targets = dryRun ? cleanTargets(root) : clean(root);
55
+ if (targets.length === 0) {
56
+ console.log('Nothing to clean.');
57
+ return 0;
58
+ }
59
+ for (const t of targets) console.log(`${dryRun ? 'would remove' : 'removed'} ${t}/`);
60
+ if (!dryRun) console.log('\nNext `npm run dev` or `npm run build` regenerates all of it.');
61
+ return 0;
62
+ }
@@ -71,11 +71,18 @@ export interface Copy {
71
71
  * The /webmaster page. `intro` wraps the agency link: `before` <a>WebMonterey</a> `after`.
72
72
  * `intro` and `body` take the inline prose subset - `**bold**`, `_italic_`, `[text](/url)`.
73
73
  */
74
+ /*
75
+ * Every string here may carry `{client}`, filled with the site's client name at render. It is
76
+ * what makes the page different on every site: the same paragraph on a hundred sites is
77
+ * duplicate content, and a page that names the client is not.
78
+ */
74
79
  webmaster: {
75
80
  title: string;
76
81
  description: string;
77
82
  intro: { before: string; after: string };
78
83
  body: string[];
84
+ /** The label of the outbound button to the agency. */
85
+ cta: string;
79
86
  };
80
87
  }
81
88
 
@@ -138,16 +145,17 @@ export const DEFAULT_COPY: Copy = {
138
145
  webmaster: {
139
146
  title: 'Our Webmaster',
140
147
  description:
141
- 'This custom website was designed, built and managed by WebMonterey, a webmaster service in Monterey, California.',
148
+ 'This {client} custom website was designed, built and managed by WebMonterey, a webmaster service in Monterey, California.',
142
149
  intro: {
143
- before: 'This custom website was designed, built and managed by',
150
+ before: 'This {client} custom website was designed, built and managed by',
144
151
  after:
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.',
152
+ ', a webmaster service in Monterey, California. WebMonterey handles web hosting, security, strategy and ongoing care of our site.',
146
153
  },
147
154
  /* Bold on purpose: the contact instruction is the paragraph a visitor with a problem needs. */
148
155
  body: [
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.**",
156
+ "**If you have a question about the {client} website, notice something that isn't working, or have trouble using a page, please let WebMonterey know and they will take care of it.**",
150
157
  ],
158
+ cta: 'Visit WebMonterey',
151
159
  },
152
160
  };
153
161
 
@@ -10,7 +10,10 @@ import {
10
10
  introHtml,
11
11
  webmasterPageProps,
12
12
  WEBMASTER_PATH,
13
+ AGENCY_LINK_ATTRS,
14
+ personalize,
13
15
  } from './webmaster.ts';
16
+ import { DEFAULT_COPY } from '../copy-defaults.ts';
14
17
 
15
18
  /*
16
19
  * Webmaster.astro read as SOURCE, because it cannot be imported here: an .astro file only
@@ -99,16 +102,16 @@ test('introHtml escapes the copy and the href', () => {
99
102
  assert.ok(html.endsWith('&quot;q&quot;'));
100
103
  });
101
104
 
105
+ const TEXT = {
106
+ title: 'T',
107
+ description: 'D',
108
+ intro: { before: 'Built by', after: ', **really**.' },
109
+ body: ["**If it isn't working, say so.**", 'See [the policy](/privacy).'],
110
+ cta: 'Visit',
111
+ };
112
+
102
113
  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
- );
114
+ const props = webmasterPageProps(TEXT, 'https://x.test/');
112
115
  assert.equal(props.title, 'T');
113
116
  assert.equal(props.description, 'D');
114
117
  assert.ok(props.intro.endsWith('</a>, <strong>really</strong>.'));
@@ -118,15 +121,68 @@ test('the copy carries the inline prose subset, in the intro and the body', () =
118
121
  ]);
119
122
  });
120
123
 
124
+ test('the cta is the copy label and the same attributed href the intro links to', () => {
125
+ const props = webmasterPageProps(TEXT, 'https://x.test/?utm_source=client');
126
+ assert.deepEqual(props.cta, { label: 'Visit', href: 'https://x.test/?utm_source=client' });
127
+ assert.match(props.intro, /href="https:\/\/x\.test\/\?utm_source=client"/);
128
+ });
129
+
130
+ /* ── the client's name, so no two sites carry the same paragraph ────────────────────────── */
131
+
132
+ test('personalize fills {client} and leaves other braces alone', () => {
133
+ assert.equal(personalize('This {client} site, {other}.', 'Acme'), 'This Acme site, {other}.');
134
+ });
135
+
136
+ test('personalize drops the placeholder and its space on an unconfigured site', () => {
137
+ assert.equal(personalize('This {client} custom website', ''), 'This custom website');
138
+ assert.equal(personalize('about the {client} website', ''), 'about the website');
139
+ assert.equal(personalize('{client} is here', ''), 'is here');
140
+ });
141
+
142
+ test('the client name reaches every prop, and is escaped like text', () => {
143
+ const props = webmasterPageProps(
144
+ {
145
+ title: '{client} webmaster',
146
+ description: 'About {client}',
147
+ intro: { before: 'The {client} site, by', after: ', for {client}.' },
148
+ body: ['Ask {client}.'],
149
+ cta: 'Visit, {client}',
150
+ },
151
+ 'https://x.test/',
152
+ 'Smith & Sons',
153
+ );
154
+ assert.equal(props.title, 'Smith & Sons webmaster');
155
+ assert.equal(props.description, 'About Smith & Sons');
156
+ assert.ok(props.intro.startsWith('The Smith &amp; Sons site, by <a '), props.intro);
157
+ assert.ok(props.intro.endsWith('</a>, for Smith &amp; Sons.'), props.intro);
158
+ assert.deepEqual(props.body, ['Ask Smith &amp; Sons.']);
159
+ assert.equal(props.cta.label, 'Visit, Smith & Sons');
160
+ });
161
+
162
+ test('the default copy names the client in the description, the intro and the contact line', () => {
163
+ const props = webmasterPageProps(DEFAULT_COPY.webmaster, 'https://x.test/', 'Acme Co');
164
+ assert.match(props.description, /^This Acme Co custom website/);
165
+ assert.match(props.intro, /^This Acme Co custom website was designed, built and managed by <a /);
166
+ assert.match(props.body[0]!, /about the Acme Co website/);
167
+ assert.equal(props.cta.label, 'Visit WebMonterey');
168
+ });
169
+
121
170
  test('the built-in page renders the same intro string a site layout receives', () => {
122
171
  /*
123
172
  * 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.
173
+ * is how a second copy of the attributes would drift; now both layouts render introHtml. The
174
+ * one <a> the template does write is the button, and its attributes are the spread constant,
175
+ * never typed out.
125
176
  */
126
177
  const page = readFileSync(new URL('../../../pages/webmaster.astro', import.meta.url), 'utf8');
127
- const template = page.slice(page.lastIndexOf('---'));
178
+ const template = page.slice(page.indexOf('---', 3));
128
179
  assert.match(template, /<p set:html=\{props\.intro\} \/>/);
129
- assert.doesNotMatch(template, /<a\s/, 'the page must not assemble the agency link itself');
180
+ const anchors = template.match(/<a\s[^>]*>/g) ?? [];
181
+ assert.equal(anchors.length, 1, 'the template writes exactly one link: the button');
182
+ assert.match(anchors[0]!, /href=\{props\.cta\.href\}/);
183
+ assert.match(anchors[0]!, /\{\.\.\.AGENCY_LINK_ATTRS\}/);
184
+ assert.doesNotMatch(anchors[0]!, /target=|rel=/, 'attributes come from the constant only');
185
+ assert.deepEqual(AGENCY_LINK_ATTRS, { target: '_blank', rel: 'noopener' });
130
186
  const html = introHtml({ before: '', after: '' }, 'https://x.test/');
131
187
  assert.match(html, /target="_blank"/);
132
188
  assert.match(html, /rel="noopener"/);
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { escapeHtml, renderInline } from '../prose/inline.ts';
29
+ import { fill } from '../copy-defaults.ts';
29
30
 
30
31
  /** The credit wording. Rendered verbatim on the site and in email. */
31
32
  export const CREDIT_TEXT = 'Powered by WebMonterey';
@@ -108,6 +109,33 @@ export interface WebmasterPageProps {
108
109
  intro: string;
109
110
  /** The remaining paragraphs' inner HTML, one entry per `<p>`. */
110
111
  body: string[];
112
+ /**
113
+ * The outbound button: `copy.webmaster.cta` as text, and the same attributed agency URL the
114
+ * intro links to. Render it as a link with `AGENCY_LINK_ATTRS` spread on - it leaves the site.
115
+ */
116
+ cta: { label: string; href: string };
117
+ }
118
+
119
+ /**
120
+ * The attributes every link to the agency carries: a new tab because it leaves the site,
121
+ * `noopener` without `noreferrer` because the referrer IS the attribution. One constant, spread
122
+ * by the built-in page and by introHtml, so the intro link and the button cannot disagree.
123
+ */
124
+ export const AGENCY_LINK_ATTRS = { target: '_blank', rel: 'noopener' } as const;
125
+
126
+ /**
127
+ * `{client}` in a copy string becomes the client's name; everything else is left alone.
128
+ *
129
+ * WHY THE PAGE NAMES THE CLIENT. The same paragraph on every site the agency builds is duplicate
130
+ * content across the fleet. A paragraph that says whose website this is is not, and it reads as
131
+ * written for the client rather than pasted. `client` is '' on a site that has not set one yet
132
+ * - the CHANGEME placeholder never reaches a page - and then the placeholder simply drops out
133
+ * and the doubled space with it: "This custom website" rather than "This custom website".
134
+ *
135
+ * Runs BEFORE the inline renderer, so a name with an ampersand is escaped like any other text.
136
+ */
137
+ export function personalize(text: string, client: string): string {
138
+ return fill(text, { client }).replace(/ {2,}/g, ' ').trim();
111
139
  }
112
140
 
113
141
  /**
@@ -122,24 +150,34 @@ export interface WebmasterPageProps {
122
150
  * and an element on separate lines, and "managed byWebMonterey" shipped.
123
151
  */
124
152
  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>`;
153
+ const attrs = Object.entries(AGENCY_LINK_ATTRS)
154
+ .map(([k, v]) => `${k}="${v}"`)
155
+ .join(' ');
156
+ const link = `<a href="${escapeHtml(href)}" ${attrs}>${escapeHtml(AGENCY.name)}</a>`;
126
157
  return `${renderInline(intro.before)} ${link}${renderInline(intro.after)}`;
127
158
  }
128
159
 
129
- /** The whole prop set, from the merged copy and the attributed agency link. */
160
+ /**
161
+ * The whole prop set, from the merged copy, the attributed agency link and the client's name.
162
+ * `client` is '' when the site has not set one - see personalize.
163
+ */
130
164
  export function webmasterPageProps(
131
165
  text: {
132
166
  title: string;
133
167
  description: string;
134
168
  intro: { before: string; after: string };
135
169
  body: string[];
170
+ cta: string;
136
171
  },
137
172
  href: string,
173
+ client = '',
138
174
  ): WebmasterPageProps {
175
+ const p = (s: string) => personalize(s, client);
139
176
  return {
140
- title: text.title,
141
- description: text.description,
142
- intro: introHtml(text.intro, href),
143
- body: text.body.map(renderInline),
177
+ title: p(text.title),
178
+ description: p(text.description),
179
+ intro: introHtml({ before: p(text.intro.before), after: p(text.intro.after) }, href),
180
+ body: text.body.map((paragraph) => renderInline(p(paragraph))),
181
+ cta: { label: p(text.cta), href },
144
182
  };
145
183
  }
@@ -0,0 +1,50 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { sitePage } from './config.ts';
7
+
8
+ /*
9
+ * WHY THIS EXISTS. The integration injects the package's /404 on every site, and Astro warns on
10
+ * every build of a site that also has src/pages/404.astro: "A static route cannot be defined
11
+ * more than once … will result in a hard error in following versions". The fix is to not inject
12
+ * when the site has its own, and this is the file check that decides it.
13
+ */
14
+
15
+ const withSrc = (files: string[], fn: (src: string) => void) => {
16
+ const dir = mkdtempSync(join(tmpdir(), 'webm-cfg-'));
17
+ try {
18
+ mkdirSync(join(dir, 'pages'), { recursive: true });
19
+ for (const f of files) writeFileSync(join(dir, 'pages', f), '');
20
+ fn(dir);
21
+ } finally {
22
+ rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ };
25
+
26
+ test('sitePage is null when src/pages has no file for the route', () => {
27
+ withSrc(['index.astro', 'contact.astro'], (src) => {
28
+ assert.equal(sitePage(src, '404'), null);
29
+ });
30
+ });
31
+
32
+ test('sitePage finds a 404 in every form Astro treats as a route', () => {
33
+ for (const ext of ['astro', 'md', 'mdx', 'html', 'ts', 'js']) {
34
+ withSrc([`404.${ext}`], (src) => {
35
+ assert.equal(sitePage(src, '404'), join(src, 'pages', `404.${ext}`), ext);
36
+ });
37
+ }
38
+ });
39
+
40
+ test('sitePage ignores files that are not routes', () => {
41
+ withSrc(['404.css', '404.json', '404.astro.bak'], (src) => {
42
+ assert.equal(sitePage(src, '404'), null);
43
+ });
44
+ });
45
+
46
+ test('sitePage is exact about the stem: 404-old is not 404', () => {
47
+ withSrc(['404-old.astro', '4040.astro'], (src) => {
48
+ assert.equal(sitePage(src, '404'), null);
49
+ });
50
+ });
@@ -94,3 +94,25 @@ export function loadForms(root: string): Record<string, unknown> {
94
94
  }
95
95
  return out;
96
96
  }
97
+
98
+ /*
99
+ * THE EXTENSIONS ASTRO TREATS AS A PAGE OR ENDPOINT under src/pages. The docs name .astro and .md
100
+ * for a custom 404 and .html for plain pages; .mdx arrives with the MDX integration; .ts and .js
101
+ * are endpoints. Anything else in src/pages is not a route and cannot collide with one.
102
+ */
103
+ const PAGE_EXTENSIONS = ['astro', 'md', 'mdx', 'html', 'ts', 'js'] as const;
104
+
105
+ /**
106
+ * The site's own file for a top-level route, or null when it has none.
107
+ *
108
+ * `name` is the route's file stem - `404` for `/404` - and `srcDir` is the site's src directory
109
+ * as a path, not a URL. Pure, so the integration's "do not inject what the site already has" rule
110
+ * has a test that runs under `node --test` with a temp directory and nothing else.
111
+ */
112
+ export function sitePage(srcDir: string, name: string): string | null {
113
+ for (const ext of PAGE_EXTENSIONS) {
114
+ const file = join(srcDir, 'pages', `${name}.${ext}`);
115
+ if (existsSync(file)) return file;
116
+ }
117
+ return null;
118
+ }
@@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url';
30
30
 
31
31
  import { compileToCss } from '../design/compile.ts';
32
32
  import { imageSize } from './image-size.ts';
33
- import { loadForms, loadSiteFiles, resolveSiteUrl } from './config.ts';
33
+ import { loadForms, loadSiteFiles, resolveSiteUrl, sitePage } from './config.ts';
34
34
  import {
35
35
  APP_DIR,
36
36
  appEnabled,
@@ -251,6 +251,20 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
251
251
  */
252
252
  optimizeDeps: {
253
253
  exclude: ['@astrojs/cloudflare/handler'],
254
+ /*
255
+ * REBUILD THE PRE-BUNDLE ON EVERY DEV START. The cache in node_modules/.vite
256
+ * outlives the things it was built from - a package update, a branch switch, an
257
+ * `astro check` that re-optimised under a server that has since stopped - and a
258
+ * stale one is the dev server 500ing on every request, or serving last week's
259
+ * copy of a dependency. Forcing costs a few seconds once per start; trusting the
260
+ * cache costs a session's worth of "restart it and clear the cache". Dev only:
261
+ * a build does not use the pre-bundle, and this flag is a no-op there anyway.
262
+ *
263
+ * Startup only, and that is the limit of it: a re-optimisation triggered while
264
+ * the server is running still swaps the hash underneath it. `webm clean` is the
265
+ * reset for that, and the handler exclusion above removes the known trigger.
266
+ */
267
+ force: command === 'dev',
254
268
  },
255
269
  ssr: {
256
270
  /*
@@ -468,10 +482,21 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
468
482
  *
469
483
  * Astro turns the `/404` route into 404.html, which the Workers asset router serves for
470
484
  * any unmatched path. A site that wants its own overrides this the ordinary Astro way, by
471
- * having src/pages/404.astro - a file in the site beats an injected route, which is the
472
- * child-theme rule holding without anything special here.
485
+ * having src/pages/404.astro - and then the package MUST NOT inject its own. It used to,
486
+ * relying on the site's file winning the collision. It did win, but Astro warns on every
487
+ * build ("A static route cannot be defined more than once … will result in a hard error
488
+ * in following versions of Astro"), and nothing in the routing docs promises a file beats
489
+ * an injected route. So the site's file is checked for first, in every form Astro treats
490
+ * as a route, and the package's page is injected only into the gap. See sitePage.
473
491
  */
474
- injectRoute({ pattern: '/404', entrypoint: '@cparkerwebm/webmonterey/pages/404.astro' });
492
+ const own404 = sitePage(config.srcDir.pathname, '404');
493
+ if (own404) {
494
+ logger.debug(
495
+ `the site's own ${own404.slice(root.length)} is the 404; not injecting the package's`,
496
+ );
497
+ } else {
498
+ injectRoute({ pattern: '/404', entrypoint: '@cparkerwebm/webmonterey/pages/404.astro' });
499
+ }
475
500
  },
476
501
  },
477
502
  };
@@ -91,8 +91,9 @@ declare module 'virtual:webm/registry' {
91
91
  * pages rather than as the package's `<h1>` and stack of paragraphs.
92
92
  *
93
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
94
+ * `{ title, description, intro, body, cta }`, the merged `copy.webmaster` with the client's
95
+ * name filled in - `intro` and `body` as HTML through the inline prose renderer, the agency
96
+ * link already in `intro`, `cta` the outbound button's label and href. The component
96
97
  * 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
98
  * Null means the built-in layout, `pageHeader` seam included.
98
99
  */
@@ -26,11 +26,12 @@ import Base from '../layouts/base.astro';
26
26
  import { copy } from '../includes/webmonterey/copy.ts';
27
27
  import {
28
28
  AGENCY,
29
+ AGENCY_LINK_ATTRS,
29
30
  creditUrl,
30
31
  webmasterPageProps,
31
32
  WEBMASTER_OG_PATH,
32
33
  } from '../includes/webmonterey/webmaster/webmaster.ts';
33
- import { domain, hasDomain } from '../includes/webmonterey/site.ts';
34
+ import { client, domain, hasClient, hasDomain } from '../includes/webmonterey/site.ts';
34
35
  import { compact, renderJsonLd } from '../includes/webmonterey/structured-data/nodes.ts';
35
36
  import { pageHeader, webmasterPage } from 'virtual:webm/registry';
36
37
  import ogImage from 'virtual:webm/webmaster-og';
@@ -41,11 +42,17 @@ const WebmasterPage = webmasterPage;
41
42
  const text = copy.webmaster;
42
43
  const agencyHref = hasDomain ? creditUrl(domain, 'website') : AGENCY.url;
43
44
  /*
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.
45
+ * The words, resolved once for both layouts: the client's name filled into every `{client}`,
46
+ * the intro with the agency link in it, every paragraph through the inline prose renderer, so
47
+ * `**bold**` in copy.webmaster works here exactly as it does in page JSON.
48
+ */
49
+ const props = webmasterPageProps(text, agencyHref, hasClient ? client : '');
50
+ /*
51
+ * FROM HERE ON, `props`, NEVER `text`. The title and description reach the <title>, the meta
52
+ * description, the Open Graph tags, the page header and the JSON-LD from props, because that is
53
+ * where `{client}` has been filled in. The first cut read `text` for those and shipped a meta
54
+ * description that said "This {client} custom website" - the e2e caught it, the example did not.
47
55
  */
48
- const props = webmasterPageProps(text, agencyHref);
49
56
 
50
57
  /*
51
58
  * The graph needs a real origin for stable @id values, which is the same gate the canonical tag
@@ -60,8 +67,8 @@ if (Astro.site && !build.preview) {
60
67
  '@type': 'WebPage',
61
68
  '@id': `${url}#webpage`,
62
69
  url,
63
- name: text.title,
64
- description: text.description,
70
+ name: props.title,
71
+ description: props.description,
65
72
  isPartOf: { '@id': new URL('#website', Astro.site).href },
66
73
  about: { '@id': AGENCY.id },
67
74
  inLanguage: 'en',
@@ -80,8 +87,8 @@ if (Astro.site && !build.preview) {
80
87
  ---
81
88
 
82
89
  <Base
83
- title={text.title}
84
- description={text.description}
90
+ title={props.title}
91
+ description={props.description}
85
92
  shareImage={WEBMASTER_OG_PATH}
86
93
  shareImageWidth={ogImage.width}
87
94
  shareImageHeight={ogImage.height}
@@ -110,11 +117,11 @@ if (Astro.site && !build.preview) {
110
117
  * on the site; the router's plain <h1> otherwise. Same fallback the block router uses.
111
118
  */
112
119
  PageHeader ? (
113
- <PageHeader title={text.title} description={text.description} />
120
+ <PageHeader title={props.title} description={props.description} />
114
121
  ) : (
115
122
  <section class="webm-section" data-space="sm">
116
123
  <div class="webm-container" data-width="text">
117
- <h1>{text.title}</h1>
124
+ <h1>{props.title}</h1>
118
125
  </div>
119
126
  </section>
120
127
  )
@@ -134,6 +141,16 @@ if (Astro.site && !build.preview) {
134
141
  {props.body.map((paragraph) => (
135
142
  <p set:html={paragraph} />
136
143
  ))}
144
+ {/*
145
+ * The button, to the same attributed URL. Outline rather than filled, so it reads
146
+ * as an invitation on a page about the site, not a call to action for the agency.
147
+ * The same attributes as the intro link, from the same constant.
148
+ */}
149
+ <p>
150
+ <a class="webm-webmaster-cta" href={props.cta.href} {...AGENCY_LINK_ATTRS}>
151
+ {props.cta.label}
152
+ </a>
153
+ </p>
137
154
  </div>
138
155
  </div>
139
156
  </section>
@@ -141,3 +158,29 @@ if (Astro.site && !build.preview) {
141
158
  )
142
159
  }
143
160
  </Base>
161
+
162
+ {
163
+ /*
164
+ GLOBAL, NOT SCOPED, on purpose. A scoped style would stamp a data-astro-cid hash on every
165
+ element of this page, and examples/minimal asserts the built-in <main> byte for byte so the
166
+ fallback cannot drift under the seam. The class is package-prefixed and used nowhere else,
167
+ which is all scoping would buy here. Tokens only: the outline is the link colour, the radius
168
+ and border width are the site's.
169
+ */
170
+ }
171
+ <style is:global>
172
+ @layer webm.components.core {
173
+ .webm-webmaster-cta {
174
+ display: inline-block;
175
+ padding: var(--webm-space-2xs) var(--webm-space-md);
176
+ border: var(--webm-border-width-2) solid currentColor;
177
+ border-radius: var(--webm-radius-md);
178
+ color: var(--webm-link);
179
+ text-decoration: none;
180
+ }
181
+
182
+ .webm-webmaster-cta:hover {
183
+ color: var(--webm-link-hover);
184
+ }
185
+ }
186
+ </style>
@@ -37,6 +37,7 @@ what it forked from and why, so the next person knows it is deliberate.
37
37
  | `npm run preview` | **a real build on real workerd** |
38
38
  | `npm run check` | types and content schema |
39
39
  | `npx webm doctor` | the things that fail silently |
40
+ | `npx webm clean` | reset a stale dev server: drops `.vite`, `.astro`, `dist` |
40
41
  | `npm run format` | prettier |
41
42
 
42
43
  **Run `npm run preview`, not `dev`, before merging anything** that touches styles,