@cparkerwebm/webmonterey 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,83 @@
1
+ /*
2
+ * The consent dialog's layout contract, asserted against the component source.
3
+ *
4
+ * These are not style preferences. A consent dialog where "Reject all" sits below the fold is the
5
+ * shape regulators treat as a dark pattern, and it happened here by accident: `overflow-y: auto`
6
+ * was on the form, which contains the buttons as well as the category list, so on a laptop with
7
+ * four categories the decline and save buttons scrolled out of view.
8
+ *
9
+ * It is asserted on the SOURCE rather than on rendered output because there is no headless
10
+ * browser in this toolchain - so this catches the rule being removed or moved, not a visual
11
+ * regression from some other direction. That limit is worth stating rather than pretending.
12
+ */
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { readFileSync } from 'node:fs';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const SOURCE = readFileSync(
19
+ fileURLToPath(new URL('./CookieConsent.astro', import.meta.url)),
20
+ 'utf8',
21
+ );
22
+
23
+ /** The declarations of one rule, by selector, with comments stripped. */
24
+ function rule(selector: string): string {
25
+ const css = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '');
26
+ const at = css.indexOf(`${selector} {`);
27
+ assert.notEqual(at, -1, `no rule for ${selector}`);
28
+ return css.slice(at, css.indexOf('}', at));
29
+ }
30
+
31
+ test('the action buttons are not inside the scrolling region', () => {
32
+ /*
33
+ * The bug. If the form scrolls, the buttons scroll with it, and a visitor has to scroll a
34
+ * consent dialog to find the way to decline.
35
+ */
36
+ assert.doesNotMatch(
37
+ rule('.webm-consent-dialog__form'),
38
+ /overflow-y:\s*auto/,
39
+ 'the form must not be the scroller - the category list is',
40
+ );
41
+ assert.match(rule('.webm-consent-list'), /overflow-y:\s*auto/, 'the list scrolls instead');
42
+ });
43
+
44
+ test('the scroll container can actually shrink', () => {
45
+ /*
46
+ * min-block-size: 0 is load-bearing and looks like noise. A flex item defaults to a minimum of
47
+ * its content size, so without it the list never gets a bounded height, the overflow moves back
48
+ * out to the dialog, and the buttons scroll away again - with the overflow rule still sitting
49
+ * there looking correct.
50
+ */
51
+ assert.match(rule('.webm-consent-list'), /min-block-size:\s*0/);
52
+ assert.match(rule('.webm-consent-dialog__form'), /min-block-size:\s*0/);
53
+ });
54
+
55
+ test('the actions are never compressed instead of the list', () => {
56
+ assert.match(rule('.webm-consent-dialog__actions'), /flex-shrink:\s*0/);
57
+ });
58
+
59
+ test('the dialog is only made a flex container when it is open', () => {
60
+ /*
61
+ * A closed <dialog> is display:none. `display: flex` on the bare element would override that
62
+ * and leave the consent dialog on the page permanently, for every visitor.
63
+ */
64
+ assert.match(rule('.webm-consent-dialog[open]'), /display:\s*flex/);
65
+ assert.doesNotMatch(rule('.webm-consent-dialog'), /display:\s*flex/);
66
+ });
67
+
68
+ test('buttons sit in a row by default, and stack only on a narrow viewport', () => {
69
+ // Three stacked buttons on a desktop banner is what a flattened media query produces.
70
+ assert.match(rule('.webm-consent-banner__actions'), /flex-wrap:\s*wrap/);
71
+ assert.doesNotMatch(rule('.webm-consent-banner__actions'), /flex-direction:\s*column/);
72
+ assert.doesNotMatch(rule('.webm-consent-dialog__actions'), /flex-direction:\s*column/);
73
+ });
74
+
75
+ test('the primary button sets its background and its text color together', () => {
76
+ /*
77
+ * They must live in the same rule. Split across two, an override of one leaves the other -
78
+ * which is exactly how "Accept all" shipped as pale text on a pale background.
79
+ */
80
+ const primary = rule('.webm-consent-btn--primary');
81
+ assert.match(primary, /background-color:\s*var\(--webm-action\)/);
82
+ assert.match(primary, /color:\s*var\(--webm-text-on-action\)/);
83
+ });
@@ -0,0 +1,142 @@
1
+ /*
2
+ * Cookie consent — the API every other feature uses.
3
+ *
4
+ * Client-side only. Import this from a component's <script> block; never from frontmatter.
5
+ *
6
+ * THE STATE LIVES ON <html data-webm-consent="...">, a comma-separated list of granted
7
+ * categories, written by an inline script in <head> before any third party loads. Reading an
8
+ * attribute means a feature can check consent synchronously, with no race against the banner
9
+ * and no import order to get right.
10
+ *
11
+ * WHEN COMPLIANCE IS DISABLED for a site (features.compliance = false in webmonterey.json),
12
+ * the attribute is absent and EVERY function here reports granted. Gated features simply run.
13
+ * That is deliberate: a feature written against this API works unchanged on sites that use
14
+ * consent and sites that do not.
15
+ */
16
+
17
+ declare global {
18
+ interface Window {
19
+ /** Defined by ConsentInit.astro, and by Google Tag Manager once it loads. */
20
+ gtag?: (...args: unknown[]) => void;
21
+ dataLayer?: unknown[];
22
+ }
23
+ }
24
+
25
+ export const CONSENT_CATEGORIES = ['essential', 'functional', 'analytics', 'marketing'] as const;
26
+
27
+ export type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
28
+
29
+ export type ConsentState = Record<ConsentCategory, boolean>;
30
+
31
+ /** Fired on <document> whenever the visitor changes their choices. */
32
+ export const CONSENT_EVENT = 'webm:consent-change';
33
+
34
+ /** Cookie name. Also referenced by the inline script in CookieConsent.astro. */
35
+ export const CONSENT_COOKIE = 'webm_consent';
36
+
37
+ /** One year — matches the wording shown to the visitor in the preferences dialog. */
38
+ export const CONSENT_MAX_AGE = 60 * 60 * 24 * 365;
39
+
40
+ const ALL_GRANTED: ConsentState = {
41
+ essential: true,
42
+ functional: true,
43
+ analytics: true,
44
+ marketing: true,
45
+ };
46
+
47
+ /**
48
+ * Is the consent system running on this site at all?
49
+ *
50
+ * False when the client has compliance switched off. Useful for hiding a "Cookie
51
+ * preferences" footer link that would otherwise open nothing.
52
+ */
53
+ export function isComplianceActive(): boolean {
54
+ return document.documentElement.dataset.webmConsent !== undefined;
55
+ }
56
+
57
+ /** The visitor's current choices. Reports everything granted when compliance is disabled. */
58
+ export function getConsent(): ConsentState {
59
+ const raw = document.documentElement.dataset.webmConsent;
60
+
61
+ if (raw === undefined) return { ...ALL_GRANTED };
62
+
63
+ const granted = new Set(raw.split(',').filter(Boolean));
64
+
65
+ return {
66
+ // Essential is never optional — it is what makes the site work, including storing this
67
+ // very choice. It is listed in the dialog as always-on for transparency, not as a toggle.
68
+ essential: true,
69
+ functional: granted.has('functional'),
70
+ analytics: granted.has('analytics'),
71
+ marketing: granted.has('marketing'),
72
+ };
73
+ }
74
+
75
+ /** Has the visitor allowed this category? */
76
+ export function hasConsent(category: ConsentCategory): boolean {
77
+ return getConsent()[category];
78
+ }
79
+
80
+ /**
81
+ * Run `fn` as soon as `category` is allowed, and again if it is re-granted later.
82
+ *
83
+ * This is the function most features want. It fires immediately when consent already exists,
84
+ * so there is no "did I miss the event?" problem, and it keeps working if the visitor opens
85
+ * preferences later and turns the category on.
86
+ *
87
+ * import { whenConsented } from '../webmonterey/compliance/consent.ts';
88
+ *
89
+ * whenConsented('analytics', () => {
90
+ * const s = document.createElement('script');
91
+ * s.src = 'https://example.com/analytics.js';
92
+ * document.head.appendChild(s);
93
+ * });
94
+ *
95
+ * `fn` runs AT MOST ONCE. Returns an unsubscribe function.
96
+ */
97
+ export function whenConsented(category: ConsentCategory, fn: () => void): () => void {
98
+ let done = false;
99
+
100
+ const run = () => {
101
+ if (done || !hasConsent(category)) return;
102
+ done = true;
103
+ document.removeEventListener(CONSENT_EVENT, run);
104
+ fn();
105
+ };
106
+
107
+ document.addEventListener(CONSENT_EVENT, run);
108
+ run();
109
+
110
+ return () => document.removeEventListener(CONSENT_EVENT, run);
111
+ }
112
+
113
+ /**
114
+ * Subscribe to every change. Unlike `whenConsented` this fires on withdrawal too, so use it
115
+ * when something must be torn down as well as set up.
116
+ */
117
+ export function onConsentChange(handler: (state: ConsentState) => void): () => void {
118
+ const listener = () => handler(getConsent());
119
+ document.addEventListener(CONSENT_EVENT, listener);
120
+ return () => document.removeEventListener(CONSENT_EVENT, listener);
121
+ }
122
+
123
+ /**
124
+ * Open the preferences dialog.
125
+ *
126
+ * Wire this to a "Cookie preferences" link in the footer — US state privacy law expects an
127
+ * ongoing way to change the choice, not just a one-time banner. No-ops when compliance is
128
+ * disabled.
129
+ */
130
+ export function openPreferences(): void {
131
+ document.dispatchEvent(new CustomEvent('webm:consent-open'));
132
+ }
133
+
134
+ /**
135
+ * Was the visitor's browser sending a Global Privacy Control signal?
136
+ *
137
+ * When true, analytics and marketing were switched off automatically and the banner was not
138
+ * shown — the signal IS the choice, and asking again would undermine it.
139
+ */
140
+ export function isGpcHonored(): boolean {
141
+ return document.documentElement.dataset.webmConsentGpc === '1';
142
+ }
@@ -0,0 +1,94 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ APP_DIR,
5
+ isConfigured,
6
+ isStagingDeployment,
7
+ isValidTimeZone,
8
+ PLACEHOLDER,
9
+ resolveAppPath,
10
+ resolveDisplayName,
11
+ workerFirstPaths,
12
+ } from './config.ts';
13
+
14
+ test('the app path defaults to the folder, so the common case needs no rewrite', () => {
15
+ assert.equal(resolveAppPath({}), APP_DIR);
16
+ assert.equal(resolveAppPath({ app: { path: 'portal' } }), 'portal');
17
+ assert.equal(resolveAppPath({ app: { path: '/portal/' } }), 'portal', 'slashes are stripped');
18
+ assert.equal(resolveAppPath({ app: { path: '/' } }), APP_DIR, 'and cannot become the root');
19
+ });
20
+
21
+ test('run_worker_first carries the PUBLIC app path in every form, only when enabled', () => {
22
+ assert.deepEqual(workerFirstPaths({}), ['/_actions/*']);
23
+ assert.deepEqual(workerFirstPaths({ app: { enabled: false, path: 'portal' } }), ['/_actions/*']);
24
+ assert.deepEqual(workerFirstPaths({ app: { enabled: true, path: 'portal' } }), [
25
+ '/_actions/*',
26
+ '/portal/*',
27
+ '/portal',
28
+ '/portal/',
29
+ ]);
30
+ });
31
+
32
+ test('isConfigured rejects the placeholder, empty and absent', () => {
33
+ assert.equal(isConfigured(PLACEHOLDER), false);
34
+ assert.equal(isConfigured('CHANGEME'), false);
35
+ assert.equal(isConfigured(''), false);
36
+ assert.equal(isConfigured(null), false);
37
+ assert.equal(isConfigured(undefined), false);
38
+ assert.equal(isConfigured('Acme Co'), true);
39
+ });
40
+
41
+ test('displayName prefers client, then domain, then a generic label', () => {
42
+ assert.equal(resolveDisplayName('Acme Co', 'acme.com'), 'Acme Co');
43
+ assert.equal(resolveDisplayName('CHANGEME', 'acme.com'), 'acme.com');
44
+ assert.equal(resolveDisplayName('CHANGEME', 'CHANGEME'), 'This website');
45
+ assert.equal(resolveDisplayName(undefined, undefined), 'This website');
46
+ });
47
+
48
+ test('displayName never leaks the placeholder to a visitor or an inbox', () => {
49
+ for (const args of [
50
+ ['CHANGEME', 'CHANGEME'],
51
+ ['', ''],
52
+ [undefined, undefined],
53
+ ] as const) {
54
+ const name = resolveDisplayName(args[0], args[1]);
55
+ assert.ok(name.length > 0);
56
+ assert.ok(!name.includes(PLACEHOLDER));
57
+ }
58
+ });
59
+
60
+ test('Pacific/LA is rejected - it does not exist', () => {
61
+ assert.equal(isValidTimeZone('Pacific/LA'), false);
62
+ assert.equal(isValidTimeZone('America/Los_Angeles'), true);
63
+ assert.equal(isValidTimeZone('Pacific/Honolulu'), true);
64
+ assert.equal(isValidTimeZone('Nowhere/Nothing'), false);
65
+ });
66
+
67
+ test('staging is decided by config OR by a workers.dev hostname, each covering the other', () => {
68
+ // A cron has no hostname, so config is the only signal it can read; a branch preview of a
69
+ // launched site inherits `production` from main, so the hostname is the only signal there.
70
+ assert.equal(isStagingDeployment('staging', null), true);
71
+ assert.equal(isStagingDeployment('production', 'x-webm-acme.acct.workers.dev'), true);
72
+ assert.equal(isStagingDeployment('production', 'acme.com'), false);
73
+ assert.equal(isStagingDeployment(undefined, 'www.acme.com'), false, 'a www variant still sends');
74
+ assert.equal(isStagingDeployment('production', 'notworkers.dev'), false, 'label, not substring');
75
+ });
76
+
77
+ test('a telephone is only "configured" when it is actually set', () => {
78
+ // Same placeholder discipline as client and domain: an unset number must not reach a tel:
79
+ // href, where it renders as a call link that dials nothing.
80
+ assert.equal(isConfigured(''), false);
81
+ assert.equal(isConfigured('CHANGEME'), false);
82
+ assert.equal(isConfigured('+18312220028'), true);
83
+ });
84
+
85
+ test('shortName falls back to the display name when unset', () => {
86
+ /*
87
+ * "About | Friends of the Marina Library" is 44 characters before the page name and Google
88
+ * truncates around 60, so the suffix eats the title it is meant to caption. A client whose
89
+ * name is already short sets nothing and nothing changes for them.
90
+ */
91
+ assert.equal(isConfigured(undefined), false, 'unset falls through to displayName');
92
+ assert.equal(isConfigured('FoML'), true);
93
+ assert.equal(isConfigured(''), false);
94
+ });
@@ -0,0 +1,346 @@
1
+ /*
2
+ * The shape of webmonterey.json, and the virtual module that carries it.
3
+ *
4
+ * A PACKAGE CANNOT REACH THE CLIENT'S FILE. In generation 2 four separate files each did
5
+ * `import site from '../../../webmonterey.json'`, which worked because everything lived in one
6
+ * repo. Here the config sits in the client repo and the code sits in node_modules, so the
7
+ * integration resolves `virtual:webm/site` to the real file at build time and every consumer
8
+ * goes through site.ts.
9
+ *
10
+ * Consolidating on one seam is an improvement rather than a workaround: there is now exactly one
11
+ * place that reads the config, so a field rename is one edit and `webm doctor` has one thing to
12
+ * validate.
13
+ */
14
+
15
+ export interface SiteFeatures {
16
+ /**
17
+ * Cookie consent, GPC honoring and Consent Mode v2. Defaults ON. Set false and every
18
+ * consent-gated feature runs unconditionally - `whenConsented` fires immediately.
19
+ */
20
+ compliance?: boolean;
21
+ d1?: boolean;
22
+ turnstile?: boolean;
23
+ /**
24
+ * RESERVED, and inert today. Will route transactional mail through the platform rather than a
25
+ * site-held Mailgun key once the relay exists. Declared now so switching it on later is a
26
+ * config edit rather than a codemod.
27
+ */
28
+ platform?: boolean;
29
+ }
30
+
31
+ /**
32
+ * Feeds the JSON-LD graph. EVERY field is omitted from output when empty - never guess one to
33
+ * fill it in, because wrong opening hours in structured data is materially worse than none:
34
+ * search engines surface it as fact.
35
+ *
36
+ * There is deliberately no ratings field. Google forbids a business publishing aggregateRating
37
+ * about itself, and doing it anyway risks a manual action.
38
+ */
39
+ export interface Organization {
40
+ /**
41
+ * One or two sentences describing the business, emitted on the organization node.
42
+ *
43
+ * Distinct from a page's meta description: this one describes the ORGANISATION and is the same
44
+ * on every page. Omitted when unset rather than falling back to the homepage's description,
45
+ * which would describe a page and claim it was the business.
46
+ */
47
+ description?: string;
48
+ /**
49
+ * The organisation's LOGO, as a path under public/. Not the share image.
50
+ *
51
+ * This is the mark a knowledge panel draws, and Google wants at least 112x112. Omitted from the
52
+ * structured data entirely when unset - a wrong logo is worse than no logo, so it never falls
53
+ * back to the Open Graph card.
54
+ */
55
+ logo?: string;
56
+ /** Leave as Organization unless the client has a real address the public can visit. */
57
+ type?: string;
58
+ legalName?: string;
59
+ telephone?: string;
60
+ email?: string;
61
+ streetAddress?: string;
62
+ addressLocality?: string;
63
+ addressRegion?: string;
64
+ postalCode?: string;
65
+ addressCountry?: string;
66
+ /** Social and profile URLs. */
67
+ sameAs?: string[];
68
+ /*
69
+ * EVERYTHING BELOW WAS ALREADY READ BY StructuredData.astro AND NOT DECLARED HERE.
70
+ *
71
+ * `tsc --noEmit` does not parse .astro, so the package's own layouts and includes are outside
72
+ * `npm run check` - which is why an interface could drift this far from the code reading it
73
+ * without a word. A client authoring webmonterey.json got no completion and no error for any
74
+ * of these, which is a good way to discover a field does nothing only after publishing.
75
+ */
76
+ /** The person behind the business. Emitted as a Person node, linked from the organization. */
77
+ founder?: {
78
+ name?: string;
79
+ jobTitle?: string;
80
+ /** A sentence or two. Distinct from the organization's own description. */
81
+ description?: string;
82
+ /** A path under public/, resolved against the site origin. */
83
+ image?: string;
84
+ sameAs?: string[];
85
+ };
86
+ /** Places served, for a business whose catchment is wider than its address. */
87
+ areaServed?: string[];
88
+ /** Google's coarse price band - "$", "$$", "$$$". Not a number. */
89
+ priceRange?: string;
90
+ /** Named services, emitted as an OfferCatalog. */
91
+ services?: string[];
92
+ /** Opening hours. `days` are schema.org day names. */
93
+ hours?: { days?: string[]; opens?: string; closes?: string };
94
+ }
95
+
96
+ export interface SiteConfig {
97
+ /**
98
+ * Overrides for any word this package puts in front of a visitor — the consent banner, the 404,
99
+ * form errors, email boilerplate, the back-to-top label.
100
+ *
101
+ * Partial and merged over the defaults at any depth, so a site states only what it disagrees
102
+ * with. See includes/webmonterey/copy-defaults.ts for the full shape and the reasoning: the
103
+ * package owns the mechanism, the client owns what is said, and a site working in another
104
+ * language or another voice should not have to fork a component to say so.
105
+ */
106
+ copy?: Record<string, unknown>;
107
+
108
+ client: string;
109
+ domain: string;
110
+ repo?: string;
111
+ worker?: string;
112
+ launched?: string | null;
113
+
114
+ /**
115
+ * What this deployment is FOR. Defaults to 'production' when unset.
116
+ *
117
+ * The default is load-bearing: every site that predates this field has no value for it, so any
118
+ * other default would silently redirect a live client's email the moment they ran
119
+ * `npm update`. A site opts INTO staging; it never falls into production by accident.
120
+ *
121
+ * Distinct from `launched`, which records whether the site has ever gone live. The two
122
+ * disagree routinely — a launched site still has staging previews — so neither substitutes
123
+ * for the other.
124
+ *
125
+ * Read it anywhere via `environment`, `isStaging` and `isProduction` from webmonterey/site.
126
+ * The first consumer is transactional email, which redirects every recipient to `stagingEmail`
127
+ * on a staging deployment rather than mailing the client's real contacts from a preview.
128
+ */
129
+ environment?: 'production' | 'staging';
130
+
131
+ /**
132
+ * Where a staging deployment's email goes instead of its real recipients.
133
+ *
134
+ * NO DEFAULT, on purpose. The package is public, and an inbox baked into it means a stranger's
135
+ * staging site mails the author. `webm new` fills this from `git config user.email`;
136
+ * `webm doctor` fails a staging site that has it empty; sendEmail refuses rather than guesses.
137
+ */
138
+ stagingEmail?: string;
139
+
140
+ /**
141
+ * IANA zone name. D1 stores UTC via datetime('now'), so nothing reaches a person without
142
+ * passing through this. `America/Los_Angeles` - there is no `Pacific/LA`, and an invalid name
143
+ * makes Intl.DateTimeFormat throw rather than merely show the wrong hour.
144
+ */
145
+ /**
146
+ * A SHORT name for `<title>`, when the display name is too long for one.
147
+ *
148
+ * "About | Friends of the Marina Library" is 44 characters before the page name; Google
149
+ * truncates a title around 60. The suffix is meant to say whose site this is, and a suffix
150
+ * that eats the title defeats itself.
151
+ *
152
+ * Falls back to the display name, so a client whose name is already short sets nothing.
153
+ * Generation 2 hardcoded this per site as BRAND_SHORT in its own copy of site.ts.
154
+ */
155
+ shortName?: string;
156
+ /**
157
+ * Append the site name to every page `<title>`. Default true.
158
+ *
159
+ * Set FALSE for a site whose pages author their OWN full titles. stevenglaze.com writes
160
+ * "Tone Freq Studios | Recording Studio in San Jose, CA" in the page's own JSON; appending the
161
+ * client name to that produces a title too long for any search result to show, with the useful
162
+ * half truncated away.
163
+ *
164
+ * The per-title guard only catches a title that already contains the short name verbatim, and
165
+ * a site with several brands on one domain will not trip it - so this has to be a decision.
166
+ */
167
+ brandTitles?: boolean;
168
+ /**
169
+ * Google Tag Manager container, `GTM-XXXXXXX`.
170
+ *
171
+ * TRACKED HERE RATHER THAN IN `.env`, and that is the whole point of the field. The id is
172
+ * PUBLIC by design - it appears in the source of every page - so there is nothing to protect,
173
+ * while `.env` is gitignored and therefore absent on Workers Builds. A container id set only
174
+ * in the environment works perfectly on a laptop and silently loads nothing in production,
175
+ * which is a failure with no error and no visible symptom: the pages render, the tags never
176
+ * fire, and the client discovers it in an empty analytics report weeks later.
177
+ *
178
+ * `PUBLIC_GTM_ID` in the environment still WINS, for pointing a branch at a different
179
+ * container. Leave both unset and no GTM renders at all.
180
+ */
181
+ gtmId?: string;
182
+ timeZone?: string;
183
+ /** Date order, number separators, currency. The formatting half of `brand.voice`. */
184
+ locale?: string;
185
+
186
+ organization?: Organization;
187
+ features?: SiteFeatures;
188
+
189
+ /**
190
+ * THE WEB APP NAMESPACE, reserved on every site whether or not it ever grows one.
191
+ *
192
+ * A URL namespace is the one thing that is expensive to retrofit: once a site has real pages,
193
+ * carving out `/portal` later means checking every existing URL for a collision. Reserving it
194
+ * costs one field and an empty folder, so every site has it from day one.
195
+ *
196
+ * The DIRECTORY is fixed: src/pages/webapp/, on every site, always. `path` is the PUBLIC url
197
+ * segment. It defaults to the folder name so the common case needs no rewrite; a client whose
198
+ * customers log in sets `portal`, `members` or `account`, and the integration rewrites
199
+ * `/<path>/*` onto the folder. Everything else - the noindex flag, the sitemap exclusion, the
200
+ * run_worker_first entries, the doctor checks - derives from this one field.
201
+ *
202
+ * Every page under the folder must be `prerender = false`: the app needs bindings, and a
203
+ * rewrite can only reach a route the Worker renders. `webm doctor` checks.
204
+ *
205
+ * What is NOT reserved: auth, sessions, user tables. Those get built for the first client who
206
+ * needs them, in that client's repo, and promoted here only when a second one does.
207
+ */
208
+ app?: { enabled?: boolean; path?: string; label?: string };
209
+ }
210
+
211
+ /** The app folder under src/pages/. Fixed on every site; only the public path varies. */
212
+ export const APP_DIR = 'webapp';
213
+ export const DEFAULT_TIME_ZONE = 'America/Los_Angeles';
214
+
215
+ /**
216
+ * The hour, 0-23, at an instant, in a given IANA zone.
217
+ *
218
+ * THIS EXISTS BECAUSE CRON RUNS IN UTC AND NOTHING IN CLOUDFLARE CAN CHANGE THAT. A job that has
219
+ * to land at a local hour cannot be pinned to a UTC hour: Pacific is UTC-8 in winter and UTC-7 in
220
+ * summer, so any fixed schedule is an hour wrong for half the year. The pattern that works is to
221
+ * fire the cron HOURLY and let the handler ask what time it is where the client is; the other 23
222
+ * runs cost one comparison and return.
223
+ *
224
+ * Generalized from the version friendsofthemarinalibrary.org has used for its evening summary
225
+ * sweep since generation 2.
226
+ *
227
+ * `hourCycle: 'h23'` is load-bearing. The obvious `hour12: false` renders midnight as 24 in
228
+ * several implementations, so a job scheduled for hour 0 never fires and one testing `hour < 1`
229
+ * fires twice. It is the kind of thing that is wrong for a year before anyone notices.
230
+ *
231
+ * Lives here rather than in site.ts because this file is pure - site.ts imports a virtual module
232
+ * that only exists inside a build, so nothing in it can be unit tested.
233
+ */
234
+ export function zonedHour(at: Date, zone: string): number {
235
+ const parts = new Intl.DateTimeFormat('en-US', {
236
+ timeZone: zone,
237
+ hour: 'numeric',
238
+ hourCycle: 'h23',
239
+ }).formatToParts(at);
240
+
241
+ return Number(parts.find((part) => part.type === 'hour')?.value ?? NaN);
242
+ }
243
+ export const DEFAULT_LOCALE = 'en-US';
244
+
245
+ /* --------------------------------------------------------------------------
246
+ * Pure helpers.
247
+ *
248
+ * These take config as an argument rather than reading the virtual module, so they are testable
249
+ * with `node --test` and no build. site.ts is the thin layer that binds them to the real file.
250
+ * -------------------------------------------------------------------------- */
251
+
252
+ /** The literal value every unconfigured field ships with. */
253
+ export const PLACEHOLDER = 'CHANGEME';
254
+
255
+ /** True once a field holds a real value rather than the placeholder or nothing. */
256
+ export function isConfigured(value: string | null | undefined): value is string {
257
+ return typeof value === 'string' && value.length > 0 && value !== PLACEHOLDER;
258
+ }
259
+
260
+ /**
261
+ * The client's display name, falling back to the domain and then to a generic label.
262
+ *
263
+ * NEVER returns 'CHANGEME'. That string reaches a visitor and a client's inbox, so a placeholder
264
+ * leaking through is worse than a generic label.
265
+ */
266
+ export function resolveDisplayName(client: string | undefined, domain: string | undefined): string {
267
+ if (isConfigured(client)) return client;
268
+ if (isConfigured(domain)) return domain;
269
+ return 'This website';
270
+ }
271
+
272
+ /**
273
+ * Whether a string is a zone Intl actually knows.
274
+ *
275
+ * `Pacific/LA` does not exist - the Pacific/* zones are Pacific Ocean locations - and an invalid
276
+ * name makes Intl.DateTimeFormat throw at runtime rather than merely show the wrong hour. Checked
277
+ * at build by `webm doctor` so it fails on a laptop, not in a Worker.
278
+ */
279
+ export function isValidTimeZone(zone: string): boolean {
280
+ try {
281
+ new Intl.DateTimeFormat('en-US', { timeZone: zone });
282
+ return true;
283
+ } catch {
284
+ return false;
285
+ }
286
+ }
287
+
288
+ /** Whether the site has switched its web app on. Off is the scaffold's state. */
289
+ export function appEnabled(config: Pick<SiteConfig, 'app'>): boolean {
290
+ return config.app?.enabled === true;
291
+ }
292
+
293
+ /**
294
+ * The public url segment for the app, normalized without slashes. Falls back to the folder name
295
+ * so an empty or all-slash value cannot produce a route of `/`.
296
+ */
297
+ export function resolveAppPath(config: Pick<SiteConfig, 'app'>): string {
298
+ const raw = (config.app?.path ?? APP_DIR).replace(/^\/+|\/+$/g, '');
299
+ return raw || APP_DIR;
300
+ }
301
+
302
+ /**
303
+ * Every path that must appear in wrangler.jsonc's `assets.run_worker_first`.
304
+ *
305
+ * BOTH SLASH FORMS, always. The asset router treats `/portal/` and `/portal` as different paths,
306
+ * and a missing entry produces a route that returns 200 to curl and a 404 page to Chrome -
307
+ * because the interception keys off `Sec-Fetch-Dest: document`, which browsers send and curl does
308
+ * not. The PUBLIC path, not the folder: the router sees the URL, the rewrite happens after.
309
+ */
310
+ export function workerFirstPaths(config: Pick<SiteConfig, 'app'>): string[] {
311
+ const paths = ['/_actions/*'];
312
+ if (appEnabled(config)) {
313
+ const app = resolveAppPath(config);
314
+ paths.push(`/${app}/*`, `/${app}`, `/${app}/`);
315
+ }
316
+ return paths;
317
+ }
318
+
319
+ /**
320
+ * Is this deployment allowed to mail real people?
321
+ *
322
+ * TWO INDEPENDENT SIGNALS, because each covers the other's blind spot.
323
+ *
324
+ * `environment` is build-time config, so it is the only one of the two a SCHEDULED handler can
325
+ * read — a cron has no request and therefore no hostname. Without it the nightly sweep would
326
+ * mail a client's real contacts from a preview Worker, which is exactly the case that has no
327
+ * symptom until an organizer asks why they were emailed twice.
328
+ *
329
+ * The hostname covers the opposite mistake: webmonterey.json is committed, so a branch preview
330
+ * of a LAUNCHED site inherits `production` from main and would send for real. Anything served
331
+ * from workers.dev is a preview by definition, whatever the config claims.
332
+ *
333
+ * Deliberately NOT "redirect unless the hostname matches the canonical domain". `domain` is
334
+ * stored bare, so the day a site answers on www. every enquiry from that hostname would be
335
+ * redirected away from the client — a silent outage of real mail caused by a hostname variant.
336
+ * Testing for workers.dev instead fails in the safe direction: an unrecognised hostname sends.
337
+ */
338
+ export function isStagingDeployment(
339
+ environment: SiteConfig['environment'],
340
+ hostname?: string | null,
341
+ ): boolean {
342
+ if (environment === 'staging') return true;
343
+
344
+ /* Match the label, not a substring: a client domain ending "notworkers.dev" is not a preview. */
345
+ return Boolean(hostname && /(^|\.)workers\.dev$/i.test(hostname));
346
+ }