@cparkerwebm/webmonterey 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +6 -1
  3. package/dist/webm.mjs +290 -48
  4. package/package.json +5 -3
  5. package/skills/launch/SKILL.md +47 -7
  6. package/skills/start/SKILL.md +25 -15
  7. package/skills/traps/SKILL.md +11 -2
  8. package/src/assets/opengraph-webmaster.png +0 -0
  9. package/src/cli/audit.test.ts +120 -0
  10. package/src/cli/audit.ts +323 -0
  11. package/src/cli/checks.test.ts +6 -6
  12. package/src/cli/checks.ts +9 -7
  13. package/src/cli/new.ts +34 -17
  14. package/src/cli/scaffold.test.ts +27 -20
  15. package/src/cli/scaffold.ts +8 -7
  16. package/src/cli/slug.test.ts +25 -28
  17. package/src/cli/slug.ts +31 -36
  18. package/src/cli/sync.ts +1 -1
  19. package/src/emails/footer.ts +3 -3
  20. package/src/includes/cloudflare/r2/README.md +3 -3
  21. package/src/includes/cloudflare/r2/media.ts +4 -4
  22. package/src/includes/webmonterey/config.test.ts +1 -1
  23. package/src/includes/webmonterey/copy-defaults.ts +20 -0
  24. package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
  25. package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
  26. package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
  27. package/src/integration/index.ts +66 -3
  28. package/src/integration/virtual.d.ts +16 -1
  29. package/src/layouts/base.astro +28 -8
  30. package/src/package.test.ts +20 -0
  31. package/src/pages/robots.txt.ts +12 -0
  32. package/src/pages/webmaster-og.png.ts +31 -0
  33. package/src/pages/webmaster.astro +121 -0
  34. package/template/migrations/README.md +1 -1
  35. package/template/public/opengraph.png +0 -0
  36. package/template/scripts/test-hooks.mjs +1 -1
  37. package/template/site/CLAUDE.md +6 -2
  38. package/src/includes/webmonterey/credits/Credit.astro +0 -80
  39. package/src/includes/webmonterey/credits/credit.test.ts +0 -111
  40. package/src/includes/webmonterey/credits/credit.ts +0 -59
  41. package/template/public/open-graph.png +0 -0
  42. /package/template/assets/{open-graph.png → opengraph.png} +0 -0
