improve markdown rendering (#19)

This commit is contained in:
Aidan Bleser 2025-06-18 08:32:57 -05:00 committed by GitHub
parent 5b59ee04e7
commit f47b965c48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 98 additions and 30 deletions

View file

@ -41,6 +41,7 @@
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"isomorphic-dompurify": "^2.25.0",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"melt": "https://pkg.vc/-/@melt-ui/melt@42e572f", "melt": "https://pkg.vc/-/@melt-ui/melt@42e572f",
"mode-watcher": "^1.0.8", "mode-watcher": "^1.0.8",

30
pnpm-lock.yaml generated
View file

@ -123,6 +123,9 @@ importers:
globals: globals:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.2.0 version: 16.2.0
isomorphic-dompurify:
specifier: ^2.25.0
version: 2.25.0
jsdom: jsdom:
specifier: ^26.0.0 specifier: ^26.0.0
version: 26.1.0 version: 26.1.0
@ -1065,6 +1068,9 @@ packages:
'@types/node@24.0.1': '@types/node@24.0.1':
resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==} resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@3.0.3': '@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -1440,6 +1446,9 @@ packages:
dom-accessibility-api@0.6.3: dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dompurify@3.2.6:
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
dotenv@16.5.0: dotenv@16.5.0:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1714,6 +1723,10 @@ packages:
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-dompurify@2.25.0:
resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==}
engines: {node: '>=18'}
jest-axe@9.0.0: jest-axe@9.0.0:
resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==} resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==}
engines: {node: '>= 16.0.0'} engines: {node: '>= 16.0.0'}
@ -3477,6 +3490,9 @@ snapshots:
undici-types: 7.8.0 undici-types: 7.8.0
optional: true optional: true
'@types/trusted-types@2.0.7':
optional: true
'@types/unist@3.0.3': {} '@types/unist@3.0.3': {}
'@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': '@typescript-eslint/eslint-plugin@8.34.0(@typescript-eslint/parser@8.34.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)':
@ -3847,6 +3863,10 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
dompurify@3.2.6:
optionalDependencies:
'@types/trusted-types': 2.0.7
dotenv@16.5.0: {} dotenv@16.5.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@ -4172,6 +4192,16 @@ snapshots:
isexe@2.0.0: {} isexe@2.0.0: {}
isomorphic-dompurify@2.25.0:
dependencies:
dompurify: 3.2.6
jsdom: 26.1.0
transitivePeerDependencies:
- bufferutil
- canvas
- supports-color
- utf-8-validate
jest-axe@9.0.0: jest-axe@9.0.0:
dependencies: dependencies:
axe-core: 4.9.1 axe-core: 4.9.1

View file

