@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- 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
|
+
}
|