@cparkerwebm/webmonterey 1.1.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.
@@ -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,8 +24,9 @@
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';
@@ -67,9 +68,27 @@ export interface WebmontereyOptions {
67
68
  * Set false for a site publishing a sitemap another way, rather than having two disagree.
68
69
  */
69
70
  sitemap?: boolean;
71
+ /**
72
+ * Serve `/webmaster` and its share image. Default true.
73
+ *
74
+ * The page every site has: who built it, who to call. The footer credit links to it. A client
75
+ * who will not have it sets this false and the credit then has nowhere to point - so the
76
+ * footer should drop the credit too, and the agreement should say so.
77
+ */
78
+ webmaster?: boolean;
79
+ /**
80
+ * The branch Workers Builds deploys to production. Default `main`.
81
+ *
82
+ * Every other branch is a PREVIEW, and a preview build is different on purpose: every page is
83
+ * noindex, there is no sitemap, robots.txt disallows everything, and Google Tag Manager does
84
+ * not load - so a client's review link can never be indexed, and clicking around it never
85
+ * lands in their analytics. Detected from WORKERS_CI_BRANCH, which Workers Builds injects.
86
+ */
87
+ productionBranch?: string;
70
88
  }
71
89
 
72
90
  const VIRTUAL = {
91
+ build: 'virtual:webm/build',
73
92
  site: 'virtual:webm/site',
74
93
  design: 'virtual:webm/design',
75
94
  tokens: 'virtual:webm/tokens.css',
@@ -78,8 +97,18 @@ const VIRTUAL = {
78
97
  custom: 'virtual:webm/custom',
79
98
  shareImage: 'virtual:webm/share-image',
80
99
  icons: 'virtual:webm/icons',
100
+ webmasterOg: 'virtual:webm/webmaster-og',
81
101
  } as const;
82
102
 
103
+ /*
104
+ * THE WEBMASTER PAGE'S SHARE IMAGE, read here and not in the endpoint that serves it. The
105
+ * Cloudflare adapter builds server modules for the workerd target, where `import.meta.url` is
106
+ * not a file URL, so a `new URL('../assets/...', import.meta.url)` inside a route throws
107
+ * "Invalid URL string" at prerender. This integration runs in Node at config time, where the
108
+ * path is real; the bytes travel to the endpoint as base64 through a virtual module.
109
+ */
110
+ const WEBMASTER_OG = fileURLToPath(new URL('../assets/opengraph-webmaster.png', import.meta.url));
111
+
83
112
  /** Vite resolves virtual ids to a `\0`-prefixed form so other plugins leave them alone. */
84
113
  const resolved = (id: string) => `\0${id}`;
85
114
 
@@ -103,6 +132,17 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
103
132
  const app = appEnabled(files.site);
104
133
  const appPath = resolveAppPath(files.site);
105
134
 
135
+ /*
136
+ * BRANCH PREVIEW OR PRODUCTION. Workers Builds injects WORKERS_CI_BRANCH; anything that
137
+ * is not the production branch is a preview. A local build has no branch and is treated
138
+ * as production, which is what `npm run preview` and the e2e need. See the option.
139
+ */
140
+ const branch = process.env.WORKERS_CI_BRANCH ?? null;
141
+ const preview = branch !== null && branch !== (options.productionBranch ?? 'main');
142
+ if (preview) {
143
+ logger.info(`branch "${branch}" is a preview: noindex, no sitemap, no analytics`);
144
+ }
145
+
106
146
  /*
107
147
  * Editing either config file must rebuild. Without this a palette change in design.json
108
148
  * shows nothing until the dev server is restarted, which reads as the compiler being
@@ -157,7 +197,7 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
157
197
  output: 'static',
158
198
 
159
199
  integrations:
160
- site && options.sitemap !== false
200
+ site && !preview && options.sitemap !== false
161
201
  ? [
162
202
  sitemap({
163
203
  filter: (page) => {
@@ -214,6 +254,8 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
214
254
  },
215
255
  load(id: string) {
216
256
  switch (id) {
257
+ case resolved(VIRTUAL.build):
258
+ return `export default ${JSON.stringify({ preview, branch })};`;
217
259
  case resolved(VIRTUAL.site):
218
260
  return `export default ${JSON.stringify(files.site)};`;
219
261
  case resolved(VIRTUAL.design):
@@ -235,7 +277,7 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
235
277
  * both tags - the scrapers all measure the image themselves anyway, so
236
278
  * saying nothing beats saying something wrong.
237
279
  */
238
- const size = imageSize(join(root, 'public/open-graph.png'));
280
+ const size = imageSize(join(root, 'public/opengraph.png'));
239
281
  return `export default ${JSON.stringify(size)};`;
240
282
  }
241
283
  case resolved(VIRTUAL.icons): {
@@ -283,6 +325,12 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
283
325
  }
284
326
  case resolved(VIRTUAL.forms):
285
327
  return `export const FORMS = ${JSON.stringify(loadForms(root))};`;
328
+ case resolved(VIRTUAL.webmasterOg):
329
+ /* Bytes and the REAL size, so the page never declares dimensions the file does not have. */
330
+ return `export default ${JSON.stringify({
331
+ base64: readFileSync(WEBMASTER_OG, 'base64'),
332
+ ...(imageSize(WEBMASTER_OG) ?? { width: null, height: null }),
333
+ })};`;
286
334
  case resolved(VIRTUAL.registry):
