@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,402 @@
1
+ ---
2
+ /*
3
+ * The page shell. Every route renders through this.
4
+ *
5
+ * global.css is imported HERE and nowhere else — it is the single stylesheet entry point,
6
+ * and it pulls in the whole layer system in the correct order.
7
+ *
8
+ * Slots:
9
+ * head extra <head> content (analytics, structured data, a preload)
10
+ * header the site header region, once one exists
11
+ * default page content
12
+ * footer the site footer region, once one exists
13
+ */
14
+ import '../styles/global.css';
15
+ /*
16
+ * The token layer, compiled from the client's design.json. There is no tokens.css to import -
17
+ * `webm design:extract` migrates an existing one, and the integration resolves this module.
18
+ */
19
+ import 'virtual:webm/tokens.css';
20
+
21
+ /*
22
+ * The client's own CSS - src/styles/custom/_index.css in their repo - which the package cannot
23
+ * reach by a relative path. LAST, so webm.components.custom and webm.overrides are written after
24
+ * the layers they override; the @layer statement decides the cascade either way, but a reader
25
+ * opening the built CSS should find the override at the bottom where they expect it.
26
+ */
27
+ import 'virtual:webm/custom';
28
+ import { LAYER_STATEMENT } from '../styles/layers.ts';
29
+ import {
30
+ displayName,
31
+ shortName,
32
+ brandTitles,
33
+ gtmId as siteGtmId,
34
+ } from '../includes/webmonterey/site.ts';
35
+ import { header, footer, panels, structuredData } from 'virtual:webm/registry';
36
+ import measuredShareImage from 'virtual:webm/share-image';
37
+ import icons from 'virtual:webm/icons';
38
+
39
+ /*
40
+ * Site-wide plumbing, wired in once here rather than per page.
41
+ *
42
+ * ORDER IS LOAD-BEARING: ConsentInit must be the first thing in <head>, above TagManager,
43
+ * so Consent Mode defaults are set before GTM loads. See the compliance includes.
44
+ *
45
+ * Each piece switches itself off: ConsentInit/CookieConsent render nothing when
46
+ * features.compliance is false, and TagManager renders nothing without a container ID.
47
+ */
48
+ import ConsentInit from '../includes/webmonterey/compliance/ConsentInit.astro';
49
+ import CookieConsent from '../includes/webmonterey/compliance/CookieConsent.astro';
50
+ import TagManager from '../includes/google/tag-manager/TagManager.astro';
51
+ import ScrollTop from '../includes/webmonterey/scroll-top/ScrollTop.astro';
52
+
53
+ /*
54
+ * THE ENVIRONMENT WINS, webmonterey.json IS THE FALLBACK - and the fallback is the one that
55
+ * matters in production.
56
+ *
57
+ * `.env` is gitignored, so it does not exist on Workers Builds. A site that set its container id
58
+ * only in the environment therefore fired tags perfectly on a laptop and loaded nothing at all
59
+ * once deployed - no error, no broken page, just an analytics property that never received a
60
+ * hit. Reading webmonterey.json second means the id travels with the repo, which is safe because
61
+ * the id is public by design: it is in the source of every page it renders.
62
+ *
63
+ * The environment still takes precedence, so a branch can be pointed at a different container
64
+ * without editing tracked config.
65
+ */
66
+ const gtmId = import.meta.env.PUBLIC_GTM_ID || siteGtmId;
67
+
68
+ interface Props {
69
+ title: string;
70
+ description?: string;
71
+ /**
72
+ * Append ` | <shortName>` to the title. Default true.
73
+ *
74
+ * Set FALSE on a route that composes its own full title. A programme page titled "Bird Walks
75
+ * at the Marina Library" already names the client, and branding it again produces "Bird Walks
76
+ * at the Marina Library | FoML" - longer than a search result will show, with the page name
77
+ * truncated away to make room for a brand that was already in it.
78
+ *
79
+ * The automatic guard only catches a title that contains the SHORT name verbatim, which this
80
+ * one does not, so it has to be a decision the route can make.
81
+ *
82
+ * Defaults to `brandTitles` in webmonterey.json, so a site whose pages all author their own
83
+ * full titles sets it once rather than per route.
84
+ */
85
+ brandTitle?: boolean;
86
+ /**
87
+ * Keep this page out of search results.
88
+ *
89
+ * Emits `noindex, nofollow` and suppresses the canonical tag and Open Graph URL, so the
90
+ * page sends one unambiguous signal instead of "don't index me" alongside "here is my
91
+ * canonical address".
92
+ *
93
+ * A page marked this way must ALSO be excluded from the sitemap — see the `filter` in
94
+ * astro.config.mjs. Listing a noindex page in a sitemap is a contradiction search engines
95
+ * report as an error.
96
+ */
97
+ noindex?: boolean;
98
+ /**
99
+ * The social share image, as a path under `public/`. Defaults to `/open-graph.png`.
100
+ *
101
+ * MUST be a public/ path, never an imported asset run through `getImage`. On a prerendered
102
+ * route `getImage` returns a built `/_astro/…` file, but on a `prerender = false` route
103
+ * there is no build step to optimise into, so it returns the image SERVICE URL instead —
104
+ * `/_image?href=…`. With `imageService: 'compile'` (see astro.config.mjs) that endpoint is
105
+ * never deployed, so the card image 404s in production while every prerendered page looks
106
+ * fine. Files in public/ are copied verbatim and keep one URL however the page renders.
107
+ */
108
+ shareImage?: string;
109
+ /*
110
+ * Dimensions of `shareImage`, in pixels. THEY MUST TRAVEL WITH IT.
111
+ *
112
+ * These default to the size of the shipped `public/open-graph.png`. Files in public/ are
113
+ * copied verbatim and never processed, so nothing can measure the real image at build time
114
+ * and nothing will warn when these stop matching — a client dropping in a differently sized
115
+ * card image silently publishes false dimensions. Found exactly that way: a live site
116
+ * serving a 1280x672 image while declaring 1200x630.
117
+ *
118
+ * A crawler uses these to lay the card out before it fetches the image, so wrong values are
119
+ * worse than none. If the real size is not known, pass null for both and the two tags are
120
+ * omitted — the scrapers all measure the image themselves anyway.
121
+ */
122
+ shareImageWidth?: number | null;
123
+ shareImageHeight?: number | null;
124
+ /**
125
+ * Third-party analytics, and the consent UI that governs it. Default true.
126
+ *
127
+ * SET FALSE ON ANY PAGE WHOSE URL CAN CARRY A CREDENTIAL. Magic-link, password-reset,
128
+ * invitation and email-verification links arrive as `?t=<token>`; a share link carries its
129
+ * token in the path. Every one of those is a live credential sitting in the address.
130
+ *
131
+ * GTM's container script is NOT consent-gated. Consent Mode governs what tags may STORE, not
132
+ * whether the container loads, and the container reports the page URL. So on a consent-
133
+ * respecting site with nothing accepted, the token still reaches Google before the visitor
134
+ * has finished using it. That is a real leak, not a theoretical one.
135
+ *
136
+ * It also switches off the cookie banner, deliberately: with no third party on the page the
137
+ * banner governs nothing, and its analytics and marketing toggles would control something
138
+ * that is not there, over the top of somebody's file browser. A session cookie is strictly
139
+ * necessary and needs no consent.
140
+ *
141
+ * ConsentInit still renders, so Consent Mode defaults stay set for the pages that do load a
142
+ * container - a visitor moving from the portal back to the marketing site keeps one
143
+ * consistent state rather than an unset one.
144
+ */
145
+ analytics?: boolean;
146
+ /**
147
+ * The back-to-top control. Default true.
148
+ *
149
+ * It rendered on every page of every site with nothing to turn it off - a small thing, and
150
+ * exactly the shape of the problem: the package deciding something for six clients because
151
+ * nobody wrote down that they might disagree.
152
+ */
153
+ scrollTop?: boolean;
154
+ }
155
+
156
+ const {
157
+ title,
158
+ description,
159
+ brandTitle = brandTitles,
160
+ noindex = false,
161
+ shareImage = '/open-graph.png',
162
+ /*
163
+ * MEASURED, not assumed. Both default to the real size of public/open-graph.png, read from the
164
+ * file's own header at build time - so replacing the card art cannot leave the tags describing
165
+ * the old one, which is what happened on every generation-2 site.
166
+ *
167
+ * A page passing explicit values still wins, and an unreadable file leaves both null, which
168
+ * omits the tags rather than publishing a guess.
169
+ */
170
+ shareImageWidth = measuredShareImage?.width ?? null,
171
+ shareImageHeight = measuredShareImage?.height ?? null,
172
+ analytics = true,
173
+ scrollTop = true,
174
+ } = Astro.props;
175
+
176
+ /*
177
+ * Canonical URL. Only emitted once `site` is derived from `domain` in webmonterey.json.
178
+ * Astro.site is undefined until then, and a canonical pointing at localhost is worse than none.
179
+ */
180
+ const canonical = !noindex && Astro.site ? new URL(Astro.url.pathname, Astro.site).href : undefined;
181
+
182
+ /*
183
+ * Open Graph image. Absolute, because every scraper requires that — a relative og:image is
184
+ * simply ignored, which is indistinguishable from having none.
185
+ *
186
+ * So it is emitted only once `site` is derived from `domain` in webmonterey.json, the same
187
+ * gate the canonical tag uses. Until then `twitter:card` falls back to `summary`: declaring
188
+ * `summary_large_image` with no image is what produced a blank card on every page this
189
+ * starter has shipped, because the card type promises an image the markup never supplied.
190
+ */
191
+ const shareImageUrl = Astro.site ? new URL(shareImage, Astro.site).href : undefined;
192
+ /*
193
+ * `Page title | Client Name`, built here rather than typed into every page's JSON - one place
194
+ * to change it, and no page can forget.
195
+ *
196
+ * THE GUARD MATTERS. A page whose title already contains the client name - a homepage, most
197
+ * likely - would otherwise render "WebMonterey | WebMonterey". And displayName() falls back to
198
+ * the domain rather than ever publishing "CHANGEME" into a <title>, which is a thing a visitor
199
+ * and a search engine both see.
200
+ */
201
+ /* shortName, not displayName: a long client name in every <title> truncates the page name away. */
202
+ const siteName = shortName;
203
+ const pageTitle =
204
+ brandTitle && siteName && !title.includes(siteName) ? `${title} | ${siteName}` : title;
205
+
206
+ /*
207
+ * SITE OVERLAYS - dialogs, drawers, slide-in panels - rendered once at the end of <body>.
208
+ *
209
+ * A third chrome category beyond header and footer, and it is not a nicety: a mobile menu panel
210
+ * and a call-to-action drawer both have to live outside <main> to be positioned and to sit above
211
+ * everything, so a site with either has no way to render it through the block system. Generation
212
+ * 2 put them in its own base.astro; the package's layout is not the client's to edit, so they
213
+ * come through the registry instead.
214
+ */
215
+ const Panels = panels;
216
+
217
+ /*
218
+ * THE SITE'S CHROME, AS SLOT FALLBACK - and it belongs HERE, not in the block router.
219
+ *
220
+ * It was in the router first, and that meant only pages rendered from page JSON got a header:
221
+ * a site's OWN routes - a calendar, a /programs/[slug] - render <Base> directly and came out
222
+ * with no header, no footer and no announcement bar. So did the package's own /webm page. On a
223
+ * rebuild that is 27 pages quietly missing their navigation.
224
+ *
225
+ * As a slot fallback, any route that uses this layout gets the chrome, and a route that wants
226
+ * something else still overrides it by filling the slot. That is how generation 2 did it, and
227
+ * the reason is the same.
228
+ */
229
+ const Header = header;
230
+ const Footer = footer;
231
+
232
+ /*
233
+ * THE SITE'S JSON-LD, if it declares one. The package emits none of its own: a candidate, a
234
+ * recording studio and a library charity are not variants of one shape, and the generic graph
235
+ * that tried to be grew a field a day and still fit nobody. The builders in
236
+ * includes/webmonterey/structured-data are the package's half - escaping, @id wiring, dropping
237
+ * empty fields - and the site composes what it claims. /webm:launch is where that gets written.
238
+ */
239
+ const StructuredData = structuredData;
240
+ ---
241
+
242
+ <!doctype html>
243
+ <html lang="en">
244
+ <head>
245
+ <meta charset="utf-8" />
246
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
247
+
248
+ {
249
+ /*
250
+ CASCADE ORDER. Must stay ahead of every stylesheet, which is why it is authored here as
251
+ inline HTML rather than left to global.css.
252
+
253
+ The identical statement in global.css is stripped by the bundler — a bare @layer
254
+ statement is not attached to any rule, so nothing carries it into the output chunk.
255
+ Layer order then falls back to order of first appearance, the bundler picks that, and a
256
+ component's scoped style arriving before global.css silently inverts reset and
257
+ components. `is:inline` keeps Astro's hands off it for the same reason.
258
+
259
+ >> Emitted from styles/layers.ts, which global.css is tested against. There is no
260
+ second list to keep in step by hand any more. <<
261
+ */
262
+ }
263
+ <style is:inline set:html={LAYER_STATEMENT}></style>
264
+
265
+ <!-- FIRST. Sets consent state and Consent Mode defaults before any third party loads. -->
266
+ <ConsentInit />
267
+ {analytics && gtmId && <TagManager id={gtmId} />}
268
+
269
+ {
270
+ /*
271
+ Marks the document JS-capable BEFORE first paint. Every .webm-reveal hidden state in
272
+ animations.css is scoped to `html.js`, so with JavaScript off — or if the bundle simply
273
+ fails to load — nothing is ever hidden and content is visible rather than stranded at
274
+ opacity: 0. A crawler that does not run scripts sees the page in full.
275
+
276
+ `is:inline` and synchronous on purpose: Astro would otherwise bundle and defer it, which
277
+ lands after first paint and reintroduces the flash this exists to prevent.
278
+
279
+ Below ConsentInit deliberately — it sets a class and touches nothing else, so it cannot
280
+ affect the Consent Mode defaults that must reach GTM first.
281
+ */
282
+ }
283
+ <script is:inline>
284
+ document.documentElement.classList.add('js');
285
+ </script>
286
+
287
+ <title>{pageTitle}</title>
288
+ {description && <meta name="description" content={description} />}
289
+ {noindex && <meta name="robots" content="noindex, nofollow" />}
290
+ {canonical && <link rel="canonical" href={canonical} />}
291
+
292
+ <meta property="og:type" content="website" />
293
+ {
294
+ /*
295
+ og:title CARRIES THE BRAND, matching <title>. The spec's tidier pairing is an unbranded
296
+ og:title plus og:site_name, and that was the first version of this - but it changes what a
297
+ previously-shared card says, and not every platform renders site_name. A rebuild should not
298
+ silently restyle links people have already posted.
299
+
300
+ og:site_name ships as well, because the tag is correct and some platforms use it for
301
+ attribution. The small redundancy is the price of not changing existing cards.
302
+ */
303
+ }
304
+ <meta property="og:title" content={pageTitle} />
305
+ <meta property="og:site_name" content={siteName} />
306
+ {description && <meta property="og:description" content={description} />}
307
+ {canonical && <meta property="og:url" content={canonical} />}
308
+ {
309
+ shareImageUrl && (
310
+ <>
311
+ <meta property="og:image" content={shareImageUrl} />
312
+ {shareImageWidth && <meta property="og:image:width" content={String(shareImageWidth)} />}
313
+ {shareImageHeight && (
314
+ <meta property="og:image:height" content={String(shareImageHeight)} />
315
+ )}
316
+ <meta name="twitter:image" content={shareImageUrl} />
317
+ </>
318
+ )
319
+ }
320
+ <meta name="twitter:card" content={shareImageUrl ? 'summary_large_image' : 'summary'} />
321
+
322
+ {
323
+ /*
324
+ ONLY THE ICONS THAT EXIST. Linking favicon.svg unconditionally meant a site without one
325
+ served the seeded placeholder - WebMonterey's own mark - as the client's icon, and browsers
326
+ prefer SVG over .ico so it was the one actually shown.
327
+
328
+ The PNG sizes are emitted only when an SVG is absent: a scalable icon makes them redundant,
329
+ and a site that has both does not need four links to say the same thing.
330
+ */
331
+ }
332
+ {icons['favicon.svg'] && <link rel="icon" href="/favicon.svg" type="image/svg+xml" />}
333
+ {icons['favicon.ico'] && <link rel="icon" href="/favicon.ico" sizes="32x32" />}
334
+ {
335
+ !icons['favicon.svg'] && icons['favicon-16x16.png'] && (
336
+ <link rel="icon" href="/favicon-16x16.png" type="image/png" sizes="16x16" />
337
+ )
338
+ }
339
+ {
340
+ !icons['favicon.svg'] && icons['favicon-32x32.png'] && (
341
+ <link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
342
+ )
343
+ }
344
+ {icons['apple-touch-icon.png'] && <link rel="apple-touch-icon" href="/apple-touch-icon.png" />}
345
+ {icons['site.webmanifest'] && <link rel="manifest" href="/site.webmanifest" />}
346
+
347
+ {
348
+ /*
349
+ GATED HERE, not left to the component. Structured data describing a page that asks not to
350
+ be indexed is a contradiction, and the site's component should not have to remember that.
351
+ */
352
+ }
353
+ {
354
+ StructuredData && !noindex && (
355
+ <StructuredData title={title} description={description} image={shareImageUrl} />
356
+ )
357
+ }
358
+
359
+ <slot name="head" />
360
+ </head>
361
+
362
+ <body>
363
+ <a class="webm-skip-link" href="#webm-main">Skip to content</a>
364
+
365
+ <!-- GTM's noscript iframe must be the first thing in <body>. -->
366
+ {analytics && gtmId && <TagManager id={gtmId} noscript />}
367
+
368
+ <slot name="header">{Header && <Header />}</slot>
369
+
370
+ <!-- tabindex="-1" is required for the skip link to actually move focus in Safari. -->
371
+ <main id="webm-main" tabindex="-1">
372
+ <slot />
373
+ </main>
374
+
375
+ <slot name="footer">{Footer && <Footer />}</slot>
376
+
377
+ {Panels.map((Panel) => <Panel />)}
378
+
379
+ {scrollTop && <ScrollTop />}
380
+ {analytics && <CookieConsent />}
381
+
382
+ {
383
+ /*
384
+ Scroll reveal. Imported ONCE for the whole site — Astro dedupes a bundled <script>
385
+ across every component instance, so importing observe.ts anywhere else is harmless but
386
+ pointless. Nothing here runs unless a page actually renders a .webm-reveal element.
387
+ */
388
+ }
389
+ <script>
390
+ import { initAnimations } from '../includes/webmonterey/animations/observe.ts';
391
+
392
+ initAnimations();
393
+
394
+ /*
395
+ * A ClientRouter view transition swaps the DOM without a full reload, so the incoming
396
+ * page's elements need wiring again. initAnimations is idempotent, and this listener is
397
+ * inert on a site that has not enabled view transitions.
398
+ */
399
+ document.addEventListener('astro:page-load', () => initAnimations());
400
+ </script>
401
+ </body>
402
+ </html>
@@ -0,0 +1,121 @@
1
+ /*
2
+ * The package manifest is a contract with every client site. A broken export path fails at
3
+ * import time, in their build, not here - so it is worth a test.
4
+ */
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { join } from 'node:path';
10
+
11
+ const ROOT = fileURLToPath(new URL('../', import.meta.url));
12
+
13
+ /** package.json carries `//`-prefixed keys as inline documentation; JSON.parse handles them. */
14
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as {
15
+ name: string;
16
+ optionalDependencies?: Record<string, string>;
17
+ exports: Record<string, string>;
18
+ bin: Record<string, string>;
19
+ files: string[];
20
+ peerDependencies: Record<string, string>;
21
+ dependencies: Record<string, string>;
22
+ };
23
+
24
+ test('every export path exists on disk', () => {
25
+ for (const [subpath, target] of Object.entries(pkg.exports)) {
26
+ if (subpath.startsWith('//')) continue;
27
+ assert.ok(existsSync(join(ROOT, target)), `${subpath} -> ${target} does not exist`);
28
+ }
29
+ });
30
+
31
+ test('the bin exists and is the CLI', () => {
32
+ for (const target of Object.values(pkg.bin)) {
33
+ assert.ok(existsSync(join(ROOT, target)), `${target} does not exist`);
34
+ }
35
+ });
36
+
37
+ test('every published directory exists', () => {
38
+ for (const entry of pkg.files) {
39
+ assert.ok(existsSync(join(ROOT, entry)), `files lists ${entry}, which does not exist`);
40
+ }
41
+ });
42
+
43
+ test('astro is a peer, never a dependency', () => {
44
+ // Two copies of Astro in one tree break the integration in ways that are hard to read.
45
+ assert.ok(pkg.peerDependencies.astro, 'astro must be a peerDependency');
46
+ assert.equal(pkg.dependencies.astro, undefined, 'astro must not be a plain dependency');
47
+ });
48
+
49
+ test('the design subpath imports nothing from Astro', () => {
50
+ // This is what keeps it testable with node --test and usable from the platform without
51
+ // pulling a framework in to read a color.
52
+ for (const file of [
53
+ 'compile.ts',
54
+ 'defaults.ts',
55
+ 'resolve.ts',
56
+ 'brand.ts',
57
+ 'types.ts',
58
+ 'index.ts',
59
+ ]) {
60
+ const source = readFileSync(join(ROOT, 'src/design', file), 'utf8');
61
+ assert.ok(!/from '(astro|astro:)/.test(source), `src/design/${file} imports from astro`);
62
+ }
63
+ });
64
+
65
+ test('every directory the code reads out of the package is published', () => {
66
+ /*
67
+ * The inverse of "every published directory exists", and the one that actually bites: `files`
68
+ * is an allowlist, so a new directory is absent from the tarball by default. It works in this
69
+ * repo and in `npm link` - both of which read the working tree - and is missing the moment a
70
+ * real install happens. `template/` shipped that way until this test was written.
71
+ */
72
+ const sources = readdirSync(join(ROOT, 'src', 'cli'))
73
+ .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
74
+ .map((f) => readFileSync(join(ROOT, 'src', 'cli', f), 'utf8'))
75
+ .join('\n');
76
+
77
+ const published = new Set(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).files);
78
+
79
+ for (const match of sources.matchAll(/PACKAGE_ROOT,\s*'([a-z-]+)'/g)) {
80
+ const dir = match[1]!;
81
+ assert.ok(
82
+ published.has(dir),
83
+ `src/cli reads PACKAGE_ROOT/${dir}/ but "${dir}" is not in package.json files[] - ` +
84
+ `it works from a checkout and is absent from the tarball`,
85
+ );
86
+ }
87
+ });
88
+
89
+ test('the package does not depend on itself', () => {
90
+ /*
91
+ * IT DID, AND IT SHIPPED. package.json carried
92
+ * "@cparkerwebm/webmonterey": "file:/Users/cparkerwebm/Workspace/webmonterey/...tgz"
93
+ * as a runtime dependency of itself, pointing at an absolute path on one laptop. Every install
94
+ * of the published 1.0.0 failed with ENOENT on a path that exists on no other machine.
95
+ *
96
+ * It hid for two sessions because every install until the first real publish was either a
97
+ * symlink or a file: reference, and in both of those the self-reference resolves to something
98
+ * that happens to exist. Only a registry install can see it.
99
+ */
100
+ assert.ok(
101
+ !(pkg.dependencies ?? {})[pkg.name],
102
+ `${pkg.name} lists itself as a dependency - every install will try to install it into itself`,
103
+ );
104
+ });
105
+
106
+ test('no dependency is a file: or link: path', () => {
107
+ /*
108
+ * A local path in a PUBLISHED package is always wrong: it resolves on the machine that
109
+ * published and nowhere else. This is the general form of the self-dependency above, and it is
110
+ * worth checking every field rather than just the one that bit.
111
+ */
112
+ const fields = [pkg.dependencies, pkg.peerDependencies, pkg.optionalDependencies];
113
+ for (const field of fields) {
114
+ for (const [name, spec] of Object.entries(field ?? {})) {
115
+ assert.ok(
116
+ !/^(file:|link:|\.{1,2}\/)/.test(spec),
117
+ `${name} is "${spec}" - a local path cannot resolve for anyone installing this`,
118
+ );
119
+ }
120
+ }
121
+ });
@@ -0,0 +1,33 @@
1
+ ---
2
+ import { copy } from '../includes/webmonterey/copy.ts';
3
+ /*
4
+ * Served by Cloudflare for any path that matches no asset, via
5
+ * `"not_found_handling": "404-page"` in wrangler.jsonc.
6
+ *
7
+ * Deliberately not driven by page JSON - it must still render if the content layer is broken.
8
+ *
9
+ * NOINDEX, AND THAT IS NOT COSMETIC. Without it the layout emits a canonical tag pointing at
10
+ * /404/, which tells a crawler this error page is the preferred version of itself and invites it
11
+ * into the index. `noindex` suppresses the canonical and the Open Graph URL together, so the page
12
+ * sends one unambiguous signal instead of "do not index me" alongside "here is my address".
13
+ *
14
+ * The description is generic on purpose: the package cannot name a client's own sections, and a
15
+ * site wanting a better one overrides this route the ordinary Astro way, with src/pages/404.astro.
16
+ */
17
+ import Base from '../layouts/base.astro';
18
+ ---
19
+
20
+ <Base title={copy.notFound.title} description={copy.notFound.body} noindex>
21
+ {
22
+ /* The 404 carries the site's chrome too - a bare 404 is how a visitor decides the site is broken. */
23
+ }
24
+ <section class="webm-section" data-space="lg">
25
+ <div class="webm-container" data-width="text">
26
+ <div class="webm-stack">
27
+ <h1>{copy.notFound.title}</h1>
28
+ <p>That page doesn’t exist, or it has moved.</p>
29
+ <p><a href="/">Back to the home page</a></p>
30
+ </div>
31
+ </div>
32
+ </section>
33
+ </Base>
@@ -0,0 +1,126 @@
1
+ ---
2
+ /*
3
+ * THE BLOCK ROUTER
4
+ *
5
+ * Every page on the site renders through this one route. It reads a page's JSON, walks its
6
+ * `blocks` array in order, and renders each one via the component registry.
7
+ *
8
+ * Adding a page = adding a JSON file. No route file is ever written per page.
9
+ */
10
+ import type { GetStaticPaths, InferGetStaticPropsType } from 'astro';
11
+ import { getCollection } from 'astro:content';
12
+ import Base from '../layouts/base.astro';
13
+ /*
14
+ * The registry lives in the CLIENT repo, because every visible component does - the package
15
+ * ships none. The integration resolves this module to src/components/registry.ts in the site
16
+ * being built.
17
+ */
18
+ import { blocks, registeredTypes, pageHeader } from 'virtual:webm/registry';
19
+
20
+ export const getStaticPaths = (async () => {
21
+ const pages = await getCollection('pages');
22
+
23
+ return pages.map((page) => ({
24
+ /*
25
+ * `home` is the only special case: it maps to `/`, not `/home`.
26
+ * A rest param of `undefined` is how Astro matches the site root.
27
+ */
28
+ params: { slug: page.id === 'home' ? undefined : page.id },
29
+ props: { page },
30
+ }));
31
+ }) satisfies GetStaticPaths;
32
+
33
+ type Props = InferGetStaticPropsType<typeof getStaticPaths>;
34
+
35
+ const { page } = Astro.props as Props;
36
+
37
+ /* Capitalized, because Astro renders a lowercase identifier as an HTML tag rather than a component. */
38
+ /* Chrome is the LAYOUT's job - see base.astro. This route only supplies the page header. */
39
+ const PageHeader = pageHeader;
40
+ ---
41
+
42
+ {
43
+ /*
44
+ noindex and shareImage come from the PAGE, not just from a route that hardcodes them. A site
45
+ marking a confirmation page noindex in its own JSON had that silently ignored on rebuild - the
46
+ page came back indexable, with a canonical, which is the opposite of what it asked for.
47
+
48
+ Read defensively: a site using the package's own content schema may not define either field.
49
+ */
50
+ }
51
+ <Base
52
+ title={page.data.title}
53
+ description={page.data.description}
54
+ noindex={(page.data as { noindex?: boolean }).noindex ?? false}
55
+ {...(page.data as { shareImage?: string }).shareImage
56
+ ? { shareImage: (page.data as { shareImage?: string }).shareImage }
57
+ : {}}
58
+ >
59
+ {
60
+ /*
61
+ The site's own chrome, rendered once per page. The package ships no header and no footer -
62
+ it ships no components at all - so these come from the client's registry.ts. A site with
63
+ neither gets a page with neither, which is the scaffold's normal state.
64
+ */
65
+ }
66
+ {/* See `showTitle` in src/content.config.ts for why this defaults on. */}
67
+ {
68
+ /*
69
+ * THE PAGE HEADER. A site that declares `pageHeader` in its registry gets it here, with the
70
+ * page's whole frontmatter - so a header can use a subtitle, photos, a category, anything the
71
+ * package has never heard of. Without it a site wanting more than an <h1> had to fork this
72
+ * route, and a fork stops receiving every later fix to block lookup and unknown-type
73
+ * reporting.
74
+ *
75
+ * The fallback stays a bare <h1> because a page whose only headings are its blocks' h2s has
76
+ * no h1 at all - a document-outline bug rather than a style preference.
77
+ */
78
+ page.data.showTitle &&
79
+ (PageHeader ? (
80
+ <PageHeader {...page.data} />
81
+ ) : (
82
+ <section class="webm-section" data-space="sm">
83
+ <div class="webm-container" data-width="text">
84
+ <h1>{page.data.title}</h1>
85
+ </div>
86
+ </section>
87
+ ))
88
+ }
89
+ {
90
+ page.data.blocks.map((block) => {
91
+ /*
92
+ * Object.hasOwn, not a plain lookup: `blocks[block.type]` walks the prototype chain, so
93
+ * a page JSON containing {"type": "constructor"} (or toString, valueOf, __proto__)
94
+ * returns a truthy function, sails past the guard below, and kills the build with an
95
+ * opaque "no valid renderer" error.
96
+ */
97
+ const Block = Object.hasOwn(blocks, block.type) ? blocks[block.type] : undefined;
98
+
99
+ if (!Block) {
100
+ /*
101
+ * The block is in the JSON but not in the registry, so it renders as nothing.
102
+ * Almost always a missing registry entry — see src/components/registry.ts.
103
+ */
104
+ const known = registeredTypes();
105
+ // Leading newline: without it this concatenates onto Astro's progress line
106
+ // ("├─ /about/index.htmlUnknown block…") and is easy to miss entirely.
107
+ console.warn(
108
+ `\n[webm] Unknown block type "${block.type}" in src/content/pages/${page.id}.json — ` +
109
+ `it is not in the component registry, so it will render as nothing. ` +
110
+ `Registered types: ${known.length ? known.join(', ') : '(none yet)'}`,
111
+ );
112
+ return null;
113
+ }
114
+
115
+ /*
116
+ * `type` is stripped before spreading. It identifies the component; it is not a prop.
117
+ * Left in, every component receives it, and a block rendering <button {...rest}> gets
118
+ * type="content-000001" — an invalid value browsers resolve to type="submit", silently
119
+ * changing what the button does inside a form.
120
+ */
121
+ const { type: _type, ...props } = block;
122
+
123
+ return <Block {...props} />;
124
+ })
125
+ }
126
+ </Base>