diff --git a/package.json b/package.json index c213700c7..1f374c8bc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "astro dev", "predev": "node scripts/generate-openapi-json.mjs", "build": "astro build", + "postbuild": "node scripts/build-tracking-middleware.mjs", "prebuild": "node scripts/generate-openapi-json.mjs", "preview": "astro preview", "astro": "astro", diff --git a/scripts/build-tracking-middleware.mjs b/scripts/build-tracking-middleware.mjs new file mode 100644 index 000000000..30ec17aeb --- /dev/null +++ b/scripts/build-tracking-middleware.mjs @@ -0,0 +1,25 @@ +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; + +// Astro's Vercel adapter serves prerendered pages before its own middleware. +// Emit a native routing middleware before the filesystem phase instead: +// https://vercel.com/docs/build-output-api/features#routing-middleware +const output = new URL('../.vercel/output/', import.meta.url); +const configPath = new URL('config.json', output); +const config = JSON.parse(await readFile(configPath, 'utf8')); +const functionPath = new URL('functions/tracking-redirect.func/', output); +await mkdir(functionPath, { recursive: true }); +await copyFile( + new URL('../src/lib/tracking-redirect.js', import.meta.url), + new URL('index.js', functionPath), +); +await writeFile(new URL('.vc-config.json', functionPath), JSON.stringify({ + runtime: 'edge', + entrypoint: 'index.js', +})); +config.routes.unshift({ + src: '/(.*)', + has: [{ type: 'query', key: 'trk' }], + middlewarePath: 'tracking-redirect', + continue: true, +}); +await writeFile(configPath, JSON.stringify(config, null, 2)); diff --git a/scripts/tracking-redirect.test.mjs b/scripts/tracking-redirect.test.mjs new file mode 100644 index 000000000..53b3ebf52 --- /dev/null +++ b/scripts/tracking-redirect.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import trackingRedirect from '../src/lib/tracking-redirect.js'; + +function redirect(query, path = 'factories/how-factories-work') { + return trackingRedirect(new Request(`https://docs.warp.dev/${path}?${query}`)); +} + +test('permanently redirects LinkedIn variants to the clean path', () => { + for (const query of ['trk=article-ssr-frontend-pulse_little-text-block', 'trk=', 'trk=a&trk=b']) { + const response = redirect(query); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), 'https://docs.warp.dev/factories/how-factories-work'); + } +}); + +test('redirects the homepage without losing its root path', () => { + assert.equal(redirect('trk=post', '').headers.get('location'), 'https://docs.warp.dev/'); +}); + +test('preserves other parameters and does not loop', () => { + const response = redirect('utm_source=linkedin&trk=post&q=a%26b&q=c', 'factories'); + const url = new URL(response.headers.get('location')); + assert.deepEqual([...url.searchParams], [['utm_source', 'linkedin'], ['q', 'a&b'], ['q', 'c']]); + assert.equal(redirect(url.search.slice(1), 'factories').headers.get('x-middleware-next'), '1'); +}); + +test('does not change URLs without trk', () => { + const response = redirect('utm_source=linkedin'); + assert.equal(response.headers.has('location'), false); + assert.equal(response.headers.get('x-middleware-next'), '1'); +}); diff --git a/src/lib/tracking-redirect.js b/src/lib/tracking-redirect.js new file mode 100644 index 000000000..112ca6103 --- /dev/null +++ b/src/lib/tracking-redirect.js @@ -0,0 +1,9 @@ +/** Vercel edge middleware for LinkedIn URL canonicalization. */ +export default function trackingRedirect(request) { + const clean = new URL(request.url); + if (!clean.searchParams.has('trk')) { + return new Response(null, { headers: { 'x-middleware-next': '1' } }); + } + clean.searchParams.delete('trk'); + return Response.redirect(clean, 301); +}