Compare commits

...

4 Commits

Author SHA1 Message Date
Teffen Ellis
7f7802a868 web/test: e2e ak-mdx pipeline via OAuth2 provider docs
The OAuth2 provider docs page exercises the full pipeline in one place:
frontmatter title, multiple H2 slugs, `:::caution`/`:::info` admonitions
(with and without titles), relative-doc links, external links, and an
embedded mermaid sequence diagram. Tests boot the admin UI, provision a
fresh OAuth2 provider, navigate to its view page, and assert against the
compiled DOM inside `<ak-mdx>`'s shadow root.

Co-Authored-By: Agent <279763771+playpen-agent@users.noreply.github.com>
2026-04-30 15:02:46 +02:00
Teffen Ellis
35f84aa61e web/bundler/mdx-plugin: pre-render markdown to HTML at build time
Compile each `.md` / `.mdx` file through a build-time `unified` pipeline
(remark-parse, GFM, frontmatter, directives, custom admonition/headings/
lists transforms; rehype-rehype, anchor wrapping, syntax highlighting,
mermaid extraction) and emit a JSON envelope of
`{ content: HTML, frontmatter, publicPath, publicDirectory }`. The
on-load result still ships via the `file` loader so the runtime side
keeps its existing fetch path. The compiled HTML uses `<ak-md-a>` and
`<ak-alert>` custom elements so `<ak-mdx>` can stamp it directly into
shadow DOM with no client-side JavaScript evaluation.

Plugin is split across four sibling files (`node.js`, `compile.js`,
`remark.js`, `rehype.js`) so each concern lives in its own module. The
title/wrapper HTML is built as a hast tree and serialized through
`hast-util-to-html` rather than string-concatenated, dropping the
hand-rolled `escapeHTML` helper and unifying escape semantics with the
rest of the document.

Co-Authored-By: Agent <279763771+playpen-agent@users.noreply.github.com>
2026-04-30 15:02:46 +02:00
Teffen Ellis
d969619787 web/elements/ak-mdx: drop React + runtime MDX eval, use unified pipeline
Replace `@mdx-js/mdx`'s `evaluate`/`run` (which depends on `'unsafe-eval'`
in the page CSP) with a pure `unified`/remark/rehype pipeline. URL-mode
content now arrives from the build-time `mdx-plugin` as pre-rendered HTML
and is stamped through a Trusted Types passthrough policy
(`CompiledMarkdownTrustPolicy`) so admin-side custom elements like
`<ak-alert>` and `<ak-md-a>` survive. Content-mode (admin-supplied
markdown) is compiled in-browser via `compileRuntimeMarkdown` and routed
through the existing `BrandedHTMLPolicy` (DOMPurify).

`<ak-md-a>` replaces the React `MDXAnchor`/`MDXWrapper` pair: a tiny Lit
custom element with `display: contents` that intercepts in-doc fragment
clicks for shadow-root scrolling. The remark plugins gain `caution` and
`tip` admonition types and promote `:::name[Title]` directive labels to
`<strong>`. `one-dark.css` learns `:host` so syntax-highlighted code
blocks inherit the palette inside `<ak-mdx>`'s shadow tree.

Removes `@mdx-js/mdx`, `react`, `react-dom`, `rehype-mermaid`, and
`remark-mdx-frontmatter`. Adds `unified`, `remark-parse`, `remark-rehype`,
and `mdast-util-to-string`.

Co-Authored-By: Agent <279763771+playpen-agent@users.noreply.github.com>
2026-04-30 15:02:46 +02:00
Teffen Ellis
bafdd10f94 web/elements: extract mermaid runtime, modernize <ak-diagram>
Pull mermaid configuration out of `Diagram` into `#common/mermaid` and
move theme handling up to `Interface` so all diagrams share a single
mermaid initialization. `Diagram.ts` switches to `@listen(AKRefreshEvent)`
+ `guard()`/`until()` rendering and externalizes its CSS to `Diagram.css`
(renaming the mermaid SVG selector from `mermaid-svg-` to `diagram` to
match the new render id). `FlowDiagram` and `OAuthSourceDiagram` updated
for the renamed lifecycle hook.

Co-Authored-By: Agent <279763771+playpen-agent@users.noreply.github.com>
2026-04-30 15:02:46 +02:00
30 changed files with 1167 additions and 1276 deletions

View File

@@ -0,0 +1,134 @@
/**
* @file Build-time markdown → HTML pipeline.
*
* The output is wrapped in a `<div class="pf-c-content" part="content">`
* envelope so consuming `<ak-mdx>` elements can rely on PatternFly content
* styles and expose CSS parts (`title`, `content`) to host pages.
*/
import { rehypeAnchors, rehypeMermaid } from "./rehype.js";
import {
normalizeAdmonitionLabels,
remarkAdmonition,
remarkHeadings,
remarkLists,
} from "./remark.js";
import { toHtml } from "hast-util-to-html";
import apacheGrammar from "highlight.js/lib/languages/apache";
import diffGrammar from "highlight.js/lib/languages/diff";
import confGrammar from "highlight.js/lib/languages/ini";
import nginxGrammar from "highlight.js/lib/languages/nginx";
import { common } from "lowlight";
import rehypeHighlight from "rehype-highlight";
import remarkDirective from "remark-directive";
import remarkFrontmatter from "remark-frontmatter";
import remarkGFM from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import { parse as parseYAML } from "yaml";
/**
* Pull a YAML frontmatter block off the top of `source` and return both
* pieces. Returns an empty object if there is no frontmatter.
*
* @param {string} source
* @returns {{ body: string, frontmatter: Record<string, unknown> }}
*/
function splitFrontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { body: source, frontmatter: {} };
const frontmatter = parseYAML(match[1]) || {};
return { body: source.slice(match[0].length), frontmatter };
}
/**
* Build the wrapping `<div class="pf-c-content" part="content">` envelope
* with an optional `<h1 part="title">` prefix, then serialize the whole
* tree through `hast-util-to-html`. One serializer means one set of
* escaping rules — no hand-rolled `&`/`<`/`>`/`"` replacement that has
* to be remembered and audited separately.
*
* @param {import('hast').Element[]} bodyChildren Hast nodes from the markdown pipeline.
* @param {string | null} title Frontmatter title, or `null` to omit the `<h1>`.
* @returns {string}
*/
function renderEnvelope(bodyChildren, title) {
/** @type {import('hast').Element[]} */
const children = [];
if (title) {
children.push({
type: "element",
tagName: "h1",
properties: { part: "title" },
children: [{ type: "text", value: title }],
});
}
children.push(...bodyChildren);
/** @type {import('hast').Root} */
const root = {
type: "root",
children: [
{
type: "element",
tagName: "div",
properties: { className: ["pf-c-content"], part: "content" },
children,
},
],
};
return toHtml(root);
}
/**
* Compile a markdown source string to a wrapped HTML string and parsed
* frontmatter. Used by the build-time plugin; the runtime side mirrors
* this pipeline in the browser for admin-supplied prose.
*
* @param {string} source
* @param {string} publicDirectory Path of the file's directory inside the
* docs site, used to resolve relative `<a>` hrefs at build time.
* @returns {Promise<{ html: string, frontmatter: Record<string, unknown> }>}
*/
export async function compileMarkdown(source, publicDirectory) {
const { body: rawBody, frontmatter } = splitFrontmatter(source);
const body = normalizeAdmonitionLabels(rawBody);
// Run the pipeline up to (but not including) HTML stringification —
// we want the hast tree so we can splice it into the envelope and
// serialize the whole thing in one pass below.
const processor = unified()
.use(remarkParse)
.use(remarkGFM)
.use(remarkFrontmatter, ["yaml"])
.use(remarkDirective)
.use(remarkAdmonition)
.use(remarkHeadings)
.use(remarkLists)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeAnchors, { publicDirectory })
.use(rehypeHighlight, {
languages: {
...common,
nginx: nginxGrammar,
apache: apacheGrammar,
conf: confGrammar,
diff: diffGrammar,
},
})
.use(rehypeMermaid);
const tree = /** @type {import('hast').Root} */ (
await processor.run(processor.parse(body), body)
);
const title = typeof frontmatter.title === "string" ? frontmatter.title : null;
const html = renderEnvelope(/** @type {import('hast').Element[]} */ (tree.children), title);
return { html, frontmatter };
}

View File

