website: add markdown page action (#23369)

* website: add markdown page action

Expose per-page Markdown payloads during local docs development and in production builds, and add a shared docs page action that links to the Markdown view.

Agent-thread: https://koala.sdko.net/th?h=co&d=a7k&t=019efb4f-9706-7e81-816a-f20648a1718e
Signed-off-by: Dominic Roy <dominic@goauthentik.io>
Co-authored-by: Agent <gptagent@svc.sdko.net>

* website: copy markdown page links

Agent-thread: https://koala.sdko.net/th?h=co&d=a7k&t=019f195a-9780-7bd3-abe3-02d4774f5939
Signed-off-by: Dominic Roy <dominic@goauthentik.io>
Co-authored-by: Agent <gptagent@svc.sdko.net>

---------

Signed-off-by: Dominic Roy <dominic@goauthentik.io>
Co-authored-by: Agent <gptagent@svc.sdko.net>
This commit is contained in:
Dominic Roy
2026-07-30 11:56:54 -04:00
committed by GitHub
parent 07c8d29745
commit 53451700a7
12 changed files with 388 additions and 20 deletions

View File

@@ -2,6 +2,12 @@
/*
X-Frame-Options: DENY
/*.md
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Robots-Tag: noindex, nofollow
Cache-Control: public, max-age=3600, must-revalidate
/releases.gen.json
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: *

View File

@@ -0,0 +1,50 @@
.actions {
flex: 0 0 auto;
margin-top: 0.0625rem;
}
.button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.375rem;
background: var(--ifm-background-surface-color);
color: var(--ifm-color-content-secondary);
cursor: pointer;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 700;
line-height: 1.2;
text-decoration: none;
white-space: nowrap;
}
.icon {
width: 1.25rem;
height: 0.875rem;
color: var(--ifm-color-primary);
fill: currentColor;
flex: 0 0 auto;
}
.button:hover,
.button:focus {
border-color: var(--ifm-color-primary-light);
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-primary-dark);
text-decoration: none;
}
.button:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
@media (max-width: 576px) {
.actions {
margin-top: 0;
}
}

View File

@@ -0,0 +1,90 @@
import styles from "./MarkdownPageActions.module.css";
import Translate from "@docusaurus/Translate";
import React, { type ReactNode, useState } from "react";
export function markdownUrlFromPermalink(permalink: string): string {
try {
const url = new URL(permalink);
url.hash = "";
url.search = "";
let urlPath = url.pathname;
while (urlPath.length > 0 && urlPath.endsWith("/")) {
urlPath = urlPath.slice(0, -1);
}
if (!urlPath) {
url.pathname = "/index.md";
} else if (!urlPath.endsWith(".md")) {
url.pathname = `${urlPath}.md`;
}
return url.toString();
} catch {
// Relative permalinks are handled below.
}
const [pathWithQuery] = permalink.split("#");
const [path] = (pathWithQuery ?? "").split("?");
let stripped = path ?? "";
while (stripped.length > 0 && stripped.endsWith("/")) {
stripped = stripped.slice(0, -1);
}
if (!stripped) {
return "/index.md";
}
if (stripped.endsWith(".md")) {
return stripped;
}
return `${stripped}.md`;
}
export const MarkdownPageActions: React.FC = (): ReactNode => {
const [copied, setCopied] = useState(false);
const copyMarkdownUrl = async () => {
try {
const markdownUrl = markdownUrlFromPermalink(window.location.href);
await navigator.clipboard.writeText(markdownUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error("Failed to copy Markdown page URL:", error);
}
};
return (
<div className={styles.actions}>
<button className={styles.button} onClick={copyMarkdownUrl} type="button">
<svg
aria-hidden="true"
className={styles.icon}
focusable="false"
viewBox="0 0 24 16"
>
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h15A2.5 2.5 0 0 1 22 2.5v11a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 13.5v-11Zm3.4 10h2V6.85l2.05 2.55 2.05-2.55v5.65h2v-9h-2L9.45 6.05 7.4 3.5h-2v9Zm11.6 0 3-3h-2V3.5h-2v6h-2l3 3Z" />
</svg>
{copied ? (
<Translate
id="theme.common.markdownPageAction.copied"
description="The button label shown after the Markdown URL is copied"
>
Copied
</Translate>
) : (
<Translate
id="theme.common.markdownPageAction.copy"
description="The button label to copy the current documentation page's Markdown URL"
>
Copy Markdown link
</Translate>
)}
</button>
</div>
);
};

View File

@@ -0,0 +1,8 @@
---
title: Home
description: Root page.
---
# Home
Root page body.

View File

@@ -40,6 +40,7 @@
* @property {string} url Absolute URL of the rendered page.
* @property {string} description
* @property {string} content Cleaned Markdown body.
* @property {string} [slug] Docusaurus frontmatter slug override.
* @property {string} [group] Topic dir or category slug for grouping.
* @property {string} [groupLabel] Display label for the group (defaults to group).
*/

View File

@@ -197,6 +197,7 @@ export function parseDocFile(filePath, baseDir) {
url: "",
description: extractDescription(frontMatter, content),
content,
slug: typeof frontMatter.slug === "string" ? frontMatter.slug : undefined,
};
}
@@ -305,3 +306,59 @@ export function resolveDocumentUrl(relPathNoExt, routesPaths) {
}
return undefined;
}
/**
* @param {string} routeBasePath
* @returns {string}
*/
function normalizeRouteBasePath(routeBasePath) {
if (!routeBasePath || routeBasePath === "/") {
return "/";
}
let start = 0;
let end = routeBasePath.length;
while (start < end && routeBasePath[start] === "/") {
start++;
}
while (end > start && routeBasePath[end - 1] === "/") {
end--;
}
return `/${routeBasePath.slice(start, end)}/`;
}
/**
* @param {string} routePath
* @returns {string}
*/
function normalizeRoutePath(routePath) {
const normalized = `/${routePath.replace(/^\/+/, "")}`.replace(/\/{2,}/g, "/");
if (normalized === "/") {
return normalized;
}
return normalized.endsWith("/") ? normalized : `${normalized}/`;
}
/**
* Resolve a route from source metadata when Docusaurus' final route list is not
* available, such as during the dev server's content loading phase.
*
* @param {LLMSDocInfo} doc
* @param {string} routeBasePath
* @returns {string}
*/
export function resolveDocumentUrlFromSource(doc, routeBasePath) {
if (doc.slug) {
if (doc.slug.startsWith("/")) {
return normalizeRoutePath(doc.slug);
}
return normalizeRoutePath(`${normalizeRouteBasePath(routeBasePath)}${doc.slug}`);
}
if (doc.path === "" || doc.path === "index") {
return normalizeRouteBasePath(routeBasePath);
}
return normalizeRoutePath(`${normalizeRouteBasePath(routeBasePath)}${doc.path}`);
}

