@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
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { writeFileSync, mkdtempSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { imageSize } from './image-size.ts';
7
+
8
+ const dir = mkdtempSync(join(tmpdir(), 'webm-img-'));
9
+
10
+ function write(name: string, bytes: number[]): string {
11
+ const path = join(dir, name);
12
+ writeFileSync(path, new Uint8Array(bytes));
13
+ return path;
14
+ }
15
+
16
+ /** A PNG signature plus an IHDR chunk declaring the given size. Nothing else is read. */
17
+ function png(width: number, height: number): number[] {
18
+ const size = (n: number) => [(n >> 24) & 255, (n >> 16) & 255, (n >> 8) & 255, n & 255];
19
+ return [
20
+ 0x89,
21
+ 0x50,
22
+ 0x4e,
23
+ 0x47,
24
+ 0x0d,
25
+ 0x0a,
26
+ 0x1a,
27
+ 0x0a,
28
+ 0,
29
+ 0,
30
+ 0,
31
+ 13,
32
+ 0x49,
33
+ 0x48,
34
+ 0x44,
35
+ 0x52,
36
+ ...size(width),
37
+ ...size(height),
38
+ 8,
39
+ 6,
40
+ 0,
41
+ 0,
42
+ 0,
43
+ ];
44
+ }
45
+
46
+ test('a PNG reports its real dimensions', () => {
47
+ // 1280x672 is the case that motivated this: webmonterey.com shipped exactly that while
48
+ // declaring 1200x630 in its og:image tags, for its whole life.
49
+ assert.deepEqual(imageSize(write('a.png', png(1280, 672))), { width: 1280, height: 672 });
50
+ assert.deepEqual(imageSize(write('b.png', png(1200, 630))), { width: 1200, height: 630 });
51
+ });
52
+
53
+ test('a GIF reports its dimensions, little-endian', () => {
54
+ const gif = [
55
+ 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x04, 0xb0, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
56
+ 0, 0,
57
+ ];
58
+ assert.deepEqual(imageSize(write('c.gif', gif)), { width: 1024, height: 688 });
59
+ });
60
+
61
+ test('a JPEG is walked to its frame header, skipping segments that are not one', () => {
62
+ const jpeg = [
63
+ 0xff, 0xd8,
64
+ /* APP0, 16 bytes long, carrying no dimensions - it must be skipped by its length. */
65
+ 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0,
66
+ /* SOF0: length, precision, then height and width. */
67
+ 0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0xa0, 0x05, 0x00, 3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1,
68
+ ];
69
+ assert.deepEqual(imageSize(write('d.jpg', jpeg)), { width: 1280, height: 672 });
70
+ });
71
+
72
+ test('a file that is not an image, or is missing, returns null rather than a guess', () => {
73
+ assert.equal(
74
+ imageSize(
75
+ write(
76
+ 'e.txt',
77
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24],
78
+ ),
79
+ ),
80
+ null,
81
+ );
82
+ assert.equal(imageSize(join(dir, 'nope.png')), null);
83
+ });
@@ -0,0 +1,100 @@
1
+ /*
2
+ * Read an image's real dimensions from its header.
3
+ *
4
+ * WHY THIS EXISTS. og:image:width and og:image:height have to travel with the share image, and
5
+ * nothing in the toolchain can check them: files in public/ are copied verbatim and never
6
+ * processed, so a client dropping in a differently sized card publishes false dimensions and no
7
+ * build, test or linter says a word.
8
+ *
9
+ * Generations 1 and 2 handled it with a comment telling the reader to keep the numbers in step.
10
+ * They did not: webmonterey.com shipped a 1280x672 image declaring 1200x630 for its whole life,
11
+ * and the comment warning about it was sitting in the same file. A crawler uses these to lay the
12
+ * card out before it fetches the image, so wrong values are worse than none.
13
+ *
14
+ * Header parsing only - no dependency, and it reads a few dozen bytes rather than decoding.
15
+ */
16
+ import { openSync, readSync, closeSync } from 'node:fs';
17
+
18
+ export interface ImageSize {
19
+ width: number;
20
+ height: number;
21
+ }
22
+
23
+ /*
24
+ * Read the first `length` bytes, or fewer if the file is shorter.
25
+ *
26
+ * Uint8Array and DataView rather than Buffer's read helpers: the package builds under a tsconfig
27
+ * that does not pull in Node's Buffer typings, and these are the platform primitives anyway.
28
+ */
29
+ function head(path: string, length: number): Uint8Array | null {
30
+ let fd: number | undefined;
31
+ try {
32
+ fd = openSync(path, 'r');
33
+ const bytes = new Uint8Array(length);
34
+ const read = readSync(fd, bytes, 0, length, 0);
35
+ return bytes.subarray(0, read);
36
+ } catch {
37
+ return null;
38
+ } finally {
39
+ if (fd !== undefined) closeSync(fd);
40
+ }
41
+ }
42
+
43
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
44
+
45
+ const startsWith = (bytes: Uint8Array, prefix: number[]): boolean =>
46
+ prefix.every((byte, i) => bytes[i] === byte);
47
+
48
+ const ascii = (bytes: Uint8Array, start: number, end: number): string =>
49
+ String.fromCharCode(...bytes.subarray(start, end));
50
+
51
+ /**
52
+ * PNG, GIF and JPEG. WebP and AVIF are deliberately absent: neither is a safe share image
53
+ * anyway - several crawlers still will not decode them - so a card should not be in one.
54
+ */
55
+ export function imageSize(path: string): ImageSize | null {
56
+ const bytes = head(path, 65_536);
57
+ if (!bytes || bytes.length < 24) return null;
58
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
59
+
60
+ /* PNG: an 8-byte signature, then an IHDR chunk whose first two fields are the dimensions. */
61
+ if (startsWith(bytes, PNG_MAGIC) && ascii(bytes, 12, 16) === 'IHDR') {
62
+ return { width: view.getUint32(16), height: view.getUint32(20) };
63
+ }
64
+
65
+ /* GIF: 'GIF87a' or 'GIF89a', then width and height as little-endian 16-bit. */
66
+ if (ascii(bytes, 0, 3) === 'GIF') {
67
+ return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
68
+ }
69
+
70
+ /*
71
+ * JPEG: a chain of length-prefixed segments. Walk them to a Start Of Frame, which is where the
72
+ * dimensions live. SOF0/1/2/3/5/6/7/9/10/11/13/14/15 all carry them; DHT (c4), DNL (c8) and
73
+ * JPG (cc) share the range and do not, so they are skipped explicitly.
74
+ */
75
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) {
76
+ let offset = 2;
77
+ while (offset + 9 < bytes.length) {
78
+ if (bytes[offset] !== 0xff) {
79
+ offset++;
80
+ continue;
81
+ }
82
+ const marker = bytes[offset + 1]!;
83
+ if (
84
+ marker >= 0xc0 &&
85
+ marker <= 0xcf &&
86
+ marker !== 0xc4 &&
87
+ marker !== 0xc8 &&
88
+ marker !== 0xcc
89
+ ) {
90
+ return { width: view.getUint16(offset + 7), height: view.getUint16(offset + 5) };
91
+ }
92
+ /* Not a frame header - skip this segment by its own declared length. */
93
+ const length = view.getUint16(offset + 2);
94
+ if (length < 2) return null;
95
+ offset += 2 + length;
96
+ }
97
+ }
98
+
99
+ return null;
100
+ }
@@ -0,0 +1,386 @@
1
+ /*
2
+ * The WebMonterey Astro integration.
3
+ *
4
+ * A client's astro.config.mjs is this, and nothing else:
5
+ *
6
+ * import { defineConfig } from 'astro/config';
7
+ * import webmonterey, { adapter } from '@cparkerwebm/webmonterey';
8
+ * export default defineConfig({ adapter: adapter(), integrations: [webmonterey()] });
9
+ *
10
+ * The adapter is named there and not set from in here - see integration/adapter.ts for why it
11
+ * cannot be, and what the build does when it is missing.
12
+ *
13
+ * Generation 2's astro.config.mjs was ninety lines, most of them comments explaining traps that
14
+ * a reader had to keep true by hand: derive `site` from webmonterey.json, keep NOINDEX_ROUTES in
15
+ * step with every `<Base noindex>`, remember imageService: 'compile'. Each of those is now a
16
+ * decision made once, here, and a fix propagates on `npm update` rather than never.
17
+ *
18
+ * >> WRITTEN AGAINST THE ASTRO INTEGRATION API FROM RECALL, NOT FROM THE DOCS MCP, which was not
19
+ * >> available in the session that wrote it. Verify hook names, `updateConfig` merge semantics
20
+ * >> and `injectRoute` options against docs.astro.build before this ships. Astro moves fast and
21
+ * >> much of the training corpus is two majors out of date - that is rule 9, and it applies to
22
+ * >> the package as much as to a client site.
23
+ */
24
+ import type { AstroIntegration } from 'astro';
25
+ import sitemap from '@astrojs/sitemap';
26
+
27
+ import { existsSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+
30
+ import { compileToCss } from '../design/compile.ts';
31
+ import { imageSize } from './image-size.ts';
32
+ import { loadForms, loadSiteFiles, resolveSiteUrl } from './config.ts';
33
+ import { APP_DIR, appEnabled, resolveAppPath } from '../includes/webmonterey/config.ts';
34
+
35
+ export interface WebmontereyOptions {
36
+ /**
37
+ * Routes kept out of the sitemap. Each must ALSO pass `noindex` to the base layout - this list
38
+ * only controls the sitemap.
39
+ *
40
+ * Deliberately NOT added to robots.txt. Disallowing a path there stops crawlers fetching it,
41
+ * so they never see the noindex tag, and an inbound link can get the URL indexed anyway with
42
+ * no description. To exclude a page you must let it be crawled.
43
+ */
44
+ noindexRoutes?: string[];
45
+ /** Skip the block router, for a site that defines all its own routes. Rare. */
46
+ router?: boolean;
47
+ /**
48
+ * Serve `/robots.txt`. Default true.
49
+ *
50
+ * Set false for a site that needs its own — a staging host disallowing everything, or a client
51
+ * with real crawl rules. Until 1.5 this route was injected unconditionally and a static
52
+ * `public/robots.txt` lost to it with nothing said.
53
+ */
54
+ robots?: boolean;
55
+ /**
56
+ * Serve `/webm`, the component scratch page, in `astro dev`. Default true.
57
+ *
58
+ * DEV ONLY. It is a workbench for building a block in isolation, and a workbench has no
59
+ * business on a client's production domain - it used to be injected into every build, noindex
60
+ * but live, with a comment asking people to remember to empty it. Now it simply does not exist
61
+ * outside the dev server.
62
+ */
63
+ diagnosticsPage?: boolean;
64
+ /**
65
+ * Add `@astrojs/sitemap`. Default true whenever `domain` is set.
66
+ *
67
+ * Set false for a site publishing a sitemap another way, rather than having two disagree.
68
+ */
69
+ sitemap?: boolean;
70
+ }
71
+
72
+ const VIRTUAL = {
73
+ site: 'virtual:webm/site',
74
+ design: 'virtual:webm/design',
75
+ tokens: 'virtual:webm/tokens.css',
76
+ registry: 'virtual:webm/registry',
77
+ forms: 'virtual:webm/forms',
78
+ custom: 'virtual:webm/custom',
79
+ shareImage: 'virtual:webm/share-image',
80
+ icons: 'virtual:webm/icons',
81
+ } as const;
82
+
83
+ /** Vite resolves virtual ids to a `\0`-prefixed form so other plugins leave them alone. */
84
+ const resolved = (id: string) => `\0${id}`;
85
+
86
+ export default function webmonterey(options: WebmontereyOptions = {}): AstroIntegration {
87
+ return {
88
+ name: '@cparkerwebm/webmonterey',
89
+
90
+ hooks: {
91
+ 'astro:config:setup': ({
92
+ command,
93
+ config,
94
+ updateConfig,
95
+ injectRoute,
96
+ addMiddleware,
97
+ addWatchFile,
98
+ logger,
99
+ }) => {
100
+ const root = config.root.pathname;
101
+ const files = loadSiteFiles(root);
102
+ const site = resolveSiteUrl(files.site);
103
+ const app = appEnabled(files.site);
104
+ const appPath = resolveAppPath(files.site);
105
+
106
+ /*
107
+ * Editing either config file must rebuild. Without this a palette change in design.json
108
+ * shows nothing until the dev server is restarted, which reads as the compiler being
109
+ * broken.
110
+ */
111
+ addWatchFile(files.paths.site);
112
+ if (files.paths.design) addWatchFile(files.paths.design);
113
+
114
+ /*
115
+ * The adapter cannot be set from here - see integration/adapter.ts. Catch its absence with
116
+ * a message that says what to do, instead of letting the build fail later with
117
+ * "Cannot resolve entry module virtual:astro:legacy-ssr-entry", which names nothing the
118
+ * reader wrote.
119
+ */
120
+ if (!config.adapter) {
121
+ throw new Error(
122
+ '[webm] No adapter. astro.config.mjs must set one:\n\n' +
123
+ " import webmonterey, { adapter } from '@cparkerwebm/webmonterey';\n\n" +
124
+ ' export default defineConfig({\n' +
125
+ ' adapter: adapter(),\n' +
126
+ ' integrations: [webmonterey()],\n' +
127
+ ' });\n\n' +
128
+ 'It cannot be set by the integration: an adapter registered through updateConfig does ' +
129
+ 'not run its own hooks, and the build then fails on the first on-demand route.',
130
+ );
131
+ }
132
+
133
+ if (!site) {
134
+ logger.warn(
135
+ 'webmonterey.json "domain" is still CHANGEME. Canonical tags, Open Graph URLs and ' +
136
+ 'the sitemap are suppressed until it is set - deliberately, since a canonical tag ' +
137
+ 'pointing at localhost is worse than none.',
138
+ );
139
+ }
140
+
141
+ /* The app is authenticated, so it is never in the sitemap. Its PUBLIC path, not the folder. */
142
+ const noindex = [
143
+ '/webm',
144
+ ...(app ? [`/${appPath}`] : []),
145
+ ...(options.noindexRoutes ?? []),
146
+ ];
147
+
148
+ updateConfig({
149
+ site,
150
+
151
+ /*
152
+ * Static by default: marketing pages prerender to plain HTML on the edge. A route
153
+ * needing a binding opts out per file with `export const prerender = false` - and
154
+ * every such route must also appear in wrangler.jsonc's run_worker_first, or it
155
+ * returns 200 to curl and a 404 page to Chrome. `webm doctor` checks that.
156
+ */
157
+ output: 'static',
158
+
159
+ integrations:
160
+ site && options.sitemap !== false
161
+ ? [
162
+ sitemap({
163
+ filter: (page) => {
164
+ const path = new URL(page).pathname.replace(/\/$/, '') || '/';
165
+ return !noindex.some((n) => path === n || path.startsWith(`${n}/`));
166
+ },
167
+ }),
168
+ ]
169
+ : [],
170
+
171
+ vite: {
172
+ /*
173
+ * A site that adds a custom Worker entrypoint - the supported way to get a
174
+ * scheduled() handler alongside the adapter's fetch - imports
175
+ * @astrojs/cloudflare/handler. Vite pre-bundles that into node_modules/.vite/deps_ssr,
176
+ * and any re-optimisation (which `astro check`, or a build running alongside
177
+ * `astro dev`, triggers) changes its ?v= hash. The running dev server then 500s on
178
+ * EVERY request with "The file does not exist at .../handler-*.js".
179
+ *
180
+ * Excluding it is the fix the error message itself suggests. It is an adapter
181
+ * entrypoint that needs no bundling, so this costs nothing on a site that never adds
182
+ * one - and it means a site that does cannot walk into a dev server that has stopped
183
+ * serving anything at all.
184
+ *
185
+ * Found in friendsofthemarinalibrary.org, which has run a scheduled sweep this way
186
+ * since generation 2 and had to work it out by hand.
187
+ */
188
+ optimizeDeps: {
189
+ exclude: ['@astrojs/cloudflare/handler'],
190
+ },
191
+ ssr: {
192
+ /*
193
+ * PROCESS THIS PACKAGE'S SOURCE LIKE THE APP'S OWN.
194
+ *
195
+ * Vite externalizes node_modules by default, which is right for a compiled
196
+ * dependency and wrong for this one: the package ships .ts source that imports
197
+ * Astro and Cloudflare virtual modules - `cloudflare:workers` above all. Left
198
+ * external, rolldown sees a bare specifier it cannot resolve and the build dies
199
+ * with "Failed to resolve import cloudflare:workers", naming a file inside
200
+ * node_modules that the reader did not write.
201
+ *
202
+ * This only bites once a site wires the form pipeline, because that is the first
203
+ * thing to pull in includes/cloudflare/workers/env.ts. A site with no forms builds
204
+ * cleanly without it - which is exactly how it went unnoticed.
205
+ */
206
+ noExternal: ['@cparkerwebm/webmonterey'],
207
+ },
208
+ plugins: [
209
+ {
210
+ name: 'webm:virtual',
211
+ resolveId(id: string) {
212
+ if (Object.values(VIRTUAL).includes(id as never)) return resolved(id);
213
+ return null;
214
+ },
215
+ load(id: string) {
216
+ switch (id) {
217
+ case resolved(VIRTUAL.site):
218
+ return `export default ${JSON.stringify(files.site)};`;
219
+ case resolved(VIRTUAL.design):
220
+ return `export default ${JSON.stringify(files.design)};`;
221
+ case resolved(VIRTUAL.tokens):
222
+ return compileToCss(files.design);
223
+ case resolved(VIRTUAL.shareImage): {
224
+ /*
225
+ * The share image's REAL size, read from its header at build time.
226
+ *
227
+ * og:image:width and og:image:height have to match the file, and nothing
228
+ * else in the toolchain can check: public/ is copied verbatim, so a client
229
+ * dropping in a differently sized card publishes false dimensions and no
230
+ * build or test says a word. webmonterey.com shipped a 1280x672 image
231
+ * declaring 1200x630 for its whole life, with a comment warning about
232
+ * exactly that sitting in the same file.
233
+ *
234
+ * null when the file is absent or unreadable, and base.astro then omits
235
+ * both tags - the scrapers all measure the image themselves anyway, so
236
+ * saying nothing beats saying something wrong.
237
+ */
238
+ const size = imageSize(join(root, 'public/open-graph.png'));
239
+ return `export default ${JSON.stringify(size)};`;
240
+ }
241
+ case resolved(VIRTUAL.icons): {
242
+ /*
243
+ * WHICH ICON FILES THE SITE ACTUALLY HAS.
244
+ *
245
+ * The layout used to link favicon.svg unconditionally. A site without one
246
+ * then served the SEEDED PLACEHOLDER - WebMonterey's own mark - as the
247
+ * client's icon, and because browsers prefer SVG over .ico it was the one
248
+ * actually shown. Caught on a rebuild where the client had .ico and PNGs
249
+ * and no SVG at all.
250
+ *
251
+ * public/ is copied verbatim, so nothing else can see this.
252
+ */
253
+ const icons = Object.fromEntries(
254
+ [
255
+ 'favicon.svg',
256
+ 'favicon.ico',
257
+ 'favicon-16x16.png',
258
+ 'favicon-32x32.png',
259
+ 'apple-touch-icon.png',
260
+ 'site.webmanifest',
261
+ ].map((file) => [file, existsSync(join(root, 'public', file))]),
262
+ );
263
+ return `export default ${JSON.stringify(icons)};`;
264
+ }
265
+ case resolved(VIRTUAL.custom): {
266
+ /*
267
+ * THE CLIENT OVERRIDE SEAM, and it was disconnected until it was tested.
268
+ *
269
+ * base.astro imports the package's global.css by relative path, so the
270
+ * site's own src/styles/index.css - which the scaffold wrote as the entry
271
+ * point that pulls in custom/ - was imported by nothing at all. A client
272
+ * could write a rule in custom/_index.css, see it in the repo, and never
273
+ * see it on the page. No error, no warning: the file simply was not in the
274
+ * graph. That is webm.components.custom, the layer whose entire job is
275
+ * beating webm.components.core.
276
+ *
277
+ * A JS module that imports the real path, rather than the file's contents
278
+ * inlined: a virtual module has no directory, so relative @import inside
279
+ * the client's own CSS would not resolve if this returned text.
280
+ */
281
+ const custom = join(root, 'src/styles/custom/_index.css');
282
+ return existsSync(custom) ? `import ${JSON.stringify(custom)};` : '';
283
+ }
284
+ case resolved(VIRTUAL.forms):
285
+ return `export const FORMS = ${JSON.stringify(loadForms(root))};`;
286
+ case resolved(VIRTUAL.registry):
287
+ /*
288
+ * Re-exported from the client repo, because every visible component lives
289
+ * there - the package ships none. A site with no registry gets an empty
290
+ * one rather than a resolution failure, so `webm new` can scaffold in any
291
+ * order.
292
+ */
293
+ return [
294
+ `let mod = { blocks: {}, registeredTypes: () => [] };`,
295
+ `try { mod = await import(${JSON.stringify(root + 'src/components/registry.ts')}); } catch {}`,
296
+ `export const blocks = mod.blocks ?? {};`,
297
+ `export const registeredTypes = mod.registeredTypes ?? (() => Object.keys(blocks));`,
298
+ /*
299
+ * SITE CHROME. The package's layout has header and footer slots and
300
+ * ships nothing to fill them, and the router that renders every page is
301
+ * package-owned - so without this a site literally cannot get a header
302
+ * onto its own pages short of overriding the router and duplicating it.
303
+ *
304
+ * Deliberately NOT entries in `blocks`: a registered type is addressable
305
+ * from a page's JSON, and a page that lists its header as a block stacks
306
+ * a second one under the real one. Chrome is rendered once by the layout,
307
+ * so it is exported separately and named separately.
308
+ */
309
+ `export const header = mod.header ?? null;`,
310
+ `export const footer = mod.footer ?? null;`,
311
+ /* Overlays - a mobile menu, a CTA drawer - rendered at the end of body. */
312
+ `export const panels = mod.panels ?? [];`,
313
+ /*
314
+ * The page header. A site that declares one gets it in place of the
315
+ * router's plain <h1>; a site that does not keeps the <h1>.
316
+ */
317
+ `export const pageHeader = mod.pageHeader ?? null;`,
318
+ /*
319
+ * THE SITE'S JSON-LD, rendered into <head> on every route. The package
320
+ * emits none of its own: what a business claims about itself is the
321
+ * site's to say, and every attempt to say it generically grew a field a
322
+ * day and still fit nobody. The builders in ./structured-data are the
323
+ * mechanism; this component is the content. /webm:launch is where it
324
+ * gets written.
325
+ */
326
+ `export const structuredData = mod.structuredData ?? null;`,
327
+ ].join('\n');
328
+ default:
329
+ return null;
330
+ }
331
+ },
332
+ },
333
+ ],
334
+ },
335
+ });
336
+
337
+ /*
338
+ * EVERY INJECTED ROUTE HAS AN OFF SWITCH.
339
+ *
340
+ * Two of these had none. A site wanting its own /robots.txt - a staging host disallowing
341
+ * everything, a client with crawl rules - had no way to say so, and a static
342
+ * public/robots.txt lost to the injected route silently.
343
+ *
344
+ * The default is unchanged in each case; the point is only that "no" is now sayable.
345
+ */
346
+ if (options.router !== false) {
347
+ injectRoute({ pattern: '/[...slug]', entrypoint: '@cparkerwebm/webmonterey/pages/slug' });
348
+ }
349
+ if (options.robots !== false) {
350
+ injectRoute({
351
+ pattern: '/robots.txt',
352
+ entrypoint: '@cparkerwebm/webmonterey/pages/robots',
353
+ });
354
+ }
355
+ /* The scratch page exists in the dev server and nowhere else - see the option's note. */
356
+ if (options.diagnosticsPage !== false && command === 'dev') {
357
+ injectRoute({ pattern: '/webm', entrypoint: '@cparkerwebm/webmonterey/pages/webm' });
358
+ }
359
+
360
+ /*
361
+ * THE WEB APP'S PUBLIC PATH. Only when the site has switched the app on AND named a path
362
+ * other than the folder - with the default there is nothing to rewrite, and a middleware
363
+ * that runs on every request of every site to do nothing is not free. See app-middleware.
364
+ */
365
+ if (app && appPath !== APP_DIR) {
366
+ addMiddleware({ entrypoint: '@cparkerwebm/webmonterey/app-middleware', order: 'pre' });
367
+ }
368
+
369
+ /*
370
+ * THE 404. Exported by the package since day one and injected by nobody, so every site
371
+ * built on it served Cloudflare's default "page not found" instead of the client's.
372
+ *
373
+ * Astro turns the `/404` route into 404.html, which the Workers asset router serves for
374
+ * any unmatched path. A site that wants its own overrides this the ordinary Astro way, by
375
+ * having src/pages/404.astro - a file in the site beats an injected route, which is the
376
+ * child-theme rule holding without anything special here.
377
+ */
378
+ injectRoute({ pattern: '/404', entrypoint: '@cparkerwebm/webmonterey/pages/404.astro' });
379
+ },
380
+ },
381
+ };
382
+ }
383
+
384
+ export { adapter, type AdapterOptions } from './adapter.ts';
385
+ export type { SiteConfig, SiteFeatures, Organization } from '../includes/webmonterey/config.ts';
386
+ export type { DesignSystem } from '../design/types.ts';
@@ -0,0 +1,101 @@
1
+ /*
2
+ * The virtual modules the integration provides.
3
+ *
4
+ * These do not exist on disk. Each resolves at build time to something in the CLIENT repo, which
5
+ * a package cannot import relatively - see includes/webmonterey/config.ts.
6
+ */
7
+ declare module 'virtual:webm/site' {
8
+ import type { SiteConfig } from '../includes/webmonterey/config.ts';
9
+ const config: SiteConfig;
10
+ export default config;
11
+ }
12
+
13
+ declare module 'virtual:webm/design' {
14
+ import type { DesignSystem } from '../design/types.ts';
15
+ const design: DesignSystem;
16
+ export default design;
17
+ }
18
+
19
+ /** The compiled token layer. Side-effect import only. */
20
+ declare module 'virtual:webm/tokens.css';
21
+
22
+ /** Which icon files exist in the site's public/. The layout links only what is really there. */
23
+ declare module 'virtual:webm/icons' {
24
+ const icons: Record<string, boolean>;
25
+ export default icons;
26
+ }
27
+
28
+ /** The client's own stylesheet - src/styles/custom/. Side-effect import only. */
29
+ declare module 'virtual:webm/custom';
30
+
31
+ /**
32
+ * The real pixel size of `public/open-graph.png`, measured at build time, or null when there is
33
+ * no readable file there. Nothing else can check it: public/ is copied verbatim.
34
+ */
35
+ declare module 'virtual:webm/share-image' {
36
+ const size: { width: number; height: number } | null;
37
+ export default size;
38
+ }
39
+
40
+ declare module 'virtual:webm/registry' {
41
+ import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
42
+ /** Maps a block `type` in page JSON to the component that renders it. */
43
+ export const blocks: Record<string, AstroComponentFactory>;
44
+ /** Every registered block type, so the router can report an unknown one usefully. */
45
+ export const registeredTypes: () => string[];
46
+ /**
47
+ * The site header, rendered once into the layout's `header` slot on every package route.
48
+ *
49
+ * NOT a member of `blocks`. A registered type is addressable from a page's JSON, and a page
50
+ * listing its header as a block would stack a second one under the real one.
51
+ */
52
+ export const header: AstroComponentFactory | null;
53
+ /** The site footer. Same contract as `header`. */
54
+ export const footer: AstroComponentFactory | null;
55
+ /**
56
+ * Overlays rendered once at the end of `<body>` - a mobile menu panel, a CTA drawer.
57
+ *
58
+ * They have to sit outside `<main>` to be positioned above everything, so a site with either
59
+ * has no way to render it through the block system.
60
+ */
61
+ export const panels: AstroComponentFactory[];
62
+ /**
63
+ * Rendered in place of the router's plain `<h1>` when a page has `showTitle`.
64
+ *
65
+ * Receives the page's whole frontmatter, so a site's header can use fields the package has
66
+ * never heard of - a subtitle, header photos, a category. Without this seam a site that wants
67
+ * anything more than an `<h1>` has to fork the router, and a forked router stops receiving
68
+ * every later fix to block lookup, unknown-type reporting and FAQ extraction.
69
+ */
70
+ export const pageHeader: AstroComponentFactory | null;
71
+ /**
72
+ * The site's JSON-LD, rendered into <head> on every indexable route. The package emits none of
73
+ * its own; this component composes a graph from the builders in
74
+ * `@cparkerwebm/webmonterey/structured-data`, and receives `{ title, description, image }`
75
+ * for the page being rendered. Null means no structured data on this site.
76
+ */
77
+ export const structuredData: AstroComponentFactory | null;
78
+ }
79
+
80
+ declare module 'virtual:webm/forms' {
81
+ /** Every form definition in the client repo, keyed by id (the filename). */
82
+ export const FORMS: Record<
83
+ string,
84
+ {
85
+ name: string;
86
+ fields: { name: string; label: string; required?: boolean }[];
87
+ notify: { to: string[]; subject: string };
88
+ autoresponse?: { subject: string; body: string };
89
+ /**
90
+ * Opt this ONE form out of Turnstile, even with `features.turnstile` on.
91
+ *
92
+ * For a form whose component renders no widget - a newsletter box with a single inline
93
+ * field. Such a form mints no token, so verifying it rejects every real submission while
94
+ * looking completely normal. Omit it and the form is verified, which is the safe default.
95
+ */
96
+ turnstile?: boolean;
97
+ /** Rename the honeypot trap, or `false` to disable it. See includes/webmonterey/forms. */
98
+ honeypot?: string | false;
99
+ }
100
+ >;
101
+ }