Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 13 additions & 8 deletions apps/web/src/components/CodeViewerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -100,12 +101,16 @@ const CodeViewerFileContent = memo(function CodeViewerFileContent(props: {
File is larger than 1MB. Showing truncated content.
</div>
)}
<CodeMirrorViewer
contents={query.data.contents}
filePath={props.relativePath}
resolvedTheme={props.resolvedTheme}
onAddContext={props.onAddContext}
/>
{isMarkdownPreviewFilePath(props.relativePath) ? (
<MarkdownPreview contents={query.data.contents} />
) : (
<CodeMirrorViewer
contents={query.data.contents}
filePath={props.relativePath}
resolvedTheme={props.resolvedTheme}
onAddContext={props.onAddContext}
/>
)}
</div>
);
});
Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/components/MarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<MarkdownPreviewState>(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 (
<div className="flex h-full min-h-0 items-center justify-center px-5 text-center">
<div className="flex max-w-md flex-col items-center gap-2 text-destructive/80">
<AlertTriangleIcon className="size-5" />
<p className="text-sm font-medium text-foreground">Markdown preview failed</p>
<p className="text-xs">{state.error}</p>
</div>
</div>
);
}

if (!state.html) {
return (
<div className="flex h-full min-h-0 items-center justify-center px-5 text-muted-foreground/70">
<div className="flex items-center gap-2 text-xs">
<LoaderCircleIcon className="size-4 animate-spin" />
Rendering Markdown preview...
</div>
</div>
);
}

return (
<div className="h-full min-h-0 overflow-auto">
<style>{state.css}</style>
<div
className="mx-auto min-h-full max-w-4xl px-6 py-5"
style={
{
"--cm-bg": "transparent",
"--cm-text": "var(--foreground)",
"--cm-border": "var(--border)",
"--cm-muted": "var(--muted-foreground)",
"--cm-link": "var(--primary)",
"--cm-code-bg": "var(--secondary)",
"--cm-inline-code-bg": "var(--secondary)",
"--cm-table-header-bg": "var(--secondary)",
"--cm-table-stripe-bg": "var(--accent)",
"--cm-callout-bg": "var(--secondary)",
"--cm-radius": "12px",
"--cm-font":
'"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
"--cm-mono":
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
} as CSSProperties
}
>
<div data-testid="markdown-preview" dangerouslySetInnerHTML={markup} />
</div>
</div>
);
});
40 changes: 40 additions & 0 deletions apps/web/src/markdownPreview.test.ts
Original file line number Diff line number Diff line change
@@ -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 {");
});
});
14 changes: 14 additions & 0 deletions apps/web/src/markdownPreview.ts
Original file line number Diff line number Diff line change
@@ -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 {`);
}
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading