Skip to content

Commit fc37022

Browse files
fix(docs): separate main docs from versioned releases (#488)
* fix(docs): separate main docs from versioned releases * fix(docs): guard version archive edge cases * fix(docs): make latest release navigation release-aware * pr feedback * fix landing page flash * try again * clean up * fix: preserve version paths and smooth product switching * fix unit tests * chore: version and 404 adjustments (#490) * fix(docs): resolve release links and clean up 404 navigation * fix search functionality --------- Co-authored-by: Brian Watson <brianwatson@defenseunicorns.com>
1 parent 46312da commit fc37022

29 files changed

Lines changed: 1339 additions & 200 deletions

‎astro.config.mjs‎

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import starlightImageZoom from 'starlight-image-zoom';
1313
import starlightGitHubAlerts from 'starlight-github-alerts';
1414
import { fileURLToPath } from 'node:url';
1515
import { remarkLinkRewrite } from './src/plugins/remark-link-rewrite.ts';
16+
import { latestProductVersion } from './src/versionUtils.ts';
1617

1718
// Read per-product versions from .versions JSON (written by src/build/integration.ts).
1819
// Format: { "owner/repo": { "repo": "...", "branch": "...", "versions": [...], "latestTag": "..." } }
@@ -33,19 +34,24 @@ const productVersions = Object.fromEntries(
3334
return [p.id, available];
3435
})
3536
);
36-
const productLatestTags = Object.fromEntries(
37+
const productLatestVersions = Object.fromEntries(
3738
PRODUCTS.flatMap(p => {
38-
const tag = versionsByRepo[p.repo]?.latestTag;
39-
return tag ? [[p.id, tag]] : [];
39+
const latest = latestProductVersion(p, versionsByRepo);
40+
return latest ? [[p.id, latest]] : [];
4041
})
4142
);
42-
4343
// Build remark-link-rewrite options from product configs.
4444
// versionedSections provides per-version overrides for archived docs whose
4545
// sidebarOrder differs from the current product config.
4646
const linkRewriteProducts = PRODUCTS.map(p => ({
4747
contentDir: p.contentDir,
48+
channel: p.latestSource,
4849
sections: p.sidebarOrder.map(e => typeof e === 'string' ? e : e.dir),
50+
latestPrefix: p.latestSource
51+
? productLatestVersions[p.id]
52+
? `/${p.contentDir}/${productLatestVersions[p.id].slug}`
53+
: `/${p.contentDir}/${p.latestSource}`
54+
: `/${p.contentDir}`,
4955
versionedSections: Object.fromEntries(
5056
(productVersions[p.id] ?? []).map(v => {
5157
const verSidebarOrder = loadVersionSidebarOrder(p.repo, v.slug) ?? p.sidebarOrder;
@@ -83,13 +89,62 @@ function loadVersionSidebarOrder(repo, verSlug) {
8389
}
8490
}
8591

86-
// One sidebar topic per product (config loaded from .product-configs/).
87-
const productTopics = PRODUCTS.map((product) => ({
88-
id: product.id,
89-
label: product.label,
90-
link: product.link,
91-
items: makeSidebarItems(product.contentDir, product.sidebarOrder),
92-
}));
92+
// One sidebar topic per product. Products with a configured channel use the latest
93+
// release content for their sidebar, while the topic link stays at the
94+
// product root so the dropdown can distinguish it from version topics.
95+
const productTopics = PRODUCTS.map(product => {
96+
const latest = productLatestVersions[product.id];
97+
const useVersionedLatest = Boolean(product.latestSource && latest);
98+
const prefix = useVersionedLatest
99+
? `${product.contentDir}/${latest.slug}`
100+
: product.latestSource
101+
? `${product.contentDir}/${product.latestSource}`
102+
: product.contentDir;
103+
const sidebarOrder = useVersionedLatest
104+
? loadVersionSidebarOrder(product.repo, latest.slug) ?? product.sidebarOrder
105+
: product.sidebarOrder;
106+
107+
return {
108+
id: product.id,
109+
label: product.label,
110+
link: useVersionedLatest ? `/${prefix}/` : product.link,
111+
items: makeSidebarItems(prefix, sidebarOrder),
112+
};
113+
});
114+
115+
const channelTopics = PRODUCTS
116+
.filter(product => product.latestSource)
117+
.map(product => ({
118+
id: `${product.id}-${product.latestSource}`,
119+
label: product.label,
120+
link: `${product.link}${product.latestSource}/`,
121+
items: makeSidebarItems(`${product.contentDir}/${product.latestSource}`, product.sidebarOrder),
122+
}));
123+
124+
function productContentPrefixes(product) {
125+
const prefixes = product.latestSource
126+
? [
127+
productLatestVersions[product.id]
128+
? `${product.contentDir}/${productLatestVersions[product.id].slug}`
129+
: `${product.contentDir}/${product.latestSource}`,
130+
`${product.contentDir}/${product.latestSource}`,
131+
]
132+
: [product.contentDir];
133+
134+
return [...new Set([
135+
...prefixes,
136+
...(productVersions[product.id] ?? []).map(version => `${product.contentDir}/${version.slug}`),
137+
])];
138+
}
139+
140+
function productLatestContentPrefix(product) {
141+
if (product.latestSource) {
142+
return productLatestVersions[product.id]
143+
? `${product.contentDir}/${productLatestVersions[product.id].slug}`
144+
: `${product.contentDir}/${product.latestSource}`;
145+
}
146+
return product.contentDir;
147+
}
93148

94149
// One sidebar topic per archived version of each product.
95150
const versionedTopics = PRODUCTS.flatMap(product => {
@@ -109,10 +164,25 @@ const versionedTopics = PRODUCTS.flatMap(product => {
109164
// sidebar section (product root/index pages, 404 pages, versioned 404 pages).
110165
// Computed automatically from products; no manual unlistedPaths needed.
111166
const topicsOption = Object.fromEntries([
112-
...PRODUCTS.map((p, i) => [
113-
p.id,
114-
[`/${p.contentDir}`, `/${p.contentDir}/404`, ...(i === 0 ? ['/404'] : [])],
115-
]),
167+
...PRODUCTS.map((p, i) => {
168+
const latest = productLatestVersions[p.id];
169+
const productRoot = p.latestSource && latest
170+
? `/${p.contentDir}/${latest.slug}`
171+
: p.latestSource
172+
? `/${p.contentDir}/${p.latestSource}`
173+
: `/${p.contentDir}`;
174+
const paths = new Set([productRoot, `${productRoot}/404`, `/${p.contentDir}/404`]);
175+
return [
176+
p.id,
177+
[...paths, ...(i === 0 ? ['/404'] : [])],
178+
];
179+
}),
180+
...PRODUCTS
181+
.filter(product => product.latestSource)
182+
.map(product => [
183+
`${product.id}-${product.latestSource}`,
184+
[`/${product.contentDir}/${product.latestSource}`, `/${product.contentDir}/${product.latestSource}/404`],
185+
]),
116186
...PRODUCTS.flatMap(product => {
117187
const versions = productVersions[product.id] ?? [];
118188
return versions.map(v => {
@@ -122,14 +192,19 @@ const topicsOption = Object.fromEntries([
122192
}),
123193
]);
124194

195+
let generatedRedirects = {};
196+
try {
197+
generatedRedirects = JSON.parse(readFileSync('.product-configs/redirects.json', 'utf8'));
198+
} catch { /* not present in local dev */ }
199+
125200
// https://astro.build/config
126201
export default defineConfig({
127202
site: 'https://docs.defenseunicorns.com/docs/',
128203
prefetch: true,
129-
redirects:
130-
{
204+
redirects: {
131205
'/docs': '/',
132206
'/en': '/',
207+
...generatedRedirects,
133208
},
134209

135210
integrations: [
@@ -164,7 +239,7 @@ export default defineConfig({
164239
const entry = typeof e === 'string' ? { dir: e, label: titleCase(e) } : e;
165240
return {
166241
label: `${p.label} > ${entry.label}`,
167-
paths: [`${p.contentDir}/${entry.dir}/**`],
242+
paths: [`${productLatestContentPrefix(p)}/${entry.dir}/**`],
168243
};
169244
})
170245
),
@@ -173,9 +248,11 @@ export default defineConfig({
173248
// Core is first in products.json, so Core pages sort before CLI pages in llms-full.txt.
174249
promote: [
175250
'index*',
176-
...PRODUCTS.map(p => `${p.contentDir}/index*`),
251+
...PRODUCTS.flatMap(p => productContentPrefixes(p).map(prefix => `${prefix}/index*`)),
177252
...PRODUCTS.flatMap(p =>
178-
p.sidebarOrder.map(e => `${p.contentDir}/${typeof e === 'string' ? e : e.dir}/**`)
253+
productContentPrefixes(p).flatMap(prefix =>
254+
p.sidebarOrder.map(e => `${prefix}/${typeof e === 'string' ? e : e.dir}/**`)
255+
)
179256
),
180257
],
181258
minify: { note: true, tip: true, caution: true, danger: true, details: true, whitespace: true },
@@ -184,6 +261,7 @@ export default defineConfig({
184261
}),
185262
starlightSidebarTopics([
186263
...productTopics,
264+
...channelTopics,
187265
...versionedTopics,
188266
], { topics: topicsOption }),
189267
],
@@ -251,10 +329,16 @@ export default defineConfig({
251329
])
252330
)
253331
),
254-
// Per-product latest release tags for VersionPicker label
255-
__PRODUCT_LATEST_TAGS__: JSON.stringify(productLatestTags),
332+
// Latest release metadata for VersionPicker
333+
__PRODUCT_LATEST_VERSIONS__: JSON.stringify(productLatestVersions),
256334
// Product registry for client-side components (VersionPicker, Search)
257-
__PRODUCTS__: JSON.stringify(PRODUCTS.map(({ id, label, link, repo }) => ({ id, label, link, githubRepo: repo ?? null }))),
335+
__PRODUCTS__: JSON.stringify(PRODUCTS.map(({ id, label, link, repo, latestSource }) => ({
336+
id,
337+
label,
338+
link,
339+
githubRepo: repo ?? null,
340+
latestSource: latestSource ?? null,
341+
}))),
258342
},
259343
plugins: [
260344
tailwindcss(),

‎src/build/fileOps.spec.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,11 @@ describe('write404Page', () => {
173173
expect(content).toContain("doesn't exist in this version");
174174
expect(content).toContain('Version');
175175
});
176+
177+
it('latest versioned page identifies the latest release', () => {
178+
write404Page(join(tmpDir, '404.md'), true, true);
179+
const content = readFileSync(join(tmpDir, '404.md'), 'utf8');
180+
expect(content).toContain("doesn't exist in the latest release");
181+
expect(content).toContain('Version');
182+
});
176183
});

‎src/build/fileOps.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,19 @@ The page you're looking for doesn't exist or may have moved.
181181
Use the sidebar to navigate, or return to the product home.
182182
`;
183183

184-
const VERSIONED_404_BODY = `
185-
The page you're looking for doesn't exist in this version.
184+
function versioned404Body(scope: string): string {
185+
return `
186+
The page you're looking for doesn't exist in ${scope}.
186187
187188
Use the sidebar to navigate, or use the **Version** selector to switch to a different version.
188189
`;
190+
}
189191

190-
/** Write a `404.md` page — versioned variant mentions the Version selector. */
191-
export function write404Page(destPath: string, isVersioned: boolean): void {
192-
const body = isVersioned ? VERSIONED_404_BODY : NON_VERSIONED_404_BODY;
192+
/** Write a `404.md` page for the applicable product/version channel. */
193+
export function write404Page(destPath: string, isVersioned: boolean, isLatest = false): void {
194+
const body = !isVersioned
195+
? NON_VERSIONED_404_BODY
196+
: versioned404Body(isLatest ? 'the latest release' : 'this version');
193197
writeFileSync(destPath, PAGE_FRONTMATTER + body);
194198
}
195199

‎src/build/integration.spec.ts‎

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
33
import { join } from 'path';
44
import { tmpdir } from 'os';
55
import {
66
collectDirsDeepestFirst,
77
collectMarkdownFiles,
88
removeStaleVersionDirs,
9+
writeChannelRedirects,
910
} from './integration';
11+
import type { DocsConfig } from './types';
1012

1113
describe('collectDirsDeepestFirst', () => {
1214
let tmpDir: string;
@@ -53,9 +55,12 @@ describe('collectDirsDeepestFirst', () => {
5355
expect(result).toHaveLength(0);
5456
});
5557

56-
it('excludes version directories and their contents', () => {
58+
it('traverses version directories without collecting the version directory itself', () => {
5759
mkdir('core', 'v0-61', 'getting-started', 'local-demo');
58-
expect(collectDirsDeepestFirst(tmpDir)).toHaveLength(0);
60+
const result = collectDirsDeepestFirst(tmpDir);
61+
expect(result).toHaveLength(1);
62+
expect(result.every(path => !path.endsWith('v0-61'))).toBe(true);
63+
expect(result.some(path => path.endsWith('local-demo'))).toBe(true);
5964
});
6065

6166
it('does not exclude non-version dirs that start with "v"', () => {
@@ -91,12 +96,13 @@ describe('collectMarkdownFiles', () => {
9196
expect(collectMarkdownFiles(tmpDir)).toHaveLength(2);
9297
});
9398

94-
it('excludes files inside version directories', () => {
99+
it('includes files inside version directories', () => {
95100
touch('core/getting-started/overview.md');
96101
touch('core/v0-61/getting-started/overview.md');
97102
const result = collectMarkdownFiles(tmpDir);
98-
expect(result).toHaveLength(1);
99-
expect(result[0]).not.toContain('v0-61');
103+
expect(result).toHaveLength(2);
104+
expect(result.some(path => path.includes('v0-61'))).toBe(true);
105+
expect(result.some(path => !path.includes('v0-61'))).toBe(true);
100106
});
101107

102108
it('collects from nested dirs and root', () => {
@@ -148,3 +154,74 @@ describe('removeStaleVersionDirs', () => {
148154
expect(() => removeStaleVersionDirs(tmpDir)).not.toThrow();
149155
});
150156
});
157+
158+
describe('writeChannelRedirects', () => {
159+
let tmpDir: string;
160+
let targetDir: string;
161+
let configDir: string;
162+
163+
beforeEach(() => {
164+
tmpDir = mkdtempSync(join(tmpdir(), 'uds-channel-redirects-'));
165+
targetDir = join(tmpDir, 'content');
166+
configDir = join(tmpDir, 'config');
167+
mkdirSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging'), { recursive: true });
168+
mkdirSync(join(targetDir, 'core', 'v1-10'), { recursive: true });
169+
mkdirSync(configDir, { recursive: true });
170+
writeFileSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging', 'overview.md'), '');
171+
writeFileSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging', 'some-and-page.md'), '');
172+
});
173+
174+
afterEach(() => {
175+
rmSync(tmpDir, { recursive: true, force: true });
176+
});
177+
178+
const versions = {
179+
'defenseunicorns/uds-core': {
180+
latestTag: 'v1.10.0',
181+
versions: [{ display: 'v1.10', slug: 'v1-10' }],
182+
},
183+
};
184+
const config = {
185+
repo: 'defenseunicorns/uds-core',
186+
contentDir: 'core',
187+
} as DocsConfig & { repo: string };
188+
189+
it('writes current and legacy double-hyphen routes to the latest release', () => {
190+
writeChannelRedirects(
191+
versions,
192+
new Map([['uds-core', config]]),
193+
new Map([['uds-core', 'develop']]),
194+
{ 'Configuration & Packaging': 'configuration-and-packaging' },
195+
targetDir,
196+
configDir,
197+
);
198+
199+
const redirects = JSON.parse(readFileSync(join(configDir, 'redirects.json'), 'utf8')) as Record<string, string>;
200+
expect(redirects['/core/configuration-and-packaging/overview']).toBe(
201+
'/core/v1-10/configuration--packaging/overview/',
202+
);
203+
expect(redirects['/core/configuration--packaging/overview']).toBe(
204+
'/core/v1-10/configuration--packaging/overview/',
205+
);
206+
expect(redirects['/core/configuration--packaging/some-and-page']).toBe(
207+
'/core/v1-10/configuration--packaging/some-and-page/',
208+
);
209+
expect(redirects['/core/configuration--packaging/some--page']).toBeUndefined();
210+
});
211+
212+
it('keeps the root on the configured channel when the latest clone is unavailable', () => {
213+
rmSync(join(targetDir, 'core', 'v1-10'), { recursive: true, force: true });
214+
215+
writeChannelRedirects(
216+
versions,
217+
new Map([['uds-core', config]]),
218+
new Map([['uds-core', 'develop']]),
219+
{},
220+
targetDir,
221+
configDir,
222+
);
223+
224+
const redirects = JSON.parse(readFileSync(join(configDir, 'redirects.json'), 'utf8')) as Record<string, string>;
225+
expect(redirects['/core']).toBe('/core/develop/');
226+
});
227+
});

0 commit comments

Comments
 (0)