web: Client-side MDX rendering (#13610)
* web: Allow build errors to propagate. * web: Refactor MDX for client-side rendering. * Remove override Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> * revert css for links and tables Signed-off-by: Jens Langhammer <jens@goauthentik.io> * web: Move Markdown specific styles. --------- Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@ -1,86 +0,0 @@
|
||||
import "@goauthentik/elements/Alert";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
||||
import { CSSResult, PropertyValues, css, nothing } from "lit";
|
||||
import { customElement, property } 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";
|
||||
|
||||
export type Replacer = (input: string) => string;
|
||||
|
||||
@customElement("ak-markdown")
|
||||
export class Markdown extends AKElement {
|
||||
@property()
|
||||
content: string = "";
|
||||
|
||||
@property()
|
||||
meta: string = "";
|
||||
|
||||
@property({ attribute: false })
|
||||
replacers: Replacer[] = [];
|
||||
|
||||
resolvedHTML = "";
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFList,
|
||||
PFContent,
|
||||
css`
|
||||
h2:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
const headingLinks =
|
||||
this.shadowRoot?.querySelectorAll<HTMLAnchorElement>("a.markdown-heading") ?? [];
|
||||
|
||||
for (const headingLink of headingLinks) {
|
||||
headingLink.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const url = new URL(headingLink.href);
|
||||
const elementID = url.hash.slice(1);
|
||||
|
||||
const target = this.shadowRoot?.getElementById(elementID);
|
||||
|
||||
if (!target) {
|
||||
console.warn(`Element with ID ${elementID} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
willUpdate(properties: PropertyValues<this>) {
|
||||
if (properties.has("content")) {
|
||||
this.resolvedHTML = this.replacers.reduce(
|
||||
(html, replacer) => replacer(html),
|
||||
this.content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.content) return nothing;
|
||||
|
||||
return unsafeHTML(this.resolvedHTML);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-markdown": Markdown;
|
||||
}
|
||||
}
|
||||
53
web/src/elements/ak-mdx/MDXModuleContext.ts
Normal file
53
web/src/elements/ak-mdx/MDXModuleContext.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
220
web/src/elements/ak-mdx/ak-mdx.tsx
Normal file
220
web/src/elements/ak-mdx/ak-mdx.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import "@goauthentik/elements/Alert";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import {
|
||||
MDXModule,
|
||||
MDXModuleContext,
|
||||
fetchMDXModule,
|
||||
} from "@goauthentik/elements/ak-mdx/MDXModuleContext";
|
||||
import { MDXAnchor } from "@goauthentik/elements/ak-mdx/components/MDXAnchor";
|
||||
import { MDXWrapper } from "@goauthentik/elements/ak-mdx/components/MDXWrapper";
|
||||
import { remarkAdmonition } from "@goauthentik/elements/ak-mdx/remark/remark-admonition";
|
||||
import { remarkHeadings } from "@goauthentik/elements/ak-mdx/remark/remark-headings";
|
||||
import { remarkLists } from "@goauthentik/elements/ak-mdx/remark/remark-lists";
|
||||
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 { Root, createRoot } from "react-dom/client";
|
||||
import * as runtime from "react/jsx-runtime";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { 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 { CSSResult, css } from "lit";
|
||||
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 { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
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 {
|
||||
@property({
|
||||
reflect: true,
|
||||
})
|
||||
url: string = "";
|
||||
|
||||
@property()
|
||||
content: string = "";
|
||||
|
||||
@property({ attribute: false })
|
||||
replacers: Replacer[] = [];
|
||||
|
||||
#reactRoot: Root | null = null;
|
||||
|
||||
resolvedHTML = "";
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFList,
|
||||
PFContent,
|
||||
css`
|
||||
a {
|
||||
--pf-global--link--Color: var(--pf-global--link--Color--light);
|
||||
--pf-global--link--Color--hover: var(--pf-global--link--Color--light--hover);
|
||||
--pf-global--link--Color--visited: var(--pf-global--link--Color);
|
||||
}
|
||||
|
||||
/*
|
||||
Note that order of anchor pseudo-selectors must follow:
|
||||
1. link
|
||||
2. visited
|
||||
3. hover
|
||||
4. active
|
||||
*/
|
||||
|
||||
a:link {
|
||||
color: var(--pf-global--link--Color);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--pf-global--link--Color--visited);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--pf-global--link--Color--hover);
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: var(--pf-global--link--Color);
|
||||
}
|
||||
|
||||
h2:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
table thead,
|
||||
table tr:nth-child(2n) {
|
||||
background-color: var(
|
||||
--ak-table-stripe-background,
|
||||
var(--pf-global--BackgroundColor--light-200)
|
||||
);
|
||||
}
|
||||
|
||||
table td,
|
||||
table th {
|
||||
border: var(--pf-table-border-width) solid var(--ifm-table-border-color);
|
||||
padding: var(--pf-global--spacer--md);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#reactRoot = createRoot(this.shadowRoot!);
|
||||
|
||||
let nextMDXModule: MDXModule | undefined;
|
||||
|
||||
if (this.url) {
|
||||
nextMDXModule = await fetchMDXModule(this.url);
|
||||
} else {
|
||||
nextMDXModule = {
|
||||
content: 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;
|
||||
}
|
||||
}
|
||||
61
web/src/elements/ak-mdx/components/MDXAnchor.tsx
Normal file
61
web/src/elements/ak-mdx/components/MDXAnchor.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useMDXModule } from "@goauthentik/elements/ak-mdx/MDXModuleContext";
|
||||
import { resolve } from "node:path";
|
||||
import React, { memo } from "react";
|
||||
|
||||
const DOCS_DOMAIN = "https://goauthentik.io";
|
||||
|
||||
/**
|
||||
* 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 = memo<React.AnchorHTMLAttributes<HTMLAnchorElement>>(
|
||||
({ href, children, ...props }) => {
|
||||
const { publicDirectory } = useMDXModule();
|
||||
|
||||
if (href?.startsWith(".") && publicDirectory) {
|
||||
const nextPathname = resolve(publicDirectory, href);
|
||||
|
||||
const nextURL = new URL(nextPathname, DOCS_DOMAIN);
|
||||
// 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MDXAnchor.displayName = "MDXAnchor";
|
||||
20
web/src/elements/ak-mdx/components/MDXWrapper.tsx
Normal file
20
web/src/elements/ak-mdx/components/MDXWrapper.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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: React.FC<MDXWrapperProps> = ({ children, frontmatter }) => {
|
||||
const { title } = frontmatter;
|
||||
const nextChildren = React.Children.toArray(children);
|
||||
|
||||
if (title) {
|
||||
nextChildren.unshift(<h1 key="header-title">{title}</h1>);
|
||||
}
|
||||
|
||||
return <>{nextChildren}</>;
|
||||
};
|
||||
2
web/src/elements/ak-mdx/index.ts
Normal file
2
web/src/elements/ak-mdx/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./ak-mdx";
|
||||
export { AKMDX as default } from "./ak-mdx";
|
||||
39
web/src/elements/ak-mdx/remark/remark-admonition.ts
Normal file
39
web/src/elements/ak-mdx/remark/remark-admonition.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { h } from "hastscript";
|
||||
import type { 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"]);
|
||||
|
||||
/**
|
||||
* Remark plugin to add admonition classes to directives.
|
||||
*/
|
||||
export const remarkAdmonition: Plugin<[unknown], Root, VFile> = () => {
|
||||
return function transformer(tree) {
|
||||
const visitor = (node: Directives) => {
|
||||
if (
|
||||
node.type === "containerDirective" ||
|
||||
node.type === "leafDirective" ||
|
||||
node.type === "textDirective"
|
||||
) {
|
||||
if (!ADMONITION_TYPES.has(node.name)) return;
|
||||
|
||||
const data = node.data || (node.data = {});
|
||||
|
||||
const tagName = node.type === "textDirective" ? "span" : "ak-alert";
|
||||
|
||||
data.hName = tagName;
|
||||
|
||||
const element = h(tagName, node.attributes || {});
|
||||
|
||||
data.hProperties = element.properties || {};
|
||||
data.hProperties.level = `pf-m-${node.name}`;
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore - visit cannot infer the type of the visitor.
|
||||
visit(tree, visitor);
|
||||
};
|
||||
};
|
||||
27
web/src/elements/ak-mdx/remark/remark-headings.ts
Normal file
27
web/src/elements/ak-mdx/remark/remark-headings.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { kebabCase } from "change-case";
|
||||
import { Heading, Root } from "mdast";
|
||||
import { toString } from "mdast-util-to-string";
|
||||
import { Plugin } from "unified";
|
||||
import { visit } from "unist-util-visit";
|
||||
import { VFile } from "vfile";
|
||||
|
||||
/**
|
||||
* Remark plugin to add IDs to headings.
|
||||
*/
|
||||
export const remarkHeadings: Plugin<[unknown], Root, VFile> = () => {
|
||||
return function transformer(tree) {
|
||||
const visitor = (node: Heading) => {
|
||||
const textContent = toString(node);
|
||||
const id = kebabCase(textContent);
|
||||
|
||||
node.data = node.data || {};
|
||||
node.data.hProperties = {
|
||||
...node.data.hProperties,
|
||||
id,
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-ignore - visit cannot infer the type of the visitor.
|
||||
visit(tree, "heading", visitor);
|
||||
};
|
||||
};
|
||||
23
web/src/elements/ak-mdx/remark/remark-lists.ts
Normal file
23
web/src/elements/ak-mdx/remark/remark-lists.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { List, Root } from "mdast";
|
||||
import type { Plugin } from "unified";
|
||||
import { visit } from "unist-util-visit";
|
||||
import type { VFile } from "vfile";
|
||||
|
||||
/**
|
||||
* Remark plugin to process lists.
|
||||
*/
|
||||
export const remarkLists: Plugin<[unknown], Root, VFile> = () => {
|
||||
return function transformer(tree) {
|
||||
const visitor = (node: List) => {
|
||||
node.data = node.data || {};
|
||||
|
||||
node.data.hProperties = {
|
||||
...node.data.hProperties,
|
||||
className: "pf-c-list",
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-ignore - visit cannot infer the type of the visitor.
|
||||
visit(tree, "list", visitor);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user