@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,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* EVERY WORD THIS PACKAGE PUTS IN FRONT OF A VISITOR, IN ONE PLACE, OVERRIDABLE.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFAULTS AND THE MERGE, WITH NO IMPORTS. Kept free of `virtual:webm/site` because the email
|
|
5
|
+
* templates are unit-tested with plain `node --test`, outside Vite, where a virtual module cannot
|
|
6
|
+
* resolve - wiring them to the resolved copy directly broke both suites at once. The resolved
|
|
7
|
+
* value lives next door in ./copy.ts, which every component and action uses; anything that must
|
|
8
|
+
* stay importable outside a build takes its strings as input and falls back to these.
|
|
9
|
+
*
|
|
10
|
+
* WHY. An audit of the fleet found the package writing visitor-facing copy for six clients with
|
|
11
|
+
* no way to change any of it: a cookie banner with four category descriptions, a 404 page, six
|
|
12
|
+
* form error messages, an email footer, a "Back to top" label. All in English, all in one voice,
|
|
13
|
+
* all decided here.
|
|
14
|
+
*
|
|
15
|
+
* That is the same mistake as the structured-data component, which built nineteen node types of
|
|
16
|
+
* opinion about what a business is and could only be replaced wholesale. The lesson from it: the
|
|
17
|
+
* package owns the MECHANISM - consent state, validation order, sending mail - and the client
|
|
18
|
+
* owns what is SAID. A recording studio, a mayoral campaign and a library charity do not share a
|
|
19
|
+
* voice, and none of them necessarily works in English.
|
|
20
|
+
*
|
|
21
|
+
* ONE SEAM, NOT A PROP PER STRING. Threading `title` and `body` into the consent banner was the
|
|
22
|
+
* shape of the old fix, and the banner shows why it fails: two of its dozen strings got props,
|
|
23
|
+
* the rest did not, and Base passes neither - so even the two were unreachable. A single
|
|
24
|
+
* override merged over these defaults costs the package nothing per string added.
|
|
25
|
+
*
|
|
26
|
+
* A site overrides only what it wants, in webmonterey.json:
|
|
27
|
+
*
|
|
28
|
+
* "copy": {
|
|
29
|
+
* "consent": { "title": "Cookies on this site" },
|
|
30
|
+
* "notFound": { "body": "We could not find that page." }
|
|
31
|
+
* }
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface Copy {
|
|
35
|
+
consent: {
|
|
36
|
+
title: string;
|
|
37
|
+
body: string;
|
|
38
|
+
privacyLink: string;
|
|
39
|
+
manage: string;
|
|
40
|
+
rejectAll: string;
|
|
41
|
+
acceptAll: string;
|
|
42
|
+
save: string;
|
|
43
|
+
prefsTitle: string;
|
|
44
|
+
notice: string;
|
|
45
|
+
categories: Record<
|
|
46
|
+
'essential' | 'functional' | 'analytics' | 'marketing',
|
|
47
|
+
{
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
}
|
|
51
|
+
>;
|
|
52
|
+
};
|
|
53
|
+
notFound: { title: string; body: string };
|
|
54
|
+
scrollTop: { label: string };
|
|
55
|
+
form: {
|
|
56
|
+
unknownForm: string;
|
|
57
|
+
/** `{fields}` is replaced with the comma-separated labels still missing. */
|
|
58
|
+
missing: string;
|
|
59
|
+
verifyUnavailable: string;
|
|
60
|
+
verifyFailed: string;
|
|
61
|
+
noDelivery: string;
|
|
62
|
+
};
|
|
63
|
+
email: {
|
|
64
|
+
autoresponseHeading: string;
|
|
65
|
+
/** `{id}` is replaced with the submission id. */
|
|
66
|
+
reference: string;
|
|
67
|
+
/** `{domain}` is replaced with the site's domain. */
|
|
68
|
+
footerNotice: string;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/*
|
|
73
|
+
* The defaults are the copy the fleet shipped with, unchanged, so adopting this changes nothing
|
|
74
|
+
* for a site that overrides nothing. They are DEFAULTS, not a house style: a client is entitled
|
|
75
|
+
* to disagree with every one.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_COPY: Copy = {
|
|
78
|
+
consent: {
|
|
79
|
+
title: 'We Value Your Privacy',
|
|
80
|
+
body: 'We use cookies for essential functions and to understand how this site is used, so we can keep making it better.',
|
|
81
|
+
privacyLink: 'Privacy policy',
|
|
82
|
+
manage: 'Manage preferences',
|
|
83
|
+
rejectAll: 'Reject all',
|
|
84
|
+
acceptAll: 'Accept all',
|
|
85
|
+
save: 'Save preferences',
|
|
86
|
+
prefsTitle: 'Privacy Preferences',
|
|
87
|
+
notice: 'Cookie notice',
|
|
88
|
+
categories: {
|
|
89
|
+
essential: {
|
|
90
|
+
name: 'Essential',
|
|
91
|
+
description:
|
|
92
|
+
'Required for the site to function — security, navigation, and your privacy choices. Always on.',
|
|
93
|
+
},
|
|
94
|
+
functional: {
|
|
95
|
+
name: 'Functional',
|
|
96
|
+
description:
|
|
97
|
+
'Remembers preferences such as language or region to personalize your experience.',
|
|
98
|
+
},
|
|
99
|
+
analytics: {
|
|
100
|
+
name: 'Analytics',
|
|
101
|
+
description:
|
|
102
|
+
'Helps us understand how visitors use the site so we can improve it. Aggregated and anonymous.',
|
|
103
|
+
},
|
|
104
|
+
marketing: {
|
|
105
|
+
name: 'Marketing',
|
|
106
|
+
description:
|
|
107
|
+
'Used to measure campaigns and show you more relevant advertising across the web.',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
notFound: {
|
|
112
|
+
title: 'Page not found',
|
|
113
|
+
body: 'That page is not here. Try the navigation above, or get in touch.',
|
|
114
|
+
},
|
|
115
|
+
scrollTop: { label: 'Back to top' },
|
|
116
|
+
form: {
|
|
117
|
+
unknownForm: 'Unknown form.',
|
|
118
|
+
missing: 'Please complete: {fields}.',
|
|
119
|
+
verifyUnavailable: 'We could not verify your submission. Please try again shortly.',
|
|
120
|
+
verifyFailed: 'Verification failed. Please reload the page and try again.',
|
|
121
|
+
noDelivery: 'This form is not accepting messages right now. Please get in touch directly.',
|
|
122
|
+
},
|
|
123
|
+
email: {
|
|
124
|
+
autoresponseHeading: 'What you sent us',
|
|
125
|
+
reference: 'Reference: #{id}',
|
|
126
|
+
footerNotice: 'This is an automated notification for your account at the {domain} website.',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Merge the site's overrides over the defaults, key by key, at any depth. */
|
|
131
|
+
export function merge<T>(base: T, over: unknown): T {
|
|
132
|
+
if (!over || typeof over !== 'object' || Array.isArray(over)) return base;
|
|
133
|
+
const out = { ...(base as Record<string, unknown>) };
|
|
134
|
+
for (const [k, v] of Object.entries(over as Record<string, unknown>)) {
|
|
135
|
+
if (v === undefined || v === null) continue;
|
|
136
|
+
const current = out[k];
|
|
137
|
+
out[k] =
|
|
138
|
+
current && typeof current === 'object' && !Array.isArray(current) ? merge(current, v) : v;
|
|
139
|
+
}
|
|
140
|
+
return out as T;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Substitute `{name}` placeholders. Missing keys are left as written rather than blanked. */
|
|
144
|
+
export function fill(template: string, values: Record<string, string | number>): string {
|
|
145
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) =>
|
|
146
|
+
key in values ? String(values[key]) : whole,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* THE RESOLVED COPY: package defaults with the site's overrides merged over them.
|
|
3
|
+
*
|
|
4
|
+
* Split from ./copy-defaults.ts because this half imports `virtual:webm/site`, which only exists
|
|
5
|
+
* inside a Vite build. Anything unit-tested outside one - the email templates - imports the
|
|
6
|
+
* defaults and takes overrides as input instead.
|
|
7
|
+
*/
|
|
8
|
+
import site from 'virtual:webm/site';
|
|
9
|
+
import { DEFAULT_COPY, merge, type Copy } from './copy-defaults.ts';
|
|
10
|
+
|
|
11
|
+
export { DEFAULT_COPY, fill, type Copy } from './copy-defaults.ts';
|
|
12
|
+
|
|
13
|
+
export const copy: Copy = merge(DEFAULT_COPY, (site as { copy?: unknown }).copy);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
/*
|
|
3
|
+
* The WebMonterey agency credit. Goes in the site footer.
|
|
4
|
+
*
|
|
5
|
+
* <Credit /> -> Powered by WebMonterey
|
|
6
|
+
*
|
|
7
|
+
* The wording and the UTM-tagged link both come from credit.ts, which the email footer also
|
|
8
|
+
* imports — the two surfaces must say the same thing, and email cannot reuse this component
|
|
9
|
+
* (scoped CSS does not survive an email client).
|
|
10
|
+
*/
|
|
11
|
+
import { CREDIT_TEXT, creditUrl } from './credit.ts';
|
|
12
|
+
import { domain, hasDomain } from '../site.ts';
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
class?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { class: className } = Astro.props;
|
|
19
|
+
|
|
20
|
+
// Fail loudly at build rather than emit a credit link pointing at a placeholder.
|
|
21
|
+
if (!hasDomain) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'[webm] webmonterey.json "domain" is still CHANGEME. Set the production domain - the ' +
|
|
24
|
+
'agency credit link uses it for utm_content attribution.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const href = creditUrl(domain, 'website');
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
<p class:list={['webm-credit', className]}>
|
|
32
|
+
{
|
|
33
|
+
/*
|
|
34
|
+
OPENS IN A NEW TAB, and `target` is the half that was missing.
|
|
35
|
+
|
|
36
|
+
This element carried `rel="noopener"` and no `target`, which does nothing at all - noopener
|
|
37
|
+
only means anything alongside a target - and the credit navigated away from the client's site
|
|
38
|
+
in the same tab. Verified broken on live client sites before this was fixed.
|
|
39
|
+
|
|
40
|
+
`noopener` stays: without it the opened page gets a `window.opener` handle back into this one.
|
|
41
|
+
Deliberately NOT `noreferrer` - the referrer is the attribution, and stripping it would leave
|
|
42
|
+
only the utm_content.
|
|
43
|
+
|
|
44
|
+
The visually-hidden note is not decoration. A link that moves the user to another tab without
|
|
45
|
+
saying so is a semantics bug, and semantics are correctness rather than styling. The email
|
|
46
|
+
footer deliberately does NOT carry it - the hiding technique needs a stylesheet, and email
|
|
47
|
+
clients strip those. See emails/footer.ts.
|
|
48
|
+
|
|
49
|
+
NO `title` ATTRIBUTE, and that is the correct state rather than an omission. `title` is not
|
|
50
|
+
reliably announced by screen readers, is unreachable by keyboard and touch entirely, and
|
|
51
|
+
either duplicates the link text or competes with it for the accessible name. The link text
|
|
52
|
+
plus the hidden note IS the accessible name.
|
|
53
|
+
*/
|
|
54
|
+
}
|
|
55
|
+
<a href={href} target="_blank" rel="noopener">
|
|
56
|
+
{CREDIT_TEXT}<span class="webm-visually-hidden"> (opens in a new tab)</span>
|
|
57
|
+
</a>
|
|
58
|
+
</p>
|
|
59
|
+
|
|
60
|
+
<style>
|
|
61
|
+
@layer webm.components.core {
|
|
62
|
+
.webm-credit {
|
|
63
|
+
font-size: var(--webm-font-size-xs);
|
|
64
|
+
color: var(--webm-text-muted);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* Underline at the text's own color. An underline in --webm-border-subtle is ~1.3:1 on
|
|
69
|
+
* white — effectively invisible, leaving nothing to mark the link as clickable.
|
|
70
|
+
*/
|
|
71
|
+
.webm-credit a {
|
|
72
|
+
color: inherit;
|
|
73
|
+
text-decoration-color: currentColor;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.webm-credit a:hover {
|
|
77
|
+
color: var(--webm-link-hover);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { contentTag, CREDIT_TEXT, creditUrl } from './credit.ts';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Credit.astro read as SOURCE, because it cannot be imported here: it pulls in ../site.ts, which
|
|
9
|
+
* imports virtual:webm/site, which only resolves inside an Astro build. A source assertion is the
|
|
10
|
+
* weaker tool and it is the one available - and the alternative in place until now was nothing.
|
|
11
|
+
*
|
|
12
|
+
* WHY IT NEEDS ONE AT ALL. This anchor once carried rel="noopener" and no target, which does
|
|
13
|
+
* nothing whatsoever - noopener only means anything alongside a target - so the credit navigated
|
|
14
|
+
* the visitor away from the client's site in the same tab. That shipped to live sites. The
|
|
15
|
+
* regression test written afterwards guards the EMAIL footer, in emails/footer.test.ts; the
|
|
16
|
+
* component where the bug actually happened had no test until this one.
|
|
17
|
+
*/
|
|
18
|
+
const componentSource = readFileSync(new URL('./Credit.astro', import.meta.url), 'utf8');
|
|
19
|
+
/** The template with its {/* *\/} comment blocks removed, so prose about the rule is not read as the rule. */
|
|
20
|
+
const template = componentSource.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '');
|
|
21
|
+
const anchors: string[] = template.match(/<a\s[^>]*>/g) ?? [];
|
|
22
|
+
/** The single credit anchor. Asserted to be the only one below. */
|
|
23
|
+
const anchor = (): string => anchors[0] ?? '';
|
|
24
|
+
|
|
25
|
+
test('creditUrl points at the main site, not a /credits page', () => {
|
|
26
|
+
const url = new URL(creditUrl('example.com'));
|
|
27
|
+
assert.equal(url.origin, 'https://webmonterey.com');
|
|
28
|
+
assert.equal(url.pathname, '/', 'the credit link must not reintroduce a /credits path');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('creditUrl carries all four UTM parameters', () => {
|
|
32
|
+
const { searchParams } = new URL(creditUrl('example.com'));
|
|
33
|
+
assert.equal(searchParams.get('utm_source'), 'client');
|
|
34
|
+
assert.equal(searchParams.get('utm_campaign'), 'credits');
|
|
35
|
+
assert.equal(searchParams.get('utm_content'), 'example_com');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('utm_medium separates the two surfaces', () => {
|
|
39
|
+
const site = new URL(creditUrl('example.com', 'website'));
|
|
40
|
+
const email = new URL(creditUrl('example.com', 'email'));
|
|
41
|
+
assert.equal(site.searchParams.get('utm_medium'), 'website');
|
|
42
|
+
assert.equal(email.searchParams.get('utm_medium'), 'email');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('utm_medium defaults to website', () => {
|
|
46
|
+
assert.equal(new URL(creditUrl('example.com')).searchParams.get('utm_medium'), 'website');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('utm_content is the production domain, with every dot as an underscore', () => {
|
|
50
|
+
// Not the host the page is served from — a preview build must not fragment attribution.
|
|
51
|
+
const url = new URL(creditUrl('sub.example.co.uk', 'email'));
|
|
52
|
+
assert.equal(url.searchParams.get('utm_content'), 'sub_example_co_uk');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('both surfaces tag the same client the same way', () => {
|
|
56
|
+
const site = new URL(creditUrl('example.com', 'website'));
|
|
57
|
+
const email = new URL(creditUrl('example.com', 'email'));
|
|
58
|
+
assert.equal(site.searchParams.get('utm_content'), email.searchParams.get('utm_content'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('no dot survives into the credit URL, so nothing can linkify it', () => {
|
|
62
|
+
const url = new URL(creditUrl('example.com', 'email'));
|
|
63
|
+
assert.equal(url.searchParams.get('utm_content'), 'example_com');
|
|
64
|
+
/*
|
|
65
|
+
* Scoped to the QUERY STRING, not the whole URL: the destination host is webmonterey.com
|
|
66
|
+
* and is allowed to look like a host. It is the parameter value that must not.
|
|
67
|
+
*/
|
|
68
|
+
assert.equal(url.search.includes('.'), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('contentTag leaves a domain that has no dots alone', () => {
|
|
72
|
+
assert.equal(contentTag('localhost'), 'localhost');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('contentTag changes nothing but the dots', () => {
|
|
76
|
+
// The value still has to be recognisably the site it came from in the report.
|
|
77
|
+
assert.equal(contentTag('steven-glaze.com'), 'steven-glaze_com');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('CREDIT_TEXT is the shared wording', () => {
|
|
81
|
+
assert.equal(CREDIT_TEXT, 'Powered by WebMonterey');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/* ── the footer credit's link, mirroring emails/footer.test.ts ────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
test('the site credit renders exactly one link', () => {
|
|
87
|
+
assert.equal(anchors.length, 1, `expected one anchor, found ${anchors.length}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the site credit opens in a new tab', () => {
|
|
91
|
+
// The half that was missing. Without it the visitor leaves the client's site to read ours.
|
|
92
|
+
assert.match(anchor(), /target="_blank"/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('the new tab cannot reach back into the client page', () => {
|
|
96
|
+
assert.match(anchor(), /rel="[^"]*noopener/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* Locking a decision rather than an implementation detail: the referrer IS the attribution, so
|
|
101
|
+
* stripping it would leave only utm_content. This is the kind of thing someone adds in good faith
|
|
102
|
+
* while "hardening" a target=_blank link.
|
|
103
|
+
*/
|
|
104
|
+
test('the credit link deliberately does not send noreferrer', () => {
|
|
105
|
+
assert.doesNotMatch(anchor(), /noreferrer/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the new tab is announced, not just implemented', () => {
|
|
109
|
+
// A link that moves the user to another tab without saying so is a semantics bug.
|
|
110
|
+
assert.match(template, /webm-visually-hidden[^>]*>\s*\(opens in a new tab\)/);
|
|
111
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The agency credit's wording and link, in ONE place.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces render this credit and they cannot share a component: the site footer is an
|
|
5
|
+
* .astro component with scoped CSS, and transactional email is a string with inline styles
|
|
6
|
+
* (email clients strip <style> blocks). Without this module the wording exists twice, and the
|
|
7
|
+
* next time it changes one copy gets missed — which is exactly how "a WebMonterey website"
|
|
8
|
+
* would have survived in email after the footer moved to "Powered by WebMonterey".
|
|
9
|
+
*
|
|
10
|
+
* utm_content is ALWAYS the production domain from webmonterey.json, never the host the page
|
|
11
|
+
* happens to be served from. A preview build at `feature-x-webm-example-com.workers.dev`
|
|
12
|
+
* still reports the client's own domain, so staging traffic does not fragment the attribution.
|
|
13
|
+
*
|
|
14
|
+
* IT IS WRITTEN WITH UNDERSCORES — `example_com`, not `example.com`. A dot makes the value look
|
|
15
|
+
* like a hostname, and analytics UIs read it as one rather than as the label it is. Underscores
|
|
16
|
+
* keep it a label. Every dot goes, subdomains included: `sub.example.co.uk` is `sub_example_co_uk`.
|
|
17
|
+
*
|
|
18
|
+
* This comment used to also claim a mail client would linkify `example.com` inside the query
|
|
19
|
+
* string and truncate the URL there. That does not apply: auto-linkification acts on plain text,
|
|
20
|
+
* and here the URL only ever appears inside an href. Corrected rather than deleted, because a
|
|
21
|
+
* justification that does not survive scrutiny invites someone to reverse the decision it
|
|
22
|
+
* defends — and the analytics reason on its own is enough.
|
|
23
|
+
*
|
|
24
|
+
* The link points at the WebMonterey home page, not a /credits landing page. The UTM
|
|
25
|
+
* parameters carry the whole story, so the destination stays the main site and there is no
|
|
26
|
+
* separate page to keep alive. utm_medium is what separates the two surfaces: `website` from
|
|
27
|
+
* the footer, `email` from a transactional template.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The credit wording. Rendered verbatim on the site and in email. */
|
|
31
|
+
export const CREDIT_TEXT = 'Powered by WebMonterey';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which surface the click came from. Kept separate from utm_content so the report can tell
|
|
35
|
+
* a footer click from an email click without splitting it per client.
|
|
36
|
+
*/
|
|
37
|
+
export type CreditMedium = 'website' | 'email';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The client's domain as a UTM label rather than as a domain. Dots only — the value is
|
|
41
|
+
* otherwise left exactly as webmonterey.json wrote it, so what lands in the report is still
|
|
42
|
+
* recognisably the site it came from.
|
|
43
|
+
*/
|
|
44
|
+
export function contentTag(domain: string): string {
|
|
45
|
+
return domain.replace(/\./g, '_');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The attributed credit link for one client site. */
|
|
49
|
+
export function creditUrl(domain: string, medium: CreditMedium = 'website'): string {
|
|
50
|
+
const params = new URLSearchParams({
|
|
51
|
+
utm_source: 'client',
|
|
52
|
+
utm_medium: medium,
|
|
53
|
+
utm_campaign: 'credits',
|
|
54
|
+
/* See the note above: a dot here is a link waiting to be made out of the query string. */
|
|
55
|
+
utm_content: contentTag(domain),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return `https://webmonterey.com/?${params}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { HONEYPOT_FIELD, honeypotAttributes, isHoneypotFilled } from './honeypot.ts';
|
|
4
|
+
|
|
5
|
+
const form = (entries: Record<string, string>) => {
|
|
6
|
+
const data = new FormData();
|
|
7
|
+
for (const [k, v] of Object.entries(entries)) data.append(k, v);
|
|
8
|
+
return data;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
test('an empty or absent honeypot is a human', () => {
|
|
12
|
+
assert.equal(isHoneypotFilled(form({})), false);
|
|
13
|
+
assert.equal(isHoneypotFilled(form({ website: '' })), false);
|
|
14
|
+
assert.equal(isHoneypotFilled(form({ website: ' ' })), false, 'whitespace is not a fill');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('any real value is a bot', () => {
|
|
18
|
+
assert.equal(isHoneypotFilled(form({ website: 'http://spam.example' })), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('the field name is configurable, so a real "website" field is possible', () => {
|
|
22
|
+
/*
|
|
23
|
+
* Reserving one name across a whole fleet means the first client whose form genuinely asks for
|
|
24
|
+
* a website has every submission silently treated as spam.
|
|
25
|
+
*/
|
|
26
|
+
const data = form({ website: 'https://a-real-clients-site.com', hp_url: '' });
|
|
27
|
+
assert.equal(isHoneypotFilled(data, 'hp_url'), false, 'the real field is not the trap');
|
|
28
|
+
assert.equal(isHoneypotFilled(data), true, 'and the default would have caught it wrongly');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the rendering contract carries all four parts', () => {
|
|
32
|
+
// Get any one wrong and it catches real people, whose submission vanishes with a cheerful
|
|
33
|
+
// confirmation - the worst failure a contact form has.
|
|
34
|
+
const attrs = honeypotAttributes();
|
|
35
|
+
assert.equal(attrs.name, HONEYPOT_FIELD);
|
|
36
|
+
assert.equal(attrs['aria-hidden'], 'true');
|
|
37
|
+
assert.equal(attrs.tabindex, '-1');
|
|
38
|
+
assert.equal(attrs.autocomplete, 'off');
|
|
39
|
+
assert.match(attrs.class, /honeypot/);
|
|
40
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HONEYPOT - a field no human ever fills in.
|
|
3
|
+
*
|
|
4
|
+
* The cheapest bot filter there is: a real text input, rendered off-screen and out of the tab
|
|
5
|
+
* order, that a person never sees and therefore never types into. Naive form spammers fill every
|
|
6
|
+
* input they find, so a non-empty value here is a bot with near-certainty.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS RATHER THAN A CHALLENGE WIDGET, for a small form: no third-party script, so nothing to
|
|
9
|
+
* consent-gate, no request to another origin, no cost, and nothing for a real visitor to solve.
|
|
10
|
+
* It stops the crude majority and does NOT stop a targeted bot that reads the CSS - Turnstile is
|
|
11
|
+
* the answer to that. The two compose and neither replaces the other, which matters most for a
|
|
12
|
+
* form too small to justify a widget: a newsletter box that mints no Turnstile token has 100% of
|
|
13
|
+
* its sign-ups rejected while looking completely normal.
|
|
14
|
+
*
|
|
15
|
+
* Built independently on two client sites before it was in the package, which is the argument for
|
|
16
|
+
* it being here.
|
|
17
|
+
*
|
|
18
|
+
* EXPORTED FROM THE PACKAGE at `@cparkerwebm/webmonterey/webmonterey/forms`, because the trap is
|
|
19
|
+
* rendered by the site's own form COMPONENT and checked by the package's handler - two halves
|
|
20
|
+
* that must agree on one name. It was unexported until mikeformarina.com's rebuild could not
|
|
21
|
+
* resolve it: without the export a site copies the constant, and the day the default changes the
|
|
22
|
+
* component renders one name while the server checks another. A honeypot that catches nothing is
|
|
23
|
+
* silent, and so is one that catches everyone.
|
|
24
|
+
*
|
|
25
|
+
* THE FIELD NAME IS CONFIGURABLE, and that is not decoration. Bots weight recognisable names, so
|
|
26
|
+
* a URL-shaped one is caught most often - but reserving a single name across a whole fleet means
|
|
27
|
+
* the first client whose form genuinely asks for a website silently has every submission treated
|
|
28
|
+
* as spam. Per-form, defaulting to the name that works.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The default. Bots fill a field called `website` reliably; no real form may then use that name. */
|
|
32
|
+
export const HONEYPOT_FIELD = 'website';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The rendering contract, as a data attribute set. ALL FOUR PARTS MATTER:
|
|
36
|
+
*
|
|
37
|
+
* the class off-screen, NOT `display: none` - some bots skip undisplayed inputs
|
|
38
|
+
* aria-hidden a screen reader must not announce it
|
|
39
|
+
* tabindex="-1" a keyboard user must not land on it
|
|
40
|
+
* autocomplete a browser must not helpfully fill it in for someone
|
|
41
|
+
*
|
|
42
|
+
* Get any one of them wrong and it starts catching real people, whose submission then vanishes
|
|
43
|
+
* with a cheerful confirmation - the worst possible failure for a contact form.
|
|
44
|
+
*/
|
|
45
|
+
export const honeypotAttributes = (name: string = HONEYPOT_FIELD) =>
|
|
46
|
+
({
|
|
47
|
+
type: 'text',
|
|
48
|
+
name,
|
|
49
|
+
class: 'webm-honeypot',
|
|
50
|
+
'aria-hidden': 'true',
|
|
51
|
+
tabindex: '-1',
|
|
52
|
+
autocomplete: 'off',
|
|
53
|
+
value: '',
|
|
54
|
+
}) as const;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when the honeypot caught something.
|
|
58
|
+
*
|
|
59
|
+
* The caller should answer as though the submission SUCCEEDED rather than showing an error:
|
|
60
|
+
* telling a bot which check it failed is how the next attempt gets past it. That also means a
|
|
61
|
+
* false positive is invisible to the visitor, which is why the rendering contract above is
|
|
62
|
+
* strict.
|
|
63
|
+
*/
|
|
64
|
+
export function isHoneypotFilled(formData: FormData, name: string = HONEYPOT_FIELD): boolean {
|
|
65
|
+
return String(formData.get(name) ?? '').trim().length > 0;
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { escapeHtml, renderInline } from './inline.ts';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* This module is the sanitiser for everything passed to `set:html` in a prose block, so the
|
|
8
|
+
* escaping tests below are the load-bearing ones. Page JSON is trusted content, but "trusted"
|
|
9
|
+
* is a property of today's workflow, not of the code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
test('escapeHtml covers the five characters that break out', () => {
|
|
13
|
+
assert.equal(escapeHtml(`<>&"'`), '<>&"'');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('markup in page JSON is rendered inert', () => {
|
|
17
|
+
assert.equal(renderInline('<script>alert(1)</script>'), '<script>alert(1)</script>');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('an img onerror payload cannot escape', () => {
|
|
21
|
+
const out = renderInline('<img src=x onerror=alert(1)>');
|
|
22
|
+
assert.ok(!out.includes('<img'));
|
|
23
|
+
assert.ok(out.startsWith('<img'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('bold becomes strong', () => {
|
|
27
|
+
assert.equal(renderInline('**Essential** — always on'), '<strong>Essential</strong> — always on');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('italic becomes em', () => {
|
|
31
|
+
assert.equal(renderInline('it is _essential_ here'), 'it is <em>essential</em> here');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('underscores inside a word are left alone', () => {
|
|
35
|
+
assert.equal(renderInline('snake_case_name stays'), 'snake_case_name stays');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('a relative link is kept', () => {
|
|
39
|
+
assert.equal(renderInline('[policy](/privacy)'), '<a href="/privacy">policy</a>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('an anchor and a mailto are kept', () => {
|
|
43
|
+
assert.equal(renderInline('[top](#top)'), '<a href="#top">top</a>');
|
|
44
|
+
assert.equal(renderInline('[mail](mailto:a@b.com)'), '<a href="mailto:a@b.com">mail</a>');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('an https link is kept', () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
renderInline('[cf](https://cloudflare.com)'),
|
|
50
|
+
'<a href="https://cloudflare.com">cf</a>',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a javascript: link degrades to its own text', () => {
|
|
55
|
+
// The words the author wrote survive; the link does not.
|
|
56
|
+
const out = renderInline('[click](javascript:alert%281%29)');
|
|
57
|
+
assert.ok(!out.includes('<a '));
|
|
58
|
+
assert.ok(!out.includes('javascript:'));
|
|
59
|
+
assert.ok(out.includes('click'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a data: link degrades to its own text', () => {
|
|
63
|
+
const out = renderInline('[x](data:text/html;base64,PHNjcmlwdD4=)');
|
|
64
|
+
assert.ok(!out.includes('<a '));
|
|
65
|
+
assert.ok(!out.includes('data:'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('an unknown scheme is refused — allowlist, not blocklist', () => {
|
|
69
|
+
assert.ok(!renderInline('[x](vbscript:msgbox)').includes('<a '));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a quote in a URL cannot break out of the href attribute', () => {
|
|
73
|
+
const out = renderInline('[x](/a"onmouseover="alert(1))');
|
|
74
|
+
assert.ok(!out.includes('onmouseover="alert'));
|
|
75
|
+
assert.ok(out.includes('"'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('underscores in a URL are not treated as emphasis', () => {
|
|
79
|
+
assert.equal(
|
|
80
|
+
renderInline('[link](https://x.com/a_b_c)'),
|
|
81
|
+
'<a href="https://x.com/a_b_c">link</a>',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('bold and a link compose in one string', () => {
|
|
86
|
+
assert.equal(
|
|
87
|
+
renderInline('**Cloudflare** — see [docs](https://cf.com).'),
|
|
88
|
+
'<strong>Cloudflare</strong> — see <a href="https://cf.com">docs</a>.',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('plain text passes through unchanged', () => {
|
|
93
|
+
assert.equal(renderInline('Just a sentence.'), 'Just a sentence.');
|
|
94
|
+
});
|