287
335
  /*
288
336
  * Re-exported from the client repo, because every visible component lives
@@ -357,6 +405,21 @@ export default function webmonterey(options: WebmontereyOptions = {}): AstroInte
357
405
  injectRoute({ pattern: '/webm', entrypoint: '@cparkerwebm/webmonterey/pages/webm' });
358
406
  }
359
407
 
408
+ /*
409
+ * THE WEBMASTER PAGE, and its share image served from the package. Indexable and in the
410
+ * sitemap - the footer credit links here rather than off the site. See pages/webmaster.
411
+ */
412
+ if (options.webmaster !== false) {
413
+ injectRoute({
414
+ pattern: '/webmaster',
415
+ entrypoint: '@cparkerwebm/webmonterey/pages/webmaster',
416
+ });
417
+ injectRoute({
418
+ pattern: '/webmaster/og.png',
419
+ entrypoint: '@cparkerwebm/webmonterey/pages/webmaster-og',
420
+ });
421
+ }
422
+
360
423
  /*
361
424
  * THE WEB APP'S PUBLIC PATH. Only when the site has switched the app on AND named a path
362
425
  * other than the folder - with the default there is nothing to rewrite, and a middleware
@@ -4,6 +4,15 @@
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 on any Workers Builds branch other than the
9
+ * production one - every page noindex, no sitemap, no analytics. A local build is not a preview.
10
+ */
11
+ declare module 'virtual:webm/build' {
12
+ const build: { preview: boolean; branch: string | null };
13
+ export default build;
14
+ }
15
+
7
16
  declare module 'virtual:webm/site' {
8
17
  import type { SiteConfig } from '../includes/webmonterey/config.ts';
9
18
  const config: SiteConfig;
@@ -28,8 +37,14 @@ declare module 'virtual:webm/icons' {
28
37
  /** The client's own stylesheet - src/styles/custom/. Side-effect import only. */
29
38
  declare module 'virtual:webm/custom';
30
39
 
40
+ /** The webmaster page's share image - bytes as base64 and its measured size - from the package. */
41
+ declare module 'virtual:webm/webmaster-og' {
42
+ const image: { base64: string; width: number | null; height: number | null };
43
+ export default image;
44
+ }
45
+
31
46
  /**
32
- * The real pixel size of `public/open-graph.png`, measured at build time, or null when there is
47
+ * The real pixel size of `public/opengraph.png`, measured at build time, or null when there is
33
48
  * no readable file there. Nothing else can check it: public/ is copied verbatim.
34
49
  */
35
50
  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,21 @@ 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 BRANCH PREVIEW IS NOINDEX, EVERY PAGE. The review link a client gets is a public workers.dev
187
+ * URL, and a search engine that finds one indexes a duplicate of the site under the wrong
188
+ * hostname. noindex also suppresses the canonical and og:url below, so the preview sends one
189
+ * signal rather than "do not index me" beside "my real address is over there". GTM is skipped on
190
+ * a preview for the same reason in the other direction: a client clicking through their review
191
+ * link must not show up in their own analytics.
192
+ */
193
+ const noindex = noindexProp || build.preview;
194
+ const analyticsOn = analytics && !build.preview;
195
+
176
196
  /*
177
197
  * Canonical URL. Only emitted once `site` is derived from `domain` in webmonterey.json.
178
198
  * Astro.site is undefined until then, and a canonical pointing at localhost is worse than none.
@@ -264,7 +284,7 @@ const StructuredData = structuredData;
264
284
 
265
285
  <!-- FIRST. Sets consent state and Consent Mode defaults before any third party loads. -->
266
286
  <ConsentInit />
267
- {analytics && gtmId && <TagManager id={gtmId} />}
287
+ {analyticsOn && gtmId && <TagManager id={gtmId} />}
268
288
 
269
289
  {
270
290
  /*
@@ -351,7 +371,7 @@ const StructuredData = structuredData;
351
371
  */
352
372
  }
353
373
  {
354
- StructuredData && !noindex && (
374
+ StructuredData && renderStructuredData && !noindex && (
355
375
  <StructuredData title={title} description={description} image={shareImageUrl} />
356
376
  )
357
377
  }
@@ -363,7 +383,7 @@ const StructuredData = structuredData;
363
383
  <a class="webm-skip-link" href="#webm-main">Skip to content</a>
364
384
 
365
385
  <!-- GTM's noscript iframe must be the first thing in <body>. -->
366
- {analytics && gtmId && <TagManager id={gtmId} noscript />}
386
+ {analyticsOn && gtmId && <TagManager id={gtmId} noscript />}
367
387
 
368
388
  <slot name="header">{Header && <Header />}</slot>
369
389
 
@@ -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,20 @@
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 BRANCH PREVIEW DISALLOWS EVERYTHING. Every page on a preview is already noindex; this is
29
+ * the belt to that brace, and it is the one place Disallow is right - there is nothing on a
30
+ * preview a crawler should ever fetch, and no noindex tag it needs to see.
31
+ */
32
+ if (build.preview) {
33
+ return new Response('User-agent: *\nDisallow: /\n', {
34
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
35
+ });
36
+ }
37
+
26
38
  const lines = ['User-agent: *', 'Allow: /'];
27
39
 
28
40
  /*
@@ -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
+ };
@@ -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>
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
  *
@@ -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>