@ -87,6 +87,7 @@ export const create = mutation({
export const createAndAddMessage = mutation({ export const createAndAddMessage = mutation({
args: { args: {
content: v.string(), content: v.string(),
content_html: v.optional(v.string()),
role: messageRoleValidator, role: messageRoleValidator,
session_token: v.string(), session_token: v.string(),
images: v.optional(v.array(v.object({ images: v.optional(v.array(v.object({
@ -120,6 +121,7 @@ export const createAndAddMessage = mutation({
const messageId = await ctx.runMutation(api.messages.create, { const messageId = await ctx.runMutation(api.messages.create, {
content: args.content, content: args.content,
content_html: args.content_html,
role: args.role, role: args.role,
conversation_id: conversationId, conversation_id: conversationId,
session_token: args.session_token, session_token: args.session_token,

View file

@ -33,6 +33,7 @@ export const create = mutation({
args: { args: {
conversation_id: v.string(), conversation_id: v.string(),
content: v.string(), content: v.string(),
content_html: v.optional(v.string()),
role: messageRoleValidator, role: messageRoleValidator,
session_token: v.string(), session_token: v.string(),
@ -73,6 +74,7 @@ export const create = mutation({
ctx.db.insert('messages', { ctx.db.insert('messages', {
conversation_id: args.conversation_id, conversation_id: args.conversation_id,
content: args.content, content: args.content,
content_html: args.content_html,
role: args.role, role: args.role,
// Optional, coming from SK API route // Optional, coming from SK API route
model_id: args.model_id, model_id: args.model_id,
@ -96,6 +98,7 @@ export const updateContent = mutation({
session_token: v.string(), session_token: v.string(),
message_id: v.string(), message_id: v.string(),
content: v.string(), content: v.string(),
content_html: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, { const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
@ -114,6 +117,7 @@ export const updateContent = mutation({
await ctx.db.patch(message._id, { await ctx.db.patch(message._id, {
content: args.content, content: args.content,
content_html: args.content_html,
}); });
}, },
}); });
@ -125,6 +129,7 @@ export const updateMessage = mutation({
token_count: v.optional(v.number()), token_count: v.optional(v.number()),
cost_usd: v.optional(v.number()), cost_usd: v.optional(v.number()),
generation_id: v.optional(v.string()), generation_id: v.optional(v.string()),
content_html: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const session = await ctx.runQuery(api.betterAuth.publicGetSession, { const session = await ctx.runQuery(api.betterAuth.publicGetSession, {
@ -145,6 +150,7 @@ export const updateMessage = mutation({
token_count: args.token_count, token_count: args.token_count,
cost_usd: args.cost_usd, cost_usd: args.cost_usd,
generation_id: args.generation_id, generation_id: args.generation_id,
content_html: args.content_html,
}); });
}, },
}); });

View file

@ -53,6 +53,7 @@ export default defineSchema({
conversation_id: v.string(), conversation_id: v.string(),
role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')), role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')),
content: v.string(), content: v.string(),
content_html: v.optional(v.string()),
// Optional, coming from SK API route // Optional, coming from SK API route
model_id: v.optional(v.string()), model_id: v.optional(v.string()),
provider: v.optional(providerValidator), provider: v.optional(providerValidator),

View file

@ -0,0 +1,25 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import MarkdownItAsync from 'markdown-it-async';
import { codeToHtml } from 'shiki';
import DOMPurify from 'isomorphic-dompurify';
const md = MarkdownItAsync();
md.use(
fromAsyncCodeToHtml(
// Pass the codeToHtml function
codeToHtml,
{
themes: {
light: 'github-light-default',
dark: 'github-dark-default',
},
}
)
);
function sanitizeHtml(html: string) {
return DOMPurify.sanitize(html);
}
export { md, sanitizeHtml }

View file

@ -1,22 +1,5 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import MarkdownItAsync from 'markdown-it-async';
import type { Getter } from 'runed'; import type { Getter } from 'runed';
import { codeToHtml } from 'shiki'; import { md } from './markdown-it';
const md = MarkdownItAsync();
md.use(
fromAsyncCodeToHtml(
// Pass the codeToHtml function
codeToHtml,
{
themes: {
light: 'github-light-default',
dark: 'github-dark-default',
},
}
)
);
export class Markdown { export class Markdown {
highlighted = $state<string | null>(null); highlighted = $state<string | null>(null);

View file

@ -10,6 +10,7 @@ import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai'; import OpenAI from 'openai';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js'; import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js';
// Set to true to enable debug logging // Set to true to enable debug logging
const ENABLE_LOGGING = true; const ENABLE_LOGGING = true;
@ -379,6 +380,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return; return;
} }
const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content),
(e) => `Failed to render HTML: ${e}`
);
const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), { const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), {
delay: 500, delay: 500,
retries: 2, retries: 2,
@ -398,6 +404,12 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
log('Background: Got generation stats', startTime); log('Background: Got generation stats', startTime);
const contentHtmlResult = await contentHtmlResultPromise;
if (contentHtmlResult.isErr()) {
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([ const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
ResultAsync.fromPromise( ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, { client.mutation(api.messages.updateMessage, {
@ -406,6 +418,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
cost_usd: generationStats.total_cost, cost_usd: generationStats.total_cost,
generation_id: generationId, generation_id: generationId,
session_token: sessionToken, session_token: sessionToken,
content_html: contentHtmlResult.unwrapOr(undefined),
}), }),
(e) => `Failed to update message: ${e}` (e) => `Failed to update message: ${e}`
), ),
@ -521,6 +534,7 @@ export const POST: RequestHandler = async ({ request }) => {
const convMessageResult = await ResultAsync.fromPromise( const convMessageResult = await ResultAsync.fromPromise(
client.mutation(api.conversations.createAndAddMessage, { client.mutation(api.conversations.createAndAddMessage, {
content: args.message, content: args.message,
content_html: '',
role: 'user', role: 'user',
images: args.images, images: args.images,
session_token: sessionToken, session_token: sessionToken,

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { sanitizeHtml } from '$lib/utils/markdown-it';
import { Markdown } from '$lib/utils/markdown.svelte'; import { Markdown } from '$lib/utils/markdown.svelte';
type Props = { type Props = {
@ -10,4 +11,4 @@
const markdown = new Markdown(() => content); const markdown = new Markdown(() => content);
</script> </script>
{@html markdown.current} {@html sanitizeHtml(markdown.current ?? '')}

View file

@ -6,6 +6,7 @@
import '../../../markdown.css'; import '../../../markdown.css';
import MarkdownRenderer from './markdown-renderer.svelte'; import MarkdownRenderer from './markdown-renderer.svelte';
import { ImageModal } from '$lib/components/ui/image-modal'; import { ImageModal } from '$lib/components/ui/image-modal';
import { sanitizeHtml } from '$lib/utils/markdown-it';
const style = tv({ const style = tv({
base: 'prose rounded-xl p-2', base: 'prose rounded-xl p-2',
@ -58,6 +59,9 @@
</div> </div>
{/if} {/if}
<div class={style({ role: message.role })}> <div class={style({ role: message.role })}>
{#if message.content_html}
{@html sanitizeHtml(message.content_html)}
{:else}
<svelte:boundary> <svelte:boundary>
<MarkdownRenderer content={message.content} /> <MarkdownRenderer content={message.content} />
@ -70,6 +74,7 @@
</div> </div>
{/snippet} {/snippet}
</svelte:boundary> </svelte:boundary>
{/if}
</div> </div>
<div <div
class={cn( class={cn(