fix(ui): escape raw HTML in user prompt messages (#260)

## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
This commit is contained in:
codenomadbot[bot]
2026-03-30 08:48:52 +01:00
committed by GitHub
parent 37b3f85e61
commit d1a27ac31b
3 changed files with 24 additions and 5 deletions

View File

@@ -83,6 +83,7 @@ interface MarkdownProps {
isDark?: boolean isDark?: boolean
size?: "base" | "sm" | "tight" size?: "base" | "sm" | "tight"
disableHighlight?: boolean disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void onRendered?: () => void
} }
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text) const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text) const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey } return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
}) })
const cacheHandle = useGlobalCache({ const cacheHandle = useGlobalCache({
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text, text: snapshot.text,
html: renderedHtml, html: renderedHtml,
theme: snapshot.themeKey, theme: snapshot.themeKey,
mode: snapshot.version, mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
} }
setHtml(renderedHtml) setHtml(renderedHtml)
cacheHandle.set(cacheEntry) cacheHandle.set(cacheEntry)
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark") markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, { const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled, suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
}) })
if (latestRequestKey === snapshot.requestKey) { if (latestRequestKey === snapshot.requestKey) {
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => { createEffect(() => {
const snapshot = resolved() const snapshot = resolved()
latestRequestKey = snapshot.requestKey latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version return cache.theme === snapshot.themeKey && cache.mode === cacheMode
} }
const localCache = snapshot.part.renderCache const localCache = snapshot.part.renderCache

View File

@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isDark={isDark()} isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"} size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered} onRendered={props.onRendered}
/> />
</Show> </Show>

View File

@@ -11,6 +11,7 @@ let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light" let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let escapeRawHtmlEnabled = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
@@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) {
return `<code class="inline-code">${escapeHtml(decoded)}</code>` return `<code class="inline-code">${escapeHtml(decoded)}</code>`
} }
renderer.html = (html: string) => {
if (!escapeRawHtmlEnabled) {
return html
}
return escapeHtml(decodeHtmlEntities(html))
}
marked.use({ renderer }) marked.use({ renderer })
rendererSetup = true rendererSetup = true
} }
@@ -308,6 +317,7 @@ export async function renderMarkdown(
content: string, content: string,
options?: { options?: {
suppressHighlight?: boolean suppressHighlight?: boolean
escapeRawHtml?: boolean
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
@@ -316,6 +326,7 @@ export async function renderMarkdown(
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const escapeRawHtml = options?.escapeRawHtml ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
@@ -324,13 +335,16 @@ export async function renderMarkdown(
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
const previousEscapeRawHtml = escapeRawHtmlEnabled
highlightSuppressed = suppressHighlight highlightSuppressed = suppressHighlight
escapeRawHtmlEnabled = escapeRawHtml
try { try {
// Proceed to parse immediately - highlighting will be available on next render // Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string> return marked.parse(decoded) as Promise<string>
} finally { } finally {
highlightSuppressed = previousSuppressed highlightSuppressed = previousSuppressed
escapeRawHtmlEnabled = previousEscapeRawHtml
} }
} }