Skip to content

Commit 4e004fa

Browse files
authored
Unify markdown rendering and add theme presets (#454)
- Share GitHub-flavored markdown rendering across previews and PR review UI - Add a reusable MarkdownHtml wrapper plus markdown/theme utilities - Include the Hot Tamale color theme in theme config and CSS
1 parent 584b7bd commit 4e004fa

File tree

13 files changed

+395
-73
lines changed

13 files changed

+395
-73
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { memo, useMemo } from "react";
2+
3+
import {
4+
type MarkdownPreviewTheme,
5+
renderMarkdownHtml,
6+
MARKDOWN_PREVIEW_CONTAINER_STYLE,
7+
} from "~/lib/markdownHtml";
8+
9+
export function MarkdownHtml({
10+
markdown,
11+
theme,
12+
className,
13+
bodyClassName,
14+
testId,
15+
}: {
16+
markdown: string;
17+
theme: MarkdownPreviewTheme;
18+
className?: string;
19+
bodyClassName?: string;
20+
testId?: string;
21+
}) {
22+
const rendered = useMemo(() => renderMarkdownHtml(markdown, theme), [markdown, theme]);
23+
24+
return (
25+
<div className={className} style={MARKDOWN_PREVIEW_CONTAINER_STYLE}>
26+
<style>{rendered.css}</style>
27+
<div
28+
className={bodyClassName}
29+
data-testid={testId}
30+
dangerouslySetInnerHTML={{ __html: rendered.html }}
31+
/>
32+
</div>
33+
);
34+
}
35+
36+
export default memo(MarkdownHtml);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("~/hooks/useTheme", () => ({
5+
useTheme: () => ({ resolvedTheme: "light" as const }),
6+
}));
7+
8+
import { MarkdownPreview } from "./MarkdownPreview";
9+
10+
describe("MarkdownPreview", () => {
11+
it("does not show the loading state for an empty file", () => {
12+
const html = renderToStaticMarkup(<MarkdownPreview contents="" />);
13+
14+
expect(html).toContain('data-testid="markdown-preview"');
15+
expect(html).not.toContain("Rendering Markdown preview");
16+
});
17+
});

apps/web/src/components/MarkdownPreview.tsx

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,105 @@
11
import { AlertTriangleIcon, LoaderCircleIcon } from "lucide-react";
2-
import { memo, type CSSProperties, useEffect, useMemo, useState } from "react";
2+
import { memo, useEffect, useMemo, useState, type CSSProperties } from "react";
33

4-
import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview";
4+
import { useTheme } from "~/hooks/useTheme";
55

66
interface MarkdownPreviewProps {
77
contents: string;
88
}
99

1010
interface MarkdownPreviewState {
11+
status: "loading" | "ready" | "error";
1112
html: string;
1213
css: string;
1314
error: string | null;
1415
}
1516

16-
const INITIAL_STATE: MarkdownPreviewState = {
17-
html: "",
18-
css: "",
19-
error: null,
20-
};
17+
function createLoadingState(): MarkdownPreviewState {
18+
return {
19+
status: "loading",
20+
html: "",
21+
css: "",
22+
error: null,
23+
};
24+
}
25+
26+
function createReadyState(html: string, css: string): MarkdownPreviewState {
27+
return {
28+
status: "ready",
29+
html,
30+
css,
31+
error: null,
32+
};
33+
}
34+
35+
function createErrorState(error: unknown): MarkdownPreviewState {
36+
return {
37+
status: "error",
38+
html: "",
39+
css: "",
40+
error: error instanceof Error ? error.message : "Failed to render Markdown preview.",
41+
};
42+
}
43+
44+
const MARKDOWN_PREVIEW_CONTAINER_STYLE = {
45+
"--cm-bg": "transparent",
46+
"--cm-text": "var(--foreground)",
47+
"--cm-border": "var(--border)",
48+
"--cm-muted": "var(--muted-foreground)",
49+
"--cm-link": "var(--primary)",
50+
"--cm-code-bg": "var(--secondary)",
51+
"--cm-inline-code-bg": "var(--secondary)",
52+
"--cm-table-header-bg": "var(--secondary)",
53+
"--cm-table-stripe-bg": "var(--accent)",
54+
"--cm-callout-bg": "var(--secondary)",
55+
"--cm-radius": "12px",
56+
"--cm-font":
57+
'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)',
58+
"--cm-mono": '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
59+
} as CSSProperties;
2160

2261
export const MarkdownPreview = memo(function MarkdownPreview({ contents }: MarkdownPreviewProps) {
23-
const [state, setState] = useState<MarkdownPreviewState>(INITIAL_STATE);
62+
const { resolvedTheme } = useTheme();
63+
const theme = resolvedTheme === "dark" ? "github-dark" : "github";
64+
const [state, setState] = useState<MarkdownPreviewState>(() => {
65+
if (contents.trim().length === 0) {
66+
return createReadyState("", "");
67+
}
68+
return createLoadingState();
69+
});
2470

2571
useEffect(() => {
2672
let cancelled = false;
27-
setState(INITIAL_STATE);
73+
if (contents.trim().length === 0) {
74+
setState(createReadyState("", ""));
75+
return () => {
76+
cancelled = true;
77+
};
78+
}
2879

2980
void (async () => {
3081
try {
31-
const preview = await import("@create-markdown/preview");
32-
const html = await preview.markdownToHTML(contents, {
33-
classPrefix: MARKDOWN_PREVIEW_CLASS_PREFIX,
34-
linkTarget: "_blank",
35-
theme: "system",
36-
});
37-
const css = scopeMarkdownPreviewThemeCss(preview.themes.system);
82+
const { renderMarkdownHtml } = await import("../lib/markdownHtml");
83+
const { html, css } = renderMarkdownHtml(contents, theme);
3884

3985
if (!cancelled) {
40-
setState({ html, css, error: null });
86+
setState(createReadyState(html, css));
4187
}
4288
} catch (error) {
4389
if (!cancelled) {
44-
setState({
45-
html: "",
46-
css: "",
47-
error: error instanceof Error ? error.message : "Failed to render Markdown preview.",
48-
});
90+
setState(createErrorState(error));
4991
}
5092
}
5193
})();
5294

5395
return () => {
5496
cancelled = true;
5597
};
56-
}, [contents]);
98+
}, [contents, theme]);
5799

58100
const markup = useMemo(() => ({ __html: state.html }), [state.html]);
59101

60-
if (state.error) {
102+
if (state.status === "error") {
61103
return (
62104
<div className="flex h-full min-h-0 items-center justify-center px-5 text-center">
63105
<div className="flex max-w-md flex-col items-center gap-2 text-destructive/80">
@@ -69,7 +111,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd
69111
);
70112
}
71113

72-
if (!state.html) {
114+
if (state.status === "loading") {
73115
return (
74116
<div className="flex h-full min-h-0 items-center justify-center px-5 text-muted-foreground/70">
75117
<div className="flex items-center gap-2 text-xs">
@@ -85,25 +127,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd
85127
<style>{state.css}</style>
86128
<div
87129
className="mx-auto min-h-full max-w-4xl px-6 py-5"
88-
style={
89-
{
90-
"--cm-bg": "transparent",
91-
"--cm-text": "var(--foreground)",
92-
"--cm-border": "var(--border)",
93-
"--cm-muted": "var(--muted-foreground)",
94-
"--cm-link": "var(--primary)",
95-
"--cm-code-bg": "var(--secondary)",
96-
"--cm-inline-code-bg": "var(--secondary)",
97-
"--cm-table-header-bg": "var(--secondary)",
98-
"--cm-table-stripe-bg": "var(--accent)",
99-
"--cm-callout-bg": "var(--secondary)",
100-
"--cm-radius": "12px",
101-
"--cm-font":
102-
'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)',
103-
"--cm-mono":
104-
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
105-
} as CSSProperties
106-
}
130+
style={MARKDOWN_PREVIEW_CONTAINER_STYLE}
107131
>
108132
<div data-testid="markdown-preview" dangerouslySetInnerHTML={markup} />
109133
</div>

apps/web/src/components/merge-conflicts/ExpandableSummary.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { MaximizeIcon } from "lucide-react";
22
import { memo, useState } from "react";
3-
import ReactMarkdown from "react-markdown";
4-
import remarkGfm from "remark-gfm";
53

64
import { cn } from "~/lib/utils";
5+
import { useTheme } from "~/hooks/useTheme";
76
import { Sheet, SheetPopup, SheetPanel } from "~/components/ui/sheet";
7+
import MarkdownHtml from "~/components/MarkdownHtml";
8+
import { resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";
89

910
/**
1011
* Wraps plain-text or markdown AI summaries with an expand button
@@ -39,6 +40,8 @@ export const ExpandableSummary = memo(function ExpandableSummary({
3940
children,
4041
}: ExpandableSummaryProps) {
4142
const [open, setOpen] = useState(false);
43+
const { resolvedTheme } = useTheme();
44+
const theme = resolveMarkdownPreviewTheme(resolvedTheme);
4245

4346
// Don't render the expand affordance for very short text
4447
const isExpandable = text.trim().length > 40;
@@ -77,7 +80,7 @@ export const ExpandableSummary = memo(function ExpandableSummary({
7780
) : null}
7881

7982
<div className="summary-preview-body text-[15px] leading-relaxed text-foreground/88">
80-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
83+
<MarkdownHtml markdown={text} theme={theme} />
8184
</div>
8285
</article>
8386
</SheetPanel>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
vi.hoisted(() => {
5+
if (typeof globalThis.HTMLElement === "undefined") {
6+
Object.defineProperty(globalThis, "HTMLElement", {
7+
configurable: true,
8+
// oxlint-disable-next-line typescript-eslint/no-extraneous-class
9+
value: class HTMLElement {},
10+
writable: true,
11+
});
12+
}
13+
14+
if (typeof globalThis.customElements === "undefined") {
15+
Object.defineProperty(globalThis, "customElements", {
16+
configurable: true,
17+
value: {
18+
define() {},
19+
get() {
20+
return undefined;
21+
},
22+
},
23+
writable: true,
24+
});
25+
}
26+
});
27+
28+
vi.mock("~/hooks/useTheme", () => ({
29+
useTheme: () => ({ resolvedTheme: "light" as const }),
30+
}));
31+
32+
import { PrCommentBody } from "./PrCommentBody";
33+
34+
describe("PrCommentBody", () => {
35+
it("renders GitHub-flavored markdown when the body contains markdown syntax", () => {
36+
const html = renderToStaticMarkup(
37+
<PrCommentBody
38+
body={[
39+
"# Review notes",
40+
"",
41+
"- [x] done",
42+
"- [ ] todo",
43+
"",
44+
"| A | B |",
45+
"| - | - |",
46+
"| 1 | 2 |",
47+
].join("\n")}
48+
cwd="/repo"
49+
/>,
50+
);
51+
52+
expect(html).toContain("<h1");
53+
expect(html).toContain("<table");
54+
expect(html).toContain("input");
55+
});
56+
});

apps/web/src/components/pr-review/PrCommentBody.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { PrUserHoverCard } from "./PrUserHoverCard";
2+
import MarkdownHtml from "~/components/MarkdownHtml";
3+
import { useTheme } from "~/hooks/useTheme";
4+
import { markdownLooksLikeGitHubMarkdown, resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";
25

36
export function PrCommentBody({ body, cwd }: { body: string; cwd: string | null }) {
7+
const { resolvedTheme } = useTheme();
8+
const theme = resolveMarkdownPreviewTheme(resolvedTheme);
9+
const shouldRenderMarkdown = markdownLooksLikeGitHubMarkdown(body);
10+
11+
if (shouldRenderMarkdown) {
12+
return (
13+
<MarkdownHtml
14+
bodyClassName="markdown-preview-body text-[15px] leading-6 text-foreground/88"
15+
markdown={body}
16+
theme={theme}
17+
/>
18+
);
19+
}
20+
421
const lines = body.split("\n");
522
const lineCounts = new Map<string, number>();
623
return (

apps/web/src/components/pr-review/PrWorkflowPanel.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import {
66
FileCode2Icon,
77
SparklesIcon,
88
} from "lucide-react";
9-
import ReactMarkdown from "react-markdown";
10-
import remarkGfm from "remark-gfm";
119
import { Button } from "~/components/ui/button";
1210
import {
1311
Select,
@@ -17,6 +15,9 @@ import {
1715
SelectValue,
1816
} from "~/components/ui/select";
1917
import { Badge } from "~/components/ui/badge";
18+
import MarkdownHtml from "~/components/MarkdownHtml";
19+
import { useTheme } from "~/hooks/useTheme";
20+
import { resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";
2021
import { resolveWorkflow } from "./pr-review-utils";
2122

2223
export function PrWorkflowPanel({
@@ -41,6 +42,8 @@ export function PrWorkflowPanel({
4142
const workflow = resolveWorkflow(config, workflowId);
4243
const workflowStepMap = new Map(workflowSteps.map((step) => [step.stepId, step]));
4344
const isPreviewingNonDefault = workflow?.id !== config?.defaultWorkflowId;
45+
const { resolvedTheme } = useTheme();
46+
const theme = resolveMarkdownPreviewTheme(resolvedTheme);
4447

4548
return (
4649
<div className="space-y-5">
@@ -175,8 +178,8 @@ export function PrWorkflowPanel({
175178
) : null}
176179

177180
{workflow?.body ? (
178-
<div className="prose prose-sm max-w-none rounded-2xl border border-border/70 bg-background/92 p-4 dark:prose-invert">
179-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{workflow.body}</ReactMarkdown>
181+
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
182+
<MarkdownHtml markdown={workflow.body} theme={theme} />
180183
</div>
181184
) : null}
182185
</div>

apps/web/src/hooks/themeConfig.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type Theme = "light" | "dark" | "system";
2+
export type ColorTheme =
3+
| "default"
4+
| "iridescent-void"
5+
| "carbon"
6+
| "purple-stuff"
7+
| "hot-tamale"
8+
| "custom";
9+
export type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans";
10+
11+
export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
12+
{ id: "default", label: "Default" },
13+
{ id: "iridescent-void", label: "Iridescent Void" },
14+
{ id: "carbon", label: "Carbon" },
15+
{ id: "purple-stuff", label: "Deep Purple" },
16+
{ id: "hot-tamale", label: "Hot Tamale" },
17+
{ id: "custom", label: "Custom" },
18+
];
19+
20+
export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
21+
{ id: "inter", label: "Inter" },
22+
{ id: "dm-sans", label: "DM Sans" },
23+
{ id: "plus-jakarta-sans", label: "Plus Jakarta Sans" },
24+
];
25+
26+
export const DEFAULT_COLOR_THEME: ColorTheme = "carbon";

0 commit comments

Comments
 (0)