diff --git a/astro.config.mjs b/astro.config.mjs index cde93b58..1ec8c44f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -13,6 +13,7 @@ import starlightImageZoom from 'starlight-image-zoom'; import starlightGitHubAlerts from 'starlight-github-alerts'; import { fileURLToPath } from 'node:url'; import { remarkLinkRewrite } from './src/plugins/remark-link-rewrite.ts'; +import { latestProductVersion } from './src/versionUtils.ts'; // Read per-product versions from .versions JSON (written by src/build/integration.ts). // Format: { "owner/repo": { "repo": "...", "branch": "...", "versions": [...], "latestTag": "..." } } @@ -33,19 +34,24 @@ const productVersions = Object.fromEntries( return [p.id, available]; }) ); -const productLatestTags = Object.fromEntries( +const productLatestVersions = Object.fromEntries( PRODUCTS.flatMap(p => { - const tag = versionsByRepo[p.repo]?.latestTag; - return tag ? [[p.id, tag]] : []; + const latest = latestProductVersion(p, versionsByRepo); + return latest ? [[p.id, latest]] : []; }) ); - // Build remark-link-rewrite options from product configs. // versionedSections provides per-version overrides for archived docs whose // sidebarOrder differs from the current product config. const linkRewriteProducts = PRODUCTS.map(p => ({ contentDir: p.contentDir, + channel: p.latestSource, sections: p.sidebarOrder.map(e => typeof e === 'string' ? e : e.dir), + latestPrefix: p.latestSource + ? productLatestVersions[p.id] + ? `/${p.contentDir}/${productLatestVersions[p.id].slug}` + : `/${p.contentDir}/${p.latestSource}` + : `/${p.contentDir}`, versionedSections: Object.fromEntries( (productVersions[p.id] ?? []).map(v => { const verSidebarOrder = loadVersionSidebarOrder(p.repo, v.slug) ?? p.sidebarOrder; @@ -83,13 +89,62 @@ function loadVersionSidebarOrder(repo, verSlug) { } } -// One sidebar topic per product (config loaded from .product-configs/). -const productTopics = PRODUCTS.map((product) => ({ - id: product.id, - label: product.label, - link: product.link, - items: makeSidebarItems(product.contentDir, product.sidebarOrder), -})); +// One sidebar topic per product. Products with a configured channel use the latest +// release content for their sidebar, while the topic link stays at the +// product root so the dropdown can distinguish it from version topics. +const productTopics = PRODUCTS.map(product => { + const latest = productLatestVersions[product.id]; + const useVersionedLatest = Boolean(product.latestSource && latest); + const prefix = useVersionedLatest + ? `${product.contentDir}/${latest.slug}` + : product.latestSource + ? `${product.contentDir}/${product.latestSource}` + : product.contentDir; + const sidebarOrder = useVersionedLatest + ? loadVersionSidebarOrder(product.repo, latest.slug) ?? product.sidebarOrder + : product.sidebarOrder; + + return { + id: product.id, + label: product.label, + link: useVersionedLatest ? `/${prefix}/` : product.link, + items: makeSidebarItems(prefix, sidebarOrder), + }; +}); + +const channelTopics = PRODUCTS + .filter(product => product.latestSource) + .map(product => ({ + id: `${product.id}-${product.latestSource}`, + label: product.label, + link: `${product.link}${product.latestSource}/`, + items: makeSidebarItems(`${product.contentDir}/${product.latestSource}`, product.sidebarOrder), + })); + +function productContentPrefixes(product) { + const prefixes = product.latestSource + ? [ + productLatestVersions[product.id] + ? `${product.contentDir}/${productLatestVersions[product.id].slug}` + : `${product.contentDir}/${product.latestSource}`, + `${product.contentDir}/${product.latestSource}`, + ] + : [product.contentDir]; + + return [...new Set([ + ...prefixes, + ...(productVersions[product.id] ?? []).map(version => `${product.contentDir}/${version.slug}`), + ])]; +} + +function productLatestContentPrefix(product) { + if (product.latestSource) { + return productLatestVersions[product.id] + ? `${product.contentDir}/${productLatestVersions[product.id].slug}` + : `${product.contentDir}/${product.latestSource}`; + } + return product.contentDir; +} // One sidebar topic per archived version of each product. const versionedTopics = PRODUCTS.flatMap(product => { @@ -109,10 +164,25 @@ const versionedTopics = PRODUCTS.flatMap(product => { // sidebar section (product root/index pages, 404 pages, versioned 404 pages). // Computed automatically from products; no manual unlistedPaths needed. const topicsOption = Object.fromEntries([ - ...PRODUCTS.map((p, i) => [ - p.id, - [`/${p.contentDir}`, `/${p.contentDir}/404`, ...(i === 0 ? ['/404'] : [])], - ]), + ...PRODUCTS.map((p, i) => { + const latest = productLatestVersions[p.id]; + const productRoot = p.latestSource && latest + ? `/${p.contentDir}/${latest.slug}` + : p.latestSource + ? `/${p.contentDir}/${p.latestSource}` + : `/${p.contentDir}`; + const paths = new Set([productRoot, `${productRoot}/404`, `/${p.contentDir}/404`]); + return [ + p.id, + [...paths, ...(i === 0 ? ['/404'] : [])], + ]; + }), + ...PRODUCTS + .filter(product => product.latestSource) + .map(product => [ + `${product.id}-${product.latestSource}`, + [`/${product.contentDir}/${product.latestSource}`, `/${product.contentDir}/${product.latestSource}/404`], + ]), ...PRODUCTS.flatMap(product => { const versions = productVersions[product.id] ?? []; return versions.map(v => { @@ -122,14 +192,19 @@ const topicsOption = Object.fromEntries([ }), ]); +let generatedRedirects = {}; +try { + generatedRedirects = JSON.parse(readFileSync('.product-configs/redirects.json', 'utf8')); +} catch { /* not present in local dev */ } + // https://astro.build/config export default defineConfig({ site: 'https://docs.defenseunicorns.com/docs/', prefetch: true, - redirects: - { + redirects: { '/docs': '/', '/en': '/', + ...generatedRedirects, }, integrations: [ @@ -164,7 +239,7 @@ export default defineConfig({ const entry = typeof e === 'string' ? { dir: e, label: titleCase(e) } : e; return { label: `${p.label} > ${entry.label}`, - paths: [`${p.contentDir}/${entry.dir}/**`], + paths: [`${productLatestContentPrefix(p)}/${entry.dir}/**`], }; }) ), @@ -173,9 +248,11 @@ export default defineConfig({ // Core is first in products.json, so Core pages sort before CLI pages in llms-full.txt. promote: [ 'index*', - ...PRODUCTS.map(p => `${p.contentDir}/index*`), + ...PRODUCTS.flatMap(p => productContentPrefixes(p).map(prefix => `${prefix}/index*`)), ...PRODUCTS.flatMap(p => - p.sidebarOrder.map(e => `${p.contentDir}/${typeof e === 'string' ? e : e.dir}/**`) + productContentPrefixes(p).flatMap(prefix => + p.sidebarOrder.map(e => `${prefix}/${typeof e === 'string' ? e : e.dir}/**`) + ) ), ], minify: { note: true, tip: true, caution: true, danger: true, details: true, whitespace: true }, @@ -184,6 +261,7 @@ export default defineConfig({ }), starlightSidebarTopics([ ...productTopics, + ...channelTopics, ...versionedTopics, ], { topics: topicsOption }), ], @@ -251,10 +329,16 @@ export default defineConfig({ ]) ) ), - // Per-product latest release tags for VersionPicker label - __PRODUCT_LATEST_TAGS__: JSON.stringify(productLatestTags), + // Latest release metadata for VersionPicker + __PRODUCT_LATEST_VERSIONS__: JSON.stringify(productLatestVersions), // Product registry for client-side components (VersionPicker, Search) - __PRODUCTS__: JSON.stringify(PRODUCTS.map(({ id, label, link, repo }) => ({ id, label, link, githubRepo: repo ?? null }))), + __PRODUCTS__: JSON.stringify(PRODUCTS.map(({ id, label, link, repo, latestSource }) => ({ + id, + label, + link, + githubRepo: repo ?? null, + latestSource: latestSource ?? null, + }))), }, plugins: [ tailwindcss(), diff --git a/src/build/fileOps.spec.ts b/src/build/fileOps.spec.ts index 0acd032b..4f0235d2 100644 --- a/src/build/fileOps.spec.ts +++ b/src/build/fileOps.spec.ts @@ -173,4 +173,11 @@ describe('write404Page', () => { expect(content).toContain("doesn't exist in this version"); expect(content).toContain('Version'); }); + + it('latest versioned page identifies the latest release', () => { + write404Page(join(tmpDir, '404.md'), true, true); + const content = readFileSync(join(tmpDir, '404.md'), 'utf8'); + expect(content).toContain("doesn't exist in the latest release"); + expect(content).toContain('Version'); + }); }); diff --git a/src/build/fileOps.ts b/src/build/fileOps.ts index 4825e901..99bb2df8 100644 --- a/src/build/fileOps.ts +++ b/src/build/fileOps.ts @@ -181,15 +181,19 @@ The page you're looking for doesn't exist or may have moved. Use the sidebar to navigate, or return to the product home. `; -const VERSIONED_404_BODY = ` -The page you're looking for doesn't exist in this version. +function versioned404Body(scope: string): string { + return ` +The page you're looking for doesn't exist in ${scope}. Use the sidebar to navigate, or use the **Version** selector to switch to a different version. `; +} -/** Write a `404.md` page — versioned variant mentions the Version selector. */ -export function write404Page(destPath: string, isVersioned: boolean): void { - const body = isVersioned ? VERSIONED_404_BODY : NON_VERSIONED_404_BODY; +/** Write a `404.md` page for the applicable product/version channel. */ +export function write404Page(destPath: string, isVersioned: boolean, isLatest = false): void { + const body = !isVersioned + ? NON_VERSIONED_404_BODY + : versioned404Body(isLatest ? 'the latest release' : 'this version'); writeFileSync(destPath, PAGE_FRONTMATTER + body); } diff --git a/src/build/integration.spec.ts b/src/build/integration.spec.ts index fb2e8ec6..bb333254 100644 --- a/src/build/integration.spec.ts +++ b/src/build/integration.spec.ts @@ -1,12 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { collectDirsDeepestFirst, collectMarkdownFiles, removeStaleVersionDirs, + writeChannelRedirects, } from './integration'; +import type { DocsConfig } from './types'; describe('collectDirsDeepestFirst', () => { let tmpDir: string; @@ -53,9 +55,12 @@ describe('collectDirsDeepestFirst', () => { expect(result).toHaveLength(0); }); - it('excludes version directories and their contents', () => { + it('traverses version directories without collecting the version directory itself', () => { mkdir('core', 'v0-61', 'getting-started', 'local-demo'); - expect(collectDirsDeepestFirst(tmpDir)).toHaveLength(0); + const result = collectDirsDeepestFirst(tmpDir); + expect(result).toHaveLength(1); + expect(result.every(path => !path.endsWith('v0-61'))).toBe(true); + expect(result.some(path => path.endsWith('local-demo'))).toBe(true); }); it('does not exclude non-version dirs that start with "v"', () => { @@ -91,12 +96,13 @@ describe('collectMarkdownFiles', () => { expect(collectMarkdownFiles(tmpDir)).toHaveLength(2); }); - it('excludes files inside version directories', () => { + it('includes files inside version directories', () => { touch('core/getting-started/overview.md'); touch('core/v0-61/getting-started/overview.md'); const result = collectMarkdownFiles(tmpDir); - expect(result).toHaveLength(1); - expect(result[0]).not.toContain('v0-61'); + expect(result).toHaveLength(2); + expect(result.some(path => path.includes('v0-61'))).toBe(true); + expect(result.some(path => !path.includes('v0-61'))).toBe(true); }); it('collects from nested dirs and root', () => { @@ -148,3 +154,74 @@ describe('removeStaleVersionDirs', () => { expect(() => removeStaleVersionDirs(tmpDir)).not.toThrow(); }); }); + +describe('writeChannelRedirects', () => { + let tmpDir: string; + let targetDir: string; + let configDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'uds-channel-redirects-')); + targetDir = join(tmpDir, 'content'); + configDir = join(tmpDir, 'config'); + mkdirSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging'), { recursive: true }); + mkdirSync(join(targetDir, 'core', 'v1-10'), { recursive: true }); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging', 'overview.md'), ''); + writeFileSync(join(targetDir, 'core', 'develop', 'Configuration & Packaging', 'some-and-page.md'), ''); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + const versions = { + 'defenseunicorns/uds-core': { + latestTag: 'v1.10.0', + versions: [{ display: 'v1.10', slug: 'v1-10' }], + }, + }; + const config = { + repo: 'defenseunicorns/uds-core', + contentDir: 'core', + } as DocsConfig & { repo: string }; + + it('writes current and legacy double-hyphen routes to the latest release', () => { + writeChannelRedirects( + versions, + new Map([['uds-core', config]]), + new Map([['uds-core', 'develop']]), + { 'Configuration & Packaging': 'configuration-and-packaging' }, + targetDir, + configDir, + ); + + const redirects = JSON.parse(readFileSync(join(configDir, 'redirects.json'), 'utf8')) as Record; + expect(redirects['/core/configuration-and-packaging/overview']).toBe( + '/core/v1-10/configuration--packaging/overview/', + ); + expect(redirects['/core/configuration--packaging/overview']).toBe( + '/core/v1-10/configuration--packaging/overview/', + ); + expect(redirects['/core/configuration--packaging/some-and-page']).toBe( + '/core/v1-10/configuration--packaging/some-and-page/', + ); + expect(redirects['/core/configuration--packaging/some--page']).toBeUndefined(); + }); + + it('keeps the root on the configured channel when the latest clone is unavailable', () => { + rmSync(join(targetDir, 'core', 'v1-10'), { recursive: true, force: true }); + + writeChannelRedirects( + versions, + new Map([['uds-core', config]]), + new Map([['uds-core', 'develop']]), + {}, + targetDir, + configDir, + ); + + const redirects = JSON.parse(readFileSync(join(configDir, 'redirects.json'), 'utf8')) as Record; + expect(redirects['/core']).toBe('/core/develop/'); + }); +}); diff --git a/src/build/integration.ts b/src/build/integration.ts index 07515e90..e9733f55 100644 --- a/src/build/integration.ts +++ b/src/build/integration.ts @@ -63,6 +63,12 @@ export async function main(): Promise { // ------------------------------------------------------------------ const products = readProductsJson(process.env.PRODUCTS_JSON); + const productChannels = new Map( + products + .filter(product => product.branch) + .map(product => [product.repo.split('/').pop()!, product.branch!]), + ); + const channelNames = new Set(productChannels.values()); const repoOverrides = Object.fromEntries( Object.entries(overrides).filter(([k]) => !k.includes('@')), @@ -119,11 +125,21 @@ export async function main(): Promise { if (!contentDir) { throw new Error(`contentDir is missing or empty in docs.config.json for ${repo}`); } - const destDir = join(TARGET_DIR, contentDir); + const productRoot = join(TARGET_DIR, contentDir); + const channel = productChannels.get(repoName); + if (channel) { + // Channel docs live behind an explicit URL channel. Remove any root + // content left by builds from the previous unversioned layout. + rmSync(productRoot, { recursive: true, force: true }); + } + const destDir = channel ? join(productRoot, channel) : productRoot; console.log(`Copying docs from ${docsSource}/ to ${destDir}`); copyDocs(docsSource, destDir); - cleanupUnlistedDirs(destDir, docsConfig, contentDir); + cleanupUnlistedDirs(destDir, docsConfig, channel ? `${contentDir}/${channel}` : contentDir); + if (channel) { + write404Page(join(productRoot, '404.md'), false); + } const c4Dir = join(docsSource, '.c4'); if (existsSync(c4Dir)) { @@ -140,9 +156,13 @@ export async function main(): Promise { for (const config of configCache.values()) { const contentDir = config.contentDir; if (!contentDir) continue; - const dir = join(TARGET_DIR, contentDir); + const repoName = config.repo.split('/').pop()!; + const channel = productChannels.get(repoName); + const dir = channel + ? join(TARGET_DIR, contentDir, channel) + : join(TARGET_DIR, contentDir); if (!existsSync(dir)) continue; - write404Page(join(dir, '404.md'), false); + write404Page(join(dir, '404.md'), Boolean(channel)); } // ------------------------------------------------------------------ @@ -233,7 +253,8 @@ export async function main(): Promise { } rmSync(join(versionDir, 'README.md'), { force: true }); - write404Page(join(versionDir, '404.md'), true); + const latestDisplay = entry.latestTag ? minorKey(entry.latestTag) : undefined; + write404Page(join(versionDir, '404.md'), true, latestDisplay === ver.display); console.log(`Versioned docs for ${productId} ${verRef} written to ${versionDir}`); } } @@ -244,6 +265,11 @@ export async function main(): Promise { for (const config of configCache.values()) { rmSync(join(TARGET_DIR, config.contentDir, 'README.md'), { force: true }); + const repoName = config.repo.split('/').pop()!; + const channel = productChannels.get(repoName); + if (channel) { + rmSync(join(TARGET_DIR, config.contentDir, channel, 'README.md'), { force: true }); + } } // ------------------------------------------------------------------ @@ -253,9 +279,9 @@ export async function main(): Promise { console.log('Renaming hyphenated directories to Title Case...'); const dirRenames: Record = {}; - const slugRenames: Array<[string, string]> = []; + const slugRenamesByScope = new Map>(); - const dirsToRename = collectDirsDeepestFirst(TARGET_DIR); + const dirsToRename = collectDirsDeepestFirst(TARGET_DIR, channelNames); for (const fullPath of dirsToRename) { const base = basename(fullPath); const newBase = toTitleCase(base); @@ -274,8 +300,15 @@ export async function main(): Promise { // Track slug renames for "&" dirs — section-relative paths (strip contentDir/). if (newBase.includes('&')) { const relOld = fullPath.slice(TARGET_DIR.length + 1); - const sectionOld = relOld.slice(relOld.indexOf('/') + 1); - slugRenames.push(computeSlugRename(sectionOld)); + const parts = relOld.split('/'); + const contentDir = parts.shift()!; + const channel = VERSION_SLUG_RE.test(parts[0] ?? '') || channelNames.has(parts[0] ?? '') + ? parts.shift()! + : ''; + const scope = `${contentDir}/${channel}`; + const renames = slugRenamesByScope.get(scope) ?? []; + renames.push(computeSlugRename(parts.join('/'))); + slugRenamesByScope.set(scope, renames); } } @@ -290,19 +323,22 @@ export async function main(): Promise { .map(k => [k, dirRenames[k]]), ); writeFileSync(join(CONFIG_DIR, 'dir-renames.json'), JSON.stringify(sortedRenames, null, 2) + '\n'); + writeChannelRedirects(versions, configCache, productChannels, sortedRenames); // ------------------------------------------------------------------ // Step 8: Rewrite internal markdown links // ------------------------------------------------------------------ - if (slugRenames.length > 0) { + if (slugRenamesByScope.size > 0) { console.log('Updating internal links for renamed directories...'); - const mdFiles = collectMarkdownFiles(TARGET_DIR); - for (const file of mdFiles) { - const original = readFileSync(file, 'utf8'); - const rewritten = rewriteLinks(original, slugRenames); - if (rewritten !== original) { - writeFileSync(file, rewritten); + for (const [scope, renames] of slugRenamesByScope) { + const mdFiles = collectMarkdownFiles(join(TARGET_DIR, ...scope.split('/').filter(Boolean))); + for (const file of mdFiles) { + const original = readFileSync(file, 'utf8'); + const rewritten = rewriteLinks(original, renames); + if (rewritten !== original) { + writeFileSync(file, rewritten); + } } } } @@ -331,6 +367,84 @@ function readDocsConfig(configPath: string): DocsConfig { return JSON.parse(readFileSync(configPath, 'utf8')) as DocsConfig; } +export function writeChannelRedirects( + versions: Record }>, + configCache: Map, + productChannels: Map, + dirRenames: Record, + targetDir = TARGET_DIR, + configDir = CONFIG_DIR, +): void { + const redirects: Record = {}; + + for (const [repo, entry] of Object.entries(versions)) { + const repoName = repo.split('/').pop()!; + const channel = productChannels.get(repoName); + if (!channel) continue; + + const config = configCache.get(repoName); + if (!config) continue; + + const contentDir = config.contentDir; + if (!entry.latestTag) { + redirects[`/${contentDir}`] = `/${contentDir}/${channel}/`; + continue; + } + + const latestDisplay = minorKey(entry.latestTag); + const latestVersion = entry.versions?.find(version => version.display === latestDisplay); + const latestDir = latestVersion + ? join(targetDir, contentDir, latestVersion.slug) + : ''; + if (!latestVersion || !existsSync(latestDir)) { + redirects[`/${contentDir}`] = `/${contentDir}/${channel}/`; + continue; + } + + const channelDir = join(targetDir, contentDir, channel); + redirects[`/${contentDir}`] = `/${contentDir}/${latestVersion.slug}/`; + + for (const file of collectMarkdownFiles(channelDir)) { + const relativePath = file.slice(channelDir.length + 1); + const segments = relativePath.split('/'); + if (segments.some(segment => segment.startsWith('.'))) continue; + const filename = segments.pop()!; + const stem = filename.replace(/\.(?:md|mdx)$/, ''); + if (stem === '404') continue; + + const oldDirs = segments.map(segment => dirRenames[segment] ?? segment); + const targetDirs = oldDirs.map(toDirectoryUrlSlug); + const oldParts = [...oldDirs]; + const targetParts = [...targetDirs]; + if (stem !== 'index') { + oldParts.push(stem); + targetParts.push(toUrlSlug(stem)); + } + if (oldParts.length === 0) continue; + + const legacySourceParts = oldParts.map((part, index) => { + const isFilename = stem !== 'index' && index === oldParts.length - 1; + return isFilename ? part : part.replaceAll('-and-', '--'); + }); + const sourcePaths = new Set([oldParts.join('/'), legacySourceParts.join('/')]); + for (const sourcePath of sourcePaths) { + redirects[`/${contentDir}/${sourcePath}`] = + `/${contentDir}/${latestVersion.slug}/${targetParts.join('/')}/`; + } + } + } + + writeFileSync(join(configDir, 'redirects.json'), JSON.stringify(redirects, null, 2) + '\n'); +} + +function toUrlSlug(segment: string): string { + return segment.replace(/ & /g, '--').replace(/\s+/g, '-').toLowerCase(); +} + +function toDirectoryUrlSlug(segment: string): string { + return toUrlSlug(segment.replaceAll('-and-', '--')); +} + /** Remove version-slug directories at maxdepth 1 inside `dir`. */ export function removeStaleVersionDirs(dir: string): void { if (!existsSync(dir)) return; @@ -378,9 +492,10 @@ function fatalMissingConfig(location: string): never { /** * Collect depth-3+ directories in post-order (deepest first). - * Skips dot-directories and version directories. + * Skips dot-directories and treats version directories as transparent URL + * prefixes so their archived content receives the same renames as latest. */ -export function collectDirsDeepestFirst(targetDir: string): string[] { +export function collectDirsDeepestFirst(targetDir: string, channelNames = new Set()): string[] { const result: string[] = []; function walk(dir: string, depth: number): void { @@ -394,9 +509,19 @@ export function collectDirsDeepestFirst(targetDir: string): string[] { for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('.')) continue; - if (VERSION_SLUG_RE.test(entry.name)) continue; const fullPath = join(dir, entry.name); + if (channelNames.has(entry.name)) { + // Configured channels are URL prefixes, not content sections. Keep their children at + // the same depth as the product's regular sections. + walk(fullPath, depth); + continue; + } + if (VERSION_SLUG_RE.test(entry.name)) { + // A version directory is part of the URL, not the content hierarchy. + walk(fullPath, depth); + continue; + } walk(fullPath, depth + 1); if (depth >= 3) { @@ -413,7 +538,7 @@ export function collectDirsDeepestFirst(targetDir: string): string[] { // Markdown file collection // --------------------------------------------------------------------------- -/** Collect all .md/.mdx files under `targetDir`, excluding version directories. */ +/** Collect all .md/.mdx files under `targetDir`, including archived versions. */ export function collectMarkdownFiles(targetDir: string): string[] { const result: string[] = []; @@ -429,7 +554,6 @@ export function collectMarkdownFiles(targetDir: string): string[] { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - if (VERSION_SLUG_RE.test(entry.name)) continue; walk(fullPath); } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) { result.push(fullPath); diff --git a/src/build/remark-link-rewrite.spec.ts b/src/build/remark-link-rewrite.spec.ts new file mode 100644 index 00000000..01347318 --- /dev/null +++ b/src/build/remark-link-rewrite.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import type { Html, Link, Root } from 'mdast'; +import type { VFile } from 'vfile'; +import { remarkLinkRewrite } from '../plugins/remark-link-rewrite'; + +const products = [ + { contentDir: 'cli', channel: 'develop', sections: ['getting-started'], latestPrefix: '/cli/v9-8' }, + { contentDir: 'core', channel: 'develop', sections: ['concepts'], latestPrefix: '/core/v2-3' }, +]; + +function transform(tree: Root): Root { + const rewrite = remarkLinkRewrite({ products, srcDir: '/content/docs' }); + rewrite(tree, { path: '/content/docs/cli/develop/index.mdx' } as VFile); + return tree; +} + +function link(url: string): Link { + return { type: 'link', title: null, url, children: [] }; +} + +describe('remarkLinkRewrite', () => { + it('rewrites cross-product links to the configured product latest release', () => { + const tree = transform({ + type: 'root', + children: [ + link('/core/concepts/overview/'), + link('/core/concepts/configuration-and-packaging/bundles/'), + link('/core/'), + link('/core/develop/concepts/overview/'), + link('/core/v1-7/concepts/overview/'), + link('/core/unknown/'), + link('/cli/getting-started/installation/'), + link('/getting-started/installation/'), + ], + }); + + const urls = tree.children.map(child => (child as Link).url); + expect(urls).toEqual([ + '/core/v2-3/concepts/overview/', + '/core/v2-3/concepts/configuration--packaging/bundles/', + '/core/v2-3/', + '/core/develop/concepts/overview/', + '/core/v1-7/concepts/overview/', + '/core/unknown/', + '/cli/develop/getting-started/installation/', + '/cli/develop/getting-started/installation/', + ]); + }); + + it('rewrites cross-product links in raw HTML attributes', () => { + const tree = transform({ + type: 'root', + children: [ + { type: 'html', value: 'Core' } as Html, + ], + }); + + expect((tree.children[0] as Html).value).toBe( + 'Core', + ); + }); +}); diff --git a/src/build/routeData.spec.ts b/src/build/routeData.spec.ts new file mode 100644 index 00000000..16a93712 --- /dev/null +++ b/src/build/routeData.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { latestReleaseHref } from '../routeData'; + +const product = { + contentDir: 'core', + link: '/core/', + latestSource: 'develop', +}; + +describe('latestReleaseHref', () => { + it('keeps the current page path when it exists in the latest release', () => { + const latestFile = 'src/content/docs/core/v1-10/concepts/Core Features/networking.mdx'; + + expect( + latestReleaseHref( + product, + 'v1-10', + 'v1-8', + 'core/v1-8/concepts/core-features/networking', + path => path === latestFile, + { 'core-features': 'Core Features' }, + ), + ).toBe('/core/v1-10/concepts/core-features/networking/'); + }); + + it('falls back to the product root when the latest page is missing', () => { + expect( + latestReleaseHref( + product, + 'v1-10', + 'v1-8', + 'core/v1-8/concepts/core-features/removed-page', + () => false, + ), + ).toBe('/core/v1-10/'); + }); + + it('checks the product root for products without a configured channel', () => { + const cliProduct = { + contentDir: 'cli', + link: '/cli/', + latestSource: undefined, + }; + const latestFile = 'src/content/docs/cli/commands/apply.mdx'; + + expect( + latestReleaseHref( + cliProduct, + 'v9-8', + 'v9-7', + 'cli/v9-7/commands/apply', + path => path === latestFile, + ), + ).toBe('/cli/commands/apply/'); + }); + + it('resolves latest files from the normalized route path', () => { + const latestFile = 'src/content/docs/core/v1-10/concepts/Core Features/networking.mdx'; + + expect( + latestReleaseHref( + product, + 'v1-10', + 'develop', + 'core/develop/concepts/core-features/networking', + path => path === latestFile, + { 'core-features': 'Core Features' }, + ), + ).toBe('/core/v1-10/concepts/core-features/networking/'); + }); + + it('resolves URL slugs for directories renamed with an ampersand', () => { + const latestFile = 'src/content/docs/core/v1-10/concepts/Configuration & Packaging/networking.mdx'; + + expect( + latestReleaseHref( + product, + 'v1-10', + 'v1-8', + 'core/v1-8/concepts/configuration--packaging/networking', + path => path === latestFile, + { 'configuration--packaging': 'Configuration & Packaging' }, + ), + ).toBe('/core/v1-10/concepts/configuration--packaging/networking/'); + }); +}); diff --git a/src/build/versions.spec.ts b/src/build/versions.spec.ts index 14118be4..a59c3884 100644 --- a/src/build/versions.spec.ts +++ b/src/build/versions.spec.ts @@ -11,6 +11,7 @@ import { parseOverrides, toArchivedVersion, } from './versions'; +import { latestProductVersion, latestVersionFor } from '../versionUtils'; describe('minorKey', () => { it('strips patch version', () => { @@ -20,6 +21,49 @@ describe('minorKey', () => { }); }); +describe('latestVersionFor', () => { + it('normalizes the latest tag and preserves the discovered ref', () => { + expect(latestVersionFor({ + latestTag: 'v1.2.3', + versions: [{ ref: 'v1.2.3', display: 'v1.2', slug: 'v1-2' }], + })).toEqual({ ref: 'v1.2.3', display: 'v1.2', slug: 'v1-2' }); + }); + + it('derives a slug when the latest version is absent from the archive list', () => { + expect(latestVersionFor({ latestTag: 'v1.2.3' })).toEqual({ + ref: 'v1.2.3', + display: 'v1.2', + slug: 'v1-2', + }); + }); +}); + +describe('latestProductVersion', () => { + const product = { + repo: 'defenseunicorns/uds-core', + contentDir: 'core', + latestSource: 'main', + }; + const versions = { + 'defenseunicorns/uds-core': { + latestTag: 'v1.2.3', + versions: [{ ref: 'release/1.2', display: 'v1.2', slug: 'v1-2' }], + }, + }; + + it('returns the latest release when generated content exists', () => { + expect(latestProductVersion(product, versions, path => path.endsWith('/v1-2'))).toEqual({ + ref: 'release/1.2', + display: 'v1.2', + slug: 'v1-2', + }); + }); + + it('skips the latest release when channel content is missing', () => { + expect(latestProductVersion(product, versions, () => false)).toBeNull(); + }); +}); + describe('fetchDocsConfig', () => { let tmpDir: string; @@ -332,6 +376,86 @@ describe('discoverAllVersions with versionSource', () => { expect(calls.some(c => c.includes('matching-refs'))).toBe(true); }); + it('includes the current release when latest docs use an explicit branch', async () => { + vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { + const urlStr = url.toString(); + if (new URL(urlStr).hostname === 'raw.githubusercontent.com') { + return { + ok: true, + json: () => Promise.resolve({ + id: 'core', label: 'Core', contentDir: 'core', + archiveCount: 2, versionSource: 'branch', sidebarOrder: [], + }), + } as Response; + } + if (urlStr.includes('matching-refs')) { + return { + ok: true, + json: () => Promise.resolve([ + { ref: 'refs/heads/release/1.0' }, + { ref: 'refs/heads/release/0.63' }, + { ref: 'refs/heads/release/0.62' }, + ]), + } as Response; + } + if (urlStr.includes('/releases')) { + return { + ok: true, + json: () => Promise.resolve([ + { tag_name: 'v1.0.0', prerelease: false, draft: false }, + ]), + } as Response; + } + return { ok: false, status: 404 } as Response; + }); + + const result = await discoverAllVersions( + [{ repo: 'defenseunicorns/uds-core', branch: 'main' }], + {}, + ); + const entry = result['defenseunicorns/uds-core']; + expect(entry.branch).toBe('main'); + expect(entry.latestTag).toBe('v1.0.0'); + expect(entry.versions).toEqual([ + { ref: 'release/1.0', display: 'v1.0', slug: 'v1-0' }, + { ref: 'release/0.63', display: 'v0.63', slug: 'v0-63' }, + { ref: 'release/0.62', display: 'v0.62', slug: 'v0-62' }, + ]); + }); + + it('includes the latest release when archiveCount is zero with an explicit branch', async () => { + vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { + const urlStr = url.toString(); + if (new URL(urlStr).hostname === 'raw.githubusercontent.com') { + return { + ok: true, + json: () => Promise.resolve({ + id: 'cli', label: 'CLI', contentDir: 'cli', + archiveCount: 0, sidebarOrder: [], + }), + } as Response; + } + if (urlStr.includes('/releases')) { + return { + ok: true, + json: () => Promise.resolve([ + { tag_name: 'v1.0.0', prerelease: false, draft: false }, + { tag_name: 'v0.63.0', prerelease: false, draft: false }, + ]), + } as Response; + } + return { ok: false, status: 404 } as Response; + }); + + const result = await discoverAllVersions( + [{ repo: 'defenseunicorns/uds-cli', branch: 'main' }], + {}, + ); + expect(result['defenseunicorns/uds-cli'].versions).toEqual([ + { ref: 'v1.0.0', display: 'v1.0', slug: 'v1-0' }, + ]); + }); + it('defaults to tag discovery when versionSource is not set', async () => { const calls: string[] = []; vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { @@ -370,6 +494,42 @@ describe('discoverAllVersions with versionSource', () => { expect(calls.some(c => c.includes('matching-refs'))).toBe(false); }); + it('does not duplicate the latest tag when an explicit source matches it', async () => { + vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { + const urlStr = url.toString(); + if (new URL(urlStr).hostname === 'raw.githubusercontent.com') { + return { + ok: true, + json: () => Promise.resolve({ + id: 'cli', label: 'CLI', contentDir: 'cli', + archiveCount: 2, versionSource: 'tag', sidebarOrder: [], + }), + } as Response; + } + if (urlStr.includes('/releases')) { + return { + ok: true, + json: () => Promise.resolve([ + { tag_name: 'v1.0.0', prerelease: false, draft: false }, + { tag_name: 'v0.63.0', prerelease: false, draft: false }, + { tag_name: 'v0.62.0', prerelease: false, draft: false }, + ]), + } as Response; + } + return { ok: false, status: 404 } as Response; + }); + + const result = await discoverAllVersions( + [{ repo: 'defenseunicorns/uds-cli', branch: 'v1.0.0' }], + {}, + ); + + expect(result['defenseunicorns/uds-cli'].versions).toEqual([ + { ref: 'v0.63.0', display: 'v0.63', slug: 'v0-63' }, + { ref: 'v0.62.0', display: 'v0.62', slug: 'v0-62' }, + ]); + }); + it('excludes the branch matching the current release', async () => { vi.mocked(fetch).mockImplementation(async (url: string | URL | Request) => { const urlStr = url.toString(); @@ -405,7 +565,7 @@ describe('discoverAllVersions with versionSource', () => { }); const result = await discoverAllVersions( - [{ repo: 'defenseunicorns/uds-core' }], + [{ repo: 'defenseunicorns/uds-core', branch: 'release/1.0' }], {}, ); const entry = result['defenseunicorns/uds-core']; diff --git a/src/build/versions.ts b/src/build/versions.ts index 14c6e473..faf7b4cc 100644 --- a/src/build/versions.ts +++ b/src/build/versions.ts @@ -8,15 +8,9 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import type { ArchivedVersion, DocsConfig, OverridesMap, VersionEntry, VersionsFile } from './types'; +import { latestVersionFor, minorKey } from '../versionUtils'; -// --------------------------------------------------------------------------- -// Minor version key -// --------------------------------------------------------------------------- - -/** Strip the patch version from a semver tag: `v0.61.1` → `v0.61`. */ -export function minorKey(tag: string): string { - return tag.replace(/\.\d+$/, ''); -} +export { minorKey }; // --------------------------------------------------------------------------- // Archived version normalization @@ -56,6 +50,15 @@ export function toArchivedVersion(ref: string): ArchivedVersion { }; } +function refsShareMinorVersion(first: string | undefined, second: string | null): boolean { + if (!first || !second) return false; + try { + return toArchivedVersion(first).display === toArchivedVersion(second).display; + } catch { + return false; + } +} + // --------------------------------------------------------------------------- // docs.config.json fetching // --------------------------------------------------------------------------- @@ -277,31 +280,44 @@ export async function discoverAllVersions( const docsConfig = await fetchDocsConfig(repo, configBranch, localOverridePath); const archiveCount = docsConfig?.archiveCount ?? 0; const versionSource = docsConfig?.versionSource ?? 'tag'; + const hasExplicitLatestSource = product.branch !== undefined; console.log(`${repo}: discovering versions (source: ${versionSource})...`); let versions: ArchivedVersion[]; let latestTag: string | null; - if (versionSource === 'branch') { // Branch-based: archived versions come from release/* branches, - // but latestTag still comes from the releases API. - // Request one extra in case we need to filter out the current release's branch. + // but latestTag still comes from the releases API. An explicit + // source gets the current release unless it is that release itself. const [candidates, releaseResult] = await Promise.all([ discoverBranchVersions(repo, archiveCount + 1), discoverVersions(repo, 0), ]); latestTag = releaseResult.latestTag; - // Exclude the branch matching the current release (e.g. release/1.0 when latestTag is v1.0.x) - let latestDisplay: string | null = null; - if (latestTag) { - try { latestDisplay = toArchivedVersion(latestTag).display; } catch { /* unparseable tag */ } - } - versions = candidates.filter(v => v.display !== latestDisplay).slice(0, archiveCount); + const latestDocsUseCurrentRelease = refsShareMinorVersion(product.branch, latestTag); + const latestDisplay = latestTag ? latestVersionFor({ latestTag })?.display ?? null : null; + const includeLatestRelease = hasExplicitLatestSource && !latestDocsUseCurrentRelease; + versions = candidates + .filter(v => includeLatestRelease || v.display !== latestDisplay) + .slice(0, archiveCount + (includeLatestRelease ? 1 : 0)); } else { - const result = await discoverVersions(repo, archiveCount); + const result = await discoverVersions( + repo, + archiveCount + (hasExplicitLatestSource ? 1 : 0), + ); latestTag = result.latestTag; - versions = result.archived; + const latestDocsUseCurrentRelease = refsShareMinorVersion(product.branch, latestTag); + const includeLatestRelease = hasExplicitLatestSource && !latestDocsUseCurrentRelease; + if (includeLatestRelease && latestTag) { + try { + versions = [toArchivedVersion(latestTag), ...result.archived].slice(0, archiveCount + 1); + } catch { + versions = result.archived.slice(0, archiveCount); + } + } else { + versions = result.archived.slice(0, archiveCount); + } } console.log( diff --git a/src/components/Header.astro b/src/components/Header.astro index 640dad97..5b53e7b0 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -5,11 +5,13 @@ import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; import SocialIcons from '@astrojs/starlight/components/SocialIcons.astro'; import ThemeSelect from '@astrojs/starlight/components/ThemeSelect.astro'; import VersionPicker from './VersionPicker.astro'; +import { PRODUCTS } from '../products'; const isRoot = Astro.url.pathname === '/'; +const productChannels = [...new Set(PRODUCTS.flatMap(product => product.latestSource ? [product.latestSource] : []))]; --- -
+
@@ -29,7 +31,14 @@ const isRoot = Astro.url.pathname === '/';