Skip to content

Commit a1a1d9e

Browse files
authored
Merge pull request #52 from defenseunicorns/51-fix-conditional-on-404
51 improve redirects
2 parents a6659b5 + ffca47d commit a1a1d9e

File tree

8 files changed

+267
-10
lines changed

8 files changed

+267
-10
lines changed

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ work/
1010
# Generated content (overwritten on each build when -dist is true)
1111
src/content/docs/
1212
src/content/versions/
13-
[0-9]*_*/
13+
# Autogenerated docs directories
14+
[0-9]*_*/
15+
# Auto-generated redirect files
16+
retired-redirects.js
17+
public/_redirects

build/index.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,54 @@ await executeWithErrorHandling(`Generate version configuration files`, async (lo
702702
}
703703
});
704704

705+
await executeWithErrorHandling(`Generate redirects for retired versions`, async (log) => {
706+
const siteRoot = path.dirname(path.dirname(path.dirname(RUN.site)));
707+
const redirectsFile = `${siteRoot}/retired-redirects.js`;
708+
709+
// Build redirect object for all retired versions
710+
const redirects = {};
711+
for (const version of RUN.retired) {
712+
// Strip version prefix from URLs: /vX.XX/* -> /*
713+
redirects[`/v${version}/:path*`] = '/:path*';
714+
}
715+
716+
// Write as ES module
717+
const content = `// Auto-generated redirects for retired versions
718+
// Generated by build/index.mjs - DO NOT EDIT MANUALLY
719+
720+
export const retiredVersionRedirects = ${JSON.stringify(redirects, null, 2)};
721+
722+
export const retiredVersions = ${JSON.stringify(RUN.retired, null, 2)};
723+
`;
724+
725+
await fs.writeFile(redirectsFile, content);
726+
log.push(['generated', 'retired-redirects.js']);
727+
log.push(['redirects', `${RUN.retired.length} versions`]);
728+
});
729+
730+
await executeWithErrorHandling(`Generate Netlify _redirects file`, async (log) => {
731+
const siteRoot = path.dirname(path.dirname(path.dirname(RUN.site)));
732+
const publicDir = `${siteRoot}/public`;
733+
const netlifyRedirectsFile = `${publicDir}/_redirects`;
734+
735+
// Generate Netlify redirect rules for retired versions
736+
const redirectLines = [
737+
'# Auto-generated redirects for retired documentation versions',
738+
'# Generated by build/index.mjs - DO NOT EDIT MANUALLY',
739+
''
740+
];
741+
742+
for (const version of RUN.retired) {
743+
// Netlify format: /v0.53/* /:splat 301
744+
redirectLines.push(`/v${version}/* /:splat 301`);
745+
}
746+
747+
const content = redirectLines.join('\n') + '\n';
748+
await fs.writeFile(netlifyRedirectsFile, content);
749+
log.push(['generated', '_redirects (Netlify)']);
750+
log.push(['rules', `${RUN.retired.length} redirects`]);
751+
});
752+
705753
// Generate final distribution build by copying processed content to Starlight directories
706754
// and running the Astro build process to create the static site
707755
if (opts.dist) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import * as fs from 'node:fs/promises';
3+
import * as path from 'node:path';
4+
5+
const projectRoot = path.resolve(import.meta.dirname, '../..');
6+
const netlifyRedirectsPath = path.join(projectRoot, 'public/_redirects');
7+
8+
async function getNetlifyRedirectsContent() {
9+
return await fs.readFile(netlifyRedirectsPath, 'utf8');
10+
}
11+
12+
describe('Netlify _redirects File', () => {
13+
const testCases = [
14+
{ name: 'should be auto-generated with warnings', expected: 'Auto-generated' },
15+
{ name: 'should use Netlify splat redirects with 301', expected: '/:splat 301' },
16+
];
17+
18+
it.each(testCases)('$name', async ({ expected }) => {
19+
const content = await getNetlifyRedirectsContent();
20+
expect(content).toContain(expected);
21+
});
22+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest';
2+
import * as fs from 'node:fs/promises';
3+
import * as path from 'node:path';
4+
5+
const projectRoot = path.resolve(import.meta.dirname, '../..');
6+
const retiredRedirectsPath = path.join(projectRoot, 'retired-redirects.js');
7+
8+
async function getRetiredRedirectsContent() {
9+
return await fs.readFile(retiredRedirectsPath, 'utf8');
10+
}
11+
12+
describe('Retired Version Redirects Generation', () => {
13+
const testCases = [
14+
{ name: 'should export retiredVersionRedirects', expected: 'export const retiredVersionRedirects' },
15+
{ name: 'should export retiredVersions', expected: 'export const retiredVersions' },
16+
{ name: 'should use wildcard redirects to root', expected: '/:path*' },
17+
{ name: 'should be auto-generated', expected: 'Auto-generated' },
18+
];
19+
20+
it.each(testCases)('$name', async ({ expected }) => {
21+
const content = await getRetiredRedirectsContent();
22+
expect(content).toContain(expected);
23+
});
24+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, it, beforeAll } from 'vitest';
2+
import { discoverVersions, getStarlightVersions } from '../version-discovery.mjs';
3+
import * as semver from 'semver';
4+
5+
describe('Version Discovery', () => {
6+
let coreRepoPath;
7+
8+
beforeAll(() => {
9+
coreRepoPath = process.env.CORE || process.env.PEPR_CORE_PATH;
10+
});
11+
12+
describe('discoverVersions', () => {
13+
it('should return versions and retired arrays', async () => {
14+
if (!coreRepoPath) {
15+
console.log('Skipping test: No CORE repository path provided');
16+
return;
17+
}
18+
19+
const result = await discoverVersions(coreRepoPath, 2);
20+
expect(result).toHaveProperty('versions');
21+
expect(result).toHaveProperty('retired');
22+
expect(Array.isArray(result.versions)).toBe(true);
23+
expect(Array.isArray(result.retired)).toBe(true);
24+
});
25+
26+
it('should include "latest" in versions', async () => {
27+
if (!coreRepoPath) {
28+
console.log('Skipping test: No CORE repository path provided');
29+
return;
30+
}
31+
32+
const { versions } = await discoverVersions(coreRepoPath, 2);
33+
expect(versions).toContain('latest');
34+
});
35+
36+
it('should respect cutoff parameter', async () => {
37+
if (!coreRepoPath) {
38+
console.log('Skipping test: No CORE repository path provided');
39+
return;
40+
}
41+
42+
const cutoff = 2;
43+
const { versions } = await discoverVersions(coreRepoPath, cutoff);
44+
const versionCount = versions.filter(v => v !== 'latest').length;
45+
expect(versionCount).toBe(cutoff);
46+
});
47+
48+
it('should return major.minor format for retired versions', async () => {
49+
if (!coreRepoPath) {
50+
console.log('Skipping test: No CORE repository path provided');
51+
return;
52+
}
53+
54+
const { retired } = await discoverVersions(coreRepoPath, 2);
55+
retired.forEach(version => {
56+
expect(version).toMatch(/^\d+\.\d+$/);
57+
});
58+
});
59+
60+
it('should return full semver for active versions', async () => {
61+
if (!coreRepoPath) {
62+
console.log('Skipping test: No CORE repository path provided');
63+
return;
64+
}
65+
66+
const { versions } = await discoverVersions(coreRepoPath, 2);
67+
const semverVersions = versions.filter(v => v !== 'latest');
68+
semverVersions.forEach(version => {
69+
expect(version).toMatch(/^v?\d+\.\d+\.\d+/);
70+
});
71+
});
72+
});
73+
74+
describe('getStarlightVersions', () => {
75+
it('should format versions with major.minor slugs', async () => {
76+
if (!coreRepoPath) {
77+
console.log('Skipping test: No CORE repository path provided');
78+
return;
79+
}
80+
81+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
82+
starlightVersions.forEach(v => {
83+
expect(v).toHaveProperty('slug');
84+
expect(v).toHaveProperty('label');
85+
expect(v.slug).toMatch(/^v\d+\.\d+$/);
86+
});
87+
});
88+
89+
it('should not include "latest" in starlight versions', async () => {
90+
if (!coreRepoPath) {
91+
console.log('Skipping test: No CORE repository path provided');
92+
return;
93+
}
94+
95+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
96+
const hasLatest = starlightVersions.some(v =>
97+
v.slug === 'latest' || v.label === 'latest'
98+
);
99+
expect(hasLatest).toBe(false);
100+
});
101+
102+
it('should use full version for label', async () => {
103+
if (!coreRepoPath) {
104+
console.log('Skipping test: No CORE repository path provided');
105+
return;
106+
}
107+
108+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
109+
starlightVersions.forEach(v => {
110+
expect(v.label).toMatch(/^v?\d+\.\d+\.\d+/);
111+
});
112+
});
113+
114+
it('should filter out prerelease versions', async () => {
115+
if (!coreRepoPath) {
116+
console.log('Skipping test: No CORE repository path provided');
117+
return;
118+
}
119+
120+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
121+
starlightVersions.forEach(v => {
122+
expect(semver.prerelease(v.label)).toBeNull();
123+
});
124+
});
125+
126+
it('should return array of version objects', async () => {
127+
if (!coreRepoPath) {
128+
console.log('Skipping test: No CORE repository path provided');
129+
return;
130+
}
131+
132+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
133+
expect(Array.isArray(starlightVersions)).toBe(true);
134+
expect(starlightVersions.length).toBeGreaterThan(0);
135+
});
136+
137+
it('should maintain consistency between slug and label major.minor', async () => {
138+
if (!coreRepoPath) {
139+
console.log('Skipping test: No CORE repository path provided');
140+
return;
141+
}
142+
143+
const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
144+
starlightVersions.forEach(v => {
145+
const slugMajMin = v.slug.match(/(\d+\.\d+)/)?.[1];
146+
const labelMajMin = v.label.match(/(\d+\.\d+)/)?.[1];
147+
expect(slugMajMin).toBe(labelMajMin);
148+
});
149+
});
150+
});
151+
});

build/version-discovery.mjs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,26 +52,23 @@ export async function discoverVersions(coreRepoPath, cutoff = 2) {
5252
export async function getStarlightVersions(coreRepoPath, cutoff = 2) {
5353
const { versions } = await discoverVersions(coreRepoPath, cutoff);
5454

55-
// Filter out 'latest' and prerelease versions for starlight config
5655
const stableVersions = versions.filter(v => v !== 'latest' && semver.prerelease(v) === null);
5756

58-
// Convert to major.minor format for starlight slug, but keep full version for label
5957
return stableVersions.map(version => {
6058
const versionMajMin = version.replace(/^v(\d+\.\d+)\.\d+$/, 'v$1');
6159
return {
6260
slug: versionMajMin,
63-
label: version // Use the full version (major.minor.patch) for display
61+
label: version
6462
};
6563
});
6664
}
6765

6866
/**
6967
* Find the current version (latest stable)
7068
* @param {string[]} versions - Array of version strings
71-
* @returns {string|null} - The current version or null if none found
69+
* @returns {string|null} - The most recent stable version, or null if none found
7270
*/
7371
export function findCurrentVersion(versions) {
74-
// Filter out 'latest', 'main' and prerelease versions
7572
const stableVersions = versions.filter(v =>
7673
v !== 'latest' &&
7774
v !== 'main' &&
@@ -83,7 +80,6 @@ export function findCurrentVersion(versions) {
8380
return null;
8481
}
8582

86-
// Sort and return the latest
8783
return semver.rsort(stableVersions)[0];
8884
}
8985

redirects.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { retiredVersionRedirects } from './retired-redirects.js';
2+
13
export const redirects = {
2-
//Legacy Doc Conversion Redirects
3-
// Redirect /main
4+
// Auto-generated redirects for retired versions (can be overridden by manual redirects below)
5+
...retiredVersionRedirects,
6+
7+
// //Legacy Doc Conversion Redirects
8+
// // Redirect /main
49
'/main': '/',
510
'/latest': '/',
611
'/latest/user-guide': '/user-guide',

src/pages/404.astro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
---
22
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
3+
import { retiredVersions } from '../../retired-redirects.js';
34
45
const currentPath = Astro.url.pathname;
6+
7+
const versionMatch = currentPath.match(/\/v([\d.]+)\//);
8+
const majorMinor = versionMatch?.[1].match(/^(\d+\.\d+)/)?.[1];
9+
const isRetiredVersion = majorMinor && retiredVersions.includes(majorMinor);
10+
const isLegacyPath = currentPath.includes('/legacy/');
11+
const showRetiredMessage = isRetiredVersion || isLegacyPath;
512
---
613

714
<StarlightPage frontmatter={{ title: `Oops! This page doesn't exist`, editUrl: false }}>
@@ -10,7 +17,7 @@ const currentPath = Astro.url.pathname;
1017
<p class="text-base mb-4 opacity-90">Sorry about that! The page <code class="px-2 py-1 dark:bg-gray-800 bg-gray-300 rounded text-sm">{currentPath}</code> couldn't be found. Let's get you back on track:</p>
1118
</div>
1219

13-
{currentPath.match(/\/v\d+\.|\/legacy\/|\/\d+\.\d+\//) && (
20+
{showRetiredMessage && (
1421
<div class="mb-8 p-5 rounded-lg bg-[#efca81]/20 dark:bg-[#efca81]/10 border-2 border-[#be9853]/50 dark:border-[#be9853]/30">
1522
<h3 class="text-xl font-semibold mb-3 text-[#141313] dark:text-[#fff3f3]">Looking for an older version?</h3>
1623
<p class="mb-3 text-[#141313] dark:text-[#dfd6d6a5]">We maintain docs for the latest release plus the two most recent versions. If you're looking for old docs, we recommend upgrading to a supported version.</p>

0 commit comments

Comments
 (0)