@getvitops/astro 7.0.0 → 8.0.1

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.
@@ -7,6 +7,14 @@ interface ConversionRequestContext {
7
7
  request: Request;
8
8
  locals?: unknown;
9
9
  }
10
+ /**
11
+ * Records a hit for `id` at time `at` (ms) and returns the ms since the previous
12
+ * hit for that `id`, or `null` if this is the first. Pure interface — a store
13
+ * decides nothing, it only remembers.
14
+ */
15
+ interface RateLimitStore {
16
+ hit(id: string, at: number): Promise<number | null> | number | null;
17
+ }
10
18
  interface ConversionRouteOptions {
11
19
  /** Where notifications go. Build it from your site config. */
12
20
  context: NotifyContext;
@@ -21,19 +29,94 @@ interface ConversionRouteOptions {
21
29
  binding?: (locals: unknown) => EmailBinding | undefined;
22
30
  /**
23
31
  * Reject a submission before anything is sent. Return a message to refuse.
24
- * This is where a consumer puts required-field checks and spam rules.
32
+ * This is where a consumer puts required-field checks.
25
33
  */
26
34
  validate?: (body: Record<string, unknown>) => string | null;
27
35
  /** Called with every channel outcome, delivered or not. Defaults to console. */
28
36
  onResult?: (result: Awaited<ReturnType<typeof notify>>) => void;
29
37
  /** Injected in tests. */
30
38
  now?: () => number;
39
+ /**
40
+ * Where to send a no-JS `<form method="post">` submission on success. Without
41
+ * this, that visitor's browser navigates to the raw `{"success":true}` JSON
42
+ * body as its next page — functional, but not a page anyone should see. Has
43
+ * no effect on the beacon/fetch (JSON) path, which always gets a JSON response.
44
+ */
45
+ redirectTo?: string;
46
+ /**
47
+ * Where to send a refused no-JS submission (validation, spam checks, malformed
48
+ * body). Falls back to `redirectTo` if omitted, and to the JSON error response
49
+ * if neither is set.
50
+ */
51
+ errorRedirectTo?: string;
52
+ /**
53
+ * Field names treated as honeypots: a bot fills every input it finds, a person
54
+ * never sees these (hidden by CSS in the form markup). A non-empty value is
55
+ * silently treated as spam — the response looks identical to a real success,
56
+ * denying the bot the signal it needs to know the trick worked, but nothing is
57
+ * notified and the field never reaches `notify()`. Defaults to the same names
58
+ * `render.ts`'s `OMIT_FIELDS` already knows about (`website`, `_honeypot`,
59
+ * `honeypot`), so the two lists don't need to be kept in sync by hand.
60
+ */
61
+ honeypotFields?: string[];
62
+ /**
63
+ * Reject a submission whose `Content-Length` exceeds this many bytes. Default
64
+ * 25_000 — generous for a contact form with no file upload, tiny for anything
65
+ * meant to relay large payloads. Best-effort: `Content-Length` can be absent
66
+ * or spoofed, same caveat as the origin check below.
67
+ */
68
+ maxBodyBytes?: number;
69
+ /**
70
+ * Reject a submission whose `Origin` header doesn't match this origin.
71
+ * Defaults to `context.canonical`'s origin. Pass `false` to disable.
72
+ *
73
+ * Best-effort, not a CSRF guard: browsers omit `Origin` on plenty of
74
+ * legitimate same-origin requests, so its *absence* is never treated as a
75
+ * mismatch — only a present-and-different value is rejected. That still stops
76
+ * the common case (a script hitting the endpoint directly from elsewhere)
77
+ * without breaking real submissions from older or privacy-hardened browsers.
78
+ */
79
+ allowedOrigin?: string | false;
80
+ /**
81
+ * Minimum interval between accepted submissions from the same IP. Pass
82
+ * `false` to disable. `windowMs` defaults to 10 seconds — enough to blunt a
83
+ * script firing in a loop without troubling someone who fat-fingers a form
84
+ * and resubmits.
85
+ */
86
+ rateLimit?: {
87
+ windowMs?: number;
88
+ store?: RateLimitStore;
89
+ } | false;
90
+ /**
91
+ * Verify a Cloudflare Turnstile token server-side. The **secret** key —
92
+ * unlike `site.security.turnstile.siteKey`, which is public config — must come
93
+ * from an env var, never a config file (the same rule as every other
94
+ * credential in this toolchain).
95
+ */
96
+ turnstile?: {
97
+ secretKey: string;
98
+ /** The submitted field carrying the token. Default `cf-turnstile-response`
99
+ * — the name Cloudflare's widget submits under. */
100
+ fieldName?: string;
101
+ /**
102
+ * Reject a submission with no token at all, rather than only a present-and-
103
+ * invalid one. Default `false`: an absent token is the ordinary shape of a
104
+ * visitor with JS disabled, and this factory is built around that path
105
+ * staying usable. Set `true` only for a form where verified-human matters
106
+ * enough to accept that trade-off — doing so means that form no longer
107
+ * works without JS, on purpose.
108
+ */
109
+ required?: boolean;
110
+ };
31
111
  }
32
112
  /**
33
113
  * Build a POST handler for conversions.
34
114
  *
35
- * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
36
- * `navigator.sendBeacon` and a fetch-submitting form each expect.
115
+ * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)
116
+ * form submission, matching what `navigator.sendBeacon` and a fetch-submitting
117
+ * form each expect. A no-JS `<form method="post">` — `application/
118
+ * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,
119
+ * on success, redirected to `redirectTo` if one is configured.
37
120
  *
38
121
  * **A failed notification does not fail the request.** The visitor has already
39
122
  * submitted; refusing them because our mail did not go out turns a lost
@@ -44,5 +127,5 @@ declare function createConversionRoute(options: ConversionRouteOptions): ({
44
127
  locals
45
128
  }: ConversionRequestContext) => Promise<Response>;
46
129
  //#endregion
47
- export { ConversionRequestContext, ConversionRouteOptions, createConversionRoute };
130
+ export { ConversionRequestContext, ConversionRouteOptions, RateLimitStore, createConversionRoute };
48
131
  //# sourceMappingURL=conversion.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;;;UAkDiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;AAAA,UAGe,sBAAA;EAiDD;EA/Cd,OAAA,EAAS,aAAA;EA+CK;;;;;;AAAgE;;EAtC9E,OAAA,IAAW,MAAA,cAAoB,YAAA;;;;;EAK/B,QAAA,IAAY,IAAA,EAAM,MAAA;;EAElB,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;;EAE9C,GAAA;AAAA;;;;;;;;;;;iBA0Bc,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAG/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
1
+ {"version":3,"file":"conversion.d.mts","names":[],"sources":["../../src/routes/conversion.ts"],"mappings":";;;AAsPA;;AAAA,UAjLiB,wBAAA;EACf,OAAA,EAAS,OAAO;EAChB,MAAA;AAAA;;;;;;UAQe,cAAA;EACf,GAAA,CAAI,EAAA,UAAY,EAAA,WAAa,OAAO;AAAA;AAAA,UAkBrB,sBAAA;EA4KD;EA1Kd,OAAA,EAAS,aAAA;EA0K0B;;;;AAA2C;;;;EAjK9E,OAAA,IAAW,MAAA,cAAoB,YAAA;;;;;EAK/B,QAAA,IAAY,IAAA,EAAM,MAAA;;EAElB,QAAA,IAAY,MAAA,EAAQ,OAAA,CAAQ,UAAA,QAAkB,MAAA;;EAE9C,GAAA;;;;;;;EAOA,UAAA;;;;;;EAMA,eAAA;;;;;;;;;;EAUA,cAAA;;;;;;;EAOA,YAAA;;;;;;;;;;;EAWA,aAAA;;;;;;;EAOA,SAAA;IAAc,QAAA;IAAmB,KAAA,GAAQ,cAAA;EAAA;;;;;;;EAOzC,SAAA;IACE,SAAA;;;IAGA,SAAA;;;;;;;;;IASA,QAAA;EAAA;AAAA;;;;;;;;;;;;;;iBA4DY,qBAAA,CAAsB,OAAA,EAAS,sBAAA;EAwB/B,OAAA;EAAA;AAAA,GAAqB,wBAAA,KAA2B,OAAA,CAAQ,QAAA"}
@@ -1,4 +1,5 @@
1
1
  import { notify } from "@getvitops/utils/notify";
2
+ import { verifyTurnstileToken } from "@getvitops/utils/security";
2
3
  import { parseTrackingCookie } from "@getvitops/utils/tracking";
3
4
 
4
5
  //#region src/routes/conversion.ts
@@ -10,11 +11,14 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
10
11
  * off the request, build the event, notify whoever the config says. That is the
11
12
  * same three lines in every project, so it ships here.
12
13
  *
13
- * What deliberately does **not** ship here is validation and business rules. A
14
- * consumer's form has its own required fields, its own spam handling and its own
15
- * response shape, and baking a guess at those in would make the factory something
16
- * to fight rather than mount. The handler takes the parsed body and an optional
17
- * `validate`; everything else is the caller's.
14
+ * What deliberately does **not** ship here is field-level validation and business
15
+ * rules — a consumer's form has its own required fields and its own response
16
+ * shape, and baking a guess at those in would make the factory something to
17
+ * fight rather than mount. The handler takes the parsed body and an optional
18
+ * `validate`; that part is the caller's. What **does** ship here is generic abuse
19
+ * resistance (honeypot, origin, rate limit, body size): this is a public,
20
+ * unauthenticated endpoint that sends email, so leaving it wide open makes it a
21
+ * relay and a cost amplifier regardless of whose form is behind it.
18
22
  *
19
23
  * The path matters: the capture script beacons `tel:` conversions at
20
24
  * `TRACKING_ENDPOINT` (`/api/track`, exported from `@getvitops/astro/tracking`),
@@ -34,9 +38,24 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
34
38
  * organizationName: 'Acme',
35
39
  * },
36
40
  * binding: () => env.EMAIL,
41
+ * // A no-JS <form method="post"> submission never sees a JSON response body —
42
+ * // it's a full navigation, so send it somewhere real.
43
+ * redirectTo: '/thanks',
37
44
  * });
38
45
  * ```
39
46
  *
47
+ * Cloudflare Turnstile is a separate, opt-in layer on top of the checks above —
48
+ * pass `turnstile: { secretKey }` and a submission carrying a
49
+ * `cf-turnstile-response` token gets it verified server-side via
50
+ * `verifyTurnstileToken` (`@getvitops/utils/security`). The widget itself is the
51
+ * consumer's own markup (`<script src="https://challenges.cloudflare.com/
52
+ * turnstile/v0/api.js" async defer><\/script>` + `<div class="cf-turnstile"
53
+ * data-sitekey="…">` on their form) — there is no `FormRenderer` for this to hook
54
+ * into generically, matching how the rest of the form is already hand-written.
55
+ * `site.security.turnstile.siteKey` is what the generated privacy policy reads to
56
+ * name Turnstile as a processor; wiring this option up is what makes that true
57
+ * rather than an assertion nothing checks.
58
+ *
40
59
  * Two things this example used to get wrong, both of which cost a consumer real
41
60
  * time. It read the binding off `locals.runtime.env`, which Astro removed in v6
42
61
  * — and because the resulting throw precedes the response, Astro re-reads the
@@ -46,65 +65,154 @@ import { parseTrackingCookie } from "@getvitops/utils/tracking";
46
65
  * current one. It also called a `toNotifyContext(config)` helper that does not
47
66
  * exist — `context` is a plain `NotifyContext`, built however you like.
48
67
  */
49
- /** Fields that are never part of a submission's meaning. */
50
- const CONTROL_FIELDS = new Set(["event", "phone"]);
51
- function toFormData(body) {
68
+ /**
69
+ * Per-isolate, in-memory default. Good enough for a single Worker instance
70
+ * fielding occasional contact-form spam; not durable across a fleet, and not
71
+ * shared with other routes. Inject a KV- or Durable-Object-backed `store` for
72
+ * real cross-instance limiting — the interface is one method wide on purpose.
73
+ */
74
+ var MemoryRateLimitStore = class {
75
+ lastHit = /* @__PURE__ */ new Map();
76
+ hit(id, at) {
77
+ const prev = this.lastHit.get(id);
78
+ this.lastHit.set(id, at);
79
+ return prev === void 0 ? null : at - prev;
80
+ }
81
+ };
82
+ /** Fields that are never part of a submission's meaning. `cf-turnstile-response`
83
+ * is excluded unconditionally, matching `render.ts`'s `OMIT_FIELDS` — the field
84
+ * carries a verification token, not something the visitor wrote. */
85
+ const CONTROL_FIELDS = new Set([
86
+ "event",
87
+ "phone",
88
+ "cf-turnstile-response"
89
+ ]);
90
+ const DEFAULT_HONEYPOT_FIELDS = [
91
+ "website",
92
+ "_honeypot",
93
+ "honeypot"
94
+ ];
95
+ const DEFAULT_MAX_BODY_BYTES = 25e3;
96
+ const DEFAULT_RATE_LIMIT_WINDOW_MS = 1e4;
97
+ function toFormData(body, honeypotFields) {
52
98
  const out = {};
53
99
  for (const [key, value] of Object.entries(body)) {
54
- if (CONTROL_FIELDS.has(key)) continue;
100
+ if (CONTROL_FIELDS.has(key) || honeypotFields.includes(key)) continue;
55
101
  if (value === void 0 || value === null) continue;
56
102
  out[key] = String(value);
57
103
  }
58
104
  return out;
59
105
  }
106
+ function hasHoneypot(body, honeypotFields) {
107
+ return honeypotFields.some((field) => {
108
+ const value = body[field];
109
+ return typeof value === "string" && value.trim() !== "";
110
+ });
111
+ }
112
+ function originOf(url) {
113
+ if (!url) return null;
114
+ try {
115
+ return new URL(url).origin;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ /** A `Location` header response — used for the no-JS form path instead of
121
+ * `Response.redirect()`, which requires a base URL this factory doesn't have. */
122
+ function redirectTo(location, status = 303) {
123
+ return new Response(null, {
124
+ status,
125
+ headers: { Location: location }
126
+ });
127
+ }
60
128
  /**
61
129
  * Build a POST handler for conversions.
62
130
  *
63
- * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what
64
- * `navigator.sendBeacon` and a fetch-submitting form each expect.
131
+ * Returns 204 for a beacon and 200 with `{ success }` for a JSON (fetch-based)
132
+ * form submission, matching what `navigator.sendBeacon` and a fetch-submitting
133
+ * form each expect. A no-JS `<form method="post">` — `application/
134
+ * x-www-form-urlencoded` or `multipart/form-data` — is parsed the same way and,
135
+ * on success, redirected to `redirectTo` if one is configured.
65
136
  *
66
137
  * **A failed notification does not fail the request.** The visitor has already
67
138
  * submitted; refusing them because our mail did not go out turns a lost
68
139
  * notification into a lost conversion. Failures are reported through `onResult`.
69
140
  */
70
141
  function createConversionRoute(options) {
71
- const { context, binding, validate, onResult, now = Date.now } = options;
142
+ const { context, binding, validate, onResult, now = Date.now, redirectTo: successRedirect, errorRedirectTo = successRedirect, honeypotFields = DEFAULT_HONEYPOT_FIELDS, maxBodyBytes = DEFAULT_MAX_BODY_BYTES } = options;
143
+ const allowedOrigin = options.allowedOrigin === false ? null : options.allowedOrigin ?? originOf(context.canonical);
144
+ const rateLimit = options.rateLimit === false ? null : {
145
+ windowMs: options.rateLimit?.windowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS,
146
+ store: options.rateLimit?.store ?? new MemoryRateLimitStore()
147
+ };
72
148
  return async ({ request, locals }) => {
149
+ const contentType = request.headers.get("content-type") ?? "";
150
+ const isFormPost = contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded");
151
+ const respondError = (error, status) => isFormPost && errorRedirectTo ? redirectTo(errorRedirectTo) : Response.json({
152
+ success: false,
153
+ error
154
+ }, { status });
155
+ if (Number(request.headers.get("content-length") ?? 0) > maxBodyBytes) return respondError("Request too large.", 413);
156
+ if (allowedOrigin) {
157
+ const requestOrigin = request.headers.get("origin");
158
+ if (requestOrigin && requestOrigin !== allowedOrigin) return respondError("Origin not allowed.", 403);
159
+ }
73
160
  let body;
74
161
  try {
75
- const parsed = await request.json();
76
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return Response.json({
77
- success: false,
78
- error: "Malformed request."
79
- }, { status: 400 });
80
- body = parsed;
162
+ if (isFormPost) {
163
+ const formData = await request.formData();
164
+ body = {};
165
+ for (const [key, value] of formData.entries()) if (typeof value === "string") body[key] = value;
166
+ } else {
167
+ const parsed = await request.json();
168
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return respondError("Malformed request.", 400);
169
+ body = parsed;
170
+ }
81
171
  } catch {
82
- return Response.json({
83
- success: false,
84
- error: "Malformed request."
85
- }, { status: 400 });
172
+ return respondError("Malformed request.", 400);
173
+ }
174
+ const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0;
175
+ if (rateLimit && ip) {
176
+ const sinceLastHit = await rateLimit.store.hit(ip, now());
177
+ if (sinceLastHit !== null && sinceLastHit < rateLimit.windowMs) return respondError("Too many requests.", 429);
86
178
  }
87
179
  const isCall = body["event"] === "call";
88
- if (isCall && typeof body["phone"] !== "string") return Response.json({
89
- success: false,
90
- error: "Missing phone."
91
- }, { status: 400 });
92
- const refusal = validate?.(body);
93
- if (refusal) return Response.json({
94
- success: false,
95
- error: refusal
96
- }, { status: 400 });
180
+ if (isCall && typeof body["phone"] !== "string") return respondError("Missing phone.", 400);
181
+ const spam = !isCall && hasHoneypot(body, honeypotFields);
182
+ if (!isCall && !spam && options.turnstile) {
183
+ const { secretKey, fieldName = "cf-turnstile-response", required = false } = options.turnstile;
184
+ const token = body[fieldName];
185
+ if (typeof token === "string" && token) {
186
+ if (!(await verifyTurnstileToken(token, secretKey, ip)).success) return respondError("Verification failed.", 400);
187
+ } else if (required) return respondError("Verification required.", 400);
188
+ }
189
+ const refusal = !spam && validate?.(body);
190
+ if (refusal) return respondError(refusal, 400);
191
+ const respondSuccess = () => isCall ? new Response(null, { status: 204 }) : isFormPost && successRedirect ? redirectTo(successRedirect) : Response.json({ success: true });
192
+ if (spam) {
193
+ onResult?.({
194
+ results: [{
195
+ channel: "email",
196
+ sent: false,
197
+ reason: "Skipped: honeypot triggered."
198
+ }],
199
+ delivered: false
200
+ });
201
+ return respondSuccess();
202
+ }
203
+ const turnstileFieldName = options.turnstile?.fieldName;
204
+ const excludedFields = turnstileFieldName ? [...honeypotFields, turnstileFieldName] : honeypotFields;
97
205
  const result = await notify({
98
206
  type: isCall ? "call" : "form",
99
- ...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body) },
207
+ ...isCall ? { phone: String(body["phone"]).replace(/^tel:/, "") } : { formData: toFormData(body, excludedFields) },
100
208
  tracking: parseTrackingCookie(request.headers.get("cookie")),
101
209
  userAgent: request.headers.get("user-agent") ?? void 0,
102
- ip: request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? void 0,
210
+ ip,
103
211
  at: now()
104
212
  }, context, { email: binding?.(locals) });
105
213
  if (onResult) onResult(result);
106
214
  else for (const r of result.results) if (!r.sent) console.warn(`[vitops] notification not sent (${r.channel}): ${r.reason}`);
107
- return isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });
215
+ return respondSuccess();
108
216
  };
109
217
  }
