This commit is contained in:
Fabian Kukuck 2026-09-12 12:00:14 +00:00 committed by GitHub
commit 45197f80a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 147 additions and 46 deletions

View file

@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "./markdown";
describe("Markdown", () => {
test("renders GitHub-flavored Markdown", () => {
const html = renderToStaticMarkup(
<Markdown content={"# Plan\n\n- [x] Review this"} />,
);
expect(html).toContain("<h1>Plan</h1>");
expect(html).toContain('type="checkbox"');
expect(html).toContain("Review this");
});
test("strips raw HTML and unsafe URLs", () => {
const html = renderToStaticMarkup(
<Markdown
content={'<script>alert("no")</script>\n\n[bad](javascript:alert("no"))'}
/>,
);
expect(html).not.toContain("<script>");
expect(html).not.toContain("javascript:");
});
});

View file

@ -0,0 +1,47 @@
import { useMemo } from "react";
import { Marked } from "marked";
const SAFE_HTTP_URL_RE = /^https?:\/\//i;
const SAFE_MAILTO_URL_RE = /^mailto:/i;
function isSafeMarkdownHref(href: string): boolean {
return (
SAFE_HTTP_URL_RE.test(href) ||
SAFE_MAILTO_URL_RE.test(href) ||
href.startsWith("#") ||
(href.startsWith("/") && !href.startsWith("//"))
);
}
const markedSafe = new Marked();
markedSafe.use({
async: false,
walkTokens(token) {
if (
(token.type === "link" || token.type === "image") &&
typeof token.href === "string" &&
!isSafeMarkdownHref(token.href)
) {
token.href = "";
}
},
renderer: {
html() {
return "";
},
},
});
export function Markdown({ content }: { content: string }) {
const html = useMemo(
() => markedSafe.parse(content, { async: false }) as string,
[content],
);
return (
// react-doctor-disable-next-line react-doctor/no-danger -- markedSafe strips raw HTML and unsafe link/image URLs before rendering Markdown.
<div
className="prose prose-sm max-w-none text-fg-3 prose-headings:text-fg-2 prose-strong:text-fg-2 prose-code:rounded prose-code:bg-overlay-strong prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.8em] prose-code:font-mono prose-code:text-fg-3 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-overlay-strong prose-pre:text-fg-3 prose-a:text-teal-500"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View file

@ -1,9 +1,10 @@
import { useMemo, type ReactNode } from "react";
import { Marked } from "marked";
import { highlightJson } from "../event-debug-helpers";
import { prettyJson } from "./pretty-json";
export { Markdown } from "../markdown";
export function DetailField({
label,
children,
@ -49,48 +50,3 @@ export function JsonBlock({ value }: { value: string }) {
</pre>
);
}
const SAFE_HTTP_URL_RE = /^https?:\/\//i;
const SAFE_MAILTO_URL_RE = /^mailto:/i;
function isSafeMarkdownHref(href: string): boolean {
return (
SAFE_HTTP_URL_RE.test(href) ||
SAFE_MAILTO_URL_RE.test(href) ||
href.startsWith("#") ||
(href.startsWith("/") && !href.startsWith("//"))
);
}
const markedSafe = new Marked();
markedSafe.use({
async: false,
walkTokens(token) {
if (
(token.type === "link" || token.type === "image") &&
typeof token.href === "string" &&
!isSafeMarkdownHref(token.href)
) {
token.href = "";
}
},
renderer: {
html() {
return "";
},
},
});
export function Markdown({ content }: { content: string }) {
const html = useMemo(
() => markedSafe.parse(content, { async: false }) as string,
[content],
);
return (
// react-doctor-disable-next-line react-doctor/no-danger -- markedSafe strips raw HTML and unsafe link/image URLs before rendering Markdown.
<div
className="prose prose-sm max-w-none text-fg-3 prose-headings:text-fg-2 prose-strong:text-fg-2 prose-code:rounded prose-code:bg-overlay-strong prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.8em] prose-code:font-mono prose-code:text-fg-3 prose-code:before:content-none prose-code:after:content-none prose-pre:bg-overlay-strong prose-pre:text-fg-3 prose-a:text-teal-500"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View file

@ -225,6 +225,39 @@ describe("RunFiles rendering", () => {
expect(multiFileDiffCalls[0].options.diffStyle).toBe("split");
});
test("previews Markdown files and can switch back to their diff", () => {
currentFilesPayload = makePayload(1);
currentFilesPayload.data[0] = {
change_kind: "added",
old_file: { name: "artifacts/planning/spec.md", contents: "" },
new_file: {
name: "artifacts/planning/spec.md",
contents: "# Plan\n\n- Review this",
},
};
const renderer = renderRunFiles();
expect(renderer.root.findAllByProps({ "data-markdown-preview": "true" })).toHaveLength(1);
const renderedMarkdown = renderer.root.findAll(
(node) => typeof node.props.dangerouslySetInnerHTML?.__html === "string",
);
expect(renderedMarkdown).toHaveLength(1);
expect(renderedMarkdown[0].props.dangerouslySetInnerHTML.__html).toContain(
"<h1>Plan</h1>",
);
expect(multiFileDiffCalls).toHaveLength(0);
const changesButton = renderer.root
.findAllByType("button")
.find((button) => button.children.includes("Changes"));
expect(changesButton).toBeDefined();
act(() => {
changesButton!.props.onClick();
});
expect(multiFileDiffCalls).toHaveLength(1);
});
test("passes the selected URL scope to useRunFiles", () => {
currentFilesPayload = makePayload(1);

View file

@ -15,6 +15,7 @@ import {
PatchDiff,
type FileContents,
} from "@pierre/diffs/react";
import { Markdown } from "../components/markdown";
import { useToast } from "../components/toast";
import { importChunk } from "../lib/import-chunk";
import type {
@ -74,6 +75,8 @@ const DIFF_STYLE_STORAGE_KEY = "fabro.run-files.diff-style";
// resolve a cached/304 refetch in tens of ms, leaving the user unsure
// whether the click registered.
const MIN_REFRESH_SPIN_MS = 500;
const MARKDOWN_PREVIEW_CLASS =
"rounded-md border border-line bg-panel px-8 py-6";
export const ErrorBoundary = RunFilesErrorBoundary;
@ -251,6 +254,10 @@ const RunFileRow = memo(function RunFileRow({
const newContents = file.new_file.contents;
const oldPath = file.old_file.name || display;
const newPath = file.new_file.name || display;
const canPreviewMarkdown =
/\.md$/i.test(newPath) && newContents != null && !placeholder;
const [showMarkdownPreview, setShowMarkdownPreview] =
useState(canPreviewMarkdown);
const oldFile = useMemo<FileContents | null>(() => {
if (oldContents == null) return null;
@ -297,6 +304,12 @@ const RunFileRow = memo(function RunFileRow({
let body: ReactElement | null = null;
if (placeholder) {
body = placeholder;
} else if (canPreviewMarkdown && showMarkdownPreview) {
body = (
<div className={MARKDOWN_PREVIEW_CLASS} data-markdown-preview="true">
<Markdown content={newContents} />
</div>
);
} else if (oldFile && newFile) {
body = (
<MultiFileDiff
@ -323,6 +336,31 @@ const RunFileRow = memo(function RunFileRow({
aria-label={`${file.change_kind ?? "modified"}: ${display}`}
className="focus:outline-2 focus:outline-focus focus:outline-offset-2 rounded-md"
>
{canPreviewMarkdown ? (
<header className="mb-2 flex items-center justify-between gap-3 rounded-md border border-line bg-panel-alt px-3 py-2">
<span className="truncate font-mono text-xs text-fg-2" title={display}>
{display}
</span>
<div className="flex shrink-0 rounded-md bg-overlay p-0.5 text-xs">
<button
type="button"
aria-pressed={showMarkdownPreview}
onClick={() => setShowMarkdownPreview(true)}
className={`rounded px-2 py-1 ${showMarkdownPreview ? "bg-panel text-fg" : "text-fg-muted hover:text-fg-2"}`}
>
Preview
</button>
<button
type="button"
aria-pressed={!showMarkdownPreview}
onClick={() => setShowMarkdownPreview(false)}
className={`rounded px-2 py-1 ${showMarkdownPreview ? "text-fg-muted hover:text-fg-2" : "bg-panel text-fg"}`}
>
Changes
</button>
</div>
</header>
) : null}
{body}
</section>
);