mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
6
website/docs/static/_headers
vendored
6
website/docs/static/_headers
vendored
@@ -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: *
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
90
website/docusaurus-theme/components/MarkdownPageActions.tsx
Normal file
90
website/docusaurus-theme/components/MarkdownPageActions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Home
|
||||
description: Root page.
|
||||
---
|
||||
|
||||
# Home
|
||||
|
||||
Root page body.
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user