diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index 7f6ca81..6760730 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -41,6 +41,8 @@ jobs:
--site fund.lasvegasfortransit.org --expect present
node packages/analytics/dist/cli/index.mjs verify https://map.lasvegasfortransit.org \
--site map.lasvegasfortransit.org --expect present
+ node packages/analytics/dist/cli/index.mjs verify https://lvwwd.org \
+ --site lvwwd.org --expect present
- name: Build reports
run: |
diff --git a/apps/collector/tests/collector.test.ts b/apps/collector/tests/collector.test.ts
index e4962e6..d7dc1b7 100644
--- a/apps/collector/tests/collector.test.ts
+++ b/apps/collector/tests/collector.test.ts
@@ -45,6 +45,54 @@ describe('analytics collector', () => {
);
});
+ test('writes an lvwwd.org campaign event with its properties in declared order', async () => {
+ const env = testEnv();
+ const response = await handle(
+ new Request(endpoint, {
+ body: JSON.stringify({
+ site: 'lvwwd.org',
+ name: 'trip_entry_submitted',
+ props: { method: 'screenshot', day: '5' },
+ }),
+ headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' },
+ method: 'POST',
+ }),
+ env,
+ );
+
+ expect(response.status).toBe(204);
+ expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://lvwwd.org');
+ expect(env.EVENTS.writeDataPoint).toHaveBeenCalledOnce();
+ const point = env.EVENTS.writeDataPoint.mock.calls[0]?.[0];
+ expect(point?.indexes).toEqual(['lvwwd.org']);
+ expect(point?.blobs?.slice(0, 2)).toEqual(['lvwwd.org', 'trip_entry_submitted']);
+ expect(point?.blobs?.slice(6)).toEqual(['5', 'screenshot']);
+ });
+
+ test('keeps LVBT events and campaign events on their own sites', async () => {
+ const campaignJoin = await workerFetch(endpoint, {
+ body: JSON.stringify({
+ site: 'lvwwd.org',
+ name: 'join_click',
+ props: { placement: 'header' },
+ }),
+ headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' },
+ method: 'POST',
+ });
+ const labsSignup = await workerFetch(endpoint, {
+ body: JSON.stringify({
+ site: 'labs.lasvegasfortransit.org',
+ name: 'campaign_signup',
+ props: {},
+ }),
+ headers: clientHeaders,
+ method: 'POST',
+ });
+
+ expect(campaignJoin.status).toBe(400);
+ expect(labsSignup.status).toBe(400);
+ });
+
test.each(['Sec-GPC', 'DNT'])('honors %s before processing the event', async (header) => {
const response = await workerFetch(endpoint, {
body: 'not json',
diff --git a/docs/development/how-to/add-a-conversion-event.md b/docs/development/how-to/add-a-conversion-event.md
index e65dc2f..8d6ff31 100644
--- a/docs/development/how-to/add-a-conversion-event.md
+++ b/docs/development/how-to/add-a-conversion-event.md
@@ -4,7 +4,9 @@ A conversion event represents a small, durable product action that cannot be ans
pageviews. Confirm that the question needs a custom event before changing the allowlist.
1. Add one entry to `packages/analytics/src/events.ts`. Choose `client` or `server`, list the exact
- production sites, and use enum properties only.
+ production sites, and use enum properties only. Name the event in lowercase snake case, such as
+ `trip_entry_submitted`. Write each value as a short lowercase label or a small count, such as
+ `screenshot` or `3`; a unit test rejects anything else.
2. Add valid and invalid cases to `packages/analytics/tests/events.test.ts` and the collector
workerd suite.
3. Run `pnpm exec lvbt-analytics events --markdown` and update the event reference with the exact
diff --git a/docs/development/reference/api.md b/docs/development/reference/api.md
index 6045605..71efedf 100644
--- a/docs/development/reference/api.md
+++ b/docs/development/reference/api.md
@@ -60,14 +60,29 @@ HTML elements use the same typed contract without application code:
Join
```
-Unknown names, property keys, and enum values are ignored by delegated click tracking and rejected
-by direct `track` calls.
+Each declared property reads from the `data-lvbt-` attribute of the same name, so `material_printed`
+reads `data-lvbt-item` and an event without properties needs only `data-lvbt-event`. Unknown names,
+property keys, and enum values are ignored by delegated click tracking and rejected by direct
+`track` calls. Server-only events are rejected in the browser.
+
+Classic scripts that cannot import the package call the same function through `window.lvbt`, which
+exists only after the gate enables analytics. Wrap the call so an analytics mistake never stops the
+page:
+
+```js
+try {
+ window.lvbt?.track('bus_finder_used', { method: 'place' });
+} catch {
+ // An event outside the allowlist is dropped.
+}
+```
## Framework entry points
`@lasvegasfortransit/analytics/astro` exports `lvbtAnalytics(options)`. It reads the standard
-environment, injects an external page module only when a token exists, and disables JavaScript asset
-inlining so the site CSP remains enforceable.
+environment, injects an external page module only when a token exists, and keeps script chunks out
+of the HTML so a `script-src 'self'` policy still runs them. Stylesheets and other assets keep the
+site's own inlining rule, so small page styles stay inline.
`@lasvegasfortransit/analytics/react` exports `Analytics`, `useAnalytics`, and `useTrack`. The
provider initializes the shared client after mount. Hooks return the disabled no-op handle during
diff --git a/docs/development/reference/configuration.md b/docs/development/reference/configuration.md
index 3b7eaa4..11af497 100644
--- a/docs/development/reference/configuration.md
+++ b/docs/development/reference/configuration.md
@@ -46,5 +46,10 @@ Site identifiers are production hostnames without a scheme or path:
- `labs.lasvegasfortransit.org`
- `fund.lasvegasfortransit.org`
- `map.lasvegasfortransit.org`
+- `lvwwd.org`, the Week Without Driving Las Vegas campaign site
The client accepts the exact hostname and its `www.` form. Other hostnames fail the production gate.
+
+`lvwwd.org` is not a `lasvegasfortransit.org` subdomain, so the organization's Web Analytics site
+does not cover it. It has its own Web Analytics site, and its token lives in the
+week-without-driving repository's `production` environment rather than in the organization variable.
diff --git a/docs/development/reference/event-allowlist.md b/docs/development/reference/event-allowlist.md
index acc59ab..f65d8a9 100644
--- a/docs/development/reference/event-allowlist.md
+++ b/docs/development/reference/event-allowlist.md
@@ -5,15 +5,44 @@ terms, share identifiers, and arbitrary campaign values do not belong in an anal
-| Event | Source | Sites | Properties |
-| ------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` |
-| `membership_intake` | server | `lasvegasfortransit.org` | None |
-| `join_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
-| `donate_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
-| `tool_feature_used` | client | `labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` |
+| Event | Source | Sites | Properties |
+| ---------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` |
+| `membership_intake` | server | `lasvegasfortransit.org` | None |
+| `join_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
+| `donate_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` |
+| `tool_feature_used` | client | `labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` |
+| `campaign_signup` | client | `lvwwd.org` | None |
+| `week_link_requested` | client | `lvwwd.org` | `method`: `link_form`, `signup_form` |
+| `trip_entry_submitted` | client | `lvwwd.org` | `day`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`
`method`: `link`, `screenshot`, `link_and_screenshot` |
+| `trip_picture_shared` | client | `lvwwd.org` | `method`: `share_sheet`, `download` |
+| `bingo_square_marked` | client | `lvwwd.org` | `marked`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `18`, `19`, `20`, `21`, `22`, `23`, `24` |
+| `bingo_completed` | client | `lvwwd.org` | `lines`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12` |
+| `bus_finder_used` | client | `lvwwd.org` | `method`: `my_location`, `place` |
+| `app_installed` | client | `lvwwd.org` | `method`: `browser`, `home_screen` |
+| `material_printed` | client | `lvwwd.org` | `item`: `partner_flyer`, `bingo_card` |
+| `mail_in_viewed` | client | `lvwwd.org` | None |
+## lvwwd.org campaign events
+
+lvwwd.org, the Week Without Driving Las Vegas campaign site, counts the steps of its campaign. Every
+value is a day of the week, a running count, or a fixed label, so no event says who a person is,
+what they wrote, or where they were.
+
+| Event | Sent when | Properties |
+| ---------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| `campaign_signup` | A new sign-up is saved | None |
+| `week_link_requested` | Someone asks for their "Open my week" link | `method`: the Get my link form, or a sign-up that already existed |
+| `trip_entry_submitted` | A day's trip entry is saved | `day`: the campaign day, 1 to 8; `method`: a post link, a screenshot, or both. The link itself is never sent |
+| `trip_picture_shared` | The trip picture goes to the phone's share sheet or is downloaded | `method`: `share_sheet` or `download` |
+| `bingo_square_marked` | A bingo square is marked | `marked`: how many squares are marked now, 1 to 24 |
+| `bingo_completed` | Marking a square finishes a line | `lines`: how many lines are complete now, 1 to 12; 12 is the whole card |
+| `bus_finder_used` | Find a bus lists stops | `method`: the phone's location or a chosen place. The location itself is never sent |
+| `app_installed` | The browser reports an install, or the site first opens from the home screen where browsers report none | `method`: `browser` or `home_screen` |
+| `material_printed` | A print starts on the partner flyer or the paper bingo card | `item`: `partner_flyer` or `bingo_card` |
+| `mail_in_viewed` | The enter-by-mail instructions come into view | None |
+
Cloudflare Web Analytics owns pageviews, referrers, UTM attribution, and Core Web Vitals.
Duplicating those values as custom events creates a second, less reliable source of truth.
diff --git a/docs/public/privacy.md b/docs/public/privacy.md
index 660844d..ff398ca 100644
--- a/docs/public/privacy.md
+++ b/docs/public/privacy.md
@@ -4,8 +4,9 @@ Las Vegas for Better Transit uses privacy-preserving analytics to understand whi
tools are useful and whether the sites perform well.
Cloudflare Web Analytics records aggregate page use and performance without cookies. A small
-LVBT-operated service records a limited set of actions such as selecting a join link or using an
-export feature. Those actions contain predefined categories, not form contents or other free text.
+LVBT-operated service records a limited set of actions such as selecting a join link, using an
+export feature, or finishing a campaign step like signing up. Those actions contain predefined
+categories, not form contents or other free text.
The system does not create visitor or session identifiers, fingerprint browsers, or store IP
addresses and user-agent strings with events. It does not collect names, email addresses, search
diff --git a/packages/analytics/astro/index.ts b/packages/analytics/astro/index.ts
index 80ed1a7..a1b8d7d 100644
--- a/packages/analytics/astro/index.ts
+++ b/packages/analytics/astro/index.ts
@@ -38,7 +38,20 @@ export function lvbtAnalytics(options: LvbtAnalyticsOptions): AstroIntegration {
'page',
`import { init } from '@lasvegasfortransit/analytics'; const options = ${JSON.stringify({ ...serializable, collector, token })}; ${patternAssignments} init(options);`,
);
- updateConfig({ vite: { build: { assetsInlineLimit: 0 } } });
+ // Keep script chunks external so a `script-src 'self'` policy still runs them, and leave
+ // every other asset, such as small stylesheets, to the site's own inlining rule.
+ const siteLimit = config.vite.build?.assetsInlineLimit;
+ updateConfig({
+ vite: {
+ build: {
+ assetsInlineLimit: (filePath: string, content: Buffer) => {
+ if (/\.m?js$/.test(filePath)) return false;
+ if (typeof siteLimit === 'function') return siteLimit(filePath, content);
+ return siteLimit === undefined ? undefined : content.byteLength < siteLimit;
+ },
+ },
+ },
+ });
},
},
};
diff --git a/packages/analytics/package.json b/packages/analytics/package.json
index fc11466..41b1257 100644
--- a/packages/analytics/package.json
+++ b/packages/analytics/package.json
@@ -1,6 +1,6 @@
{
"name": "@lasvegasfortransit/analytics",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Privacy-preserving analytics for Las Vegas for Better Transit web properties.",
"license": "MIT",
"repository": {
diff --git a/packages/analytics/scripts/check-size.mjs b/packages/analytics/scripts/check-size.mjs
index b138947..9aa4fd5 100644
--- a/packages/analytics/scripts/check-size.mjs
+++ b/packages/analytics/scripts/check-size.mjs
@@ -1,8 +1,11 @@
import { readFile } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
+// The standalone client carries the client half of the event allowlist so a
+// plain-HTML page rejects undeclared data before it leaves the browser. The
+// allowlist grows with each site, so its budget covers five sites' events.
const budgets = [
- ['standalone client', 'dist/standalone/client.iife.js', 1024],
+ ['standalone client', 'dist/standalone/client.iife.js', 1792],
['package entry', 'dist/index.mjs', 1536],
];
diff --git a/packages/analytics/src/client-events.ts b/packages/analytics/src/client-events.ts
index b072b0b..75a2e9c 100644
--- a/packages/analytics/src/client-events.ts
+++ b/packages/analytics/src/client-events.ts
@@ -1,44 +1,23 @@
-import type { AnalyticsEvent, EventName, PropsFor } from './events.js';
-
-const placements = ['header', 'footer', 'hero', 'inline', 'dialog'] as const;
-const features = [
- 'share_created',
- 'share_opened',
- 'export_png',
- 'export_svg',
- 'export_json',
- 'gtfs_import',
- 'sim_started',
- 'fuel_lever_moved',
- 'scenario_changed',
-] as const;
-
-function declaration(name: EventName) {
- if (name === 'join_click' || name === 'donate_click') return ['placement', placements] as const;
- if (name === 'tool_feature_used') return ['feature', features] as const;
- return undefined;
-}
+import { eventPayload, EVENTS, type EventName, type PropsFor } from './events.js';
export function clientEvent(site: string, name: N, props: PropsFor) {
- const declared = declaration(name);
- if (
- !declared ||
- Object.keys(props).length !== 1 ||
- typeof props[declared[0] as keyof PropsFor] !== 'string' ||
- !(declared[1] as readonly string[]).includes(props[declared[0] as keyof PropsFor])
- )
- throw new Error('Analytics event is not declared.');
- return { site, name, props } as AnalyticsEvent;
+ const event = eventPayload({ site, name, props });
+ if (EVENTS[event.name].source !== 'client') throw new Error('Analytics event is not declared.');
+ return event;
}
+// data-lvbt-event names the event; data-lvbt- carries each declared property.
export function delegatedEvent(target: HTMLElement) {
- const name = target.dataset.lvbtEvent as EventName;
- const key = name === 'tool_feature_used' ? 'feature' : 'placement';
- const value = key === 'feature' ? target.dataset.lvbtFeature : target.dataset.lvbtPlacement;
+ const name = target.dataset.lvbtEvent ?? '';
+ if (!Object.prototype.hasOwnProperty.call(EVENTS, name)) return undefined;
+ const props = Object.fromEntries(
+ Object.keys(EVENTS[name as EventName].props).map((key) => [
+ key,
+ target.dataset[`lvbt${key.charAt(0).toUpperCase()}${key.slice(1)}`],
+ ]),
+ );
try {
- return clientEvent(target.dataset.lvbtSite ?? '', name, {
- [key]: value,
- } as PropsFor);
+ return clientEvent(target.dataset.lvbtSite ?? '', name as EventName, props as never);
} catch {
return undefined;
}
diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts
index 1dfe9d6..80a9a99 100644
--- a/packages/analytics/src/events.ts
+++ b/packages/analytics/src/events.ts
@@ -1,3 +1,14 @@
+// lvwwd.org, the Week Without Driving Las Vegas campaign site, counts its
+// campaign steps with these values. Each is a day of the week, a running
+// count, or a fixed label; none says who a person is or what they wrote.
+const campaignDays = ['1', '2', '3', '4', '5', '6', '7', '8'] as const;
+// prettier-ignore
+const markedSquares = [
+ '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12',
+ '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24',
+] as const;
+const completedLines = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] as const;
+
export const EVENTS = {
newsletter_signup: {
source: 'server',
@@ -46,6 +57,40 @@ export const EVENTS = {
],
},
},
+ campaign_signup: { source: 'client', sites: ['lvwwd.org'], props: {} },
+ week_link_requested: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { method: ['link_form', 'signup_form'] },
+ },
+ trip_entry_submitted: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { day: campaignDays, method: ['link', 'screenshot', 'link_and_screenshot'] },
+ },
+ trip_picture_shared: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { method: ['share_sheet', 'download'] },
+ },
+ bingo_square_marked: { source: 'client', sites: ['lvwwd.org'], props: { marked: markedSquares } },
+ bingo_completed: { source: 'client', sites: ['lvwwd.org'], props: { lines: completedLines } },
+ bus_finder_used: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { method: ['my_location', 'place'] },
+ },
+ app_installed: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { method: ['browser', 'home_screen'] },
+ },
+ material_printed: {
+ source: 'client',
+ sites: ['lvwwd.org'],
+ props: { item: ['partner_flyer', 'bingo_card'] },
+ },
+ mail_in_viewed: { source: 'client', sites: ['lvwwd.org'], props: {} },
} as const;
export type EventName = keyof typeof EVENTS;
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
index b4b66c0..8035dc5 100644
--- a/packages/analytics/src/index.ts
+++ b/packages/analytics/src/index.ts
@@ -9,4 +9,4 @@ export {
export { shouldEnable, type GateInput, type GateReason, type GateResult } from './gate.js';
export { DEFAULT_COLLECTOR, init, type AnalyticsHandle, type InitOptions } from './init.js';
-export const VERSION = '0.1.0';
+export const VERSION = '0.2.0';
diff --git a/packages/analytics/src/standalone-runtime.ts b/packages/analytics/src/standalone-runtime.ts
index a568488..5e81174 100644
--- a/packages/analytics/src/standalone-runtime.ts
+++ b/packages/analytics/src/standalone-runtime.ts
@@ -1,15 +1,4 @@
-const placements = ['header', 'footer', 'hero', 'inline', 'dialog'];
-const features = [
- 'share_created',
- 'share_opened',
- 'export_png',
- 'export_svg',
- 'export_json',
- 'gtfs_import',
- 'sim_started',
- 'fuel_lever_moved',
- 'scenario_changed',
-];
+import { clientEvent, delegatedEvent } from './client-events.js';
interface StandaloneEnvironment {
hostname?: string;
@@ -30,17 +19,7 @@ function framed() {
}
}
-function valid(name: string, props: Record) {
- const key = name === 'tool_feature_used' ? 'feature' : 'placement';
- const values = key === 'feature' ? features : placements;
- return (
- (key === 'feature' || name === 'join_click' || name === 'donate_click') &&
- Object.keys(props).length === 1 &&
- values.includes(props[key] ?? '')
- );
-}
-
-// eslint-disable-next-line complexity -- Inline checks keep the standalone runtime under its budget.
+// eslint-disable-next-line complexity -- Inline gate checks keep the standalone runtime small.
export function startStandalone(
script: HTMLScriptElement | null,
environment: StandaloneEnvironment = {},
@@ -69,8 +48,7 @@ export function startStandalone(
const collector = script.dataset.lvbtCollector ?? 'https://events.lasvegasfortransit.org';
const sent = new Set();
const track = (name: string, props: Record) => {
- if (!valid(name, props)) throw new Error('Analytics event is not declared.');
- const body = JSON.stringify({ site, name, props });
+ const body = JSON.stringify(clientEvent(site, name as never, props as never));
if (sent.has(body)) return;
sent.add(body);
const blob = new Blob([body], { type: 'text/plain' });
@@ -95,10 +73,7 @@ export function startStandalone(
event.target instanceof Element
? event.target.closest('[data-lvbt-event]')
: null;
- if (!target) return;
- const name = target.dataset.lvbtEvent ?? '';
- const key = name === 'tool_feature_used' ? 'feature' : 'placement';
- const value = key === 'feature' ? target.dataset.lvbtFeature : target.dataset.lvbtPlacement;
- if (value && valid(name, { [key]: value })) track(name, { [key]: value });
+ const payload = target ? delegatedEvent(target) : undefined;
+ if (payload) track(payload.name, payload.props);
});
}
diff --git a/packages/analytics/tests/astro.test.ts b/packages/analytics/tests/astro.test.ts
index dc406a9..43d3a8c 100644
--- a/packages/analytics/tests/astro.test.ts
+++ b/packages/analytics/tests/astro.test.ts
@@ -15,7 +15,7 @@ test('reads the standard collector override from the build environment', async (
await integration.hooks['astro:config:setup']?.({
command: 'build',
- config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' },
+ config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} },
injectScript,
updateConfig: vi.fn(),
} as never);
@@ -26,7 +26,7 @@ test('reads the standard collector override from the build environment', async (
);
});
-test('injects the production client and prevents JavaScript inlining', async () => {
+test('injects the production client and keeps its script external', async () => {
process.env.PUBLIC_LVBT_CWA_TOKEN = 'a'.repeat(32);
const injectScript = vi.fn();
const updateConfig = vi.fn();
@@ -37,7 +37,7 @@ test('injects the production client and prevents JavaScript inlining', async ()
await integration.hooks['astro:config:setup']?.({
command: 'build',
- config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' },
+ config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} },
injectScript,
updateConfig,
} as never);
@@ -54,7 +54,43 @@ test('injects the production client and prevents JavaScript inlining', async ()
'page',
expect.stringContaining('new RegExp("^/archive/")'),
);
- expect(updateConfig).toHaveBeenCalledWith({ vite: { build: { assetsInlineLimit: 0 } } });
+ const limit = (
+ updateConfig.mock.calls[0]?.[0] as {
+ vite: {
+ build: { assetsInlineLimit: (path: string, content: Buffer) => boolean | undefined };
+ };
+ }
+ ).vite.build.assetsInlineLimit;
+ expect(limit('_astro/page.abc123.js', Buffer.from('init()'))).toBe(false);
+ expect(limit('_astro/index.abc123.css', Buffer.from('a{}'))).toBeUndefined();
+});
+
+test('keeps a site inlining limit for everything except scripts', async () => {
+ process.env.PUBLIC_LVBT_CWA_TOKEN = 'a'.repeat(32);
+ const updateConfig = vi.fn();
+ const integration = lvbtAnalytics({ site: 'labs.lasvegasfortransit.org' });
+
+ await integration.hooks['astro:config:setup']?.({
+ command: 'build',
+ config: {
+ root: new URL('file:///tmp/site/'),
+ envDir: '/tmp/site',
+ vite: { build: { assetsInlineLimit: 8 } },
+ },
+ injectScript: vi.fn(),
+ updateConfig,
+ } as never);
+
+ const limit = (
+ updateConfig.mock.calls[0]?.[0] as {
+ vite: {
+ build: { assetsInlineLimit: (path: string, content: Buffer) => boolean | undefined };
+ };
+ }
+ ).vite.build.assetsInlineLimit;
+ expect(limit('_astro/index.abc123.css', Buffer.from('a{}'))).toBe(true);
+ expect(limit('_astro/index.abc123.css', Buffer.from('a{color:red}'))).toBe(false);
+ expect(limit('_astro/page.abc123.js', Buffer.from('1'))).toBe(false);
});
test('requires the token only for guarded production builds', () => {
@@ -63,7 +99,7 @@ test('requires the token only for guarded production builds', () => {
expect(() =>
integration.hooks['astro:config:setup']?.({
command: 'build',
- config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' },
+ config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} },
injectScript: vi.fn(),
updateConfig: vi.fn(),
} as never),
diff --git a/packages/analytics/tests/events.test.ts b/packages/analytics/tests/events.test.ts
index 75a914f..ad7cd89 100644
--- a/packages/analytics/tests/events.test.ts
+++ b/packages/analytics/tests/events.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
-import { eventPayload } from '../src/events.js';
+import { clientEvent } from '../src/client-events.js';
+import { eventPayload, EVENTS } from '../src/events.js';
describe('event allowlist', () => {
test('accepts a declared event and enum property', () => {
@@ -27,4 +28,51 @@ describe('event allowlist', () => {
])('rejects undeclared event data', (payload) => {
expect(() => eventPayload(payload)).toThrow();
});
+
+ test.each([
+ { name: 'campaign_signup', props: {} },
+ { name: 'week_link_requested', props: { method: 'link_form' } },
+ { name: 'trip_entry_submitted', props: { day: '3', method: 'screenshot' } },
+ { name: 'trip_picture_shared', props: { method: 'share_sheet' } },
+ { name: 'bingo_square_marked', props: { marked: '24' } },
+ { name: 'bingo_completed', props: { lines: '12' } },
+ { name: 'bus_finder_used', props: { method: 'place' } },
+ { name: 'app_installed', props: { method: 'home_screen' } },
+ { name: 'material_printed', props: { item: 'partner_flyer' } },
+ { name: 'mail_in_viewed', props: {} },
+ ])('accepts the lvwwd.org campaign event $name', (event) => {
+ const payload = { site: 'lvwwd.org', ...event };
+ expect(eventPayload(payload)).toEqual(payload);
+ });
+
+ test.each([
+ { name: 'trip_entry_submitted', props: { day: '9', method: 'link' } },
+ { name: 'trip_entry_submitted', props: { day: '2' } },
+ {
+ name: 'trip_entry_submitted',
+ props: { day: '2', method: 'https://www.instagram.com/p/example' },
+ },
+ { name: 'campaign_signup', props: { contact: 'person@example.com' } },
+ { name: 'bingo_square_marked', props: { marked: '25' } },
+ ])('rejects campaign data outside the allowlist', (event) => {
+ expect(() => eventPayload({ site: 'lvwwd.org', ...event })).toThrow();
+ });
+
+ test('declares lvwwd.org events for lvwwd.org alone', () => {
+ const campaignEvents = Object.values(EVENTS).filter((event) =>
+ (event.sites as readonly string[]).includes('lvwwd.org'),
+ );
+ expect(campaignEvents.length).toBeGreaterThan(0);
+ for (const event of campaignEvents) expect(event.sites).toEqual(['lvwwd.org']);
+ });
+
+ test('keeps every property value a short enum label', () => {
+ for (const event of Object.values(EVENTS))
+ for (const values of Object.values(event.props) as Array)
+ for (const value of values) expect(value).toMatch(/^[a-z0-9_]{1,32}$/);
+ });
+
+ test('refuses to send a server-only event from the browser', () => {
+ expect(() => clientEvent('lasvegasfortransit.org', 'membership_intake', {} as never)).toThrow();
+ });
});
diff --git a/packages/analytics/tests/init.test.ts b/packages/analytics/tests/init.test.ts
index 7f01e47..02e9897 100644
--- a/packages/analytics/tests/init.test.ts
+++ b/packages/analytics/tests/init.test.ts
@@ -77,6 +77,24 @@ test('tracks a valid delegated event from a nested click target', () => {
expect(sendBeacon).toHaveBeenCalledOnce();
});
+test('reads each declared property of a delegated event from its data attribute', () => {
+ vi.spyOn(document.head, 'append').mockImplementation(() => undefined);
+ const sendBeacon = vi.fn(() => true);
+ Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: sendBeacon });
+ init({ site: 'test.example', token: 'a'.repeat(32) });
+ const print = document.createElement('button');
+ print.dataset.lvbtEvent = 'material_printed';
+ print.dataset.lvbtItem = 'bingo_card';
+ const mail = document.createElement('a');
+ mail.dataset.lvbtEvent = 'mail_in_viewed';
+ document.body.append(print, mail);
+
+ print.click();
+ mail.click();
+
+ expect(sendBeacon).toHaveBeenCalledTimes(2);
+});
+
test('falls back to keepalive fetch when sendBeacon declines the event', () => {
vi.spyOn(document.head, 'append').mockImplementation(() => undefined);
Object.defineProperty(navigator, 'sendBeacon', {
diff --git a/packages/analytics/tests/publishing.test.ts b/packages/analytics/tests/publishing.test.ts
index 9376f59..96571c6 100644
--- a/packages/analytics/tests/publishing.test.ts
+++ b/packages/analytics/tests/publishing.test.ts
@@ -27,7 +27,7 @@ test('publishes from the canonical repository through GitHub Packages', async ()
expect(workflow).not.toMatch(/(^|\s)npm publish/);
expect(workflow).toContain('packages: write');
expect(workflow).toContain('NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
- expect(packageJson.version).toBe('0.1.0');
+ expect(packageJson.version).toBe('0.2.0');
expect(VERSION).toBe(packageJson.version);
expect(packageJson.bin).toEqual({ 'lvbt-analytics': 'dist/cli/index.mjs' });
expect(packageJson.files).toContain('LICENSE');
diff --git a/packages/analytics/tests/standalone.test.ts b/packages/analytics/tests/standalone.test.ts
index 6816c5e..36c380c 100644
--- a/packages/analytics/tests/standalone.test.ts
+++ b/packages/analytics/tests/standalone.test.ts
@@ -45,3 +45,25 @@ test('does not initialize when a privacy signal is enabled', () => {
expect((window as Window & { lvbt?: unknown }).lvbt).toBeUndefined();
expect(document.querySelector('[data-lvbt-analytics]')).toBeNull();
});
+
+test('checks classic-script events against the shared allowlist', () => {
+ vi.spyOn(document.head, 'append').mockImplementation(() => undefined);
+ const sendBeacon = vi.fn(() => true);
+ Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: sendBeacon });
+ const script = document.createElement('script');
+ script.dataset.lvbtSite = 'lvwwd.org';
+ script.dataset.lvbtToken = 'a'.repeat(32);
+
+ startStandalone(script, { hostname: 'lvwwd.org', gpc: false, dnt: false, framed: false });
+ const analytics = (window as Window & { lvbt?: { track: (...args: unknown[]) => void } }).lvbt;
+ analytics?.track('trip_entry_submitted', { day: '4', method: 'link' });
+
+ expect(sendBeacon).toHaveBeenCalledOnce();
+ expect(() => analytics?.track('trip_entry_submitted', { day: '4', method: 'free text' })).toThrow(
+ 'not allowed',
+ );
+ expect(() => analytics?.track('newsletter_signup', { method: 'site_form' })).toThrow(
+ 'not declared',
+ );
+ expect(sendBeacon).toHaveBeenCalledOnce();
+});