@cparkerwebm/webmonterey 1.1.0 → 1.3.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 (41) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +6 -0
  3. package/dist/webm.mjs +445 -61
  4. package/package.json +5 -3
  5. package/skills/launch/SKILL.md +57 -9
  6. package/skills/start/SKILL.md +66 -12
  7. package/skills/traps/SKILL.md +17 -0
  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 +46 -6
  12. package/src/cli/checks.ts +52 -12
  13. package/src/cli/doctor.ts +78 -2
  14. package/src/cli/scaffold.test.ts +9 -0
  15. package/src/cli/scaffold.ts +12 -30
  16. package/src/cli/settings.ts +106 -0
  17. package/src/cli/sync.test.ts +61 -0
  18. package/src/cli/sync.ts +64 -1
  19. package/src/emails/footer.ts +3 -3
  20. package/src/includes/cloudflare/r2/media.ts +1 -1
  21. package/src/includes/webmonterey/config.test.ts +51 -0
  22. package/src/includes/webmonterey/config.ts +49 -0
  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 +91 -4
  28. package/src/integration/virtual.d.ts +19 -1
  29. package/src/layouts/base.astro +31 -8
  30. package/src/package.test.ts +20 -0
  31. package/src/pages/robots.txt.ts +14 -0
  32. package/src/pages/webmaster-og.png.ts +31 -0
  33. package/src/pages/webmaster.astro +121 -0
  34. package/template/public/opengraph.png +0 -0
  35. package/template/scripts/test-hooks.mjs +1 -1
  36. package/template/site/CLAUDE.md +25 -2
  37. package/src/includes/webmonterey/credits/Credit.astro +0 -80
  38. package/src/includes/webmonterey/credits/credit.test.ts +0 -111
  39. package/src/includes/webmonterey/credits/credit.ts +0 -59
  40. package/template/public/open-graph.png +0 -0
  41. /package/template/assets/{open-graph.png → opengraph.png} +0 -0
