diff --git a/package.json b/package.json index 35a9c56..34f6f59 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", + "isomorphic-dompurify": "^2.25.0", "jsdom": "^26.0.0", "melt": "https://pkg.vc/-/@melt-ui/melt@42e572f", "mode-watcher": "^1.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29a7344..df2caad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: globals: specifier: ^16.0.0 version: 16.2.0 + isomorphic-dompurify: + specifier: ^2.25.0 + version: 2.25.0 jsdom: specifier: ^26.0.0 version: 26.1.0 @@ -1065,6 +1068,9 @@ packages: '@types/node@24.0.1': resolution: {integrity: sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1440,6 +1446,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dompurify@3.2.6: + resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==} + dotenv@16.5.0: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} @@ -1714,6 +1723,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-dompurify@2.25.0: + resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==} + engines: {node: '>=18'} + jest-axe@9.0.0: resolution: {integrity: sha512-Xt7O0+wIpW31lv0SO1wQZUTyJE7DEmnDEZeTt9/S9L5WUywxrv8BrgvTuQEqujtfaQOcJ70p4wg7UUgK1E2F5g==} engines: {node: '>= 16.0.0'} @@ -3477,6 +3490,9 @@ snapshots: undici-types: 7.8.0 optional: true + '@types/trusted-types@2.0.7': + optional: true + '@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)': @@ -3847,6 +3863,10 @@ snapshots: dom-accessibility-api@0.6.3: {} + dompurify@3.2.6: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.5.0: {} emoji-regex@8.0.0: {} @@ -4172,6 +4192,16 @@ snapshots: 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: dependencies: axe-core: 4.9.1 diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index aaf2b78..8677472 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -87,6 +87,7 @@ export const create = mutation({ export const createAndAddMessage = mutation({ args: { content: v.string(), + content_html: v.optional(v.string()), role: messageRoleValidator, session_token: v.string(), images: v.optional(v.array(v.object({ @@ -120,6 +121,7 @@ export const createAndAddMessage = mutation({ const messageId = await ctx.runMutation(api.messages.create, { content: args.content, + content_html: args.content_html, role: args.role, conversation_id: conversationId, session_token: args.session_token, diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 3df1f1f..32b15a8 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -33,6 +33,7 @@ export const create = mutation({ args: { conversation_id: v.string(), content: v.string(), + content_html: v.optional(v.string()), role: messageRoleValidator, session_token: v.string(), @@ -73,6 +74,7 @@ export const create = mutation({ ctx.db.insert('messages', { conversation_id: args.conversation_id, content: args.content, + content_html: args.content_html, role: args.role, // Optional, coming from SK API route model_id: args.model_id, @@ -96,6 +98,7 @@ export const updateContent = mutation({ session_token: v.string(), message_id: v.string(), content: v.string(), + content_html: v.optional(v.string()), }, handler: async (ctx, args) => { const session = await ctx.runQuery(api.betterAuth.publicGetSession, { @@ -114,6 +117,7 @@ export const updateContent = mutation({ await ctx.db.patch(message._id, { content: args.content, + content_html: args.content_html, }); }, }); @@ -125,6 +129,7 @@ export const updateMessage = mutation({ token_count: v.optional(v.number()), cost_usd: v.optional(v.number()), generation_id: v.optional(v.string()), + content_html: v.optional(v.string()), }, handler: async (ctx, args) => { const session = await ctx.runQuery(api.betterAuth.publicGetSession, { @@ -145,6 +150,7 @@ export const updateMessage = mutation({ token_count: args.token_count, cost_usd: args.cost_usd, generation_id: args.generation_id, + content_html: args.content_html, }); }, }); diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index 3304de7..319ccce 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -53,6 +53,7 @@ export default defineSchema({ conversation_id: v.string(), role: v.union(v.literal('user'), v.literal('assistant'), v.literal('system')), content: v.string(), + content_html: v.optional(v.string()), // Optional, coming from SK API route model_id: v.optional(v.string()), provider: v.optional(providerValidator), diff --git a/src/lib/utils/markdown-it.ts b/src/lib/utils/markdown-it.ts new file mode 100644 index 0000000..2bab4ae --- /dev/null +++ b/src/lib/utils/markdown-it.ts @@ -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 } \ No newline at end of file diff --git a/src/lib/utils/markdown.svelte.ts b/src/lib/utils/markdown.svelte.ts index 9de8909..0dc1736 100644 --- a/src/lib/utils/markdown.svelte.ts +++ b/src/lib/utils/markdown.svelte.ts @@ -1,22 +1,5 @@ -import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async'; -import MarkdownItAsync from 'markdown-it-async'; import type { Getter } from 'runed'; -import { codeToHtml } from 'shiki'; - -const md = MarkdownItAsync(); - -md.use( - fromAsyncCodeToHtml( - // Pass the codeToHtml function - codeToHtml, - { - themes: { - light: 'github-light-default', - dark: 'github-dark-default', - }, - } - ) -); +import { md } from './markdown-it'; export class Markdown { highlighted = $state(null); diff --git a/src/routes/api/generate-message/+server.ts b/src/routes/api/generate-message/+server.ts index 9f629e6..df5f77f 100644 --- a/src/routes/api/generate-message/+server.ts +++ b/src/routes/api/generate-message/+server.ts @@ -10,6 +10,7 @@ import { err, ok, Result, ResultAsync } from 'neverthrow'; import OpenAI from 'openai'; import { z } from 'zod/v4'; import { generationAbortControllers } from './cache.js'; +import { md } from '$lib/utils/markdown-it.js'; // Set to true to enable debug logging const ENABLE_LOGGING = true; @@ -379,6 +380,11 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, return; } + const contentHtmlResultPromise = ResultAsync.fromPromise( + md.renderAsync(content), + (e) => `Failed to render HTML: ${e}` + ); + const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), { delay: 500, retries: 2, @@ -398,6 +404,12 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, 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([ ResultAsync.fromPromise( client.mutation(api.messages.updateMessage, { @@ -406,6 +418,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, cost_usd: generationStats.total_cost, generation_id: generationId, session_token: sessionToken, + content_html: contentHtmlResult.unwrapOr(undefined), }), (e) => `Failed to update message: ${e}` ), @@ -521,6 +534,7 @@ export const POST: RequestHandler = async ({ request }) => { const convMessageResult = await ResultAsync.fromPromise( client.mutation(api.conversations.createAndAddMessage, { content: args.message, + content_html: '', role: 'user', images: args.images, session_token: sessionToken, diff --git a/src/routes/chat/[id]/markdown-renderer.svelte b/src/routes/chat/[id]/markdown-renderer.svelte index 82e45d4..a33677e 100644 --- a/src/routes/chat/[id]/markdown-renderer.svelte +++ b/src/routes/chat/[id]/markdown-renderer.svelte @@ -1,4 +1,5 @@ -{@html markdown.current} +{@html sanitizeHtml(markdown.current ?? '')} diff --git a/src/routes/chat/[id]/message.svelte b/src/routes/chat/[id]/message.svelte index d319804..835caf0 100644 --- a/src/routes/chat/[id]/message.svelte +++ b/src/routes/chat/[id]/message.svelte @@ -6,6 +6,7 @@ import '../../../markdown.css'; import MarkdownRenderer from './markdown-renderer.svelte'; import { ImageModal } from '$lib/components/ui/image-modal'; + import { sanitizeHtml } from '$lib/utils/markdown-it'; const style = tv({ base: 'prose rounded-xl p-2', @@ -58,18 +59,22 @@ {/if}
- - + {#if message.content_html} + {@html sanitizeHtml(message.content_html)} + {:else} + + - {#snippet failed(error)} -
- Error rendering markdown: -
{error instanceof Error ? error.message : String(error)}
-
- {/snippet} -
+ {#snippet failed(error)} +
+ Error rendering markdown: +
{error instanceof Error ? error.message : String(error)}
+
+ {/snippet} +
+ {/if}