From 006b9b600450492f20b0f33b725514a956b2147c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 31 Aug 2026 14:52:13 +0530 Subject: [PATCH] feat: tag sidebar links with utm params instead of ref Sidebar footer links previously carried a custom `ref` query param holding the full URL of the page clicked from. No analytics tool recognises `ref` natively, so attributing those clicks meant a custom dimension or a regex report, and the param fragmented page reports on the destination. Use the standard UTM names instead: utm_source hostname of the docs site utm_medium site.title, slugified utm_content path of the page clicked from A `utm_*` param already present on the configured `href` is never overwritten, and any other query string on it is preserved as before. Relative hrefs are no longer tagged at all. They resolved against the current origin, so they picked up the param and navigated in-app to `/support?ref=...` on the docs site's own domain, which only registers as a self-referral. Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/docs/configuration.mdx | 14 ++++++-- .../src/components/ui/sidebar-links.tsx | 36 +++++++++++++++---- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/configuration.mdx index 05d5c1f..dc2c08c 100644 --- a/docs/content/docs/configuration.mdx +++ b/docs/content/docs/configuration.mdx @@ -300,13 +300,21 @@ links: | `label` | `string` | Menu item text | | `href` | `string` | URL. Absolute URLs open in a new tab; relative paths navigate in-app | -Every link is tagged with a `ref` query param carrying the full URL of the page it was clicked from, so the destination can attribute the visit. External links open with `noopener` (not `noreferrer`), so the destination also receives a `Referer` header — but under the browser default referrer policy that header carries only the origin, so `ref` is what identifies the specific page. +External links are tagged with UTM query params so the destination can attribute the visit, using the standard names every analytics tool recognises: + +| Param | Value | +|-------|-------| +| `utm_source` | Hostname of the docs site the click came from | +| `utm_medium` | `site.title`, slugified | +| `utm_content` | Path of the page the link was clicked from | ``` -https://support.example.com?ref=https%3A%2F%2Fdocs.example.com%2Fdocs%2Fguide +https://support.example.com?utm_source=docs.example.com&utm_medium=my-documentation&utm_content=%2Fdocs%2Fguide ``` -Any query string already on `href` is preserved. Non-web schemes (`mailto:`, `slack:`) are left untouched. +External links open with `noopener` (not `noreferrer`), so the destination also receives a `Referer` header — but under the browser default referrer policy that header carries only the origin, so `utm_content` is what identifies the specific page. + +Any query string already on `href` is preserved, and a `utm_*` param you set yourself is never overwritten. Relative paths are not tagged — they stay on the same origin, so the params would only register as a self-referral. Non-web schemes (`mailto:`, `slack:`) are left untouched. ### search diff --git a/packages/chronicle/src/components/ui/sidebar-links.tsx b/packages/chronicle/src/components/ui/sidebar-links.tsx index ef1cd52..b1182e8 100644 --- a/packages/chronicle/src/components/ui/sidebar-links.tsx +++ b/packages/chronicle/src/components/ui/sidebar-links.tsx @@ -8,6 +8,12 @@ import styles from './sidebar-links.module.css' const isExternal = (href: string) => /^[a-z][a-z0-9+.-]*:/i.test(href) +const slugify = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + interface SidebarLinksProps { /** * `menu` — a `?` icon button opening a dropdown, for the desktop sidebar footer. @@ -23,19 +29,35 @@ export function SidebarLinks({ variant = 'menu' }: SidebarLinksProps) { if (!links.length) return null - // Tag the destination with the page the user clicked from. Schemes other than - // http(s) — mailto:, slack: — carry an opaque path, so they're left alone. + // Tag outbound links so the destination can attribute the visit. Relative + // paths are left untouched — they're same-origin, and tagging them would read + // as a self-referral. Schemes other than http(s) — mailto:, slack: — carry an + // opaque path, so they're left alone too. const open = (href: string) => { const url = new URL(href, window.location.origin) const isWeb = url.protocol === 'http:' || url.protocol === 'https:' - if (isWeb) url.searchParams.set('ref', window.location.href) - if (isExternal(href)) { - // `noopener` only — the destination should see Chronicle as the referrer. - window.open(isWeb ? url.toString() : href, '_blank', 'noopener') - } else { + if (!isExternal(href)) { navigate(`${url.pathname}${url.search}${url.hash}`) + return } + + if (isWeb) { + const params = { + utm_source: window.location.hostname, + utm_medium: slugify(config.site.title), + utm_content: window.location.pathname, + } + // Anything already set on `href` wins. + for (const [key, value] of Object.entries(params)) { + if (value && !url.searchParams.has(key)) { + url.searchParams.set(key, value) + } + } + } + + // `noopener` only — the destination should see Chronicle as the referrer. + window.open(isWeb ? url.toString() : href, '_blank', 'noopener') } if (variant === 'list') {