Skip to content

Commit 4aa88d4

Browse files
feat: Generate vercel.json redirects from Netlify _redirects
Add sync-vercel-redirects.ts script that reads website/static/_redirects (Netlify format), converts to Vercel format, and updates vercel.json. This keeps _redirects as the single source of truth. Add CI check that fails if vercel.json redirects drift out of sync. Run 'yarn sync-redirects:vercel' after editing _redirects.
1 parent 39b9ea5 commit 4aa88d4

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

.github/workflows/pre-merge.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@ jobs:
3333
- name: Run plugins lint
3434
run: yarn lint:plugins
3535

36+
check-vercel-redirects:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout repository
40+
uses: actions/checkout@v6
41+
42+
- name: Set up Node.js
43+
uses: actions/setup-node@v6
44+
with:
45+
node-version: "22"
46+
cache: yarn
47+
48+
- name: Install dependencies
49+
run: yarn install --immutable
50+
51+
- name: Check Vercel redirects are in sync
52+
run: |
53+
yarn sync-redirects:vercel
54+
if ! git diff --exit-code vercel.json; then
55+
echo "::error::vercel.json redirects are out of sync with _redirects. Run 'yarn sync-redirects:vercel' and commit."
56+
exit 1
57+
fi
58+
3659
lint-website:
3760
runs-on: ubuntu-latest
3861
steps:

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"lint:website": "eslint ./website ./docs",
2424
"update-lock": "npx yarn-deduplicate",
2525
"check-dependencies": "manypkg check",
26+
"sync-redirects:vercel": "node scripts/src/sync-vercel-redirects.ts",
2627
"build:vercel": "yarn --cwd website build:vercel",
2728
"build:vercel:fast": "yarn --cwd website build:vercel:fast",
2829
"dev:vercel": "vercel dev"
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import fs from 'node:fs';
9+
import path from 'node:path';
10+
11+
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
12+
const REDIRECTS_PATH = path.join(REPO_ROOT, 'website/static/_redirects');
13+
const VERSIONS_PATH = path.join(REPO_ROOT, 'website/versions.json');
14+
const VERCEL_JSON_PATH = path.join(REPO_ROOT, 'vercel.json');
15+
16+
interface VercelRedirect {
17+
source: string;
18+
destination: string;
19+
permanent: boolean;
20+
}
21+
22+
function isPartialSegmentWildcard(source: string): boolean {
23+
return source.split('/').some(seg => seg.includes('*') && seg !== '*');
24+
}
25+
26+
function expandPartialWildcard(
27+
source: string,
28+
destination: string
29+
): VercelRedirect[] {
30+
const segments = source.split('/');
31+
const wildcardSeg = segments.find(seg => seg.includes('*') && seg !== '*')!;
32+
const prefix = wildcardSeg.replace('*', '');
33+
34+
// Infer docs directory from destination path
35+
// e.g. /docs/next/legacy/native-modules-:splat → docs/legacy/
36+
const destDir = destination
37+
.replace(/^\/docs\/next\//, '')
38+
.replace(/\/[^/]*$/, '');
39+
const searchDir = path.join(REPO_ROOT, 'docs', destDir);
40+
41+
let files: string[];
42+
try {
43+
files = fs
44+
.readdirSync(searchDir)
45+
.filter(f => f.startsWith(prefix) && /\.mdx?$/.test(f))
46+
.map(f => f.replace(/\.mdx?$/, ''));
47+
} catch {
48+
console.warn(
49+
`Warning: Could not read ${searchDir} for expanding ${source}`
50+
);
51+
return [];
52+
}
53+
54+
return files.map(filename => {
55+
const suffix = filename.slice(prefix.length);
56+
return {
57+
source: source.replace(`${prefix}*`, filename),
58+
destination: destination.replace(':splat', suffix),
59+
permanent: true,
60+
};
61+
});
62+
}
63+
64+
function syncRedirects(): void {
65+
const latestVersion: string = JSON.parse(
66+
fs.readFileSync(VERSIONS_PATH, 'utf8')
67+
)[0];
68+
69+
const lines = fs.readFileSync(REDIRECTS_PATH, 'utf8').split('\n');
70+
const redirects: VercelRedirect[] = [];
71+
72+
for (const line of lines) {
73+
const trimmed = line.trim();
74+
if (!trimmed || trimmed.startsWith('#')) continue;
75+
76+
const parts = trimmed.split(/\s+/);
77+
if (parts.length < 2) continue;
78+
79+
let [source, destination] = parts;
80+
81+
// Strip full URL sources to path only
82+
if (source.startsWith('http://') || source.startsWith('https://')) {
83+
try {
84+
source = new URL(source).pathname;
85+
} catch {
86+
continue;
87+
}
88+
}
89+
90+
// Convert same-domain full URL destinations to relative paths
91+
if (destination.startsWith('https://reactnative.dev/')) {
92+
destination = destination.replace('https://reactnative.dev', '');
93+
}
94+
95+
// Replace $LATEST_VERSION$ placeholder with actual version
96+
source = source.replaceAll('$LATEST_VERSION$', latestVersion);
97+
destination = destination.replaceAll('$LATEST_VERSION$', latestVersion);
98+
99+
// Handle partial-segment wildcards (e.g., native-modules-*)
100+
// Vercel doesn't support wildcards within a path segment, so enumerate
101+
if (isPartialSegmentWildcard(source)) {
102+
redirects.push(...expandPartialWildcard(source, destination));
103+
continue;
104+
}
105+
106+
// Convert Netlify wildcard syntax to Vercel format
107+
// Netlify: /* and :splat → Vercel: /:path*
108+
source = source.replace(/\*$/, ':path*');
109+
destination = destination.replace(':splat', ':path*');
110+
111+
redirects.push({source, destination, permanent: true});
112+
}
113+
114+
const vercelJson = JSON.parse(fs.readFileSync(VERCEL_JSON_PATH, 'utf8'));
115+
vercelJson.redirects = redirects;
116+
fs.writeFileSync(
117+
VERCEL_JSON_PATH,
118+
JSON.stringify(vercelJson, null, 2) + '\n'
119+
);
120+
121+
console.log(`Synced ${redirects.length} redirects to vercel.json`);
122+
}
123+
124+
syncRedirects();

0 commit comments

Comments
 (0)