@@ -0,0 +1,74 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ import { AGENCY, contentTag, CREDIT_TEXT, creditUrl, WEBMASTER_PATH } from './webmaster.ts';
6
+
7
+ /*
8
+ * Webmaster.astro read as SOURCE, because it cannot be imported here: an .astro file only
9
+ * resolves inside an Astro build. A source assertion is the weaker tool and it is the one
10
+ * available.
11
+ */
12
+ const componentSource = readFileSync(new URL('./Webmaster.astro', import.meta.url), 'utf8');
13
+ const template = componentSource.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '');
14
+ const anchors: string[] = template.match(/<a\s[^>]*>/g) ?? [];
15
+ const anchor = (): string => anchors[0] ?? '';
16
+
17
+ test('creditUrl points at the agency home page, not a /credits page', () => {
18
+ const url = new URL(creditUrl('example.com'));
19
+ assert.equal(url.origin, 'https://webmonterey.com');
20
+ assert.equal(url.pathname, '/');
21
+ });
22
+
23
+ test('creditUrl carries all four UTM parameters', () => {
24
+ const { searchParams } = new URL(creditUrl('example.com'));
25
+ assert.equal(searchParams.get('utm_source'), 'client');
26
+ assert.equal(searchParams.get('utm_campaign'), 'webmaster');
27
+ assert.equal(searchParams.get('utm_content'), 'example_com');
28
+ });
29
+
30
+ test('utm_medium separates the two surfaces, and defaults to website', () => {
31
+ assert.equal(
32
+ new URL(creditUrl('example.com', 'website')).searchParams.get('utm_medium'),
33
+ 'website',
34
+ );
35
+ assert.equal(new URL(creditUrl('example.com', 'email')).searchParams.get('utm_medium'), 'email');
36
+ assert.equal(new URL(creditUrl('example.com')).searchParams.get('utm_medium'), 'website');
37
+ });
38
+
39
+ test('utm_content is the production domain, with every dot as an underscore', () => {
40
+ const url = new URL(creditUrl('sub.example.co.uk', 'email'));
41
+ assert.equal(url.searchParams.get('utm_content'), 'sub_example_co_uk');
42
+ /* Scoped to the query: the destination host is allowed to look like a host. */
43
+ assert.equal(url.search.includes('.'), false);
44
+ });
45
+
46
+ test('contentTag changes nothing but the dots', () => {
47
+ assert.equal(contentTag('localhost'), 'localhost');
48
+ assert.equal(contentTag('steven-glaze.com'), 'steven-glaze_com');
49
+ });
50
+
51
+ test('CREDIT_TEXT is the shared wording', () => {
52
+ assert.equal(CREDIT_TEXT, 'Powered by WebMonterey');
53
+ });
54
+
55
+ test('the agency identity is internally consistent', () => {
56
+ assert.ok(AGENCY.id.startsWith(AGENCY.url), 'the @id lives on the agency origin');
57
+ assert.ok(AGENCY.sameAs.every((u) => u.startsWith('https://')));
58
+ assert.equal(new Set(AGENCY.sameAs).size, AGENCY.sameAs.length, 'no duplicate profile');
59
+ });
60
+
61
+ /* ── the footer credit: an INTERNAL link now ──────────────────────────────────────────────── */
62
+
63
+ test("the site credit renders exactly one link, to the site's own webmaster page", () => {
64
+ assert.equal(anchors.length, 1, `expected one anchor, found ${anchors.length}`);
65
+ assert.match(anchor(), new RegExp(`href=\\{WEBMASTER_PATH\\}`));
66
+ assert.equal(WEBMASTER_PATH, '/webmaster');
67
+ });
68
+
69
+ test('an internal link does not open a new tab and carries no rel', () => {
70
+ // The old outbound credit opened a new tab; an internal link that did would be a bug.
71
+ assert.doesNotMatch(anchor(), /target=/);
72
+ assert.doesNotMatch(anchor(), /rel=/);
73
+ assert.doesNotMatch(template, /opens in a new tab/);
74
+ });
@@ -0,0 +1,89 @@
1
+ /*
2
+ * The webmaster credit, in ONE place.
3
+ *
4
+ * Two surfaces render it and they cannot share a component: the site footer is an .astro
5
+ * component, and transactional email is a string with inline styles (email clients strip <style>
6
+ * blocks). Without this module the wording exists twice, and the next time it changes one copy
7
+ * gets missed.
8
+ *
9
+ * WHERE THE FOOTER LINK GOES CHANGED. It used to leave the client's site for webmonterey.com. Now
10
+ * it goes to the site's OWN /webmaster page - indexable, in the sitemap, rendered with the site's
11
+ * chrome - and THAT page carries the one outbound link. Three things that buys: the visitor stays
12
+ * on the client's site; every client site has a page that says who to call when something is
13
+ * wrong; and the outbound link sits on a real page with real copy, which is a backlink rather
14
+ * than a footer credit. The email footer keeps the outbound link, because an email cannot
15
+ * usefully point at a page on the site it is about.
16
+ *
17
+ * utm_content is ALWAYS the production domain from webmonterey.json, never the host the page is
18
+ * served from - a preview build still reports the client's own domain, so staging traffic does
19
+ * not fragment the attribution. It is written with underscores (`example_com`): a dot makes the
20
+ * value look like a hostname, and analytics UIs read it as one rather than as the label it is.
21
+ *
22
+ * THE AGENCY'S IDENTITY IS HERE TOO, for the webmaster page's structured data. It is the one
23
+ * place in the package that names the agency, deliberately: the credit is the package saying who
24
+ * built the framework, which a public package is allowed to do, and keeping it in one module
25
+ * means a change to the name, the address or a profile is one edit.
26
+ */
27
+
28
+ /** The credit wording. Rendered verbatim on the site and in email. */
29
+ export const CREDIT_TEXT = 'Powered by WebMonterey';
30
+
31
+ /** The page every site has. Fixed - the footer, the sitemap and the doctor all rely on it. */
32
+ export const WEBMASTER_PATH = '/webmaster';
33
+
34
+ /** The share image the webmaster page carries, served from the package by the integration. */
35
+ export const WEBMASTER_OG_PATH = '/webmaster/og.png';
36
+
37
+ /**
38
+ * The agency, as the webmaster page's structured data describes it. The `@id` is the same
39
+ * entity webmonterey.com declares for itself, so every client page points at one organization
40
+ * rather than a hundred copies of it.
41
+ */
42
+ export const AGENCY = {
43
+ id: 'https://webmonterey.com/#organization',
44
+ name: 'WebMonterey',
45
+ url: 'https://webmonterey.com/',
46
+ description:
47
+ 'A webmaster maintenance service in Monterey, California: design, build, hosting, security and ongoing care for small-business websites.',
48
+ address: { addressLocality: 'Monterey', addressRegion: 'CA', addressCountry: 'US' },
49
+ sameAs: [
50
+ /* The Google Business Profile, by its Knowledge Graph id - the stable form of the share link. */
51
+ 'https://www.google.com/search?kgmid=/g/11nvks0plt',
52
+ 'https://www.linkedin.com/company/webmonterey',
53
+ 'https://www.facebook.com/webmonterey',
54
+ 'https://www.youtube.com/@webmonterey',
55
+ 'https://www.pinterest.com/webmonterey',
56
+ 'https://github.com/webmonterey',
57
+ 'https://www.crunchbase.com/organization/webmonterey',
58
+ 'https://www.alignable.com/monterey-ca/webmonterey',
59
+ 'https://www.yelp.com/biz/webmonterey-monterey',
60
+ ],
61
+ } as const;
62
+
63
+ /**
64
+ * Which surface the click came from. Kept separate from utm_content so the report can tell a
65
+ * page click from an email click without splitting it per client.
66
+ */
67
+ export type CreditMedium = 'website' | 'email';
68
+
69
+ /**
70
+ * The client's domain as a UTM label rather than as a domain. Dots only - the value is
71
+ * otherwise left exactly as webmonterey.json wrote it, so what lands in the report is still
72
+ * recognizably the site it came from.
73
+ */
74
+ export function contentTag(domain: string): string {
75
+ return domain.replace(/\./g, '_');
76
+ }
77
+
78
+ /** The attributed link to the agency for one client site. */
79
+ export function creditUrl(domain: string, medium: CreditMedium = 'website'): string {
80
+ const params = new URLSearchParams({
81
+ utm_source: 'client',
82
+ utm_medium: medium,
83
+ utm_campaign: 'webmaster',
84
+ /* See the note above: a dot here is a link waiting to be made out of the query string. */
85
+ utm_content: contentTag(domain),
86
+ });
87
+
88
+ return `${AGENCY.url}?${params}`;
89
+ }
@@ -24,13 +24,19 @@
24
24
  import type { AstroIntegration } from 'astro';
