Compare commits
1 Commits
main
...
docusaurus
Author | SHA1 | Date | |
---|---|---|---|
19f706e7aa |
@ -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",
|
||||
{
|
||||
|
64
website/releases/plugin.mjs
Normal file
64
website/releases/plugin.mjs
Normal 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;
|
69
website/releases/utils.mjs
Normal file
69
website/releases/utils.mjs
Normal 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;
|
||||
}
|
@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
|
231
website/src/components/VersionPicker/index.tsx
Normal file
231
website/src/components/VersionPicker/index.tsx
Normal 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} />;
|
||||
};
|
27
website/src/components/VersionPicker/styles.css
Normal file
27
website/src/components/VersionPicker/styles.css
Normal 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);
|
||||
}
|
||||
}
|
24
website/src/theme/DocSidebarItems/index.tsx
Normal file
24
website/src/theme/DocSidebarItems/index.tsx
Normal 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);
|
3
website/types/docusaurus.d.ts
vendored
3
website/types/docusaurus.d.ts
vendored
@ -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";
|
||||
|
||||
|
Reference in New Issue
Block a user