From c6e54ea9d01a98577c16429bc191aa1c96640c70 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:13:28 +0000 Subject: [PATCH 1/4] Canonicalize LinkedIn tracking URLs before serving static docs --- middleware.js | 13 +++++++++++++ scripts/tracking-redirect.test.mjs | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 middleware.js create mode 100644 scripts/tracking-redirect.test.mjs diff --git a/middleware.js b/middleware.js new file mode 100644 index 000000000..f14d4ed03 --- /dev/null +++ b/middleware.js @@ -0,0 +1,13 @@ +/** + * Vercel Routing Middleware runs before static files are served. Astro's + * src/middleware.ts only runs at build time for prerendered documentation. + */ +export default function middleware(request) { + const url = new URL(request.url); + if (!url.searchParams.has('trk')) { + return new Response(null, { headers: { 'x-middleware-next': '1' } }); + } + + url.searchParams.delete('trk'); + return Response.redirect(url, 301); +} diff --git a/scripts/tracking-redirect.test.mjs b/scripts/tracking-redirect.test.mjs new file mode 100644 index 000000000..649cbc64d --- /dev/null +++ b/scripts/tracking-redirect.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import middleware from '../middleware.js'; + +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 = middleware(new Request(`https://docs.warp.dev/factories/how-factories-work?${query}`)); + assert.equal(response.status, 301); + assert.equal(response.headers.get('location'), 'https://docs.warp.dev/factories/how-factories-work'); + } +}); + +test('preserves other parameters and does not loop', () => { + const response = middleware(new Request('https://docs.warp.dev/factories?utm_source=linkedin&trk=post&q=a%26b&q=c')); + const url = new URL(response.headers.get('location')); + assert.deepEqual([...url.searchParams], [['utm_source', 'linkedin'], ['q', 'a&b'], ['q', 'c']]); + assert.equal(middleware(new Request(url)).headers.get('x-middleware-next'), '1'); +}); + +test('does not change URLs without trk', () => { + const response = middleware(new Request('https://docs.warp.dev/factories?utm_source=linkedin')); + assert.equal(response.headers.has('location'), false); + assert.equal(response.headers.get('x-middleware-next'), '1'); +}); From 0e0b7202900e8a0607cdaca3ba05d4014a06c530 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:16:12 +0000 Subject: [PATCH 2/4] Serve query redirects at request time behind a conditional Vercel rewrite --- middleware.js | 13 ----------- scripts/tracking-redirect.test.mjs | 23 +++++++++++++++----- src/pages/api/tracking-redirect/[...path].js | 15 +++++++++++++ vercel.json | 10 +++++++++ 4 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 src/pages/api/tracking-redirect/[...path].js diff --git a/middleware.js b/middleware.js index f14d4ed03..e69de29bb 100644 --- a/middleware.js +++ b/middleware.js @@ -1,13 +0,0 @@ -/** - * Vercel Routing Middleware runs before static files are served. Astro's - * src/middleware.ts only runs at build time for prerendered documentation. - */ -export default function middleware(request) { - const url = new URL(request.url); - if (!url.searchParams.has('trk')) { - return new Response(null, { headers: { 'x-middleware-next': '1' } }); - } - - url.searchParams.delete('trk'); - return Response.redirect(url, 301); -} diff --git a/scripts/tracking-redirect.test.mjs b/scripts/tracking-redirect.test.mjs index 649cbc64d..56a926c06 100644 --- a/scripts/tracking-redirect.test.mjs +++ b/scripts/tracking-redirect.test.mjs @@ -1,24 +1,35 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import middleware from '../middleware.js'; +import { GET } from '../src/pages/api/tracking-redirect/[...path].js'; + +function redirect(query, path = 'factories/how-factories-work') { + return GET({ + url: new URL(`https://docs.warp.dev/api/tracking-redirect/${path}?${query}`), + params: { path }, + }); +} 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 = middleware(new Request(`https://docs.warp.dev/factories/how-factories-work?${query}`)); + 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 = middleware(new Request('https://docs.warp.dev/factories?utm_source=linkedin&trk=post&q=a%26b&q=c')); + 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(middleware(new Request(url)).headers.get('x-middleware-next'), '1'); + assert.equal(redirect(url.search.slice(1), 'factories').status, 404); }); test('does not change URLs without trk', () => { - const response = middleware(new Request('https://docs.warp.dev/factories?utm_source=linkedin')); + const response = redirect('utm_source=linkedin'); assert.equal(response.headers.has('location'), false); - assert.equal(response.headers.get('x-middleware-next'), '1'); + assert.equal(response.status, 404); }); diff --git a/src/pages/api/tracking-redirect/[...path].js b/src/pages/api/tracking-redirect/[...path].js new file mode 100644 index 000000000..8059ee05e --- /dev/null +++ b/src/pages/api/tracking-redirect/[...path].js @@ -0,0 +1,15 @@ +export const prerender = false; + +/** @type {import('astro').APIRoute} */ +export const GET = ({ url, params }) => { + if (!url.searchParams.has('trk')) { + return new Response(null, { status: 404 }); + } + + const clean = new URL(url); + clean.pathname = `/${params.path ?? ''}`; + clean.searchParams.delete('trk'); + return Response.redirect(clean, 301); +}; + +export const HEAD = GET; diff --git a/vercel.json b/vercel.json index 720c61e7c..eb12bffe3 100644 --- a/vercel.json +++ b/vercel.json @@ -53,6 +53,16 @@ } ], "rewrites": [ + { + "source": "/:path*", + "has": [ + { + "type": "query", + "key": "trk" + } + ], + "destination": "/api/tracking-redirect/:path*" + }, { "source": "/", "has": [ From 174b11268bdfd9cda7721ac3947c1e85894e0cfe Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:22:39 +0000 Subject: [PATCH 3/4] Run tracking redirects before static files using Vercel edge middleware --- package.json | 1 + scripts/build-tracking-middleware.mjs | 25 ++++++++++++++++++++ scripts/tracking-redirect.test.mjs | 11 ++++----- src/lib/tracking-redirect.js | 9 +++++++ src/pages/api/tracking-redirect/[...path].js | 15 ------------ vercel.json | 10 -------- 6 files changed, 39 insertions(+), 32 deletions(-) create mode 100644 scripts/build-tracking-middleware.mjs create mode 100644 src/lib/tracking-redirect.js 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 index 56a926c06..53b3ebf52 100644 --- a/scripts/tracking-redirect.test.mjs +++ b/scripts/tracking-redirect.test.mjs @@ -1,12 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { GET } from '../src/pages/api/tracking-redirect/[...path].js'; +import trackingRedirect from '../src/lib/tracking-redirect.js'; function redirect(query, path = 'factories/how-factories-work') { - return GET({ - url: new URL(`https://docs.warp.dev/api/tracking-redirect/${path}?${query}`), - params: { path }, - }); + return trackingRedirect(new Request(`https://docs.warp.dev/${path}?${query}`)); } test('permanently redirects LinkedIn variants to the clean path', () => { @@ -25,11 +22,11 @@ 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').status, 404); + 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.status, 404); + 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); +} diff --git a/src/pages/api/tracking-redirect/[...path].js b/src/pages/api/tracking-redirect/[...path].js index 8059ee05e..e69de29bb 100644 --- a/src/pages/api/tracking-redirect/[...path].js +++ b/src/pages/api/tracking-redirect/[...path].js @@ -1,15 +0,0 @@ -export const prerender = false; - -/** @type {import('astro').APIRoute} */ -export const GET = ({ url, params }) => { - if (!url.searchParams.has('trk')) { - return new Response(null, { status: 404 }); - } - - const clean = new URL(url); - clean.pathname = `/${params.path ?? ''}`; - clean.searchParams.delete('trk'); - return Response.redirect(clean, 301); -}; - -export const HEAD = GET; diff --git a/vercel.json b/vercel.json index eb12bffe3..720c61e7c 100644 --- a/vercel.json +++ b/vercel.json @@ -53,16 +53,6 @@ } ], "rewrites": [ - { - "source": "/:path*", - "has": [ - { - "type": "query", - "key": "trk" - } - ], - "destination": "/api/tracking-redirect/:path*" - }, { "source": "/", "has": [ From 1b55e3c3e5ebe09c0b1eb73e81e4b6cfd42a39a9 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:25 +0000 Subject: [PATCH 4/4] Remove superseded empty routing files --- middleware.js | 0 src/pages/api/tracking-redirect/[...path].js | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 middleware.js delete mode 100644 src/pages/api/tracking-redirect/[...path].js diff --git a/middleware.js b/middleware.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/pages/api/tracking-redirect/[...path].js b/src/pages/api/tracking-redirect/[...path].js deleted file mode 100644 index e69de29bb..000000000