@@ -1,5 +1,17 @@
/**
* @file MDX plugin for ESBuild.
* @file Markdown plugin for ESBuild.
*
* Resolves `~docs/...` imports to the website docs tree, then compiles each
* `.md` / `.mdx` file to HTML at build time. The compiled HTML uses
* `<ak-md-a>` and `<ak-alert>` custom elements so the runtime side can
* stamp the HTML directly into shadow DOM without any client-side
* JavaScript evaluation — this is what lets the page CSP drop
* `'unsafe-eval'`.
*
* The on-load result is shipped via the `file` loader so the JSON travels
* over the existing fetch-then-set-innerHTML path used by `<ak-mdx>`. The
* shape is `{ content, frontmatter, publicPath, publicDirectory }` where
* `content` is now pre-rendered HTML rather than raw markdown source.
*
* @import {
* OnLoadArgs,
@@ -14,35 +26,25 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { MonoRepoRoot } from "@goauthentik/core/paths/node";
import { compileMarkdown } from "./compile.js";
/**
* @typedef {Omit<OnLoadArgs, 'pluginData'> & LoadDataFields} LoadData Data passed to `onload`.
*
* @typedef LoadDataFields Extra fields given in `data` to `onload`.
* @property {PluginData | null | undefined} [pluginData] Plugin data.
*
* @typedef PluginData Extra data passed.
* @property {Buffer | string | null | undefined} [contents] File contents.
*/
import { MonoRepoRoot } from "@goauthentik/core/paths/node";
const pluginName = "mdx-plugin";
/**
* @typedef MDXPluginOptions
*
* @property {string} root Root directory.
* @property {string} root Repository root.
*/
/**
* Bundle MDX into JSON modules.
* Bundle markdown and MDX source into JSON modules.
*
* @param {MDXPluginOptions} options
* @returns {Plugin}
*/
export function mdxPlugin({ root }) {
const prefix = "~docs";
// TODO: Replace with `resolvePackage` after NPM Workspaces support is added.
const docsPackageRoot = path.resolve(MonoRepoRoot, "website");
@@ -59,32 +61,32 @@ export function mdxPlugin({ root }) {
return {
path: path.join(docsPackageRoot, "docs", args.path.slice(prefix.length)),
pluginName,
};
}
/**
* @param {LoadData} data
* @param {OnLoadArgs} args
* @returns {Promise<OnLoadResult>}
*/
async function loadListener(data) {
const content = String(
data.pluginData &&
data.pluginData.contents !== null &&
data.pluginData.contents !== undefined
? data.pluginData.contents
: await fs.readFile(data.path),
);
async function loadListener(args) {
const source = String(await fs.readFile(args.path));
const publicPath = path.resolve(
"/",
path.relative(path.join(root, "website", "docs"), data.path),
path.relative(path.join(root, "website", "docs"), args.path),
);
const publicDirectory = path.dirname(publicPath);
const { html, frontmatter } = await compileMarkdown(source, publicDirectory);
return {
contents: JSON.stringify({ content, publicPath, publicDirectory }),
contents: JSON.stringify({
content: html,
frontmatter,
publicPath,
publicDirectory,
}),
loader: "file",
pluginName,
};

View File

@@ -0,0 +1,117 @@
/**
* @file Rehype plugins for the build-time markdown pipeline.
*/
import { CurrentReleaseDocsURL } from "@goauthentik/core/version/node";
import { SKIP, visit } from "unist-util-visit";
/**
* Resolve a relative `href` against the docs base URL. Same logic the old
* runtime `MDXAnchor` used: take a `./...` href relative to the file's
* `publicDirectory`, drop trailing `index`/`.md`/`.mdx`, and absolutize
* against {@linkcode CurrentReleaseDocsURL}.
*
* @param {string} href
* @param {string} publicDirectory
* @returns {string}
*/
function resolveDocsHref(href, publicDirectory) {
// `new URL(...)` against `file:///` lets us reuse the browser-style
// path resolver while preserving the hash and any query string.
const joined = `${publicDirectory}/${href}`.replace(/\/{2,}/g, "/");
const placeholder = new URL(joined, "file:///");
const next = new URL(placeholder.pathname, CurrentReleaseDocsURL);
next.pathname = next.pathname.replace(/(index)?\.mdx?$/, "");
next.search = placeholder.search;
next.hash = placeholder.hash;
return next.toString();
}
/**
* Rehype plugin: resolve relative anchors at build time and wrap every
* `<a>` in an `<ak-md-a>` light-DOM custom element. The wrapper attaches
* the fragment-link click interceptor at runtime so clicks on
* `<a href="#section">` scroll within the host shadow tree rather than
* overwriting `location.hash` (which would yank the hash-routed SPA off
* its current page).
*
* Wrapping (rather than replacing) keeps the real `<a>` element inside
* `<ak-mdx>`'s shadow tree where the existing PatternFly link CSS in
* `styles.css` applies. The wrapper itself uses `display: contents` so
* it does not perturb inline-flow layout.
*
* @param {{ publicDirectory: string }} options
*/
export function rehypeAnchors({ publicDirectory }) {
return (/** @type {import('hast').Root} */ tree) => {
visit(tree, "element", (node) => {
if (node.tagName !== "a") return;
const props = node.properties || (node.properties = {});
const href = typeof props.href === "string" ? props.href : "";
if (!href) return;
if (href.startsWith(".")) {
props.href = resolveDocsHref(href, publicDirectory);
props.target = "_blank";
props.rel = "noopener noreferrer";
} else if (!href.startsWith("#")) {
// Already-absolute external link: open in a new tab.
props.target = "_blank";
props.rel = "noopener noreferrer";
}
// Wrap the anchor in `<ak-md-a>` by mutating the node in
// place: the `<a>`'s contents become a single child, the
// outer node becomes the wrapper. Returning `SKIP` keeps
// the visitor from descending into the freshly-stamped
// child anchor (which would re-match this filter and
// recurse forever).
/** @type {import('hast').Element} */
const original = {
type: "element",
tagName: "a",
properties: { ...props },
children: node.children,
};
node.tagName = "ak-md-a";
node.properties = {};
node.children = [original];
return SKIP;
});
};
}
/**
* Rehype plugin: replace `language-mermaid` code blocks with
* `<ak-diagram>` elements carrying the mermaid source as text content.
* `<ak-diagram>` reads its own `textContent` and renders the SVG, so no
* wrapper element is needed.
*/
export function rehypeMermaid() {
return (/** @type {import('hast').Root} */ tree) => {
visit(tree, "element", (node) => {
if (node.tagName !== "pre") return;
const child = node.children?.[0];
if (!child || child.type !== "element" || child.tagName !== "code") return;
const className = child.properties?.className ?? [];
const classes = Array.isArray(className) ? className : [className];
if (!classes.includes("language-mermaid")) return;
const source = (child.children ?? [])
.map((c) => (c.type === "text" ? c.value : ""))
.join("");
node.tagName = "ak-diagram";
node.properties = {};
node.children = [{ type: "text", value: source }];
return SKIP;
});
};
}

View File

@@ -0,0 +1,134 @@
/**
* @file Remark plugins for the build-time markdown pipeline.
*
* The runtime side (`src/elements/ak-mdx/remark/*`) mirrors a subset of
* these. Keeping the shapes parallel makes it easier to spot drift when
* either pipeline grows a new transform.
*/
import { visit } from "unist-util-visit";
const ADMONITIONS = new Set(["info", "warning", "danger", "note", "caution", "tip"]);
/**
* `caution` and `tip` aren't first-class PatternFly alert levels — map
* them onto the closest equivalent so PFAlert styles render correctly.
*/
const ADMONITION_LEVEL = {
info: "pf-m-info",
warning: "pf-m-warning",
danger: "pf-m-danger",
note: "pf-m-info",
caution: "pf-m-warning",
tip: "pf-m-success",
};
/**
* Match a Docusaurus-style admonition opening line:
*
* :::caution Reserved application slugs
*
* `remark-directive` only understands the spec form `:::name[label]{attrs}`
* — a bare-space label silently falls through as plain text. We rewrite
* the source so the directive parser sees the bracketed form and the
* label is preserved as the directive's first paragraph.
*/
const ADMONITION_BARE_LABEL_RE = new RegExp(
`^(:::(?:${[...ADMONITIONS].join("|")}))[ \\t]+(.+?)[ \\t]*$`,
"gm",
);
/**
* @param {string} source
* @returns {string}
*/
export function normalizeAdmonitionLabels(source) {
return source.replace(ADMONITION_BARE_LABEL_RE, "$1[$2]");
}
/**
* Remark plugin: convert `:::info` / `:::warning` / `:::danger` / `:::note`
* directives into `<ak-alert>` elements with a level attribute. The first
* child paragraph carrying the `directiveLabel` flag (i.e. `:::info[Title]`
* syntax) is promoted to a `<strong>` so the title renders as a heading-ish
* element inside the slot.
*/
export function remarkAdmonition() {
return (/** @type {import('mdast').Root} */ tree) => {
visit(tree, (node) => {
if (
node.type !== "containerDirective" &&
node.type !== "leafDirective" &&
node.type !== "textDirective"
) {
return;
}
if (!ADMONITIONS.has(node.name)) return;
const tagName = node.type === "textDirective" ? "span" : "ak-alert";
const data = node.data || (node.data = {});
data.hName = tagName;
data.hProperties = {
...(data.hProperties || {}),
...(node.attributes || {}),
level:
/** @type {Record<string, string>} */ (ADMONITION_LEVEL)[node.name] ??
`pf-m-${node.name}`,
};
const children = /** @type {any[]} */ (node.children || []);
const labelIndex = children.findIndex(
(c) => c.type === "paragraph" && c.data?.directiveLabel,
);
if (labelIndex !== -1) {
const label = children[labelIndex];
children[labelIndex] = {
type: "paragraph",
children: [{ type: "strong", children: label.children }],
};
}
});
};
}
/**
* Remark plugin: kebab-case heading slugs into `id` attributes.
*/
export function remarkHeadings() {
/**
* @param {{ value?: string, children?: any[] }} n
* @returns {string}
*/
const flatten = (n) => {
if (n.value) return n.value;
if (n.children) return n.children.map(flatten).join("");
return "";
};
return (/** @type {import('mdast').Root} */ tree) => {
visit(tree, "heading", (node) => {
const id = flatten(node)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const data = node.data || (node.data = {});
data.hProperties = { ...(data.hProperties || {}), id };
});
};
}
/**
* Remark plugin: tag lists with PatternFly's content class.
*/
export function remarkLists() {
return (/** @type {import('mdast').Root} */ tree) => {
visit(tree, "list", (node) => {
const data = node.data || (node.data = {});
data.hProperties = {
...(data.hProperties || {}),
className: "pf-c-list",
};
});
};
}

786
web/package-lock.json generated
View File

@@ -35,7 +35,6 @@
"@lit/localize-tools": "^0.8.1",
"@lit/reactive-element": "^2.1.2",
"@lit/task": "^1.0.3",
"@mdx-js/mdx": "^3.1.1",
"@mrmarble/djangoql-completion": "^0.8.3",
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
@@ -77,6 +76,7 @@
"fuse.js": "^7.3.0",
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hast-util-to-html": "^9.0.0",
"hastscript": "^9.0.1",
"knip": "^6.6.0",
"lex": "^2025.11.0",
@@ -85,6 +85,7 @@
"lit-element": "^4.2.2",
"lit-html": "^3.3.2",
"md-front-matter": "^1.0.4",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.14.0",
"node-domexception": "^2025.11.0",
"npm-run-all": "^4.1.5",
@@ -95,16 +96,14 @@
"prettier-plugin-packagejson": "^3.0.2",
"pseudolocale": "^2.2.0",
"rapidoc": "^9.3.8",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"rehype-highlight": "^7.0.2",
"rehype-mermaid": "^3.0.0",
"rehype-parse": "^9.0.1",
"rehype-stringify": "^10.0.1",
"remark-directive": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"storybook": "^10.2.1",
"style-mod": "^4.1.3",
"trusted-types": "^2.0.0",
@@ -113,6 +112,7 @@
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.57.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "^8.0.8",
"vitest": "^4.1.1",
@@ -1762,43 +1762,6 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
"integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdx": "^2.0.0",
"acorn": "^8.0.0",
"collapse-white-space": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"estree-util-scope": "^1.0.0",
"estree-walker": "^3.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"markdown-extensions": "^2.0.0",
"recma-build-jsx": "^1.0.0",
"recma-jsx": "^1.0.0",
"recma-stringify": "^1.0.0",
"rehype-recma": "^1.0.0",
"remark-mdx": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.0.0",
"source-map": "^0.7.0",
"unified": "^11.0.0",
"unist-util-position-from-estree": "^2.0.0",
"unist-util-stringify-position": "^4.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@@ -5200,15 +5163,6 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/estree-jsx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -6478,15 +6432,6 @@
"node": ">=4"
}
},
"node_modules/astring": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz",
"integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==",
"license": "MIT",
"bin": {
"astring": "bin/astring"
}
},
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -7301,16 +7246,6 @@
"@codemirror/view": "^6.0.0"
}
},
"node_modules/collapse-white-space": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -8635,38 +8570,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esast-util-from-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz",
"integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"devlop": "^1.0.0",
"estree-util-visit": "^2.0.0",
"unist-util-position-from-estree": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/esast-util-from-js": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz",
"integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"acorn": "^8.0.0",
"esast-util-from-estree": "^2.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
@@ -9108,100 +9011,6 @@
"node": ">=4.0"
}
},
"node_modules/estree-util-attach-comments": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz",
"integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-util-build-jsx": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz",
"integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"estree-walker": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-util-is-identifier-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-util-scope": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz",
"integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"devlop": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-util-to-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz",
"integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"astring": "^1.8.0",
"source-map": "^0.7.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-util-value-to-estree": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.5.0.tgz",
"integrity": "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
}
},
"node_modules/estree-util-visit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz",
"integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -10274,21 +10083,6 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-dom": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz",
"integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==",
"license": "ISC",
"dependencies": {
"@types/hast": "^3.0.0",
"hastscript": "^9.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
@@ -10307,22 +10101,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-html-isomorphic": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz",
"integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-dom": "^5.0.0",
"hast-util-from-html": "^2.0.0",
"unist-util-remove-position": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
@@ -10369,34 +10147,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-estree": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz",
"integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-attach-comments": "^3.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"style-to-js": "^1.0.0",
"unist-util-position": "^5.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -10420,33 +10170,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"hast-util-whitespace": "^3.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"style-to-js": "^1.0.0",
"unist-util-position": "^5.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@@ -10688,12 +10411,6 @@
"node": ">=8"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/inspect-with-kind": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/inspect-with-kind/-/inspect-with-kind-1.0.5.tgz",
@@ -12256,18 +11973,6 @@
"integrity": "sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==",
"license": "BSD-2-Clause"
},
"node_modules/markdown-extensions": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
"integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -12512,83 +12217,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz",
"integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-mdx-expression": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-jsx": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"parse-entities": "^4.0.0",
"stringify-entities": "^4.0.0",
"unist-util-stringify-position": "^4.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdxjs-esm": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
"license": "MIT",
"dependencies": {
"@types/estree-jsx": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -12710,37 +12338,6 @@
"uuid": "^11.1.0"
}
},
"node_modules/mermaid-isomorphic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mermaid-isomorphic/-/mermaid-isomorphic-3.1.0.tgz",
"integrity": "sha512-mzrvfEVjnJIkJlEqxp3eMuR1wS0TeLCH1VK5E/T5yzWaBwI3JqjJuw70yUIThSCDJ5bRs6O3rgfp00oBAbvSeQ==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.0.0",
"katex": "^0.16.0",
"mermaid": "^11.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
},
"peerDependencies": {
"playwright": "1"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
}
}
},
"node_modules/mermaid-isomorphic/node_modules/@fortawesome/fontawesome-free": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/micromark": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -12966,108 +12563,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-mdx-expression": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
"integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"devlop": "^1.0.0",
"micromark-factory-mdx-expression": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-events-to-acorn": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-mdx-jsx": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz",
"integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"devlop": "^1.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"micromark-factory-mdx-expression": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-events-to-acorn": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-mdx-md": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz",
"integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-mdxjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz",
"integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==",
"license": "MIT",
"dependencies": {
"acorn": "^8.0.0",
"acorn-jsx": "^5.0.0",
"micromark-extension-mdx-expression": "^3.0.0",
"micromark-extension-mdx-jsx": "^3.0.0",
"micromark-extension-mdx-md": "^2.0.0",
"micromark-extension-mdxjs-esm": "^3.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-mdxjs-esm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz",
"integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-events-to-acorn": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"unist-util-position-from-estree": "^2.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -13111,33 +12606,6 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-factory-mdx-expression": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz",
"integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-events-to-acorn": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"unist-util-position-from-estree": "^2.0.0",
"vfile-message": "^4.0.0"
}
},
"node_modules/micromark-factory-space": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
@@ -13339,31 +12807,6 @@
],
"license": "MIT"
},
"node_modules/micromark-util-events-to-acorn": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz",
"integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"estree-util-visit": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0",
"vfile-message": "^4.0.0"
}
},
"node_modules/micromark-util-html-tag-name": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
@@ -13572,15 +13015,6 @@
"node": ">=4"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"license": "MIT",
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minim": {
"version": "0.23.8",
"resolved": "https://registry.npmjs.org/minim/-/minim-0.23.8.tgz",
@@ -15343,73 +14777,6 @@
"node": ">=0.10.0"
}
},
"node_modules/recma-build-jsx": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz",
"integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-util-build-jsx": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/recma-jsx": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz",
"integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==",
"license": "MIT",
"dependencies": {
"acorn-jsx": "^5.0.0",
"estree-util-to-js": "^2.0.0",
"recma-parse": "^1.0.0",
"recma-stringify": "^1.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
},
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/recma-parse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz",
"integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"esast-util-from-js": "^2.0.0",
"unified": "^11.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/recma-stringify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz",
"integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-util-to-js": "^2.0.0",
"unified": "^11.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -15482,34 +14849,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-mermaid": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/rehype-mermaid/-/rehype-mermaid-3.0.0.tgz",
"integrity": "sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.0",
"mermaid-isomorphic": "^3.0.0",
"mini-svg-data-uri": "^1.0.0",
"space-separated-tokens": "^2.0.0",
"unified": "^11.0.0",
"unist-util-visit-parents": "^6.0.0",
"vfile": "^6.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
},
"peerDependencies": {
"playwright": "1"
},
"peerDependenciesMeta": {
"playwright": {
"optional": true
}
}
},
"node_modules/rehype-parse": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz",
@@ -15525,21 +14864,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-recma": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz",
"integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"hast-util-to-estree": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-stringify": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz",
@@ -15605,37 +14929,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
"integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==",
"license": "MIT",
"dependencies": {
"mdast-util-mdx": "^3.0.0",
"micromark-extension-mdxjs": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-mdx-frontmatter": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/remark-mdx-frontmatter/-/remark-mdx-frontmatter-5.2.0.tgz",
"integrity": "sha512-U/hjUYTkQqNjjMRYyilJgLXSPF65qbLPdoESOkXyrwz2tVyhAnm4GUKhfXqOOS9W34M3545xEMq+aMpHgVjEeQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"estree-util-value-to-estree": "^3.0.0",
"toml": "^3.0.0",
"unified": "^11.0.0",
"unist-util-mdx-define": "^1.0.0",
"yaml": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -17408,24 +16701,6 @@
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
"license": "MIT",
"dependencies": {
"style-to-object": "1.0.14"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@@ -17695,12 +16970,6 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==",
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -18138,24 +17407,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-mdx-define": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/unist-util-mdx-define/-/unist-util-mdx-define-1.1.2.tgz",
"integrity": "sha512-9ncH7i7TN5Xn7/tzX5bE3rXgz1X/u877gYVAUB3mLeTKYJmQHmqKTDBi6BTGXV7AeolBCI9ErcVsOt2qryoD0g==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"estree-util-scope": "^1.0.0",
"estree-walker": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"url": "https://github.com/sponsors/remcohaszing"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
@@ -18169,33 +17420,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position-from-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz",
"integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-remove-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",

View File

@@ -111,7 +111,6 @@
"@lit/localize-tools": "^0.8.1",
"@lit/reactive-element": "^2.1.2",
"@lit/task": "^1.0.3",
"@mdx-js/mdx": "^3.1.1",
"@mrmarble/djangoql-completion": "^0.8.3",
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
@@ -153,6 +152,7 @@
"fuse.js": "^7.3.0",
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hast-util-to-html": "^9.0.0",
"hastscript": "^9.0.1",
"knip": "^6.6.0",
"lex": "^2025.11.0",
@@ -161,6 +161,7 @@
"lit-element": "^4.2.2",
"lit-html": "^3.3.2",
"md-front-matter": "^1.0.4",
"mdast-util-to-string": "^4.0.0",
"mermaid": "^11.14.0",
"node-domexception": "^2025.11.0",
"npm-run-all": "^4.1.5",
@@ -171,16 +172,14 @@
"prettier-plugin-packagejson": "^3.0.2",
"pseudolocale": "^2.2.0",
"rapidoc": "^9.3.8",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"rehype-highlight": "^7.0.2",
"rehype-mermaid": "^3.0.0",
"rehype-parse": "^9.0.1",
"rehype-stringify": "^10.0.1",
"remark-directive": "^4.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-mdx-frontmatter": "^5.2.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"storybook": "^10.2.1",
"style-mod": "^4.1.3",
"trusted-types": "^2.0.0",
@@ -189,6 +188,7 @@
"type-fest": "^5.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.57.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "^8.0.8",
"vitest": "^4.1.1",

View File

@@ -6,22 +6,29 @@ import { Diagram } from "#elements/Diagram";
import { FlowsApi } from "@goauthentik/api";
import { PropertyValues } from "lit-element/lit-element.js";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-flow-diagram")
export class FlowDiagram extends Diagram {
@property()
flowSlug?: string;
@property({ type: String, useDefault: true })
flowSlug: string | null = null;
refreshHandler = (): void => {
this.diagram = undefined;
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("flowSlug")) {
this.refresh();
}
}
protected refresh = (): void => {
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesDiagramRetrieve({
slug: this.flowSlug || "",
})
.then((data) => {
this.diagram = data.diagram;
this.requestUpdate();
});
};
}

View File

@@ -9,24 +9,28 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-source-oauth-diagram")
export class OAuthSourceDiagram extends Diagram {
@property({ attribute: false })
source?: OAuthSource;
@property({ attribute: false, useDefault: true })
public source: OAuthSource | null = null;
refreshHandler = (): void => {
protected override syncDiagramContent = (): void => {
if (!this.source) return;
const graph = ["graph LR"];
graph.push(`source[${msg(str`OAuth Source ${this.source.name}`)}]`);
graph.push(
const graph = [
"graph LR",
`source[${msg(str`OAuth Source ${this.source.name}`)}]`,
`source --> flow_manager["${UserMatchingModeToLabel(
this.source.userMatchingMode || UserMatchingModeEnum.Identifier,
)}"]`,
);
];
if (this.source.enrollmentFlow) {
graph.push("flow_manager --> flow_enroll[Enrollment flow]");
}
if (this.source.authenticationFlow) {
graph.push("flow_manager --> flow_auth[Authentication flow]");
}
this.diagram = graph.join("\n");
};
}

49
web/src/common/mermaid.ts Normal file
View File

@@ -0,0 +1,49 @@
import { DOM_PURIFY_STRICT } from "#common/purify";
import { ResolvedUITheme } from "#common/theme";
import type { Mermaid, MermaidConfig } from "mermaid";
export const DefaultMermaidConfig: Readonly<MermaidConfig> = {
logLevel: "fatal",
startOnLoad: false,
flowchart: {
curve: "linear",
},
htmlLabels: false,
securityLevel: "strict",
dompurifyConfig: DOM_PURIFY_STRICT,
};
let lastActiveTheme: ResolvedUITheme | null = null;
let mermaid: Mermaid | null = null;
/**
* Load the Mermaid library and initialize it with the appropriate theme based on the provided UI theme.
*
* @remarks
*
* Mermaid is only loaded once and cached for subsequent calls. Note that
* Mermaid is a singleton and does not support multiple instances with different configurations.
*/
export async function loadMermaid(uiTheme: ResolvedUITheme): Promise<Mermaid> {
if (!mermaid) {
const mermaidModule = await import("mermaid");
mermaid = mermaidModule.default;
}
if (uiTheme && uiTheme === lastActiveTheme) {
return mermaid;
}
const theme = uiTheme === "dark" ? "dark" : "default";
mermaid.initialize({
...DefaultMermaidConfig,
theme,
darkMode: uiTheme === "dark",
});
lastActiveTheme = uiTheme;
return mermaid;
}

View File

@@ -50,6 +50,17 @@ export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitiz
},
});
/**
* Trusted types policy for HTML produced by our own build-time markdown
* pipeline. The HTML is generated from source we own (the `mdx-plugin`),
* including custom elements like `<ak-md-a>` and `<ak-alert>` that
* DOMPurify's default tag list would strip. Treat the input as already
* trusted and pass it through unmodified.
*/
export const CompiledMarkdownTrustPolicy = trustedTypes.createPolicy("authentik-markdown", {
createHTML: (trustedHTML: string) => trustedHTML,
});
/**
* Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by
* a trusted source, such as the brand API.

View File

@@ -261,26 +261,29 @@ declare global {
* @param hint The color scheme hint to use.
* @param doc The document to apply the theme to.
*/
export const applyDocumentTheme = ((currentUITheme = resolveUITheme(), doc = document): void => {
export const applyDocumentTheme = ((
currentUITheme = resolveUITheme(),
ownerDocument = document,
): void => {
console.debug(`authentik/theme (document): want to switch to ${currentUITheme} theme`);
const { themeChoice } = doc.documentElement.dataset;
const { themeChoice } = ownerDocument.documentElement.dataset;
if (themeChoice && themeChoice !== "auto") {
console.debug(
`authentik/theme (document): skipping theme application due to explicit choice (${themeChoice})`,
);
doc.dispatchEvent(new ThemeChangeEvent(themeChoice));
ownerDocument.dispatchEvent(new ThemeChangeEvent(themeChoice));
return;
}
doc.documentElement.dataset.theme = currentUITheme;
ownerDocument.documentElement.dataset.theme = currentUITheme;
console.debug(`authentik/theme (document): switching to ${currentUITheme} theme`);
doc.dispatchEvent(new ThemeChangeEvent(currentUITheme));
ownerDocument.dispatchEvent(new ThemeChangeEvent(currentUITheme));
}) satisfies UIThemeListener;
/**

View File

@@ -0,0 +1,41 @@
:host {
--ak-mermaid-message-text: var(--pf-c-content--Color);
display: flex;
justify-content: center;
}
svg#diagram {
.node {
rect,
circle,
ellipse,
polygon,
path {
fill: var(--pf-global--BackgroundColor--300);
}
}
.rect {
fill: var(
--ak-mermaid-box-background-color,
var(--pf-global--BackgroundColor--light-300)
) !important;
}
.messageText {
stroke-width: 4;
fill: var(--ak-mermaid-message-text) !important;
paint-order: stroke;
}
}
/* #region Dark Theme */
:host([theme="dark"]) {
--ak-mermaid-message-text: var(--ak-dark-foreground);
--ak-mermaid-box-background-color: var(--ak-dark-background-lighter);
--ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200);
}
/* #endregion */

View File

@@ -1,98 +1,82 @@
import "#elements/EmptyState";
import { EVENT_REFRESH } from "#common/constants";
import { DOM_PURIFY_STRICT } from "#common/purify";
import { ThemeChangeEvent } from "#common/theme";
import { AKRefreshEvent } from "#common/events";
import { loadMermaid } from "#common/mermaid";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import Styles from "#elements/Diagram.css";
import { EmptyState } from "#elements/EmptyState";
import { SlottedTemplateResult } from "#elements/types";
import { UiThemeEnum } from "@goauthentik/api";
import mermaid, { MermaidConfig } from "mermaid";
import { css, CSSResult, html, TemplateResult } from "lit";
import { CSSResult, PropertyValues } from "lit";
import { guard } from "lit-html/directives/guard.js";
import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
@customElement("ak-diagram")
export class Diagram extends AKElement {
@property({ attribute: false })
diagram?: string;
static styles: CSSResult[] = [Styles];
refreshHandler = (): void => {
#diagram = "";
@property({ attribute: false, useDefault: true })
public get diagram(): string {
return this.#diagram || this.textContent.trim() || "";
}
public set diagram(value: string) {
const previous = this.#diagram;
this.#diagram = value.trim();
this.requestUpdate("diagram", previous);
}
@listen(AKRefreshEvent, {
target: window,
})
protected syncDiagramContent = (): void => {
if (!this.textContent) return;
this.diagram = this.textContent;
};
handlerBound = false;
static styles: CSSResult[] = [
css`
:host {
display: flex;
justify-content: center;
}
`,
];
config: MermaidConfig;
loadingPlaceholder: EmptyState;
constructor() {
super();
this.config = {
// The type definition for this says number
// but the example use strings
// and numbers don't work
logLevel: "fatal",
startOnLoad: false,
flowchart: {
curve: "linear",
},
htmlLabels: false,
securityLevel: "strict",
dompurifyConfig: DOM_PURIFY_STRICT,
};
mermaid.initialize(this.config);
this.loadingPlaceholder = new EmptyState();
this.loadingPlaceholder.loading = true;
}
firstUpdated(): void {
if (this.handlerBound) return;
window.addEventListener(EVENT_REFRESH, this.refreshHandler);
this.addEventListener(ThemeChangeEvent.eventName, ((ev: CustomEvent<UiThemeEnum>) => {
if (ev.detail === UiThemeEnum.Dark) {
this.config.theme = "dark";
} else {
this.config.theme = "default";
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.syncDiagramContent();
}
protected renderMermaid(): Promise<SlottedTemplateResult> {
return loadMermaid(this.activeTheme).then((mermaid) => {
if (!this.diagram) {
return null;
}
mermaid.initialize(this.config);
}) as EventListener);
this.handlerBound = true;
this.refreshHandler();
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_REFRESH, this.refreshHandler);
}
return mermaid.render("diagram", this.diagram).then((result) => {
result.bindFunctions?.(this.renderRoot as HTMLElement);
render(): TemplateResult {
this.querySelectorAll("*").forEach((el) => {
try {
el.remove();
} catch {
console.debug(`authentik/diagram: failed to remove element ${el}`);
}
return unsafeHTML(result.svg);
});
});
}
protected override render(): SlottedTemplateResult {
const { diagram, loadingPlaceholder, activeTheme } = this;
return guard([diagram, activeTheme], () => {
if (!diagram) {
return loadingPlaceholder;
}
return until(this.renderMermaid(), loadingPlaceholder);
});
if (!this.diagram) {
return html`<ak-empty-state loading></ak-empty-state>`;
}
return html`${until(
mermaid.render("graph", this.diagram).then((r) => {
r.bindFunctions?.(this.shadowRoot as unknown as Element);
return unsafeHTML(r.svg);
}),
)}`;
}
}

View File

@@ -62,6 +62,7 @@ export abstract class Interface extends AKElement {
public override connectedCallback(): void {
super.connectedCallback();
requestAnimationFrame(() => {
this.commandPalette.modal.setCommands(
createCommonCommands().map((command) => ({

View File

@@ -1,36 +0,0 @@
import { createContext, useContext } from "react";
import type { MDXModule } from "~docs/types";
/**
* Fetches an MDX module from a URL or ESBuild static asset.
*/
export function fetchMDXModule(url: string | URL): Promise<MDXModule> {
return fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch content: ${response.statusText}`);
}
return response.json();
})
.catch((error) => {
console.error("Error fetching content", error);
return { content: "", publicPath: "", publicDirectory: "" };
});
}
/**
* A context for the current MDX module.
*/
export const MDXModuleContext = createContext<MDXModule>({
content: "",
});
MDXModuleContext.displayName = "MDXModuleContext";
/**
* A hook to access the current MDX module.
*/
export function useMDXModule(): MDXModule {
return useContext(MDXModuleContext);
}

View File

@@ -0,0 +1,146 @@
import "#elements/Alert";
import "#elements/Diagram";
import "#elements/ak-mdx/components/ak-md-a";
import { globalAK } from "#common/global";
import { BrandedHTMLPolicy, CompiledMarkdownTrustPolicy, sanitizeHTML } from "#common/purify";
import { compileRuntimeMarkdown } from "#elements/ak-mdx/markdown";
import Styles from "#elements/ak-mdx/styles.css";
import { AKElement } from "#elements/Base";
import { SlottedTemplateResult } from "#elements/types";
import { DistDirectoryName, StaticDirectoryName } from "#paths";
import OneDark from "#styles/atom/one-dark.css";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
/**
* The JSON envelope our build-time `mdx-plugin` emits for every imported
* `.md` / `.mdx` file: the `content` field is **pre-rendered HTML**, not
* raw markdown source.
*/
interface MarkdownModule {
content: string;
frontmatter?: Record<string, unknown>;
publicPath?: string;
publicDirectory?: string;
}
async function fetchMarkdownModule(url: string | URL): Promise<MarkdownModule> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch markdown: ${response.statusText}`);
return response.json();
}
/**
* A replacer applied to the compiled HTML before it is stamped into the
* shadow DOM. Used by callers who need to substitute `{placeholder}`-style
* tokens (e.g. proxy-provider sample configs).
*/
export type Replacer = (input: string) => string;
/**
* Renders markdown into shadow DOM with no client-side JavaScript
* evaluation. Two modes:
*
* - `url`: resolves to a JSON envelope produced by the build-time
* `mdx-plugin`. The envelope's `content` is already HTML.
* - `content`: an admin-supplied markdown string. Compiled in-browser
* through a pure `unified` / remark / rehype pipeline (no `eval`,
* no `Function`), then sanitized via `BrandedHTMLPolicy`.
*/
@customElement("ak-mdx")
export class AKMDX extends AKElement {
@property({ type: String, reflect: true, useDefault: true })
public url: string | null = null;
@property()
public content?: string;
@property({ attribute: false })
public replacers: Replacer[] = [];
@state()
protected compiledTemplate: SlottedTemplateResult = null;
static styles = [
// ---
PFList,
PFTable,
PFContent,
OneDark,
Styles,
];
public override async connectedCallback() {
super.connectedCallback();
await this.hydrate();
}
#applyReplacers(html: string): string {
return this.replacers.reduce((acc, replacer) => replacer(acc), html);
}
/**
* URL mode: HTML comes from our build-time pipeline. It may contain
* custom-element tags (`<ak-alert>`, `<ak-md-a>`) that DOMPurify's
* default tag list would strip, so we route it through a Trusted
* Types policy that passes the input through unmodified.
*/
async #hydrateFromURL(url: string): Promise<SlottedTemplateResult> {
const { relBase } = globalAK().api;
const pathname =
relBase +
StaticDirectoryName +
"/" +
DistDirectoryName +
url.slice(url.indexOf("/assets"));
const module = await fetchMarkdownModule(pathname);
if (module.publicDirectory) {
this.dataset.publicDirectory = module.publicDirectory;
}
const trustedHTML = CompiledMarkdownTrustPolicy.createHTML(
this.#applyReplacers(module.content),
);
return unsafeHTML(trustedHTML.toString());
}
/**
* Content mode: admin-supplied markdown compiled in-browser through
* a pure `unified` / remark / rehype pipeline (no `eval`, no
* `Function`), then sanitized via `BrandedHTMLPolicy`.
*/
async #hydrateFromContent(source: string): Promise<SlottedTemplateResult> {
const html = this.#applyReplacers(await compileRuntimeMarkdown(source));
return sanitizeHTML(BrandedHTMLPolicy, html);
}
/**
* Resolve `url` or `content` into a template result and stash it on
* reactive state. After this completes, Lit's render takes over.
*/
protected async hydrate(): Promise<void> {
this.compiledTemplate = this.url
? await this.#hydrateFromURL(this.url)
: await this.#hydrateFromContent(this.content ?? "");
}
public override render(): SlottedTemplateResult {
return this.compiledTemplate;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-mdx": AKMDX;
}
}

View File

@@ -1,165 +0,0 @@
import "#elements/Alert";
import { globalAK } from "#common/global";
import { BrandedHTMLPolicy } from "#common/purify";
import { MDXAnchor } from "#elements/ak-mdx/components/MDXAnchor";
import { MDXWrapper } from "#elements/ak-mdx/components/MDXWrapper";
import { fetchMDXModule, MDXModuleContext } from "#elements/ak-mdx/MDXModuleContext";
import { remarkAdmonition } from "#elements/ak-mdx/remark/remark-admonition";
import { remarkHeadings } from "#elements/ak-mdx/remark/remark-headings";
import { remarkLists } from "#elements/ak-mdx/remark/remark-lists";
import Styles from "#elements/ak-mdx/styles.css";
import { AKElement } from "#elements/Base";
import { DistDirectoryName, StaticDirectoryName } from "#paths";
import OneDark from "#styles/atom/one-dark.css";
import { UiThemeEnum } from "@goauthentik/api";
import { compile as compileMDX, run as runMDX } from "@mdx-js/mdx";
import apacheGrammar from "highlight.js/lib/languages/apache";
import diffGrammar from "highlight.js/lib/languages/diff";
import confGrammar from "highlight.js/lib/languages/ini";
import nginxGrammar from "highlight.js/lib/languages/nginx";
import { common } from "lowlight";
import { createRoot, Root } from "react-dom/client";
import * as runtime from "react/jsx-runtime";
import rehypeHighlight, { Options as HighlightOptions } from "rehype-highlight";
import rehypeMermaid, { RehypeMermaidOptions } from "rehype-mermaid";
import remarkDirective from "remark-directive";
import remarkFrontmatter from "remark-frontmatter";
import remarkGFM from "remark-gfm";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import remarkParse from "remark-parse";
import type { MDXModule } from "~docs/types";
import { customElement, property } from "lit/decorators.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
const highlightThemeOptions: HighlightOptions = {
languages: {
...common,
nginx: nginxGrammar,
apache: apacheGrammar,
conf: confGrammar,
diff: diffGrammar,
},
};
/**
* A replacer function that can be used to modify the output of the MDX component.
*/
export type Replacer = (input: string) => string;
@customElement("ak-mdx")
export class AKMDX extends AKElement {
// HACK: Fixes Lit Analyzer's parsing of TSX files with decorators.
@((property as typeof property)({ type: String, reflect: true }))
public url?: string;
@((property as typeof property)())
public content?: string;
@((property as typeof property)({ attribute: false }))
public replacers: Replacer[] = [];
#reactRoot: Root | null = null;
static styles = [
// ---
PFList,
PFTable,
PFContent,
OneDark,
Styles,
];
public async connectedCallback() {
super.connectedCallback();
this.#reactRoot = createRoot(this.shadowRoot!);
let nextMDXModule: MDXModule | undefined;
const { relBase } = globalAK().api;
if (this.url) {
const pathname =
relBase +
StaticDirectoryName +
"/" +
DistDirectoryName +
this.url.slice(this.url.indexOf("/assets"));
nextMDXModule = await fetchMDXModule(pathname);
} else {
nextMDXModule = {
content: `${BrandedHTMLPolicy.createHTML(this.content || "")}`,
};
}
return this.delegateRender(nextMDXModule);
}
protected async delegateRender(mdxModule: MDXModule): Promise<void> {
if (!this.#reactRoot) return;
const normalized = this.replacers.reduce(
(content, replacer) => replacer(content),
mdxModule.content,
);
const mdx = await compileMDX(normalized, {
outputFormat: "function-body",
remarkPlugins: [
remarkParse,
remarkDirective,
remarkAdmonition,
remarkGFM,
remarkFrontmatter,
remarkMdxFrontmatter,
remarkHeadings,
remarkLists,
],
rehypePlugins: [
// ---
[rehypeHighlight, highlightThemeOptions],
[
rehypeMermaid,
{
prefix: "mermaid-svg-",
colorScheme: this.activeTheme === UiThemeEnum.Dark ? "dark" : "light",
} satisfies RehypeMermaidOptions,
],
],
});
const { default: Content, ...mdxExports } = await runMDX(mdx, {
...runtime,
baseUrl: import.meta.url,
});
const { frontmatter = {} } = mdxExports;
this.#reactRoot.render(
<MDXModuleContext.Provider value={mdxModule}>
<Content
frontmatter={frontmatter}
components={{
wrapper: MDXWrapper,
a: MDXAnchor,
}}
/>
</MDXModuleContext.Provider>,
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-mdx": AKMDX;
}
}

View File

@@ -1,72 +0,0 @@
import { useMDXModule } from "#elements/ak-mdx/MDXModuleContext";
import React from "react";
/**
* A simplified version of Node's `path.resolve`:
*/
function resolvePath(...args: string[]): string {
const pathname = args
// Combine all arguments into a single path...
.join("/")
// Normalizing any delimiting slashes...
.replace(/\/{2,}/g, "/");
return new URL(pathname, "file:///").pathname;
}
/**
* A custom anchor element that applies special behavior for MDX content.
*
* - Resolves relative links to the public directory in the public docs domain.
* - Intercepts local links and scrolls to the target element.
*/
export const MDXAnchor = ({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const { publicDirectory } = useMDXModule();
if (href?.startsWith(".") && publicDirectory) {
const nextPathname = resolvePath(publicDirectory, href);
const nextURL = new URL(nextPathname, import.meta.env.AK_DOCS_URL);
// Remove trailing .md and .mdx, and trailing "index".
nextURL.pathname = nextURL.pathname.replace(/(index)?\.mdx?$/, "");
href = nextURL.toString();
}
const interceptHeadingLinks = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!href || !href.startsWith("#")) return;
event.preventDefault();
const rootNode = event.currentTarget.getRootNode() as ShadowRoot;
const elementID = href.slice(1);
const target = rootNode.getElementById(elementID);
if (!target) {
console.warn(`Element with ID ${elementID} not found`);
return;
}
target.scrollIntoView({
behavior: "smooth",
block: "center",
});
};
return (
<a
href={href}
onClick={interceptHeadingLinks}
rel="noopener noreferrer"
target="_blank"
{...props}
>
{children}
</a>
);
};

View File

@@ -1,28 +0,0 @@
import React from "react";
export interface MDXWrapperProps {
children: React.ReactNode;
frontmatter: Record<string, string>;
}
/**
* A wrapper component for MDX content that adds a title if one is provided in the frontmatter.
*/
export const MDXWrapper = ({ children, frontmatter }: MDXWrapperProps) => {
const { title } = frontmatter;
const nextChildren = React.Children.toArray(children);
if (title) {
nextChildren.unshift(
<h1 key="header-title" part="title">
{title}
</h1>,
);
}
return (
<div className="pf-c-content" part="content">
{nextChildren}
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { AKElement } from "#elements/Base";
import { css, PropertyValues } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ak-md-a")
export class AKMarkdownAnchor extends AKElement {
public static styles = [
css`
:host {
display: contents;
}
`,
];
protected defaultSlot: HTMLSlotElement = this.ownerDocument.createElement("slot");
protected override render() {
return this.defaultSlot;
}
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
const anchors = this.defaultSlot
.assignedElements({ flatten: true })
.filter((element) => element.matches("a"));
for (const anchor of anchors) {
anchor.addEventListener("click", this.clickListener);
}
}
protected clickListener(event: MouseEvent): void {
const anchor = event.currentTarget as HTMLAnchorElement;
const href = anchor.getAttribute("href");
if (!href || !href.startsWith("#")) return;
event.preventDefault();
const rootNode = anchor.getRootNode() as ShadowRoot;
const elementID = href.slice(1);
const target = rootNode.getElementById(elementID);
if (!target) {
console.warn(`Element with ID ${elementID} not found`);
return;
}
target.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-md-a": AKMarkdownAnchor;
}
}

View File

@@ -0,0 +1,46 @@
import {
normalizeAdmonitionLabels,
remarkAdmonition,
} from "#elements/ak-mdx/remark/remark-admonition";
import { remarkHeadings } from "#elements/ak-mdx/remark/remark-headings";
import { remarkLists } from "#elements/ak-mdx/remark/remark-lists";
import rehypeStringify from "rehype-stringify";
import remarkDirective from "remark-directive";
import remarkGFM from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
/**
* Compile an admin-supplied markdown string to an HTML string in the
* browser. The pipeline is a strict subset of the build-time one: no
* syntax highlighting, no anchor rewriting — the output is plain HTML
* that the existing `BrandedHTMLPolicy` (DOMPurify) sanitizes cleanly.
*
* Unlike `@mdx-js/mdx`'s `evaluate` / `run`, none of the `unified`,
* `remark-*`, or `rehype-*` packages execute the input as JavaScript:
* they are pure tree transformers. This is what lets us drop
* `'unsafe-eval'` from the page CSP.
*/
export async function compileRuntimeMarkdown(source: string): Promise<string> {
if (!source.trim()) return "";
// Translate Docusaurus's `:::name Title` syntax to `:::name[Title]`
// before remark-directive parses it; otherwise it falls through as
// plain text.
const normalized = normalizeAdmonitionLabels(source);
const file = await unified()
.use(remarkParse)
.use(remarkGFM)
.use(remarkDirective)
.use(remarkAdmonition)
.use(remarkHeadings)
.use(remarkLists)
.use(remarkRehype, { allowDangerousHtml: false })
.use(rehypeStringify)
.process(normalized);
return String(file);
}

View File

@@ -1,35 +1,77 @@
import { UnwrapSet } from "#common/sets";
import { h } from "hastscript";
import type { Root } from "mdast";
import type { Paragraph, Root } from "mdast";
import type { Directives } from "mdast-util-directive";
import type { Plugin } from "unified";
import { visit } from "unist-util-visit";
import type { VFile } from "vfile";
const ADMONITION_TYPES = new Set(["info", "warning", "danger", "note"]);
export const ADMONITION_TYPES = new Set([
"info",
"warning",
"danger",
"note",
"caution",
"tip",
] as const);
export type AdmonitionType = UnwrapSet<typeof ADMONITION_TYPES>;
export function isAdmonitionType(value: string): value is AdmonitionType {
return ADMONITION_TYPES.has(value as AdmonitionType);
}
/**
* Remark plugin to add admonition classes to directives.
* `caution` and `tip` are not first-class PatternFly alert levels — map
* them to the closest equivalent so PFAlert styles render correctly.
*/
export const remarkAdmonition: Plugin<[unknown], Root, VFile> = () => {
const ADMONITION_LEVEL = {
info: "pf-m-info",
warning: "pf-m-warning",
danger: "pf-m-danger",
note: "pf-m-info",
caution: "pf-m-warning",
tip: "pf-m-success",
} as const satisfies Record<AdmonitionType, string>;
/**
* Remark plugin to convert `:::info` / `:::warning` / etc. directives
* to `<ak-alert>` elements. The first child paragraph carrying the
* `directiveLabel` flag (i.e. `:::info[Title]` syntax) is promoted to
* a `<strong>` so the title renders inside the admonition slot.
*/
export const remarkAdmonition: Plugin<[], Root, VFile> = () => {
return function transformer(tree) {
const visitor = (node: Directives) => {
if (
node.type === "containerDirective" ||
node.type === "leafDirective" ||
node.type === "textDirective"
node.type !== "containerDirective" &&
node.type !== "leafDirective" &&
node.type !== "textDirective"
) {
if (!ADMONITION_TYPES.has(node.name)) return;
return;
}
const data = node.data || (node.data = {});
if (!isAdmonitionType(node.name)) return;
const tagName = node.type === "textDirective" ? "span" : "ak-alert";
const data = node.data || (node.data = {});
const tagName = node.type === "textDirective" ? "span" : "ak-alert";
data.hName = tagName;
data.hName = tagName;
const element = h(tagName, node.attributes || {});
data.hProperties = element.properties || {};
data.hProperties.level = ADMONITION_LEVEL[node.name] ?? `pf-m-${node.name}`;
const element = h(tagName, node.attributes || {});
data.hProperties = element.properties || {};
data.hProperties.level = `pf-m-${node.name}`;
const children = node.children as Paragraph[];
const labelIndex = children.findIndex(
(c) => c.type === "paragraph" && c.data?.directiveLabel,
);
if (labelIndex !== -1) {
const label = children[labelIndex];
children[labelIndex] = {
type: "paragraph",
children: [{ type: "strong", children: label.children }],
};
}
};
@@ -37,3 +79,22 @@ export const remarkAdmonition: Plugin<[unknown], Root, VFile> = () => {
visit(tree, visitor);
};
};
/**
* Match a Docusaurus-style admonition opening line:
*
* ```
* :::info Title
*```
* `remark-directive` only understands the spec form `:::name[label]{attrs}`,
* so a bare-space label silently falls through as plain text. Rewrite
* the source so the directive parser sees the bracketed form.
*/
const ADMONITION_BARE_LABEL_RE = new RegExp(
`^(:::(?:${[...ADMONITION_TYPES].join("|")}))[ \\t]+(.+?)[ \\t]*$`,
"gm",
);
export function normalizeAdmonitionLabels(source: string): string {
return source.replace(ADMONITION_BARE_LABEL_RE, "$1[$2]");
}

View File

@@ -8,7 +8,7 @@ import { VFile } from "vfile";
/**
* Remark plugin to add IDs to headings.
*/
export const remarkHeadings: Plugin<[unknown], Root, VFile> = () => {
export const remarkHeadings: Plugin<[], Root, VFile> = () => {
return function transformer(tree) {
const visitor = (node: Heading) => {
const textContent = toString(node);

View File

@@ -6,7 +6,7 @@ import type { VFile } from "vfile";
/**
* Remark plugin to process lists.
*/
export const remarkLists: Plugin<[unknown], Root, VFile> = () => {
export const remarkLists: Plugin<[], Root, VFile> = () => {
return function transformer(tree) {
const visitor = (node: List) => {
node.data = node.data || {};

View File

@@ -1,8 +1,18 @@
:host {
--ak-mermaid-message-text: var(--pf-c-content--Color);
--ak-table-stripe-background: var(--pf-global--BackgroundColor--light-200);
}
/*
* `<ak-alert>` deliberately does not set its own `:host { display }` —
* every consumer is expected to set it (see e.g. captcha/styles.css).
* Without this, the shadow-tree flex layout collapses into an inline
* box and the admonition is unreadable.
*/
ak-alert {
display: block;
margin-block-start: var(--pf-global--spacer--md);
}
ak-alert + p {
margin-block-start: var(--pf-global--spacer--md);
}
@@ -59,37 +69,10 @@ pre:has(.hljs) {
padding: var(--pf-global--spacer--md);
}
svg[id^="mermaid-svg-"] {
.rect {
fill: var(
--ak-mermaid-box-background-color,
var(--pf-global--BackgroundColor--light-300)
) !important;
}
.messageText {
stroke-width: 4;
fill: var(--ak-mermaid-message-text) !important;
paint-order: stroke;
}
}
/* #region Dark Theme */
:host([theme="dark"]) {
--ak-mermaid-message-text: var(--ak-dark-foreground);
--ak-mermaid-box-background-color: var(--ak-dark-background-lighter);
--ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200);
svg[id^="mermaid-svg-"] {
line[class^="messageLine"] {
/*
Mermaid's support for dynamic palette changes leaves a lot to be desired.
This is a workaround to keep content readable while not breaking the rest of the theme.
*/
filter: invert(1) !important;
}
}
}
/* #endregion */

View File

@@ -6,7 +6,7 @@ import { ifPresent } from "#elements/utils/attributes";
import type { ThemedUrls } from "@goauthentik/api";
import { spread } from "@open-wc/lit-helpers";
import { ImgHTMLAttributes } from "react";
import type { ImgHTMLAttributes } from "react";
import { html, nothing } from "lit";

View File

@@ -4,7 +4,8 @@
* @see https://github.com/atom/one-dark-syntax
*/
:root {
:root,
:host {
--one-dark-base: #282c34;
--one-dark-mono-1: #abb2bf;
--one-dark-mono-2: #818896;

View File

@@ -13,7 +13,7 @@ import { Application } from "@goauthentik/api";
import { spread } from "@open-wc/lit-helpers";
import { kebabCase } from "change-case";
import { HTMLAttributes } from "react";
import type { HTMLAttributes } from "react";
import { msg } from "@lit/localize";
import { html } from "lit";

View File

@@ -0,0 +1,198 @@
import { expect, test } from "#e2e";
import { randomName } from "#e2e/utils/generators";
import { IDGenerator } from "@goauthentik/core/id";
import { series } from "@goauthentik/core/promises";
/**
* `<ak-mdx>` renders the OAuth 2.0 provider docs (`oauth2/index.mdx`) on
* the OAuth2 provider view page. That document is well-suited to exercise
* the full pipeline because it contains:
*
* - frontmatter (`title: OAuth 2.0 provider`)
* - multiple H2 headings (id slugs)
* - `:::caution` and `:::info` admonitions (two flavours: with title, without)
* - relative-doc links (`./create-oauth2-provider.md`)
* - external links (`https://oauth.net/2/`)
* - a `mermaid` sequence diagram
*
* These tests boot the admin UI, create a fresh OAuth2 provider, navigate
* to its view page, and then assert against the rendered DOM inside
* `<ak-mdx>`'s shadow root.
*/
test.describe("ak-mdx renders compiled markdown", () => {
let providerName: string;
test.beforeEach("Provision an OAuth2 provider", async ({ session, form, pointer, page }) => {
const seed = IDGenerator.randomID(6);
providerName = `${randomName(seed)} (${seed})`;
const { fill, selectSearchValue } = form;
const { click } = pointer;
await test.step("Authenticate", () => session.login({ to: "/if/admin/#/core/providers" }));
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
await test.step("Create provider via wizard", async () => {
await expect(dialog).toBeHidden();
await page.getByRole("button", { name: "New Provider" }).click();
await expect(dialog).toBeVisible();
await series(
[click, "OAuth2/OpenID", "option"],
[fill, "Provider Name", providerName],
[
selectSearchValue,
"Authorization Flow",
/default-provider-authorization-explicit-consent/,
],
[click, "Create", "button", dialog],
);
await expect(dialog).toBeHidden();
});
await test.step("Navigate to the provider's view page", async () => {
const $row = await form.search(providerName);
// The provider name cell is a link that opens the view page.
await $row.getByRole("link", { name: providerName }).first().click();
});
});
/**
* @returns a Locator scoped to the rendered `<ak-mdx>` element on the
* provider view page (there is exactly one inside the docs card).
*/
const $mdx = (page: import("@playwright/test").Page) =>
page.locator("ak-mdx").filter({ has: page.locator('h1[part="title"]') });
test("frontmatter title and heading slugs are rendered", async ({ page }) => {
const mdx = $mdx(page);
await expect(
mdx.locator('h1[part="title"]'),
"Frontmatter `title` rendered as an `<h1 part=title>`",
).toHaveText("OAuth 2.0 provider");
await expect(
mdx.locator("h2#authentik-and-oauth-2-0"),
"H2 carries a kebab-cased id slug derived from its text",
).toBeVisible();
await expect(
mdx.locator("h2#about-oauth-2-0-and-oidc"),
"Multiple H2s each receive their own slug",
).toBeVisible();
});
test("admonitions render as <ak-alert> with the right level", async ({ page }) => {
const mdx = $mdx(page);
const $caution = mdx
.locator('ak-alert[level="pf-m-warning"]')
.filter({ hasText: "Reserved application slugs" });
await expect(
$caution,
"`:::caution Title` renders an `<ak-alert level=pf-m-warning>` with the title in `<strong>`",
).toBeVisible();
await expect(
$caution.locator("strong"),
"Bare-space directive label is promoted to `<strong>`",
).toHaveText("Reserved application slugs");
await expect(
mdx.locator('ak-alert[level="pf-m-info"]').first(),
"`:::info` blocks render as `<ak-alert level=pf-m-info>`",
).toBeVisible();
});
test("links are wrapped in <ak-md-a> with build-time URL resolution", async ({ page }) => {
const mdx = $mdx(page);
const $external = mdx.locator('ak-md-a > a[href="https://oauth.net/2/"]');
await expect($external, "External link preserved verbatim").toBeVisible();
await expect($external).toHaveAttribute("target", "_blank");
await expect($external).toHaveAttribute("rel", "noopener noreferrer");
const $relative = mdx
.locator('ak-md-a > a[href*="next.goauthentik.io"][href*="create-oauth2-provider"]')
.first();
await expect(
$relative,
"Relative `./create-oauth2-provider.md` resolved to docs site URL at build time",
).toBeVisible();
await expect($relative).toHaveAttribute("target", "_blank");
// Fragment href is preserved verbatim from the source markdown,
// even when (as here) the docs author's intended target slug
// doesn't match this pipeline's slug algorithm. The wrapper
// intercepts the click regardless — the lookup only fails the
// scroll, not the link itself.
const $fragment = mdx.locator('ak-md-a > a[href="#about-oauth-20-and-oidc"]').first();
await expect(
$fragment,
"Fragment links are kept as `#…` so the wrapper can intercept them",
).toBeVisible();
await expect(
$fragment,
"Fragment links do NOT receive `target=_blank`",
).not.toHaveAttribute("target", "_blank");
});
test("mermaid diagrams render via <ak-diagram>", async ({ page }) => {
const mdx = $mdx(page);
const $diagram = mdx.locator("ak-diagram").first();
await expect($diagram).toBeVisible();
const $svg = $diagram.locator("svg");
await expect(
$svg,
"<ak-diagram> resolves the mermaid SVG into its shadow root",
).toBeVisible({ timeout: 10_000 });
});
test("mermaid responds to theme changes", async ({ page }) => {
const mdx = $mdx(page);
const $svg = mdx.locator("ak-diagram svg").first();
await expect($svg).toBeVisible({ timeout: 10_000 });
// `<ak-diagram>` re-renders the whole SVG via `mermaid.render(...)` on
// every `AKMermaidRefreshEvent`. Mermaid bakes the active theme into
// an inline `<style>` block inside the SVG, so the easiest stable
// signal that the right theme was applied is to assert the
// serialized SVG content changes between toggles.
const captureSVG = () => $svg.evaluate((el) => el.outerHTML);
const darkSVG = await captureSVG();
expect(darkSVG.length, "Initial mermaid SVG is non-empty").toBeGreaterThan(0);
await test.step("Toggle to light theme", async () => {
await page.evaluate(() => {
document.documentElement.dataset.themeChoice = "light";
});
});
await expect
.poll(captureSVG, {
message: "SVG content should change when re-rendered for light theme",
timeout: 10_000,
})
.not.toBe(darkSVG);
const lightSVG = await captureSVG();
await test.step("Toggle back to dark theme", async () => {
await page.evaluate(() => {
document.documentElement.dataset.themeChoice = "dark";
});
});
await expect
.poll(captureSVG, {
message: "SVG content should change again when re-rendered for dark theme",
timeout: 10_000,
})
.not.toBe(lightSVG);
});
});

37
web/types/mdx.d.ts vendored
View File

@@ -1,39 +1,22 @@
/**
* @file Provides types for ESBuild "virtual modules" generated from MDX files.
* @file Provides types for ESBuild "virtual modules" generated from
* Markdown / MDX files. The bundler's `mdx-plugin` compiles these to
* HTML at build time and emits a JSON envelope; importing the file
* yields the URL of that JSON envelope.
*/
declare module "~docs/types" {
/**
* A parsed JSON module containing MDX content and metadata from ESBuild.
*/
export interface MDXModule {
/**
* The Markdown content of the module.
*/
content: string;
/**
* The public path of the module, typically identical to the docs page path.
*/
publicPath?: string;
/**
* The public directory of the module, used to resolve relative links.
*/
publicDirectory?: string;
}
}
declare module "~docs/*.md" {
/**
* The serialized JSON content of an MD file.
* URL of the JSON envelope emitted for the imported file.
*/
const serializedJSON: string;
export default serializedJSON;
const url: string;
export default url;
}
declare module "~docs/*.mdx" {
/**
* The serialized JSON content of an MDX file.
* URL of the JSON envelope emitted for the imported file.
*/
const serializedJSON: string;
export default serializedJSON;
const url: string;
export default url;
}