Skip to content

Commit 52b6b9b

Browse files
committed
fix(frontend): render investigation reasoning as markdown
1 parent 94672c8 commit 52b6b9b

5 files changed

Lines changed: 129 additions & 2 deletions

File tree

src/typescript/frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
"dependencies": {
1616
"@sveltejs/adapter-node": "^5.5.3",
1717
"@sveltejs/kit": "^2.52.2",
18+
"markdown-it": "^14.1.1",
1819
"svelte": "^5.53.0"
1920
},
2021
"devDependencies": {
2122
"@playwright/test": "^1.56.1",
2223
"@sveltejs/vite-plugin-svelte": "^6.2.4",
24+
"@types/markdown-it": "^14.1.2",
2325
"svelte-check": "^4.2.1",
2426
"tsx": "^4.21.0",
2527
"typescript": "^5.9.3",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import MarkdownIt from "markdown-it";
2+
3+
const markdownRenderer = new MarkdownIt({
4+
html: false,
5+
linkify: true,
6+
breaks: true,
7+
});
8+
9+
const defaultLinkOpenRenderer =
10+
markdownRenderer.renderer.rules["link_open"] ??
11+
((tokens, index, options, _env, self) => self.renderToken(tokens, index, options));
12+
13+
markdownRenderer.renderer.rules["link_open"] = (tokens, index, options, env, self) => {
14+
const token = tokens[index];
15+
token?.attrSet("target", "_blank");
16+
token?.attrSet("rel", "noopener noreferrer");
17+
return defaultLinkOpenRenderer(tokens, index, options, env, self);
18+
};
19+
20+
export function renderClaimReasoningHtml(markdown: string): string {
21+
return markdownRenderer.render(markdown);
22+
}

src/typescript/frontend/src/routes/corrections/[id]/+page.svelte

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import type { PageData } from "./$types";
33
import type { PublicInvestigationResult } from "./+page.server";
4+
import { renderClaimReasoningHtml } from "$lib/claim-markdown";
45
56
const { data }: { data: PageData } = $props();
67
@@ -120,7 +121,8 @@
120121

121122
<details class="claim-details">
122123
<summary>Full reasoning</summary>
123-
<div class="reasoning">{claim.reasoning}</div>
124+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
125+
<div class="reasoning">{@html renderClaimReasoningHtml(claim.reasoning)}</div>
124126
</details>
125127

126128
{#if claim.sources.length > 0}
@@ -364,7 +366,78 @@
364366
margin-top: 0.5rem;
365367
padding-left: 0.75rem;
366368
border-left: 2px solid var(--color-border);
367-
white-space: pre-wrap;
369+
}
370+
371+
.reasoning :global(p) {
372+
margin: 0 0 0.875rem;
373+
}
374+
375+
.reasoning :global(p:last-child) {
376+
margin-bottom: 0;
377+
}
378+
379+
.reasoning :global(h1),
380+
.reasoning :global(h2),
381+
.reasoning :global(h3),
382+
.reasoning :global(h4) {
383+
color: var(--color-text);
384+
font-weight: 600;
385+
line-height: 1.4;
386+
margin: 1rem 0 0.5rem;
387+
}
388+
389+
.reasoning :global(h1) {
390+
font-size: 1.1rem;
391+
}
392+
393+
.reasoning :global(h2) {
394+
font-size: 1rem;
395+
}
396+
397+
.reasoning :global(h3),
398+
.reasoning :global(h4) {
399+
font-size: 0.9375rem;
400+
}
401+
402+
.reasoning :global(ul),
403+
.reasoning :global(ol) {
404+
margin: 0.5rem 0 0.875rem 1.25rem;
405+
padding: 0;
406+
}
407+
408+
.reasoning :global(li) {
409+
margin-bottom: 0.375rem;
410+
}
411+
412+
.reasoning :global(a) {
413+
color: var(--color-accent);
414+
}
415+
416+
.reasoning :global(a:hover) {
417+
text-decoration: underline;
418+
}
419+
420+
.reasoning :global(code) {
421+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
422+
font-size: 0.8125rem;
423+
padding: 0.125rem 0.25rem;
424+
border-radius: 4px;
425+
background: var(--color-bg);
426+
color: var(--color-text);
427+
}
428+
429+
.reasoning :global(pre) {
430+
overflow-x: auto;
431+
padding: 0.75rem;
432+
border-radius: 8px;
433+
background: var(--color-bg);
434+
border: 1px solid var(--color-border);
435+
margin: 0.75rem 0;
436+
}
437+
438+
.reasoning :global(pre code) {
439+
background: transparent;
440+
padding: 0;
368441
}
369442
370443
/* Sources */
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
import { renderClaimReasoningHtml } from "../../src/lib/claim-markdown.js";
4+
5+
describe("renderClaimReasoningHtml", () => {
6+
it("renders headings, emphasis, and safe links as HTML", () => {
7+
const html = renderClaimReasoningHtml(
8+
"### Contradictory evidence\n\n**Bold** text and [source](https://example.com).",
9+
);
10+
11+
assert.match(html, /<h3>Contradictory evidence<\/h3>/);
12+
assert.match(html, /<strong>Bold<\/strong>/);
13+
assert.match(html, /href="https:\/\/example\.com"/);
14+
assert.match(html, /target="_blank"/);
15+
assert.match(html, /rel="noopener noreferrer"/);
16+
});
17+
18+
it("escapes raw HTML instead of rendering it", () => {
19+
const html = renderClaimReasoningHtml('<script>alert("xss")</script>');
20+
21+
assert.equal(html.includes("<script>"), false);
22+
assert.equal(html.includes("&lt;script&gt;alert"), true);
23+
});
24+
});

src/typescript/pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)