@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,323 @@
1
+ /*
2
+ * `webm audit [dist/client]` - the pre-launch checks that only a BUILD can answer.
3
+ *
4
+ * `webm doctor` reads source. Some things are only visible in the output: whether every image
5
+ * that reached the page has alt text, whether every internal link lands on a file the build
6
+ * produced (or a route the Worker serves), whether the sitemap the build wrote is complete and
7
+ * advertised. Each is the kind of thing a launch checklist says to "check" and nobody does
8
+ * exhaustively by hand on a forty-page site.
9
+ *
10
+ * Pure where it can be: `audit()` takes the built files and returns findings, so the rules are
11
+ * tested with literal HTML. `run()` reads dist/ and, unless told not to, makes one request per
12
+ * unique external link - the one part that needs a network.
13
+ *
14
+ * WHAT IT DOES NOT DO: write alt text. It lists the images that have none; the launch skill is
15
+ * what looks at each one and writes the words. An empty `alt=""` is a valid declaration that an
16
+ * image is decorative and is not reported - only a MISSING attribute is.
17
+ */
18
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { join, relative, resolve } from 'node:path';
20
+
21
+ export interface AuditInput {
22
+ /** Relative html path -> contents, for every page in the build. */
23
+ pages: Map<string, string>;
24
+ /** Whether a relative path exists in the build output. */
25
+ exists: (rel: string) => boolean;
26
+ /** Read a relative non-html file from the build, or null. */
27
+ read: (rel: string) => string | null;
28
+ /** wrangler.jsonc's run_worker_first, so an on-demand route is not reported as broken. */
29
+ workerFirst: string[];
30
+ /** The production origin, for sitemap and external-link decisions. Undefined while unset. */
31
+ origin?: string;
32
+ }
33
+
34
+ export interface AuditReport {
35
+ missingAlt: { page: string; src: string }[];
36
+ brokenInternal: { page: string; href: string }[];
37
+ /** Unique external URLs, for the caller to probe. */
38
+ external: string[];
39
+ sitemap: { problems: string[]; urls: number };
40
+ }
41
+
42
+ /** The value of one attribute on a tag, or null when absent. Quoted or bare. */
43
+ export function attr(tag: string, name: string): string | null {
44
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
45
+ if (!m) return null;
46
+ return m[1] ?? m[2] ?? m[3] ?? '';
47
+ }
48
+
49
+ /** Whether an attribute is present at all, with or without a value. */
50
+ export function hasAttr(tag: string, name: string): boolean {
51
+ return new RegExp(`\\s${name}(?=[\\s=>/])`, 'i').test(tag);
52
+ }
53
+
54
+ /** Every `<img>` on a page that declares no alt attribute at all. */
55
+ export function imagesWithoutAlt(html: string): string[] {
56
+ const out: string[] = [];
57
+ for (const m of html.matchAll(/<img\b[^>]*>/gi)) {
58
+ const tag = m[0];
59
+ if (hasAttr(tag, 'alt')) continue;
60
+ out.push(attr(tag, 'src') ?? '(no src)');
61
+ }
62
+ return out;
63
+ }
64
+
65
+ const SKIP_SCHEMES = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
66
+
67
+ /** The hrefs on a page, split into internal paths and external URLs. */
68
+ export function links(html: string, origin?: string): { internal: string[]; external: string[] } {
69
+ const internal = new Set<string>();
70
+ const external = new Set<string>();
71
+ for (const m of html.matchAll(/<a\b[^>]*>/gi)) {
72
+ const href = attr(m[0], 'href');
73
+ if (!href || SKIP_SCHEMES.test(href)) continue;
74
+ if (/^https?:\/\//i.test(href)) {
75
+ /* A link to the site's own origin is internal - check it as a path. */
76
+ if (origin && href.startsWith(origin)) internal.add(href.slice(origin.length) || '/');
77
+ else external.add(href);
78
+ continue;
79
+ }
80
+ if (href.startsWith('//')) {
81
+ external.add(`https:${href}`);
82
+ continue;
83
+ }
84
+ internal.add(href);
85
+ }
86
+ return { internal: [...internal], external: [...external] };
87
+ }
88
+
89
+ /**
90
+ * Does an internal href land somewhere? A file the build wrote, in any of the forms the asset
91
+ * router would serve it under, or a route the Worker answers.
92
+ */
93
+ export function resolves(href: string, input: Pick<AuditInput, 'exists' | 'workerFirst'>): boolean {
94
+ let path = href.split('#')[0]!.split('?')[0]!;
95
+ try {
96
+ path = decodeURIComponent(path);
97
+ } catch {
98
+ /* leave it */
99
+ }
100
+ if (!path.startsWith('/')) return true; /* relative to the page; not worth a false positive */
101
+ if (path === '/') return input.exists('index.html');
102
+
103
+ const bare = path.replace(/\/$/, '');
104
+ const rel = bare.slice(1);
105
+ if (input.exists(rel) || input.exists(`${rel}.html`) || input.exists(`${rel}/index.html`)) {
106
+ return true;
107
+ }
108
+ return input.workerFirst.some((entry) =>
109
+ entry.endsWith('/*')
110
+ ? bare === entry.slice(0, -2) || bare.startsWith(entry.slice(0, -1))
111
+ : entry === bare || entry === `${bare}/`,
112
+ );
113
+ }
114
+
115
+ /** `<loc>` values out of a sitemap document. */
116
+ export function locs(xml: string): string[] {
117
+ return [...xml.matchAll(/<loc>\s*([^<]+?)\s*<\/loc>/g)].map((m) => m[1]!);
118
+ }
119
+
120
+ /**
121
+ * The sitemap the build wrote: present, advertised in robots.txt, every child present, every URL
122
+ * on the production origin and landing on a page.
123
+ */
124
+ export function auditSitemap(input: AuditInput): AuditReport['sitemap'] {
125
+ const problems: string[] = [];
126
+ const index = input.read('sitemap-index.xml');
127
+ if (!index) {
128
+ return {
129
+ problems: [
130
+ input.origin
131
+ ? 'sitemap-index.xml was not built'
132
+ : 'no sitemap - `domain` in webmonterey.json is unset, so nothing has an absolute URL',
133
+ ],
134
+ urls: 0,
135
+ };
136
+ }
137
+
138
+ const robots = input.read('robots.txt') ?? '';
139
+ if (!/^Sitemap:\s*\S+/m.test(robots)) problems.push('robots.txt has no Sitemap: line');
140
+
141
+ let urls = 0;
142
+ for (const childUrl of locs(index)) {
143
+ const childRel = childUrl.replace(/^https?:\/\/[^/]+\//, '');
144
+ const child = input.read(childRel);
145
+ if (!child) {
146
+ problems.push(`${childRel} is listed in the index and was not built`);
147
+ continue;
148
+ }
149
+ for (const url of locs(child)) {
150
+ urls++;
151
+ if (input.origin && !url.startsWith(input.origin)) {
152
+ problems.push(`${url} is not on ${input.origin}`);
153
+ continue;
154
+ }
155
+ const path = url.replace(/^https?:\/\/[^/]+/, '') || '/';
156
+ if (!resolves(path, input)) problems.push(`${url} is in the sitemap and has no page`);
157
+ }
158
+ }
159
+ if (urls === 0) problems.push('the sitemap lists no URLs');
160
+ return { problems, urls };
161
+ }
162
+
163
+ export function audit(input: AuditInput): AuditReport {
164
+ const missingAlt: AuditReport['missingAlt'] = [];
165
+ const brokenInternal: AuditReport['brokenInternal'] = [];
166
+ const external = new Set<string>();
167
+
168
+ for (const [page, html] of input.pages) {
169
+ for (const src of imagesWithoutAlt(html)) missingAlt.push({ page, src });
170
+ const found = links(html, input.origin);
171
+ for (const href of found.internal) {
172
+ if (!resolves(href, input)) brokenInternal.push({ page, href });
173
+ }
174
+ for (const url of found.external) external.add(url);
175
+ }
176
+
177
+ return {
178
+ missingAlt,
179
+ brokenInternal,
180
+ external: [...external].sort(),
181
+ sitemap: auditSitemap(input),
182
+ };
183
+ }
184
+
185
+ /* ── the command ─────────────────────────────────────────────────────────────────────────── */
186
+
187
+ function walk(dir: string): string[] {
188
+ if (!existsSync(dir)) return [];
189
+ return readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
190
+ const full = join(dir, e.name);
191
+ return e.isDirectory() ? walk(full) : [full];
192
+ });
193
+ }
194
+
195
+ /** Strip // and /* comments so JSON.parse can read a .jsonc file. */
196
+ function parseJsonc<T>(source: string): T {
197
+ const stripped = source
198
+ .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (m, comment) => (comment ? '' : m))
199
+ .replace(/,(\s*[}\]])/g, '$1');
200
+ return JSON.parse(stripped) as T;
201
+ }
202
+
203
+ /**
204
+ * One request per unique external URL. HEAD first; a 405 gets a GET, because plenty of servers
205
+ * refuse HEAD and answer GET. Failures are reported as warnings, not failures: many sites block
206
+ * anything that is not a browser, and a link that a bot cannot fetch is not necessarily broken.
207
+ */
208
+ async function probe(urls: string[]): Promise<{ url: string; status: string }[]> {
209
+ const bad: { url: string; status: string }[] = [];
210
+ const queue = [...urls];
211
+ const worker = async () => {
212
+ for (let url = queue.shift(); url; url = queue.shift()) {
213
+ try {
214
+ let res = await fetch(url, {
215
+ method: 'HEAD',
216
+ redirect: 'follow',
217
+ signal: AbortSignal.timeout(8000),
218
+ });
219
+ if (res.status === 405 || res.status === 403) {
220
+ res = await fetch(url, {
221
+ method: 'GET',
222
+ redirect: 'follow',
223
+ signal: AbortSignal.timeout(8000),
224
+ });
225
+ }
226
+ if (res.status >= 400) bad.push({ url, status: String(res.status) });
227
+ } catch (error) {
228
+ bad.push({ url, status: error instanceof Error ? error.name : 'error' });
229
+ }
230
+ }
231
+ };
232
+ await Promise.all(Array.from({ length: 6 }, worker));
233
+ return bad.sort((a, b) => a.url.localeCompare(b.url));
234
+ }
235
+
236
+ export async function run(argv: string[]): Promise<number> {
237
+ const dist = resolve(argv.find((a) => !a.startsWith('-')) ?? 'dist/client');
238
+ const noExternal = argv.includes('--no-external');
239
+
240
+ if (!existsSync(join(dist, 'index.html'))) {
241
+ console.error(`webm audit: no build at ${dist}. Run \`npm run build\` first.`);
242
+ return 1;
243
+ }
244
+
245
+ const siteRoot = process.cwd();
246
+ const files = walk(dist);
247
+ const rels = new Set(files.map((f) => relative(dist, f)));
248
+ const pages = new Map(
249
+ files
250
+ .filter((f) => f.endsWith('.html'))
251
+ .map((f) => [relative(dist, f), readFileSync(f, 'utf8')]),
252
+ );
253
+
254
+ const wranglerPath = ['wrangler.jsonc', 'wrangler.json']
255
+ .map((f) => join(siteRoot, f))
256
+ .find(existsSync);
257
+ const wrangler = wranglerPath
258
+ ? parseJsonc<{ assets?: { run_worker_first?: string[] } }>(readFileSync(wranglerPath, 'utf8'))
259
+ : null;
260
+
261
+ const sitePath = join(siteRoot, 'webmonterey.json');
262
+ const site = existsSync(sitePath)
263
+ ? (JSON.parse(readFileSync(sitePath, 'utf8')) as { domain?: string })
264
+ : {};
265
+ const origin = site.domain && site.domain !== 'CHANGEME' ? `https://${site.domain}` : undefined;
266
+
267
+ const report = audit({
268
+ pages,
269
+ exists: (rel) => rels.has(rel),
270
+ read: (rel) =>
271
+ rels.has(rel) && statSync(join(dist, rel)).isFile()
272
+ ? readFileSync(join(dist, rel), 'utf8')
273
+ : null,
274
+ workerFirst: wrangler?.assets?.run_worker_first ?? [],
275
+ origin,
276
+ });
277
+
278
+ let failed = 0;
279
+ const section = (ok: boolean, title: string) => console.log(`${ok ? ' ok ' : 'FAIL '} ${title}`);
280
+
281
+ section(report.missingAlt.length === 0, `Every image declares alt text (${pages.size} pages)`);
282
+ for (const { page, src } of report.missingAlt)
283
+ console.log(` ${page}: <img src="${src}"> has no alt attribute`);
284
+ if (report.missingAlt.length) {
285
+ failed++;
286
+ console.log(
287
+ ` Write alt text for each - what the image shows, in context - or alt="" if it is decorative.`,
288
+ );
289
+ }
290
+
291
+ section(
292
+ report.brokenInternal.length === 0,
293
+ 'Every internal link lands on a page or a Worker route',
294
+ );
295
+ for (const { page, href } of report.brokenInternal) console.log(` ${page}: ${href}`);
296
+ if (report.brokenInternal.length) failed++;
297
+
298
+ section(
299
+ report.sitemap.problems.length === 0,
300
+ `The sitemap is complete and advertised (${report.sitemap.urls} URLs)`,
301
+ );
302
+ for (const p of report.sitemap.problems) console.log(` ${p}`);
303
+ if (report.sitemap.problems.length) failed++;
304
+
305
+ if (noExternal) {
306
+ console.log(` -- ${report.external.length} external links not probed (--no-external)`);
307
+ } else if (report.external.length) {
308
+ const bad = await probe(report.external);
309
+ console.log(
310
+ `${bad.length ? 'warn ' : ' ok '} ${report.external.length} external links respond (${bad.length} did not)`,
311
+ );
312
+ for (const { url, status } of bad) console.log(` ${status.padEnd(12)} ${url}`);
313
+ if (bad.length)
314
+ console.log(
315
+ ` Open each in a browser before deciding it is broken - many sites refuse bots.`,
316
+ );
317
+ } else {
318
+ console.log(' ok no external links');
319
+ }
320
+
321
+ console.log(`\n${failed === 0 ? 'audit clean' : `${failed} check(s) failed`}`);
322
+ return failed ? 1 : 0;
323
+ }
@@ -217,7 +217,7 @@ test('a site with components but no credit import warns', () => {
217
217
  const ctx = base({
218
218
  components: new Map([['src/components/regions/footer/footer.astro', '<footer>hi</footer>']]),
219
219
  });
220
- const result = runCheck('agency-credit', ctx);
220
+ const result = runCheck('webmaster-credit', ctx);
221
221
  assert.equal(result.status, 'warn');
222
222
  assert.match(result.detail!, /footer component/);
223
223
  });
