Skip to content

Commit 7d38b92

Browse files
authored
Include docs redirects in the generated docs artifact (#2240)
1 parent 050d1ff commit 7d38b92

File tree

6 files changed

+206
-0
lines changed

6 files changed

+206
-0
lines changed

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@types/node": "^22.13.2",
2424
"concurrently": "^8.2.2",
2525
"glob": "^11.0.1",
26+
"jsonc-parser": "^3.3.1",
2627
"prettier": "^3.2.5",
2728
"prettier-plugin-astro": "^0.14.0",
2829
"prettier-plugin-nginx": "^1.0.3",

scripts/build-docs.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,80 @@ title: MDX Doc
258258
expect(await fileExists(pathJoin('./dist/non-mdx-file.txt'))).toBe(false)
259259
expect(await fileExists(pathJoin('./dist/image.png'))).toBe(false)
260260
})
261+
262+
test('should copy over and process redirects', async () => {
263+
const { tempDir, readFile } = await createTempFiles([
264+
{
265+
path: './docs/manifest.json',
266+
content: JSON.stringify({
267+
navigation: [],
268+
}),
269+
},
270+
{
271+
path: './redirects/static.json',
272+
content: JSON.stringify([
273+
{
274+
source: '/docs/page-1',
275+
destination: '/docs/page-2',
276+
permanent: true,
277+
},
278+
{
279+
source: '/docs/page-2',
280+
destination: '/docs/page-3',
281+
permanent: true,
282+
},
283+
]),
284+
},
285+
{
286+
path: './redirects/dynamic.jsonc',
287+
content: JSON.stringify([
288+
{
289+
source: '/docs/login/:path*',
290+
destination: '/docs/signin/:path*',
291+
permanent: true,
292+
},
293+
]),
294+
},
295+
])
296+
297+
await build(
298+
createConfig({
299+
...baseConfig,
300+
basePath: tempDir,
301+
validSdks: ['react'],
302+
redirects: {
303+
static: {
304+
inputPath: '../redirects/static.json',
305+
outputPath: '_redirects/static.json',
306+
},
307+
dynamic: {
308+
inputPath: '../redirects/dynamic.jsonc',
309+
outputPath: '_redirects/dynamic.jsonc',
310+
},
311+
},
312+
}),
313+
)
314+
315+
expect(JSON.parse(await readFile('./dist/_redirects/static.json'))).toEqual({
316+
'/docs/page-1': {
317+
source: '/docs/page-1',
318+
destination: '/docs/page-3',
319+
permanent: true,
320+
},
321+
'/docs/page-2': {
322+
source: '/docs/page-2',
323+
destination: '/docs/page-3',
324+
permanent: true,
325+
},
326+
})
327+
expect(JSON.parse(await readFile('./dist/_redirects/dynamic.jsonc'))).toEqual([
328+
{
329+
source: '/docs/login/:path*',
330+
destination: '/docs/signin/:path*',
331+
permanent: true,
332+
},
333+
])
334+
})
261335
})
262336

