Files
InvoiceShelf/resources/scripts/utils/markdown.ts
Darko Gjorgjijoski 02704a4b20 feat(ai): render assistant chat messages as sanitized markdown
The AI chat drawer was rendering assistant responses as plain text,
so code blocks, lists, tables and inline formatting came through as
literal asterisks and backticks — noisy and hard to scan.

Adds a shared renderMarkdown() helper in resources/scripts/utils/
markdown.ts that parses GFM markdown via marked and sanitizes the
result with DOMPurify before handing it to Vue's v-html. AiChatMessage
uses the helper for assistant messages only; user messages stay as
plain text since markdown syntax in their own typed input would be
surprising.

Assistant bubbles get the Tailwind `prose prose-sm` classes from the
already-enabled @tailwindcss/typography plugin so headings, lists and
code blocks inherit sensible defaults without per-element styling.

Security: DOMPurify runs in its default browser profile, which strips
<script>, event handlers, javascript: URLs and every other XSS vector.
The AI provider isn't a trusted source — it can echo arbitrary user
input and tool-call results from the database — so sanitization is
non-negotiable even though the immediate source is our own backend.
2026-04-11 20:58:41 +02:00

43 lines
1.6 KiB
TypeScript

import { marked } from 'marked'
import DOMPurify from 'dompurify'
/**
* Render a markdown string to safe, sanitized HTML.
*
* Used by the AI chat drawer to render assistant responses. Even though
* the AI provider controls the immediate source of the content, the model
* can echo anything it's fed — including user input from earlier in the
* conversation or tool results from the database. We therefore parse
* markdown → HTML via marked and then sanitize the result with DOMPurify
* before handing it to Vue's v-html.
*
* Marked is configured with:
* - gfm: true — GitHub-flavored markdown (tables, fenced code,
* strikethrough, task lists). Matches what users
* already expect from any modern chat UI.
* - breaks: true — newlines become <br> so a single user-typed line
* break renders as a visual break without needing
* two trailing spaces.
* - async: false — force synchronous parsing so the caller doesn't
* have to await; marked defaults to returning a
* Promise when extensions are registered.
*
* DOMPurify is run in its default browser profile which strips <script>,
* event handlers, javascript: URLs, and every other HTML vector. We do
* NOT customize ALLOWED_TAGS because marked's output is already a
* conservative subset of HTML.
*/
export function renderMarkdown(source: string): string {
if (!source) {
return ''
}
const rawHtml = marked.parse(source, {
gfm: true,
breaks: true,
async: false,
}) as string
return DOMPurify.sanitize(rawHtml)
}