ReviewOS

also looking at this

stacks/ts-analytics

feat(stx): one-line stx integration (plugins: ['@stacksjs/ts-analytics']) + fix library build

#123
Closed glennmichael123 wants to merge feat/stx-integration into main
11 files +331 -13
.gitignoremodified+1-0
Changes to .gitignore
@@ -5,6 +5,7 @@
55*.tgz
66coverage
77dist
8dist-site
89lib-cov
910logs
1011node_modules
README.mdmodified+25-0
Changes to README.md
@@ -41,6 +41,31 @@ The validated pattern for adding ts-analytics to any site (docs sites included):
4141
42423. **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.
4343
44### stx framework (one config line)
45
46On 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
50export 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
59It 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
61You can also pass options inline: `plugins: [['@stacksjs/ts-analytics', { appId: 'YOUR_APP_ID' }]]`.
62
63Notes:
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
4469## Quick Start
4570
4671### 1. Set Up the Analytics Store
build-site.tsadded+17-0
Changes to build-site.ts
@@ -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 */
9import { generateStaticSite } from '@stacksjs/stx/ssg'
10
11await generateStaticSite({
12 pagesDir: 'pages',
13 outputDir: 'dist-site',
14 publicDir: 'public',
15 sitemap: true,
16 cleanOutput: true,
17})
build.tsmodified+48-8
Changes to build.ts
@@ -1,10 +1,50 @@
11#!/usr/bin/env bun
2import { generateStaticSite } from '@stacksjs/stx/ssg'
3
4await 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 */
15import { rmSync } from 'node:fs'
16import { dts } from 'bun-plugin-dtsx'
17import stx from 'bun-plugin-stx'
18
19rmSync('./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.
25const 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',
1036})
37
38if (!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
45const hasIndex = await Bun.file('./dist/index.js').exists()
46if (!hasIndex) {
47 console.error('[ts-analytics] build succeeded but dist/index.js is missing — check src/index.ts')
48 process.exit(1)
49}
50console.log(`[ts-analytics] library built → dist/ (${result.outputs.length} files, dist/index.js present)`)
package.jsonmodified+1-0
Changes to package.json
@@ -42,6 +42,7 @@
4242 "scripts": {
4343 "analytics": "bun ./bin/cli.ts",
4444 "build": "bun build.ts",
45 "build:site": "bun build-site.ts",
4546 "preview": "bun preview.ts",
4647 "dev": "bun --watch serve.ts",
4748 "dev:server": "bun --watch ./server/index.ts",
preview.tsmodified+4-3
Changes to preview.ts
@@ -1,13 +1,14 @@
11#!/usr/bin/env bun
22/**
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.)
56 *
67 * Usage: bun run preview
78 */
89
910const port = Number(process.argv[2]) || 3001
10const distDir = 'dist'
11const distDir = 'dist-site'
1112
1213Bun.serve({
1314 port,
src/index.tsmodified+5-0
Changes to src/index.ts
@@ -363,3 +363,8 @@ export {
363363 lookupFromHeaders,
364364 lookupIP,
365365} 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.
370export { default, type StxAnalyticsConfig, stxPlugin } from './integrations/stx'
src/integrations/index.tsmodified+5-0
Changes to src/integrations/index.ts
@@ -18,3 +18,8 @@ export {
1818 type CloudflareHandlerOptions,
1919 type StorageAdapter,
2020} from './cloudflare'
21
22export {
23 default as stxPlugin,
24 type StxAnalyticsConfig,
25} from './stx'
src/integrations/stx.tsadded+153-0
Changes to src/integrations/stx.ts
@@ -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 */
31import type { TrackingScriptConfig } from '../tracking-script'
32import { 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 */
39export 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). */
49interface 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 */
64export 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
153export default stxPlugin
src/tracking-script.tsmodified+6-2
Changes to src/tracking-script.ts
@@ -187,10 +187,14 @@ function buildScript(config: TrackingScriptConfig): string {
187187}
188188
189189function 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.)
190194 return `// Configuration
191195var CONFIG = {
192 siteId: '${config.siteId}',
193 endpoint: '${config.apiEndpoint}/collect',
196 siteId: ${JSON.stringify(config.siteId)},
197 endpoint: ${JSON.stringify(`${config.apiEndpoint}/collect`)},
194198 honorDnt: ${config.honorDnt ?? true},
195199 sessionTimeout: ${(config.sessionTimeout ?? 30) * 60 * 1000},
196200 debug: ${config.debug ?? false},
test/stx-integration.test.tsadded+66-0
Changes to test/stx-integration.test.ts
@@ -0,0 +1,66 @@
1import { describe, expect, it } from 'bun:test'
2import { processDirectives } from '@stacksjs/stx'
3import 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).
11function 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
17describe('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})