From 50e0130d9728473759d166398b415615e7c377e0 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 27 Mar 2026 23:46:19 -0500 Subject: [PATCH] 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 --- apps/web/package.json | 2 + apps/web/src/components/CodeViewerPanel.tsx | 21 ++-- apps/web/src/components/MarkdownPreview.tsx | 112 ++++++++++++++++++++ apps/web/src/markdownPreview.test.ts | 40 +++++++ apps/web/src/markdownPreview.ts | 14 +++ bun.lock | 6 ++ 6 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/MarkdownPreview.tsx create mode 100644 apps/web/src/markdownPreview.test.ts create mode 100644 apps/web/src/markdownPreview.ts diff --git a/apps/web/package.json b/apps/web/package.json index d4a51c3b7..7eb6b0e85 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,8 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.40.0", + "@create-markdown/core": "^2.0.0", + "@create-markdown/preview": "^2.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/components/CodeViewerPanel.tsx b/apps/web/src/components/CodeViewerPanel.tsx index b9fd958cc..11e3537f7 100644 --- a/apps/web/src/components/CodeViewerPanel.tsx +++ b/apps/web/src/components/CodeViewerPanel.tsx @@ -5,12 +5,13 @@ import { memo, useCallback } from "react"; import { useCodeViewerStore, type CodeViewerTab } from "~/codeViewerStore"; import { useTheme } from "~/hooks/useTheme"; import { projectReadFileQueryOptions } from "~/lib/projectReactQuery"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; +import { isMarkdownPreviewFilePath } from "~/markdownPreview"; import { CodeMirrorViewer, type CodeContextSelection } from "./CodeMirrorViewer"; import { DiffPanelLoadingState } from "./DiffPanelShell"; +import { MarkdownPreview } from "./MarkdownPreview"; import { isElectron } from "~/env"; import { Button } from "./ui/button"; -import { isMacPlatform } from "~/lib/utils"; function CodeViewerTabStrip(props: { tabs: CodeViewerTab[]; @@ -100,12 +101,16 @@ const CodeViewerFileContent = memo(function CodeViewerFileContent(props: { File is larger than 1MB. Showing truncated content. )} - + {isMarkdownPreviewFilePath(props.relativePath) ? ( + + ) : ( + + )} ); }); diff --git a/apps/web/src/components/MarkdownPreview.tsx b/apps/web/src/components/MarkdownPreview.tsx new file mode 100644 index 000000000..cd6482584 --- /dev/null +++ b/apps/web/src/components/MarkdownPreview.tsx @@ -0,0 +1,112 @@ +import { AlertTriangleIcon, LoaderCircleIcon } from "lucide-react"; +import { memo, type CSSProperties, useEffect, useMemo, useState } from "react"; + +import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview"; + +interface MarkdownPreviewProps { + contents: string; +} + +interface MarkdownPreviewState { + html: string; + css: string; + error: string | null; +} + +const INITIAL_STATE: MarkdownPreviewState = { + html: "", + css: "", + error: null, +}; + +export const MarkdownPreview = memo(function MarkdownPreview({ contents }: MarkdownPreviewProps) { + const [state, setState] = useState(INITIAL_STATE); + + useEffect(() => { + let cancelled = false; + setState(INITIAL_STATE); + + void (async () => { + try { + const preview = await import("@create-markdown/preview"); + const html = await preview.markdownToHTML(contents, { + classPrefix: MARKDOWN_PREVIEW_CLASS_PREFIX, + linkTarget: "_blank", + theme: "system", + }); + const css = scopeMarkdownPreviewThemeCss(preview.themes.system); + + if (!cancelled) { + setState({ html, css, error: null }); + } + } catch (error) { + if (!cancelled) { + setState({ + html: "", + css: "", + error: error instanceof Error ? error.message : "Failed to render Markdown preview.", + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [contents]); + + const markup = useMemo(() => ({ __html: state.html }), [state.html]); + + if (state.error) { + return ( +
+
+ +

Markdown preview failed

+

{state.error}

+
+
+ ); + } + + if (!state.html) { + return ( +
+
+ + Rendering Markdown preview... +
+
+ ); + } + + return ( +
+ +
+
+
+
+ ); +}); diff --git a/apps/web/src/markdownPreview.test.ts b/apps/web/src/markdownPreview.test.ts new file mode 100644 index 000000000..1be515612 --- /dev/null +++ b/apps/web/src/markdownPreview.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + MARKDOWN_PREVIEW_CLASS_PREFIX, + MARKDOWN_PREVIEW_WRAPPER_CLASS, + isMarkdownPreviewFilePath, + scopeMarkdownPreviewThemeCss, +} from "./markdownPreview"; + +describe("isMarkdownPreviewFilePath", () => { + it("matches common markdown file extensions", () => { + expect(isMarkdownPreviewFilePath("README.md")).toBe(true); + expect(isMarkdownPreviewFilePath("docs/guide.markdown")).toBe(true); + expect(isMarkdownPreviewFilePath("notes.mdown")).toBe(true); + expect(isMarkdownPreviewFilePath("draft.mkd")).toBe(true); + }); + + it("does not treat non-markdown files as markdown previews", () => { + expect(isMarkdownPreviewFilePath("src/index.ts")).toBe(false); + expect(isMarkdownPreviewFilePath("story.mdx")).toBe(false); + expect(isMarkdownPreviewFilePath("rules.mdc")).toBe(false); + }); +}); + +describe("scopeMarkdownPreviewThemeCss", () => { + it("rewrites preview classes and scopes bare code selectors", () => { + const css = ` +.cm-preview a { color: red; } +.cm-code-block { color: blue; } +code { background: black; } +`; + + const scoped = scopeMarkdownPreviewThemeCss(css); + + expect(scoped).toContain(`.${MARKDOWN_PREVIEW_WRAPPER_CLASS} a`); + expect(scoped).toContain(`.${MARKDOWN_PREVIEW_CLASS_PREFIX}code-block`); + expect(scoped).toContain(`.${MARKDOWN_PREVIEW_WRAPPER_CLASS} code {`); + expect(scoped).not.toContain("\ncode {"); + }); +}); diff --git a/apps/web/src/markdownPreview.ts b/apps/web/src/markdownPreview.ts new file mode 100644 index 000000000..59570d3d3 --- /dev/null +++ b/apps/web/src/markdownPreview.ts @@ -0,0 +1,14 @@ +export const MARKDOWN_PREVIEW_CLASS_PREFIX = "okc-md-"; +export const MARKDOWN_PREVIEW_WRAPPER_CLASS = `${MARKDOWN_PREVIEW_CLASS_PREFIX}preview`; + +const MARKDOWN_FILE_EXTENSION_PATTERN = /\.(md|markdown|mdown|mkd)$/i; + +export function isMarkdownPreviewFilePath(filePath: string): boolean { + return MARKDOWN_FILE_EXTENSION_PATTERN.test(filePath); +} + +export function scopeMarkdownPreviewThemeCss(themeCss: string): string { + return themeCss + .replaceAll(".cm-", `.${MARKDOWN_PREVIEW_CLASS_PREFIX}`) + .replace(/(^|\n)code\s*\{/g, `$1.${MARKDOWN_PREVIEW_WRAPPER_CLASS} code {`); +} diff --git a/bun.lock b/bun.lock index 71912fd78..43da20ed5 100644 --- a/bun.lock +++ b/bun.lock @@ -84,6 +84,8 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.40.0", + "@create-markdown/core": "^2.0.0", + "@create-markdown/preview": "^2.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -321,6 +323,10 @@ "@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="], + "@create-markdown/core": ["@create-markdown/core@2.0.0", "", {}, "sha512-xOmhoiDSa82EzjXp3aViQdB+xfCP4E2jEKxJiKJ702sup3p/CTCtL8fZBKQ3BvzASQRpq/xKCRXZZwRrg1DmZQ=="], + + "@create-markdown/preview": ["@create-markdown/preview@2.0.0", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.0", "mermaid": ">=10.0.0", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "mermaid", "shiki"] }, "sha512-3WTGCrCOVBy9wH2X82Oa2ZHJ+eiEqu8AlucckenWVtFSbzRKzkxgci0BRw7IDvsOsTUEMxS9Ltc9/hSUHkdidA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],