From cf55559be3c7514d5adaf1ee23a4b1b1b726f3b9 Mon Sep 17 00:00:00 2001 From: Alex Dolid Date: Sun, 23 Aug 2026 18:27:45 +0300 Subject: [PATCH] docs: add Vercel Analytics and Speed Insights to the VitePress site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire @vercel/analytics and @vercel/speed-insights into the docs theme via enhanceApp. Uses the framework-agnostic inject() / injectSpeedInsights() entrypoints rather than the @vercel/*/vue components: those declare a vue-router peer, and VitePress ships its own router. Both imports are dynamic and guarded by import.meta.env.SSR so nothing reaches the SSR pass of docs:build — they land in their own client chunks. Analytics picks up VitePress' pushState navigations by itself; Speed Insights does not, so its setRoute is chained onto router.onAfterRouteChange (chained, not replaced, to leave any existing handler intact). Both are devDependencies — docs-only, like vitepress itself. The zero-dependency guarantee is about `dependencies`, which stays absent. Data only flows once Web Analytics and Speed Insights are enabled on the Vercel project. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 + docs/.vitepress/theme/index.ts | 25 ++++++++++++ package.json | 2 + pnpm-lock.yaml | 69 ++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 5752d9c..9183d6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,3 +114,5 @@ VitePress 1.x site deployed to Vercel (`vercel.json` pins the build command/outp **Diagrams are mermaid**, through `vitepress-plugin-mermaid` (already wired: the config is wrapped in `withMermaid`). Write them as ` ```mermaid ` fences. Three constraints that are easy to rediscover the hard way: the config must **not** set `mermaid.theme` (the plugin picks `default`/`dark` from VitePress' `isDark` and re-renders on toggle; setting it freezes diagrams in one theme); mermaid's CJS-only transitive deps (`dayjs`) need `vite.optimizeDeps.include` or the dev server fails to boot; and `.mermaid` label `line-height` must be pinned to 1.25 in `custom.css` or multi-line labels overflow the shapes mermaid measured them into. Check a new diagram in **both** themes and at mobile width. **Branding** lives in `docs/public/` (`logo-mark.svg` — the mark: a Node.js hexagon holding the two directions of an RPC — `favicon.png`, `logo.png` for `og:image`, `robots.txt`, `llms.txt`) and `docs/.vitepress/theme/custom.css` (Node.js green `#5FA04E` on ink `#1B1C1E`, applied by overriding VitePress CSS variables — the Pinia/Vite pattern, layout untouched). Light-theme `--vp-c-brand-1/-2` are **darkened** greens because `#5FA04E` fails WCAG AA as link text on white. The raster assets are generated from the mark by `node scripts/brand.js` (macOS `qlmanage`; re-run it after changing `logo-mark.svg`). + +**Analytics** is Vercel Web Analytics + Speed Insights, wired in `docs/.vitepress/theme/index.ts` via `enhanceApp`. Both use the **framework-agnostic** `inject()`/`injectSpeedInsights()` entrypoints rather than the `@vercel/*/vue` components, because those declare a `vue-router` peer and VitePress ships its own router; the imports are dynamic and guarded by `import.meta.env.SSR` so nothing reaches the SSR pass of `docs:build`. Analytics picks up VitePress' pushState navigations by itself, Speed Insights does not — its `setRoute` is chained onto `router.onAfterRouteChange` (chained, not replaced). Both are devDependencies (docs-only, like `vitepress` itself — the zero-dependency guarantee is about `dependencies`), and data only flows once Web Analytics and Speed Insights are enabled on the Vercel project. diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 061e231..2cf5522 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -7,4 +7,29 @@ import './custom.css'; // layout and components stay the default theme, the look comes from CSS variables. export default { extends: DefaultTheme, + enhanceApp({ router }) { + // Vercel Web Analytics + Speed Insights. The framework-agnostic `inject` + // entrypoints are used rather than the `/vue` components: those require + // `vue-router`, which VitePress does not use (it ships its own router). + // Both are browser-only, so they are imported dynamically to stay out of + // the SSR build that `pnpm docs:build` runs. + if (import.meta.env.SSR) return; + void import('@vercel/analytics').then(({ inject }) => { + // `mode: 'auto'` logs to the console on localhost and only sends data + // from the deployed site; the injected script picks up VitePress' + // pushState navigations on its own, so no per-route call is needed. + inject({ mode: 'auto', framework: 'vitepress' }); + }); + void import('@vercel/speed-insights').then(({ injectSpeedInsights }) => { + const insights = injectSpeedInsights({ framework: 'vitepress' }); + if (!insights) return; + // Speed Insights does need the route pushed to it by hand. Chain onto any + // existing handler instead of replacing it. + const previous = router.onAfterRouteChange; + router.onAfterRouteChange = (to) => { + insights.setRoute(to); + return previous?.call(router, to); + }; + }); + }, } satisfies Theme; diff --git a/package.json b/package.json index ab119b6..3e96c7c 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,8 @@ "@trpc/client": "^11.18.0", "@trpc/server": "^11.18.0", "@types/node": "^22.10.0", + "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", "c8": "^10.1.3", "esbuild": "^0.28.1", "express": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f6c30e..8b7fc1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.20.1 + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(vue@3.5.41(typescript@7.0.2)) + '@vercel/speed-insights': + specifier: ^2.0.0 + version: 2.0.0(vue@3.5.41(typescript@7.0.2)) c8: specifier: ^10.1.3 version: 10.1.3 @@ -1361,6 +1367,61 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + + '@vercel/speed-insights@2.0.0': + resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==} + peerDependencies: + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4092,6 +4153,14 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/analytics@2.0.1(vue@3.5.41(typescript@7.0.2))': + optionalDependencies: + vue: 3.5.41(typescript@7.0.2) + + '@vercel/speed-insights@2.0.0(vue@3.5.41(typescript@7.0.2))': + optionalDependencies: + vue: 3.5.41(typescript@7.0.2) + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@22.20.1))(vue@3.5.41(typescript@7.0.2))': dependencies: vite: 5.4.21(@types/node@22.20.1)