Skip to content

Commit 36950c2

Browse files
Merge pull request #188 from nodfans/refactor/enhance-markdown-rendering
refactor: enhance markdown rendering (fixes #171)
2 parents 37e081d + e2f6d06 commit 36950c2

File tree

2 files changed

+131
-125
lines changed

2 files changed

+131
-125
lines changed

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@tauri-apps/plugin-updater": "~2.9.0",
3737
"jsonc-parser": "^3.2.1",
3838
"lucide-solid": "^0.562.0",
39+
"marked": "^17.0.1",
3940
"solid-js": "^1.9.0"
4041
},
4142
"devDependencies": {
Lines changed: 130 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { For, Match, Show, Switch } from "solid-js";
2-
1+
import { Match, Show, Switch, createMemo } from "solid-js";
2+
import { marked } from "marked";
33
import type { Part } from "@opencode-ai/sdk/v2/client";
4+
import { safeStringify } from "../utils";
45

56
type Props = {
67
part: Part;
@@ -10,90 +11,84 @@ type Props = {
1011
renderMarkdown?: boolean;
1112
};
1213

13-
type MarkdownSegment =
14-
| { type: "text"; text: string }
15-
| { type: "code"; text: string; language: string };
16-
17-
function safeStringify(value: unknown) {
18-
const seen = new WeakSet<object>();
19-
20-
try {
21-
return JSON.stringify(
22-
value,
23-
(key, val) => {
24-
if (val && typeof val === "object") {
25-
if (seen.has(val as object)) {
26-
return "<circular>";
27-
}
28-
seen.add(val as object);
29-
}
30-
31-
const lowerKey = key.toLowerCase();
32-
if (
33-
lowerKey === "reasoningencryptedcontent" ||
34-
lowerKey.includes("api_key") ||
35-
lowerKey.includes("apikey") ||
36-
lowerKey.includes("access_token") ||
37-
lowerKey.includes("refresh_token") ||
38-
lowerKey.includes("token") ||
39-
lowerKey.includes("authorization") ||
40-
lowerKey.includes("cookie") ||
41-
lowerKey.includes("secret")
42-
) {
43-
return "[redacted]";
44-
}
45-
46-
return val;
47-
},
48-
2,
49-
);
50-
} catch {
51-
return "<unserializable>";
52-
}
53-
}
54-
5514
function clampText(text: string, max = 800) {
5615
if (text.length <= max) return text;
5716
return `${text.slice(0, max)}\n\n… (truncated)`;
5817
}
5918

60-
function parseMarkdownSegments(text: string): MarkdownSegment[] {
61-
if (!text.includes("```")) {
62-
return [{ type: "text", text }];
63-
}
19+
function createCustomRenderer(tone: "light" | "dark") {
20+
const renderer = new marked.Renderer();
21+
const codeBlockClass =
22+
tone === "dark"
23+
? "bg-gray-12/10 border-gray-11/20 text-gray-12"
24+
: "bg-gray-1/80 border-gray-6/70 text-gray-12";
25+
const inlineCodeClass =
26+
tone === "dark"
27+
? "bg-gray-12/15 text-gray-12"
28+
: "bg-gray-2/70 text-gray-12";
29+
30+
const escapeHtml = (s: string) =>
31+
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32+
33+
const isSafeUrl = (url: string) => {
34+
const protocol = (url || "").trim().toLowerCase();
35+
return !protocol.startsWith("javascript:") && !protocol.startsWith("data:");
36+
};
6437

65-
const segments: MarkdownSegment[] = [];
66-
const regex = /```(\w+)?\n([\s\S]*?)```/g;
67-
let lastIndex = 0;
68-
let match: RegExpExecArray | null = null;
38+
renderer.html = ({ text }) => escapeHtml(text);
6939

70-
while ((match = regex.exec(text)) !== null) {
71-
if (match.index > lastIndex) {
72-
segments.push({ type: "text", text: text.slice(lastIndex, match.index) });
73-
}
40+
renderer.code = ({ text, lang }) => {
41+
const language = lang || "";
42+
return `
43+
<div class="rounded-2xl border px-4 py-3 my-4 ${codeBlockClass}">
44+
${
45+
language
46+
? `<div class="text-[10px] uppercase tracking-[0.2em] text-gray-9 mb-2">${escapeHtml(language)}</div>`
47+
: ""
48+
}
49+
<pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono"><code>${escapeHtml(
50+
text
51+
)}</code></pre>
52+
</div>
53+
`;
54+
};
7455

75-
const language = match[1]?.trim() ?? "";
76-
const code = match[2]?.replace(/\n$/, "") ?? "";
77-
segments.push({ type: "code", text: code, language });
78-
lastIndex = regex.lastIndex;
79-
}
56+
renderer.codespan = ({ text }) => {
57+
return `<code class="rounded-md px-1.5 py-0.5 text-[13px] font-mono ${inlineCodeClass}">${escapeHtml(
58+
text
59+
)}</code>`;
60+
};
8061

81-
if (lastIndex < text.length) {
82-
segments.push({ type: "text", text: text.slice(lastIndex) });
83-
}
62+
renderer.link = ({ href, title, text }) => {
63+
const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "#") : "#";
64+
const safeTitle = title ? escapeHtml(title) : "";
65+
return `
66+
<a
67+
href="${safeHref}"
68+
target="_blank"
69+
rel="noopener noreferrer"
70+
class="underline underline-offset-2 text-blue-600 hover:text-blue-700"
71+
${safeTitle ? `title="${safeTitle}"` : ""}
72+
>
73+
${text}
74+
</a>
75+
`;
76+
};
8477

85-
return segments.length ? segments : [{ type: "text", text }];
86-
}
78+
renderer.image = ({ href, title, text }) => {
79+
const safeHref = isSafeUrl(href) ? escapeHtml(href ?? "") : "";
80+
const safeTitle = title ? escapeHtml(title) : "";
81+
return `
82+
<img
83+
src="${safeHref}"
84+
alt="${escapeHtml(text || "")}"
85+
${safeTitle ? `title="${safeTitle}"` : ""}
86+
class="max-w-full h-auto rounded-lg my-4"
87+
/>
88+
`;
89+
};
8790

88-
function parseInlineCode(text: string) {
89-
if (!text.includes("`")) return [{ type: "text", text }];
90-
const parts = text.split(/(`[^`]+`)/g).filter(Boolean);
91-
return parts.map((part) => {
92-
if (part.startsWith("`") && part.endsWith("`")) {
93-
return { type: "code", text: part.slice(1, -1) };
94-
}
95-
return { type: "text", text: part };
96-
});
91+
return renderer;
9792
}
9893

9994
export default function PartView(props: Props) {
@@ -106,56 +101,65 @@ export default function PartView(props: Props) {
106101
const textClass = () => (tone() === "dark" ? "text-gray-12" : "text-gray-12");
107102
const subtleTextClass = () => (tone() === "dark" ? "text-gray-12/70" : "text-gray-11");
108103
const panelBgClass = () => (tone() === "dark" ? "bg-gray-2/10" : "bg-gray-2/30");
109-
const inlineCodeClass = () =>
110-
tone() === "dark"
111-
? "bg-gray-12/15 text-gray-12"
112-
: "bg-gray-2/70 text-gray-12";
113-
const codeBlockClass = () =>
114-
tone() === "dark"
115-
? "bg-gray-12/10 border-gray-11/20 text-gray-12"
116-
: "bg-gray-1/80 border-gray-6/70 text-gray-12";
117104
const toolOnly = () => developerMode();
118105
const showToolOutput = () => developerMode();
106+
const renderedMarkdown = createMemo(() => {
107+
if (!renderMarkdown() || p().type !== "text") return null;
108+
const text = "text" in p() ? String((p() as { text: string }).text ?? "") : "";
109+
if (!text.trim()) return "";
110+
111+
try {
112+
const renderer = createCustomRenderer(tone());
113+
const result = marked.parse(text, {
114+
breaks: true,
115+
gfm: true,
116+
renderer,
117+
async: false
118+
});
119+
120+
return typeof result === 'string' ? result : '';
121+
} catch (error) {
122+
console.error('Markdown parsing error:', error);
123+
return null;
124+
}
125+
});
119126

120127
return (
121128
<Switch>
122129
<Match when={p().type === "text"}>
123130
<Show
124131
when={renderMarkdown()}
125132
fallback={
126-
<div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>{(p() as any).text}</div>
133+
<div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>
134+
{"text" in p() ? (p() as { text: string }).text : ""}
135+
</div>
127136
}
128137
>
129-
<div class="space-y-3">
130-
<For each={parseMarkdownSegments(String((p() as any).text ?? ""))}>
131-
{(segment) =>
132-
segment.type === "code" ? (
133-
<div class={`rounded-2xl border px-4 py-3 ${codeBlockClass()}`.trim()}>
134-
<Show when={segment.language}>
135-
<div class="text-[10px] uppercase tracking-[0.2em] text-gray-9 mb-2">
136-
{segment.language}
137-
</div>
138-
</Show>
139-
<pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono">
140-
{segment.text}
141-
</pre>
142-
</div>
143-
) : (
144-
<div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>
145-
{parseInlineCode(segment.text).map((part) =>
146-
part.type === "code" ? (
147-
<code class={`rounded-md px-1.5 py-0.5 text-[13px] font-mono ${inlineCodeClass()}`.trim()}>
148-
{part.text}
149-
</code>
150-
) : (
151-
part.text
152-
),
153-
)}
154-
</div>
155-
)
156-
}
157-
</For>
158-
</div>
138+
<Show
139+
when={renderedMarkdown()}
140+
fallback={
141+
<div class={`whitespace-pre-wrap break-words ${textClass()}`.trim()}>
142+
{"text" in p() ? (p() as { text: string }).text : ""}
143+
</div>
144+
}
145+
>
146+
<div
147+
class={`markdown-content max-w-none ${textClass()}
148+
[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:my-4
149+
[&_h2]:text-xl [&_h2]:font-bold [&_h2]:my-3
150+
[&_h3]:text-lg [&_h3]:font-bold [&_h3]:my-2
151+
[&_p]:my-3 [&_p]:leading-relaxed
152+
[&_ul]:list-disc [&_ul]:pl-6 [&_ul]:my-3
153+
[&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:my-3
154+
[&_li]:my-1
155+
[&_blockquote]:border-l-4 [&_blockquote]:border-gray-300 [&_blockquote]:pl-4 [&_blockquote]:my-4 [&_blockquote]:italic
156+
[&_table]:w-full [&_table]:border-collapse [&_table]:my-4
157+
[&_th]:border [&_th]:border-gray-300 [&_th]:p-2 [&_th]:bg-gray-50
158+
[&_td]:border [&_td]:border-gray-300 [&_td]:p-2
159+
`.trim()}
160+
innerHTML={renderedMarkdown()!}
161+
/>
162+
</Show>
159163
</Show>
160164
</Match>
161165

@@ -164,8 +168,9 @@ export default function PartView(props: Props) {
164168
when={
165169
showThinking() &&
166170
developerMode() &&
167-
typeof (p() as any).text === "string" &&
168-
(p() as any).text.trim()
171+
"text" in p() &&
172+
typeof (p() as { text: string }).text === "string" &&
173+
(p() as { text: string }).text.trim()
169174
}
170175
>
171176
<details class={`rounded-lg ${panelBgClass()} p-2`.trim()}>
@@ -175,7 +180,7 @@ export default function PartView(props: Props) {
175180
tone() === "dark" ? "text-gray-1" : "text-gray-12"
176181
}`.trim()}
177182
>
178-
{clampText(String((p() as any).text), 2000)}
183+
{clampText(String((p() as { text: string }).text), 2000)}
179184
</pre>
180185
</details>
181186
</Show>
@@ -188,24 +193,24 @@ export default function PartView(props: Props) {
188193
<div
189194
class={`text-xs font-medium ${tone() === "dark" ? "text-gray-1" : "text-gray-12"}`.trim()}
190195
>
191-
Tool · {String((p() as any).tool)}
196+
Tool · {("tool" in p() ? String((p() as { tool: string }).tool) : "unknown")}
192197
</div>
193198
<div
194199
class={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
195-
(p() as any).state?.status === "completed"
200+
"state" in p() && (p() as any).state?.status === "completed"
196201
? "bg-green-3/15 text-green-12"
197-
: (p() as any).state?.status === "running"
202+
: "state" in p() && (p() as any).state?.status === "running"
198203
? "bg-blue-3/15 text-blue-12"
199-
: (p() as any).state?.status === "error"
204+
: "state" in p() && (p() as any).state?.status === "error"
200205
? "bg-red-3/15 text-red-12"
201206
: "bg-gray-2/10 text-gray-1"
202207
}`}
203208
>
204-
{String((p() as any).state?.status ?? "unknown")}
209+
{("state" in p() ? String((p() as any).state?.status ?? "unknown") : "unknown")}
205210
</div>
206211
</div>
207212

208-
<Show when={(p() as any).state?.title}>
213+
<Show when={"state" in p() && (p() as any).state?.title}>
209214
<div class={`text-xs ${subtleTextClass()}`.trim()}>{String((p() as any).state.title)}</div>
210215
</Show>
211216

@@ -244,7 +249,7 @@ export default function PartView(props: Props) {
244249
<Match when={p().type === "step-start" || p().type === "step-finish"}>
245250
<div class={`text-xs ${subtleTextClass()}`.trim()}>
246251
{p().type === "step-start" ? "Step started" : "Step finished"}
247-
<Show when={(p() as any).reason}>
252+
<Show when={"reason" in p() && (p() as any).reason}>
248253
<span class={tone() === "dark" ? "text-gray-12/80" : "text-gray-11"}>
249254
{" "}· {String((p() as any).reason)}
250255
</span>
@@ -265,4 +270,4 @@ export default function PartView(props: Props) {
265270
</Match>
266271
</Switch>
267272
);
268-
}
273+
}

0 commit comments

Comments
 (0)