@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,315 @@
1
+ /*
2
+ * STRUCTURED DATA, AS PARTS RATHER THAN AS A VERDICT.
3
+ *
4
+ * WHY THIS FILE EXISTS. The package used to ship one component that built nineteen node types and
5
+ * emitted them, and every site got an agency's idea of what a business is. It was absorbed whole
6
+ * from webmonterey.com on the reasoning that one site's improvement should become every site's -
7
+ * which is right about improvements and wrong about opinions. A candidate, a recording studio, a
8
+ * library charity and a compliance platform are not variants of one shape. Chasing that, the
9
+ * component gained `place`, `breadcrumbs`, a page `description`, then `description` and `image`
10
+ * on the Person node - six additions in a day - and afterwards a candidate site still could not
11
+ * say `affiliation` and was still replacing the whole thing.
12
+ *
13
+ * THE SPLIT. Serialising a graph is identical on every site: one `@graph` rather than loose
14
+ * islands so nodes can reference each other by `@id`, `<` escaped so a value cannot close the
15
+ * script tag, empty fields dropped, nothing emitted for a noindex page. That is thirty lines and
16
+ * it belongs here. WHICH nodes, with WHICH fields, is what the site is claiming about itself, and
17
+ * that belongs to the site.
18
+ *
19
+ * So this exports building blocks and a serializer, and the package emits NOTHING by default. A
20
+ * site composes its graph in a component it exports as `structuredData` from its registry; the
21
+ * layout renders that into <head> on every indexable route with { title, description, image }:
22
+ *
23
+ * ---
24
+ * import { graphContext, renderJsonLd, organizationNode, websiteNode, webPageNode }
25
+ * from '@cparkerwebm/webmonterey/structured-data';
26
+ * const { title, description, image } = Astro.props;
27
+ * const ctx = graphContext(Astro.site!, Astro.url.pathname, { title, description });
28
+ * const jsonLd = renderJsonLd([
29
+ * organizationNode(ctx, image),
30
+ * websiteNode(ctx),
31
+ * webPageNode(ctx),
32
+ * breadcrumbNode(ctx),
33
+ * ]);
34
+ * ---
35
+ * {jsonLd && <script type="application/ld+json" set:html={jsonLd} />}
36
+ *
37
+ * The parts are here so sites do not each rewrite a PostalAddress. The composition is not, so
38
+ * the package never again has to grow a field to describe somebody else's business.
39
+ * /webm:launch is where a site's graph gets decided.
40
+ */
41
+ import { client, domain, hasClient, organization as org } from '../site.ts';
42
+
43
+ /** Everything a node builder needs to make stable @id values and absolute URLs. */
44
+ export interface GraphContext {
45
+ /** The site origin, from Astro.site. */
46
+ site: URL;
47
+ /** This page's canonical URL, normalised the same way the canonical tag builds it. */
48
+ url: string;
49
+ title?: string;
50
+ description?: string;
51
+ /** `@id` of the organization node, for anything that references it. */
52
+ orgId: string;
53
+ /** `@id` of the website node. */
54
+ siteId: string;
55
+ }
56
+
57
+ export function graphContext(site: URL, pathname: string, extra: Partial<GraphContext> = {}) {
58
+ return {
59
+ site,
60
+ url: new URL(pathname, site).href,
61
+ orgId: new URL('#organization', site).href,
62
+ siteId: new URL('#website', site).href,
63
+ ...extra,
64
+ } as GraphContext;
65
+ }
66
+
67
+ /** Drop empty strings, empty arrays, null and undefined — never emit a blank field. */
68
+ export function compact<T extends Record<string, unknown>>(input: T): Partial<T> {
69
+ return Object.fromEntries(
70
+ Object.entries(input).filter(([, v]) => {
71
+ if (v === null || v === undefined) return false;
72
+ if (typeof v === 'string') return v.trim() !== '';
73
+ if (Array.isArray(v)) return v.length > 0;
74
+ return true;
75
+ }),
76
+ ) as Partial<T>;
77
+ }
78
+
79
+ /**
80
+ * The graph, as the string that goes inside one <script type="application/ld+json">.
81
+ *
82
+ * `<` is escaped to its \\u003c form. Inside a script element the HTML parser is still looking for
83
+ * `</script>`, so any value containing that sequence would close the block early and spill the
84
+ * rest into the document as markup. These values come from config rather than from visitors, but
85
+ * the escape costs nothing and the failure mode is markup injection.
86
+ *
87
+ * Returns null for an empty graph, so a caller renders no script rather than an empty one.
88
+ */
89
+ export function renderJsonLd(nodes: unknown[]): string | null {
90
+ const graph = nodes.filter(Boolean);
91
+ if (graph.length === 0) return null;
92
+ return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(
93
+ /</g,
94
+ '\\u003c',
95
+ );
96
+ }
97
+
98
+ /** The address, or null when the config carries none. Shared by the organization and any Place. */
99
+ export function addressNode() {
100
+ const address = compact({
101
+ '@type': 'PostalAddress',
102
+ streetAddress: org.streetAddress,
103
+ addressLocality: org.addressLocality,
104
+ addressRegion: org.addressRegion,
105
+ postalCode: org.postalCode,
106
+ addressCountry: org.addressCountry,
107
+ });
108
+ /*
109
+ * compact() leaves the discriminator behind, so a PostalAddress carrying only its own @type is
110
+ * an empty node. The length check is what actually decides whether there is an address at all.
111
+ */
112
+ return Object.keys(address).length > 1 ? address : null;
113
+ }
114
+
115
+ /** The organization. Its @type comes from config — Organization, NGO, LocalBusiness, whatever fits. */
116
+ export function organizationNode(ctx: GraphContext, image?: string) {
117
+ return compact({
118
+ '@type': org.type || 'Organization',
119
+ '@id': ctx.orgId,
120
+ name: hasClient ? client : domain,
121
+ legalName: org.legalName,
122
+ description: org.description,
123
+ url: ctx.site.href,
124
+ image,
125
+ /*
126
+ * THE LOGO IS NOT THE SHARE IMAGE. Both were `image` once, which published a 1200x630 social
127
+ * banner as the organisation's mark - the thing a knowledge panel draws. Omitted when unset
128
+ * rather than falling back to the card: a wrong logo is worse than no logo, and Google wants
129
+ * at least 112x112.
130
+ */
131
+ logo: org.logo ? new URL(org.logo, ctx.site).href : '',
132
+ telephone: org.telephone,
133
+ email: org.email,
134
+ address: addressNode() ?? '',
135
+ areaServed: org.areaServed,
136
+ priceRange: org.priceRange,
137
+ openingHoursSpecification: org.hours?.opens
138
+ ? {
139
+ '@type': 'OpeningHoursSpecification',
140
+ dayOfWeek: org.hours.days,
141
+ opens: org.hours.opens,
142
+ closes: org.hours.closes,
143
+ }
144
+ : '',
145
+ sameAs: org.sameAs,
146
+ founder: org.founder?.name ? { '@id': new URL('#founder', ctx.site).href } : '',
147
+ employee: org.founder?.name ? { '@id': new URL('#founder', ctx.site).href } : '',
148
+ hasOfferCatalog: org.services?.length
149
+ ? {
150
+ '@type': 'OfferCatalog',
151
+ name: 'Services',
152
+ itemListElement: org.services.map((name) => ({
153
+ '@type': 'Offer',
154
+ itemOffered: { '@type': 'Service', name },
155
+ })),
156
+ }
157
+ : '',
158
+ });
159
+ }
160
+
161
+ /** The person behind the organization, from `organization.founder`. */
162
+ export function personNode(ctx: GraphContext) {
163
+ if (!org.founder?.name) return null;
164
+ return compact({
165
+ '@type': 'Person',
166
+ '@id': new URL('#founder', ctx.site).href,
167
+ name: org.founder.name,
168
+ jobTitle: org.founder.jobTitle,
169
+ description: org.founder.description,
170
+ image: org.founder.image ? new URL(org.founder.image, ctx.site).href : '',
171
+ url: ctx.site.href,
172
+ worksFor: { '@id': ctx.orgId },
173
+ sameAs: org.founder.sameAs,
174
+ });
175
+ }
176
+
177
+ export function websiteNode(ctx: GraphContext) {
178
+ return compact({
179
+ '@type': 'WebSite',
180
+ '@id': ctx.siteId,
181
+ url: ctx.site.href,
182
+ name: hasClient ? client : domain,
183
+ description: org.description,
184
+ publisher: { '@id': ctx.orgId },
185
+ inLanguage: 'en',
186
+ });
187
+ }
188
+
189
+ export function webPageNode(ctx: GraphContext) {
190
+ return compact({
191
+ '@type': 'WebPage',
192
+ '@id': `${ctx.url}#webpage`,
193
+ url: ctx.url,
194
+ name: ctx.title,
195
+ description: ctx.description,
196
+ isPartOf: { '@id': ctx.siteId },
197
+ about: { '@id': ctx.orgId },
198
+ inLanguage: 'en',
199
+ });
200
+ }
201
+
202
+ /** A physical place — the ONE page that is the place, never site-wide. */
203
+ export interface PlaceInput {
204
+ type: 'BookStore' | 'Store' | 'Place';
205
+ name: string;
206
+ streetAddress: string;
207
+ addressLocality: string;
208
+ addressRegion: string;
209
+ postalCode: string;
210
+ addressCountry: string;
211
+ telephone?: string;
212
+ openingHours: { days: string[]; opens: string; closes: string }[];
213
+ }
214
+
215
+ export function placeNode(ctx: GraphContext, place: PlaceInput, image?: string) {
216
+ return compact({
217
+ '@type': place.type,
218
+ '@id': `${ctx.url}#place`,
219
+ name: place.name,
220
+ url: ctx.url,
221
+ telephone: place.telephone,
222
+ image,
223
+ address: {
224
+ '@type': 'PostalAddress',
225
+ streetAddress: place.streetAddress,
226
+ addressLocality: place.addressLocality,
227
+ addressRegion: place.addressRegion,
228
+ postalCode: place.postalCode,
229
+ addressCountry: place.addressCountry,
230
+ },
231
+ openingHoursSpecification: place.openingHours.map((slot) => ({
232
+ '@type': 'OpeningHoursSpecification',
233
+ dayOfWeek: slot.days,
234
+ opens: slot.opens,
235
+ closes: slot.closes,
236
+ })),
237
+ parentOrganization: { '@id': ctx.orgId },
238
+ });
239
+ }
240
+
241
+ /**
242
+ * A breadcrumb trail, root first, INCLUDING the current page as the last crumb.
243
+ *
244
+ * A single-item trail is omitted: "Home" on its own is not a trail and Google ignores it.
245
+ * The caller supplies the names because only the route knows them - deriving from the URL gives
246
+ * a slug where a name belongs, which is how /programs/chess-club/ published its middle step as
247
+ * nothing at all.
248
+ */
249
+ export function breadcrumbNode(ctx: GraphContext, trail?: { name: string; path: string }[]) {
250
+ /*
251
+ * NO TRAIL GIVEN? DERIVE A TWO-LEVEL ONE FROM THE PATH.
252
+ *
253
+ * This fallback is load-bearing and was nearly lost splitting this file out: dropping it took
254
+ * the BreadcrumbList off thirty-two inner pages across two sites at once, silently, because
255
+ * structured data has no visible surface to notice it on. Most pages pass nothing and rely on
256
+ * exactly this.
257
+ *
258
+ * Two levels is all derivation can honestly give - the middle segment of /programs/chess-club/
259
+ * is a slug here, not a name. A route that knows its own hierarchy passes the trail.
260
+ */
261
+ if (!trail) {
262
+ const segments = new URL(ctx.url).pathname.split('/').filter(Boolean);
263
+ if (segments.length === 0) return null;
264
+ return {
265
+ '@type': 'BreadcrumbList',
266
+ '@id': `${ctx.url}#breadcrumb`,
267
+ itemListElement: [
268
+ { '@type': 'ListItem', position: 1, name: 'Home', item: ctx.site.href },
269
+ {
270
+ '@type': 'ListItem',
271
+ position: 2,
272
+ name: ctx.title ?? segments[segments.length - 1],
273
+ /*
274
+ * The last crumb carries its URL too. Google treats `item` as optional on the final
275
+ * element, so omitting it is valid - but a breadcrumb whose last entry has no address is
276
+ * less useful to anything reading the graph, and generation 2 emitted it.
277
+ */
278
+ item: ctx.url,
279
+ },
280
+ ],
281
+ };
282
+ }
283
+ if (trail.length < 2) return null;
284
+ return {
285
+ '@type': 'BreadcrumbList',
286
+ '@id': `${ctx.url}#breadcrumb`,
287
+ itemListElement: trail.map((crumb, index) => ({
288
+ '@type': 'ListItem',
289
+ position: index + 1,
290
+ name: crumb.name,
291
+ item: new URL(crumb.path, ctx.site).href,
292
+ })),
293
+ };
294
+ }
295
+
296
+ /**
297
+ * FAQPage. Worth emitting for entity understanding and AI search, but NOT for Google rich
298
+ * results: those were restricted to government and health sites in August 2023.
299
+ *
300
+ * ONLY EVER PASS QUESTIONS VISIBLE ON THE PAGE. Structured data describing content a visitor
301
+ * cannot see is a manual-action risk, not a shortcut.
302
+ */
303
+ export function faqNode(ctx: GraphContext, faq: Array<{ question: string; answer: string }>) {
304
+ if (!faq?.length) return null;
305
+ return {
306
+ '@type': 'FAQPage',
307
+ '@id': `${ctx.url}#faq`,
308
+ isPartOf: { '@id': `${ctx.url}#webpage` },
309
+ mainEntity: faq.map((item) => ({
310
+ '@type': 'Question',
311
+ name: item.question,
312
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
313
+ })),
314
+ };
315
+ }
@@ -0,0 +1,49 @@
1
+ /*
2
+ * The hour-in-zone primitive that every scheduled job depends on.
3
+ *
4
+ * Cron runs in UTC with no timezone setting anywhere in Cloudflare, so a job that must land at a
5
+ * local hour fires hourly and asks this function whether it is time. Get this wrong and the job
6
+ * runs at the wrong hour for half the year, or never runs at all - and neither reports anything.
7
+ */
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { zonedHour, DEFAULT_TIME_ZONE } from './config.ts';
11
+
12
+ test('Pacific is UTC-8 in winter', () => {
13
+ // 2026-01-15T18:00Z is 10:00 PST.
14
+ assert.equal(zonedHour(new Date('2026-01-15T18:00:00Z'), 'America/Los_Angeles'), 10);
15
+ });
16
+
17
+ test('Pacific is UTC-7 in summer, so a fixed UTC hour would drift', () => {
18
+ /*
19
+ * The whole reason this exists. The SAME UTC instant is 10:00 in winter and 11:00 in summer, so
20
+ * an evening job pinned to a UTC hour goes out at 8pm for half the year and 9pm for the other.
21
+ */
22
+ assert.equal(zonedHour(new Date('2026-07-15T18:00:00Z'), 'America/Los_Angeles'), 11);
23
+ });
24
+
25
+ test('midnight is 0, never 24', () => {
26
+ /*
27
+ * hourCycle: 'h23' is what makes this true. The obvious `hour12: false` renders midnight as 24
28
+ * in several implementations - a job scheduled for hour 0 then never fires, and one testing
29
+ * `hour < 1` fires twice. Wrong for a year before anyone notices.
30
+ */
31
+ assert.equal(zonedHour(new Date('2026-01-15T08:00:00Z'), 'America/Los_Angeles'), 0);
32
+ assert.equal(zonedHour(new Date('2026-07-15T07:00:00Z'), 'America/Los_Angeles'), 0);
33
+ });
34
+
35
+ test('the day rolls over at the right moment, not at UTC midnight', () => {
36
+ // 2026-03-02T07:59Z is 23:59 on the 1st in Pacific; one minute later it is midnight on the 2nd.
37
+ assert.equal(zonedHour(new Date('2026-03-02T07:59:00Z'), 'America/Los_Angeles'), 23);
38
+ assert.equal(zonedHour(new Date('2026-03-02T08:00:00Z'), 'America/Los_Angeles'), 0);
39
+ });
40
+
41
+ test('the fleet default is Pacific, which is the whole target region', () => {
42
+ assert.equal(DEFAULT_TIME_ZONE, 'America/Los_Angeles');
43
+ assert.equal(zonedHour(new Date('2026-01-15T18:00:00Z'), DEFAULT_TIME_ZONE), 10);
44
+ });
45
+
46
+ test('another zone can be asked for explicitly', () => {
47
+ assert.equal(zonedHour(new Date('2026-01-15T18:00:00Z'), 'UTC'), 18);
48
+ assert.equal(zonedHour(new Date('2026-01-15T18:00:00Z'), 'America/New_York'), 13);
49
+ });
@@ -0,0 +1,53 @@
1
+ /*
2
+ * The Cloudflare adapter, pre-configured.
3
+ *
4
+ * WHY THIS IS A SEPARATE EXPORT AND NOT SET BY THE INTEGRATION.
5
+ *
6
+ * An adapter set through `updateConfig` from inside `astro:config:setup` does NOT get its own
7
+ * integration hooks run. Astro accepts the config, the build starts, and then dies with:
8
+ *
9
+ * [UNRESOLVED_ENTRY] Cannot resolve entry module virtual:astro:legacy-ssr-entry
10
+ *
11
+ * the moment any route is on-demand. A fully static site never notices, which is exactly how it
12
+ * survived: examples/minimal had no `prerender = false` route and no form, so it built cleanly
13
+ * while the form pipeline - the thing the package exists to provide - could not build at all.
14
+ *
15
+ * Reproduced against bare Astro: identical config, adapter in `defineConfig` builds, adapter via
16
+ * `updateConfig` fails. Generation 1 already knew this and shipped `@cparkerwebm/webmonterey/adapter`
17
+ * for the same reason.
18
+ *
19
+ * So the client's astro.config.mjs names the adapter itself:
20
+ *
21
+ * import { defineConfig } from 'astro/config';
22
+ * import webmonterey, { adapter } from '@cparkerwebm/webmonterey';
23
+ *
24
+ * export default defineConfig({
25
+ * adapter: adapter(),
26
+ * integrations: [webmonterey()],
27
+ * });
28
+ *
29
+ * Two lines rather than one, and the site does not take a direct dependency on
30
+ * @astrojs/cloudflare - the package still owns which adapter and how it is configured.
31
+ */
32
+ import cloudflare from '@astrojs/cloudflare';
33
+
34
+ export interface AdapterOptions {
35
+ /**
36
+ * Override only with a reason.
37
+ *
38
+ * 'compile' optimizes images at BUILD with sharp. The adapter's own default is
39
+ * 'cloudflare-binding', which transforms at request time through Cloudflare Images - a separate,
40
+ * paid product. For a static marketing site that is slower and an unnecessary dependency: every
41
+ * image is known at build, so it should be resized once rather than per request.
42
+ *
43
+ * Switch only for genuine runtime transforms - user uploads, or remote images whose dimensions
44
+ * are unknown until requested.
45
+ */
46
+ imageService?: 'compile' | 'cloudflare-binding' | 'passthrough';
47
+ }
48
+
49
+ export function adapter(options: AdapterOptions = {}) {
50
+ return cloudflare({ imageService: options.imageService ?? 'compile' });
51
+ }
52
+
53
+ export default adapter;
@@ -0,0 +1,43 @@
1
+ /*
2
+ * THE WEB APP'S PUBLIC PATH, when it differs from the folder.
3
+ *
4
+ * The app lives at src/pages/webapp/ on every site, and by default is served at /webapp/. A
5
+ * client whose customers log in does not want the framework's folder name in their address bar,
6
+ * so webmonterey.json's `app.path` lets them say `portal` - and this is the piece that makes
7
+ * that true: `/portal/dashboard` is rewritten to render `/webapp/dashboard` while the browser
8
+ * URL stays put.
9
+ *
10
+ * INJECTED BY THE INTEGRATION, not written by the site, and only when `app.enabled` is on AND
11
+ * the path differs from the folder. With the default path there is nothing to rewrite and this
12
+ * module is never loaded. A site's own src/middleware.ts, if it has one, runs after this.
13
+ *
14
+ * The folder name is also REDIRECTED to the public path, so one page cannot answer at two URLs
15
+ * - a canonical problem search engines report, and a leak of the framework's naming into a
16
+ * client's product.
17
+ *
18
+ * Everything under the folder must be `prerender = false`. A rewrite can only land on a route the
19
+ * Worker renders; a prerendered page under src/pages/webapp/ is a static file the asset router
20
+ * serves at its own path and this never sees. `webm doctor` checks.
21
+ */
22
+ import { defineMiddleware } from 'astro:middleware';
23
+ import site from 'virtual:webm/site';
24
+ import { APP_DIR, appEnabled, resolveAppPath } from '../includes/webmonterey/config.ts';
25
+
26
+ const publicPath = resolveAppPath(site);
27
+ const active = appEnabled(site) && publicPath !== APP_DIR;
28
+
29
+ const under = (pathname: string, segment: string) =>
30
+ pathname === `/${segment}` || pathname.startsWith(`/${segment}/`);
31
+
32
+ export const onRequest = defineMiddleware((context, next) => {
33
+ if (!active) return next();
34
+ const { pathname } = context.url;
35
+
36
+ if (under(pathname, publicPath)) {
37
+ return context.rewrite(`/${APP_DIR}${pathname.slice(publicPath.length + 1)}`);
38
+ }
39
+ if (under(pathname, APP_DIR)) {
40
+ return context.redirect(`/${publicPath}${pathname.slice(APP_DIR.length + 1)}`, 301);
41
+ }
42
+ return next();
43
+ });
@@ -0,0 +1,96 @@
1
+ /*
2
+ * Reading the client's config files.
3
+ *
4
+ * Kept separate from the integration so it is testable without Astro, and so `webm doctor` and
5
+ * `webm sync` read the config exactly the way the build does rather than reimplementing it.
6
+ */
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import type { SiteConfig } from '../includes/webmonterey/config.ts';
10
+ import type { DesignSystem } from '../design/types.ts';
11
+
12
+ export class ConfigError extends Error {
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = 'ConfigError';
16
+ }
17
+ }
18
+
19
+ function readJson<T>(path: string, what: string, required: boolean): T | null {
20
+ if (!existsSync(path)) {
21
+ if (required) {
22
+ throw new ConfigError(`${what} not found at ${path}. Every site needs one.`);
23
+ }
24
+ return null;
25
+ }
26
+ try {
27
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
28
+ } catch (error) {
29
+ /*
30
+ * Name the file. A JSON syntax error surfaces from deep inside the build otherwise, and the
31
+ * message alone ("Unexpected token }") does not say which of the two config files it came
32
+ * from.
33
+ */
34
+ throw new ConfigError(
35
+ `${what} at ${path} is not valid JSON: ${error instanceof Error ? error.message : error}`,
36
+ );
37
+ }
38
+ }
39
+
40
+ export interface SiteFiles {
41
+ site: SiteConfig;
42
+ design: DesignSystem;
43
+ /** Absolute paths, for addWatchFile - editing either must trigger a rebuild. */
44
+ paths: { site: string; design: string | null };
45
+ }
46
+
47
+ /**
48
+ * Load webmonterey.json and design.json from a site root.
49
+ *
50
+ * design.json is OPTIONAL. A site with no design file compiles the default token set, which is
51
+ * exactly what a freshly minted site should look like before anyone has chosen a palette.
52
+ * webmonterey.json is required, because `domain` alone switches on canonical tags, Open Graph
53
+ * URLs, the sitemap and the credit's attribution.
54
+ */
55
+ export function loadSiteFiles(root: string): SiteFiles {
56
+ const sitePath = join(root, 'webmonterey.json');
57
+ const designPath = join(root, 'design.json');
58
+
59
+ const site = readJson<SiteConfig>(sitePath, 'webmonterey.json', true)!;
60
+ const design = readJson<DesignSystem>(designPath, 'design.json', false);
61
+
62
+ return {
63
+ site,
64
+ design: design ?? {},
65
+ paths: { site: sitePath, design: design ? designPath : null },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * The production origin, or undefined while `domain` is still the placeholder.
71
+ *
72
+ * Deliberately undefined rather than a localhost guess. With `site` unset Astro suppresses
73
+ * canonical tags and the sitemap integration is not added - both better than a canonical tag
74
+ * pointing at localhost, or a sitemap that cannot be built because it has no absolute URL.
75
+ */
76
+ export function resolveSiteUrl(config: SiteConfig): string | undefined {
77
+ const domain = config.domain;
78
+ return domain && domain !== 'CHANGEME' ? `https://${domain}` : undefined;
79
+ }
80
+
81
+ /**
82
+ * Every form definition in a site, keyed by id.
83
+ *
84
+ * The id is the FILENAME, which is what page JSON and the action's `form` field reference. There
85
+ * is no registry to keep in step - adding src/forms/quote.json is the whole job.
86
+ */
87
+ export function loadForms(root: string): Record<string, unknown> {
88
+ const dir = join(root, 'src/forms');
89
+ if (!existsSync(dir)) return {};
90
+ const out: Record<string, unknown> = {};
91
+ for (const file of readdirSync(dir)) {
92
+ if (!file.endsWith('.json')) continue;
93
+ out[file.replace(/\.json$/, '')] = readJson(join(dir, file), `src/forms/${file}`, true);
94
+ }
95
+ return out;
96
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * The content collection, for a client's src/content.config.ts.
3
+ *
4
+ * Astro requires that file at a FIXED path in the client repo, so the package cannot own it.
5
+ * What the package can own is the page schema - everything except the block union, which is
6
+ * necessarily site-local because every component is.
7
+ *
8
+ * A client's content.config.ts becomes:
9
+ *
10
+ * import { webmontereyCollections } from '@cparkerwebm/webmonterey/content';
11
+ * import { schema as content000001 } from './components/content/content-000001/schema.ts';
12
+ * export const collections = webmontereyCollections([content000001]);
13
+ *
14
+ * WHY THE UNION MATTERS: it is what makes a typo in page JSON a BUILD ERROR rather than a blank
15
+ * space on the page. Generation 2 used `z.looseObject({ type: z.string() })` until v1.3 purely
16
+ * because there was no component to build a union from, and that version accepted any typo
17
+ * silently - the block rendered as nothing, with only a console warning.
18
+ */
19
+ import { defineCollection } from 'astro:content';
20
+ import { glob } from 'astro/loaders';
21
+ import { z } from 'astro/zod';
22
+
23
+ /*
24
+ * The option type for a discriminated union.
25
+ *
26
+ * DERIVED FROM THE FUNCTION, not imported. astro/zod is zod 4, where the option constraint is
27
+ * `core.$ZodTypeDiscriminable` - an internal export that is not re-exported and would break on a
28
+ * patch release. Zod 3's `ZodDiscriminatedUnionOption` no longer exists, so any snippet using it
29
+ * is pre-v4 and wrong.
30
+ */
31
+ type BlockSchema = Parameters<typeof z.discriminatedUnion>[1][number];
32
+
33
+ /**
34
+ * @param blockSchemas Every component's schema. Each must be an object schema with a literal
35
+ * `type` matching its folder ID. Zod needs at least one member for a discriminated union.
36
+ */
37
+ export function webmontereyCollections(blockSchemas: readonly [BlockSchema, ...BlockSchema[]]) {
38
+ const blockSchema = z.discriminatedUnion('type', [...blockSchemas]);
39
+
40
+ const pages = defineCollection({
41
+ /*
42
+ * One JSON file per page; the filename is the route.
43
+ * src/content/pages/home.json -> /
44
+ * src/content/pages/about.json -> /about
45
+ * src/content/pages/services/seo.json -> /services/seo
46
+ * `home` is the only special case - see the router.
47
+ */
48
+ loader: glob({ base: './src/content/pages', pattern: '**/*.json' }),
49
+ schema: z.object({
50
+ /** Used for <title>, and rendered as the page's h1 unless `showTitle` is false. */
51
+ title: z.string(),
52
+ /*
53
+ * Defaults TRUE so a page is structurally complete by default: a page whose only headings
54
+ * are the h2s of its prose blocks has no h1 at all, which is a document-outline bug rather
55
+ * than a style preference. Set false on a page whose first block renders its own h1.
56
+ */
57
+ showTitle: z.boolean().default(true),
58
+ /** Meta description. Optional, but set it on any page that matters for search. */
59
+ description: z.string().optional(),
60
+ /** Ordered list of blocks. Each `type` must exist in the component registry. */
61
+ blocks: z.array(blockSchema).default([]),
62
+ }),
63
+ });
64
+
65
+ return { pages };
66
+ }