diff --git a/apps/web/src/components/MarkdownHtml.tsx b/apps/web/src/components/MarkdownHtml.tsx new file mode 100644 index 00000000..c6f38970 --- /dev/null +++ b/apps/web/src/components/MarkdownHtml.tsx @@ -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 ( +
+ +
+
+ ); +} + +export default memo(MarkdownHtml); diff --git a/apps/web/src/components/MarkdownPreview.test.tsx b/apps/web/src/components/MarkdownPreview.test.tsx new file mode 100644 index 00000000..034d08e7 --- /dev/null +++ b/apps/web/src/components/MarkdownPreview.test.tsx @@ -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(); + + expect(html).toContain('data-testid="markdown-preview"'); + expect(html).not.toContain("Rendering Markdown preview"); + }); +}); diff --git a/apps/web/src/components/MarkdownPreview.tsx b/apps/web/src/components/MarkdownPreview.tsx index b968e177..e3b793c5 100644 --- a/apps/web/src/components/MarkdownPreview.tsx +++ b/apps/web/src/components/MarkdownPreview.tsx @@ -1,51 +1,93 @@ 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(INITIAL_STATE); + const { resolvedTheme } = useTheme(); + const theme = resolvedTheme === "dark" ? "github-dark" : "github"; + const [state, setState] = useState(() => { + 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)); } } })(); @@ -53,11 +95,11 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd return () => { cancelled = true; }; - }, [contents]); + }, [contents, theme]); const markup = useMemo(() => ({ __html: state.html }), [state.html]); - if (state.error) { + if (state.status === "error") { return (
@@ -69,7 +111,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd ); } - if (!state.html) { + if (state.status === "loading") { return (
@@ -85,25 +127,7 @@ export const MarkdownPreview = memo(function MarkdownPreview({ contents }: Markd
diff --git a/apps/web/src/components/merge-conflicts/ExpandableSummary.tsx b/apps/web/src/components/merge-conflicts/ExpandableSummary.tsx index 7eaf4a01..86610714 100644 --- a/apps/web/src/components/merge-conflicts/ExpandableSummary.tsx +++ b/apps/web/src/components/merge-conflicts/ExpandableSummary.tsx @@ -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 @@ -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; @@ -77,7 +80,7 @@ export const ExpandableSummary = memo(function ExpandableSummary({ ) : null}
- {text} +
diff --git a/apps/web/src/components/pr-review/PrCommentBody.test.tsx b/apps/web/src/components/pr-review/PrCommentBody.test.tsx new file mode 100644 index 00000000..9e925f74 --- /dev/null +++ b/apps/web/src/components/pr-review/PrCommentBody.test.tsx @@ -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( + , + ); + + expect(html).toContain(" + ); + } + const lines = body.split("\n"); const lineCounts = new Map(); return ( diff --git a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx index 2fc82ec5..dfd20e18 100644 --- a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx +++ b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx @@ -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, @@ -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({ @@ -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 (
@@ -175,8 +178,8 @@ export function PrWorkflowPanel({ ) : null} {workflow?.body ? ( -
- {workflow.body} +
+
) : null}
diff --git a/apps/web/src/hooks/themeConfig.ts b/apps/web/src/hooks/themeConfig.ts new file mode 100644 index 00000000..f735c3a1 --- /dev/null +++ b/apps/web/src/hooks/themeConfig.ts @@ -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"; diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000..d68b23ce --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; + +import { COLOR_THEMES } from "./themeConfig"; + +describe("COLOR_THEMES", () => { + it("includes the Hot Tamale preset", () => { + expect(COLOR_THEMES.some((theme) => theme.id === "hot-tamale")).toBe(true); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index eb032ef9..bebf966f 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,10 +1,8 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { initCustomTheme } from "../lib/customTheme"; - -type Theme = "light" | "dark" | "system"; -type ColorTheme = "default" | "iridescent-void" | "carbon" | "purple-stuff" | "custom"; - -type FontFamily = "dm-sans" | "inter" | "plus-jakarta-sans"; +import { DEFAULT_COLOR_THEME } from "./themeConfig"; +export { COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "./themeConfig"; +import type { ColorTheme, FontFamily, Theme } from "./themeConfig"; type ThemeSnapshot = { theme: Theme; @@ -13,25 +11,10 @@ type ThemeSnapshot = { fontFamily: FontFamily; }; -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: "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" }, -]; - const STORAGE_KEY = "okcode:theme"; const COLOR_THEME_STORAGE_KEY = "okcode:color-theme"; const FONT_FAMILY_STORAGE_KEY = "okcode:font-family"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; -export const DEFAULT_COLOR_THEME: ColorTheme = "carbon"; const SERVER_SNAPSHOT: ThemeSnapshot = { theme: "system", @@ -66,6 +49,7 @@ function getStoredColorTheme(): ColorTheme { normalized === "iridescent-void" || normalized === "carbon" || normalized === "purple-stuff" || + normalized === "hot-tamale" || normalized === "custom" ) { if (normalized !== raw) { diff --git a/apps/web/src/lib/markdownHtml.test.ts b/apps/web/src/lib/markdownHtml.test.ts new file mode 100644 index 00000000..7dcec37a --- /dev/null +++ b/apps/web/src/lib/markdownHtml.test.ts @@ -0,0 +1,66 @@ +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, + }); + } +}); + +import { + markdownLooksLikeGitHubMarkdown, + renderMarkdownHtml, + resolveMarkdownPreviewTheme, +} from "./markdownHtml"; + +describe("resolveMarkdownPreviewTheme", () => { + it("maps app themes to the GitHub preview themes", () => { + expect(resolveMarkdownPreviewTheme("light")).toBe("github"); + expect(resolveMarkdownPreviewTheme("dark")).toBe("github-dark"); + }); +}); + +describe("markdownLooksLikeGitHubMarkdown", () => { + it("detects GitHub-flavored markdown features", () => { + expect(markdownLooksLikeGitHubMarkdown("- [x] complete")).toBe(true); + expect(markdownLooksLikeGitHubMarkdown("| A | B |\n| - | - |\n| 1 | 2 |")).toBe(true); + expect(markdownLooksLikeGitHubMarkdown("Plain text only")).toBe(false); + }); +}); + +describe("renderMarkdownHtml", () => { + it("renders GitHub-flavored markdown into HTML", () => { + const { html, css } = renderMarkdownHtml( + "# Title\n\n- [x] done\n- [ ] todo\n\n| A | B |\n| - | - |\n| 1 | 2 |", + "github", + ); + + expect(html).toContain(" { + const { html } = renderMarkdownHtml("", "github"); + + expect(html).toBe(""); + }); +}); diff --git a/apps/web/src/lib/markdownHtml.ts b/apps/web/src/lib/markdownHtml.ts new file mode 100644 index 00000000..dac10ad2 --- /dev/null +++ b/apps/web/src/lib/markdownHtml.ts @@ -0,0 +1,79 @@ +import type { CSSProperties } from "react"; + +import { parse } from "@create-markdown/core"; +import { blocksToHTML, themes } from "@create-markdown/preview"; + +import { MARKDOWN_PREVIEW_CLASS_PREFIX, scopeMarkdownPreviewThemeCss } from "~/markdownPreview"; + +export type MarkdownPreviewTheme = "github" | "github-dark"; + +export const MARKDOWN_PREVIEW_CONTAINER_STYLE: CSSProperties = { + "--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; + +function getThemeCss(theme: MarkdownPreviewTheme): string { + const themeCss = theme === "github" ? themes.github : themes.githubDark; + if (typeof themeCss !== "string") { + throw new Error(`Unsupported markdown preview theme: ${theme}`); + } + + return scopeMarkdownPreviewThemeCss(themeCss); +} + +export function resolveMarkdownPreviewTheme(resolvedTheme: "light" | "dark"): MarkdownPreviewTheme { + return resolvedTheme === "dark" ? "github-dark" : "github"; +} + +export function markdownLooksLikeGitHubMarkdown(markdown: string): boolean { + return ( + /^#{1,6}\s/m.test(markdown) || + /^>\s/m.test(markdown) || + /^(-|\*|\+)\s/m.test(markdown) || + /^\d+\.\s/m.test(markdown) || + /^\s*[-*+]\s+\[[ xX]\]\s/m.test(markdown) || + /\|.+\|/m.test(markdown) || + /```[\s\S]*```/.test(markdown) || + /`[^`]+`/.test(markdown) || + /\[[^\]]+\]\([^)]+\)/.test(markdown) || + /\*\*[^*]+\*\*/.test(markdown) || + /\*[^*\n]+\*/.test(markdown) + ); +} + +export function renderMarkdownHtml( + markdown: string, + theme: MarkdownPreviewTheme, +): { html: string; css: string } { + if (markdown.trim().length === 0) { + return { + html: "", + css: getThemeCss(theme), + }; + } + + const blocks = parse(markdown); + const html = blocksToHTML(blocks, { + classPrefix: MARKDOWN_PREVIEW_CLASS_PREFIX, + linkTarget: "_blank", + sanitize: true, + theme, + }); + + return { + html, + css: getThemeCss(theme), + }; +} diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css index ee74f713..5a5d605d 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -125,7 +125,8 @@ /* ─── Deep Purple ─── minimal cool elegant dashboard portfolio ─── */ -:root.theme-purple-stuff { +:root.theme-purple-stuff, +:root.theme-hot-tamale { color-scheme: light; --background: oklch(0.9838 0.0035 247.8583); --foreground: oklch(0.1284 0.0267 261.5937); @@ -187,7 +188,8 @@ --spacing: 0.25rem; } -:root.theme-purple-stuff.dark { +:root.theme-purple-stuff.dark, +:root.theme-hot-tamale.dark { color-scheme: dark; --background: oklch(0.1091 0.0091 301.6956); --foreground: oklch(0.9838 0.0035 247.8583);