@@ -227,15 +227,15 @@ test('a footer that imports the credit passes', () => {
227
227
  components: new Map([
228
228
  [
229
229
  'src/components/regions/footer/footer.astro',
230
- `import Credit from '@cparkerwebm/webmonterey/webmonterey/credits/Credit.astro';`,
230
+ `import Webmaster from '@cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro';`,
231
231
  ],
232
232
  ]),
233
233
  });
234
- assert.equal(runCheck('agency-credit', ctx).status, 'pass');
234
+ assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
235
235
  });
236
236
 
237
237
  test('a site with no components yet is not nagged', () => {
238
- assert.equal(runCheck('agency-credit', base()).status, 'pass');
238
+ assert.equal(runCheck('webmaster-credit', base()).status, 'pass');
239
239
  });
240
240
 
241
241
  test('a comment explaining a trap does not trip the check that enforces it', () => {
@@ -274,7 +274,7 @@ test("WebMonterey's own site is not asked to credit itself", () => {
274
274
  site: { client: 'WebMonterey', domain: 'webmonterey.com' },
275
275
  components: new Map([['src/components/regions/footer/footer.astro', '<footer>x</footer>']]),
276
276
  });
277
- assert.equal(runCheck('agency-credit', ctx).status, 'pass');
277
+ assert.equal(runCheck('webmaster-credit', ctx).status, 'pass');
278
278
  });