@@ -0,0 +1,121 @@
1
+ ---
2
+ /*
3
+ * /webmaster - the page every site has, saying who built it and who to call.
4
+ *
5
+ * Injected by the integration on every site (with a `webmaster: false` off switch), rendered
6
+ * through the site's own layout so it carries the client's header, footer and tokens. INDEXABLE
7
+ * and in the sitemap, on purpose: the footer credit links here rather than off the site, and this
8
+ * page carries the one outbound link to the agency - a followed link on a real page with real
9
+ * copy, which is a backlink rather than a footer decoration. It is also the answer to "something
10
+ * is wrong with the website, who do I contact?" on the site itself.
11
+ *
12
+ * Every word is overridable through `copy.webmaster` in webmonterey.json, like everything else
13
+ * the package puts in front of a visitor.
14
+ *
15
+ * ITS OWN STRUCTURED DATA, and the site's is switched off for this route. The graph here is about
16
+ * the agency - a WebPage whose subject is the agency's Organization, using the same `@id` the
17
+ * agency's own site declares, so every client page points at one entity rather than a hundred
18
+ * copies. The site's structuredData component would also emit a WebPage for this URL, and two
19
+ * nodes claiming the same `@id` is worse than one.
20
+ *
21
+ * The share image is served from the package at /webmaster/og.png, so a redesign reaches every
22
+ * site on `npm update` and nothing has to be seeded into public/. Its og:image dimensions are
23
+ * measured from the file by the integration, never typed in.
24
+ */
25
+ import Base from '../layouts/base.astro';
26
+ import { copy } from '../includes/webmonterey/copy.ts';
27
+ import {
28
+ AGENCY,
29
+ creditUrl,
30
+ WEBMASTER_OG_PATH,
31
+ } from '../includes/webmonterey/webmaster/webmaster.ts';
32
+ import { domain, hasDomain } from '../includes/webmonterey/site.ts';
33
+ import { compact, renderJsonLd } from '../includes/webmonterey/structured-data/nodes.ts';
34
+ import { pageHeader } from 'virtual:webm/registry';
35
+ import ogImage from 'virtual:webm/webmaster-og';
36
+ import build from 'virtual:webm/build';
37
+
38
+ const PageHeader = pageHeader;
39
+ const text = copy.webmaster;
40
+ const agencyHref = hasDomain ? creditUrl(domain, 'website') : AGENCY.url;
41
+
42
+ /*
43
+ * The graph needs a real origin for stable @id values, which is the same gate the canonical tag
44
+ * and the site's own structured data use. Until `domain` is set there is no page @id to claim.
45
+ */
46
+ let jsonLd: string | null = null;
47
+ /* No graph on a preview: the page is noindex there and an @id on a workers.dev host is noise. */
48
+ if (Astro.site && !build.preview) {
49
+ const url = new URL(Astro.url.pathname, Astro.site).href;
50
+ jsonLd = renderJsonLd([
51
+ compact({
52
+ '@type': 'WebPage',
53
+ '@id': `${url}#webpage`,
54
+ url,
55
+ name: text.title,
56
+ description: text.description,
57
+ isPartOf: { '@id': new URL('#website', Astro.site).href },
58
+ about: { '@id': AGENCY.id },
59
+ inLanguage: 'en',
60
+ }),
61
+ compact({
62
+ '@type': 'ProfessionalService',
63
+ '@id': AGENCY.id,
64
+ name: AGENCY.name,
65
+ url: AGENCY.url,
66
+ description: AGENCY.description,
67
+ address: { '@type': 'PostalAddress', ...AGENCY.address },
68
+ sameAs: [...AGENCY.sameAs],
69
+ }),
70
+ ]);
71
+ }
72
+ ---
73
+
74
+ <Base
75
+ title={text.title}
76
+ description={text.description}
77
+ shareImage={WEBMASTER_OG_PATH}
78
+ shareImageWidth={ogImage.width}
79
+ shareImageHeight={ogImage.height}
80
+ structuredData={false}
81
+ >
82
+ <Fragment slot="head">
83
+ {jsonLd && <script type="application/ld+json" set:html={jsonLd} />}
84
+ </Fragment>
85
+
86
+ {
87
+ /*
88
+ * The site's page header if it declares one, so this page looks like every other page on the
89
+ * site; the router's plain <h1> otherwise. Same fallback the block router uses.
90
+ */
91
+ PageHeader ? (
92
+ <PageHeader title={text.title} description={text.description} />
93
+ ) : (
94
+ <section class="webm-section" data-space="sm">
95
+ <div class="webm-container" data-width="text">
96
+ <h1>{text.title}</h1>
97
+ </div>
98
+ </section>
99
+ )
100
+ }
101
+
102
+ <section class="webm-section" data-space="lg">
103
+ <div class="webm-container" data-width="text">
104
+ <div class="webm-stack">
105
+ {
106
+ /*
107
+ * The agency is named as a link in the first paragraph - the one outbound link on the
108
+ * site, followed, with the UTM parameters. Opens in a new tab because it leaves the
109
+ * site; noopener without noreferrer, because the referrer is the attribution.
110
+ */
111
+ }
112
+ <p>
113
+ {text.intro.before}
114
+ <a href={agencyHref} target="_blank" rel="noopener">{AGENCY.name}</a>
115
+ {text.intro.after}
116
+ </p>
117
+ {text.body.map((paragraph) => <p>{paragraph}</p>)}
118
+ </div>
119
+ </div>
120
+ </section>
121
+ </Base>
@@ -35,7 +35,7 @@ Wrangler tracks applied migrations in a `d1_migrations` table inside the databas
35
35
  The database must exist and be bound in `wrangler.jsonc`:
36
36
 
37
37
  ```sh
38
- npx wrangler d1 create webm-<domain-dashed>-db --update-config
38
+ npx wrangler d1 create <slug> --update-config
39
39
  ```
40
40
 
41
41
  `--update-config` writes the `d1_databases` binding into `wrangler.jsonc` for you.
Binary file
@@ -5,7 +5,7 @@
5
5
  * would otherwise force a choice between "no tests" and "rewrite every import in the app to
6
6
  * suit the test runner". The app wins that argument, so the adaptation lives here.
7
7
  *
