web: Fix inline documentation rendering (#13379)
web: Fix issues surrounding markdown rendering. - Fix issue where Mermaid diagrams do not render. - Fix link colors in dark mode. - Fix anchored links triggering router. - Fix issue where links occasionally link to missing page.
This commit is contained in:
299
web/scripts/esbuild/build-mdx-plugin.mjs
Normal file
299
web/scripts/esbuild/build-mdx-plugin.mjs
Normal file
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @import {Options as HighlightOptions} from 'rehype-highlight'
|
||||
* @import {CompileOptions} from '@mdx-js/mdx'
|
||||
* @import {mdxmermaid} from 'mdx-mermaid'
|
||||
* @import {Message,
|
||||
OnLoadArgs,
|
||||
OnLoadResult,
|
||||
Plugin,
|
||||
PluginBuild
|
||||
* } from 'esbuild'
|
||||
*/
|
||||
import { run as runMDX } from "@mdx-js/mdx";
|
||||
import { createFormatAwareProcessors } from "@mdx-js/mdx/internal-create-format-aware-processors";
|
||||
import { extnamesToRegex } from "@mdx-js/mdx/internal-extnames-to-regex";
|
||||
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 mdxMermaid from "mdx-mermaid";
|
||||
import { Mermaid } from "mdx-mermaid/lib/Mermaid";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import * as runtime from "react/jsx-runtime";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
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 { SourceMapGenerator } from "source-map";
|
||||
import { VFile } from "vfile";
|
||||
import { VFileMessage } from "vfile-message";
|
||||
|
||||
import { remarkAdmonition } from "./remark/remark-admonition.mjs";
|
||||
import { remarkHeadings } from "./remark/remark-headings.mjs";
|
||||
import { remarkLinks } from "./remark/remark-links.mjs";
|
||||
import { remarkLists } from "./remark/remark-lists.mjs";
|
||||
|
||||
/**
|
||||
* @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 {CompileOptions} Options
|
||||
* Configuration.
|
||||
*
|
||||
* Options are the same as `compile` from `@mdx-js/mdx`.
|
||||
*
|
||||
* @typedef PluginData
|
||||
* Extra data passed.
|
||||
* @property {Buffer | string | null | undefined} [contents]
|
||||
* File contents.
|
||||
*
|
||||
* @typedef State
|
||||
* Info passed around.
|
||||
* @property {string} doc
|
||||
* File value.
|
||||
* @property {string} name
|
||||
* Plugin name.
|
||||
* @property {string} path
|
||||
* File path.
|
||||
*/
|
||||
|
||||
const eol = /\r\n|\r|\n|\u2028|\u2029/g;
|
||||
|
||||
const name = "@mdx-js/esbuild";
|
||||
|
||||
/**
|
||||
* Compile MDX to HTML.
|
||||
* *
|
||||
* @param {Readonly<Options> | null | undefined} [mdxOptions]
|
||||
* Configuration (optional).
|
||||
* @return {Plugin}
|
||||
* Plugin.
|
||||
*/
|
||||
export function mdxPlugin(mdxOptions) {
|
||||
/** @type {mdxmermaid.Config} */
|
||||
const mermaidConfig = {
|
||||
output: "svg",
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {HighlightOptions}
|
||||
*/
|
||||
const highlightThemeOptions = {
|
||||
languages: {
|
||||
...common,
|
||||
nginx: nginxGrammar,
|
||||
apache: apacheGrammar,
|
||||
conf: confGrammar,
|
||||
diff: diffGrammar,
|
||||
},
|
||||
};
|
||||
|
||||
const { extnames, process } = createFormatAwareProcessors({
|
||||
...mdxOptions,
|
||||
SourceMapGenerator,
|
||||
outputFormat: "function-body",
|
||||
|
||||
remarkPlugins: [
|
||||
remarkParse,
|
||||
remarkDirective,
|
||||
remarkAdmonition,
|
||||
remarkGFM,
|
||||
remarkFrontmatter,
|
||||
remarkMdxFrontmatter,
|
||||
remarkHeadings,
|
||||
remarkLinks,
|
||||
remarkLists,
|
||||
[mdxMermaid, mermaidConfig],
|
||||
],
|
||||
rehypePlugins: [[rehypeHighlight, highlightThemeOptions]],
|
||||
});
|
||||
|
||||
return { name, setup };
|
||||
|
||||
/**
|
||||
* @param {PluginBuild} build
|
||||
* Build.
|
||||
* @returns {undefined}
|
||||
* Nothing.
|
||||
*/
|
||||
function setup(build) {
|
||||
build.onLoad({ filter: extnamesToRegex(extnames) }, onload);
|
||||
|
||||
/**
|
||||
* @param {LoadData} data
|
||||
* Data.
|
||||
* @returns {Promise<OnLoadResult>}
|
||||
* Result.
|
||||
*/
|
||||
async function onload(data) {
|
||||
const document = String(
|
||||
data.pluginData &&
|
||||
data.pluginData.contents !== null &&
|
||||
data.pluginData.contents !== undefined
|
||||
? data.pluginData.contents
|
||||
: await fs.readFile(data.path),
|
||||
);
|
||||
|
||||
/** @type {State} */
|
||||
const state = {
|
||||
doc: document,
|
||||
name,
|
||||
path: data.path,
|
||||
};
|
||||
|
||||
let file = new VFile({
|
||||
path: data.path,
|
||||
value: document,
|
||||
});
|
||||
|
||||
/** @type {string | undefined} */
|
||||
let value;
|
||||
|
||||
/** @type {Array<VFileMessage>} */
|
||||
let messages = [];
|
||||
|
||||
/** @type {Array<Message>} */
|
||||
const errors = [];
|
||||
|
||||
/** @type {Array<Message>} */
|
||||
const warnings = [];
|
||||
|
||||
/**
|
||||
* @type {React.ComponentType<{children: React.ReactNode, frontmatter: Record<string, string>}>}
|
||||
*/
|
||||
const wrapper = ({ children, frontmatter }) => {
|
||||
const title = frontmatter.title;
|
||||
const nextChildren = React.Children.toArray(children);
|
||||
|
||||
if (title) {
|
||||
nextChildren.unshift(React.createElement("h1", { key: "title" }, title));
|
||||
}
|
||||
|
||||
return React.createElement(React.Fragment, null, nextChildren);
|
||||
};
|
||||
|
||||
try {
|
||||
file = await process(file);
|
||||
const { default: Content, ...mdxExports } = await runMDX(file, {
|
||||
...runtime,
|
||||
useMDXComponents: () => {
|
||||
return {
|
||||
mermaid: Mermaid,
|
||||
Mermaid,
|
||||
};
|
||||
},
|
||||
baseUrl: import.meta.url,
|
||||
});
|
||||
|
||||
const { frontmatter = {} } = mdxExports;
|
||||
const result = renderToStaticMarkup(
|
||||
Content({
|
||||
frontmatter,
|
||||
components: {
|
||||
wrapper,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
value = result;
|
||||
|
||||
messages = file.messages;
|
||||
} catch (error_) {
|
||||
const cause = /** @type {VFileMessage | Error} */ (error_);
|
||||
|
||||
console.error(cause);
|
||||
|
||||
const message =
|
||||
"reason" in cause
|
||||
? cause
|
||||
: new VFileMessage("Cannot process MDX file with esbuild", {
|
||||
cause,
|
||||
ruleId: "process-error",
|
||||
source: "@mdx-js/esbuild",
|
||||
});
|
||||
|
||||
message.fatal = true;
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
const list = message.fatal ? errors : warnings;
|
||||
list.push(vfileMessageToEsbuild(state, message));
|
||||
}
|
||||
|
||||
// Safety check: the file has a path, so there has to be a `dirname`.
|
||||
assert(file.dirname, "expected `dirname` to be defined");
|
||||
|
||||
return {
|
||||
contents: value || "",
|
||||
loader: "text",
|
||||
errors,
|
||||
resolveDir: path.resolve(file.cwd, file.dirname),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<State>} state
|
||||
* Info passed around.
|
||||
* @param {Readonly<VFileMessage>} message
|
||||
* VFile message or error.
|
||||
* @returns {Message}
|
||||
* ESBuild message.
|
||||
*/
|
||||
function vfileMessageToEsbuild(state, message) {
|
||||
const place = message.place;
|
||||
const start = place ? ("start" in place ? place.start : place) : undefined;
|
||||
const end = place && "end" in place ? place.end : undefined;
|
||||
let length = 0;
|
||||
let lineStart = 0;
|
||||
let line = 0;
|
||||
let column = 0;
|
||||
|
||||
if (start && start.offset !== undefined) {
|
||||
line = start.line;
|
||||
column = start.column - 1;
|
||||
lineStart = start.offset - column;
|
||||
length = 1;
|
||||
|
||||
if (end && end.offset !== undefined) {
|
||||
length = end.offset - start.offset;
|
||||
}
|
||||
}
|
||||
|
||||
eol.lastIndex = lineStart;
|
||||
|
||||
const match = eol.exec(state.doc);
|
||||
const lineEnd = match ? match.index : state.doc.length;
|
||||
|
||||
return {
|
||||
detail: message,
|
||||
id: "",
|
||||
location: {
|
||||
column,
|
||||
file: state.path,
|
||||
length: Math.min(length, lineEnd),
|
||||
line,
|
||||
lineText: state.doc.slice(lineStart, lineEnd),
|
||||
namespace: "file",
|
||||
suggestion: "",
|
||||
},
|
||||
notes: [],
|
||||
pluginName: state.name,
|
||||
text: message.reason,
|
||||
};
|
||||
}
|
141
web/scripts/esbuild/build-observer-plugin.mjs
Normal file
141
web/scripts/esbuild/build-observer-plugin.mjs
Normal file
@ -0,0 +1,141 @@
|
||||
import * as http from "http";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Serializes a custom event to a text stream.
|
||||
* a
|
||||
* @param {Event} event
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeCustomEventToStream(event) {
|
||||
// @ts-expect-error - TS doesn't know about the detail property
|
||||
const data = event.detail ?? {};
|
||||
|
||||
const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
|
||||
|
||||
return eventContent.join("\n") + "\n\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the build observer plugin.
|
||||
*
|
||||
* @typedef {Object} BuildObserverOptions
|
||||
*
|
||||
* @property {URL} serverURL
|
||||
* @property {string} logPrefix
|
||||
* @property {string} relativeRoot
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a plugin that listens for build events and sends them to a server-sent event stream.
|
||||
*
|
||||
* @param {BuildObserverOptions} options
|
||||
* @returns {import('esbuild').Plugin}
|
||||
*/
|
||||
export function buildObserverPlugin({ serverURL, logPrefix, relativeRoot }) {
|
||||
const timerLabel = `[${logPrefix}] Build`;
|
||||
const endpoint = serverURL.pathname;
|
||||
const dispatcher = new EventTarget();
|
||||
|
||||
const eventServer = http.createServer((req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.url !== endpoint) {
|
||||
console.log(`🚫 Invalid request to ${req.url}`);
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔌 Client connected");
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
const listener = (event) => {
|
||||
const body = serializeCustomEventToStream(event);
|
||||
|
||||
res.write(body);
|
||||
};
|
||||
|
||||
dispatcher.addEventListener("esbuild:start", listener);
|
||||
dispatcher.addEventListener("esbuild:error", listener);
|
||||
dispatcher.addEventListener("esbuild:end", listener);
|
||||
|
||||
req.on("close", () => {
|
||||
console.log("🔌 Client disconnected");
|
||||
|
||||
clearInterval(keepAliveInterval);
|
||||
|
||||
dispatcher.removeEventListener("esbuild:start", listener);
|
||||
dispatcher.removeEventListener("esbuild:error", listener);
|
||||
dispatcher.removeEventListener("esbuild:end", listener);
|
||||
});
|
||||
|
||||
const keepAliveInterval = setInterval(() => {
|
||||
console.timeStamp("🏓 Keep-alive");
|
||||
|
||||
res.write("event: keep-alive\n\n");
|
||||
res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
return {
|
||||
name: "build-watcher",
|
||||
setup: (build) => {
|
||||
eventServer.listen(parseInt(serverURL.port, 10), serverURL.hostname);
|
||||
|
||||
build.onDispose(() => {
|
||||
eventServer.close();
|
||||
});
|
||||
|
||||
build.onStart(() => {
|
||||
console.time(timerLabel);
|
||||
|
||||
dispatcher.dispatchEvent(
|
||||
new CustomEvent("esbuild:start", {
|
||||
detail: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
build.onEnd((buildResult) => {
|
||||
console.timeEnd(timerLabel);
|
||||
|
||||
if (!buildResult.errors.length) {
|
||||
dispatcher.dispatchEvent(
|
||||
new CustomEvent("esbuild:end", {
|
||||
detail: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Build ended with ${buildResult.errors.length} errors`);
|
||||
|
||||
dispatcher.dispatchEvent(
|
||||
new CustomEvent("esbuild:error", {
|
||||
detail: buildResult.errors.map((error) => ({
|
||||
...error,
|
||||
location: error.location
|
||||
? {
|
||||
...error.location,
|
||||
file: path.resolve(relativeRoot, error.location.file),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
46
web/scripts/esbuild/remark/remark-admonition.mjs
Normal file
46
web/scripts/esbuild/remark/remark-admonition.mjs
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @import {Plugin} from 'unified'
|
||||
* @import {Directives} from 'mdast-util-directive'
|
||||
* @import {} from 'mdast-util-to-hast'
|
||||
* @import {Root} from 'mdast'
|
||||
* @import {VFile} from 'vfile'
|
||||
*/
|
||||
import { h } from "hastscript";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
const ADMONITION_TYPES = new Set(["info", "warning", "danger", "note"]);
|
||||
|
||||
/**
|
||||
* Remark plugin to process links
|
||||
* @type {Plugin<[unknown], Root, VFile>}
|
||||
*/
|
||||
export function remarkAdmonition() {
|
||||
return function transformer(tree) {
|
||||
/**
|
||||
* @param {Directives} node
|
||||
*/
|
||||
const visitor = (node) => {
|
||||
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);
|
||||
};
|
||||
}
|
33
web/scripts/esbuild/remark/remark-headings.mjs
Normal file
33
web/scripts/esbuild/remark/remark-headings.mjs
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @import {Plugin} from 'unified'
|
||||
* @import {Root, Heading} from 'mdast'
|
||||
* @import {VFile} from 'vfile'
|
||||
*/
|
||||
import { kebabCase } from "change-case";
|
||||
import { toString } from "mdast-util-to-string";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
/**
|
||||
* Remark plugin to process links
|
||||
* @type {Plugin<[unknown], Root, VFile>}
|
||||
*/
|
||||
export const remarkHeadings = () => {
|
||||
return function transformer(tree) {
|
||||
/**
|
||||
* @param {Heading} node
|
||||
*/
|
||||
const visitor = (node) => {
|
||||
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);
|
||||
};
|
||||
};
|
59
web/scripts/esbuild/remark/remark-links.mjs
Normal file
59
web/scripts/esbuild/remark/remark-links.mjs
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @import {Plugin} from 'unified'
|
||||
* @import {} from 'mdast-util-directive'
|
||||
* @import {} from 'mdast-util-to-hast'
|
||||
* @import {Root, Link} from 'mdast'
|
||||
* @import {VFile} from 'vfile'
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
const DOCS_DOMAIN = "https://goauthentik.io";
|
||||
|
||||
/**
|
||||
* Remark plugin to process links
|
||||
* @type {Plugin<[unknown], Root, VFile>}
|
||||
*/
|
||||
export const remarkLinks = () => {
|
||||
return function transformer(tree, file) {
|
||||
const docsRoot = path.resolve(file.cwd, "..", "website");
|
||||
|
||||
/**
|
||||
* @param {Link} node
|
||||
*/
|
||||
const visitor = (node) => {
|
||||
node.data = node.data || {};
|
||||
|
||||
if (node.url.startsWith("#")) {
|
||||
node.data.hProperties = {
|
||||
className: "markdown-heading",
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
node.data.hProperties = {
|
||||
...node.data.hProperties,
|
||||
rel: "noopener noreferrer",
|
||||
target: "_blank",
|
||||
};
|
||||
|
||||
if (node.url.startsWith(".") && file.dirname) {
|
||||
const nextPathname = path.resolve(
|
||||
"/",
|
||||
path.relative(docsRoot, file.dirname),
|
||||
node.url,
|
||||
);
|
||||
const nextURL = new URL(nextPathname, DOCS_DOMAIN);
|
||||
|
||||
// Remove trailing .md and .mdx, and trailing "index".
|
||||
nextURL.pathname = nextURL.pathname.replace(/(index)?\.mdx?$/, "");
|
||||
|
||||
node.data.hProperties.href = nextURL.toString();
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore - visit cannot infer the type of the visitor.
|
||||
visit(tree, "link", visitor);
|
||||
};
|
||||
};
|
29
web/scripts/esbuild/remark/remark-lists.mjs
Normal file
29
web/scripts/esbuild/remark/remark-lists.mjs
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @import {Plugin} from 'unified'
|
||||
* @import {Root, List} from 'mdast'
|
||||
* @import {VFile} from 'vfile'
|
||||
*/
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
/**
|
||||
* Remark plugin to process links
|
||||
* @type {Plugin<[unknown], Root, VFile>}
|
||||
*/
|
||||
export const remarkLists = () => {
|
||||
return function transformer(tree) {
|
||||
/**
|
||||
* @param {List} node
|
||||
*/
|
||||
const visitor = (node) => {
|
||||
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