279
279
 
280
280
  test('a cron with no custom entrypoint FAILS, and names the fix', () => {
@@ -405,7 +405,7 @@ test("a placeholder favicon still in public/ fails - it is the agency's mark on
405
405
  */
406
406
  const ctx = base({
407
407
  site: { client: 'Acme', domain: 'acme.com', launched: '2026-03-01' },
408
- placeholders: ['public/favicon.svg', 'public/open-graph.png'],
408
+ placeholders: ['public/favicon.svg', 'public/opengraph.png'],
409
409
  });
410
410
  const result = runCheck('placeholder-branding', ctx);
411
411
  assert.equal(result.status, 'fail', 'a LAUNCHED site shipping the agency mark is a fault');
package/src/cli/checks.ts CHANGED
@@ -676,14 +676,16 @@ export const CHECKS: Check[] = [
676
676
  },
677
677
  },
678
678
  {
679
- id: 'agency-credit',
680
- title: 'Something renders the agency credit',
681
- silentAs: 'the site ships with no "Powered by WebMonterey" and nobody notices for months',
679
+ id: 'webmaster-credit',
680
+ title: 'Something renders the webmaster credit',
681
+ silentAs:
682
+ 'the site ships with no "Powered by WebMonterey", the /webmaster page is orphaned, and nobody notices for months',
682
683
  run(ctx) {
683
684
  /*
684
685
  * The package ships no footer - it ships no components at all - so the credit is imported
685
686
  * by whichever site component renders the footer. That is the right seam and it is also
686
- * easy to simply never do, which is how live client sites ended up without it.
687
+ * easy to simply never do, which is how live client sites ended up without it. Without it
688
+ * the /webmaster page the package injects is reachable from nothing.
687
689
  *
688
690
  * A warning, not a failure: a site mid-build has no footer yet, and failing there trains
689
691
  * people to ignore the doctor. `/webm:launch` is where it becomes blocking.
@@ -697,11 +699,11 @@ export const CHECKS: Check[] = [
697
699
  if (ctx.site.domain === 'webmonterey.com') return pass;
698
700
 
699
701
  for (const src of ctx.components.values()) {
700
- if (/webmonterey\/credits/.test(stripComments(src))) return pass;
702
+ if (/webmonterey\/webmaster/.test(stripComments(src))) return pass;
701
703
  }
702
704
  return warn(
703
- 'no component imports @cparkerwebm/webmonterey/webmonterey/credits/Credit.astro. ' +
704
- 'The footer component is where it goes.',
705
+ 'no component imports @cparkerwebm/webmonterey/webmonterey/webmaster/Webmaster.astro. ' +
706
+ 'The footer component is where it goes; it links to the /webmaster page.',
705
707
  );
706
708
  },
707
709
  },
@@ -14,7 +14,7 @@
14
14
  * constant — and a caller that has to remember to pass the year eventually forgets, which
15
15
  * shows up as a stale copyright the following January.
16
16
  */
17
- import { CREDIT_TEXT, creditUrl } from '../includes/webmonterey/credits/credit.ts';
17
+ import { CREDIT_TEXT, creditUrl } from '../includes/webmonterey/webmaster/webmaster.ts';
18
18
  import { DEFAULT_COPY, fill } from '../includes/webmonterey/copy-defaults.ts';
19
19
 
20
20
  export interface EmailFooterInput {
@@ -75,13 +75,13 @@ export function renderFooterHtml(input: EmailFooterInput): string {
75
75
  * in a browser is the expected behavior, so the warning would be noise about something no
76
76
  * reader was surprised by. Nothing is lost that email a11y actually asks for.
77
77
  *
78
- * `title` is absent from both links ON PURPOSE, here and in Credit.astro. It is not reliably
78
+ * `title` is absent from both links ON PURPOSE, here and in Webmaster.astro. It is not reliably
79
79
  * announced by screen readers, is unreachable by keyboard and touch entirely, and either
80
80
  * duplicates the link text or competes with it for the accessible name. The link text is the
81
81
  * accessible name; that is the mechanism that works.
82
82
  *
83
83
  * `rel="noopener"` without `noreferrer`, also on purpose: the referrer IS the attribution.
84
- * Stripping it would leave only utm_content. See credit.ts.
84
+ * Stripping it would leave only utm_content. See webmaster.ts.
85
85
  */
86
86
  return ` <div style="max-width:640px;margin:0 auto;padding:24px 32px 8px;text-align:center;font-size:13px;line-height:1.6;color:#3f3f3f;">
87
87
  <p style="margin:0;">&copy; ${year} ${escapeHtml(input.client)}</p>
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * src/assets/ images the DESIGN uses — imported, hashed, optimised at build by sharp.
7
7
  * A logo, an icon, a hero shot. These belong in the repo.
8
- * public/ small fixed files that need a stable URL (favicons, open-graph.png).
8
+ * public/ small fixed files that need a stable URL (favicons, opengraph.png).
9
9
  * R2 (here) everything too large or too numerous to sit in git: video, audio, PDFs,
10
10
  * photo galleries, downloads, anything migrated wholesale off a WordPress
11
11
  * uploads folder.
@@ -67,6 +67,13 @@ export interface Copy {
67
67
  /** `{domain}` is replaced with the site's domain. */
68
68
  footerNotice: string;
69
69
  };
70
+ /** The /webmaster page. `intro` wraps the agency link: `before` <a>WebMonterey</a> `after`. */
71
+ webmaster: {
72
+ title: string;
73
+ description: string;
74
+ intro: { before: string; after: string };
75
+ body: string[];
76
+ };
70
77
  }
71
78
 
72
79
  /*
@@ -125,6 +132,19 @@ export const DEFAULT_COPY: Copy = {
125
132
  reference: 'Reference: #{id}',
126
133
  footerNotice: 'This is an automated notification for your account at the {domain} website.',
127
134
  },
135
+ webmaster: {
136
+ title: 'Our Webmaster',
137
+ description:
138
+ 'This website was designed, built and managed by WebMonterey, a webmaster maintenance service in Monterey, California.',
139
+ intro: {
140
+ before: 'This website was designed, built and managed by',
141
+ after:
142
+ ', a webmaster maintenance service in Monterey, California. WebMonterey handles the hosting, security, updates and ongoing care of the site so that we can focus on what we do.',
143
+ },
144
+ body: [
145
+ "If you have a question about this website, notice something that isn't working, or have trouble using a page, please let WebMonterey know and they will take care of it.",
146
+ ],
147
+ },
128
148
  };
129
149
 
130
150
  /** Merge the site's overrides over the defaults, key by key, at any depth. */
@@ -0,0 +1,52 @@
1
+ ---
2
+ /*
3
+ * The webmaster credit. Goes in the site footer.
4
+ *
5
+ * <Webmaster /> -> Powered by WebMonterey
6
+ *
7
+ * AN INTERNAL LINK. It points at the site's own /webmaster page, which the package injects on
8
+ * every site - so the visitor stays on the client's site, and the page they land on says who
9
+ * built it and who to contact when something is wrong. The outbound link to the agency lives on
10
+ * that page, once, with its UTM parameters. See webmaster.ts for why.
11
+ *
12
+ * No target, no rel: this is an ordinary same-site link and it navigates like one. The old
13
+ * outbound credit opened a new tab; an internal link that did would be a bug.
14
+ *
15
+ * NO `title` ATTRIBUTE, and that is the correct state rather than an omission. `title` is not
16
+ * reliably announced by screen readers, is unreachable by keyboard and touch entirely, and either
17
+ * duplicates the link text or competes with it for the accessible name.
18
+ */
19
+ import { CREDIT_TEXT, WEBMASTER_PATH } from './webmaster.ts';
20
+
21
+ interface Props {
22
+ class?: string;
23
+ }
24
+
25
+ const { class: className } = Astro.props;
26
+ ---
27
+
28
+ <p class:list={['webm-credit', className]}>
29
+ <a href={WEBMASTER_PATH}>{CREDIT_TEXT}</a>
30
+ </p>
31
+
32
+ <style>
33
+ @layer webm.components.core {
34
+ .webm-credit {
35
+ font-size: var(--webm-font-size-xs);
36
+ color: var(--webm-text-muted);
37
+ }
38
+
39
+ /*
40
+ * Underline at the text's own color. An underline in --webm-border-subtle is ~1.3:1 on
41
+ * white - effectively invisible, leaving nothing to mark the link as clickable.
42
+ */
43
+ .webm-credit a {
44
+ color: inherit;
45
+ text-decoration-color: currentColor;
46
+ }
47
+
48
+ .webm-credit a:hover {
49
+ color: var(--webm-link-hover);
50
+ }
51
+ }
52
+ </style>
@@ -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
+ });