also looking at this
feat(stx): one-line stx integration (plugins: ['@stacksjs/ts-analytics']) + fix library build
#123
11 files
+331
-13
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -41,6 +41,31 @@ The validated pattern for adding ts-analytics to any site (docs sites included): | ||
| 41 | 41 | |
| 42 | 42 | 3. **Optional hardening**: append `?stealth=true` for the unlisted beacon path, or serve it first-party from your own domain — see the [ad-blocker resilience guide](docs/guide/tracking-script.md#ad-blocker-resilience-first-party-proxy). Error tracking without analytics: grab the DSN + SDK snippet from the same Settings page. |
| 43 | 43 | |
| 44 | ### stx framework (one config line) | |
| 45 | ||
| 46 | On an [stx](https://github.com/stacksjs/stx) app there's no `index.html` to paste a snippet into. Add the plugin and your app ID — that's the whole install (mirrors `nuxt-fathom`): | |
| 47 | ||
| 48 | ```ts | |
| 49 | // stx.config.ts | |
| 50 | export default { | |
| 51 | plugins: ['@stacksjs/ts-analytics'], | |
| 52 | analytics: { | |
| 53 | appId: 'YOUR_APP_ID', | |
| 54 | // apiEndpoint defaults to '/api/analytics' (events POST to `${apiEndpoint}/collect`) | |
| 55 | }, | |
| 56 | } | |
| 57 | ``` | |
| 58 | ||
| 59 | It injects the SPA-aware tracker before `</head>` on every server-rendered page — pageviews fire on the first load **and** on client-side (`<StxLink>`) navigations, since the tracker patches `history.pushState`. | |
| 60 | ||
| 61 | You can also pass options inline: `plugins: [['@stacksjs/ts-analytics', { appId: 'YOUR_APP_ID' }]]`. | |
| 62 | ||
| 63 | Notes: | |
| 64 | ||
| 65 | - **The plugin is the package's default export**, so `plugins: ['@stacksjs/ts-analytics']` resolves to it directly. Non-stx code should use the **named** exports (`import { Analytics } from '@stacksjs/ts-analytics'`). | |
| 66 | - **It owns the `analytics` config key** and is mutually exclusive with stx's native single-driver analytics — if you've set `analytics.driver` to a native provider (`fathom`, `plausible`, …), ts-analytics backs off (and logs a warning). | |
| 67 | - **Mount a collector** for `${apiEndpoint}/collect` (e.g. `mountAnalyticsRoutes` / `createBunRouter` from this package), or point `apiEndpoint` at a hosted ts-analytics instance — otherwise beacons hit a 404. | |
| 68 | ||
| 44 | 69 | ## Quick Start |
| 45 | 70 | |
| 46 | 71 | ### 1. Set Up the Analytics Store |
| @@ -0,0 +1,17 @@ | ||
| 1 | #!/usr/bin/env bun | |
| 2 | /** | |
| 3 | * Static dashboard SITE build (deploy artifact for Netlify / Vercel / S3 / | |
| 4 | * the lambda views) → dist-site/. This is NOT the npm package output — the | |
| 5 | * importable library is built by build.ts (`bun run build`) into dist/. | |
| 6 | * | |
| 7 | * Run with `bun run build:site`. | |
| 8 | */ | |
| 9 | import { generateStaticSite } from '@stacksjs/stx/ssg' | |
| 10 | ||
| 11 | await generateStaticSite({ | |
| 12 | pagesDir: 'pages', | |
| 13 | outputDir: 'dist-site', | |
| 14 | publicDir: 'public', | |
| 15 | sitemap: true, | |
| 16 | cleanOutput: true, | |
| 17 | }) | |
| @@ -1,10 +1,50 @@ | ||
| 1 | 1 | #!/usr/bin/env bun |
| 2 | import { generateStaticSite } from '@stacksjs/stx/ssg' | |
| 3 | ||
| 4 | await generateStaticSite({ | |
| 5 | pagesDir: 'pages', | |
| 6 | outputDir: 'dist', | |
| 7 | publicDir: 'public', | |
| 8 | sitemap: true, | |
| 9 | cleanOutput: true, | |
| 2 | /** | |
| 3 | * Library build — emits dist/index.js (+ every module under ./*) and | |
| 4 | * declarations, so `@stacksjs/ts-analytics` is importable as a package | |
| 5 | * (package.json `module`/`exports`/`types` all point at ./dist). | |
| 6 | * | |
| 7 | * The static dashboard SITE is a separate deploy artifact — see build-site.ts | |
| 8 | * (`bun run build:site` → dist-site/). Keeping them apart stops the SSG's | |
| 9 | * cleanOutput from wiping the library and vice-versa. | |
| 10 | * | |
| 11 | * Mirrors the sibling stacks library builds: every source module is built so | |
| 12 | * the `./*` subpath export resolves to real JS (not just a .d.ts stub), with | |
| 13 | * node_modules left external (`packages: 'external'`). | |
| 14 | */ | |
| 15 | import { rmSync } from 'node:fs' | |
| 16 | import { dts } from 'bun-plugin-dtsx' | |
| 17 | import stx from 'bun-plugin-stx' | |
| 18 | ||
| 19 | rmSync('./dist', { recursive: true, force: true }) | |
| 20 | ||
| 21 | // Build from the public entry only and let Bun follow the import graph, so the | |
| 22 | // dashboard-UI shims (src/components/dashboard re-exports repo-root `.stx`) that | |
| 23 | // aren't part of the library API are excluded automatically. `splitting` keeps | |
| 24 | // shared code in chunks instead of duplicating it into index.js. | |
| 25 | const result = await Bun.build({ | |
| 26 | entrypoints: ['./src/index.ts'], | |
| 27 | splitting: true, | |
| 28 | outdir: './dist', | |
| 29 | root: './src', | |
| 30 | // bun-plugin-stx resolves the `.stx` imports a few library modules carry | |
| 31 | // (e.g. handlers render dashboard views server-side); dts() emits declarations. | |
| 32 | plugins: [stx(), dts()], | |
| 33 | target: 'bun', | |
| 34 | format: 'esm', | |
| 35 | packages: 'external', | |
| 10 | 36 | }) |
| 37 | ||
| 38 | if (!result.success) { | |
| 39 | console.error('[ts-analytics] library build failed:') | |
| 40 | for (const log of result.logs) | |
| 41 | console.error(log) | |
| 42 | process.exit(1) | |
| 43 | } | |
| 44 | ||
| 45 | const hasIndex = await Bun.file('./dist/index.js').exists() | |
| 46 | if (!hasIndex) { | |
| 47 | console.error('[ts-analytics] build succeeded but dist/index.js is missing — check src/index.ts') | |
| 48 | process.exit(1) | |
| 49 | } | |
| 50 | console.log(`[ts-analytics] library built → dist/ (${result.outputs.length} files, dist/index.js present)`) | |
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | #!/usr/bin/env bun |
| 2 | 2 | /** |
| 3 | * Serves the static `dist/` build locally for testing before deploying | |
| 4 | * to Netlify / Vercel / S3. Plain file server, no processing. | |
| 3 | * Serves the static site build (`dist-site/`, produced by `bun run build:site`) | |
| 4 | * locally for testing before deploying to Netlify / Vercel / S3. Plain file | |
| 5 | * server, no processing. (The npm library build lives in `dist/` — see build.ts.) | |
| 5 | 6 | * |
| 6 | 7 | * Usage: bun run preview |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | const port = Number(process.argv[2]) || 3001 |
| 10 | const distDir = 'dist' | |
| 11 | const distDir = 'dist-site' | |
| 11 | 12 | |
| 12 | 13 | Bun.serve({ |
| 13 | 14 | port, |
| @@ -363,3 +363,8 @@ export { | ||
| 363 | 363 | lookupFromHeaders, |
| 364 | 364 | lookupIP, |
| 365 | 365 | } from './geolocation' |
| 366 | ||
| 367 | // stx integration. The plugin is the package's DEFAULT export so stx resolves | |
| 368 | // `plugins: ['@stacksjs/ts-analytics']` to it directly (no subpath). Config is | |
| 369 | // read from the top-level `analytics: { appId }` key. See ./integrations/stx. | |
| 370 | export { default, type StxAnalyticsConfig, stxPlugin } from './integrations/stx' | |
| @@ -0,0 +1,153 @@ | ||
| 1 | /** | |
| 2 | * stx integration for ts-analytics. | |
| 3 | * | |
| 4 | * The whole install is: add the plugin and give it your app ID — mirroring | |
| 5 | * the nuxt-fathom one-liner (register the module + provide an ID). | |
| 6 | * | |
| 7 | * ```ts | |
| 8 | * // stx.config.ts | |
| 9 | * export default { | |
| 10 | * plugins: ['@stacksjs/ts-analytics'], | |
| 11 | * analytics: { | |
| 12 | * appId: 'YOUR_APP_ID', | |
| 13 | * // apiEndpoint defaults to '/api/analytics' | |
| 14 | * }, | |
| 15 | * } | |
| 16 | * ``` | |
| 17 | * | |
| 18 | * It injects ts-analytics' SPA-aware tracking script before `</head>` on every | |
| 19 | * server-rendered page. The tracker patches `history.pushState`/`replaceState`, | |
| 20 | * so it counts client-side (SPA) navigations after the first load — not just | |
| 21 | * full reloads. This is exposed as the package's DEFAULT export, so stx | |
| 22 | * resolves `plugins: ['@stacksjs/ts-analytics']` to it directly (no subpath). | |
| 23 | * | |
| 24 | * Config is read from the top-level `analytics` key (the same way nuxt-fathom | |
| 25 | * reads `fathom:`); you can also pass options inline on the plugin entry: | |
| 26 | * | |
| 27 | * ```ts | |
| 28 | * plugins: [['@stacksjs/ts-analytics', { appId: 'YOUR_APP_ID' }]] | |
| 29 | * ``` | |
| 30 | */ | |
| 31 | import type { TrackingScriptConfig } from '../tracking-script' | |
| 32 | import { generateFullTrackingScript } from '../tracking-script' | |
| 33 | ||
| 34 | /** | |
| 35 | * ts-analytics config for stx — the shape of the top-level `analytics` key. | |
| 36 | * Extends the tracker config but swaps the internal `siteId` for the | |
| 37 | * consumer-facing `appId`, and adds an `apiEndpoint` default. | |
| 38 | */ | |
| 39 | export interface StxAnalyticsConfig extends Partial<Omit<TrackingScriptConfig, 'siteId' | 'apiEndpoint'>> { | |
| 40 | /** Your ts-analytics app ID (the site/app identifier). Required to track. */ | |
| 41 | appId?: string | |
| 42 | /** Collector base URL. POSTs land at `${apiEndpoint}/collect`. Default: `/api/analytics`. */ | |
| 43 | apiEndpoint?: string | |
| 44 | /** Set `false` to disable without removing the plugin from the list. */ | |
| 45 | enabled?: boolean | |
| 46 | } | |
| 47 | ||
| 48 | /** Minimal shape of the stx plugin setup context (avoids a hard dep on stx types). */ | |
| 49 | interface StxPluginContext { | |
| 50 | config?: { | |
| 51 | analytics?: StxAnalyticsConfig & { | |
| 52 | enabled?: boolean | |
| 53 | driver?: string | |
| 54 | custom?: { inlineScript?: string, scriptUrl?: string, __tsAnalytics?: boolean } | |
| 55 | } | |
| 56 | } & Record<string, unknown> | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * The stx plugin object. Exported by name for explicit imports; also the | |
| 61 | * package default export (see `src/index.ts`) so `plugins: ['@stacksjs/ts-analytics']` | |
| 62 | * resolves to it. | |
| 63 | */ | |
| 64 | export const stxPlugin = { | |
| 65 | name: 'ts-analytics', | |
| 66 | setup(options: StxAnalyticsConfig | undefined, stx: StxPluginContext): void { | |
| 67 | // Merge the top-level `analytics` config with any inline plugin options | |
| 68 | // (inline wins). Makes BOTH styles work: | |
| 69 | // plugins: ['@stacksjs/ts-analytics'], analytics: { appId } | |
| 70 | // plugins: [['@stacksjs/ts-analytics', { appId }]] | |
| 71 | const cfg: StxAnalyticsConfig = { ...(stx.config?.analytics ?? {}), ...(options ?? {}) } | |
| 72 | ||
| 73 | if (cfg.enabled === false) | |
| 74 | return | |
| 75 | ||
| 76 | const appId = cfg.appId ?? (cfg as { siteId?: string }).siteId | |
| 77 | if (!appId) { | |
| 78 | console.warn( | |
| 79 | '[ts-analytics] no `appId` configured — add `analytics: { appId: \'…\' }` to stx.config.ts. Skipping tracker injection.', | |
| 80 | ) | |
| 81 | return | |
| 82 | } | |
| 83 | ||
| 84 | // Validate before these values get interpolated into the inline tracker. | |
| 85 | // They're developer config, but a stray quote/backslash/newline would | |
| 86 | // silently break the whole tracker, and a `</script>` would close the | |
| 87 | // injected <script> element (the HTML parser ignores JS string context). | |
| 88 | // Warn-and-skip — matching the missing-appId path — beats a broken page. | |
| 89 | if (!/^[\w.-]+$/.test(String(appId))) { | |
| 90 | console.warn( | |
| 91 | `[ts-analytics] appId '${appId}' has unexpected characters (allowed: A–Z a–z 0–9 . _ -). Skipping tracker injection.`, | |
| 92 | ) | |
| 93 | return | |
| 94 | } | |
| 95 | // Strip trailing slashes so we never emit `${apiEndpoint}//collect`, then | |
| 96 | // reject anything that could still break out of the literal / <script>. | |
| 97 | const apiEndpoint = (cfg.apiEndpoint ?? '/api/analytics').replace(/\/+$/, '') | |
| 98 | if (/[<>"'\\\n\r]/.test(apiEndpoint)) { | |
| 99 | console.warn( | |
| 100 | `[ts-analytics] apiEndpoint '${apiEndpoint}' contains unsafe characters. Skipping tracker injection.`, | |
| 101 | ) | |
| 102 | return | |
| 103 | } | |
| 104 | ||
| 105 | // Everything except the integration-only keys flows through to the tracker | |
| 106 | // (honorDnt, trackOutboundLinks, heatmap, excludePaths, …). `siteId` is | |
| 107 | // dropped explicitly so a stray value can't shadow appId regardless of key order. | |
| 108 | const { appId: _appId, apiEndpoint: _apiEndpoint, enabled: _enabled, siteId: _siteId, ...trackerOpts } | |
| 109 | = cfg as StxAnalyticsConfig & { siteId?: string } | |
| 110 | const trackerJs = generateFullTrackingScript({ | |
| 111 | autoPageView: true, | |
| 112 | ...trackerOpts, | |
| 113 | siteId: appId, | |
| 114 | apiEndpoint, | |
| 115 | }) | |
| 116 | ||
| 117 | // stx's native analytics injection (injectAnalytics) is the right home for | |
| 118 | // this: it runs at process.ts ~1356, and the client-script transform runs | |
| 119 | // LATER (~1482). So our injected inline <script> IS re-wrapped into a | |
| 120 | // `<script data-stx-scoped>;(function(){'use strict'; … })()`, and because | |
| 121 | // the SPA fragment extractor re-runs every data-stx-scoped script, it is | |
| 122 | // re-executed on every client-side navigation. That re-execution is exactly | |
| 123 | // why the `if(!window.__tsAnalytics)` guard below is LOAD-BEARING (not | |
| 124 | // defensive): it keeps history.pushState patched once and avoids | |
| 125 | // double-counting. (The wrapper carries no `window.stx` destructure, so it's | |
| 126 | // safe on no-signals pages.) We adapt the simple `{ appId }` config into | |
| 127 | // stx's single-driver native analytics: { enabled, driver:'custom', custom }. | |
| 128 | stx.config ??= {} | |
| 129 | stx.config.analytics ??= {} | |
| 130 | const a = stx.config.analytics | |
| 131 | // Evaluate the conflict against the EFFECTIVE config (analytics + inline | |
| 132 | // options), and treat our own prior injection as a no-op — so HMR / a re-run | |
| 133 | // of setup neither warns nor double-injects. | |
| 134 | const effectiveDriver = (cfg as { driver?: string }).driver | |
| 135 | if ((effectiveDriver && effectiveDriver !== 'custom') || (a.custom && !a.custom.__tsAnalytics)) { | |
| 136 | console.warn( | |
| 137 | `[ts-analytics] a native analytics provider is already configured (${effectiveDriver ? `driver: '${effectiveDriver}'` : 'analytics.custom'}); ts-analytics tracker NOT injected.`, | |
| 138 | ) | |
| 139 | return | |
| 140 | } | |
| 141 | a.enabled = true | |
| 142 | a.driver = 'custom' | |
| 143 | // if-block guard (not a top-level `return`, which is illegal inside <script>); | |
| 144 | // load-bearing — see above. `__tsAnalytics: true` marks the block as ours so | |
| 145 | // a re-run of setup is an idempotent no-op. | |
| 146 | a.custom = { | |
| 147 | __tsAnalytics: true, | |
| 148 | inlineScript: `if(!window.__tsAnalytics){window.__tsAnalytics=1;\n${trackerJs}\n}`, | |
| 149 | } | |
| 150 | }, | |
| 151 | } | |
| 152 | ||
| 153 | export default stxPlugin | |
| @@ -187,10 +187,14 @@ function buildScript(config: TrackingScriptConfig): string { | ||
| 187 | 187 | } |
| 188 | 188 | |
| 189 | 189 | function buildConfigSection(config: TrackingScriptConfig): string { |
| 190 | // JSON.stringify the string values (matching excludePaths below) so a quote, | |
| 191 | // backslash, or newline in siteId/apiEndpoint can't break out of the literal | |
| 192 | // and corrupt the whole inline tracker. (A literal `</script>` still has to be | |
| 193 | // rejected upstream — the HTML parser closes <script> regardless of JS context.) | |
| 190 | 194 | return `// Configuration |
| 191 | 195 | var CONFIG = { |
| 192 | siteId: '${config.siteId}', | |
| 193 | endpoint: '${config.apiEndpoint}/collect', | |
| 196 | siteId: ${JSON.stringify(config.siteId)}, | |
| 197 | endpoint: ${JSON.stringify(`${config.apiEndpoint}/collect`)}, | |
| 194 | 198 | honorDnt: ${config.honorDnt ?? true}, |
| 195 | 199 | sessionTimeout: ${(config.sessionTimeout ?? 30) * 60 * 1000}, |
| 196 | 200 | debug: ${config.debug ?? false}, |
| @@ -0,0 +1,66 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { processDirectives } from '@stacksjs/stx' | |
| 3 | import stxPlugin from '../src/integrations/stx' | |
| 4 | ||
| 5 | // The plugin adapts `{ appId }` into stx's native single-driver analytics | |
| 6 | // (enabled + driver:'custom' + custom.inlineScript) so the tracker injects via | |
| 7 | // injectAnalytics. stx reprocesses that inline <script> into a data-stx-scoped | |
| 8 | // IIFE and re-runs it on every SPA navigation — so the `if(!window.__tsAnalytics)` | |
| 9 | // guard is LOAD-BEARING. These tests pin that contract (and would have caught | |
| 10 | // the inverted-comment drift the review flagged). | |
| 11 | function setup(analytics: Record<string, unknown>, options?: Record<string, unknown>) { | |
| 12 | const config: any = { analytics } | |
| 13 | stxPlugin.setup(options as any, { config }) | |
| 14 | return config | |
| 15 | } | |
| 16 | ||
| 17 | describe('ts-analytics stx integration', () => { | |
| 18 | it('adapts { appId } into native analytics and injects the tracker before </head>', async () => { | |
| 19 | const config = setup({ appId: 'app_123' }) | |
| 20 | expect(config.analytics.enabled).toBe(true) | |
| 21 | expect(config.analytics.driver).toBe('custom') | |
| 22 | expect(config.analytics.custom.inlineScript).toContain('app_123') | |
| 23 | expect(config.analytics.custom.inlineScript).toContain('/api/analytics/collect') | |
| 24 | ||
| 25 | const page = `<!doctype html><html><head><title>D</title></head><body></body></html>` | |
| 26 | const out = await processDirectives(page, {}, 'page.stx', config, new Set()) | |
| 27 | const tracker = out.indexOf('__tsAnalytics') | |
| 28 | expect(tracker).toBeGreaterThan(-1) | |
| 29 | expect(tracker).toBeLessThan(out.indexOf('</head>')) | |
| 30 | }) | |
| 31 | ||
| 32 | it('wraps the tracker in the load-bearing idempotency guard', () => { | |
| 33 | const config = setup({ appId: 'app_g' }) | |
| 34 | expect(config.analytics.custom.inlineScript.startsWith('if(!window.__tsAnalytics)')).toBe(true) | |
| 35 | expect(config.analytics.custom.__tsAnalytics).toBe(true) | |
| 36 | }) | |
| 37 | ||
| 38 | it('normalizes a trailing slash on apiEndpoint (no //collect)', () => { | |
| 39 | const s = setup({ appId: 'x', apiEndpoint: '/api/analytics/' }).analytics.custom.inlineScript | |
| 40 | expect(s).toContain('/api/analytics/collect') | |
| 41 | expect(s).not.toContain('//collect') | |
| 42 | }) | |
| 43 | ||
| 44 | it('rejects an appId / apiEndpoint that could break out of the inline script', () => { | |
| 45 | expect(setup({ appId: 'x\');alert(1)//' }).analytics.custom).toBeUndefined() | |
| 46 | expect(setup({ appId: 'ok', apiEndpoint: '/a</script>' }).analytics.custom).toBeUndefined() | |
| 47 | }) | |
| 48 | ||
| 49 | it('is an idempotent no-op when setup runs again (HMR / re-load)', () => { | |
| 50 | const config: any = { analytics: { appId: 'app_r' } } | |
| 51 | stxPlugin.setup(undefined, { config }) | |
| 52 | const first = config.analytics.custom.inlineScript | |
| 53 | stxPlugin.setup(undefined, { config }) | |
| 54 | expect(config.analytics.custom.inlineScript).toBe(first) | |
| 55 | }) | |
| 56 | ||
| 57 | it('skips when no appId is configured, and backs off an explicit native driver', () => { | |
| 58 | expect(setup({}).analytics.custom).toBeUndefined() | |
| 59 | expect(setup({ appId: 'x' }, { appId: 'x', driver: 'fathom' }).analytics.custom).toBeUndefined() | |
| 60 | }) | |
| 61 | ||
| 62 | it('is exposed as the package default export (plugins: [\'@stacksjs/ts-analytics\'])', () => { | |
| 63 | expect(stxPlugin.name).toBe('ts-analytics') | |
| 64 | expect(typeof stxPlugin.setup).toBe('function') | |
| 65 | }) | |
| 66 | }) | |