@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.
- package/CHANGELOG.md +49 -0
- package/dist/webm.mjs +260 -19
- package/package.json +5 -3
- package/skills/launch/SKILL.md +45 -5
- package/skills/start/SKILL.md +4 -0
- package/skills/traps/SKILL.md +9 -0
- package/src/assets/opengraph-webmaster.png +0 -0
- package/src/cli/audit.test.ts +120 -0
- package/src/cli/audit.ts +323 -0
- package/src/cli/checks.test.ts +6 -6
- package/src/cli/checks.ts +9 -7
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/media.ts +1 -1
- package/src/includes/webmonterey/copy-defaults.ts +20 -0
- package/src/includes/webmonterey/webmaster/Webmaster.astro +52 -0
- package/src/includes/webmonterey/webmaster/webmaster.test.ts +74 -0
- package/src/includes/webmonterey/webmaster/webmaster.ts +89 -0
- package/src/integration/index.ts +66 -3
- package/src/integration/virtual.d.ts +16 -1
- package/src/layouts/base.astro +28 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +12 -0
- package/src/pages/webmaster-og.png.ts +31 -0
- package/src/pages/webmaster.astro +121 -0
- package/template/public/opengraph.png +0 -0
- package/template/scripts/test-hooks.mjs +1 -1
- package/src/includes/webmonterey/credits/Credit.astro +0 -80
- package/src/includes/webmonterey/credits/credit.test.ts +0 -111
- package/src/includes/webmonterey/credits/credit.ts +0 -59
- package/template/public/open-graph.png +0 -0
- /package/template/assets/{open-graph.png → opengraph.png} +0 -0
|
@@ -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
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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' {
|
package/src/layouts/base.astro
CHANGED
|
@@ -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 `/
|
|
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/
|
|
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 = '/
|
|
168
|
+
noindex: noindexProp = false,
|
|
169
|
+
shareImage = '/opengraph.png',
|
|
162
170
|
/*
|
|
163
|
-
* MEASURED, not assumed. Both default to the real size of public/
|
|
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
|
-
{
|
|
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
|
-
{
|
|
386
|
+
{analyticsOn && gtmId && <TagManager id={gtmId} noscript />}
|
|
367
387
|
|
|
368
388
|
<slot name="header">{Header && <Header />}</slot>
|
|
369
389
|
|
package/src/package.test.ts
CHANGED
|
@@ -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
|
package/src/pages/robots.txt.ts
CHANGED
|
@@ -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 '../
|
|
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>
|