also looking at this
feat(stx): one-line stx integration (plugins: ['@stacksjs/ts-analytics']) + fix library build
#123
11 files
+331
-13
| @@ -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 | |