110
218
 
@@ -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 validation and business rules. A\n * consumer's form has its own required fields, its own spam handling and its own\n * response shape, and baking a guess at those in would make the factory something\n * to fight rather than mount. The handler takes the parsed body and an optional\n * `validate`; everything else is the caller's.\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 * });\n * ```\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 { 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\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 and spam rules.\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\n/** Fields that are never part of a submission's meaning. */\nconst CONTROL_FIELDS = new Set(['event', 'phone']);\n\nfunction toFormData(body: Record<string, unknown>): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(body)) {\n if (CONTROL_FIELDS.has(key)) continue;\n if (value === undefined || value === null) continue;\n out[key] = String(value);\n }\n return out;\n}\n\n/**\n * Build a POST handler for conversions.\n *\n * Returns 204 for a beacon and 200 with `{ success }` for a form, matching what\n * `navigator.sendBeacon` and a fetch-submitting form each expect.\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 { context, binding, validate, onResult, now = Date.now } = options;\n\n return async ({ request, locals }: ConversionRequestContext): Promise<Response> => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await request.json();\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n body = parsed as Record<string, unknown>;\n } catch {\n return Response.json({ success: false, error: 'Malformed request.' }, { status: 400 });\n }\n\n const isCall = body['event'] === 'call';\n if (isCall && typeof body['phone'] !== 'string') {\n return Response.json({ success: false, error: 'Missing phone.' }, { status: 400 });\n }\n\n const refusal = validate?.(body);\n if (refusal) return Response.json({ success: false, error: refusal }, { status: 400 });\n\n const event: ConversionEvent = {\n type: isCall ? 'call' : 'form',\n ...(isCall\n ? { phone: String(body['phone']).replace(/^tel:/, '') }\n : { formData: toFormData(body) }),\n tracking: parseTrackingCookie(request.headers.get('cookie')),\n userAgent: request.headers.get('user-agent') ?? undefined,\n // Cloudflare's own header first; `x-forwarded-for` is the portable fallback\n // and may carry a proxy chain, whose first entry is the client.\n ip:\n request.headers.get('cf-connecting-ip') ??\n request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??\n undefined,\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 isCall ? new Response(null, { status: 204 }) : Response.json({ success: true });\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+EA,MAAM,iBAAiB,IAAI,IAAI,CAAC,SAAS,OAAO,CAAC;AAEjD,SAAS,WAAW,MAAuD;CACzE,MAAM,MAA8B,CAAC;CACrC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,GAAG;EAC/C,IAAI,eAAe,IAAI,GAAG,GAAG;EAC7B,IAAI,UAAU,UAAa,UAAU,MAAM;EAC3C,IAAI,OAAO,OAAO,KAAK;CACzB;CACA,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,sBAAsB,SAAiC;CACrE,MAAM,EAAE,SAAS,SAAS,UAAU,UAAU,MAAM,KAAK,QAAQ;CAEjE,OAAO,OAAO,EAAE,SAAS,aAA0D;EACjF,IAAI;EACJ,IAAI;GACF,MAAM,SAAkB,MAAM,QAAQ,KAAK;GAC3C,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;GAEvF,OAAO;EACT,QAAQ;GACN,OAAO,SAAS,KAAK;IAAE,SAAS;IAAO,OAAO;GAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;EACvF;EAEA,MAAM,SAAS,KAAK,aAAa;EACjC,IAAI,UAAU,OAAO,KAAK,aAAa,UACrC,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;EAGnF,MAAM,UAAU,WAAW,IAAI;EAC/B,IAAI,SAAS,OAAO,SAAS,KAAK;GAAE,SAAS;GAAO,OAAO;EAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;EAkBrF,MAAM,SAAS,MAAM,OAAO;GAf1B,MAAM,SAAS,SAAS;GACxB,GAAI,SACA,EAAE,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,QAAQ,SAAS,EAAE,EAAE,IACpD,EAAE,UAAU,WAAW,IAAI,EAAE;GACjC,UAAU,oBAAoB,QAAQ,QAAQ,IAAI,QAAQ,CAAC;GAC3D,WAAW,QAAQ,QAAQ,IAAI,YAAY,KAAK;GAGhD,IACE,QAAQ,QAAQ,IAAI,kBAAkB,KACtC,QAAQ,QAAQ,IAAI,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK,KAC5D;GACF,IAAI,IAAI;EAGsB,GAAG,SAAS,EAAE,OAAO,UAAU,MAAM,EAAE,CAAC;EAExE,IAAI,UAAU,SAAS,MAAM;OAE3B,KAAK,MAAM,KAAK,OAAO,SACrB,IAAI,CAAC,EAAE,MAAM,QAAQ,KAAK,mCAAmC,EAAE,QAAQ,KAAK,EAAE,QAAQ;EAG1F,OAAO,SAAS,IAAI,SAAS,MAAM,EAAE,QAAQ,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,SAAS,KAAK,CAAC;CACvF;AACF"}
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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"}
package/package.json CHANGED
@@ -1,8 +1,15 @@
1
1
  {
2
2
  "name": "@getvitops/astro",
3
- "version": "7.0.0",
3
+ "version": "8.0.1",
4
4
  "description": "Astro integration for the Vitops design system — favicons/PWA, web-component scripts, and CSS, via a getvitops() integration + <Head /> component.",
5
+ "homepage": "https://docs.vitops.ca",
6
+ "bugs": "https://github.com/getvitops/design-system/issues",
5
7
  "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/getvitops/design-system.git",
11
+ "directory": "packages/astro"
12
+ },
6
13
  "files": [
7
14
  "dist",
8
15
  "src/components/Head.astro",
@@ -37,7 +44,10 @@
37
44
  "src/components/Tree.astro",
38
45
  "src/components/TreeNode.astro",
39
46
  "src/components/tree.ts",
40
- "src/exports/tracking.ts"
47
+ "src/exports/tracking.ts",
48
+ "src/components/ReviewCard.astro",
49
+ "src/components/ReviewCardBody.astro",
50
+ "src/components/Marquee.astro"
41
51
  ],
42
52
  "type": "module",
43
53
  "types": "./dist/index.d.mts",
@@ -75,6 +85,8 @@
75
85
  "./components/NavShellToggle.astro": "./src/components/NavShellToggle.astro",
76
86
  "./components/Tree.astro": "./src/components/Tree.astro",
77
87
  "./components/tree.ts": "./src/components/tree.ts",
88
+ "./components/ReviewCard.astro": "./src/components/ReviewCard.astro",
89
+ "./components/Marquee.astro": "./src/components/Marquee.astro",
78
90
  "./schemas/*": "./src/schemas/*",
79
91
  "./package.json": "./package.json"
80
92
  },
@@ -82,17 +94,17 @@
82
94
  "access": "public"
83
95
  },
84
96
  "dependencies": {
85
- "@getvitops/core": "7.0.0",
86
- "@getvitops/utils": "7.0.0",
87
- "@getvitops/generator": "7.0.0",
88
- "@getvitops/vite": "7.0.0"
97
+ "@getvitops/core": "8.0.1",
98
+ "@getvitops/generator": "8.0.1",
99
+ "@getvitops/utils": "8.0.1",
100
+ "@getvitops/vite": "8.0.1"
89
101
  },
90
102
  "devDependencies": {
91
103
  "@astrojs/sitemap": "^3.7.3",
92
104
  "@tailwindcss/vite": "^4.3.3",
93
105
  "astro": "^7.1.2",
94
106
  "tailwindcss": "^4.3.3",
95
- "vitest": "^4.1.9"
107
+ "vitest": "4.1.9"
96
108
  },
97
109
  "peerDependencies": {
98
110
  "@astrojs/sitemap": ">=3.6",
@@ -114,5 +126,8 @@
114
126
  "tailwindcss": {
115
127
  "optional": true
116
128
  }
129
+ },
130
+ "engines": {
131
+ "node": ">=24"
117
132
  }
118
133
  }