@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.
- package/CHANGELOG.md +104 -0
- package/README.md +6 -0
- package/dist/webm.mjs +445 -61
- package/package.json +5 -3
- package/skills/launch/SKILL.md +57 -9
- package/skills/start/SKILL.md +66 -12
- package/skills/traps/SKILL.md +17 -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 +46 -6
- package/src/cli/checks.ts +52 -12
- package/src/cli/doctor.ts +78 -2
- package/src/cli/scaffold.test.ts +9 -0
- package/src/cli/scaffold.ts +12 -30
- package/src/cli/settings.ts +106 -0
- package/src/cli/sync.test.ts +61 -0
- package/src/cli/sync.ts +64 -1
- package/src/emails/footer.ts +3 -3
- package/src/includes/cloudflare/r2/media.ts +1 -1
- package/src/includes/webmonterey/config.test.ts +51 -0
- package/src/includes/webmonterey/config.ts +49 -0
- 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 +91 -4
- package/src/integration/virtual.d.ts +19 -1
- package/src/layouts/base.astro +31 -8
- package/src/package.test.ts +20 -0
- package/src/pages/robots.txt.ts +14 -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/template/site/CLAUDE.md +25 -2
- 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,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
|
+
}
|
package/src/integration/index.ts
CHANGED
|
@@ -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 {
|
|
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/
|
|
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/
|
|
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' {
|
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,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
|
-
{
|
|
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
|
-
{
|
|
389
|
+
{analyticsOn && gtmId && <TagManager id={gtmId} noscript />}
|
|
367
390
|
|
|
368
391
|
<slot name="header">{Header && <Header />}</slot>
|
|
369
392
|
|
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,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
|
+
};
|