@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,82 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { renderHtml, renderText } from './autoresponse.ts';
5
+ import { renderHtml as renderNotificationHtml } from './submission-notification.ts';
6
+
7
+ const input = {
8
+ body: 'Thanks for getting in touch.',
9
+ fields: [
10
+ { label: 'Your name', name: 'name', value: 'Ada' },
11
+ { label: 'Email address', name: 'email', value: 'ada@example.com' },
12
+ ],
13
+ client: 'Acme Co',
14
+ domain: 'example.com',
15
+ };
16
+
17
+ test('the visitor sees the client-authored copy first', () => {
18
+ assert.ok(renderText(input).startsWith('Thanks for getting in touch.'));
19
+ assert.ok(renderHtml(input).includes('Thanks for getting in touch.'));
20
+ });
21
+
22
+ test('their own submission is echoed back', () => {
23
+ const text = renderText(input);
24
+ assert.ok(text.includes('Your name'));
25
+ assert.ok(text.includes('Ada'));
26
+ });
27
+
28
+ test('it never carries the D1 reference', () => {
29
+ /*
30
+ * The notification includes "Reference: #id" so an enquiry can be found later. This message
31
+ * goes to an address the site does not control, so an internal row id must not ride along.
32
+ * The input type has no submissionId at all — this guards against one being added.
33
+ */
34
+ assert.ok(!renderText(input).includes('Reference'));
35
+ assert.ok(!renderHtml(input).includes('Reference'));
36
+
37
+ const notification = renderNotificationHtml({
38
+ form: 'contact',
39
+ formName: 'Contact',
40
+ fields: input.fields,
41
+ client: input.client,
42
+ domain: input.domain,
43
+ submissionId: 42,
44
+ });
45
+ assert.ok(notification.includes('Reference'), 'the notification still carries it');
46
+ });
47
+
48
+ test('the body copy is escaped', () => {
49
+ const html = renderHtml({ ...input, body: '<script>alert(1)</script>' });
50
+ assert.ok(!html.includes('<script>alert'));
51
+ assert.ok(html.includes('&lt;script&gt;'));
52
+ });
53
+
54
+ test('field values are escaped', () => {
55
+ const html = renderHtml({
56
+ ...input,
57
+ fields: [{ label: 'Name', name: 'name', value: '<img src=x onerror=alert(1)>' }],
58
+ });
59
+ assert.ok(!html.includes('<img src=x'));
60
+ });
61
+
62
+ test('an empty value renders a dash rather than a blank cell', () => {
63
+ const html = renderHtml({
64
+ ...input,
65
+ fields: [{ label: 'Phone', name: 'phone', value: '' }],
66
+ });
67
+ assert.ok(html.includes('&mdash;'));
68
+ assert.ok(
69
+ renderText({ ...input, fields: [{ label: 'Phone', name: 'phone', value: '' }] }).includes('-'),
70
+ );
71
+ });
72
+
73
+ test('it reuses the shared footer', () => {
74
+ assert.ok(renderText(input).includes('Powered by WebMonterey'));
75
+ assert.ok(renderHtml(input).includes('Powered by WebMonterey'));
76
+ assert.ok(renderText(input).includes('Acme Co'));
77
+ });
78
+
79
+ test('both parts are produced for deliverability', () => {
80
+ assert.ok(renderText(input).length > 0);
81
+ assert.ok(renderHtml(input).startsWith('<!doctype html>'));
82
+ });
@@ -0,0 +1,83 @@
1
+ /*
2
+ * The confirmation email sent to the VISITOR after they submit a form.
3
+ *
4
+ * The second template, and the reason footer.ts and subject.ts are shared modules rather than
5
+ * code inside submission-notification.ts. Nothing about the footer or the subject format is
6
+ * re-implemented here.
7
+ *
8
+ * IT IS NOT THE NOTIFICATION WITH THE RECIPIENTS SWAPPED. Three things invert:
9
+ *
10
+ * - `to` is the visitor, so this is the one message on the site that reaches an address the
11
+ * site does not control. It must never carry the D1 reference, internal notes, or any
12
+ * field the visitor did not themselves type.
13
+ * - Reply-To is the CLIENT's address, so a visitor replying reaches a human. The
14
+ * notification does the opposite and points at the enquirer.
15
+ * - It leads with copy, because the recipient did not ask for a data dump — they want
16
+ * confirmation that the thing they just did worked.
17
+ *
18
+ * The echo of their own submission is deliberate: it is the only record they have, since the
19
+ * form cleared when it submitted.
20
+ */
21
+ import { escapeHtml, renderFooterHtml, renderFooterText } from './footer.ts';
22
+ import { DEFAULT_COPY } from '../includes/webmonterey/copy-defaults.ts';
23
+
24
+ export interface AutoresponseEmailInput {
25
+ /** Client-authored copy from the form definition's `autoresponse.body`. */
26
+ body: string;
27
+ /** The submitted fields, echoed back in display order. */
28
+ fields: Array<{ label: string; name: string; value: string }>;
29
+ /** The client's display name, for the footer's copyright line. */
30
+ client: string;
31
+ /** The site's production domain, named in the footer. */
32
+ domain: string;
33
+ }
34
+
35
+ /** The heading above the echoed fields, in both parts. */
36
+ const ECHO_HEADING = '${DEFAULT_COPY.email.autoresponseHeading}';
37
+
38
+ export function renderText(input: AutoresponseEmailInput): string {
39
+ const lines = [
40
+ input.body,
41
+ '',
42
+ ECHO_HEADING,
43
+ '',
44
+ ...input.fields.map((f) => `${f.label}:\n${f.value || '-'}\n`),
45
+ ];
46
+
47
+ lines.push(renderFooterText({ client: input.client, domain: input.domain }));
48
+
49
+ return lines.join('\n');
50
+ }
51
+
52
+ export function renderHtml(input: AutoresponseEmailInput): string {
53
+ const rows = input.fields
54
+ .map(
55
+ (f) => ` <tr>
56
+ <th align="left" style="padding:8px 16px 8px 0;vertical-align:top;color:#3f3f3f;font-weight:600;white-space:nowrap;">${escapeHtml(f.label)}</th>
57
+ <td style="padding:8px 0;vertical-align:top;color:#222;">${escapeHtml(f.value) || '&mdash;'}</td>
58
+ </tr>`,
59
+ )
60
+ .join('\n');
61
+
62
+ /*
63
+ * Inline styles and a table layout, for the same reason as the notification: email clients
64
+ * strip <style> blocks, so the site's design tokens cannot reach here.
65
+ *
66
+ * The body copy is escaped like everything else. It comes from the form definition rather
67
+ * than from a visitor, but it is still authored text arriving through JSON, and there is no
68
+ * case where raw HTML in it would be intended.
69
+ */
70
+ return `<!doctype html>
71
+ <html>
72
+ <body style="margin:0;padding:24px;background:#f1eae8;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
73
+ <div style="max-width:640px;margin:0 auto;padding:32px;background:#fff;border-radius:8px;">
74
+ <p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#222;">${escapeHtml(input.body)}</p>
75
+ <p style="margin:0 0 8px;font-size:13px;font-weight:600;color:#3f3f3f;">${ECHO_HEADING}</p>
76
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
77
+ ${rows}
78
+ </table>
79
+ </div>
80
+ ${renderFooterHtml({ client: input.client, domain: input.domain })}
81
+ </body>
82
+ </html>`;
83
+ }
@@ -0,0 +1,102 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { escapeHtml, renderFooterHtml, renderFooterText } from './footer.ts';
5
+
6
+ const input = { client: 'Acme Co', domain: 'example.com' };
7
+
8
+ test('escapeHtml neutralises the four dangerous characters', () => {
9
+ assert.equal(escapeHtml('<a href="x">&</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;');
10
+ });
11
+
12
+ test('escapeHtml escapes the ampersand first', () => {
13
+ // Escaping & last would double-escape the entities the other replacements just produced.
14
+ assert.equal(escapeHtml('&lt;'), '&amp;lt;');
15
+ });
16
+
17
+ test('the text footer is three lines below a leading blank', () => {
18
+ const lines = renderFooterText(input).split('\n');
19
+ assert.equal(lines[0], '');
20
+ assert.equal(lines.length, 4);
21
+ assert.match(lines[1], /^© \d{4} Acme Co$/);
22
+ assert.equal(
23
+ lines[2],
24
+ 'This is an automated notification for your account at the example.com website.',
25
+ );
26
+ assert.equal(lines[3], 'Powered by WebMonterey');
27
+ });
28
+
29
+ test('the year is the render year, not a build constant', () => {
30
+ assert.ok(renderFooterText(input).includes(String(new Date().getFullYear())));
31
+ });
32
+
33
+ test('the HTML footer escapes an interpolated client name', () => {
34
+ const html = renderFooterHtml({ client: '<script>alert(1)</script>', domain: 'example.com' });
35
+ assert.ok(!html.includes('<script>'));
36
+ assert.ok(html.includes('&lt;script&gt;'));
37
+ });
38
+
39
+ test('the HTML footer escapes an interpolated domain', () => {
40
+ const html = renderFooterHtml({ client: 'Acme', domain: '"onmouseover="x' });
41
+ assert.ok(!html.includes('"onmouseover="x</a>'));
42
+ assert.ok(html.includes('&quot;'));
43
+ });
44
+
45
+ /** The credit link out of the rendered footer, told apart from the client's own site link. */
46
+ function creditHref(html: string): string {
47
+ const href = html.match(/href="(https:\/\/webmonterey\.com[^"]*)"/)?.[1];
48
+ assert.ok(href, 'the credit link should be present');
49
+ return href;
50
+ }
51
+
52
+ test('the credit link is tagged as coming from email', () => {
53
+ const href = creditHref(renderFooterHtml(input));
54
+ // The HTML is escaped, so read the params back out of the escaped form.
55
+ assert.match(href, /utm_medium=email/);
56
+ /* Underscored, not dotted: a mail client linkifies a domain inside a query string. */
57
+ assert.match(href, /utm_content=example_com/);
58
+ assert.ok(!href.includes('/credits'), 'the credit link must not reintroduce a /credits path');
59
+ });
60
+
61
+ test("the client's TLD appears nowhere in the email credit URL", () => {
62
+ /*
63
+ * THE assertion that catches the bug this guards. A dotted utm_content is a domain sitting
64
+ * in a query string; a mail client linkifies it and truncates the href there, so the credit
65
+ * link arrives broken. Checked against the query rather than the whole URL because the
66
+ * destination host is webmonterey.com and is allowed to look like a host.
67
+ */
68
+ const { search } = new URL(
69
+ creditHref(renderFooterHtml({ ...input, domain: 'sub.example.co.uk' })),
70
+ );
71
+ assert.equal(search.includes('.uk'), false);
72
+ assert.equal(search.includes('.'), false, 'no dot may reach the query string at all');
73
+ assert.match(search, /utm_content=sub_example_co_uk/);
74
+ });
75
+
76
+ test('both surfaces use the same wording', () => {
77
+ assert.ok(renderFooterText(input).includes('Powered by WebMonterey'));
78
+ assert.ok(renderFooterHtml(input).includes('Powered by WebMonterey'));
79
+ });
80
+
81
+ /*
82
+ * Both footer links open in a new tab.
83
+ *
84
+ * The web component carried `rel="noopener"` and no `target`, which does nothing - noopener only
85
+ * means anything alongside a target - so the credit navigated away from the client's site in the
86
+ * same tab. It was found broken on live sites. These assert the email side cannot regress the
87
+ * same way; the Astro component is covered by a build-output check in examples/minimal.
88
+ */
89
+ test('every link in the HTML footer opens in a new tab', () => {
90
+ const html = renderFooterHtml({ client: 'Acme Co', domain: 'acme.com' });
91
+ const anchors = [...html.matchAll(/<a\b[^>]*>/g)].map((m) => m[0]);
92
+ assert.ok(anchors.length >= 2, 'expected the site link and the credit link');
93
+ for (const a of anchors) {
94
+ assert.match(a, /target="_blank"/, `missing target: ${a}`);
95
+ assert.match(a, /rel="[^"]*noopener/, `target without noopener: ${a}`);
96
+ }
97
+ });
98
+
99
+ test('the credit link keeps its referrer - that is the attribution', () => {
100
+ const html = renderFooterHtml({ client: 'Acme Co', domain: 'acme.com' });
101
+ assert.ok(!html.includes('noreferrer'), 'noreferrer would strip the attribution signal');
102
+ });
@@ -0,0 +1,91 @@
1
+ /*
2
+ * The footer every transactional email ends with. Three lines, below the white card:
3
+ *
4
+ * (c) 2026 Client Name
5
+ * This is an automated notification for your account at the example.com website.
6
+ * Powered by WebMonterey
7
+ *
8
+ * A separate module because submission-notification.ts is the FIRST template, not the only
9
+ * one — an autoresponder or a receipt would otherwise copy this block, and the copies drift.
10
+ * Same reasoning the Mailgun sender already carries.
11
+ *
12
+ * The year is computed HERE rather than passed in. These only ever render on
13
+ * `prerender = false` routes, so `new Date()` is real request time, not a frozen build
14
+ * constant — and a caller that has to remember to pass the year eventually forgets, which
15
+ * shows up as a stale copyright the following January.
16
+ */
17
+ import { CREDIT_TEXT, creditUrl } from '../includes/webmonterey/credits/credit.ts';
18
+ import { DEFAULT_COPY, fill } from '../includes/webmonterey/copy-defaults.ts';
19
+
20
+ export interface EmailFooterInput {
21
+ /** The client's display name, for the copyright line. */
22
+ client: string;
23
+ /** The site's production domain, named in the disclaimer and used for attribution. */
24
+ domain: string;
25
+ }
26
+
27
+ /** Minimal HTML escape. Shared with the templates; every interpolated value is untrusted. */
28
+ export function escapeHtml(value: string): string {
29
+ return value
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;');
34
+ }
35
+
36
+ function disclaimer(domain: string): string {
37
+ return `${fill(DEFAULT_COPY.email.footerNotice, { domain })}`;
38
+ }
39
+
40
+ /**
41
+ * The plain-text footer.
42
+ *
43
+ * DELIBERATELY CARRIES NO LINK. The credit appears as words only, so this surface contributes no
44
+ * attribution and `utm_medium=email` only ever fires from the HTML version. That is accepted: a
45
+ * UTM-laden URL on its own line is ugly in plain text, and it is the one place a mail client
46
+ * genuinely would linkify and truncate it.
47
+ *
48
+ * Do not "fix" this by adding the URL.
49
+ */
50
+ export function renderFooterText(input: EmailFooterInput): string {
51
+ return [
52
+ '',
53
+ `© ${new Date().getFullYear()} ${input.client}`,
54
+ disclaimer(input.domain),
55
+ CREDIT_TEXT,
56
+ ].join('\n');
57
+ }
58
+
59
+ export function renderFooterHtml(input: EmailFooterInput): string {
60
+ const year = new Date().getFullYear();
61
+ const href = creditUrl(input.domain, 'email');
62
+
63
+ /*
64
+ * Inline styles and explicit colors: email clients strip <style> blocks entirely, so the
65
+ * site's design tokens cannot reach here. Deliberately NOT a table — this is three
66
+ * centred lines and every client renders that consistently.
67
+ *
68
+ * BOTH LINKS OPEN IN A NEW TAB AND NEITHER ANNOUNCES IT, unlike the site footer, which
69
+ * appends a visually-hidden "(opens in a new tab)". That asymmetry is deliberate, and the
70
+ * reason is the same one that forced inline styles: the hiding technique cannot come with
71
+ * it. `.webm-visually-hidden` is a stylesheet rule, and clients strip <style>; the inline
72
+ * equivalent leans on `clip-path`, which Outlook and several webmail clients do not honour,
73
+ * so the note would land as literal visible text in the footer of every notification.
74
+ * Announcing it in words is the alternative and it is worse: a mail client opening a link
75
+ * in a browser is the expected behavior, so the warning would be noise about something no
76
+ * reader was surprised by. Nothing is lost that email a11y actually asks for.
77
+ *
78
+ * `title` is absent from both links ON PURPOSE, here and in Credit.astro. It is not reliably
79
+ * announced by screen readers, is unreachable by keyboard and touch entirely, and either
80
+ * duplicates the link text or competes with it for the accessible name. The link text is the
81
+ * accessible name; that is the mechanism that works.
82
+ *
83
+ * `rel="noopener"` without `noreferrer`, also on purpose: the referrer IS the attribution.
84
+ * Stripping it would leave only utm_content. See credit.ts.
85
+ */
86
+ return ` <div style="max-width:640px;margin:0 auto;padding:24px 32px 8px;text-align:center;font-size:13px;line-height:1.6;color:#3f3f3f;">
87
+ <p style="margin:0;">&copy; ${year} ${escapeHtml(input.client)}</p>
88
+ <p style="margin:0;">This is an automated notification for your account at the <a href="https://${escapeHtml(input.domain)}" target="_blank" rel="noopener" style="color:#3f3f3f;">${escapeHtml(input.domain)}</a> website.</p>
89
+ <p style="margin:8px 0 0;"><a href="${href}" target="_blank" rel="noopener" style="color:#222;font-weight:700;text-decoration:none;">${CREDIT_TEXT}</a></p>
90
+ </div>`;
91
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ * @cparkerwebm/webmonterey/emails
3
+ *
4
+ * Transactional templates - form notifications and autoresponses - as plain functions.
5
+ *
6
+ * PLAIN FUNCTIONS RETURNING STRINGS, not a component framework. Email HTML is tables and inline
7
+ * styles - Outlook has enforced that for fifteen years - so a framework's main advantages do not
8
+ * survive the constraint, and @react-email/render would mean React SSR inside a Worker on every
9
+ * send. Zero dependencies, runs in a Worker, testable with `node --test`.
10
+ *
11
+ * Styling comes from `emailPalette()` in ./design, which resolves every var() chain to a literal.
12
+ * A custom property in an email is inert; `--webm-link: var(--webm-action)` is useless in a mail
13
+ * client and `#006abe` is not.
14
+ */
15
+ export { renderFooterHtml, renderFooterText, escapeHtml } from './footer.ts';
16
+ export { renderSubject, renderTopic, toAsciiSubject } from './subject.ts';
17
+ export {
18
+ renderHtml as renderNotificationHtml,
19
+ renderText as renderNotificationText,
20
+ } from './submission-notification.ts';
21
+ export {
22
+ renderHtml as renderAutoresponseHtml,
23
+ renderText as renderAutoresponseText,
24
+ } from './autoresponse.ts';
@@ -0,0 +1,66 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { renderSubject, renderTopic, toAsciiSubject } from './subject.ts';
5
+
6
+ test('toAsciiSubject transliterates accents rather than dropping them', () => {
7
+ assert.equal(toAsciiSubject('José'), 'Jose');
8
+ assert.equal(toAsciiSubject('naïve café'), 'naive cafe');
9
+ });
10
+
11
+ test('toAsciiSubject folds smart punctuation to ASCII', () => {
12
+ assert.equal(toAsciiSubject('“quoted” — it’s here…'), '"quoted" - it\'s here...');
13
+ });
14
+
15
+ test('toAsciiSubject strips CR and LF — header injection', () => {
16
+ // The whole point: a newline would end the Subject: header and let what follows be read
17
+ // as a new one. Bcc: must not survive in any form.
18
+ const injected = toAsciiSubject('Enquiry\r\nBcc: attacker@example.com');
19
+ assert.ok(!injected.includes('\r'));
20
+ assert.ok(!injected.includes('\n'));
21
+ /*
22
+ * Note the missing space: control characters are DELETED by the printable-ASCII filter
23
+ * before `\s+` ever runs, so the words either side join rather than being separated. That
24
+ * is only ever visible on a payload like this one, and stripping is the property that
25
+ * matters — but assert the real output so a future rewrite that turns CR/LF into a space
26
+ * (and might therefore be preserving them somewhere) shows up here.
27
+ */
28
+ assert.equal(injected, 'EnquiryBcc: attacker@example.com');
29
+ });
30
+
31
+ test('toAsciiSubject collapses whitespace and trims', () => {
32
+ assert.equal(toAsciiSubject(' a\t\t b '), 'a b');
33
+ });
34
+
35
+ test('renderTopic fills placeholders from fields', () => {
36
+ const out = renderTopic('New enquiry from {{name}}', [{ name: 'name', value: 'Ada' }]);
37
+ assert.equal(out, 'New enquiry from Ada');
38
+ });
39
+
40
+ test('renderTopic tolerates whitespace inside the braces', () => {
41
+ assert.equal(renderTopic('Hi {{ name }}', [{ name: 'name', value: 'Ada' }]), 'Hi Ada');
42
+ });
43
+
44
+ test('renderTopic empties an unmatched placeholder rather than showing it', () => {
45
+ assert.equal(renderTopic('From {{missing}}!', []), 'From !');
46
+ });
47
+
48
+ test('renderSubject prefixes with the client name', () => {
49
+ assert.equal(renderSubject('Acme Co', 'New enquiry', []), '[Acme Co] New enquiry');
50
+ });
51
+
52
+ test('renderSubject omits the prefix entirely when there is no client', () => {
53
+ // Never "[CHANGEME] ..." and never a stray "[] " — callers pass null on an unconfigured site.
54
+ assert.equal(renderSubject(null, 'New enquiry', []), 'New enquiry');
55
+ });
56
+
57
+ test('renderSubject ASCII-folds the client name too', () => {
58
+ assert.equal(renderSubject('Café Ltd', 'Enquiry', []), '[Cafe Ltd] Enquiry');
59
+ });
60
+
61
+ test('renderSubject sanitises values arriving through placeholders', () => {
62
+ const subject = renderSubject('Acme', 'Enquiry from {{name}}', [
63
+ { name: 'name', value: 'Ada\r\nBcc: attacker@example.com' },
64
+ ]);
65
+ assert.ok(!/[\r\n]/.test(subject));
66
+ });
@@ -0,0 +1,80 @@
1
+ /*
2
+ * Subject lines for transactional email.
3
+ *
4
+ * FORMAT: [Client Name] Topic
5
+ *
6
+ * The bracketed prefix is applied HERE rather than written into each form's `notify.subject`,
7
+ * so a form definition only ever states its topic. Putting it in the JSON would make it a
8
+ * thing to remember for every new form and every new client site, and the first one forgotten
9
+ * is invisible until a client's inbox rules quietly stop matching.
10
+ *
11
+ * The prefix is the CLIENT NAME, not the domain — it is read by a person triaging their inbox,
12
+ * and "[Steven Glaze]" sorts and scans better than "[stevenglaze.com]".
13
+ */
14
+
15
+ /**
16
+ * Force a subject fragment to single-line ASCII.
17
+ *
18
+ * TWO reasons, and the second one matters more than it looks:
19
+ *
20
+ * 1. A non-ASCII subject has to be MIME encoded-word encoded, and anything that fails to
21
+ * decode it shows the recipient a raw `=?UTF-8?Q?...?=`. Visitor input reaches this
22
+ * string through `{{name}}`-style placeholders, so a smart quote pasted out of Word is
23
+ * enough to trigger it.
24
+ *
25
+ * 2. A subject is a mail HEADER. A CR or LF inside one is the classic header-injection
26
+ * vector — it ends the Subject: header early and lets whatever follows be read as a new
27
+ * header (Bcc:, say). Mailgun's form-encoded API makes that unlikely to reach the wire,
28
+ * but the correct place to be sure is where untrusted text enters a header, not one layer
29
+ * down in someone else's parser.
30
+ *
31
+ * Accents are transliterated rather than dropped, so "José" reads as "Jose" and not "Jos".
32
+ */
33
+ export function toAsciiSubject(value: string): string {
34
+ return (
35
+ value
36
+ // Decompose accented characters into base letter + combining mark, then drop the marks.
37
+ .normalize('NFKD')
38
+ .replace(/[̀-ͯ]/g, '')
39
+ // Punctuation NFKD leaves alone but that is still not ASCII.
40
+ .replace(/[‘’‚‛]/g, "'")
41
+ .replace(/[“”„‟]/g, '"')
42
+ .replace(/[–—―]/g, '-')
43
+ .replace(/…/g, '...')
44
+ .replace(/[   ]/g, ' ')
45
+ // Anything left outside printable ASCII, including CR/LF and other control characters.
46
+ .replace(/[^\x20-\x7e]/g, '')
47
+ .replace(/\s+/g, ' ')
48
+ .trim()
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Replace `{{field}}` placeholders in a topic template.
54
+ * An unmatched placeholder becomes an empty string rather than being left visible.
55
+ */
56
+ export function renderTopic(
57
+ template: string,
58
+ fields: Array<{ name: string; value: string }>,
59
+ ): string {
60
+ return template.replace(/\{\{\s*([\w-]+)\s*\}\}/g, (_match, key: string) => {
61
+ return fields.find((f) => f.name === key)?.value ?? '';
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Build the full subject line: `[Client Name] Topic`.
67
+ *
68
+ * The prefix is omitted entirely rather than rendered as `[CHANGEME]` on an unconfigured
69
+ * starter — see the callers' handling in src/actions/index.ts.
70
+ */
71
+ export function renderSubject(
72
+ client: string | null,
73
+ topicTemplate: string,
74
+ fields: Array<{ name: string; value: string }>,
75
+ ): string {
76
+ const topic = toAsciiSubject(renderTopic(topicTemplate, fields));
77
+ const prefix = client ? toAsciiSubject(client) : '';
78
+
79
+ return prefix ? `[${prefix}] ${topic}`.trim() : topic;
80
+ }
@@ -0,0 +1,76 @@
1
+ /*
2
+ * The notification email sent to the client when someone submits a form.
3
+ *
4
+ * Plain functions returning strings rather than a template engine — an email body is a
5
+ * string, and adding a dependency to build one would break the vanilla rule for no gain.
6
+ *
7
+ * Both a text and an HTML part are produced. Sending both materially improves deliverability;
8
+ * text-only mail is more likely to be filtered, and HTML-only is worse.
9
+ *
10
+ * NO HEADING IN THE BODY. The subject line already says what this is —
11
+ * `[Client Name] New contact enquiry from Jane` — and repeating it as an <h1> directly under
12
+ * it just pushes the actual fields further down the preview pane. Subject lines are built by
13
+ * subject.ts; the shared three-line footer is footer.ts.
14
+ */
15
+ import { escapeHtml, renderFooterHtml, renderFooterText } from './footer.ts';
16
+ import { DEFAULT_COPY, fill } from '../includes/webmonterey/copy-defaults.ts';
17
+
18
+ export interface SubmissionEmailInput {
19
+ /** Form id, e.g. 'contact'. */
20
+ form: string;
21
+ /** Human name of the form, from its definition. */
22
+ formName: string;
23
+ /** The submitted fields, in display order. */
24
+ fields: Array<{ label: string; name: string; value: string }>;
25
+ /** The client's display name, for the footer's copyright line. */
26
+ client: string;
27
+ /** The site's production domain, named in the footer. */
28
+ domain: string;
29
+ /** D1 row id, so a specific enquiry can be found later. */
30
+ submissionId?: number;
31
+ }
32
+
33
+ export function renderText(input: SubmissionEmailInput): string {
34
+ const lines = [...input.fields.map((f) => `${f.label}:\n${f.value || '-'}\n`)];
35
+
36
+ if (input.submissionId !== undefined) {
37
+ lines.push(`${fill(DEFAULT_COPY.email.reference, { id: input.submissionId ?? '' })}`);
38
+ }
39
+
40
+ lines.push(renderFooterText({ client: input.client, domain: input.domain }));
41
+
42
+ return lines.join('\n');
43
+ }
44
+
45
+ export function renderHtml(input: SubmissionEmailInput): string {
46
+ const rows = input.fields
47
+ .map(
48
+ (f) => ` <tr>
49
+ <th align="left" style="padding:8px 16px 8px 0;vertical-align:top;color:#3f3f3f;font-weight:600;white-space:nowrap;">${escapeHtml(f.label)}</th>
50
+ <td style="padding:8px 0;vertical-align:top;color:#222;">${escapeHtml(f.value) || '&mdash;'}</td>
51
+ </tr>`,
52
+ )
53
+ .join('\n');
54
+
55
+ const reference =
56
+ input.submissionId !== undefined
57
+ ? `<p style="margin:24px 0 0;color:#3f3f3f;font-size:13px;">${fill(DEFAULT_COPY.email.reference, { id: input.submissionId ?? '' })}</p>`
58
+ : '';
59
+
60
+ /*
61
+ * Inline styles and a table layout on purpose. Email clients strip <style> blocks and have
62
+ * no meaningful CSS support — the site's design tokens cannot be used here.
63
+ */
64
+ return `<!doctype html>
65
+ <html>
66
+ <body style="margin:0;padding:24px;background:#f1eae8;font-family:system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
67
+ <div style="max-width:640px;margin:0 auto;padding:32px;background:#fff;border-radius:8px;">
68
+ <table style="width:100%;border-collapse:collapse;font-size:14px;">
69
+ ${rows}
70
+ </table>
71
+ ${reference}
72
+ </div>
73
+ ${renderFooterHtml({ client: input.client, domain: input.domain })}
74
+ </body>
75
+ </html>`;
76
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /// <reference types="astro/client" />
2
+ /// <reference types="@cloudflare/workers-types" />
3
+
4
+ /*
5
+ * Cloudflare's runtime types (D1Database, R2Bucket, KVNamespace) and Astro's virtual modules
6
+ * (astro:content, astro:actions) are ambient inside a site's build but not inside this package,
7
+ * where there is no site to generate them from. Referencing them here is what lets `tsc --noEmit`
8
+ * check the package on its own.
9
+ *
10
+ * A client site still gets its own worker-configuration.d.ts from `wrangler types`, which is what
11
+ * types THAT site's bindings. This file types the shapes the package uses generically.
12
+ */