refactor: unify markdown parsing logic

This commit is contained in:
Shirasawa 2026-04-15 02:55:30 +08:00
parent f102060a6d
commit 77edbd527d
32 changed files with 253 additions and 216 deletions

View file

@ -1,9 +1,9 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { createEventDispatcher, onMount } from 'svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -118,7 +118,7 @@
{/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
{@html DOMPurify.sanitize(marked(DOMPurify.sanitize(content, { ALLOWED_TAGS: [] })))}
{@html renderMarkdownToHTMLSanitized(DOMPurify.sanitize(content, { ALLOWED_TAGS: [] }))}
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
@ -607,13 +607,13 @@
class=" flex-1 self-center {(model?.is_active ?? true) ? '' : 'text-gray-500'}"
>
<Tooltip
content={marked.parse(
content={(renderMarkdownToHTML(
!!model?.meta?.description
? model?.meta?.description
: model?.ollama?.digest
? `${model?.ollama?.digest} **(${model?.ollama?.modified_at})**`
: model.id
)}
) as string)}
className=" w-fit"
placement="top-start"
>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
import { onMount, getContext } from 'svelte';
@ -46,12 +45,10 @@
}}
>
<Tooltip
content={DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)
content={renderMarkdownToHTMLSanitized(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="right"
>
@ -99,12 +96,10 @@
<div
class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3 markdown"
>
{@html DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
)
{@html renderMarkdownToHTMLSanitized(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description
).replaceAll('\n', '<br>')
)}
</div>
{#if models[selectedModelIdx]?.info?.meta?.user}

View file

@ -1,8 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { settings } from '$lib/stores';
import { isCodeFile } from '$lib/utils/codeHighlight';
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
@ -112,7 +112,7 @@
: null;
$: renderedHtml =
isMarkdown && fileContent
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
? renderMarkdownToHTMLSanitized(fileContent)
: '';
let markdownEl: HTMLDivElement;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { highlightCode } from '$lib/utils/codeHighlight';
import {
createNotebookSession,
@ -73,8 +73,7 @@
}
// ── Markdown / output helpers ────────────────────────────────────────
const renderMarkdown = (src: string): string =>
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
const renderMarkdown = (src: string): string => renderMarkdownToHTMLSanitized(src);
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');

View file

@ -1,8 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { v4 as uuidv4 } from 'uuid';
import dayjs from '$lib/dayjs';
import duration from 'dayjs/plugin/duration';
@ -2068,7 +2067,7 @@
{#if $config?.license_metadata?.input_footer}
<div class=" text-xs text-gray-500 text-center line-clamp-1 marked">
{@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
{@html renderMarkdownToHTMLSanitized($config?.license_metadata?.input_footer ?? '')}
</div>
{:else}
<div class="mb-1" />

View file

@ -1,18 +1,10 @@
<script>
import { onDestroy } from 'svelte';
import { marked } from 'marked';
import { replaceTokens, processResponseContent } from '$lib/utils';
import { lexer } from '$lib/utils/marked';
import { user } from '$lib/stores';
import markedExtension from '$lib/utils/marked/extension';
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { disableSingleTilde } from '$lib/utils/marked/strikethrough-extension';
import { mentionExtension } from '$lib/utils/marked/mention-extension';
import colonFenceExtension from '$lib/utils/marked/colon-fence-extension';
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
import footnoteExtension from '$lib/utils/marked/footnote-extension';
import citationExtension from '$lib/utils/marked/citation-extension';
export let id = '';
export let content;
@ -40,25 +32,6 @@
let lastContent = '';
let lastParsedContent = '';
const options = {
throwOnError: false,
breaks: true
};
marked.use(markedKatexExtension(options));
marked.use(markedExtension(options));
marked.use(citationExtension(options));
marked.use(footnoteExtension(options));
marked.use(colonFenceExtension(options));
marked.use(disableSingleTilde);
marked.use({
extensions: [
mentionExtension({ triggerChar: '@' }),
mentionExtension({ triggerChar: '#' }),
mentionExtension({ triggerChar: '$' })
]
});
const parseTokens = () => {
if (content === lastContent) return;
lastContent = content;
@ -67,7 +40,7 @@
if (processed === lastParsedContent) return;
lastParsedContent = processed;
tokens = marked.lexer(processed);
tokens = lexer(processed, { breaks: true });
};
const updateHandler = (content) => {
@ -87,8 +60,7 @@
$: updateHandler(content);
// Throttle parsing to once per animation frame while streaming
$: onDestroy(() => {
onDestroy(() => {
cancelAnimationFrame(pendingUpdate);
});
</script>

View file

@ -1,5 +1,5 @@
<script lang="ts" context="module">
import { marked, type Token } from 'marked';
import { lexer, type Token } from '$lib/utils/marked';
type AlertType = 'NOTE' | 'TIP' | 'IMPORTANT' | 'WARNING' | 'CAUTION';
@ -51,7 +51,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, { breaks: true });
return {
type: alertType,
text: newText,

View file

@ -1,6 +1,6 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import type { Token } from 'marked';
import type { Token } from '$lib/utils/marked';
import { WEBUI_BASE_URL } from '$lib/constants';
import { settings } from '$lib/stores';

View file

@ -2,7 +2,7 @@
import DOMPurify from 'dompurify';
import { toast } from 'svelte-sonner';
import type { Token } from 'marked';
import type { Token } from '$lib/utils/marked';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Token } from 'marked';
import type { Token } from '$lib/utils/marked';
import { LinkPreview } from 'bits-ui';
import { getContext } from 'svelte';

View file

@ -6,7 +6,7 @@
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { marked, type Token } from 'marked';
import { lexer, type Token } from '$lib/utils/marked';
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { WEBUI_BASE_URL } from '$lib/constants';
@ -397,7 +397,7 @@
<div class="mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-${detailIdx}-d`}
tokens={marked.lexer(decode(detailToken.text))}
tokens={lexer(decode(detailToken.text), { breaks: true })}
attributes={detailToken?.attributes}
{done}
{editCodeBlock}
@ -445,7 +445,7 @@
<div class=" mb-1.5" slot="content">
<svelte:self
id={`${id}-${tokenIdx}-d`}
tokens={marked.lexer(decode(token.text))}
tokens={lexer(decode(token.text), { breaks: true })}
attributes={token?.attributes}
{done}
{editCodeBlock}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import { getContext, tick } from 'svelte';
import dayjs from '$lib/dayjs';
@ -206,9 +206,9 @@
{#if item.model?.info?.meta?.description}
<Tooltip
content={`${marked.parse(
content={`${renderMarkdownToHTML(
sanitizeResponseContent(item.model?.info?.meta?.description).replaceAll('\n', '<br>')
)}`}
) as string}`}
>
<div class=" translate-y-[1px]">
<svg

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { marked } from 'marked';
import Fuse from 'fuse.js';
import dayjs from '$lib/dayjs';

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { onMount, getContext, tick, createEventDispatcher } from 'svelte';
import { blur, fade } from 'svelte/transition';
@ -165,24 +164,20 @@
{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
<Tooltip
className=" w-fit"
content={DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)
content={renderMarkdownToHTMLSanitized(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
placement="top"
>
<div
class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
>
{@html DOMPurify.sanitize(
marked.parse(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)
{@html renderMarkdownToHTMLSanitized(
sanitizeResponseContent(
models[selectedModelIdx]?.info?.meta?.description ?? ''
).replaceAll('\n', '<br>')
)}
</div>
</Tooltip>

View file

@ -2,8 +2,7 @@
import type { Banner } from '$lib/types';
import { onMount, createEventDispatcher, getContext } from 'svelte';
import { fade } from 'svelte/transition';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { WEBUI_BASE_URL } from '$lib/constants';
const dispatch = createEventDispatcher();
@ -100,7 +99,7 @@
{/if}
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html DOMPurify.sanitize(marked.parse((banner?.content ?? '').replace(/\n/g, '<br>')))}
{@html renderMarkdownToHTMLSanitized((banner?.content ?? '').replace(/\n/g, '<br>'))}
</div>
</div>

View file

@ -1,6 +1,4 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { onMount, getContext, createEventDispatcher, onDestroy, tick } from 'svelte';
import * as FocusTrap from 'focus-trap';
@ -9,7 +7,7 @@
import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import SensitiveInput from './SensitiveInput.svelte';
export let title = '';
@ -127,7 +125,7 @@
<slot>
<div class=" text-sm text-gray-500 flex-1">
{#if message !== ''}
{@const html = DOMPurify.sanitize(marked.parse(message))}
{@const html = renderMarkdownToHTMLSanitized(message)}
{@html html}
{:else}
{$i18n.t('This action cannot be undone. Do you wish to continue?')}

View file

@ -1,33 +1,7 @@
<script lang="ts">
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import DOMPurify from 'dompurify';
marked.use({
breaks: true,
gfm: true,
renderer: {
list(body, ordered, start) {
const isTaskList = body.includes('data-checked=');
if (isTaskList) {
return `<ul data-type="taskList">${body}</ul>`;
}
const type = ordered ? 'ol' : 'ul';
const startatt = ordered && start !== 1 ? ` start="${start}"` : '';
return `<${type}${startatt}>${body}</${type}>`;
},
listitem(text, task, checked) {
if (task) {
const checkedAttr = checked ? 'true' : 'false';
return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
}
return `<li>${text}</li>`;
}
}
});
import TurndownService from 'turndown';
import { gfm } from '@joplin/turndown-plugin-gfm';
const turndownService = new TurndownService({
@ -357,12 +331,7 @@
if (insertPromptAsRichText) {
const htmlContent = DOMPurify.sanitize(
marked
.parse(text, {
breaks: true,
gfm: true
})
.trim()
renderMarkdownToHTML(text, { breaks: true, gfm: true }).trim()
);
// Create a temporary div to parse HTML
@ -480,7 +449,7 @@
const { schema, tr } = state;
// If content is a string, convert it to a ProseMirror node
const htmlContent = marked.parse(content);
const htmlContent = renderMarkdownToHTML(content);
// insert the HTML content at the current selection
editor.commands.insertContent(htmlContent);
@ -696,7 +665,7 @@
async function tryParse(value, attempts = 3, interval = 100) {
try {
// Try parsing the value
return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
return renderMarkdownToHTML(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false
});
} catch (error) {
@ -1261,7 +1230,7 @@
editor.commands.setContent(
preserveBreaks
? value
: marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
: renderMarkdownToHTML(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false
})
);

View file

@ -1,6 +1,5 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { getAdminDetails } from '$lib/apis/auths';
import { onMount, tick, getContext } from 'svelte';
@ -41,8 +40,8 @@
style="white-space: pre-wrap;"
>
{#if ($config?.ui?.pending_user_overlay_content ?? '').trim() !== ''}
{@html DOMPurify.sanitize(
marked.parse(($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>'))
{@html renderMarkdownToHTMLSanitized(
($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>')
)}
{:else}
{$i18n.t('Your account status is currently pending activation.')}{'\n'}{$i18n.t(

View file

@ -6,7 +6,7 @@
const i18n = getContext('i18n');
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@ -163,7 +163,7 @@
// Computed HTML for editor: fall back to markdown if HTML is missing
$: editorHtml =
note?.data?.content?.html ||
(note?.data?.content?.md ? marked.parse(note.data.content.md) : '');
(note?.data?.content?.md ? renderMarkdownToHTML(note.data.content.md) : '');
const init = async () => {
loading = true;
@ -707,7 +707,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
const choice = data.choices[0];
if (choice.delta && choice.delta.content) {
enhancedContent.md += choice.delta.content;
enhancedContent.html = marked.parse(enhancedContent.md);
enhancedContent.html = renderMarkdownToHTML(enhancedContent.md);
note.data.content.md = enhancedContent.md;
note.data.content.html = enhancedContent.html;

View file

@ -2,33 +2,7 @@
export let show = false;
export let selectedModelId = '';
import { marked } from 'marked';
// Configure marked with extensions
marked.use({
breaks: true,
gfm: true,
renderer: {
list(body, ordered, start) {
const isTaskList = body.includes('data-checked=');
if (isTaskList) {
return `<ul data-type="taskList">${body}</ul>`;
}
const type = ordered ? 'ol' : 'ul';
const startatt = ordered && start !== 1 ? ` start="${start}"` : '';
return `<${type}${startatt}>${body}</${type}>`;
},
listitem(text, task, checked) {
if (task) {
const checkedAttr = checked ? 'true' : 'false';
return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
}
return `<li>${text}</li>`;
}
}
});
import { renderMarkdownToHTML } from '$lib/utils/marked';
import { toast } from 'svelte-sonner';
@ -265,7 +239,7 @@ Based on the user's instruction, update and enhance the existing notes or select
streaming = true;
enhancedContent.md += deltaContent;
enhancedContent.html = marked.parse(enhancedContent.md);
enhancedContent.html = renderMarkdownToHTML(enhancedContent.md);
if (!selectedContent || !selectedContent?.text) {
note.data.content.md = enhancedContent.md;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
@ -138,7 +138,7 @@
data: {
content: {
json: null,
html: marked.parse(content ?? ''),
html: renderMarkdownToHTML(content ?? ''),
md: content
}
},

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import { toast } from 'svelte-sonner';
import Sortable from 'sortablejs';
@ -757,7 +757,7 @@
<div>·</div>
<Tooltip
content={marked.parse(model?.meta?.description ?? model.id)}
content={renderMarkdownToHTML(model?.meta?.description ?? model.id)}
className=" w-fit text-left"
placement="top-start"
>

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
const i18n = getContext('i18n');
@ -85,7 +85,7 @@
/>
<div class="py-0.5 text-sm">
<Tooltip content={marked.parse(toolLabels[tool].description)}>
<Tooltip content={renderMarkdownToHTML(toolLabels[tool].description)}>
{$i18n.t(toolLabels[tool].label)}
</Tooltip>
</div>

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
const i18n = getContext('i18n');
@ -90,7 +90,7 @@
/>
<div class=" py-0.5 text-sm capitalize">
<Tooltip content={marked.parse(capabilityLabels[capability].description)}>
<Tooltip content={renderMarkdownToHTML(capabilityLabels[capability].description)}>
{$i18n.t(capabilityLabels[capability].label)}
</Tooltip>
</div>

View file

@ -2,7 +2,7 @@
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { marked } from 'marked';
import { renderMarkdownToHTML } from '$lib/utils/marked';
const i18n = getContext('i18n');
@ -44,7 +44,7 @@
/>
<div class=" py-0.5 text-sm capitalize">
<Tooltip content={marked.parse(featureLabels[feature].description)}>
<Tooltip content={renderMarkdownToHTML(featureLabels[feature].description)}>
{$i18n.t(featureLabels[feature].label)}
</Tooltip>
</div>

View file

@ -18,9 +18,7 @@ import { TTS_RESPONSE_SPLIT } from '$lib/types';
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { marked } from 'marked';
import markedExtension from '$lib/utils/marked/extension';
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import { renderMarkdownToHTML } from '$lib/utils/marked';
import hljs from 'highlight.js';
import { decode } from 'html-entities';
@ -423,18 +421,12 @@ export const copyToClipboard = async (text, html = null, formatted = false) => {
if (formatted) {
let styledHtml = '';
if (!html) {
const options = {
throwOnError: false,
highlight: function (code, lang) {
const htmlContent = renderMarkdownToHTML(text, {
highlight(code: string, lang: string) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
};
marked.use(markedKatexExtension(options));
marked.use(markedExtension(options));
// DEVELOPER NOTE: Go to `$lib/components/chat/Messages/Markdown.svelte` to add extra markdown extensions for rendering.
const htmlContent = marked.parse(text);
}) as string;
// Add basic styling to make the content look better when pasted
styledHtml = `

View file

@ -0,0 +1,32 @@
/**
* Centralized Markdown utilities: re-exports parse API from `./marked` and adds DOMPurify sanitize.
*
* Worker-friendly imports: use `$lib/utils/marked/marked` (lexer / renderMarkdownToHTML only).
*/
import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify';
import { renderMarkdownToHTML, type MarkedParseOptions } from './marked';
export type { Token, TokensList, MarkedParseOptions } from './marked';
export { lexer, renderMarkdownToHTML } from './marked';
export interface RenderMarkdownToHTMLSanitizedOptions {
markedOptions?: MarkedParseOptions | null;
sanitizeOptions?: DOMPurifyConfig | null;
}
/**
* Render Markdown to HTML and sanitize with DOMPurify. Use when rendering arbitrary markdown into the DOM
* (banners, footers, toasts, confirm dialogs, etc.) so callers do not need to call DOMPurify separately.
*
* @param markdown - Raw markdown text
* @param options - Optional markdown parse and sanitize options.
* @returns Sanitized HTML string (sync only; for async use renderMarkdownToHTML() then DOMPurify.sanitize yourself)
*/
export function renderMarkdownToHTMLSanitized(
markdown: string,
options?: RenderMarkdownToHTMLSanitizedOptions | null
): string {
const html = renderMarkdownToHTML(markdown, options?.markedOptions);
return DOMPurify.sanitize(html, options?.sanitizeOptions ?? undefined);
}

View file

@ -0,0 +1,113 @@
/**
* Markdown parsing (Marked instance, lexer, HTML render). No DOMPurify safe for Web Worker bundles
* that import only from this module.
*
* UI code may import from `$lib/utils/marked` (index) for `renderMarkdownToHTMLSanitized`.
*
* Do not import "marked" elsewhere or call marked.use().
*/
import { Marked, type TokensList, type MarkedOptions } from 'marked';
import markedExtension from './extension';
import markedKatexExtension from './katex-extension';
import { disableSingleTildeExtension } from './strikethrough-extension';
import { mentionExtension } from './mention-extension';
import footnoteExtensionDefault from './footnote-extension';
import citationExtensionDefault from './citation-extension';
import colonFenceExtension from './colon-fence-extension';
/** Shared base options (no callbacks that close over component context). */
const baseOptions = {
throwOnError: false,
breaks: false,
gfm: true
};
/** Task list renderer: outputs data-type="taskList" / "taskItem" for the UI; no component context. */
const taskListRenderer = {
list(body: string, ordered: boolean, start: number | ''): string {
const isTaskList = body.includes('data-checked=');
if (isTaskList) {
return `<ul data-type="taskList">${body}</ul>`;
}
const type = ordered ? 'ol' : 'ul';
const startAttr = ordered && start !== 1 ? ` start="${start}"` : '';
return `<${type}${startAttr}>${body}</${type}>`;
},
listitem(text: string, task: boolean, checked: boolean): string {
if (task) {
const checkedAttr = checked ? 'true' : 'false';
return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
}
return `<li>${text}</li>`;
}
};
/**
* Single marked instance used by the app. Configured once at load; holds no Svelte context.
*/
const markedInstance = new Marked(
markedKatexExtension(baseOptions),
markedExtension(baseOptions),
citationExtensionDefault(),
footnoteExtensionDefault(),
colonFenceExtension(baseOptions),
disableSingleTildeExtension(),
{
extensions: [
mentionExtension({ triggerChar: '@' }),
mentionExtension({ triggerChar: '#' }),
mentionExtension({ triggerChar: '$' })
]
},
{
...baseOptions,
renderer: taskListRenderer
}
);
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type { Token, TokensList } from 'marked';
/**
* Options for renderMarkdownToHTML(). Only pass options that do not hold component references.
*/
export interface MarkedParseOptions {
/** Sync mode only for now; async parsing is intentionally unsupported. */
async?: false | undefined;
/** Optional highlighter for code blocks; must not close over component/store. */
highlight?: (code: string, lang: string) => string;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
/**
* Lex Markdown into a token list (no HTML rendering). Use for token-based rendering (e.g. chat messages).
*
* @param markdown - Raw markdown (can be preprocessed with replaceTokens / processResponseContent)
* @param options - Optional; usually omitted
* @returns Token list (with links etc.)
*/
export function lexer(markdown: string, options?: MarkedOptions | null): TokensList {
return markedInstance.lexer(markdown, options ?? undefined);
}
/**
* Render Markdown to HTML.
*
* @param markdown - Raw markdown text
* @param options - Optional sync render options (e.g. highlight). Do not pass renderer/walkTokens etc.
* @returns HTML string
*/
export function renderMarkdownToHTML(
markdown: string,
options?: MarkedParseOptions | null
): string {
return markedInstance.parse(markdown, (options ?? undefined) as MarkedOptions) as string;
}

View file

@ -1,29 +1,34 @@
export const disableSingleTilde = {
tokenizer: {
del(src) {
// 1. First check for the REAL strikethrough: ~~text~~
const doubleMatch = /^~~(?=\S)([\s\S]*?\S)~~/.exec(src);
if (doubleMatch) {
return {
type: 'del',
raw: doubleMatch[0],
text: doubleMatch[1],
tokens: this.lexer.inlineTokens(doubleMatch[1])
};
}
import type { MarkedExtension, Tokens } from 'marked';
// 2. Check for single-tilde: ~text~
const singleMatch = /^~(?=\S)([\s\S]*?\S)~/.exec(src);
if (singleMatch) {
// return a plain-text token, NOT del
return {
type: 'text',
raw: singleMatch[0],
text: singleMatch[0] // include both tildes as literal text
};
}
/**
* Replaces the default `del` tokenizer so that only ~~text~~ is strikethrough.
* Single tilde ~text~ is not matched as del (returns false), so it is rendered as plain text.
*/
export function disableSingleTildeExtension() {
const extension: MarkedExtension = {
tokenizer: {
del(this: { lexer: { inlineTokens: (src: string) => Tokens.Generic[] } }, src: string) {
// 1. Real strikethrough: ~~text~~
const doubleMatch = /^~~(?=\S)([\s\S]*?\S)~~/.exec(src);
if (doubleMatch) {
return {
type: 'del',
raw: doubleMatch[0],
text: doubleMatch[1],
tokens: this.lexer.inlineTokens(doubleMatch[1])
};
}
return false;
// 2. Single tilde ~text~: do not match as del, so it stays plain text
const singleMatch = /^~(?=\S)([\s\S]*?\S)~/.exec(src);
if (singleMatch) {
return false;
}
return false;
}
}
}
};
};
return extension;
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { renderMarkdownToHTMLSanitized } from '$lib/utils/marked';
import { toast } from 'svelte-sonner';
@ -577,7 +576,7 @@
{#if $config?.metadata?.login_footer}
<div class="max-w-3xl mx-auto">
<div class="mt-2 text-[0.7rem] text-gray-500 dark:text-gray-400 marked">
{@html DOMPurify.sanitize(marked($config?.metadata?.login_footer))}
{@html renderMarkdownToHTMLSanitized($config?.metadata?.login_footer ?? '')}
</div>
</div>
{/if}