8
- * 1. EXTENSIONLESS RELATIVE IMPORTS — `from '../credits/credit'`. Node ESM requires the
8
+ * 1. EXTENSIONLESS RELATIVE IMPORTS — `from '../webmaster/webmaster'`. Node ESM requires the
9
9
  * extension; bundlers resolve it. The resolve hook appends `.ts` when the bare specifier
10
10
  * does not resolve but the `.ts` file exists.
11
11
  *
@@ -103,8 +103,12 @@ Private data — user logins, form submissions — goes to **Cloudflare D1**, ne
103
103
 
104
104
  ### 5. The prefix is `webm-`, never `wm-`
105
105
 
106
- Custom properties (`--webm-action`), class names (`.webm-section`), Cloudflare resource names
107
- (`webm-<slug>`), the CLI, the skills namespace. No exceptions.
106
+ Custom properties (`--webm-action`), class names (`.webm-section`), the CLI, the skills
107
+ namespace. No exceptions.
108
+
109
+ Cloud resources are the other convention: the GitHub repo, Worker, D1, R2 and KV all carry **one
110
+ name**, the domain minus its TLD (`example.com` → `example`). A second resource of one kind takes
111
+ a purpose suffix (`example-portal`).
108
112
 
109
113
  ### 6. Cloudflare bindings come from `cloudflare:workers`
110
114
 