263337
describe('Manifest Validation', () => {

scripts/build-docs.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ import { insertFrontmatter } from './lib/plugins/insertFrontmatter'
7070
import { validateAndEmbedLinks } from './lib/plugins/validateAndEmbedLinks'
7171
import { validateIfComponents } from './lib/plugins/validateIfComponents'
7272
import { validateUniqueHeadings } from './lib/plugins/validateUniqueHeadings'
73+
import {
74+
analyzeAndFixRedirects as optimizeRedirects,
75+
readRedirects,
76+
transformRedirectsToObject,
77+
writeRedirects,
78+
} from './lib/redirects'
7379

7480
// Only invokes the main function if we run the script directly eg npm run build, bun run ./scripts/build-docs.ts
7581
if (require.main === module) {
@@ -87,6 +93,16 @@ async function main() {
8793
partialsPath: '../docs/_partials',
8894
distPath: '../dist',
8995
typedocPath: '../clerk-typedoc',
96+
redirects: {
97+
static: {
98+
inputPath: '../redirects/static/docs.json',
99+
outputPath: '_redirects/static.json',
100+
},
101+
dynamic: {
102+
inputPath: '../redirects/dynamic/docs.jsonc',
103+
outputPath: '_redirects/dynamic.jsonc',
104+
},
105+
},
90106
ignoreLinks: [
91107
'/docs/core-1',
92108
'/docs/reference/backend-api',
@@ -166,6 +182,16 @@ export async function build(config: BuildConfig, store: Store = createBlankStore
166182

167183
await ensureDir(config.distPath)
168184

185+
if (config.redirects) {
186+
const { staticRedirects, dynamicRedirects } = await readRedirects(config)
187+
188+
const optimizedStaticRedirects = optimizeRedirects(staticRedirects)
189+
const transformedStaticRedirects = transformRedirectsToObject(optimizedStaticRedirects)
190+
191+
await writeRedirects(config, transformedStaticRedirects, dynamicRedirects)
192+
console.info('✓ Wrote redirects to disk')
193+
}
194+
169195
const userManifest = await getManifest()
170196
console.info('✓ Read Manifest')
171197

scripts/lib/config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ type BuildConfigOptions = {
2424
collapseDefault: boolean
2525
hideTitleDefault: boolean
2626
}
27+
redirects?: {
28+
static: {
29+
inputPath: string
30+
outputPath: string
31+
}
32+
dynamic: {
33+
inputPath: string
34+
outputPath: string
35+
}
36+
}
2737
cleanDist: boolean
2838
flags?: {
2939
watch?: boolean
@@ -72,6 +82,19 @@ export function createConfig(config: BuildConfigOptions) {
7282
hideTitleDefault: false,
7383
},
7484

85+
redirects: config.redirects
86+
? {
87+
static: {
88+
inputPath: resolve(path.join(config.distPath, config.redirects.static.inputPath)),
89+
outputPath: resolve(path.join(config.distPath, config.redirects.static.outputPath)),
90+
},
91+
dynamic: {
92+
inputPath: resolve(path.join(config.distPath, config.redirects.dynamic.inputPath)),
93+
outputPath: resolve(path.join(config.distPath, config.redirects.dynamic.outputPath)),
94+
},
95+
}
96+
: null,
97+
7598
cleanDist: config.cleanDist,
7699

77100
flags: {

scripts/lib/redirects.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { BuildConfig } from './config'
2+
import fs from 'node:fs/promises'
3+
import path from 'node:path'
4+
import { parse as parseJSONC } from 'jsonc-parser'
5+
6+
interface Redirect {
7+
source: string
8+
destination: string
9+
permanent: boolean
10+
}
11+
12+
export function transformRedirectsToObject(redirects: Redirect[]) {
13+
return Object.fromEntries(redirects.map((item) => [item.source, item]))
14+
}
15+
16+
export async function readRedirects(config: BuildConfig) {
17+
const { static: staticConfig, dynamic: dynamicConfig } = config.redirects ?? {}
18+
if (!staticConfig?.inputPath || !dynamicConfig?.inputPath) {
19+
throw new Error('Redirect paths not configured')
20+
}
21+
22+
const [staticContent, dynamicContent] = await Promise.all([
23+
fs.readFile(staticConfig.inputPath, 'utf-8'),
24+
fs.readFile(dynamicConfig.inputPath, 'utf-8'),
25+
])
26+
27+
return {
28+
staticRedirects: JSON.parse(staticContent) as Redirect[],
29+
dynamicRedirects: parseJSONC(dynamicContent) as Redirect[],
30+
}
31+
}
32+
33+
export function analyzeAndFixRedirects(redirects: Redirect[]): Redirect[] {
34+
const redirectMap = new Map(redirects.map((r) => [r.source, r.destination]))
35+
const finalDestinations = new Map<string, string>()
36+
37+
// Find final destinations for each redirect
38+
for (const { source, destination, permanent } of redirects) {
39+
let current = destination
40+
const visited = new Set([source])
41+
42+
while (redirectMap.has(current) && !visited.has(current)) {
43+
visited.add(current)
44+
current = redirectMap.get(current)!
45+
}
46+
47+
finalDestinations.set(source, current)
48+
}
49+
50+
// Create new redirects pointing to final destinations
51+
return redirects.map(({ source, permanent }) => ({
52+
source,
53+
destination: finalDestinations.get(source)!,
54+
permanent,
55+
}))
56+
}
57+
58+
export async function writeRedirects(
59+
config: BuildConfig,
60+
staticRedirects: Record<string, Redirect>,
61+
dynamicRedirects: Redirect[],
62+
) {
63+
const { static: staticConfig, dynamic: dynamicConfig } = config.redirects ?? {}
64+
if (!staticConfig?.outputPath || !dynamicConfig?.outputPath) {
65+
throw new Error('Redirect output paths not configured')
66+
}
67+
68+
await fs.mkdir(path.dirname(staticConfig.outputPath), { recursive: true })
69+
70+
await Promise.all([
71+
fs.writeFile(staticConfig.outputPath, JSON.stringify(staticRedirects)),
72+
fs.writeFile(dynamicConfig.outputPath, JSON.stringify(dynamicRedirects)),
73+
])
74+
}

0 commit comments

Comments
 (0)