25
25
  import sitemap from '@astrojs/sitemap';
26
26
 
27
- import { existsSync } from 'node:fs';
27
+ import { existsSync, readFileSync } from 'node:fs';
28
28
  import { join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
29
30
 
30
31
  import { compileToCss } from '../design/compile.ts';
31
32
  import { imageSize } from './image-size.ts';
32
33
  import { loadForms, loadSiteFiles, resolveSiteUrl } from './config.ts';
33
- import { APP_DIR, appEnabled, resolveAppPath } from '../includes/webmonterey/config.ts';
34
+ import {
35
+ APP_DIR,
36
+ appEnabled,
37
+ previewReason,
38
+ resolveAppPath,
39
+ } from '../includes/webmonterey/config.ts';
34
40
 
35
41
  export interface WebmontereyOptions {
36
42
  /**
@@ -67,9 +73,31 @@ export interface WebmontereyOptions {
67
73
  * Set false for a site publishing a sitemap another way, rather than having two disagree.
68
74
  */
69
75
  sitemap?: boolean;
76
+ /**
77
+ * Serve `/webmaster` and its share image. Default true.
78
+ *
79
+ * The page every site has: who built it, who to call. The footer credit links to it. A client
80
+ * who will not have it sets this false and the credit then has nowhere to point - so the
81
+ * footer should drop the credit too, and the agreement should say so.
82
+ */
83
+ webmaster?: boolean;
84
+ /**
85
+ * The branch Workers Builds deploys to production. Default `main`.
86
+ *
87
+ * Every other branch is a PREVIEW - and so is every build, on any branch and from any machine,
88
+ * of a site whose webmonterey.json says `environment: "staging"`. A preview build is different
89
+ * on purpose: every page is noindex with no canonical, there is no sitemap, robots.txt
90
+ * disallows everything, and Google Tag Manager does not load - so a client's review link can
91
+ * never be indexed, a site that has not launched cannot be indexed before it exists, and
92
+ * clicking around either never lands in their analytics. The branch comes from
93
+ * WORKERS_CI_BRANCH, which Workers Builds injects; the decision is `isPreviewBuild` in
94
+ * includes/webmonterey/config.ts.
95
+ */
96
+ productionBranch?: string;
70
97
  }
71
98
 
72
99
  const VIRTUAL = {
100
+ build: 'virtual:webm/build',
73
101
  site: 'virtual:webm/site',
74
102
  design: 'virtual:webm/design',
75
103
  tokens: 'virtual:webm/tokens.css',
@@ -78,8 +106,18 @@ const VIRTUAL = {
78
106
  custom: 'virtual:webm/custom',
79
107
  shareImage: 'virtual:webm/share-image',
80
108
  icons: 'virtual:webm/icons',
109
+ webmasterOg: 'virtual:webm/webmaster-og',
81
110
  } as const;
82
111
 
112
+ /*
113
+ * THE WEBMASTER PAGE'S SHARE IMAGE, read here and not in the endpoint that serves it. The
114
+ * Cloudflare adapter builds server modules for the workerd target, where `import.meta.url` is
115
+ * not a file URL, so a `new URL('../assets/...', import.meta.url)` inside a route throws
116
+ * "Invalid URL string" at prerender. This integration runs in Node at config time, where the
117
+ * path is real; the bytes travel to the endpoint as base64 through a virtual module.
118
+ */
119
+ const WEBMASTER_OG = fileURLToPath(new URL('../assets/opengraph-webmaster.png', import.meta.url));
120
+
83
121
  /** Vite resolves virtual ids to a `\0`-prefixed form so other plugins leave them alone. */
84
122
  const resolved = (id: string) => `\0${id}`;
85
123
 
@@ -103,6 +141,32 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
103
141
  const app = appEnabled(files.site);
104
142
  const appPath = resolveAppPath(files.site);
105
143
 
144
+ /*
145
+ * PREVIEW OR PRODUCTION, decided in one place - previewReason - from two signals. A site
146
+ * whose webmonterey.json says `environment: "staging"` is a preview in every build,
147
+ * whatever the branch and whatever the machine; and on a launched site any Workers
148
+ * Builds branch other than the production one is a preview too. A local build of a
149
+ * production site has no branch and is production output, which is what
150
+ * `npm run preview` and the e2e need. See the option, and the function.
151
+ */
152
+ const branch = process.env.WORKERS_CI_BRANCH ?? null;
153
+ const productionBranch = options.productionBranch ?? 'main';
154
+ const reason = previewReason({
155
+ environment: files.site.environment,
156
+ branch,
157
+ productionBranch,
158
+ });
159
+ const preview = reason !== null;
160
+ if (reason === 'staging') {
161
+ logger.info(
162
+ 'environment is "staging" in webmonterey.json: a preview build - noindex, no sitemap, no analytics',
163
+ );
164
+ } else if (reason === 'branch') {
165
+ logger.info(
166
+ `branch "${branch}" is not ${productionBranch}: a preview build - noindex, no sitemap, no analytics`,
167
+ );
168
+ }
169
+
106
170
  /*
107
171
  * Editing either config file must rebuild. Without this a palette change in design.json
108
172
  * shows nothing until the dev server is restarted, which reads as the compiler being
@@ -157,7 +221,7 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
157
221
  output: 'static',
158
222
 
159
223
  integrations:
160
- site && options.sitemap !== false
224
+ site && !preview && options.sitemap !== false
161
225
  ? [
162
226
  sitemap({
163
227
  filter: (page) => {
@@ -214,6 +278,8 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
214
278
  },
215
279
  load(id: string) {
216
280
  switch (id) {
281
+ case resolved(VIRTUAL.build):
282
+ return `export default ${JSON.stringify({ preview, reason, branch })};`;
217
283
  case resolved(VIRTUAL.site):
218
284
  return `export default ${JSON.stringify(files.site)};`;
219
285
  case resolved(VIRTUAL.design):
@@ -235,7 +301,7 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
235
301
  * both tags - the scrapers all measure the image themselves anyway, so
236
302
  * saying nothing beats saying something wrong.
237
303
  */
238
- const size = imageSize(join(root, 'public/open-graph.png'));
304
+ const size = imageSize(join(root, 'public/opengraph.png'));
239
305
  return `export default ${JSON.stringify(size)};`;
240
306
  }
241
307
  case resolved(VIRTUAL.icons): {
@@ -283,6 +349,12 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
283
349
  }
284
350
  case resolved(VIRTUAL.forms):
285
351
  return `export const FORMS = ${JSON.stringify(loadForms(root))};`;
352
+ case resolved(VIRTUAL.webmasterOg):
353
+ /* Bytes and the REAL size, so the page never declares dimensions the file does not have. */
354
+ return `export default ${JSON.stringify({
355
+ base64: readFileSync(WEBMASTER_OG, 'base64'),
356
+ ...(imageSize(WEBMASTER_OG) ?? { width: null, height: null }),
357
+ })};`;
286
358
  case resolved(VIRTUAL.registry):
287
359
  /*
288
360
  * Re-exported from the client repo, because every visible component lives
@@ -357,6 +429,21 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
357
429
  injectRoute({ pattern: '/webm', entrypoint: '@cparkerwebm/webmonterey/pages/webm' });
358
430
  }
359
431
 
432
+ /*
433
+ * THE WEBMASTER PAGE, and its share image served from the package. Indexable and in the
434
+ * sitemap - the footer credit links here rather than off the site. See pages/webmaster.
435
+ */
436
+ if (options.webmaster !== false) {
437
+ injectRoute({
438
+ pattern: '/webmaster',
439
+ entrypoint: '@cparkerwebm/webmonterey/pages/webmaster',
440
+ });
441
+ injectRoute({
442
+ pattern: '/webmaster/og.png',
443
+ entrypoint: '@cparkerwebm/webmonterey/pages/webmaster-og',
444
+ });
445
+ }
446
+
360
447
  /*
361
448
  * THE WEB APP'S PUBLIC PATH. Only when the site has switched the app on AND named a path
362
449
  * other than the folder - with the default there is nothing to rewrite, and a middleware
@@ -4,6 +4,18 @@
4
4
  * These do not exist on disk. Each resolves at build time to something in the CLIENT repo, which
5
5
  * a package cannot import relatively - see includes/webmonterey/config.ts.
6
6
  */
7
+ /**
8
+ * What this build is FOR. `preview` is true when webmonterey.json says `environment: "staging"`,
9
+ * or on any Workers Builds branch other than the production one - every page noindex, no
10
+ * sitemap, no analytics. `reason` says which signal decided it. A local build of a production
11
+ * site is not a preview. See `isPreviewBuild` in includes/webmonterey/config.ts.
12
+ */
13
+ declare module 'virtual:webm/build' {
14
+ import type { PreviewReason } from '../includes/webmonterey/config.ts';
15
+ const build: { preview: boolean; reason: PreviewReason; branch: string | null };
16
+ export default build;
17
+ }
18
+
7
19
  declare module 'virtual:webm/site' {
8
20
  import type { SiteConfig } from '../includes/webmonterey/config.ts';
9
21
  const config: SiteConfig;
@@ -28,8 +40,14 @@ declare module 'virtual:webm/icons' {
28
40
  /** The client's own stylesheet - src/styles/custom/. Side-effect import only. */
29
41
  declare module 'virtual:webm/custom';
30
42
 
43
+ /** The webmaster page's share image - bytes as base64 and its measured size - from the package. */
44
+ declare module 'virtual:webm/webmaster-og' {
45
+ const image: { base64: string; width: number | null; height: number | null };
46
+ export default image;
47
+ }
48
+
31
49
  /**
32
- * The real pixel size of `public/open-graph.png`, measured at build time, or null when there is
50
+ * The real pixel size of `public/opengraph.png`, measured at build time, or null when there is
33
51
  * no readable file there. Nothing else can check it: public/ is copied verbatim.
34
52
  */
35
53
  declare module 'virtual:webm/share-image' {
@@ -35,6 +35,7 @@ import {
35
35
  import { header, footer, panels, structuredData } from 'virtual:webm/registry';
36
36
  import measuredShareImage from 'virtual:webm/share-image';
37
37
  import icons from 'virtual:webm/icons';
38
+ import build from 'virtual:webm/build';
38
39
 
39
40
  /*
40
41
  * Site-wide plumbing, wired in once here rather than per page.
@@ -96,7 +97,7 @@ interface Props {
96
97
  */
97
98
  noindex?: boolean;
98
99
  /**
99
- * The social share image, as a path under `public/`. Defaults to `/open-graph.png`.
100
+ * The social share image, as a path under `public/`. Defaults to `/opengraph.png`.
100
101
  *
101
102
  * MUST be a public/ path, never an imported asset run through `getImage`. On a prerendered
102
103
  * route `getImage` returns a built `/_astro/…` file, but on a `prerender = false` route
@@ -109,7 +110,7 @@ interface Props {
109
110
  /*
110
111
  * Dimensions of `shareImage`, in pixels. THEY MUST TRAVEL WITH IT.
111
112
  *
112
- * These default to the size of the shipped `public/open-graph.png`. Files in public/ are
113
+ * These default to the size of the shipped `public/opengraph.png`. Files in public/ are
113
114
  * copied verbatim and never processed, so nothing can measure the real image at build time
114
115
  * and nothing will warn when these stop matching — a client dropping in a differently sized
115
116
  * card image silently publishes false dimensions. Found exactly that way: a live site
@@ -143,6 +144,13 @@ interface Props {
143
144
  * consistent state rather than an unset one.
144
145
  */
145
146
  analytics?: boolean;
147
+ /**
148
+ * Render the site's `structuredData` component. Default true.
149
+ *
150
+ * Set false on a route that emits its own graph through the `head` slot - the package's
151
+ * /webmaster page does, about the agency - so two nodes never claim the same page `@id`.
152
+ */
153
+ structuredData?: boolean;
146
154
  /**
147
155
  * The back-to-top control. Default true.
148
156
  *
@@ -157,10 +165,10 @@ const {
157
165
  title,
158
166
  description,
159
167
  brandTitle = brandTitles,
160
- noindex = false,
161
- shareImage = '/open-graph.png',
168
+ noindex: noindexProp = false,
169
+ shareImage = '/opengraph.png',
162
170
  /*
163
- * MEASURED, not assumed. Both default to the real size of public/open-graph.png, read from the
171
+ * MEASURED, not assumed. Both default to the real size of public/opengraph.png, read from the
164
172
  * file's own header at build time - so replacing the card art cannot leave the tags describing
165
173
  * the old one, which is what happened on every generation-2 site.
166
174
  *
@@ -170,9 +178,24 @@ const {
170
178
  shareImageWidth = measuredShareImage?.width ?? null,
171
179
  shareImageHeight = measuredShareImage?.height ?? null,
172
180
  analytics = true,
181
+ structuredData: renderStructuredData = true,
173
182
  scrollTop = true,
174
183
  } = Astro.props;
175
184
 
185
+ /*
186
+ * A PREVIEW BUILD IS NOINDEX, EVERY PAGE. A preview is a staging site (`environment` in
187
+ * webmonterey.json) on any hostname, or a non-production branch of a launched one - see
188
+ * isPreviewBuild in includes/webmonterey/config.ts. The review link a client gets is a public
189
+ * workers.dev URL, and a search engine that finds one indexes a duplicate of the site under the
190
+ * wrong hostname - or, for a site that has not launched, indexes the site before it exists.
191
+ * noindex also suppresses the canonical and og:url below, so the preview sends one signal rather
192
+ * than "do not index me" beside "my real address is over there". GTM is skipped on a preview for
193
+ * the same reason in the other direction: a client clicking through their review link must not
194
+ * show up in their own analytics.
195
+ */
196
+ const noindex = noindexProp || build.preview;
197
+ const analyticsOn = analytics && !build.preview;
198
+
176
199
  /*
177
200
  * Canonical URL. Only emitted once `site` is derived from `domain` in webmonterey.json.
178
201
  * Astro.site is undefined until then, and a canonical pointing at localhost is worse than none.
@@ -264,7 +287,7 @@ const StructuredData = structuredData;
264
287
 
265
288
  <!-- FIRST. Sets consent state and Consent Mode defaults before any third party loads. -->
266
289
  <ConsentInit />
267
- {analytics && gtmId && <TagManager id={gtmId} />}
290
+ {analyticsOn && gtmId && <TagManager id={gtmId} />}
268
291
 
269
292
  {
270
293
  /*
@@ -351,7 +374,7 @@ const StructuredData = structuredData;
351
374
  */
352
375
  }
353
376
  {
354
- StructuredData && !noindex && (
377
+ StructuredData && renderStructuredData && !noindex && (
355
378
  <StructuredData title={title} description={description} image={shareImageUrl} />
356
379
  )
357
380
  }
@@ -363,7 +386,7 @@ const StructuredData = structuredData;
363
386
  <a class="webm-skip-link" href="#webm-main">Skip to content</a>
364
387
 
365
388
  <!-- GTM's noscript iframe must be the first thing in <body>. -->
366
- {analytics && gtmId && <TagManager id={gtmId} noscript />}
389
+ {analyticsOn && gtmId && <TagManager id={gtmId} noscript />}
367
390
 
368
391
  <slot name="header">{Header && <Header />}</slot>
369
392
 
@@ -86,6 +86,26 @@ test('every directory the code reads out of the package is published', () => {
86
86
  }
87
87
  });
88
88
 
89
+ test('the webmaster share image is a share-card shape, and is what the seed ships too', async () => {
90
+ /*
91
+ * og:image:width/height on the webmaster page are MEASURED from this file by the integration,
92
+ * so any size works - but a crawler crops to roughly 1.91:1 and wants at least 1200 wide, and
93
+ * an image outside that gets letterboxed or rejected. The same artwork is the default share
94
+ * image a client site starts with, so the two must not drift apart.
95
+ */
96
+ const { imageSize } = await import('./integration/image-size.ts');
97
+ const size = imageSize(join(ROOT, 'src/assets/opengraph-webmaster.png'));
98
+ assert.ok(size, 'src/assets/opengraph-webmaster.png is not a readable PNG/JPEG/GIF');
99
+ assert.ok(size.width >= 1200, `at least 1200 wide, got ${size.width}`);
100
+ const ratio = size.width / size.height;
101
+ assert.ok(Math.abs(ratio - 1.91) < 0.03, `share cards are ~1.91:1, got ${ratio.toFixed(2)}`);
102
+ assert.equal(
103
+ readFileSync(join(ROOT, 'src/assets/opengraph-webmaster.png'), 'base64'),
104
+ readFileSync(join(ROOT, 'template/public/opengraph.png'), 'base64'),
105
+ 'the seed share image and the webmaster image are the same artwork',
106
+ );
107
+ });
108
+
89
109
  test('the package does not depend on itself', () => {
90
110
  /*
91
111
  * IT DID, AND IT SHIPPED. package.json carried
@@ -21,8 +21,22 @@
21
21
  * before concluding the file has been overwritten.
22
22
  */
23
23
  import type { APIRoute } from 'astro';
24
+ import build from 'virtual:webm/build';
24
25
 
25
26
  export const GET: APIRoute = ({ site }) => {
27
+ /*
28
+ * A PREVIEW DISALLOWS EVERYTHING - a staging site on any hostname, or a non-production branch
29
+ * of a launched one (isPreviewBuild in includes/webmonterey/config.ts). Every page on a preview
30
+ * is already noindex; this is the belt to that brace, and it is the one place Disallow is
31
+ * right - there is nothing on a preview a crawler should ever fetch, and no noindex tag it
32
+ * needs to see.
33
+ */
34
+ if (build.preview) {
35
+ return new Response('User-agent: *\nDisallow: /\n', {
36
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
37
+ });
38
+ }
39
+
26
40
  const lines = ['User-agent: *', 'Allow: /'];
27
41
 
28
42
  /*
@@ -0,0 +1,31 @@
1
+ /*
2
+ * /webmaster/og.png - the webmaster page's share image, served FROM THE PACKAGE.
3
+ *
4
+ * public/ is copied verbatim from the site root and the package cannot add to it; a file seeded
5
+ * there is written once and never refreshed. This endpoint serves the image out of the package
6
+ * instead, so it is same-origin on every site, needs nothing in the client's repo, and a
7
+ * redesign reaches every site on `npm update`. Prerendered, so the deployed result is a plain
8
+ * static file.
9
+ *
10
+ * The bytes arrive through a virtual module rather than a file read: the Cloudflare adapter
11
+ * builds routes for the workerd target, where `import.meta.url` is not a file URL and a
12
+ * relative `new URL()` throws at prerender. The integration reads the file in Node and inlines
13
+ * it. See integration/index.ts.
14
+ *
15
+ * The page reads the image's measured size from the same module, so og:image:width and height
16
+ * are always the file's own.
17
+ */
18
+ import type { APIRoute } from 'astro';
19
+ import image from 'virtual:webm/webmaster-og';
20
+
21
+ export const prerender = true;
22
+
23
+ export const GET: APIRoute = () => {
24
+ const bytes = Uint8Array.from(atob(image.base64), (c) => c.charCodeAt(0));
25
+ return new Response(bytes, {
26
+ headers: {
27
+ 'Content-Type': 'image/png',
28
+ 'Cache-Control': 'public, max-age=86400',
29
+ },
30
+ });
31
+ };