web/elements: extract mermaid runtime, modernize <ak-diagram> (#22980)

* web: Clean up diagram behavior.

* Add accessor.

* Fix import.

* Fix theme colors, consistent patternfly colors.

* Fix spelling.
This commit is contained in:
Teffen Ellis
2026-06-11 06:10:36 +02:00
committed by GitHub
parent 8554427d3f
commit 269a89708c
13 changed files with 462 additions and 166 deletions
+20
View File
@@ -37,6 +37,7 @@
"@lit/reactive-element": "^2.1.2",
"@lit/task": "^1.0.3",
"@mdx-js/mdx": "^3.1.1",
"@mermaid-js/layout-elk": "^0.2.1",
"@mrmarble/djangoql-completion": "^0.8.3",
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
@@ -2037,6 +2038,19 @@
"react": ">=16"
}
},
"node_modules/@mermaid-js/layout-elk": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/layout-elk/-/layout-elk-0.2.1.tgz",
"integrity": "sha512-MX9jwhMyd5zDcFsYcl3duDUkKhjVRUCGEQrdCeNV5hCIR6+3FuDDbRbFmvVbAu15K1+juzsYGG+K8MDvCY1Amg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
"elkjs": "^0.9.3"
},
"peerDependencies": {
"mermaid": "^11.0.2"
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
@@ -9387,6 +9401,12 @@
"integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==",
"license": "ISC"
},
"node_modules/elkjs": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz",
"integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
"license": "EPL-2.0"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+13 -12
View File
@@ -112,6 +112,7 @@
"@lit/reactive-element": "^2.1.2",
"@lit/task": "^1.0.3",
"@mdx-js/mdx": "^3.1.1",
"@mermaid-js/layout-elk": "^0.2.1",
"@mrmarble/djangoql-completion": "^0.8.3",
"@open-wc/lit-helpers": "^0.7.0",
"@openlayers-elements/core": "^0.4.0",
@@ -212,14 +213,10 @@
],
"wireit": {
"build": {
"#comment": [
"`npm run build` and `npm run watch` are the most common ",
"commands you should be using when working on the front end",
"The files and output spec here expect you to use `npm run build --watch` ",
"instead of `npm run watch`. The former is more comprehensive, but ",
"the latter is faster."
],
"command": "${NODE_RUNNER} scripts/build-web.mjs",
"dependencies": [
"build-locales"
],
"files": [
"src/**/*.{css,jpg,png,ts,js,json}",
"!src/**/*.stories.ts",
@@ -239,8 +236,12 @@
"./dist/poly-*.js.map",
"./dist/styles/**"
],
"dependencies": [
"build-locales"
"#comment": [
"`npm run build` and `npm run watch` are the most common ",
"commands you should be using when working on the front end",
"The files and output spec here expect you to use `npm run build --watch` ",
"instead of `npm run watch`. The former is more comprehensive, but ",
"the latter is faster."
],
"env": {
"NODE_RUNNER": {
@@ -255,9 +256,6 @@
"build-locales"
]
},
"locales:repair": {
"command": "prettier --write ./src/locale-codes.ts"
},
"lint:components": {
"command": "lit-analyzer src"
},
@@ -270,6 +268,9 @@
"lit-analyse": {
"command": "lit-analyzer src"
},
"locales:repair": {
"command": "prettier --write ./src/locale-codes.ts"
},
"precommit": {
"command": "prettier --write .",
"dependencies": [
+8 -7
View File
@@ -2,28 +2,29 @@ import "#elements/EmptyState";
import { aki } from "#common/api/client";
import { Diagram } from "#elements/Diagram";
import { Diagram } from "#elements/Diagram/ak-diagram";
import { FlowsApi } from "@goauthentik/api";
import { observes } from "@patternfly/pfe-core/decorators/observes.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 })
public flowSlug: string | null = null;
refreshHandler = (): void => {
this.diagram = undefined;
@observes("flowSlug")
protected refresh(): void {
aki(FlowsApi)
.flowsInstancesDiagramRetrieve({
slug: this.flowSlug || "",
})
.then((data) => {
this.diagram = data.diagram;
this.requestUpdate();
});
};
}
}
declare global {
@@ -1,4 +1,4 @@
import { Diagram } from "#elements/Diagram";
import { Diagram } from "#elements/Diagram/ak-diagram";
import { UserMatchingModeToLabel } from "#admin/sources/oauth/utils";
@@ -9,22 +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(
`source --> flow_manager["${UserMatchingModeToLabel(this.source.userMatchingMode || UserMatchingModeEnum.Identifier)}"]`,
);
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");
};
}
+8 -5
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;
/**
-103
View File
@@ -1,103 +0,0 @@
import "#elements/EmptyState";
import { EVENT_REFRESH } from "#common/constants";
import { DOM_PURIFY_STRICT } from "#common/purify";
import { ThemeChangeEvent } from "#common/theme";
import { AKElement } from "#elements/Base";
import { UiThemeEnum } from "@goauthentik/api";
import mermaid, { MermaidConfig } from "mermaid";
import { css, CSSResult, html, TemplateResult } from "lit";
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;
refreshHandler = (): void => {
if (!this.textContent) return;
this.diagram = this.textContent;
};
handlerBound = false;
static styles: CSSResult[] = [
css`
:host {
display: flex;
justify-content: center;
}
`,
];
config: MermaidConfig;
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);
}
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";
}
mermaid.initialize(this.config);
}) as EventListener);
this.handlerBound = true;
this.refreshHandler();
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_REFRESH, this.refreshHandler);
}
render(): TemplateResult {
this.querySelectorAll("*").forEach((el) => {
try {
el.remove();
} catch {
console.debug(`authentik/diagram: failed to remove element ${el}`);
}
});
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);
}),
)}`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-diagram": Diagram;
}
}
+4
View File
@@ -0,0 +1,4 @@
:host {
display: flex;
justify-content: center;
}
+88
View File
@@ -0,0 +1,88 @@
import "#elements/EmptyState";
import { AKRefreshEvent } from "#common/events";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import Styles from "#elements/Diagram/ak-diagram.css";
import { EmptyState } from "#elements/EmptyState";
import MermaidStyles from "#elements/mermaid/mermaid.css";
import { loadMermaid } from "#elements/mermaid/utils";
import { SlottedTemplateResult } from "#elements/types";
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 {
static styles: CSSResult[] = [MermaidStyles, Styles];
#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;
};
loadingPlaceholder: EmptyState;
constructor() {
super();
this.loadingPlaceholder = new EmptyState();
this.loadingPlaceholder.loading = true;
}
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;
}
return mermaid.render(`mermaid-svg-${this.localName}`, this.diagram).then((result) => {
result.bindFunctions?.(this.renderRoot as HTMLElement);
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);
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-diagram": Diagram;
}
}
+7 -3
View File
@@ -11,12 +11,12 @@ 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 MermaidStyles from "#elements/mermaid/mermaid.css";
import { loadMermaid } from "#elements/mermaid/utils";
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";
@@ -77,6 +77,7 @@ export class AKMDX extends AKElement {
PFTable,
PFContent,
OneDark,
MermaidStyles,
Styles,
];
@@ -113,6 +114,8 @@ export class AKMDX extends AKElement {
mdxModule.content,
);
const { activeTheme } = this;
const mdx = await compileMDX(normalized, {
outputFormat: "function-body",
remarkPlugins: [
@@ -132,7 +135,8 @@ export class AKMDX extends AKElement {
rehypeMermaid,
{
prefix: "mermaid-svg-",
colorScheme: this.activeTheme === UiThemeEnum.Dark ? "dark" : "light",
colorScheme: activeTheme,
mermaidConfig: await loadMermaid(activeTheme),
} satisfies RehypeMermaidOptions,
],
],
-27
View File
@@ -59,21 +59,6 @@ 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;
}
}
ak-alert + :is(h2, p) {
padding-top: var(--pf-global--spacer--md);
}
@@ -81,19 +66,7 @@ ak-alert + :is(h2, p) {
/* #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 */
+59
View File
@@ -0,0 +1,59 @@
/* svg[id^="mermaid-svg-"] { */
/* &.flowchart {
.edgeLabel .label {
padding: 4px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: var(--ak-foreground, #fff);
}
} */
.flowchart {
foreignObject:has(.edgeLabel) {
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.edgeLabel,
.edgeLabel .labelBkg {
background-color: transparent;
display: flex !important;
}
.edgeLabel > span,
.labelBkg > span.edgeLabel {
margin-inline: auto;
max-width: max-content;
padding: 3px 12px;
border-radius: 6px;
white-space: nowrap;
}
.edgeLabel {
background-color: var(--pf-global--palette--gold-200) !important;
border: 1px solid var(--pf-global--palette--gold-500) !important;
}
}
svg[id^="mermaid-svg-"] {
& > .rect {
fill: color-mix(var(--pf-global--palette--gold-100), transparent 95%);
stroke: var(--pf-global--palette--gold-100);
stroke-width: 1;
}
.messageText {
fill: var(--pf-global--Color--100) !important;
}
.messageLine0 {
stroke: var(--pf-global--palette--purple-300) !important;
}
[id$="-arrowhead"] path {
fill: var(--pf-global--palette--purple-300) !important;
stroke: var(--pf-global--palette--purple-300) !important;
}
}
+164
View File
@@ -0,0 +1,164 @@
import type { MermaidConfig } from "mermaid";
/**
* Resolves PatternFly CSS custom properties into concrete hex colors and maps
* them onto Mermaid's `themeVariables` keyset.
*
* @remarks
*
* Colors are parsed through a 1x1 canvas so that any valid CSS color form
* (named, rgb/rgba, hsl, or a `var()` chain) collapses to a hex string Mermaid
* can consume. Fully transparent values resolve to `"transparent"`.
*
* PatternFly 4 handles light/dark theming at the token level, so a single token
* set resolves correctly under either theme — no per-theme branching needed.
*/
export class MermaidThemeAdapter {
canvas = new OffscreenCanvas(1, 1);
ctx = this.canvas.getContext("2d");
constructor(protected computedStyle: CSSStyleDeclaration) {}
/**
* Resolve a CSS custom property to a hex color string.
*
* @param cssProperty The CSS custom property name to read.
* @param fallback Color used when the property is unset or empty.
*
* @returns A hex color code string, or `"transparent"` for fully transparent values.
*/
public readHexColorVariable = (cssProperty: string, fallback = "#ff0000"): string => {
if (!this.ctx) {
throw new Error("Could not create canvas context for color parsing");
}
this.ctx.clearRect(0, 0, 1, 1);
this.ctx.fillStyle = this.computedStyle.getPropertyValue(cssProperty).trim() || fallback;
this.ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = this.ctx.getImageData(0, 0, 1, 1).data;
if (a === 0) {
return "transparent";
}
// eslint-disable-next-line no-bitwise
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
/**
* Read a surface color, substituting an opaque fallback when the token
* resolves to `transparent`. Node fills must never be see-through.
*/
protected readSurface = (cssProperty: string, fallback: string): string => {
const value = this.readHexColorVariable(cssProperty, fallback);
return value === "transparent" ? fallback : value;
};
/**
* Map PatternFly tokens onto Mermaid's `themeVariables`.
*
* @remarks
*
* Requires `theme: "base"` in the Mermaid config — other built-in themes
* ignore most of these overrides.
*/
public toThemeVariables(darkMode?: boolean): MermaidConfig["themeVariables"] {
const { readHexColorVariable: read, readSurface } = this;
const surface = readSurface("--pf-global--palette--purple-50", "#ffffff");
const surfaceAlt = readSurface("--pf-global--palette--blue-50", surface);
const surfaceDark = readSurface("--pf-global--palette--black-200", surfaceAlt);
const textBase = read("--pf-global--palette--purple-700");
const textSecondary = read(
darkMode ? "--pf-global--palette--gold-100" : "--pf-global--palette--gold-400",
);
const border = read(
darkMode ? "--pf-global--palette--purple-300" : "--pf-global--palette--purple-700",
);
const primaryBorder = read("--pf-global--palette--purple-400");
const primaryAccent = read("--pf-global--palette--purple-100");
const primaryAccentText = read("--pf-global--palette--purple-700");
const success = read("--pf-global--success-color--100");
const danger = read("--pf-global--danger-color--100");
const warning = read("--pf-global--warning-color--100");
const info = read("--pf-global--info-color--100");
return {
// Base / canvas
background: surface,
mainBkg: surface,
fontFamily: "var(--ak-font-family-sans-serif)",
// Primary node
primaryColor: surface,
primaryBorderColor: primaryBorder,
primaryTextColor: textBase,
// Secondary node
secondaryColor: surfaceAlt,
secondaryBorderColor: border,
secondaryTextColor: textBase,
// Tertiary node
tertiaryColor: surfaceDark,
tertiaryBorderColor: border,
tertiaryTextColor: textBase,
// Edges / lines / labels
lineColor: textSecondary,
edgeLabelBackground: surface,
titleColor: textBase,
// Generic node fallbacks
nodeBorder: border,
nodeTextColor: textBase,
// Clusters / subgraphs
clusterBkg: surfaceDark,
clusterBorder: border,
// Notes
noteBkgColor: warning,
noteTextColor: textBase,
noteBorderColor: border,
// Brand accents (classDef / linkStyle)
primaryColorAccent: primaryAccent,
primaryTextColorAccent: primaryAccentText,
// Status (state / git / quadrant diagrams)
successColor: success,
errorColor: danger,
warningColor: warning,
infoColor: info,
// Sequence / state actors
actorBkg: surface,
actorBorder: border,
actorTextColor: textBase,
labelBoxBkgColor: surface,
labelTextColor: textBase,
};
}
/**
* Semantic accent colors for emitting `linkStyle` / `classDef` directives
* into diagram source (e.g. coloring policy pass/fail edges).
*/
public toAccents() {
const { readHexColorVariable: read } = this;
return {
success: read("--pf-global--success-color--100"),
danger: read("--pf-global--danger-color--100"),
warning: read("--pf-global--warning-color--100"),
info: read("--pf-global--info-color--100"),
primary: read("--pf-global--palette--purple-100"),
};
}
}
+76
View File
@@ -0,0 +1,76 @@
import MermaidStyles from "./mermaid.css";
import { DOM_PURIFY_STRICT } from "#common/purify";
import { ResolvedUITheme } from "#common/theme";
import { MermaidThemeAdapter } from "#elements/mermaid/theme";
import elkLayouts from "@mermaid-js/layout-elk";
import type { Mermaid, MermaidConfig } from "mermaid";
export const DefaultMermaidConfig: Readonly<MermaidConfig> = {
logLevel: "fatal",
startOnLoad: false,
htmlLabels: true,
fontFamily: "var(--ak-font-family-sans-serif)",
layout: "elk",
flowchart: {
curve: "linear",
nodeSpacing: 25,
rankSpacing: 25,
wrappingWidth: 500,
},
theme: "base",
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. Re-initialization occurs only when the active theme changes.
*
* @param uiTheme The resolved UI theme to derive Mermaid colors from.
* @returns The initialized Mermaid singleton.
*/
export async function loadMermaid(uiTheme: ResolvedUITheme): Promise<Mermaid> {
if (!mermaid) {
const mermaidModule = await import("mermaid");
mermaid = mermaidModule.default;
mermaid.registerLayoutLoaders(elkLayouts);
}
if (uiTheme && uiTheme === lastActiveTheme) {
return mermaid;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
const computedStyle = getComputedStyle(document.documentElement);
const darkMode = uiTheme === "dark";
const themeAdapter = new MermaidThemeAdapter(computedStyle);
const themeVariables = themeAdapter.toThemeVariables(darkMode);
mermaid.initialize({
...DefaultMermaidConfig,
themeVariables: {
...themeVariables,
},
darkMode,
themeCSS: String(MermaidStyles),
});
lastActiveTheme = uiTheme;
return mermaid;
}