diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz
index f1a7568d7c674..c026f36c4844d 100644
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json
index c300ecaa77562..9cd6ef9138c95 100644
--- a/tools/server/webui/package-lock.json
+++ b/tools/server/webui/package-lock.json
@@ -50,6 +50,7 @@
"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
+ "mdast": "^3.0.0",
"mdsvex": "^0.12.3",
"playwright": "^1.53.0",
"prettier": "^3.4.2",
@@ -66,6 +67,7 @@
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
+ "unified": "^11.0.5",
"uuid": "^13.0.0",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^0.2.0",
@@ -2128,6 +2130,66 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.4.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.0.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.4.3",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.0.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.11",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.9.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.9.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
@@ -4946,6 +5008,13 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/mdast": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mdast/-/mdast-3.0.0.tgz",
+ "integrity": "sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json
index 7bf21bf57cb61..e073cd32f07e1 100644
--- a/tools/server/webui/package.json
+++ b/tools/server/webui/package.json
@@ -52,6 +52,7 @@
"eslint-plugin-svelte": "^3.0.0",
"fflate": "^0.8.2",
"globals": "^16.0.0",
+ "mdast": "^3.0.0",
"mdsvex": "^0.12.3",
"playwright": "^1.53.0",
"prettier": "^3.4.2",
@@ -68,6 +69,7 @@
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
+ "unified": "^11.0.5",
"uuid": "^13.0.0",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^0.2.0",
diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
index 45b9c6debbbd5..24d29c2b3e51e 100644
--- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
+++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
@@ -14,6 +14,7 @@
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
+ import { remarkLiteralHtml } from '$lib/markdown/literal-html';
interface Props {
content: string;
@@ -50,36 +51,59 @@
.use(remarkGfm) // GitHub Flavored Markdown
.use(remarkMath) // Parse $inline$ and $$block$$ math
.use(remarkBreaks) // Convert line breaks to
- .use(remarkRehype) // Convert to rehype (HTML AST)
+ .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
+ .use(remarkRehype) // Convert Markdown AST to rehype
.use(rehypeKatex) // Render math using KaTeX
.use(rehypeHighlight) // Add syntax highlighting
.use(rehypeStringify); // Convert to HTML string
});
function enhanceLinks(html: string): string {
+ if (!html.includes(' {
diff --git a/tools/server/webui/src/lib/constants/literal-html.ts b/tools/server/webui/src/lib/constants/literal-html.ts
new file mode 100644
index 0000000000000..ed1b0cf0d90d6
--- /dev/null
+++ b/tools/server/webui/src/lib/constants/literal-html.ts
@@ -0,0 +1,15 @@
+export const LINE_BREAK = /\r?\n/;
+
+export const PHRASE_PARENTS = new Set([
+ 'paragraph',
+ 'heading',
+ 'emphasis',
+ 'strong',
+ 'delete',
+ 'link',
+ 'linkReference',
+ 'tableCell'
+]);
+
+export const NBSP = '\u00a0';
+export const TAB_AS_SPACES = NBSP.repeat(4);
diff --git a/tools/server/webui/src/lib/markdown/literal-html.ts b/tools/server/webui/src/lib/markdown/literal-html.ts
new file mode 100644
index 0000000000000..d4ace01afe4fc
--- /dev/null
+++ b/tools/server/webui/src/lib/markdown/literal-html.ts
@@ -0,0 +1,121 @@
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
+import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants/literal-html';
+
+/**
+ * remark plugin that rewrites raw HTML nodes into plain-text equivalents.
+ *
+ * remark parses inline HTML into `html` nodes even when we do not want to render
+ * them. We turn each of those nodes into regular text (plus `
` break markers)
+ * so the downstream rehype pipeline escapes the characters instead of executing
+ * them. Leading spaces and tab characters are converted to non‑breaking spaces to
+ * keep indentation identical to the original author input.
+ */
+
+function preserveIndent(line: string): string {
+ let index = 0;
+ let output = '';
+
+ while (index < line.length) {
+ const char = line[index];
+
+ if (char === ' ') {
+ output += NBSP;
+ index += 1;
+ continue;
+ }
+
+ if (char === '\t') {
+ output += TAB_AS_SPACES;
+ index += 1;
+ continue;
+ }
+
+ break;
+ }
+
+ return output + line.slice(index);
+}
+
+function createLiteralChildren(value: string): PhrasingContent[] {
+ const lines = value.split(LINE_BREAK);
+ const nodes: PhrasingContent[] = [];
+
+ for (const [lineIndex, rawLine] of lines.entries()) {
+ if (lineIndex > 0) {
+ nodes.push({ type: 'break' } as Break as unknown as PhrasingContent);
+ }
+
+ nodes.push({
+ type: 'text',
+ value: preserveIndent(rawLine)
+ } as Text as unknown as PhrasingContent);
+ }
+
+ if (!nodes.length) {
+ nodes.push({ type: 'text', value: '' } as Text as unknown as PhrasingContent);
+ }
+
+ return nodes;
+}
+
+export const remarkLiteralHtml: Plugin<[], Root> = () => {
+ return (tree) => {
+ visit(tree, 'html', (node, index, parent) => {
+ if (!parent || typeof index !== 'number') {
+ return;
+ }
+
+ const replacement = createLiteralChildren(node.value);
+
+ if (!PHRASE_PARENTS.has(parent.type as string)) {
+ const paragraph: Paragraph = {
+ type: 'paragraph',
+ children: replacement as Paragraph['children'],
+ data: { literalHtml: true }
+ };
+
+ const siblings = parent.children as unknown as Content[];
+ siblings.splice(index, 1, paragraph as unknown as Content);
+
+ if (index > 0) {
+ const previous = siblings[index - 1] as Paragraph | undefined;
+
+ if (
+ previous?.type === 'paragraph' &&
+ (previous.data as { literalHtml?: boolean } | undefined)?.literalHtml
+ ) {
+ const prevChildren = previous.children as unknown as PhrasingContent[];
+
+ if (prevChildren.length) {
+ const lastChild = prevChildren[prevChildren.length - 1];
+
+ if (lastChild.type !== 'break') {
+ prevChildren.push({
+ type: 'break'
+ } as Break as unknown as PhrasingContent);
+ }
+ }
+
+ prevChildren.push(...(paragraph.children as unknown as PhrasingContent[]));
+
+ siblings.splice(index, 1);
+
+ return index;
+ }
+ }
+
+ return index + 1;
+ }
+
+ (parent.children as unknown as PhrasingContent[]).splice(
+ index,
+ 1,
+ ...(replacement as unknown as PhrasingContent[])
+ );
+
+ return index + replacement.length;
+ });
+ };
+};