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
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