mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-13 23:14:17 +00:00
Merge 3b1781022f into d6fc85b9a8
This commit is contained in:
commit
45197f80a3
5 changed files with 147 additions and 46 deletions
27
apps/fabro-web/app/components/markdown.test.tsx
Normal file
27
apps/fabro-web/app/components/markdown.test.tsx
Normal 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:");
|
||||
});
|
||||
});
|
||||
47
apps/fabro-web/app/components/markdown.tsx
Normal file
47
apps/fabro-web/app/components/markdown.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue