Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified tools/server/public/index.html.gz
Binary file not shown.
69 changes: 69 additions & 0 deletions tools/server/webui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tools/server/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,36 +51,59 @@
.use(remarkGfm) // GitHub Flavored Markdown
.use(remarkMath) // Parse $inline$ and $$block$$ math
.use(remarkBreaks) // Convert line breaks to <br>
.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('<a')) {
return html;
}

const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;

// Make all links open in new tabs
const linkElements = tempDiv.querySelectorAll('a[href]');
let mutated = false;

for (const link of linkElements) {
const target = link.getAttribute('target');
const rel = link.getAttribute('rel');

if (target !== '_blank' || rel !== 'noopener noreferrer') {
mutated = true;
}

link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener noreferrer');
}

return tempDiv.innerHTML;
return mutated ? tempDiv.innerHTML : html;
}

function enhanceCodeBlocks(html: string): string {
if (!html.includes('<pre')) {
return html;
}

const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;

const preElements = tempDiv.querySelectorAll('pre');
let mutated = false;

for (const [index, pre] of Array.from(preElements).entries()) {
const codeElement = pre.querySelector('code');

if (!codeElement) continue;
if (!codeElement) {
continue;
}

mutated = true;

let language = 'text';
const classList = Array.from(codeElement.classList);
Expand Down Expand Up @@ -127,7 +151,7 @@
pre.parentNode?.replaceChild(wrapper, pre);
}

return tempDiv.innerHTML;
return mutated ? tempDiv.innerHTML : html;
}

async function processMarkdown(text: string): Promise<string> {
Expand Down
15 changes: 15 additions & 0 deletions tools/server/webui/src/lib/constants/literal-html.ts
Original file line number Diff line number Diff line change
@@ -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);
121 changes: 121 additions & 0 deletions tools/server/webui/src/lib/markdown/literal-html.ts
Original file line number Diff line number Diff line change
@@ -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 `<br>` 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;
});
};
};