View File

@@ -10,15 +10,37 @@ import {
normalizePath,
parseDocFile,
resolveDocumentUrl,
resolveDocumentUrlFromSource,
} from "./node.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const FIXTURE = resolve(__dirname, "__fixtures__", "site");
/**
* @param {string} path
* @param {string} [slug]
* @returns {import("./common.mjs").LLMSDocInfo}
*/
function testDoc(path, slug) {
return {
title: "Test",
path,
url: "",
description: "",
content: "",
slug,
};
}
test("collectDocFiles finds md and mdx, excludes partials", () => {
const files = collectDocFiles(FIXTURE).map((f) => normalizePath(f));
const rels = files.map((f) => f.slice(normalizePath(FIXTURE).length + 1)).sort();
assert.deepEqual(rels, ["topic-a/index.mdx", "topic-a/page-one.md", "topic-b/page-two.mdx"]);
assert.deepEqual(rels, [
"index.mdx",
"topic-a/index.mdx",
"topic-a/page-one.md",
"topic-b/page-two.mdx",
]);
});
test("collectDocFiles honors extra ignore patterns", () => {
@@ -107,6 +129,29 @@ test("resolveDocumentUrl maps the root index page to /", () => {
assert.equal(resolveDocumentUrl("", ROUTES), "/");
});
test("resolveDocumentUrlFromSource maps routeBasePath and index pages", () => {
assert.equal(
resolveDocumentUrlFromSource(testDoc("topic-a/page-one"), "/"),
"/topic-a/page-one/",
);
assert.equal(resolveDocumentUrlFromSource(testDoc("index"), "/"), "/");
assert.equal(
resolveDocumentUrlFromSource(testDoc("topic-a/page-one"), "/docs"),
"/docs/topic-a/page-one/",
);
});
test("resolveDocumentUrlFromSource honors frontmatter slug overrides", () => {
assert.equal(
resolveDocumentUrlFromSource(testDoc("customize/branding", "/branding"), "/"),
"/branding/",
);
assert.equal(
resolveDocumentUrlFromSource(testDoc("customize/branding", "branding"), "/docs"),
"/docs/branding/",
);
});
test("assignGroup always returns the first path segment (slug) for topic grouping", () => {
const doc = { path: "topic-a/page-one" };
assert.equal(assignGroup(doc, { groupBy: "topic" }), "topic-a");

View File

@@ -29,12 +29,18 @@ import {
groupLabel,
parseDocFile,
resolveDocumentUrl,
resolveDocumentUrlFromSource,
} from "./node.mjs";
const PLUGIN_NAME = "ak-llms-txt-plugin";
export { assignGroup, groupLabel };
/**
* @typedef {object} LLMSPluginContent
* @property {string} devOutputDir
*/
/**
* Resolve the base URL for generated links. In a Netlify deploy preview or
* branch deploy the canonical site URL (e.g. docs.goauthentik.io) is wrong —
@@ -81,7 +87,9 @@ export async function buildLLMSOutputs(ctx) {
const parsed = parseDocFile(file, absDir);
if (!parsed) continue;
const route = resolveDocumentUrl(parsed.path, ctx.routesPaths);
const route = ctx.routesPaths.length
? resolveDocumentUrl(parsed.path, ctx.routesPaths)
: resolveDocumentUrlFromSource(parsed, section.routeBasePath);
if (!route) {
// Expected for source files Docusaurus does not route (e.g.
// historical release notes). Counted and summarized, not warned per-page.
@@ -99,7 +107,7 @@ export async function buildLLMSOutputs(ctx) {
if (skippedNoRoute || mdxFallbacks) {
console.log(
`${PLUGIN_NAME}: indexed ${docs.length} pages ` +
`🚀 ${PLUGIN_NAME}: indexed ${docs.length} pages ` +
`(${skippedNoRoute} skipped — no route; ${mdxFallbacks} used the regex fallback)`,
);
}
@@ -145,14 +153,70 @@ export async function buildLLMSOutputs(ctx) {
}
/**
* @param {LoadContext} _loadContext
* @param {LLMSPluginOptions} options
* @returns {Plugin}
* @param {string} outDir
* @param {Map<string, string>} outputs
* @returns {Promise<void>}
*/
function akLLMSPlugin(_loadContext, options) {
async function writeLLMSOutputs(outDir, outputs) {
await Promise.all(
[...outputs.entries()].map(async ([rel, contents]) => {
const dest = path.join(outDir, rel);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.writeFile(dest, contents, "utf-8");
}),
);
}
/**
* @param {LoadContext} loadContext
* @param {LLMSPluginOptions} options
* @returns {Plugin<LLMSPluginContent>}
*/
function akLLMSPlugin(loadContext, options) {
const devOutputDir = path.join(loadContext.generatedFilesDir, PLUGIN_NAME);
return {
name: PLUGIN_NAME,
async loadContent() {
const outputs = await buildLLMSOutputs({
siteDir: loadContext.siteDir,
outDir: devOutputDir,
siteUrl: resolveSiteUrl(options, loadContext.siteConfig),
title: options.title ?? loadContext.siteConfig.title,
description: options.description ?? loadContext.siteConfig.tagline ?? "",
routesPaths: [],
options,
});
await fs.rm(devOutputDir, { recursive: true, force: true });
await writeLLMSOutputs(devOutputDir, outputs);
return {
devOutputDir,
};
},
configureWebpack(_config, isServer, _utils, content) {
if (isServer || !content?.devOutputDir) {
return undefined;
}
/** @type {any} */
const devServerConfig = {
devServer: {
static: [
{
directory: content.devOutputDir,
publicPath: loadContext.baseUrl,
},
],
},
};
return devServerConfig;
},
/**
* @param {Props} props
*/
@@ -169,13 +233,7 @@ function akLLMSPlugin(_loadContext, options) {
options,
});
await Promise.all(
[...outputs.entries()].map(async ([rel, contents]) => {
const dest = path.join(props.outDir, rel);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.writeFile(dest, contents, "utf-8");
}),
);
await writeLLMSOutputs(props.outDir, outputs);
console.log(`${PLUGIN_NAME} wrote ${outputs.size} files`);
},

View File

@@ -116,3 +116,22 @@ test("buildLLMSOutputs writes per-category index at the slug path with a label h
assert.ok(![...outputs.keys()].some((k) => k.includes("Topic A Label")), "no label-named path");
assert.ok(outputs.get("llms.txt")?.includes("## Topic A Label"), "root heading uses the LABEL");
});
test("buildLLMSOutputs emits dev-server files from source routes without Docusaurus routes", async () => {
const outputs = await buildLLMSOutputs({
siteDir: FIXTURE,
outDir: "/tmp/ignored",
siteUrl: "https://docs.x",
title: "authentik Documentation",
description: "Unified auth.",
routesPaths: [],
options: {
sections: [{ path: ".", routeBasePath: "/" }],
groupBy: "topic",
crossLinks: [],
},
});
assert.ok(outputs.has("index.md"), "root markdown is served from /index.md");
assert.ok(outputs.has("topic-a/page-one.md"), "source path route gets a markdown payload");
});

View File

@@ -10,6 +10,7 @@
import "./styles.css";
import { MarkdownPageActions } from "#components/MarkdownPageActions.tsx";
import { SupportBadge } from "#components/SupportBadge.tsx";
import { VersionBadge } from "#components/VersionBadge.tsx";
@@ -126,13 +127,19 @@ const DocItemContent: React.FC<Props> = ({ children }) => {
return (
<div className={clsx(ThemeClassNames.docs.docMarkdown, "markdown")}>
{syntheticTitle ? (
<header>
<Heading as="h1">{syntheticTitle}</Heading>
<header>
<div className="authentik-doc-title-row">
{syntheticTitle ? (
<Heading as="h1">{syntheticTitle}</Heading>
) : (
<span className="authentik-doc-title-spacer" />
)}
<BadgeGroup badges={badges} />
</header>
) : null}
<MarkdownPageActions />
</div>
{syntheticTitle ? <BadgeGroup badges={badges} /> : null}
</header>
{preReleaseDoc ? <PreReleaseAdmonition /> : null}

View File

@@ -1,3 +1,24 @@
.authentik-doc-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--ifm-spacing-horizontal);
margin-bottom: var(--ifm-heading-margin-bottom);
}
.authentik-doc-title-row h1 {
flex: 1 1 min(24rem, 100%);
margin-bottom: 0;
}
.authentik-doc-title-spacer {
flex: 1 1 auto;
}
.authentik-doc-title-row + .badge-group {
margin-top: 0;
}
.docusaurus-mermaid-container {
.architecture-service {
svg {

View File

@@ -1,3 +1,9 @@
# Headers for static files
/*
X-Frame-Options: DENY
/*.md
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Robots-Tag: noindex, nofollow
Cache-Control: public, max-age=3600, must-revalidate