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
36 changes: 36 additions & 0 deletions apps/web/src/components/MarkdownHtml.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { memo, useMemo } from "react";

import {
type MarkdownPreviewTheme,
renderMarkdownHtml,
MARKDOWN_PREVIEW_CONTAINER_STYLE,
} from "~/lib/markdownHtml";

export function MarkdownHtml({
markdown,
theme,
className,
bodyClassName,
testId,
}: {
markdown: string;
theme: MarkdownPreviewTheme;
className?: string;
bodyClassName?: string;
testId?: string;
}) {
const rendered = useMemo(() => renderMarkdownHtml(markdown, theme), [markdown, theme]);

return (
<div className={className} style={MARKDOWN_PREVIEW_CONTAINER_STYLE}>
<style>{rendered.css}</style>
<div
className={bodyClassName}
data-testid={testId}
dangerouslySetInnerHTML={{ __html: rendered.html }}
/>
</div>
);
}

export default memo(MarkdownHtml);
17 changes: 17 additions & 0 deletions apps/web/src/components/MarkdownPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";

vi.mock("~/hooks/useTheme", () => ({
useTheme: () => ({ resolvedTheme: "light" as const }),
}));

import { MarkdownPreview } from "./MarkdownPreview";

describe("MarkdownPreview", () => {
it("does not show the loading state for an empty file", () => {
const html = renderToStaticMarkup(<MarkdownPreview contents="" />);

expect(html).toContain('data-testid="markdown-preview"');
expect(html).not.toContain("Rendering Markdown preview");
});
});
112 changes: 68 additions & 44 deletions apps/web/src/components/MarkdownPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,105 @@
import { AlertTriangleIcon, LoaderCircleIcon } from "lucide-react";
import { memo, type CSSProperties, useEffect, useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState, type CSSProperties } from "react";

import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview";
import { useTheme } from "~/hooks/useTheme";

interface MarkdownPreviewProps {
contents: string;
}

interface MarkdownPreviewState {
status: "loading" | "ready" | "error";
html: string;
css: string;
error: string | null;
}

const INITIAL_STATE: MarkdownPreviewState = {
html: "",
css: "",
error: null,
};
function createLoadingState(): MarkdownPreviewState {
return {
status: "loading",
html: "",
css: "",
error: null,
};
}

function createReadyState(html: string, css: string): MarkdownPreviewState {
return {
status: "ready",
html,
css,
error: null,
};
}

function createErrorState(error: unknown): MarkdownPreviewState {
return {
status: "error",
html: "",
css: "",
error: error instanceof Error ? error.message : "Failed to render Markdown preview.",
};
}

const MARKDOWN_PREVIEW_CONTAINER_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":
'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)',
"--cm-mono": '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
} as CSSProperties;

export const MarkdownPreview = memo(function MarkdownPreview({ contents }: MarkdownPreviewProps) {
const [state, setState] = useState<MarkdownPreviewState>(INITIAL_STATE);
const { resolvedTheme } = useTheme();
const theme = resolvedTheme === "dark" ? "github-dark" : "github";
const [state, setState] = useState<MarkdownPreviewState>(() => {
if (contents.trim().length === 0) {
return createReadyState("", "");
}
return createLoadingState();
});

useEffect(() => {
let cancelled = false;
setState(INITIAL_STATE);
if (contents.trim().length === 0) {
setState(createReadyState("", ""));
return () => {
cancelled = true;
};
}

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);
const { renderMarkdownHtml } = await import("../lib/markdownHtml");
const { html, css } = renderMarkdownHtml(contents, theme);

if (!cancelled) {
setState({ html, css, error: null });
setState(createReadyState(html, css));
}
} catch (error) {
if (!cancelled) {
setState({
html: "",
css: "",
error: error instanceof Error ? error.message : "Failed to render Markdown preview.",
});
setState(createErrorState(error));
}
}
})();

return () => {
cancelled = true;
};
}, [contents]);
}, [contents, theme]);

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

if (state.error) {
if (state.status === "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">
Expand All @@ -69,7 +111,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd
);
}

