mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-12 23:02:35 +00:00
refactor: unify markdown parsing logic
This commit is contained in:
parent
f102060a6d
commit
77edbd527d
32 changed files with 253 additions and 216 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import dayjs from '$lib/dayjs';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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?')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
|
|
|
|||
32
src/lib/utils/marked/index.ts
Normal file
32
src/lib/utils/marked/index.ts
Normal 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);
|
||||
}
|
||||
113
src/lib/utils/marked/marked.ts
Normal file
113
src/lib/utils/marked/marked.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue