Skip to content

Commit 50e0130

Browse files
committed
Add markdown preview rendering
- Render markdown files with the new preview component - Add markdown preview helpers and coverage for file detection and CSS scoping - Pull in create-markdown preview dependencies
1 parent d03694c commit 50e0130

6 files changed

Lines changed: 187 additions & 8 deletions

File tree

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"@codemirror/state": "^6.6.0",
2121
"@codemirror/theme-one-dark": "^6.1.3",
2222
"@codemirror/view": "^6.40.0",
23+
"@create-markdown/core": "^2.0.0",
24+
"@create-markdown/preview": "^2.0.0",
2325
"@dnd-kit/core": "^6.3.1",
2426
"@dnd-kit/modifiers": "^9.0.0",
2527
"@dnd-kit/sortable": "^10.0.0",

apps/web/src/components/CodeViewerPanel.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { memo, useCallback } from "react";
55
import { useCodeViewerStore, type CodeViewerTab } from "~/codeViewerStore";
66
import { useTheme } from "~/hooks/useTheme";
77
import { projectReadFileQueryOptions } from "~/lib/projectReactQuery";
8-
import { cn } from "~/lib/utils";
8+
import { cn, isMacPlatform } from "~/lib/utils";
9+
import { isMarkdownPreviewFilePath } from "~/markdownPreview";
910
import { CodeMirrorViewer, type CodeContextSelection } from "./CodeMirrorViewer";
1011
import { DiffPanelLoadingState } from "./DiffPanelShell";
12+
import { MarkdownPreview } from "./MarkdownPreview";
1113
import { isElectron } from "~/env";
1214
import { Button } from "./ui/button";
13-
import { isMacPlatform } from "~/lib/utils";
1415

1516
function CodeViewerTabStrip(props: {
1617
tabs: CodeViewerTab[];
@@ -100,12 +101,16 @@ const CodeViewerFileContent = memo(function CodeViewerFileContent(props: {
100101
File is larger than 1MB. Showing truncated content.
101102
</div>
102103
)}
103-
<CodeMirrorViewer
104-
contents={query.data.contents}
105-
filePath={props.relativePath}
106-
resolvedTheme={props.resolvedTheme}
107-
onAddContext={props.onAddContext}
108-
/>
104+
{isMarkdownPreviewFilePath(props.relativePath) ? (
105+
<MarkdownPreview contents={query.data.contents} />
106+
) : (
107+
<CodeMirrorViewer
108+
contents={query.data.contents}
109+
filePath={props.relativePath}
110+
resolvedTheme={props.resolvedTheme}
111+
onAddContext={props.onAddContext}
112+
/>
113+
)}
109114
</div>
110115
);
111116
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { AlertTriangleIcon, LoaderCircleIcon } from "lucide-react";
2+
import { memo, type CSSProperties, useEffect, useMemo, useState } from "react";
3+
4+
import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview";
5+
6+
interface MarkdownPreviewProps {
7+
contents: string;
8+
}
9+
10+
interface MarkdownPreviewState {
11+
html: string;
12+
css: string;
13+
error: string | null;
14+
}
15+
16+
const INITIAL_STATE: MarkdownPreviewState = {
17+
html: "",
18+
css: "",
19+
error: null,
20+
};
21+
22+
export const MarkdownPreview = memo(function MarkdownPreview({ contents }: MarkdownPreviewProps) {
23+
const [state, setState] = useState<MarkdownPreviewState>(INITIAL_STATE);
24+
25+
useEffect(() => {
26+
let cancelled = false;
27+
setState(INITIAL_STATE);
28+
29+
void (async () => {
30+
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);
38+
39+
if (!cancelled) {
40+
setState({ html, css, error: null });
41+
}
42+
} catch (error) {
43+
if (!cancelled) {
44+
setState({
45+
html: "",
46+
css: "",
47+
error: error instanceof Error ? error.message : "Failed to render Markdown preview.",
48+
});
49+
}
50+
}
51+
})();
52+
53+
return () => {
54+
cancelled = true;
55+
};
56+
}, [contents]);
57+
58+
const markup = useMemo(() => ({ __html: state.html }), [state.html]);
59+
60+
if (state.error) {
61+
return (
62+
<div className="flex h-full min-h-0 items-center justify-center px-5 text-center">
63+
<div className="flex max-w-md flex-col items-center gap-2 text-destructive/80">
64+
<AlertTriangleIcon className="size-5" />
65+
<p className="text-sm font-medium text-foreground">Markdown preview failed</p>
66+
<p className="text-xs">{state.error}</p>
67+
</div>
68+
</div>
69+
);
70+
}
71+
72+
if (!state.html) {
73+
return (
74+
<div className="flex h-full min-h-0 items-center justify-center px-5 text-muted-foreground/70">
75+
<div className="flex items-center gap-2 text-xs">
76+
<LoaderCircleIcon className="size-4 animate-spin" />
77+
Rendering Markdown preview...
78+
</div>
79+
</div>
80+
);
81+
}
82+
83+
return (
84+
<div className="h-full min-h-0 overflow-auto">
85+
<style>{state.css}</style>
86+
<div
87+
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+
'"DM Sans", -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+
}
107+
>
108+
<div data-testid="markdown-preview" dangerouslySetInnerHTML={markup} />
109+
</div>
110+
</div>
111+
);
112+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
MARKDOWN_PREVIEW_CLASS_PREFIX,
5+
MARKDOWN_PREVIEW_WRAPPER_CLASS,
6+
isMarkdownPreviewFilePath,
7+
scopeMarkdownPreviewThemeCss,
8+
} from "./markdownPreview";
9+
10+
describe("isMarkdownPreviewFilePath", () => {
11+
it("matches common markdown file extensions", () => {
12+
expect(isMarkdownPreviewFilePath("README.md")).toBe(true);
13+
expect(isMarkdownPreviewFilePath("docs/guide.markdown")).toBe(true);
14+
expect(isMarkdownPreviewFilePath("notes.mdown")).toBe(true);
15+
expect(isMarkdownPreviewFilePath("draft.mkd")).toBe(true);
16+
});
17+
18+
it("does not treat non-markdown files as markdown previews", () => {
19+
expect(isMarkdownPreviewFilePath("src/index.ts")).toBe(false);
20+
expect(isMarkdownPreviewFilePath("story.mdx")).toBe(false);
21+
expect(isMarkdownPreviewFilePath("rules.mdc")).toBe(false);
22+
});
23+
});
24+
25+
describe("scopeMarkdownPreviewThemeCss", () => {
26+
it("rewrites preview classes and scopes bare code selectors", () => {
27+
const css = `
28+
.cm-preview a { color: red; }
29+
.cm-code-block { color: blue; }
30+
code { background: black; }
31+
`;
32+
33+
const scoped = scopeMarkdownPreviewThemeCss(css);
34+
35+
expect(scoped).toContain(`.${MARKDOWN_PREVIEW_WRAPPER_CLASS} a`);
36+
expect(scoped).toContain(`.${MARKDOWN_PREVIEW_CLASS_PREFIX}code-block`);
37+
expect(scoped).toContain(`.${MARKDOWN_PREVIEW_WRAPPER_CLASS} code {`);
38+
expect(scoped).not.toContain("\ncode {");
39+
});
40+
});

apps/web/src/markdownPreview.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const MARKDOWN_PREVIEW_CLASS_PREFIX = "okc-md-";
2+
export const MARKDOWN_PREVIEW_WRAPPER_CLASS = `${MARKDOWN_PREVIEW_CLASS_PREFIX}preview`;
3+
4+
const MARKDOWN_FILE_EXTENSION_PATTERN = /\.(md|markdown|mdown|mkd)$/i;
5+
6+
export function isMarkdownPreviewFilePath(filePath: string): boolean {
7+
return MARKDOWN_FILE_EXTENSION_PATTERN.test(filePath);
8+
}
9+
10+
export function scopeMarkdownPreviewThemeCss(themeCss: string): string {
11+
return themeCss
12+
.replaceAll(".cm-", `.${MARKDOWN_PREVIEW_CLASS_PREFIX}`)
13+
.replace(/(^|\n)code\s*\{/g, `$1.${MARKDOWN_PREVIEW_WRAPPER_CLASS} code {`);
14+
}

bun.lock

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)