@@ -1,80 +0,0 @@
1
- ---
2
- /*
3
- * The WebMonterey agency credit. Goes in the site footer.
4
- *
5
- * <Credit /> -> Powered by WebMonterey
6
- *
7
- * The wording and the UTM-tagged link both come from credit.ts, which the email footer also
8
- * imports — the two surfaces must say the same thing, and email cannot reuse this component
9
- * (scoped CSS does not survive an email client).
10
- */
11
- import { CREDIT_TEXT, creditUrl } from './credit.ts';
12
- import { domain, hasDomain } from '../site.ts';
13
-
14
- interface Props {
15
- class?: string;
16
- }
17
-
18
- const { class: className } = Astro.props;
19
-
20
- // Fail loudly at build rather than emit a credit link pointing at a placeholder.
21
- if (!hasDomain) {
22
- throw new Error(
23
- '[webm] webmonterey.json "domain" is still CHANGEME. Set the production domain - the ' +
24
- 'agency credit link uses it for utm_content attribution.',
25
- );
26
- }
27
-
28
- const href = creditUrl(domain, 'website');
29
- ---
30
-
31
- <p class:list={['webm-credit', className]}>
32
- {
33
- /*
34
- OPENS IN A NEW TAB, and `target` is the half that was missing.
35
-
36
- This element carried `rel="noopener"` and no `target`, which does nothing at all - noopener
37
- only means anything alongside a target - and the credit navigated away from the client's site
38
- in the same tab. Verified broken on live client sites before this was fixed.
39
-
40
- `noopener` stays: without it the opened page gets a `window.opener` handle back into this one.
41
- Deliberately NOT `noreferrer` - the referrer is the attribution, and stripping it would leave
42
- only the utm_content.
43
-
44
- The visually-hidden note is not decoration. A link that moves the user to another tab without
45
- saying so is a semantics bug, and semantics are correctness rather than styling. The email
46
- footer deliberately does NOT carry it - the hiding technique needs a stylesheet, and email
47
- clients strip those. See emails/footer.ts.
48
-
49
- NO `title` ATTRIBUTE, and that is the correct state rather than an omission. `title` is not
50
- reliably announced by screen readers, is unreachable by keyboard and touch entirely, and
51
- either duplicates the link text or competes with it for the accessible name. The link text
52
- plus the hidden note IS the accessible name.
53
- */
54
- }
55
- <a href={href} target="_blank" rel="noopener">
56
- {CREDIT_TEXT}<span class="webm-visually-hidden"> (opens in a new tab)</span>
57
- </a>
58
- </p>
59
-
60
- <style>
61
- @layer webm.components.core {
62
- .webm-credit {
63
- font-size: var(--webm-font-size-xs);
64
- color: var(--webm-text-muted);
65
- }
66
-
67
- /*
68
- * Underline at the text's own color. An underline in --webm-border-subtle is ~1.3:1 on
69
- * white — effectively invisible, leaving nothing to mark the link as clickable.
70
- */
71
- .webm-credit a {
72
- color: inherit;
73
- text-decoration-color: currentColor;
74
- }
75
-
76
- .webm-credit a:hover {
77
- color: var(--webm-link-hover);
78
- }
79
- }
80
- </style>
@@ -1,111 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { readFileSync } from 'node:fs';
4
-
5
- import { contentTag, CREDIT_TEXT, creditUrl } from './credit.ts';
6
-
7
- /*
8
- * Credit.astro read as SOURCE, because it cannot be imported here: it pulls in ../site.ts, which
9
- * imports virtual:webm/site, which only resolves inside an Astro build. A source assertion is the
10
- * weaker tool and it is the one available - and the alternative in place until now was nothing.
11
- *
12
- * WHY IT NEEDS ONE AT ALL. This anchor once carried rel="noopener" and no target, which does
13
- * nothing whatsoever - noopener only means anything alongside a target - so the credit navigated
14
- * the visitor away from the client's site in the same tab. That shipped to live sites. The
15
- * regression test written afterwards guards the EMAIL footer, in emails/footer.test.ts; the
16
- * component where the bug actually happened had no test until this one.
17
- */
18
- const componentSource = readFileSync(new URL('./Credit.astro', import.meta.url), 'utf8');
19
- /** The template with its {/* *\/} comment blocks removed, so prose about the rule is not read as the rule. */
20
- const template = componentSource.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '');
21
- const anchors: string[] = template.match(/<a\s[^>]*>/g) ?? [];
22
- /** The single credit anchor. Asserted to be the only one below. */
23
- const anchor = (): string => anchors[0] ?? '';
24
-
25
- test('creditUrl points at the main site, not a /credits page', () => {
26
- const url = new URL(creditUrl('example.com'));
27
- assert.equal(url.origin, 'https://webmonterey.com');
28
- assert.equal(url.pathname, '/', 'the credit link must not reintroduce a /credits path');
29
- });
30
-
31
- test('creditUrl carries all four UTM parameters', () => {
32
- const { searchParams } = new URL(creditUrl('example.com'));
33
- assert.equal(searchParams.get('utm_source'), 'client');
34
- assert.equal(searchParams.get('utm_campaign'), 'credits');
35
- assert.equal(searchParams.get('utm_content'), 'example_com');
36
- });
37
-
38
- test('utm_medium separates the two surfaces', () => {
39
- const site = new URL(creditUrl('example.com', 'website'));
40
- const email = new URL(creditUrl('example.com', 'email'));
41
- assert.equal(site.searchParams.get('utm_medium'), 'website');
42
- assert.equal(email.searchParams.get('utm_medium'), 'email');
43
- });
44
-
45
- test('utm_medium defaults to website', () => {
46
- assert.equal(new URL(creditUrl('example.com')).searchParams.get('utm_medium'), 'website');
47
- });
48
-
49
- test('utm_content is the production domain, with every dot as an underscore', () => {
50
- // Not the host the page is served from — a preview build must not fragment attribution.
51
- const url = new URL(creditUrl('sub.example.co.uk', 'email'));
52
- assert.equal(url.searchParams.get('utm_content'), 'sub_example_co_uk');
53
- });
54
-
55
- test('both surfaces tag the same client the same way', () => {
56
- const site = new URL(creditUrl('example.com', 'website'));
57
- const email = new URL(creditUrl('example.com', 'email'));
58
- assert.equal(site.searchParams.get('utm_content'), email.searchParams.get('utm_content'));
59
- });
60
-
61
- test('no dot survives into the credit URL, so nothing can linkify it', () => {
62
- const url = new URL(creditUrl('example.com', 'email'));
63
- assert.equal(url.searchParams.get('utm_content'), 'example_com');
64
- /*
65
- * Scoped to the QUERY STRING, not the whole URL: the destination host is webmonterey.com
66
- * and is allowed to look like a host. It is the parameter value that must not.
67
- */
68
- assert.equal(url.search.includes('.'), false);
69
- });
70
-
71
- test('contentTag leaves a domain that has no dots alone', () => {
72
- assert.equal(contentTag('localhost'), 'localhost');
73
- });
74
-
75
- test('contentTag changes nothing but the dots', () => {
76
- // The value still has to be recognisably the site it came from in the report.
77
- assert.equal(contentTag('steven-glaze.com'), 'steven-glaze_com');
78
- });
79
-
80
- test('CREDIT_TEXT is the shared wording', () => {
81
- assert.equal(CREDIT_TEXT, 'Powered by WebMonterey');
82
- });
83
-
84
- /* ── the footer credit's link, mirroring emails/footer.test.ts ────────────────────────────── */
85
-
86
- test('the site credit renders exactly one link', () => {
87
- assert.equal(anchors.length, 1, `expected one anchor, found ${anchors.length}`);
88
- });
89
-
90
- test('the site credit opens in a new tab', () => {
91
- // The half that was missing. Without it the visitor leaves the client's site to read ours.
92
- assert.match(anchor(), /target="_blank"/);
93
- });
94
-
95
- test('the new tab cannot reach back into the client page', () => {
96
- assert.match(anchor(), /rel="[^"]*noopener/);
97
- });
98
-
99
- /*
100
- * Locking a decision rather than an implementation detail: the referrer IS the attribution, so
101
- * stripping it would leave only utm_content. This is the kind of thing someone adds in good faith
102
- * while "hardening" a target=_blank link.
103
- */
104
- test('the credit link deliberately does not send noreferrer', () => {
105
- assert.doesNotMatch(anchor(), /noreferrer/);
106
- });
107
-
108
- test('the new tab is announced, not just implemented', () => {
109
- // A link that moves the user to another tab without saying so is a semantics bug.
110
- assert.match(template, /webm-visually-hidden[^>]*>\s*\(opens in a new tab\)/);
111
- });
@@ -1,59 +0,0 @@
1
- /*
2
- * The agency credit's wording and link, in ONE place.
3
- *
4
- * Two surfaces render this credit and they cannot share a component: the site footer is an
5
- * .astro component with scoped CSS, and transactional email is a string with inline styles
6
- * (email clients strip <style> blocks). Without this module the wording exists twice, and the
7
- * next time it changes one copy gets missed — which is exactly how "a WebMonterey website"
8
- * would have survived in email after the footer moved to "Powered by WebMonterey".
9
- *
10
- * utm_content is ALWAYS the production domain from webmonterey.json, never the host the page
11
- * happens to be served from. A preview build at `feature-x-webm-example-com.workers.dev`
12
- * still reports the client's own domain, so staging traffic does not fragment the attribution.
13
- *
14
- * IT IS WRITTEN WITH UNDERSCORES — `example_com`, not `example.com`. A dot makes the value look
15
- * like a hostname, and analytics UIs read it as one rather than as the label it is. Underscores
16
- * keep it a label. Every dot goes, subdomains included: `sub.example.co.uk` is `sub_example_co_uk`.
17
- *
18
- * This comment used to also claim a mail client would linkify `example.com` inside the query
19
- * string and truncate the URL there. That does not apply: auto-linkification acts on plain text,
20
- * and here the URL only ever appears inside an href. Corrected rather than deleted, because a
21
- * justification that does not survive scrutiny invites someone to reverse the decision it
22
- * defends — and the analytics reason on its own is enough.
23
- *
24
- * The link points at the WebMonterey home page, not a /credits landing page. The UTM
25
- * parameters carry the whole story, so the destination stays the main site and there is no
26
- * separate page to keep alive. utm_medium is what separates the two surfaces: `website` from
27
- * the footer, `email` from a transactional template.
28
- */
29
-
30
- /** The credit wording. Rendered verbatim on the site and in email. */
31
- export const CREDIT_TEXT = 'Powered by WebMonterey';
32
-
33
- /**
34
- * Which surface the click came from. Kept separate from utm_content so the report can tell
35
- * a footer click from an email click without splitting it per client.
36
- */
37
- export type CreditMedium = 'website' | 'email';
38
-
39
- /**
40
- * The client's domain as a UTM label rather than as a domain. Dots only — the value is
41
- * otherwise left exactly as webmonterey.json wrote it, so what lands in the report is still
42
- * recognisably the site it came from.
43
- */
44
- export function contentTag(domain: string): string {
45
- return domain.replace(/\./g, '_');
46
- }
47
-
48
- /** The attributed credit link for one client site. */
49
- export function creditUrl(domain: string, medium: CreditMedium = 'website'): string {
50
- const params = new URLSearchParams({
51
- utm_source: 'client',
52
- utm_medium: medium,
53
- utm_campaign: 'credits',
54
- /* See the note above: a dot here is a link waiting to be made out of the query string. */
55
- utm_content: contentTag(domain),
56
- });
57
-
58
- return `https://webmonterey.com/?${params}`;
59
- }
Binary file