Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ work/
# Generated content (overwritten on each build when -dist is true)
src/content/docs/
src/content/versions/
[0-9]*_*/
# Autogenerated docs directories
[0-9]*_*/
# Auto-generated redirect files
retired-redirects.js
public/_redirects
48 changes: 48 additions & 0 deletions build/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,54 @@ await executeWithErrorHandling(`Generate version configuration files`, async (lo
}
});

await executeWithErrorHandling(`Generate redirects for retired versions`, async (log) => {
const siteRoot = path.dirname(path.dirname(path.dirname(RUN.site)));
const redirectsFile = `${siteRoot}/retired-redirects.js`;

// Build redirect object for all retired versions
const redirects = {};
for (const version of RUN.retired) {
// Strip version prefix from URLs: /vX.XX/* -> /*
redirects[`/v${version}/:path*`] = '/:path*';
}

// Write as ES module
const content = `// Auto-generated redirects for retired versions
// Generated by build/index.mjs - DO NOT EDIT MANUALLY

export const retiredVersionRedirects = ${JSON.stringify(redirects, null, 2)};

export const retiredVersions = ${JSON.stringify(RUN.retired, null, 2)};
`;

await fs.writeFile(redirectsFile, content);
log.push(['generated', 'retired-redirects.js']);
log.push(['redirects', `${RUN.retired.length} versions`]);
});

await executeWithErrorHandling(`Generate Netlify _redirects file`, async (log) => {
const siteRoot = path.dirname(path.dirname(path.dirname(RUN.site)));
const publicDir = `${siteRoot}/public`;
const netlifyRedirectsFile = `${publicDir}/_redirects`;

// Generate Netlify redirect rules for retired versions
const redirectLines = [
'# Auto-generated redirects for retired documentation versions',
'# Generated by build/index.mjs - DO NOT EDIT MANUALLY',
''
];

for (const version of RUN.retired) {
// Netlify format: /v0.53/* /:splat 301
redirectLines.push(`/v${version}/* /:splat 301`);
}

const content = redirectLines.join('\n') + '\n';
await fs.writeFile(netlifyRedirectsFile, content);
log.push(['generated', '_redirects (Netlify)']);
log.push(['rules', `${RUN.retired.length} redirects`]);
});

// Generate final distribution build by copying processed content to Starlight directories
// and running the Astro build process to create the static site
if (opts.dist) {
Expand Down
22 changes: 22 additions & 0 deletions build/tests/netlify-redirects.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

const projectRoot = path.resolve(import.meta.dirname, '../..');
const netlifyRedirectsPath = path.join(projectRoot, 'public/_redirects');

async function getNetlifyRedirectsContent() {
return await fs.readFile(netlifyRedirectsPath, 'utf8');
}

describe('Netlify _redirects File', () => {
const testCases = [
{ name: 'should be auto-generated with warnings', expected: 'Auto-generated' },
{ name: 'should use Netlify splat redirects with 301', expected: '/:splat 301' },
];

it.each(testCases)('$name', async ({ expected }) => {
const content = await getNetlifyRedirectsContent();
expect(content).toContain(expected);
});
});
24 changes: 24 additions & 0 deletions build/tests/retired-redirects.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

const projectRoot = path.resolve(import.meta.dirname, '../..');
const retiredRedirectsPath = path.join(projectRoot, 'retired-redirects.js');

async function getRetiredRedirectsContent() {
return await fs.readFile(retiredRedirectsPath, 'utf8');
}

describe('Retired Version Redirects Generation', () => {
const testCases = [
{ name: 'should export retiredVersionRedirects', expected: 'export const retiredVersionRedirects' },
{ name: 'should export retiredVersions', expected: 'export const retiredVersions' },
{ name: 'should use wildcard redirects to root', expected: '/:path*' },
{ name: 'should be auto-generated', expected: 'Auto-generated' },
];

it.each(testCases)('$name', async ({ expected }) => {
const content = await getRetiredRedirectsContent();
expect(content).toContain(expected);
});
});
151 changes: 151 additions & 0 deletions build/tests/version-discovery.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { discoverVersions, getStarlightVersions } from '../version-discovery.mjs';
import * as semver from 'semver';

describe('Version Discovery', () => {
let coreRepoPath;

beforeAll(() => {
coreRepoPath = process.env.CORE || process.env.PEPR_CORE_PATH;
});

describe('discoverVersions', () => {
it('should return versions and retired arrays', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const result = await discoverVersions(coreRepoPath, 2);
expect(result).toHaveProperty('versions');
expect(result).toHaveProperty('retired');
expect(Array.isArray(result.versions)).toBe(true);
expect(Array.isArray(result.retired)).toBe(true);
});

it('should include "latest" in versions', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const { versions } = await discoverVersions(coreRepoPath, 2);
expect(versions).toContain('latest');
});

it('should respect cutoff parameter', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const cutoff = 2;
const { versions } = await discoverVersions(coreRepoPath, cutoff);
const versionCount = versions.filter(v => v !== 'latest').length;
expect(versionCount).toBe(cutoff);
});

it('should return major.minor format for retired versions', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const { retired } = await discoverVersions(coreRepoPath, 2);
retired.forEach(version => {
expect(version).toMatch(/^\d+\.\d+$/);
});
});

it('should return full semver for active versions', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const { versions } = await discoverVersions(coreRepoPath, 2);
const semverVersions = versions.filter(v => v !== 'latest');
semverVersions.forEach(version => {
expect(version).toMatch(/^v?\d+\.\d+\.\d+/);
});
});
});

