Compare commits

...

1 Commits

Author SHA1 Message Date
19f706e7aa website/docs: Fix version picker. 2025-05-06 16:13:44 +02:00
8 changed files with 437 additions and 62 deletions

View File

@ -4,9 +4,12 @@
* @import * as Preset from "@docusaurus/preset-classic";
* @import * as OpenApiPlugin from "docusaurus-plugin-openapi-docs";
* @import { BuildUrlValues } from "remark-github";
* @import { ReleasesPluginOptions } from "./releases/plugin.mjs"
*/
import { createDocusaurusConfig } from "@goauthentik/docusaurus-config";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import remarkDirective from "remark-directive";
import remarkGithub, { defaultBuildUrl } from "remark-github";
@ -15,6 +18,7 @@ import remarkPreviewDirective from "./remark/preview-directive.mjs";
import remarkSupportDirective from "./remark/support-directive.mjs";
import remarkVersionDirective from "./remark/version-directive.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const require = createRequire(import.meta.url);
/**
@ -131,6 +135,12 @@ const config = createDocusaurusConfig({
],
],
plugins: [
[
"./releases/plugin.mjs",
/** @type {ReleasesPluginOptions} */ ({
docsDirectory: path.join(__dirname, "docs"),
}),
],
[
"@docusaurus/plugin-content-docs",
{

View File

@ -0,0 +1,64 @@
/**
* @file Docusaurus releases plugin.
*
* @import { LoadContext, Plugin } from "@docusaurus/types"
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { collectReleaseFiles } from "./utils.mjs";
const PLUGIN_NAME = "ak-releases-plugin";
const RELEASES_FILENAME = "releases.gen.json";
/**
* @typedef {object} ReleasesPluginOptions
* @property {string} docsDirectory The path to the documentation directory.
*/
/**
* @typedef {object} AKReleasesPluginData
* @property {string} publicPath The URL to the plugin's public directory.
* @property {string[]} releases The available versions of the documentation.
*/
/**
* @param {LoadContext} loadContext
* @param {ReleasesPluginOptions} options
* @returns {Promise<Plugin<AKReleasesPluginData>>}
*/
async function akReleasesPlugin(loadContext, { docsDirectory }) {
return {
name: PLUGIN_NAME,
async loadContent() {
console.log(`🚀 ${PLUGIN_NAME} loaded`);
const releases = collectReleaseFiles(docsDirectory).map((release) => release.name);
const outputPath = path.join(loadContext.siteDir, "static", RELEASES_FILENAME);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, JSON.stringify(releases, null, 2), "utf-8");
console.log(`${RELEASES_FILENAME} generated`);
/**
* @type {AKReleasesPluginData}
*/
const content = {
releases,
publicPath: path.join("/", RELEASES_FILENAME),
};
return content;
},
contentLoaded({ content, actions }) {
const { setGlobalData } = actions;
setGlobalData(content);
},
};
}
export default akReleasesPlugin;

View File

@ -0,0 +1,69 @@
/**
* @file Docusaurus release utils.
*
* @import { SidebarItemConfig } from "@docusaurus/plugin-content-docs-types"
*/
import FastGlob from "fast-glob";
import * as path from "node:path";
import { coerce } from "semver";
/**
*
* @param {string} releasesParentDirectory
* @returns {FastGlob.Entry[]}
*/
export function collectReleaseFiles(releasesParentDirectory) {
const releaseFiles = FastGlob.sync("releases/**/v*.{md,mdx}", {
cwd: releasesParentDirectory,
onlyFiles: true,
objectMode: true,
})
.map((fileEntry) => {
return {
...fileEntry,
path: fileEntry.path.replace(/\.mdx?$/, ""),
name: fileEntry.name.replace(/^v/, "").replace(/\.mdx?$/, ""),
};
})
.sort((a, b) => {
const aSemVer = coerce(a.name);
const bSemVer = coerce(b.name);
if (aSemVer && bSemVer) {
return bSemVer.compare(aSemVer);
}
return b.name.localeCompare(a.name);
});
return releaseFiles;
}
export const SUPPORTED_RELEASE_COUNT = 3;
/**
*
* @param {FastGlob.Entry[]} releaseFiles
*/
export function createReleaseSidebarEntries(releaseFiles) {
/**
* @type {SidebarItemConfig[]}
*/
let sidebarEntries = releaseFiles.map((fileEntry) => {
return path.join(fileEntry.path);
});
if (releaseFiles.length > SUPPORTED_RELEASE_COUNT) {
// Then we add the rest of the releases as a category.
sidebarEntries = [
...sidebarEntries.slice(0, SUPPORTED_RELEASE_COUNT),
{
type: "category",
label: "Previous versions",
items: sidebarEntries.slice(SUPPORTED_RELEASE_COUNT),
},
];
}
return sidebarEntries;
}

View File

@ -3,73 +3,20 @@
*
* @import { SidebarItemConfig } from "@docusaurus/plugin-content-docs-types"
*/
import apiReference from "../docs/developer-docs/api/reference/sidebar";
import { generateVersionDropdown } from "../src/utils.js";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
/**
* @type {SidebarItemConfig[]}
*/
const releases = [
"releases/2025/v2025.4",
"releases/2025/v2025.2",
"releases/2024/v2024.12",
{
type: "category",
label: "Previous versions",
items: [
"releases/2024/v2024.10",
"releases/2024/v2024.8",
"releases/2024/v2024.6",
"releases/2024/v2024.4",
"releases/2024/v2024.2",
"releases/2023/v2023.10",
"releases/2023/v2023.8",
"releases/2023/v2023.6",
"releases/2023/v2023.5",
"releases/2023/v2023.4",
"releases/2023/v2023.3",
"releases/2023/v2023.2",
"releases/2023/v2023.1",
"releases/2022/v2022.12",
"releases/2022/v2022.11",
"releases/2022/v2022.10",
"releases/2022/v2022.9",
"releases/2022/v2022.8",
"releases/2022/v2022.7",
"releases/2022/v2022.6",
"releases/2022/v2022.5",
"releases/2022/v2022.4",
"releases/2022/v2022.2",
"releases/2022/v2022.1",
"releases/2021/v2021.12",
"releases/2021/v2021.10",
"releases/2021/v2021.9",
"releases/2021/v2021.8",
"releases/2021/v2021.7",
"releases/2021/v2021.6",
"releases/2021/v2021.5",
"releases/2021/v2021.4",
"releases/2021/v2021.3",
"releases/2021/v2021.2",
"releases/2021/v2021.1",
"releases/old/v0.14",
"releases/old/v0.13",
"releases/old/v0.12",
"releases/old/v0.11",
"releases/old/v0.10",
"releases/old/v0.9",
],
},
];
import apiReference from "../docs/developer-docs/api/reference/sidebar";
import { collectReleaseFiles, createReleaseSidebarEntries } from "../releases/utils.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const releases = collectReleaseFiles(path.join(__dirname, "..", "docs"));
/**
* @type {SidebarItemConfig[]}
*/
const items = [
{
type: "html",
value: generateVersionDropdown(releases),
},
{
type: "doc",
id: "index",
@ -796,7 +743,7 @@ const items = [
slug: "releases",
description: "Release Notes for recent authentik versions",
},
items: releases,
items: createReleaseSidebarEntries(releases),
},
];

View File

@ -0,0 +1,231 @@
import { usePluginData } from "@docusaurus/useGlobalData";
import useIsBrowser from "@docusaurus/useIsBrowser";
import type { AKReleasesPluginData } from "@site/releases/plugin.mjs";
import clsx from "clsx";
import React, { memo, useEffect, useMemo, useState } from "react";
import { coerce } from "semver";
import "./styles.css";
const ProductionURL = new URL("https://docs.goauthentik.io");
const LocalhostAliases: ReadonlySet<string> = new Set(["localhost", "127.0.0.1"]);
/**
* Given a semver, create the URL for the version.
*/
function createVersionURL(semver: string): string {
const subdomain = `version-${semver.replace(".", "-")}`;
return `https://${subdomain}.goauthentik.io`;
}
/**
* Predicate to determine if a hostname appears to be a prerelease origin.
*/
function isPrerelease(hostname: string | null): boolean {
if (!hostname) return false;
if (hostname === ProductionURL.hostname) return true;
if (hostname.endsWith(".netlify.app")) return true;
if (LocalhostAliases.has(hostname)) return true;
return false;
}
/**
* Given a hostname, parse the semver from the subdomain.
*/
function parseHostnameSemVer(hostname: string | null): string | null {
if (!hostname) return null;
const [, possibleSemVer] = hostname.match(/version-(.+)\.goauthentik\.io/) || [];
if (!possibleSemVer) return null;
const formattedSemVer = possibleSemVer.replace("-", ".");
if (!coerce(formattedSemVer)) return null;
return formattedSemVer;
}
interface VersionDropdownProps {
/**
* The hostname of the client.
*/
hostname: string | null;
/**
* The origin of the prerelease documentation.
*
* @format url
*/
prereleaseOrigin: string;
/**
* The available versions of the documentation.
*
* @format semver
*/
releases: string[];
}
/**
* A dropdown that shows the available versions of the documentation.
*/
const VersionDropdown = memo<VersionDropdownProps>(({ hostname, prereleaseOrigin, releases }) => {
const prerelease = isPrerelease(hostname);
const parsedSemVer = !prerelease ? parseHostnameSemVer(hostname) : null;
const currentLabel = parsedSemVer || "Pre-Release";
const endIndex = parsedSemVer ? releases.indexOf(parsedSemVer) : -1;
const visibleReleases = releases.slice(0, endIndex === -1 ? 3 : endIndex + 3);
return (
<li className="navbar__item dropdown dropdown--hoverable dropdown--right ak-version-selector">
<div
aria-haspopup="true"
aria-expanded="false"
role="button"
className="navbar__link menu__link"
>
Version: {currentLabel}
</div>
<ul className="dropdown__menu menu__list-item--collapsed">
{!prerelease ? (
<li>
<a
href={prereleaseOrigin}
target="_blank"
rel="noopener noreferrer"
className="dropdown__link menu__link"
>
Pre-Release
</a>
</li>
) : null}
{visibleReleases.map((semVer, idx) => {
let label = semVer;
if (idx === 0) {
label += " (Current Release)";
}
return (
<li key={idx}>
<a
href={createVersionURL(semVer)}
target="_blank"
rel="noopener noreferrer"
className={clsx("dropdown__link menu__link", {
"menu__link--active": semVer === currentLabel,
})}
>
{label}
</a>
</li>
);
})}
</ul>
</li>
);
});
interface VersionPickerLoaderProps {
pluginData: AKReleasesPluginData;
}
/**
* A data-fetching component that loads available versions of the documentation.
*
* @see {@linkcode VersionPicker} for the component.
* @see {@linkcode AKReleasesPluginData} for the plugin data.
* @client
*/
const VersionPickerLoader: React.FC<VersionPickerLoaderProps> = ({ pluginData }) => {
const [releases, setReleases] = useState(pluginData.releases);
const browser = useIsBrowser();
const prereleaseOrigin = useMemo(() => {
if (browser && LocalhostAliases.has(window.location.hostname)) {
return window.location.origin;
}
return ProductionURL.href;
}, [browser]);
const hostname = useMemo(() => {
if (!browser) return null;
const searchParams = new URLSearchParams(window.location.search);
// Query parameter used for debugging.
// Note that this doesn't synchronize with Docusaurus's router state.
const subdomain = searchParams.get("version");
if (subdomain) return subdomain;
return window.location.hostname;
}, [browser]);
useEffect(() => {
if (!browser || !prereleaseOrigin) return;
const controller = new AbortController();
const updateURL = new URL(pluginData.publicPath, prereleaseOrigin);
fetch(updateURL, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch new releases: ${response.status}`);
}
return response.json();
})
.then((data: unknown) => {
// We're extra cautious here to be ready if the API shape ever changes.
if (!data) throw new Error("Failed to parse releases");
if (!Array.isArray(data)) throw new Error("Releases must be an array");
if (!data.every((item) => typeof item === "string"))
throw new Error("Releases must be an array of strings");
setReleases(data);
})
.catch((error) => {
console.warn(`Failed to fetch new releases: ${error}`);
});
return () => controller.abort("unmount");
}, [browser, prereleaseOrigin]);
return (
<VersionDropdown
hostname={hostname}
prereleaseOrigin={prereleaseOrigin}
releases={releases}
/>
);
};
/**
* A component that shows the available versions of the documentation.
*
* @see {@linkcode VersionPickerLoader} for the data-fetching component.
*/
export const VersionPicker: React.FC = () => {
const pluginData = usePluginData("ak-releases-plugin", undefined, {
failfast: true,
}) as AKReleasesPluginData;
if (!pluginData.releases.length) return null;
return <VersionPickerLoader pluginData={pluginData} />;
};

View File

@ -0,0 +1,27 @@
.theme-doc-sidebar-menu .dropdown.ak-version-selector {
--ak-version-selector-padding: calc(var(--ifm-spacing-vertical) / 2);
width: calc(100% - (var(--ifm-spacing-horizontal) / 2));
border-block-end: var(--ifm-hr-height) solid var(--ifm-toc-border-color);
padding-block-start: calc(var(--ak-version-selector-padding) / 2);
padding-block-end: var(--ak-version-selector-padding);
margin-block-end: var(--ak-version-selector-padding);
.navbar__link.menu__link {
display: flex;
width: 100%;
justify-content: space-between;
font-weight: var(--ifm-font-weight-semibold);
&::after {
color: var(--ifm-color-emphasis-400);
filter: var(--ifm-menu-link-sublist-icon-filter);
}
}
.dropdown__menu {
background: var(--ifm-dropdown-background-color);
box-shadow: var(--ifm-global-shadow-lw);
border: 1px solid var(--ifm-color-emphasis-200);
}
}

View File

@ -0,0 +1,24 @@
import {
DocSidebarItemsExpandedStateProvider,
useVisibleSidebarItems,
} from "@docusaurus/plugin-content-docs/client";
import { VersionPicker } from "@site/src/components/VersionPicker/index";
import DocSidebarItem from "@theme/DocSidebarItem";
import type { Props as DocSidebarItemsProps } from "@theme/DocSidebarItems";
import { memo } from "react";
const DocSidebarItems: React.FC<DocSidebarItemsProps> = ({ items, ...props }) => {
const visibleItems = useVisibleSidebarItems(items, props.activePath);
const includeVersionPicker = props.level === 1 && props.activePath.startsWith("/docs");
return (
<DocSidebarItemsExpandedStateProvider>
{includeVersionPicker ? <VersionPicker /> : null}
{visibleItems.map((item, index) => (
<DocSidebarItem key={index} item={item} index={index} {...props} />
))}
</DocSidebarItemsExpandedStateProvider>
);
};
export default memo(DocSidebarItems);

View File

@ -21,6 +21,9 @@ declare module "@docusaurus/plugin-content-docs/src/sidebars/types" {
}
declare module "@docusaurus/plugin-content-docs/client" {
export * from "@docusaurus/plugin-content-docs/lib/client/docSidebarItemsExpandedState.js";
export * from "@docusaurus/plugin-content-docs/lib/client/docsUtils.js";
import { DocContextValue as BaseDocContextValue } from "@docusaurus/plugin-content-docs/lib/client/doc.js";
import { DocFrontMatter as BaseDocFrontMatter } from "@docusaurus/plugin-content-docs";