@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 +63 -0
- package/README.md +2 -2
- package/dist/webm.mjs +50 -4
- package/package.json +4 -4
- package/skills/traps/SKILL.md +7 -0
- package/skills/webmaster/SKILL.md +25 -6
- package/src/cli/clean.test.ts +48 -0
- package/src/cli/clean.ts +62 -0
- package/src/includes/webmonterey/copy-defaults.ts +12 -4
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +68 -12
- package/src/includes/webmonterey/webmaster/webmaster.ts +44 -6
- package/src/integration/config.test.ts +50 -0
- package/src/integration/config.ts +22 -0
- package/src/integration/index.ts +29 -4
- package/src/integration/virtual.d.ts +3 -2
- package/src/pages/webmaster.astro +54 -11
- package/template/site/CLAUDE.md +1 -0
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
|
|
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
|
|
72
|
-
const parts =
|
|
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
|
|
2702
|
-
var pkg = JSON.parse(readFileSync10(
|
|
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.
|
|
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.
|
|
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.
|
|
30
|
+
"@cloudflare/workers-types": "^5.20260906.1",
|
|
31
31
|
"@types/node": "^26.1.2",
|
|
32
|
-
"astro": "^7.1
|
|
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",
|
package/skills/traps/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
113
|
-
target="_blank" rel="noopener"
|
|
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
|
+
});
|
package/src/cli/clean.ts
ADDED
|
@@ -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
|
|
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
|
|
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('"q"'));
|
|
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 & Sons site, by <a '), props.intro);
|
|
157
|
+
assert.ok(props.intro.endsWith('</a>, for Smith & Sons.'), props.intro);
|
|
158
|
+
assert.deepEqual(props.body, ['Ask Smith & 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.
|
|
178
|
+
const template = page.slice(page.indexOf('---', 3));
|
|
128
179
|
assert.match(template, /<p set:html=\{props\.intro\} \/>/);
|
|
129
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -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 -
|
|
472
|
-
*
|
|
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
|
-
|
|
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`
|
|
95
|
-
* HTML through the inline prose renderer, the agency
|
|
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
|
|
45
|
-
* paragraph through the inline prose renderer, so
|
|
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:
|
|
64
|
-
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={
|
|
84
|
-
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={
|
|
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>{
|
|
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>
|
package/template/site/CLAUDE.md
CHANGED
|
@@ -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,
|