if (!state.html) {
if (state.status === "loading") {
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">
Expand All @@ -85,25 +127,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd
<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":
'var(--font-ui, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif)',
"--cm-mono":
'"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace',
} as CSSProperties
}
style={MARKDOWN_PREVIEW_CONTAINER_STYLE}
>
<div data-testid="markdown-preview" dangerouslySetInnerHTML={markup} />
</div>
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/merge-conflicts/ExpandableSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { MaximizeIcon } from "lucide-react";
import { memo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

import { cn } from "~/lib/utils";
import { useTheme } from "~/hooks/useTheme";
import { Sheet, SheetPopup, SheetPanel } from "~/components/ui/sheet";
import MarkdownHtml from "~/components/MarkdownHtml";
import { resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";

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

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

<div className="summary-preview-body text-[15px] leading-relaxed text-foreground/88">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
<MarkdownHtml markdown={text} theme={theme} />
</div>
</article>
</SheetPanel>
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/pr-review/PrCommentBody.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";

vi.hoisted(() => {
if (typeof globalThis.HTMLElement === "undefined") {
Object.defineProperty(globalThis, "HTMLElement", {
configurable: true,
// oxlint-disable-next-line typescript-eslint/no-extraneous-class
value: class HTMLElement {},
writable: true,
});
}

if (typeof globalThis.customElements === "undefined") {
Object.defineProperty(globalThis, "customElements", {
configurable: true,
value: {
define() {},
get() {
return undefined;
},
},
writable: true,
});
}
});

vi.mock("~/hooks/useTheme", () => ({
useTheme: () => ({ resolvedTheme: "light" as const }),
}));

import { PrCommentBody } from "./PrCommentBody";

describe("PrCommentBody", () => {
it("renders GitHub-flavored markdown when the body contains markdown syntax", () => {
const html = renderToStaticMarkup(
<PrCommentBody
body={[
"# Review notes",
"",
"- [x] done",
"- [ ] todo",
"",
"| A | B |",
"| - | - |",
"| 1 | 2 |",
].join("\n")}
cwd="/repo"
/>,
);

expect(html).toContain("<h1");
expect(html).toContain("<table");
expect(html).toContain("input");
});
});
17 changes: 17 additions & 0 deletions apps/web/src/components/pr-review/PrCommentBody.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { PrUserHoverCard } from "./PrUserHoverCard";
import MarkdownHtml from "~/components/MarkdownHtml";
import { useTheme } from "~/hooks/useTheme";
import { markdownLooksLikeGitHubMarkdown, resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";

export function PrCommentBody({ body, cwd }: { body: string; cwd: string | null }) {
const { resolvedTheme } = useTheme();
const theme = resolveMarkdownPreviewTheme(resolvedTheme);
const shouldRenderMarkdown = markdownLooksLikeGitHubMarkdown(body);

if (shouldRenderMarkdown) {
return (
<MarkdownHtml
bodyClassName="markdown-preview-body text-[15px] leading-6 text-foreground/88"
markdown={body}
theme={theme}
/>
);
}

const lines = body.split("\n");
const lineCounts = new Map<string, number>();
return (
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/components/pr-review/PrWorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
FileCode2Icon,
SparklesIcon,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "~/components/ui/button";
import {
Select,
Expand All @@ -17,6 +15,9 @@ import {
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import MarkdownHtml from "~/components/MarkdownHtml";
import { useTheme } from "~/hooks/useTheme";
import { resolveMarkdownPreviewTheme } from "~/lib/markdownHtml";
import { resolveWorkflow } from "./pr-review-utils";

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

return (
<div className="space-y-5">
Expand Down Expand Up @@ -175,8 +178,8 @@ export function PrWorkflowPanel({
) : null}

{workflow?.body ? (
<div className="prose prose-sm max-w-none rounded-2xl border border-border/70 bg-background/92 p-4 dark:prose-invert">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{workflow.body}</ReactMarkdown>
<div className="rounded-2xl border border-border/70 bg-background/92 p-4">
<MarkdownHtml markdown={workflow.body} theme={theme} />
</div>
) : null}
</div>
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/hooks/themeConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type Theme = "light" | "dark" | "system";
export type ColorTheme =
| "default"
| "iridescent-void"
| "carbon"
| "purple-stuff"
| "hot-tamale"
| "custom";
export type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans";

export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
{ id: "default", label: "Default" },
{ id: "iridescent-void", label: "Iridescent Void" },
{ id: "carbon", label: "Carbon" },
{ id: "purple-stuff", label: "Deep Purple" },
{ id: "hot-tamale", label: "Hot Tamale" },
{ id: "custom", label: "Custom" },
];

export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
{ id: "inter", label: "Inter" },
{ id: "dm-sans", label: "DM Sans" },
{ id: "plus-jakarta-sans", label: "Plus Jakarta Sans" },
];

export const DEFAULT_COLOR_THEME: ColorTheme = "carbon";
Loading
Loading