From 53451700a774f0e49eff9432764430123dc6c4e2 Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Thu, 30 Jul 2026 11:56:54 -0400 Subject: [PATCH] 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 Co-authored-by: Agent * 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 Co-authored-by: Agent --------- Signed-off-by: Dominic Roy Co-authored-by: Agent --- website/docs/static/_headers | 6 ++ .../components/MarkdownPageActions.module.css | 50 +++++++++++ .../components/MarkdownPageActions.tsx | 90 +++++++++++++++++++ .../llms-txt/__fixtures__/site/index.mdx | 8 ++ website/docusaurus-theme/llms-txt/common.mjs | 1 + website/docusaurus-theme/llms-txt/node.mjs | 57 ++++++++++++ .../docusaurus-theme/llms-txt/node.test.mjs | 47 +++++++++- website/docusaurus-theme/llms-txt/plugin.mjs | 84 ++++++++++++++--- .../docusaurus-theme/llms-txt/plugin.test.mjs | 19 ++++ .../theme/DocItem/Content/index.tsx | 19 ++-- .../theme/DocItem/Content/styles.css | 21 +++++ website/integrations/static/_headers | 6 ++ 12 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 website/docusaurus-theme/components/MarkdownPageActions.module.css create mode 100644 website/docusaurus-theme/components/MarkdownPageActions.tsx create mode 100644 website/docusaurus-theme/llms-txt/__fixtures__/site/index.mdx diff --git a/website/docs/static/_headers b/website/docs/static/_headers index a1ee08fb96..3ba874463b 100644 --- a/website/docs/static/_headers +++ b/website/docs/static/_headers @@ -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: * diff --git a/website/docusaurus-theme/components/MarkdownPageActions.module.css b/website/docusaurus-theme/components/MarkdownPageActions.module.css new file mode 100644 index 0000000000..57c48f6e4c --- /dev/null +++ b/website/docusaurus-theme/components/MarkdownPageActions.module.css @@ -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; + } +} diff --git a/website/docusaurus-theme/components/MarkdownPageActions.tsx b/website/docusaurus-theme/components/MarkdownPageActions.tsx new file mode 100644 index 0000000000..0ee70ede39 --- /dev/null +++ b/website/docusaurus-theme/components/MarkdownPageActions.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/website/docusaurus-theme/llms-txt/__fixtures__/site/index.mdx b/website/docusaurus-theme/llms-txt/__fixtures__/site/index.mdx new file mode 100644 index 0000000000..8328ae6bff --- /dev/null +++ b/website/docusaurus-theme/llms-txt/__fixtures__/site/index.mdx @@ -0,0 +1,8 @@ +--- +title: Home +description: Root page. +--- + +# Home + +Root page body. diff --git a/website/docusaurus-theme/llms-txt/common.mjs b/website/docusaurus-theme/llms-txt/common.mjs index fc5ed99cf1..d414e4be41 100644 --- a/website/docusaurus-theme/llms-txt/common.mjs +++ b/website/docusaurus-theme/llms-txt/common.mjs @@ -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). */ diff --git a/website/docusaurus-theme/llms-txt/node.mjs b/website/docusaurus-theme/llms-txt/node.mjs index df1ba78a5c..b5b14ea0cb 100644 --- a/website/docusaurus-theme/llms-txt/node.mjs +++ b/website/docusaurus-theme/llms-txt/node.mjs @@ -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}`); +} diff --git a/website/docusaurus-theme/llms-txt/node.test.mjs b/website/docusaurus-theme/llms-txt/node.test.mjs index 66a9413b37..3dd81cb1cc 100644 --- a/website/docusaurus-theme/llms-txt/node.test.mjs +++ b/website/docusaurus-theme/llms-txt/node.test.mjs @@ -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"); diff --git a/website/docusaurus-theme/llms-txt/plugin.mjs b/website/docusaurus-theme/llms-txt/plugin.mjs index fe1ec4ebbf..c5329173ff 100644 --- a/website/docusaurus-theme/llms-txt/plugin.mjs +++ b/website/docusaurus-theme/llms-txt/plugin.mjs @@ -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} outputs + * @returns {Promise} */ -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} + */ +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`); }, diff --git a/website/docusaurus-theme/llms-txt/plugin.test.mjs b/website/docusaurus-theme/llms-txt/plugin.test.mjs index 75161ee4ac..594f2de48a 100644 --- a/website/docusaurus-theme/llms-txt/plugin.test.mjs +++ b/website/docusaurus-theme/llms-txt/plugin.test.mjs @@ -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"); +}); diff --git a/website/docusaurus-theme/theme/DocItem/Content/index.tsx b/website/docusaurus-theme/theme/DocItem/Content/index.tsx index 13711988e4..7b85bfe9d8 100644 --- a/website/docusaurus-theme/theme/DocItem/Content/index.tsx +++ b/website/docusaurus-theme/theme/DocItem/Content/index.tsx @@ -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 = ({ children }) => { return (
- {syntheticTitle ? ( -
- {syntheticTitle} +
+
+ {syntheticTitle ? ( + {syntheticTitle} + ) : ( + + )} - -
- ) : null} + +
+ + {syntheticTitle ? : null} + {preReleaseDoc ? : null} diff --git a/website/docusaurus-theme/theme/DocItem/Content/styles.css b/website/docusaurus-theme/theme/DocItem/Content/styles.css index 79a3a264a5..6d5cc161c1 100644 --- a/website/docusaurus-theme/theme/DocItem/Content/styles.css +++ b/website/docusaurus-theme/theme/DocItem/Content/styles.css @@ -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 { diff --git a/website/integrations/static/_headers b/website/integrations/static/_headers index 45086db6c8..e1c804e639 100644 --- a/website/integrations/static/_headers +++ b/website/integrations/static/_headers @@ -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