This commit is contained in:
Timothy Jaeryang Baek 2026-10-15 23:02:47 +04:00
parent 7ca62fdf93
commit c9a52208da
7 changed files with 187 additions and 62 deletions

View file

@ -1,11 +1,10 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import MarkdownDocument from './MarkdownDocument.svelte';
import { settings, config } from '$lib/stores';
import { injectCsp } from '$lib/utils/csp';
import { isCodeFile } from '$lib/utils/codeHighlight';
import { initMermaid, renderMermaidDiagram, sanitizeSvg } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PdfPagesPreview from '../../common/PdfPagesPreview.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
@ -125,46 +124,6 @@
isHtml && selectedFile && baseUrl && baseUrl.includes('/api/v1/terminals/')
? `${baseUrl}/files/serve/${selectedFile.replace(/^\//, '')}`
: null;
$: renderedHtml =
isMarkdown && fileContent
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
: '';
let markdownEl: HTMLDivElement;
let mermaidInstance: any = null;
const renderMermaidBlocks = async (el: HTMLDivElement) => {
if (!el) return;
const codeEls = el.querySelectorAll('code.language-mermaid');
if (codeEls.length === 0) return;
if (!mermaidInstance) {
mermaidInstance = await initMermaid();
}
for (const codeEl of codeEls) {
const pre = codeEl.parentElement;
if (!pre || pre.tagName !== 'PRE' || pre.dataset.mermaidRendered) continue;
pre.dataset.mermaidRendered = 'true';
try {
const svg = await renderMermaidDiagram(mermaidInstance, codeEl.textContent ?? '');
if (svg) {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram flex justify-center py-2';
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
}
} catch (e) {
console.error('Mermaid render error:', e);
}
}
};
$: if (renderedHtml && markdownEl) {
tick().then(() => renderMermaidBlocks(markdownEl));
}
// Simple CSV parser that handles quoted fields
const parseCsv = (text: string, delimiter: string): string[][] => {
const rows: string[][] = [];
@ -424,9 +383,9 @@
/>
</div>
{:else if isMarkdown && !showRaw}
<div bind:this={markdownEl} class="prose dark:prose-invert max-w-full text-sm p-3">
{@html renderedHtml}
</div>
{#key selectedFile}
<MarkdownDocument content={fileContent} id={`file-${selectedFile}`} />
{/key}
{:else if isMarkdown && showRaw}
<div class="absolute inset-0">
<FileCodeEditor

View file

@ -0,0 +1,133 @@
<script lang="ts" context="module">
import { Marked } from 'marked';
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { footnoteExtension } from '$lib/utils/marked/footnote-extension';
// Documents must not inherit chat's global extensions or soft-line-break settings.
const documentMarkdown = new Marked(markedKatexExtension(), {
breaks: false,
extensions: [
{
...footnoteExtension(),
renderer: (token) => `<sup class="footnote-ref">${token.escapedText}</sup>`
}
]
});
</script>
<script lang="ts">
import { setContext } from 'svelte';
import MarkdownTokens from '../Messages/Markdown/MarkdownTokens.svelte';
import type { Token } from 'marked';
export let content: string;
export let id: string;
setContext('markdown', {
document: true,
allowRemoteContent: true,
lexer: (source: string) => documentMarkdown.lexer(source),
parseInline: (tokens: Token[]) =>
documentMarkdown.Parser.parseInline(tokens, documentMarkdown.defaults)
});
$: tokens = documentMarkdown.lexer(content);
</script>
<div class="document-markdown prose prose-sm dark:prose-invert min-w-0 max-w-none p-3">
<MarkdownTokens {id} {tokens} editCodeBlock={false} />
</div>
<style>
.document-markdown {
font-size: 0.75rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.document-markdown :global(h1),
.document-markdown :global(h2),
.document-markdown :global(h3),
.document-markdown :global(h4),
.document-markdown :global(h5),
.document-markdown :global(h6) {
margin: 1rem 0 0.375rem;
font-weight: 600;
line-height: 1.3;
}
.document-markdown :global(h1) {
font-size: 1.125rem;
}
.document-markdown :global(h2) {
font-size: 1rem;
}
.document-markdown :global(h3) {
font-size: 0.875rem;
}
.document-markdown :global(h4),
.document-markdown :global(h5),
.document-markdown :global(h6) {
font-size: 0.75rem;
}
.document-markdown :global(p),
.document-markdown :global(ul),
.document-markdown :global(ol) {
margin: 0.5rem 0;
}
.document-markdown :global(li) {
margin: 0.125rem 0;
}
.document-markdown :global(blockquote) {
margin: 0.75rem 0;
font-style: normal;
font-weight: 400;
}
.document-markdown :global(strong) {
font-weight: 600;
}
.document-markdown :global(hr) {
margin: 0.75rem 0;
}
.document-markdown :global(img) {
margin: 0.5rem 0;
}
.document-markdown :global(table) {
font-size: 0.75rem;
margin: 0.5rem 0;
}
.document-markdown :global(th),
.document-markdown :global(td) {
padding: 0.375rem 0.5rem !important;
}
.document-markdown :global(pre) {
margin: 0.5rem 0;
font-family: var(--font-mono);
}
.document-markdown :global(pre.hljs) {
padding: 0.5rem 0.75rem;
}
.document-markdown :global(pre code) {
font-size: 0.75rem;
}
.document-markdown :global(.codespan) {
font-size: 0.95em;
}
.document-markdown :global(input[type='checkbox']) {
cursor: default;
}
.document-markdown :global(.document-code-block) {
border-radius: 0.5rem;
}
.document-markdown :global(.document-code-block > .sticky) {
padding: 0.25rem 0.5rem;
}
.document-markdown :global(.document-code-block > .sticky button) {
height: 1.5rem;
padding-inline: 0.375rem;
font-size: 0.6875rem;
}
.document-markdown > :global(:first-child) {
margin-top: 0;
}
.document-markdown > :global(:last-child) {
margin-bottom: 0;
}
</style>

View file

@ -30,7 +30,9 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown');
const markdown = getContext<{ allowRemoteContent: boolean; document?: boolean } | undefined>(
'markdown'
);
export let id = '';
export let edit = true;
@ -370,7 +372,10 @@
const render = async () => {
onUpdate(token, id);
if (markdown?.allowRemoteContent === false) return;
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
if (
lang === 'mermaid' &&
(markdown?.document || (token?.raw ?? '').slice(-4).includes('```'))
) {
try {
renderHTML = await renderMermaid(code);
} catch (error) {
@ -381,7 +386,7 @@
}
} else if (
(lang === 'vega' || lang === 'vega-lite') &&
(token?.raw ?? '').slice(-4).includes('```')
(markdown?.document || (token?.raw ?? '').slice(-4).includes('```'))
) {
try {
renderHTML = await renderVegaVisualization(code, lang);

View file

@ -43,7 +43,10 @@
}
};
export function alertComponent(token: Token): AlertData | false {
export function alertComponent(
token: Token,
lexer: (source: string) => Token[] = marked.lexer
): AlertData | false {
const regExpStr = `^(?:\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\])\\s*?\n*`;
const regExp = new RegExp(regExpStr);
const matches = token.text?.match(regExp);
@ -51,7 +54,7 @@
if (matches && matches.length) {
const alertType = matches[1] as AlertType;
const newText = token.text.replace(regExp, '');
const newTokens = marked.lexer(newText);
const newTokens = lexer(newText);
return {
type: alertType,
text: newText,

View file

@ -7,6 +7,7 @@
import { settings } from '$lib/stores';
const i18n: any = getContext('i18n');
const markdown = getContext<{ document?: boolean } | undefined>('markdown');
export let id: string;
export let token: Token;
@ -18,7 +19,9 @@
</script>
{#if token.type === 'html'}
{#if html && html.includes('<video')}
{#if markdown?.document}
{@html html ?? ''}
{:else if html && html.includes('<video')}
{@const video = html.match(/<video[^>]*>([\s\S]*?)<\/video>/)}
{@const videoSrc = video && video[1]}
{#if videoSrc}

View file

@ -7,7 +7,10 @@
import { goto } from '$app/navigation';
const i18n = getContext('i18n');
const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown');
const markdown = getContext<
| { allowRemoteContent: boolean; document?: boolean; parseInline?: (tokens: Token[]) => string }
| undefined
>('markdown');
import { WEBUI_BASE_URL } from '$lib/constants';
import { skills, terminalSkills } from '$lib/stores';
@ -82,16 +85,24 @@
// Invalid URL, let browser handle it
}
};
$: inlineHtml =
markdown?.document && tokens.some((token) => token.type === 'html')
? DOMPurify.sanitize(markdown.parseInline?.(tokens) ?? '')
: null;
</script>
{#each tokens as token, tokenIdx (tokenIdx)}
<!-- Keep paired inline HTML tags together; sanitizing individual tags loses their contents. -->
{#if inlineHtml !== null}
{@html inlineHtml}
{/if}
{#each inlineHtml === null ? tokens : [] as token, tokenIdx (tokenIdx)}
{#if token.type === 'escape'}
{unescapeHtml(token.text)}
{:else if token.type === 'html'}
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'link'}
{@const linkToken = getLinkToken(token)}
{#if linkToken.noteId && markdown?.allowRemoteContent !== false}
{#if linkToken.noteId && !markdown?.document && markdown?.allowRemoteContent !== false}
<NoteLinkToken noteId={linkToken.noteId} href={linkToken.href} />
{:else if token.tokens && !linkToken.isAutolink}
<a
@ -162,6 +173,10 @@
<TextToken {token} {done} />
{/if}
{:else if token.type === 'text'}
<TextToken {token} {done} />
{#if markdown?.document}
{unescapeHtml(token.text)}
{:else}
<TextToken {token} {done} />
{/if}
{/if}
{/each}

View file

@ -2,6 +2,9 @@
import { decode } from 'html-entities';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
const markdown = getContext<
{ document?: boolean; lexer?: (source: string) => Token[] } | undefined
>('markdown');
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
@ -184,17 +187,19 @@
/>
</svelte:element>
{:else if token.type === 'code'}
{#if token.codeBlockStyle !== 'indented'}
{#if markdown?.document || token.codeBlockStyle !== 'indented'}
<CodeBlock
id={`${id}-${tokenIdx}`}
collapsed={$settings?.collapseCodeBlocks ?? false}
className={markdown?.document ? 'document-code-block' : ''}
collapsed={markdown?.document ? false : ($settings?.collapseCodeBlocks ?? false)}
{token}
lang={token?.lang ?? ''}
code={token?.text ?? ''}
{attributes}
{save}
{preview}
edit={editCodeBlock}
save={save && !markdown?.document}
preview={preview && !markdown?.document}
run={!markdown?.document}
edit={editCodeBlock && !markdown?.document}
stickyButtonsClassName={topPadding ? 'top-10' : 'top-0'}
onSave={(value) => {
onSave({
@ -295,7 +300,7 @@
</div>
</div>
{:else if token.type === 'blockquote'}
{@const alert = alertComponent(token)}
{@const alert = alertComponent(token, markdown?.lexer)}
{#if alert}
<AlertRenderer {token} {alert} {allowEmbeds} />
{:else}
@ -327,6 +332,7 @@
<input
class=" translate-y-[1px] -translate-x-1 flex-shrink-0"
type="checkbox"
disabled={markdown?.document ?? false}
checked={item.checked}
on:change={(e) => {
onTaskClick({
@ -368,6 +374,7 @@
<input
class="flex-shrink-0"
type="checkbox"
disabled={markdown?.document ?? false}
checked={item.checked}
on:change={(e) => {
onTaskClick({