describe('getStarlightVersions', () => {
it('should format versions with major.minor slugs', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
starlightVersions.forEach(v => {
expect(v).toHaveProperty('slug');
expect(v).toHaveProperty('label');
expect(v.slug).toMatch(/^v\d+\.\d+$/);
});
});

it('should not include "latest" in starlight versions', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
const hasLatest = starlightVersions.some(v =>
v.slug === 'latest' || v.label === 'latest'
);
expect(hasLatest).toBe(false);
});

it('should use full version for label', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
starlightVersions.forEach(v => {
expect(v.label).toMatch(/^v?\d+\.\d+\.\d+/);
});
});

it('should filter out prerelease versions', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
starlightVersions.forEach(v => {
expect(semver.prerelease(v.label)).toBeNull();
});
});

it('should return array of version objects', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
expect(Array.isArray(starlightVersions)).toBe(true);
expect(starlightVersions.length).toBeGreaterThan(0);
});

it('should maintain consistency between slug and label major.minor', async () => {
if (!coreRepoPath) {
console.log('Skipping test: No CORE repository path provided');
return;
}

const starlightVersions = await getStarlightVersions(coreRepoPath, 2);
starlightVersions.forEach(v => {
const slugMajMin = v.slug.match(/(\d+\.\d+)/)?.[1];
const labelMajMin = v.label.match(/(\d+\.\d+)/)?.[1];
expect(slugMajMin).toBe(labelMajMin);
});
});
});
});
8 changes: 2 additions & 6 deletions build/version-discovery.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,23 @@ export async function discoverVersions(coreRepoPath, cutoff = 2) {
export async function getStarlightVersions(coreRepoPath, cutoff = 2) {
const { versions } = await discoverVersions(coreRepoPath, cutoff);

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

// Convert to major.minor format for starlight slug, but keep full version for label
return stableVersions.map(version => {
const versionMajMin = version.replace(/^v(\d+\.\d+)\.\d+$/, 'v$1');
return {
slug: versionMajMin,
label: version // Use the full version (major.minor.patch) for display
label: version
};
});
}

/**
* Find the current version (latest stable)
* @param {string[]} versions - Array of version strings
* @returns {string|null} - The current version or null if none found
* @returns {string|null} - The most recent stable version, or null if none found
*/
export function findCurrentVersion(versions) {
// Filter out 'latest', 'main' and prerelease versions
const stableVersions = versions.filter(v =>
v !== 'latest' &&
v !== 'main' &&
Expand All @@ -83,7 +80,6 @@ export function findCurrentVersion(versions) {
return null;
}

// Sort and return the latest
return semver.rsort(stableVersions)[0];
}

Expand Down
9 changes: 7 additions & 2 deletions redirects.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { retiredVersionRedirects } from './retired-redirects.js';

export const redirects = {
//Legacy Doc Conversion Redirects
// Redirect /main
// Auto-generated redirects for retired versions (can be overridden by manual redirects below)
...retiredVersionRedirects,

// //Legacy Doc Conversion Redirects
// // Redirect /main
'/main': '/',
'/latest': '/',
'/latest/user-guide': '/user-guide',
Expand Down
9 changes: 8 additions & 1 deletion src/pages/404.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import { retiredVersions } from '../../retired-redirects.js';

const currentPath = Astro.url.pathname;

const versionMatch = currentPath.match(/\/v([\d.]+)\//);
const majorMinor = versionMatch?.[1].match(/^(\d+\.\d+)/)?.[1];
const isRetiredVersion = majorMinor && retiredVersions.includes(majorMinor);
const isLegacyPath = currentPath.includes('/legacy/');
const showRetiredMessage = isRetiredVersion || isLegacyPath;
---

<StarlightPage frontmatter={{ title: `Oops! This page doesn't exist`, editUrl: false }}>
Expand All @@ -10,7 +17,7 @@ const currentPath = Astro.url.pathname;
<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>
</div>

{currentPath.match(/\/v\d+\.|\/legacy\/|\/\d+\.\d+\//) && (
{showRetiredMessage && (
<div class="mb-8 p-5 rounded-lg bg-[#efca81]/20 dark:bg-[#efca81]/10 border-2 border-[#be9853]/50 dark:border-[#be9853]/30">
<h3 class="text-xl font-semibold mb-3 text-[#141313] dark:text-[#fff3f3]">Looking for an older version?</h3>
<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>
Expand Down