mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
feat(web): render inline Markdown in run titles
Add InlineMarkdown component that tokenizes titles via marked's Lexer.lexInline and renders code, strong, and em. Block syntax, links, images, and raw HTML degrade to safe text — no dangerouslySetInnerHTML. Applied to the run detail h2 heading and the run list row titles. Title metadata, search, and the API/server data model are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f656aecd63
commit
cf5fdd8712
6 changed files with 205 additions and 6 deletions
110
apps/fabro-web/app/components/inline-markdown.test.tsx
Normal file
110
apps/fabro-web/app/components/inline-markdown.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import { InlineMarkdown } from "./inline-markdown";
|
||||
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
||||
async function render(content: string, className?: string) {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: TestRenderer.ReactTestRenderer | undefined;
|
||||
await act(async () => {
|
||||
renderer = TestRenderer.create(
|
||||
<InlineMarkdown content={content} className={className} />,
|
||||
);
|
||||
});
|
||||
mountedRenderers.push(renderer!);
|
||||
return renderer!;
|
||||
}
|
||||
|
||||
function textFromInstance(node: TestRenderer.ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === "string" ? child : textFromInstance(child)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("InlineMarkdown", () => {
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
for (const renderer of mountedRenderers.splice(0)) {
|
||||
renderer.unmount();
|
||||
}
|
||||
});
|
||||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
test("renders backtick spans as <code> without visible backticks", async () => {
|
||||
const renderer = await render(
|
||||
"Move from `[server.integrations.github]` to `[run.integrations.github]`",
|
||||
);
|
||||
|
||||
const codes = renderer.root.findAllByType("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(textFromInstance(codes[0]!)).toBe("[server.integrations.github]");
|
||||
expect(textFromInstance(codes[1]!)).toBe("[run.integrations.github]");
|
||||
|
||||
const fullText = textFromInstance(renderer.root);
|
||||
expect(fullText).not.toContain("`");
|
||||
});
|
||||
|
||||
test("renders **bold** as <strong> and _italic_ as <em>", async () => {
|
||||
const renderer = await render("**bold** and _italic_");
|
||||
|
||||
const strongs = renderer.root.findAllByType("strong");
|
||||
expect(strongs).toHaveLength(1);
|
||||
expect(textFromInstance(strongs[0]!)).toBe("bold");
|
||||
|
||||
const ems = renderer.root.findAllByType("em");
|
||||
expect(ems).toHaveLength(1);
|
||||
expect(textFromInstance(ems[0]!)).toBe("italic");
|
||||
});
|
||||
|
||||
test("renders *italic* (asterisk form) as <em>", async () => {
|
||||
const renderer = await render("*italic*");
|
||||
const ems = renderer.root.findAllByType("em");
|
||||
expect(ems).toHaveLength(1);
|
||||
expect(textFromInstance(ems[0]!)).toBe("italic");
|
||||
});
|
||||
|
||||
test("block markdown like headings, lists, blockquotes stays text", async () => {
|
||||
const renderer = await render("# heading - item > quote");
|
||||
|
||||
expect(renderer.root.findAllByType("h1")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("h2")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("ul")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("li")).toHaveLength(0);
|
||||
expect(renderer.root.findAllByType("blockquote")).toHaveLength(0);
|
||||
|
||||
expect(textFromInstance(renderer.root)).toContain("# heading");
|
||||
expect(textFromInstance(renderer.root)).toContain("> quote");
|
||||
});
|
||||
|
||||
test("link syntax renders the label as text without an <a>", async () => {
|
||||
const renderer = await render("[label](javascript:alert(1))");
|
||||
|
||||
expect(renderer.root.findAllByType("a")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("label");
|
||||
expect(textFromInstance(renderer.root)).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
test("image syntax renders the alt text without an <img>", async () => {
|
||||
const renderer = await render("");
|
||||
|
||||
expect(renderer.root.findAllByType("img")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("alt");
|
||||
});
|
||||
|
||||
test("raw HTML is shown literally as text, not interpreted", async () => {
|
||||
const renderer = await render("<script>x</script>");
|
||||
|
||||
expect(renderer.root.findAllByType("script")).toHaveLength(0);
|
||||
expect(textFromInstance(renderer.root)).toContain("<script>");
|
||||
expect(textFromInstance(renderer.root)).toContain("</script>");
|
||||
});
|
||||
|
||||
test("applies a className to the wrapper span", async () => {
|
||||
const renderer = await render("hello", "text-fg-2");
|
||||
const wrapper = renderer.root.findByType("span");
|
||||
expect(wrapper.props.className).toBe("text-fg-2");
|
||||
});
|
||||
});
|
||||
51
apps/fabro-web/app/components/inline-markdown.tsx
Normal file
51
apps/fabro-web/app/components/inline-markdown.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Fragment, type ReactNode } from "react";
|
||||
import { Lexer, type Token } from "marked";
|
||||
|
||||
const CODE_CLASSNAME =
|
||||
"rounded bg-overlay-strong px-1 py-0.5 font-mono text-[0.85em] text-fg-2";
|
||||
|
||||
function renderTokens(tokens: Token[]): ReactNode[] {
|
||||
return tokens.map((token, index) => (
|
||||
<Fragment key={index}>{renderToken(token)}</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
function renderToken(token: Token): ReactNode {
|
||||
switch (token.type) {
|
||||
case "codespan":
|
||||
return <code className={CODE_CLASSNAME}>{token.text}</code>;
|
||||
case "strong":
|
||||
return <strong>{renderTokens(token.tokens)}</strong>;
|
||||
case "em":
|
||||
return <em>{renderTokens(token.tokens)}</em>;
|
||||
case "del":
|
||||
return renderTokens(token.tokens);
|
||||
case "link":
|
||||
return token.tokens.length > 0 ? renderTokens(token.tokens) : token.text;
|
||||
case "image":
|
||||
return token.text;
|
||||
case "html":
|
||||
return token.raw;
|
||||
case "br":
|
||||
return " ";
|
||||
case "escape":
|
||||
return token.text;
|
||||
case "text":
|
||||
return token.tokens && token.tokens.length > 0
|
||||
? renderTokens(token.tokens)
|
||||
: token.text;
|
||||
default:
|
||||
return "raw" in token ? token.raw : "";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineMarkdown({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const tokens = Lexer.lexInline(content);
|
||||
return <span className={className}>{renderTokens(tokens)}</span>;
|
||||
}
|
||||
|
|
@ -71,10 +71,15 @@ type RunDetailActionResult = import("./run-detail").RunDetailActionResult;
|
|||
|
||||
const h = createElement;
|
||||
|
||||
function makeRunSummary(status = "succeeded", diffSummary: any = null, pullRequest: any = null) {
|
||||
function makeRunSummary(
|
||||
status = "succeeded",
|
||||
diffSummary: any = null,
|
||||
pullRequest: any = null,
|
||||
title = "Run 1",
|
||||
) {
|
||||
return {
|
||||
run_id: "run_1",
|
||||
title: "Run 1",
|
||||
title,
|
||||
repository: { name: "fabro" },
|
||||
status: { kind: status },
|
||||
workflow_slug: "default",
|
||||
|
|
@ -111,14 +116,16 @@ async function renderRunDetail({
|
|||
questions = [],
|
||||
diffSummary = null,
|
||||
pullRequest = null,
|
||||
title,
|
||||
}: {
|
||||
initialEntry: string;
|
||||
status?: string;
|
||||
questions?: any[];
|
||||
diffSummary?: any;
|
||||
pullRequest?: any;
|
||||
title?: string;
|
||||
}) {
|
||||
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest);
|
||||
currentRunSummary = makeRunSummary(status, diffSummary, pullRequest, title);
|
||||
currentQuestions = questions;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
|
@ -453,6 +460,32 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(clearanceOwners.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders inline <code> in the run title heading for Markdown-formatted titles", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
title: "Move from `[server.integrations.github]` to `[run.integrations.github]`",
|
||||
});
|
||||
|
||||
const headings = renderer.root.findAll(
|
||||
(node) =>
|
||||
node.type === "h2" &&
|
||||
hasClasses(node.props.className, ["text-xl", "font-semibold", "text-fg"]),
|
||||
);
|
||||
expect(headings).toHaveLength(1);
|
||||
|
||||
const codes = headings[0]!.findAllByType("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(
|
||||
codes
|
||||
.map((code) =>
|
||||
code.children.filter((child) => typeof child === "string").join(""),
|
||||
),
|
||||
).toEqual([
|
||||
"[server.integrations.github]",
|
||||
"[run.integrations.github]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves document-flow layout for child routes without fullHeight", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { InterviewDock } from "../components/interview-dock";
|
||||
import { GitPullRequestIcon } from "../components/icons";
|
||||
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
|
||||
|
|
@ -296,7 +297,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-fg">{run.title}</h2>
|
||||
<h2 className="text-xl font-semibold text-fg">
|
||||
<InlineMarkdown content={run.title} />
|
||||
</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${run.statusDot}`} />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnSta
|
|||
import { formatRelativeTime } from "../lib/format";
|
||||
import { EmptyState } from "../components/state";
|
||||
import { GitPullRequestIcon } from "../components/icons";
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { useToast } from "../components/toast";
|
||||
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
|
||||
import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries";
|
||||
|
|
@ -606,7 +607,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
<span className="truncate font-mono text-xs font-medium text-teal-500 pr-2">{run.repo}</span>
|
||||
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
|
||||
{lifecycleLabel != null && (
|
||||
<span className="rounded-full border border-line px-1.5 py-0.5 font-mono text-[11px] uppercase tracking-wide text-fg-muted">
|
||||
{lifecycleLabel}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
|||
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { GitPullRequestIcon } from "../components/icons";
|
||||
import { InlineMarkdown } from "../components/inline-markdown";
|
||||
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
|
||||
import type { ColumnStatus, RunWithStatus } from "../data/runs";
|
||||
import { useWorkflowRuns } from "../lib/queries";
|
||||
|
|
@ -36,7 +37,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
</span>
|
||||
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate text-sm text-fg-2">{run.title}</span>
|
||||
<InlineMarkdown content={run.title} className="truncate text-sm text-fg-2" />
|
||||
{run.comments != null && run.comments > 0 && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-fg-muted">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" className="size-3" aria-hidden="true">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue