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
130 changes: 107 additions & 23 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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": "..." } }
Expand All @@ -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;
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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: [
Expand Down Expand Up @@ -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}/**`],
};
})
),
Expand All @@ -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 },
Expand All @@ -184,6 +261,7 @@ export default defineConfig({
}),
starlightSidebarTopics([
...productTopics,
...channelTopics,
...versionedTopics,
], { topics: topicsOption }),
],
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions src/build/fileOps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
14 changes: 9 additions & 5 deletions src/build/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
89 changes: 83 additions & 6 deletions src/build/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<string, string>;
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<string, string>;
expect(redirects['/core']).toBe('/core/develop/');
});
});
Loading
Loading