@getvitops/astro 8.0.1 → 8.2.0-alpha.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 +395 -29
- package/dist/exports/tracking.d.mts +1 -1
- package/dist/exports/tracking.mjs +1 -1
- package/dist/index.d.mts +45 -18
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +153 -17
- package/dist/index.mjs.map +1 -1
- package/dist/middleware.d.mts +25 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +35 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/routes/conversion.d.mts +70 -11
- package/dist/routes/conversion.d.mts.map +1 -1
- package/dist/routes/conversion.mjs +114 -20
- package/dist/routes/conversion.mjs.map +1 -1
- package/dist/{tracking-DdEKExlE.d.mts → tracking-BNKKBqOe.d.mts} +2 -2
- package/dist/tracking-BNKKBqOe.d.mts.map +1 -0
- package/dist/{tracking-CmSfxWC3.mjs → tracking-M2PqlMpw.mjs} +2 -2
- package/dist/tracking-M2PqlMpw.mjs.map +1 -0
- package/package.json +10 -7
- package/src/ads.ts +46 -3
- package/src/analytics.ts +98 -11
- package/src/components/Analytics.astro +5 -1
- package/src/components/Icon.astro +8 -1
- package/src/components/Marquee.astro +12 -2
- package/src/components/Tracking.astro +2 -1
- package/src/scripts/tracking.ts +114 -21
- package/src/tracking.ts +1 -1
- package/virtual.d.ts +17 -1
- package/dist/tracking-CmSfxWC3.mjs.map +0 -1
- package/dist/tracking-DdEKExlE.d.mts.map +0 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MiddlewareHandler } from "astro";
|
|
2
|
+
//#region src/middleware.d.ts
|
|
3
|
+
export interface AbTestingExperiment {
|
|
4
|
+
/** The cookie this experiment is assigned/pinned through. */
|
|
5
|
+
cookieName: string;
|
|
6
|
+
/** Seconds. Omitted means a session cookie. */
|
|
7
|
+
cookieMaxAge?: number;
|
|
8
|
+
/** Fraction of new visitors auto-assigned to `variants[1]` (default 0.5). */
|
|
9
|
+
splitRatio?: number;
|
|
10
|
+
/** Exactly two variant names — `variants[0]` is the default/control. */
|
|
11
|
+
variants: readonly [string, string];
|
|
12
|
+
}
|
|
13
|
+
export interface VitopsAbTestingOptions {
|
|
14
|
+
/** Keyed the same way as `site.experiments` — pass only the enabled entries. */
|
|
15
|
+
experiments: Record<string, AbTestingExperiment>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.
|
|
19
|
+
* Exported so the assignment policy itself — not just its wiring — is
|
|
20
|
+
* directly testable without going through a request.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assignVariant(experiment: Pick<AbTestingExperiment, 'splitRatio' | 'variants'>, random?: () => number): string;
|
|
23
|
+
export declare function vitopsAbTesting(opts: VitopsAbTestingOptions): MiddlewareHandler;
|
|
24
|
+
//#endregion
|
|
25
|
+
//# sourceMappingURL=middleware.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.mts","names":[],"sources":["../src/middleware.ts"],"mappings":";;iBAqCiB;;EAEf;;EAEA;;EAEA;;EAEA;;iBAGe;;EAEf,aAAa,eAAe;;;;;;;wBAQd,cACd,YAAY,KAAK,iDACjB;wBAOc,gBAAgB,MAAM,yBAAyB"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/middleware.ts
|
|
2
|
+
/**
|
|
3
|
+
* Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.
|
|
4
|
+
* Exported so the assignment policy itself — not just its wiring — is
|
|
5
|
+
* directly testable without going through a request.
|
|
6
|
+
*/
|
|
7
|
+
function assignVariant(experiment, random = Math.random) {
|
|
8
|
+
return random() < (experiment.splitRatio ?? .5) ? experiment.variants[1] : experiment.variants[0];
|
|
9
|
+
}
|
|
10
|
+
function vitopsAbTesting(opts) {
|
|
11
|
+
const handler = (context, next) => {
|
|
12
|
+
const secure = context.url.protocol === "https:";
|
|
13
|
+
for (const experiment of Object.values(opts.experiments)) {
|
|
14
|
+
const cookieOpts = {
|
|
15
|
+
path: "/",
|
|
16
|
+
sameSite: "lax",
|
|
17
|
+
secure,
|
|
18
|
+
...experiment.cookieMaxAge != null ? { maxAge: experiment.cookieMaxAge } : {}
|
|
19
|
+
};
|
|
20
|
+
const queryValue = context.url.searchParams.get(experiment.cookieName);
|
|
21
|
+
if (queryValue && experiment.variants.includes(queryValue)) {
|
|
22
|
+
context.cookies.set(experiment.cookieName, queryValue, cookieOpts);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (context.cookies.get(experiment.cookieName)?.value) continue;
|
|
26
|
+
context.cookies.set(experiment.cookieName, assignVariant(experiment), cookieOpts);
|
|
27
|
+
}
|
|
28
|
+
return next();
|
|
29
|
+
};
|
|
30
|
+
return handler;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { assignVariant, vitopsAbTesting };
|
|
35
|
+
//# sourceMappingURL=middleware.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.mjs","names":[],"sources":["../src/middleware.ts"],"sourcesContent":["/**\n * `vitopsAbTesting()` — the assignment half of runtime A/B experiments (see\n * `site.experiments` in `@getvitops/generator`). A consumer wires this into\n * their own `src/middleware.ts`:\n *\n * ```ts\n * import { defineConfig } from 'astro:config';\n * import { vitopsAbTesting } from '@getvitops/astro/middleware';\n * import config from '../site.json' with { type: 'json' };\n *\n * export const onRequest = vitopsAbTesting({ experiments: config.site.experiments ?? {} });\n * ```\n *\n * The first Astro middleware this toolchain ships, and the first place it\n * calls `Astro.cookies.set()` — every existing cookie write elsewhere is\n * client-side `document.cookie`. Both are deliberate: assigning a visitor a\n * variant, once, before the page renders, is exactly what a middleware is for.\n *\n * **What this does NOT do**: check consent before setting the assignment\n * cookie. Every other demand in this toolchain (`_ac`, the ad-click\n * attribution cookie) is raised by a client-side `require()` call — a\n * concept that doesn't have a server-side equivalent here yet. The\n * *reporting* half (`@getvitops/astro/analytics`'s per-provider bootstrap\n * extensions) already piggybacks on each provider's own existing consent\n * gate, so nothing about this ships ungated data to a third party — but the\n * cookie itself is set unconditionally on first visit. `experiment.category`\n * is validated and carried through the config for when this gets a real\n * answer; treat its absence here as a known, stated gap, not an oversight.\n */\nimport type { MiddlewareHandler } from 'astro';\n\n// `astro:middleware`'s `defineMiddleware()` is an identity helper meant for\n// inference inside a real Astro project's own `src/middleware.ts` — that\n// virtual module isn't resolvable from a library package's own build. Typing\n// the returned function directly against the real `MiddlewareHandler` type\n// (imported from `astro` itself, not the virtual module) does the same job.\n\nexport interface AbTestingExperiment {\n /** The cookie this experiment is assigned/pinned through. */\n cookieName: string;\n /** Seconds. Omitted means a session cookie. */\n cookieMaxAge?: number;\n /** Fraction of new visitors auto-assigned to `variants[1]` (default 0.5). */\n splitRatio?: number;\n /** Exactly two variant names — `variants[0]` is the default/control. */\n variants: readonly [string, string];\n}\n\nexport interface VitopsAbTestingOptions {\n /** Keyed the same way as `site.experiments` — pass only the enabled entries. */\n experiments: Record<string, AbTestingExperiment>;\n}\n\n/**\n * Picks `variants[1]` with probability `splitRatio`, else `variants[0]`.\n * Exported so the assignment policy itself — not just its wiring — is\n * directly testable without going through a request.\n */\nexport function assignVariant(\n experiment: Pick<AbTestingExperiment, 'splitRatio' | 'variants'>,\n random = Math.random,\n): string {\n return random() < (experiment.splitRatio ?? 0.5)\n ? experiment.variants[1]\n : experiment.variants[0];\n}\n\nexport function vitopsAbTesting(opts: VitopsAbTestingOptions): MiddlewareHandler {\n const handler: MiddlewareHandler = (context, next) => {\n const secure = context.url.protocol === 'https:';\n\n for (const experiment of Object.values(opts.experiments)) {\n const cookieOpts = {\n path: '/',\n sameSite: 'lax' as const,\n secure,\n ...(experiment.cookieMaxAge != null ? { maxAge: experiment.cookieMaxAge } : {}),\n };\n\n // A recognized query param (named after the cookie itself) is checked\n // FIRST, regardless of whether a cookie already exists — this is what\n // makes a shared preview link (\"?<cookieName>=<variant>\") reliably force\n // that variant, even for a visitor already pinned to the other one.\n // Combined with `splitRatio: 0` (nobody auto-assigned), this is the\n // blue/green-style \"100% on control, but a manual link reveals the\n // other variant\" mode with no special-cased logic of its own.\n const queryValue = context.url.searchParams.get(experiment.cookieName);\n if (queryValue && (experiment.variants as readonly string[]).includes(queryValue)) {\n context.cookies.set(experiment.cookieName, queryValue, cookieOpts);\n continue;\n }\n\n // Otherwise: assign only when nothing is set yet. Never inspect or\n // \"correct\" an existing value against the current splitRatio — that's\n // what keeps an existing assignment (manual or random) pinned.\n if (context.cookies.get(experiment.cookieName)?.value) continue;\n context.cookies.set(experiment.cookieName, assignVariant(experiment), cookieOpts);\n }\n\n return next();\n };\n return handler;\n}\n"],"mappings":";;;;;;AA0DA,SAAgB,cACd,YACA,SAAS,KAAK,QACN;CACR,OAAO,OAAO,KAAK,WAAW,cAAc,MACxC,WAAW,SAAS,KACpB,WAAW,SAAS;AAC1B;AAEA,SAAgB,gBAAgB,MAAiD;CAC/E,MAAM,WAA8B,SAAS,SAAS;EACpD,MAAM,SAAS,QAAQ,IAAI,aAAa;EAExC,KAAK,MAAM,cAAc,OAAO,OAAO,KAAK,WAAW,GAAG;GACxD,MAAM,aAAa;IACjB,MAAM;IACN,UAAU;IACV;IACA,GAAI,WAAW,gBAAgB,OAAO,EAAE,QAAQ,WAAW,aAAa,IAAI,CAAC;GAC/E;GASA,MAAM,aAAa,QAAQ,IAAI,aAAa,IAAI,WAAW,UAAU;GACrE,IAAI,cAAe,WAAW,SAA+B,SAAS,UAAU,GAAG;IACjF,QAAQ,QAAQ,IAAI,WAAW,YAAY,YAAY,UAAU;IACjE;GACF;GAKA,IAAI,QAAQ,QAAQ,IAAI,WAAW,UAAU,CAAC,EAAE,OAAO;GACvD,QAAQ,QAAQ,IAAI,WAAW,YAAY,cAAc,UAAU,GAAG,UAAU;EAClF;EAEA,OAAO,KAAK;CACd;CACA,OAAO;AACT"}
|
|
@@ -1,9 +1,48 @@
|
|
|
1
|
+
import { Config } from "@getvitops/generator";
|
|
2
|
+
import { CrmConfig, CrmCredentials, CrmResult } from "@getvitops/utils/crm";
|
|
1
3
|
import { EmailBinding, NotifyContext, notify } from "@getvitops/utils/notify";
|
|
2
|
-
|
|
4
|
+
//#region src/routes/config-adapters.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the one display name for a site's organization — `organization.name`
|
|
7
|
+
* (localizable; `locale` picks which one, defaulting to `site.defaultLocale`,
|
|
8
|
+
* which is also the fallback if the pick has no entry), falling back to
|
|
9
|
+
* `legalName` when `name` isn't set at all, and to `undefined` (never `''`)
|
|
10
|
+
* when neither resolves to anything — `t()` returns `''` for a genuinely
|
|
11
|
+
* missing translation, and an empty name is worse than none.
|
|
12
|
+
*
|
|
13
|
+
* This is the one place that fallback chain is written. An EmDash site's own
|
|
14
|
+
* `getSiteSettings().title` is a separate, runtime-only fact (EmDash's
|
|
15
|
+
* settings aren't readable at Astro build/config time at all — see
|
|
16
|
+
* AGENTS.md's "EmDash plugin as the design-system editing surface") and stays
|
|
17
|
+
* authoritative for what it drives (the rendered page chrome); this helper is
|
|
18
|
+
* for the build-time-only consumers that can't reach EmDash at all — favicon
|
|
19
|
+
* generation, generated legal documents, notification emails — so a scaffold
|
|
20
|
+
* needing a name in `astro.config.mjs` has one real source instead of a third
|
|
21
|
+
* hardcoded string.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveOrganizationName(config: Config, locale?: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Builds the `context` option `createConversionRoute()` expects, straight from a
|
|
26
|
+
* resolved `Config` — instead of every consumer hand-writing a `NotifyContext`
|
|
27
|
+
* literal.
|
|
28
|
+
*
|
|
29
|
+
* `locationEmails` is primary-first, then every other location with an `email`,
|
|
30
|
+
* in `organization.locations`' own key order — `planNotifications` picks the
|
|
31
|
+
* first that's set, so order here is the whole precedence.
|
|
32
|
+
*/
|
|
33
|
+
export declare function toNotifyContext(config: Config, locale?: string): NotifyContext;
|
|
34
|
+
/**
|
|
35
|
+
* `site.crm` is already field-for-field identical to `CrmConfig` (see that
|
|
36
|
+
* interface's own docblock — it's deliberately kept in lockstep) — this is a
|
|
37
|
+
* type-narrowing pass-through, not a remap. Named rather than inlined at each
|
|
38
|
+
* call site so a future divergence between the two shapes has one place to fix.
|
|
39
|
+
*/
|
|
40
|
+
export declare function toCrmConfig(config: Config): CrmConfig | undefined;
|
|
41
|
+
//#endregion
|
|
3
42
|
//#region src/routes/conversion.d.ts
|
|
4
43
|
/** The subset of Astro's `APIContext` this needs — typed structurally so the
|
|
5
44
|
* factory does not pin an Astro version. */
|
|
6
|
-
interface ConversionRequestContext {
|
|
45
|
+
export interface ConversionRequestContext {
|
|
7
46
|
request: Request;
|
|
8
47
|
locals?: unknown;
|
|
9
48
|
}
|
|
@@ -12,10 +51,10 @@ interface ConversionRequestContext {
|
|
|
12
51
|
* hit for that `id`, or `null` if this is the first. Pure interface — a store
|
|
13
52
|
* decides nothing, it only remembers.
|
|
14
53
|
*/
|
|
15
|
-
interface RateLimitStore {
|
|
54
|
+
export interface RateLimitStore {
|
|
16
55
|
hit(id: string, at: number): Promise<number | null> | number | null;
|
|
17
56
|
}
|
|
18
|
-
interface ConversionRouteOptions {
|
|
57
|
+
export interface ConversionRouteOptions {
|
|
19
58
|
/** Where notifications go. Build it from your site config. */
|
|
20
59
|
context: NotifyContext;
|
|
21
60
|
/**
|
|
@@ -27,13 +66,31 @@ interface ConversionRouteOptions {
|
|
|
27
66
|
* in v6, and this package peers on `>= 7`.
|
|
28
67
|
*/
|
|
29
68
|
binding?: (locals: unknown) => EmailBinding | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Push the submission into a CRM (HubSpot, Zoho Bigin or Jobber) before notifying.
|
|
71
|
+
* Credentials arrive as a function of `locals`, exactly like `binding` above
|
|
72
|
+
* — the consumer reads them from `import { env } from 'cloudflare:workers'`.
|
|
73
|
+
* Credentials are never config; that rule is absolute in this toolchain.
|
|
74
|
+
*
|
|
75
|
+
* A failed CRM write never fails the request and never blocks the honeypot/
|
|
76
|
+
* Turnstile gates above it — those run first, so a bot never reaches the
|
|
77
|
+
* CRM. The outcome is folded into the notification email's "Delivery"
|
|
78
|
+
* section (via `describeCrmResult`) so a failed write is never silent.
|
|
79
|
+
*/
|
|
80
|
+
crm?: {
|
|
81
|
+
config: CrmConfig;
|
|
82
|
+
credentials?: (locals: unknown) => CrmCredentials | undefined;
|
|
83
|
+
fetch?: typeof fetch;
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
};
|
|
30
86
|
/**
|
|
31
87
|
* Reject a submission before anything is sent. Return a message to refuse.
|
|
32
88
|
* This is where a consumer puts required-field checks.
|
|
33
89
|
*/
|
|
34
90
|
validate?: (body: Record<string, unknown>) => string | null;
|
|
35
|
-
/** Called with every channel outcome, delivered or not
|
|
36
|
-
|
|
91
|
+
/** Called with every channel outcome, delivered or not, plus the CRM
|
|
92
|
+
* outcome when `crm` is configured. Defaults to console. */
|
|
93
|
+
onResult?: (result: ConversionResult) => void;
|
|
37
94
|
/** Injected in tests. */
|
|
38
95
|
now?: () => number;
|
|
39
96
|
/**
|
|
@@ -109,6 +166,12 @@ interface ConversionRouteOptions {
|
|
|
109
166
|
required?: boolean;
|
|
110
167
|
};
|
|
111
168
|
}
|
|
169
|
+
/** `notify()`'s own result, plus the CRM outcome when `crm` is configured.
|
|
170
|
+
* Additive over `Awaited<ReturnType<typeof notify>>` — a consumer's existing
|
|
171
|
+
* `onResult` reading `.results`/`.delivered` keeps compiling unchanged. */
|
|
172
|
+
export interface ConversionResult extends Awaited<ReturnType<typeof notify>> {
|
|
173
|
+
crm?: CrmResult | undefined;
|
|
174
|
+
}
|
|
112
175
|
/**
|
|
113
176
|
* Build a POST handler for conversions.
|
|
114
177
|
*
|
|
@@ -122,10 +185,6 @@ interface ConversionRouteOptions {
|
|
|
122
185
|
* submitted; refusing them because our mail did not go out turns a lost
|
|
123
186
|
* notification into a lost conversion. Failures are reported through `onResult`.
|
|
124
187
|
*/
|
|
125
|
-
declare function createConversionRoute(options: ConversionRouteOptions): ({
|
|
126
|
-
request,
|
|
127
|
-
locals
|
|
128
|
-
}: ConversionRequestContext) => Promise<Response>;
|
|
188
|
+
export declare function createConversionRoute(options: ConversionRouteOptions): ({ request, locals }: ConversionRequestContext) => Promise<Response>;
|
|
129
189
|
//#endregion
|
|
130
|
-
export { ConversionRequestContext, ConversionRouteOptions, RateLimitStore, createConversionRoute };
|
|
131
190
|
//# sourceMappingURL=conversion.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/config-adapters.ts","../../src/routes/conversion.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;wBAqCgB,wBAAwB,QAAQ,QAAQ;;;;;;;;;;wBAmBxC,gBAAgB,QAAQ,QAAQ,kBAAkB;;;;;;;wBAsBlD,YAAY,QAAQ,SAAS;;;;;iBCS5B;EACf,SAAS;EACT;;;;;;;iBAQe;EACf,IAAI,YAAY,aAAa;;iBAkBd;;EAEf,SAAS;;;;;;;;;EAST,WAAW,oBAAoB;;;;;;;;;;;;EAY/B;IACE,QAAQ;IACR,eAAe,oBAAoB;IACnC,eAAe;IACf;;;;;;EAMF,YAAY,MAAM;;;EAGlB,YAAY,QAAQ;;EAEpB;;;;;;;EAOA;;;;;;EAMA;;;;;;;;;;EAUA;;;;;;;EAOA;;;;;;;;;;;EAWA;;;;;;;EAOA;IAAc;IAAmB,QAAQ;;;;;;;;EAOzC;IACE;;;IAGA;;;;;;;;;IASA;;;;;;iBAOa,yBAAyB,QAAQ,kBAAkB;EAClE,MAAM;;;;;;;;;;;;;;;wBA2DQ,sBAAsB,SAAS,4BAwB/B,SAAA,UAAqB,6BAA2B,QAAQ"}
|
|
@@ -1,15 +1,73 @@
|
|
|
1
|
+
import { t } from "@getvitops/utils";
|
|
2
|
+
import { deliverToCrm, describeCrmResult } from "@getvitops/utils/crm";
|
|
1
3
|
import { notify } from "@getvitops/utils/notify";
|
|
2
4
|
import { verifyTurnstileToken } from "@getvitops/utils/security";
|
|
3
|
-
import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
5
|
+
import { parseTrackingCookie, readCookie } from "@getvitops/utils/tracking";
|
|
4
6
|
|
|
7
|
+
//#region src/routes/config-adapters.ts
|
|
8
|
+
/**
|
|
9
|
+
* Resolves the one display name for a site's organization — `organization.name`
|
|
10
|
+
* (localizable; `locale` picks which one, defaulting to `site.defaultLocale`,
|
|
11
|
+
* which is also the fallback if the pick has no entry), falling back to
|
|
12
|
+
* `legalName` when `name` isn't set at all, and to `undefined` (never `''`)
|
|
13
|
+
* when neither resolves to anything — `t()` returns `''` for a genuinely
|
|
14
|
+
* missing translation, and an empty name is worse than none.
|
|
15
|
+
*
|
|
16
|
+
* This is the one place that fallback chain is written. An EmDash site's own
|
|
17
|
+
* `getSiteSettings().title` is a separate, runtime-only fact (EmDash's
|
|
18
|
+
* settings aren't readable at Astro build/config time at all — see
|
|
19
|
+
* AGENTS.md's "EmDash plugin as the design-system editing surface") and stays
|
|
20
|
+
* authoritative for what it drives (the rendered page chrome); this helper is
|
|
21
|
+
* for the build-time-only consumers that can't reach EmDash at all — favicon
|
|
22
|
+
* generation, generated legal documents, notification emails — so a scaffold
|
|
23
|
+
* needing a name in `astro.config.mjs` has one real source instead of a third
|
|
24
|
+
* hardcoded string.
|
|
25
|
+
*/
|
|
26
|
+
function resolveOrganizationName(config, locale) {
|
|
27
|
+
const org = config.organization;
|
|
28
|
+
const resolvedLocale = locale ?? config.site.defaultLocale;
|
|
29
|
+
return (org?.name != null ? t(org.name, resolvedLocale, config.site.defaultLocale) || void 0 : void 0) ?? org?.legalName;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Builds the `context` option `createConversionRoute()` expects, straight from a
|
|
33
|
+
* resolved `Config` — instead of every consumer hand-writing a `NotifyContext`
|
|
34
|
+
* literal.
|
|
35
|
+
*
|
|
36
|
+
* `locationEmails` is primary-first, then every other location with an `email`,
|
|
37
|
+
* in `organization.locations`' own key order — `planNotifications` picks the
|
|
38
|
+
* first that's set, so order here is the whole precedence.
|
|
39
|
+
*/
|
|
40
|
+
function toNotifyContext(config, locale) {
|
|
41
|
+
const org = config.organization;
|
|
42
|
+
const primaryKey = org?.primaryLocation;
|
|
43
|
+
const locationEmails = Object.entries(org?.locations ?? {}).sort(([a], [b]) => a === primaryKey ? -1 : b === primaryKey ? 1 : 0).map(([, loc]) => loc.email).filter((email) => !!email);
|
|
44
|
+
return {
|
|
45
|
+
notifications: config.site.notifications,
|
|
46
|
+
canonical: config.site.domains?.canonical,
|
|
47
|
+
organizationName: resolveOrganizationName(config, locale),
|
|
48
|
+
locationEmails
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `site.crm` is already field-for-field identical to `CrmConfig` (see that
|
|
53
|
+
* interface's own docblock — it's deliberately kept in lockstep) — this is a
|
|
54
|
+
* type-narrowing pass-through, not a remap. Named rather than inlined at each
|
|
55
|
+
* call site so a future divergence between the two shapes has one place to fix.
|
|
56
|
+
*/
|
|
57
|
+
function toCrmConfig(config) {
|
|
58
|
+
return config.site.crm;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
//#endregion
|
|
5
62
|
//#region src/routes/conversion.ts
|
|
6
63
|
/**
|
|
7
64
|
* The conversion endpoint, as a factory.
|
|
8
65
|
*
|
|
9
|
-
* A site's contact form and its `tel:`
|
|
10
|
-
* got in touch — and
|
|
11
|
-
* off the request, build the event, notify whoever the
|
|
12
|
-
* same three lines in every project, so it ships
|
|
66
|
+
* A site's contact form and its `tel:`/`mailto:` beacons all mean the same
|
|
67
|
+
* thing — someone got in touch — and all want the same treatment: read the
|
|
68
|
+
* attribution cookie off the request, build the event, notify whoever the
|
|
69
|
+
* config says. That is the same three lines in every project, so it ships
|
|
70
|
+
* here.
|
|
13
71
|
*
|
|
14
72
|
* What deliberately does **not** ship here is field-level validation and business
|
|
15
73
|
* rules — a consumer's form has its own required fields and its own response
|
|
@@ -20,7 +78,7 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
20
78
|
* unauthenticated endpoint that sends email, so leaving it wide open makes it a
|
|
21
79
|
* relay and a cost amplifier regardless of whose form is behind it.
|
|
22
80
|
*
|
|
23
|
-
* The path matters: the capture script beacons `tel:` conversions at
|
|
81
|
+
* The path matters: the capture script beacons `tel:`/`mailto:` conversions at
|
|
24
82
|
* `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),
|
|
25
83
|
* and the integration warns at build if no route answers it.
|
|
26
84
|
*
|
|
@@ -62,8 +120,22 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
|
|
|
62
120
|
* request and reports a misleading "Body has already been used" instead of the
|
|
63
121
|
* actual error. This package peers on `astro >= 7`, so that form could not work
|
|
64
122
|
* for any supported consumer; `import { env } from 'cloudflare:workers'` is the
|
|
65
|
-
* current one. It also called a `toNotifyContext(config)` helper that
|
|
66
|
-
* exist — `context`
|
|
123
|
+
* current one. It also called a `toNotifyContext(config)` helper that used to
|
|
124
|
+
* not exist — `context` can now be built with `toNotifyContext(config)` (also
|
|
125
|
+
* exported from this module), rather than every consumer hand-writing a
|
|
126
|
+
* `NotifyContext` literal:
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* import config from '../../site.json' with { type: 'json' };
|
|
130
|
+
* import { createConversionRoute, toNotifyContext, toCrmConfig } from '@getvitops/astro/routes';
|
|
131
|
+
*
|
|
132
|
+
* export const POST = createConversionRoute({
|
|
133
|
+
* context: toNotifyContext(config),
|
|
134
|
+
* crm: toCrmConfig(config) && { config: toCrmConfig(config)!, credentials: () => ({ ... }) },
|
|
135
|
+
* binding: () => env.EMAIL,
|
|
136
|
+
* redirectTo: '/thanks',
|
|
137
|
+
* });
|
|
138
|
+
* ```
|
|
67
139
|
*/
|
|
68
140
|
/**
|
|
69
141
|
* Per-isolate, in-memory default. Good enough for a single Worker instance
|
|
@@ -82,7 +154,7 @@ var MemoryRateLimitStore = class {
|
|
|
82
154
|
/** Fields that are never part of a submission's meaning. `cf-turnstile-response`
|
|
83
155
|
* is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field
|
|
84
156
|
* carries a verification token, not something the visitor wrote. */
|
|
85
|
-
const CONTROL_FIELDS = new Set([
|
|
157
|
+
const CONTROL_FIELDS = /* @__PURE__ */ new Set([
|
|
86
158
|
"event",
|
|
87
159
|
"phone",
|
|
88
160
|
"cf-turnstile-response"
|
|
@@ -172,14 +244,18 @@ function createConversionRoute(options) {
|
|
|
172
244
|
return respondError("Malformed request.", 400);
|
|
173
245
|
}
|
|
174
246
|
const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0;
|
|
247
|
+
const isCall = body["event"] === "call";
|
|
248
|
+
const isEmailClick = body["event"] === "email";
|
|
175
249
|
if (rateLimit && ip) {
|
|
176
|
-
const
|
|
250
|
+
const rlKey = `${ip}:${isCall ? "call" : isEmailClick ? "email" : "form"}`;
|
|
251
|
+
const sinceLastHit = await rateLimit.store.hit(rlKey, now());
|
|
177
252
|
if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) return respondError("Too many requests.", 429);
|
|
178
253
|
}
|
|
179
|
-
const isCall = body["event"] === "call";
|
|
180
254
|
if (isCall && typeof body["phone"] !== "string") return respondError("Missing phone.", 400);
|
|
181
|
-
|
|
182
|
-
|
|
255
|
+
if (isEmailClick && typeof body["email"] !== "string") return respondError("Missing email.", 400);
|
|
256
|
+
const isBeacon = isCall || isEmailClick;
|
|
257
|
+
const spam = !isBeacon && hasHoneypot(body, honeypotFields);
|
|
258
|
+
if (!isBeacon && !spam && options.turnstile) {
|
|
183
259
|
const { secretKey, fieldName = "cf-turnstile-response", required = false } = options.turnstile;
|
|
184
260
|
const token = body[fieldName];
|
|
185
261
|
if (typeof token === "string" && token) {
|
|
@@ -188,7 +264,7 @@ function createConversionRoute(options) {
|
|
|
188
264
|
}
|
|
189
265
|
const refusal = !spam && validate?.(body);
|
|
190
266
|
if (refusal) return respondError(refusal, 400);
|
|
191
|
-
const respondSuccess = () =>
|
|
267
|
+
const respondSuccess = () => isBeacon ? new Response(null, { status: 204 }) : isFormPost && successRedirect ? redirectTo(successRedirect) : Response.json({ success: true });
|
|
192
268
|
if (spam) {
|
|
193
269
|
onResult?.({
|
|
194
270
|
results: [{
|
|
@@ -202,20 +278,38 @@ function createConversionRoute(options) {
|
|
|
202
278
|
}
|
|
203
279
|
const turnstileFieldName = options.turnstile?.fieldName;
|
|
204
280
|
const excludedFields = turnstileFieldName ? [...honeypotFields, turnstileFieldName] : honeypotFields;
|
|
205
|
-
const
|
|
206
|
-
type: isCall ? "call" : "form",
|
|
207
|
-
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body, excludedFields) },
|
|
281
|
+
const event = {
|
|
282
|
+
type: isCall ? "call" : isEmailClick ? "email" : "form",
|
|
283
|
+
...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : isEmailClick ? { email: String(body["email"]).replace(/^mailto:/, "") } : { formData: toFormData(body, excludedFields) },
|
|
208
284
|
tracking: parseTrackingCookie(request.headers.get("cookie")),
|
|
209
285
|
userAgent: request.headers.get("user-agent") ?? void 0,
|
|
210
286
|
ip,
|
|
211
287
|
at: now()
|
|
212
|
-
}
|
|
288
|
+
};
|
|
289
|
+
let crmResult = null;
|
|
290
|
+
if (!isBeacon && options.crm) crmResult = await deliverToCrm(event, options.crm.config, {
|
|
291
|
+
credentials: options.crm.credentials?.(locals),
|
|
292
|
+
fetch: options.crm.fetch,
|
|
293
|
+
timeoutMs: options.crm.timeoutMs,
|
|
294
|
+
pageContext: {
|
|
295
|
+
hutk: readCookie(request.headers.get("cookie"), "hubspotutk") ?? void 0,
|
|
296
|
+
pageUri: request.headers.get("referer") ?? void 0
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
const crmNote = describeCrmResult(crmResult);
|
|
300
|
+
const result = {
|
|
301
|
+
...await notify(event, context, { email: binding?.(locals) }, crmNote ? { notes: [crmNote] } : {}),
|
|
302
|
+
...crmResult ? { crm: crmResult } : {}
|
|
303
|
+
};
|
|
213
304
|
if (onResult) onResult(result);
|
|
214
|
-
else
|
|
305
|
+
else {
|
|
306
|
+
for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
|
|
307
|
+
if (crmResult && !crmResult.ok) console.warn(`[vitops] CRM write not delivered (${crmResult.provider}): ${crmResult.reason}`);
|
|
308
|
+
}
|
|
215
309
|
return respondSuccess();
|
|
216
310
|
};
|
|
217
311
|
}
|
|
218
312
|
|
|
219
313
|
//#endregion
|
|
220
|
-
export { createConversionRoute };
|
|
314
|
+
export { createConversionRoute, resolveOrganizationName, toCrmConfig, toNotifyContext };
|
|
221
315
|
//# sourceMappingURL=conversion.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/conversion.ts"],"sourcesContent":["/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:` beacon both mean the same thing — someone\n * got in touch — and both want the same treatment: read the attribution cookie\n * off the request, build the event, notify whoever the config says. That is the\n * same three lines in every project, so it ships here.\n *\n * What deliberately does **not** ship here is field-level validation and business\n * rules — a consumer's form has its own required fields and its own response\n * shape, and baking a guess at those in would make the factory something to\n * fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; that part is the caller's. What **does** ship here is generic abuse\n * resistance (honeypot, origin, rate limit, body size): this is a public,\n * unauthenticated endpoint that sends email, so leaving it wide open makes it a\n * relay and a cost amplifier regardless of whose form is behind it.\n *\n * The path matters: the capture script beacons `tel:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * // A no-JS <form method=\"post\"> submission never sees a JSON response body —\n * // it's a full navigation, so send it somewhere real.\n * redirectTo: '/thanks',\n * });\n * ```\n *\n * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —\n * pass `turnstile: { secretKey }` and a submission carrying a\n * `cf-turnstile-response` token gets it verified server-side via\n * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the\n * consumer's own markup (`<script src=\"https://challenges.cloudflare.com/\n * turnstile/v0/api.js\" async defer></script>` + `<div class=\"cf-turnstile\"\n * data-sitekey=\"…\">` on their form) — there is no `FormRenderer` for this to hook\n * into generically, matching how the rest of the form is already hand-written.\n * `site.security.turnstile.siteKey` is what the generated privacy policy reads to\n * name Turnstile as a processor; wiring this option up is what makes that true\n * rather than an assertion nothing checks.\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that does not\n * exist — `context` is a plain `NotifyContext`, built however you like.\n */\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { verifyTurnstileToken } from '@getvitops/utils/security';\nimport { parseTrackingCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\n/**\n * Records a hit for `id` at time `at` (ms) and returns the ms since the previous\n * hit for that `id`, or `null` if this is the first. Pure interface — a store\n * decides nothing, it only remembers.\n */\nexport interface RateLimitStore {\n hit(id: string, at: number): Promise<number | null> | number | null;\n}\n\n/**\n * Per-isolate, in-memory default. Good enough for a single Worker instance\n * fielding occasional contact-form spam; not durable across a fleet, and not\n * shared with other routes. Inject a KV- or Durable-Object-backed `store` for\n * real cross-instance limiting — the interface is one method wide on purpose.\n */\nclass MemoryRateLimitStore implements RateLimitStore {\n private lastHit = new Map<string, number>();\n hit(id: string, at: number): number | null {\n const prev = this.lastHit.get(id);\n this.lastHit.set(id, at);\n return prev === undefined ? null : at - prev;\n }\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not. Defaults to console. */\n onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;\n /** Injected in tests. */\n now?: () => number;\n /**\n * Where to send a no-JS `<form method=\"post\">` submission on success. Without\n * this, that visitor's browser navigates to the raw `{\"success\":true}` JSON\n * body as its next page — functional, but not a page anyone should see. Has\n * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.\n */\n redirectTo?: string;\n /**\n * Where to send a refused no-JS submission (validation, spam checks, malformed\n * body). Falls back to `redirectTo` if omitted, and to the JSON error response\n * if neither is set.\n */\n errorRedirectTo?: string;\n /**\n * Field names treated as honeypots: a bot fills every input it finds, a person\n * never sees these (hidden by CSS in the form markup). A non-empty value is\n * silently treated as spam — the response looks identical to a real success,\n * denying the bot the signal it needs to know the trick worked, but nothing is\n * notified and the field never reaches `notify()`. Defaults to the same names\n * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,\n * `honeypot`), so the two lists don't need to be kept in sync by hand.\n */\n honeypotFields?: string[];\n /**\n * Reject a submission whose `Content-Length` exceeds this many bytes. Default\n * 25_000 — generous for a contact form with no file upload, tiny for anything\n * meant to relay large payloads. Best-effort: `Content-Length` can be absent\n * or spoofed, same caveat as the origin check below.\n */\n maxBodyBytes?: number;\n /**\n * Reject a submission whose `Origin` header doesn't match this origin.\n * Defaults to `context.canonical`'s origin. Pass `false` to disable.\n *\n * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of\n * legitimate same-origin requests, so its *absence* is never treated as a\n * mismatch — only a present-and-different value is rejected. That still stops\n * the common case (a script hitting the endpoint directly from elsewhere)\n * without breaking real submissions from older or privacy-hardened browsers.\n */\n allowedOrigin?: string | false;\n /**\n * Minimum interval between accepted submissions from the same IP. Pass\n * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a\n * script firing in a loop without troubling someone who fat-fingers a form\n * and resubmits.\n */\n rateLimit?: { windowMs?: number; store?: RateLimitStore } | false;\n /**\n * Verify a Cloudflare Turnstile token server-side. The **secret** key —\n * unlike `site.security.turnstile.siteKey`, which is public config — must come\n * from an env var, never a config file (the same rule as every other\n * credential in this toolchain).\n */\n turnstile?: {\n secretKey: string;\n /** The submitted field carrying the token. Default `cf-turnstile-response`\n * — the name Cloudflare's widget submits under. */\n fieldName?: string;\n /**\n * Reject a submission with no token at all, rather than only a present-and-\n * invalid one. Default `false`: an absent token is the ordinary shape of a\n * visitor with JS disabled, and this factory is built around that path\n * staying usable. Set `true` only for a form where verified-human matters\n * enough to accept that trade-off — doing so means that form no longer\n * works without JS, on purpose.\n */\n required?: boolean;\n };\n}\n\n/** Fields that are never part of a submission's meaning. `cf-turnstile-response`\n * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field\n * carries a verification token, not something the visitor wrote. */\nconst CONTROL_FIELDS = new Set(['event', 'phone', 'cf-turnstile-response']);\nconst DEFAULT_HONEYPOT_FIELDS = ['website', '_honeypot', 'honeypot'];\nconst DEFAULT_MAX_BODY_BYTES = 25_000;\nconst DEFAULT_RATE_LIMIT_WINDOW_MS = 10_000;\n\nfunction toFormData(\n body: Record<string, unknown>,\n honeypotFields: string[],\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\nfunction hasHoneypot(body: Record<string, unknown>, honeypotFields: string[]): boolean {\n return honeypotFields.some((field) => {\n const value = body[field];\n return typeof value === 'string' && value.trim() !== '';\n });\n}\n\nfunction originOf(url: string | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).origin;\n } catch {\n return null;\n }\n}\n\n/** A `Location` header response — used for the no-JS form path instead of\n * `Response.redirect()`, which requires a base URL this factory doesn't have. */\nfunction redirectTo(location: string, status = 303): Response {\n return new Response(null, { status, headers: { Location: location } });\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)\n * form submission, matching what `navigator.sendBeacon` and a fetch-submitting\n * form each expect. A no-JS `<form method=\"post\">` — `application/\n * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,\n * on success, redirected to `redirectTo` if one is configured.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const {\n context,\n binding,\n validate,\n onResult,\n now = Date.now,\n redirectTo: successRedirect,\n errorRedirectTo = successRedirect,\n honeypotFields = DEFAULT_HONEYPOT_FIELDS,\n maxBodyBytes = DEFAULT_MAX_BODY_BYTES,\n } = options;\n\n const allowedOrigin =\n options.allowedOrigin === false ? null : (options.allowedOrigin ?? originOf(context.canonical));\n\n const rateLimit =\n options.rateLimit === false\n ? null\n : {\n windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,\n store: options.rateLimit?.store ?? new MemoryRateLimitStore(),\n };\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n const contentType = request.headers.get('content-type') ?? '';\n const isFormPost =\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded');\n\n const respondError = (error: string, status: number): Response =>\n isFormPost && errorRedirectTo\n ? redirectTo(errorRedirectTo)\n : Response.json({ success: false, error }, { status });\n\n const contentLength = Number(request.headers.get('content-length') ?? 0);\n if (contentLength > maxBodyBytes) {\n return respondError('Request too large.', 413);\n }\n\n if (allowedOrigin) {\n const requestOrigin = request.headers.get('origin');\n if (requestOrigin && requestOrigin !== allowedOrigin) {\n return respondError('Origin not allowed.', 403);\n }\n }\n\n let body: Record<string, unknown>;\n try {\n if (isFormPost) {\n const formData = await request.formData();\n body = {};\n for (const [key, value] of formData.entries()) {\n if (typeof value === 'string') body[key] = value;\n }\n } else {\n // Not just `application/json`: `navigator.sendBeacon` sends a bare\n // string as `text/plain`, and this must keep accepting that body.\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return respondError('Malformed request.', 400);\n }\n body = parsed as Record<string, unknown>;\n }\n } catch {\n return respondError('Malformed request.', 400);\n }\n\n const ip =\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined;\n\n if (rateLimit && ip) {\n const sinceLastHit = await rateLimit.store.hit(ip, now());\n if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) {\n return respondError('Too many requests.', 429);\n }\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return respondError('Missing phone.', 400);\n }\n\n const spam = !isCall && hasHoneypot(body, honeypotFields);\n\n if (!isCall && !spam && options.turnstile) {\n const {\n secretKey,\n fieldName = 'cf-turnstile-response',\n required = false,\n } = options.turnstile;\n const token = body[fieldName];\n if (typeof token === 'string' && token) {\n const verification = await verifyTurnstileToken(token, secretKey, ip);\n if (!verification.success) return respondError('Verification failed.', 400);\n } else if (required) {\n return respondError('Verification required.', 400);\n }\n }\n\n const refusal = !spam && validate?.(body);\n if (refusal) return respondError(refusal, 400);\n\n const respondSuccess = (): Response =>\n isCall\n ? new Response(null, { status: 204 })\n : isFormPost && successRedirect\n ? redirectTo(successRedirect)\n : Response.json({ success: true });\n\n // A honeypot trip never reaches `notify()` — the response is identical to a\n // real success, so a bot gets no signal that it was caught.\n if (spam) {\n onResult?.({\n results: [{ channel: 'email', sent: false, reason: 'Skipped: honeypot triggered.' }],\n delivered: false,\n });\n return respondSuccess();\n }\n\n const turnstileFieldName = options.turnstile?.fieldName;\n const excludedFields = turnstileFieldName\n ? [...honeypotFields, turnstileFieldName]\n : honeypotFields;\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body, excludedFields) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n ip,\n at: now(),\n };\n\n const result = await notify(event, context, { email: binding?.(locals) });\n\n if (onResult) onResult(result);\n else\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n\n return respondSuccess();\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"conversion.mjs","names":[],"sources":["../../src/routes/config-adapters.ts","../../src/routes/conversion.ts"],"sourcesContent":["/**\n * Adapts a resolved `Config` (`@getvitops/generator`) into the structural shapes\n * `createConversionRoute()` expects — `context: NotifyContext` and `crm.config:\n * CrmConfig`. Neither of those types can import `Config` themselves (they live in\n * `@getvitops/utils`, which the generator already depends on — importing back\n * would be a cycle), which is why `conversion.ts`'s own docblock used to describe\n * `toNotifyContext(config)` as a helper that \"does not exist.\" This package\n * already depends on both the generator and utils, so it's the one place the\n * adapter can live.\n *\n * `import type` only — erased at build, so pulling in `Config`'s type costs\n * nothing at runtime and doesn't drag the generator's `node:fs`/lightningcss\n * entry point into a Worker bundle.\n */\nimport type { Config } from '@getvitops/generator';\nimport { t } from '@getvitops/utils';\nimport type { CrmConfig } from '@getvitops/utils/crm';\nimport type { NotifyContext } from '@getvitops/utils/notify';\n\n/**\n * Resolves the one display name for a site's organization — `organization.name`\n * (localizable; `locale` picks which one, defaulting to `site.defaultLocale`,\n * which is also the fallback if the pick has no entry), falling back to\n * `legalName` when `name` isn't set at all, and to `undefined` (never `''`)\n * when neither resolves to anything — `t()` returns `''` for a genuinely\n * missing translation, and an empty name is worse than none.\n *\n * This is the one place that fallback chain is written. An EmDash site's own\n * `getSiteSettings().title` is a separate, runtime-only fact (EmDash's\n * settings aren't readable at Astro build/config time at all — see\n * AGENTS.md's \"EmDash plugin as the design-system editing surface\") and stays\n * authoritative for what it drives (the rendered page chrome); this helper is\n * for the build-time-only consumers that can't reach EmDash at all — favicon\n * generation, generated legal documents, notification emails — so a scaffold\n * needing a name in `astro.config.mjs` has one real source instead of a third\n * hardcoded string.\n */\nexport function resolveOrganizationName(config: Config, locale?: string): string | undefined {\n const org = config.organization;\n const resolvedLocale = locale ?? config.site.defaultLocale;\n const name =\n org?.name != null\n ? t(org.name, resolvedLocale, config.site.defaultLocale) || undefined\n : undefined;\n return name ?? org?.legalName;\n}\n\n/**\n * Builds the `context` option `createConversionRoute()` expects, straight from a\n * resolved `Config` — instead of every consumer hand-writing a `NotifyContext`\n * literal.\n *\n * `locationEmails` is primary-first, then every other location with an `email`,\n * in `organization.locations`' own key order — `planNotifications` picks the\n * first that's set, so order here is the whole precedence.\n */\nexport function toNotifyContext(config: Config, locale?: string): NotifyContext {\n const org = config.organization;\n const primaryKey = org?.primaryLocation;\n const locationEmails = Object.entries(org?.locations ?? {})\n .sort(([a], [b]) => (a === primaryKey ? -1 : b === primaryKey ? 1 : 0))\n .map(([, loc]) => loc.email)\n .filter((email): email is string => !!email);\n\n return {\n notifications: config.site.notifications,\n canonical: config.site.domains?.canonical,\n organizationName: resolveOrganizationName(config, locale),\n locationEmails,\n };\n}\n\n/**\n * `site.crm` is already field-for-field identical to `CrmConfig` (see that\n * interface's own docblock — it's deliberately kept in lockstep) — this is a\n * type-narrowing pass-through, not a remap. Named rather than inlined at each\n * call site so a future divergence between the two shapes has one place to fix.\n */\nexport function toCrmConfig(config: Config): CrmConfig | undefined {\n return config.site.crm;\n}\n","/**\n * The conversion endpoint, as a factory.\n *\n * A site's contact form and its `tel:`/`mailto:` beacons all mean the same\n * thing — someone got in touch — and all want the same treatment: read the\n * attribution cookie off the request, build the event, notify whoever the\n * config says. That is the same three lines in every project, so it ships\n * here.\n *\n * What deliberately does **not** ship here is field-level validation and business\n * rules — a consumer's form has its own required fields and its own response\n * shape, and baking a guess at those in would make the factory something to\n * fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; that part is the caller's. What **does** ship here is generic abuse\n * resistance (honeypot, origin, rate limit, body size): this is a public,\n * unauthenticated endpoint that sends email, so leaving it wide open makes it a\n * relay and a cost amplifier regardless of whose form is behind it.\n *\n * The path matters: the capture script beacons `tel:`/`mailto:` conversions at\n * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),\n * and the integration warns at build if no route answers it.\n *\n * ```ts\n * // src/pages/api/track.ts\n * export const prerender = false;\n * import { env } from 'cloudflare:workers';\n * import { createConversionRoute } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: {\n * // A bare address is shorthand for `{ provider: 'cloudflare', to }`.\n * notifications: { email: 'sales@acme.ca' },\n * canonical: 'https://acme.ca',\n * organizationName: 'Acme',\n * },\n * binding: () => env.EMAIL,\n * // A no-JS <form method=\"post\"> submission never sees a JSON response body —\n * // it's a full navigation, so send it somewhere real.\n * redirectTo: '/thanks',\n * });\n * ```\n *\n * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —\n * pass `turnstile: { secretKey }` and a submission carrying a\n * `cf-turnstile-response` token gets it verified server-side via\n * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the\n * consumer's own markup (`<script src=\"https://challenges.cloudflare.com/\n * turnstile/v0/api.js\" async defer></script>` + `<div class=\"cf-turnstile\"\n * data-sitekey=\"…\">` on their form) — there is no `FormRenderer` for this to hook\n * into generically, matching how the rest of the form is already hand-written.\n * `site.security.turnstile.siteKey` is what the generated privacy policy reads to\n * name Turnstile as a processor; wiring this option up is what makes that true\n * rather than an assertion nothing checks.\n *\n * Two things this example used to get wrong, both of which cost a consumer real\n * time. It read the binding off `locals.runtime.env`, which Astro removed in v6\n * — and because the resulting throw precedes the response, Astro re-reads the\n * request and reports a misleading \"Body has already been used\" instead of the\n * actual error. This package peers on `astro >= 7`, so that form could not work\n * for any supported consumer; `import { env } from 'cloudflare:workers'` is the\n * current one. It also called a `toNotifyContext(config)` helper that used to\n * not exist — `context` can now be built with `toNotifyContext(config)` (also\n * exported from this module), rather than every consumer hand-writing a\n * `NotifyContext` literal:\n *\n * ```ts\n * import config from '../../site.json' with { type: 'json' };\n * import { createConversionRoute, toNotifyContext, toCrmConfig } from '@getvitops/astro/routes';\n *\n * export const POST = createConversionRoute({\n * context: toNotifyContext(config),\n * crm: toCrmConfig(config) && { config: toCrmConfig(config)!, credentials: () => ({ ... }) },\n * binding: () => env.EMAIL,\n * redirectTo: '/thanks',\n * });\n * ```\n */\nimport { deliverToCrm, describeCrmResult } from '@getvitops/utils/crm';\nimport type { CrmConfig, CrmCredentials, CrmResult } from '@getvitops/utils/crm';\nimport { type EmailBinding, notify, type NotifyContext } from '@getvitops/utils/notify';\nimport { verifyTurnstileToken } from '@getvitops/utils/security';\nimport { parseTrackingCookie, readCookie } from '@getvitops/utils/tracking';\nimport type { ConversionEvent } from '@getvitops/utils/tracking';\nexport { resolveOrganizationName, toCrmConfig, toNotifyContext } from './config-adapters.ts';\n\n/** The subset of Astro's `APIContext` this needs — typed structurally so the\n * factory does not pin an Astro version. */\nexport interface ConversionRequestContext {\n request: Request;\n locals?: unknown;\n}\n\n/**\n * Records a hit for `id` at time `at` (ms) and returns the ms since the previous\n * hit for that `id`, or `null` if this is the first. Pure interface — a store\n * decides nothing, it only remembers.\n */\nexport interface RateLimitStore {\n hit(id: string, at: number): Promise<number | null> | number | null;\n}\n\n/**\n * Per-isolate, in-memory default. Good enough for a single Worker instance\n * fielding occasional contact-form spam; not durable across a fleet, and not\n * shared with other routes. Inject a KV- or Durable-Object-backed `store` for\n * real cross-instance limiting — the interface is one method wide on purpose.\n */\nclass MemoryRateLimitStore implements RateLimitStore {\n private lastHit = new Map<string, number>();\n hit(id: string, at: number): number | null {\n const prev = this.lastHit.get(id);\n this.lastHit.set(id, at);\n return prev === undefined ? null : at - prev;\n }\n}\n\nexport interface ConversionRouteOptions {\n /** Where notifications go. Build it from your site config. */\n context: NotifyContext;\n /**\n * The Workers `send_email` binding for this request, e.g. `() => env.EMAIL`\n * with `env` imported from `cloudflare:workers`. Omit it in dev and the\n * notification is printed instead of sent.\n *\n * Not `(locals) => locals.runtime.env.EMAIL` — Astro removed `locals.runtime`\n * in v6, and this package peers on `>= 7`.\n */\n binding?: (locals: unknown) => EmailBinding | undefined;\n /**\n * Push the submission into a CRM (HubSpot, Zoho Bigin or Jobber) before notifying.\n * Credentials arrive as a function of `locals`, exactly like `binding` above\n * — the consumer reads them from `import { env } from 'cloudflare:workers'`.\n * Credentials are never config; that rule is absolute in this toolchain.\n *\n * A failed CRM write never fails the request and never blocks the honeypot/\n * Turnstile gates above it — those run first, so a bot never reaches the\n * CRM. The outcome is folded into the notification email's \"Delivery\"\n * section (via `describeCrmResult`) so a failed write is never silent.\n */\n crm?: {\n config: CrmConfig;\n credentials?: (locals: unknown) => CrmCredentials | undefined;\n fetch?: typeof fetch;\n timeoutMs?: number;\n };\n /**\n * Reject a submission before anything is sent. Return a message to refuse.\n * This is where a consumer puts required-field checks.\n */\n validate?: (body: Record<string, unknown>) => string | null;\n /** Called with every channel outcome, delivered or not, plus the CRM\n * outcome when `crm` is configured. Defaults to console. */\n onResult?: (result: ConversionResult) => void;\n /** Injected in tests. */\n now?: () => number;\n /**\n * Where to send a no-JS `<form method=\"post\">` submission on success. Without\n * this, that visitor's browser navigates to the raw `{\"success\":true}` JSON\n * body as its next page — functional, but not a page anyone should see. Has\n * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.\n */\n redirectTo?: string;\n /**\n * Where to send a refused no-JS submission (validation, spam checks, malformed\n * body). Falls back to `redirectTo` if omitted, and to the JSON error response\n * if neither is set.\n */\n errorRedirectTo?: string;\n /**\n * Field names treated as honeypots: a bot fills every input it finds, a person\n * never sees these (hidden by CSS in the form markup). A non-empty value is\n * silently treated as spam — the response looks identical to a real success,\n * denying the bot the signal it needs to know the trick worked, but nothing is\n * notified and the field never reaches `notify()`. Defaults to the same names\n * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,\n * `honeypot`), so the two lists don't need to be kept in sync by hand.\n */\n honeypotFields?: string[];\n /**\n * Reject a submission whose `Content-Length` exceeds this many bytes. Default\n * 25_000 — generous for a contact form with no file upload, tiny for anything\n * meant to relay large payloads. Best-effort: `Content-Length` can be absent\n * or spoofed, same caveat as the origin check below.\n */\n maxBodyBytes?: number;\n /**\n * Reject a submission whose `Origin` header doesn't match this origin.\n * Defaults to `context.canonical`'s origin. Pass `false` to disable.\n *\n * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of\n * legitimate same-origin requests, so its *absence* is never treated as a\n * mismatch — only a present-and-different value is rejected. That still stops\n * the common case (a script hitting the endpoint directly from elsewhere)\n * without breaking real submissions from older or privacy-hardened browsers.\n */\n allowedOrigin?: string | false;\n /**\n * Minimum interval between accepted submissions from the same IP. Pass\n * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a\n * script firing in a loop without troubling someone who fat-fingers a form\n * and resubmits.\n */\n rateLimit?: { windowMs?: number; store?: RateLimitStore } | false;\n /**\n * Verify a Cloudflare Turnstile token server-side. The **secret** key —\n * unlike `site.security.turnstile.siteKey`, which is public config — must come\n * from an env var, never a config file (the same rule as every other\n * credential in this toolchain).\n */\n turnstile?: {\n secretKey: string;\n /** The submitted field carrying the token. Default `cf-turnstile-response`\n * — the name Cloudflare's widget submits under. */\n fieldName?: string;\n /**\n * Reject a submission with no token at all, rather than only a present-and-\n * invalid one. Default `false`: an absent token is the ordinary shape of a\n * visitor with JS disabled, and this factory is built around that path\n * staying usable. Set `true` only for a form where verified-human matters\n * enough to accept that trade-off — doing so means that form no longer\n * works without JS, on purpose.\n */\n required?: boolean;\n };\n}\n\n/** `notify()`'s own result, plus the CRM outcome when `crm` is configured.\n * Additive over `Awaited<ReturnType<typeof notify>>` — a consumer's existing\n * `onResult` reading `.results`/`.delivered` keeps compiling unchanged. */\nexport interface ConversionResult extends Awaited<ReturnType<typeof notify>> {\n crm?: CrmResult | undefined;\n}\n\n/** Fields that are never part of a submission's meaning. `cf-turnstile-response`\n * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field\n * carries a verification token, not something the visitor wrote. */\nconst CONTROL_FIELDS = new Set(['event', 'phone', 'cf-turnstile-response']);\nconst DEFAULT_HONEYPOT_FIELDS = ['website', '_honeypot', 'honeypot'];\nconst DEFAULT_MAX_BODY_BYTES = 25_000;\nconst DEFAULT_RATE_LIMIT_WINDOW_MS = 10_000;\n\nfunction toFormData(\n body: Record<string, unknown>,\n honeypotFields: string[],\n): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\nfunction hasHoneypot(body: Record<string, unknown>, honeypotFields: string[]): boolean {\n return honeypotFields.some((field) => {\n const value = body[field];\n return typeof value === 'string' && value.trim() !== '';\n });\n}\n\nfunction originOf(url: string | undefined): string | null {\n if (!url) return null;\n try {\n return new URL(url).origin;\n } catch {\n return null;\n }\n}\n\n/** A `Location` header response — used for the no-JS form path instead of\n * `Response.redirect()`, which requires a base URL this factory doesn't have. */\nfunction redirectTo(location: string, status = 303): Response {\n return new Response(null, { status, headers: { Location: location } });\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)\n * form submission, matching what `navigator.sendBeacon` and a fetch-submitting\n * form each expect. A no-JS `<form method=\"post\">` — `application/\n * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,\n * on success, redirected to `redirectTo` if one is configured.\n *\n * **A failed notification does not fail the request.** The visitor has already\n * submitted; refusing them because our mail did not go out turns a lost\n * notification into a lost conversion. Failures are reported through `onResult`.\n */\nexport function createConversionRoute(options: ConversionRouteOptions) {\n const {\n context,\n binding,\n validate,\n onResult,\n now = Date.now,\n redirectTo: successRedirect,\n errorRedirectTo = successRedirect,\n honeypotFields = DEFAULT_HONEYPOT_FIELDS,\n maxBodyBytes = DEFAULT_MAX_BODY_BYTES,\n } = options;\n\n const allowedOrigin =\n options.allowedOrigin === false ? null : (options.allowedOrigin ?? originOf(context.canonical));\n\n const rateLimit =\n options.rateLimit === false\n ? null\n : {\n windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,\n store: options.rateLimit?.store ?? new MemoryRateLimitStore(),\n };\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n const contentType = request.headers.get('content-type') ?? '';\n const isFormPost =\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded');\n\n const respondError = (error: string, status: number): Response =>\n isFormPost && errorRedirectTo\n ? redirectTo(errorRedirectTo)\n : Response.json({ success: false, error }, { status });\n\n const contentLength = Number(request.headers.get('content-length') ?? 0);\n if (contentLength > maxBodyBytes) {\n return respondError('Request too large.', 413);\n }\n\n if (allowedOrigin) {\n const requestOrigin = request.headers.get('origin');\n if (requestOrigin && requestOrigin !== allowedOrigin) {\n return respondError('Origin not allowed.', 403);\n }\n }\n\n let body: Record<string, unknown>;\n try {\n if (isFormPost) {\n const formData = await request.formData();\n body = {};\n for (const [key, value] of formData.entries()) {\n if (typeof value === 'string') body[key] = value;\n }\n } else {\n // Not just `application/json`: `navigator.sendBeacon` sends a bare\n // string as `text/plain`, and this must keep accepting that body.\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return respondError('Malformed request.', 400);\n }\n body = parsed as Record<string, unknown>;\n }\n } catch {\n return respondError('Malformed request.', 400);\n }\n\n const ip =\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined;\n\n // Classified before rate limiting, not read raw off the client — see the key\n // below. `phone`/`email` presence is still checked after limiting, since that\n // check just rejects a malformed beacon rather than deciding the bucket.\n const isCall = body['event'] === 'call';\n const isEmailClick = body['event'] === 'email';\n\n // Keyed on IP *and* the server's own classification (`call` | `email` |\n // `form`), never the raw `event` string the client sent. A call beacon and an\n // email-click beacon from the same visitor are two distinct, legitimate\n // conversions, not a repeat of one, so they need separate buckets — but\n // trusting the raw string for that also let a client dodge the form rate\n // limit entirely by varying `event` on every request (any value other than\n // exactly `'call'`/`'email'` used to get its own bucket). Collapsing\n // everything else to `'form'` closes that without losing the call/email split.\n if (rateLimit && ip) {\n const rlKey = `${ip}:${isCall ? 'call' : isEmailClick ? 'email' : 'form'}`;\n const sinceLastHit = await rateLimit.store.hit(rlKey, now());\n if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) {\n return respondError('Too many requests.', 429);\n }\n }\n\n if (isCall && typeof body['phone'] !== 'string') {\n return respondError('Missing phone.', 400);\n }\n\n if (isEmailClick && typeof body['email'] !== 'string') {\n return respondError('Missing email.', 400);\n }\n\n // A call and an email-click beacon are the same shape of thing: a fire-and-\n // forget ping with no form fields to validate, honeypot, verify or map into\n // a CRM — both skip every check below that exists for a form submission.\n const isBeacon = isCall || isEmailClick;\n\n const spam = !isBeacon && hasHoneypot(body, honeypotFields);\n\n if (!isBeacon && !spam && options.turnstile) {\n const {\n secretKey,\n fieldName = 'cf-turnstile-response',\n required = false,\n } = options.turnstile;\n const token = body[fieldName];\n if (typeof token === 'string' && token) {\n const verification = await verifyTurnstileToken(token, secretKey, ip);\n if (!verification.success) return respondError('Verification failed.', 400);\n } else if (required) {\n return respondError('Verification required.', 400);\n }\n }\n\n const refusal = !spam && validate?.(body);\n if (refusal) return respondError(refusal, 400);\n\n const respondSuccess = (): Response =>\n isBeacon\n ? new Response(null, { status: 204 })\n : isFormPost && successRedirect\n ? redirectTo(successRedirect)\n : Response.json({ success: true });\n\n // A honeypot trip never reaches `notify()` — the response is identical to a\n // real success, so a bot gets no signal that it was caught.\n if (spam) {\n onResult?.({\n results: [{ channel: 'email', sent: false, reason: 'Skipped: honeypot triggered.' }],\n delivered: false,\n });\n return respondSuccess();\n }\n\n const turnstileFieldName = options.turnstile?.fieldName;\n const excludedFields = turnstileFieldName\n ? [...honeypotFields, turnstileFieldName]\n : honeypotFields;\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : isEmailClick ? 'email' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : isEmailClick\n ? { email: String(body['email']).replace(/^mailto:/, '') }\n : { formData: toFormData(body, excludedFields) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n ip,\n at: now(),\n };\n\n // CRM before notify: the notification email states the CRM outcome, which\n // is only possible once it's known. Never attempted for a `tel:`/`mailto:`\n // beacon — a CRM contact comes from submitted form fields, and neither\n // event has any to map.\n let crmResult: CrmResult | null = null;\n if (!isBeacon && options.crm) {\n crmResult = await deliverToCrm(event, options.crm.config, {\n credentials: options.crm.credentials?.(locals),\n fetch: options.crm.fetch,\n timeoutMs: options.crm.timeoutMs,\n // Provider-neutral: HubSpot reads `hutk` to stitch the submission to\n // its own tracking; Jobber reads `pageUri` for its note body. Neither\n // is HubSpot-specific to build, so this is passed unconditionally\n // rather than gated on `options.crm.config.provider`.\n pageContext: {\n hutk: readCookie(request.headers.get('cookie'), 'hubspotutk') ?? undefined,\n pageUri: request.headers.get('referer') ?? undefined,\n },\n });\n }\n const crmNote = describeCrmResult(crmResult);\n\n const result: ConversionResult = {\n ...(await notify(\n event,\n context,\n { email: binding?.(locals) },\n crmNote ? { notes: [crmNote] } : {},\n )),\n ...(crmResult ? { crm: crmResult } : {}),\n };\n\n if (onResult) onResult(result);\n else {\n for (const r of result.results) {\n if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);\n }\n if (crmResult && !crmResult.ok) {\n console.warn(\n `[vitops] CRM write not delivered (${crmResult.provider}): ${crmResult.reason}`,\n );\n }\n }\n\n return respondSuccess();\n };\n}\n"],"mappings":"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"}
|
|
@@ -25,7 +25,7 @@ interface GetvitopsTrackingOptions {
|
|
|
25
25
|
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
26
26
|
declare const TRACKING_COOKIE = "_ac";
|
|
27
27
|
/**
|
|
28
|
-
* Where the capture script sends its `tel:` conversion
|
|
28
|
+
* Where the capture script sends its `tel:`/`mailto:` conversion beacons.
|
|
29
29
|
*
|
|
30
30
|
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
31
31
|
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
@@ -62,4 +62,4 @@ interface ResolveTrackingContext {
|
|
|
62
62
|
declare function resolveTracking(opts: GetvitopsTrackingOptions | undefined, ctx?: ResolveTrackingContext): ResolvedTracking;
|
|
63
63
|
//#endregion
|
|
64
64
|
export { TRACKING_ENDPOINT as a, TRACKING_COOKIE as i, ResolveTrackingContext as n, TrackingCategory as o, ResolvedTracking as r, resolveTracking as s, GetvitopsTrackingOptions as t };
|
|
65
|
-
//# sourceMappingURL=tracking-
|
|
65
|
+
//# sourceMappingURL=tracking-BNKKBqOe.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking-BNKKBqOe.d.mts","names":[],"sources":["../src/tracking.ts"],"mappings":";;;;;;;;;;;;;;;KAeY;UAEK;;EAEf;;EAEA,WAAW;;EAEX;;;cAIW;;;;;;;;;;;;cAaA;UAEI;EACf;EACA,UAAU;;EAEV;;EAEA;;UAGe;;EAEf;;EAEA;;;;;;;;;;;iBAYc,gBACd,MAAM,sCACN,MAAK,yBACJ"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */
|
|
3
3
|
const TRACKING_COOKIE = "_ac";
|
|
4
4
|
/**
|
|
5
|
-
* Where the capture script sends its `tel:` conversion
|
|
5
|
+
* Where the capture script sends its `tel:`/`mailto:` conversion beacons.
|
|
6
6
|
*
|
|
7
7
|
* The route itself is the consumer's to write (`createConversionRoute()` in
|
|
8
8
|
* `@getvitops/astro/routes`), so this is the one place the two halves agree on
|
|
@@ -44,4 +44,4 @@ function resolveTracking(opts, ctx = {}) {
|
|
|
44
44
|
|
|
45
45
|
//#endregion
|
|
46
46
|
export { TRACKING_ENDPOINT as n, resolveTracking as r, TRACKING_COOKIE as t };
|
|
47
|
-
//# sourceMappingURL=tracking-
|
|
47
|
+
//# sourceMappingURL=tracking-M2PqlMpw.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tracking-M2PqlMpw.mjs","names":[],"sources":["../src/tracking.ts"],"sourcesContent":["/**\n * The `<Tracking />` resolution layer — tracking config in, what to emit out.\n *\n * Separate from `Tracking.astro` for the same reason `analytics.ts` is separate\n * from `Analytics.astro`: everything decidable is a pure function asserted in\n * `tracking.test.ts`, and the component only renders what this returns.\n *\n * The one decision here is **which consent category the `_ac` cookie waits on**,\n * and it is stated rather than inferred. `_ac` is a 90-day identifier tying a\n * visitor to the ad that brought them, so `marketing` is the default — but a site\n * using click IDs purely for its own campaign reporting may reasonably file it\n * under `analytics`, and that is the consumer's call to make, not ours to guess.\n */\n\n/** Which consent category the attribution cookie waits on. */\nexport type TrackingCategory = 'marketing' | 'analytics';\n\nexport interface GetvitopsTrackingOptions {\n /** Capture ad click IDs and UTMs into `_ac`. */\n enabled?: boolean;\n /** Default `marketing`. */\n category?: TrackingCategory;\n /** Informational; capture recognises every known click-ID parameter regardless. */\n platforms?: string[];\n}\n\n/** The cookie the capture script writes. Mirrors `TRACKING_COOKIE` in utils. */\nexport const TRACKING_COOKIE = '_ac';\n\n/**\n * Where the capture script sends its `tel:`/`mailto:` conversion beacons.\n *\n * The route itself is the consumer's to write (`createConversionRoute()` in\n * `@getvitops/astro/routes`), so this is the one place the two halves agree on\n * a path. It was a bare literal inside the inlined script: a consumer who named\n * the file anything else got a silent 404 on every beacon and lost conversions\n * with no error anywhere to explain it. Exported so a consumer can assert\n * against it, mirrored in `scripts/tracking.ts` because that file is inlined\n * into the document and cannot import, and pinned by `tracking.test.ts`.\n */\nexport const TRACKING_ENDPOINT = '/api/track';\n\nexport interface ResolvedTracking {\n enabled: boolean;\n category: TrackingCategory;\n /** Cookie names to clear on revoke — rides on `data-consent-cookies`. */\n cookies: string[];\n /** Configuration problems, as prose. The integration logs these. */\n warnings: string[];\n}\n\nexport interface ResolveTrackingContext {\n /** Is the consent gate enabled on this site (`vitops({ consent })`)? */\n consent?: boolean;\n /** Categories the banner offers, so we can catch a demand with no row. */\n consentCategories?: readonly string[];\n}\n\n/**\n * Resolve the tracking config into what the component emits.\n *\n * Two warnings, both about the same failure — a persistent identifier written\n * with no way for the visitor to decline, or a permission asked for with no way\n * to answer. Returned as data rather than logged here so the check is a test\n * rather than a line someone might scroll past, exactly as `resolveAnalytics`\n * does.\n */\nexport function resolveTracking(\n opts: GetvitopsTrackingOptions | undefined,\n ctx: ResolveTrackingContext = {},\n): ResolvedTracking {\n const warnings: string[] = [];\n const category: TrackingCategory = opts?.category ?? 'marketing';\n const enabled = opts?.enabled === true;\n\n if (!enabled) return { enabled: false, category, cookies: [], warnings };\n\n if (!ctx.consent) {\n warnings.push(\n `tracking is on, so this site writes the \\`${TRACKING_COOKIE}\\` cookie — a 90-day ` +\n 'identifier tying a visitor to the ad that brought them — and `consent` is off, so it ' +\n 'is written for every visitor with no way to decline. Set `consent: true`.',\n );\n } else if (ctx.consentCategories && !ctx.consentCategories.includes(category)) {\n // The banner would be raised for a category it renders no row for: the\n // visitor is asked a question the form does not show them.\n warnings.push(\n `tracking demands the \\`${category}\\` consent category, but the banner offers ` +\n `${ctx.consentCategories.join(', ') || 'nothing'}. Add \\`${category}\\` to ` +\n '`consent.categories`, or the visitor is asked about something the banner never names.',\n );\n }\n\n return { enabled, category, cookies: [TRACKING_COOKIE], warnings };\n}\n"],"mappings":";;AA2BA,MAAa,kBAAkB;;;;;;;;;;;;AAa/B,MAAa,oBAAoB;;;;;;;;;;AA2BjC,SAAgB,gBACd,MACA,MAA8B,CAAC,GACb;CAClB,MAAM,WAAqB,CAAC;CAC5B,MAAM,WAA6B,MAAM,YAAY;CACrD,MAAM,UAAU,MAAM,YAAY;CAElC,IAAI,CAAC,SAAS,OAAO;EAAE,SAAS;EAAO;EAAU,SAAS,CAAC;EAAG;CAAS;CAEvE,IAAI,CAAC,IAAI,SACP,SAAS,KACP,mDAA6D,wLAG/D;MACK,IAAI,IAAI,qBAAqB,CAAC,IAAI,kBAAkB,SAAS,QAAQ,GAG1E,SAAS,KACP,0BAA0B,SAAS,6CAC9B,IAAI,kBAAkB,KAAK,IAAI,KAAK,UAAU,UAAU,SAAS,8FAExE;CAGF,OAAO;EAAE;EAAS;EAAU,SAAS,MAAgB;EAAG;CAAS;AACnE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getvitops/astro",
|
|
3
|
-
"version": "8.0.
|
|
3
|
+
"version": "8.2.0-alpha.0",
|
|
4
4
|
"description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
|
|
5
5
|
"homepage": "https://docs.vitops.ca",
|
|
6
6
|
"bugs": "https://github.com/getvitops/design-system/issues",
|
|
@@ -64,6 +64,10 @@
|
|
|
64
64
|
"types": "./dist/exports/tracking.d.mts",
|
|
65
65
|
"import": "./dist/exports/tracking.mjs"
|
|
66
66
|
},
|
|
67
|
+
"./middleware": {
|
|
68
|
+
"types": "./dist/middleware.d.mts",
|
|
69
|
+
"import": "./dist/middleware.mjs"
|
|
70
|
+
},
|
|
67
71
|
"./Head.astro": "./src/components/Head.astro",
|
|
68
72
|
"./Seo.astro": "./src/components/Seo.astro",
|
|
69
73
|
"./Analytics.astro": "./src/components/Analytics.astro",
|
|
@@ -94,17 +98,16 @@
|
|
|
94
98
|
"access": "public"
|
|
95
99
|
},
|
|
96
100
|
"dependencies": {
|
|
97
|
-
"@getvitops/core": "8.0.
|
|
98
|
-
"@getvitops/generator": "8.0.
|
|
99
|
-
"@getvitops/utils": "8.0.
|
|
100
|
-
"@getvitops/vite": "8.0.
|
|
101
|
+
"@getvitops/core": "8.2.0-alpha.0",
|
|
102
|
+
"@getvitops/generator": "8.2.0-alpha.0",
|
|
103
|
+
"@getvitops/utils": "8.2.0-alpha.0",
|
|
104
|
+
"@getvitops/vite": "8.2.0-alpha.0"
|
|
101
105
|
},
|
|
102
106
|
"devDependencies": {
|
|
103
107
|
"@astrojs/sitemap": "^3.7.3",
|
|
104
108
|
"@tailwindcss/vite": "^4.3.3",
|
|
105
109
|
"astro": "^7.1.2",
|
|
106
|
-
"tailwindcss": "^4.3.3"
|
|
107
|
-
"vitest": "4.1.9"
|
|
110
|
+
"tailwindcss": "^4.3.3"
|
|
108
111
|
},
|
|
109
112
|
"peerDependencies": {
|
|
110
113
|
"@astrojs/sitemap": ">=3.6",
|