diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 618f364..cb70f30 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -1041,3 +1041,63 @@ describe("unified tool card", () => { expect(screen.queryByText("next msg")).toBeNull(); }); }); + +describe("renderMarkdown blocks", () => { + it("headings render as h1-h6 with inline content", () => { + const out = renderMarkdown("## Hello **world**"); + const h = out[0] as React.ReactElement<{ className: string }>; + expect(h.type).toBe("h2"); + expect(h.props.className).toContain("md-h-2"); + }); + + it("--- renders a horizontal rule", () => { + const out = renderMarkdown("above\n---\nbelow"); + expect(out.some((n) => (n as React.ReactElement).type === "hr")).toBe(true); + }); + + it("> lines group into one blockquote", () => { + const out = renderMarkdown("> quoted a\n> quoted b\nplain"); + const q = out.find( + (n) => (n as React.ReactElement).type === "blockquote", + ) as React.ReactElement<{ children: React.ReactNode[] }>; + expect(q).toBeDefined(); + expect(q.props.children.join("")).toContain("quoted a"); + expect(q.props.children.join("")).toContain("quoted b"); + }); + + it("- and * lines render as an unordered list", () => { + const out = renderMarkdown("- one\n- two\n* three"); + const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + expect(ul.type).toBe("ul"); + expect(ul.props.children).toHaveLength(3); + }); + + it("1. lines render as an ordered list", () => { + const out = renderMarkdown("1. first\n2. second"); + const ol = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + expect(ol.type).toBe("ol"); + expect(ol.props.children).toHaveLength(2); + }); + + it("emphasis start is not a list marker", () => { + const out = renderMarkdown("*bold start* to line"); + expect(out.some((n) => (n as React.ReactElement).type === "ul")).toBe(false); + }); + + it("list items keep inline markdown", () => { + const out = renderMarkdown("- has `code` and [l](https://x.io)"); + const ul = out[0] as React.ReactElement<{ children: React.ReactNode[] }>; + const li = ul.props.children[0] as React.ReactElement<{ + children: React.ReactNode[]; + }>; + expect(JSON.stringify(li.props.children)).toContain("code"); + expect(JSON.stringify(li.props.children)).toContain("https://x.io"); + }); + + it("fences still win over block markers", () => { + const out = renderMarkdown("```\n- not a list\n# not a heading\n```"); + const pre = out[0] as React.ReactElement<{ children: string }>; + expect(pre.type).toBe("pre"); + expect(pre.props.children).toContain("- not a list"); + }); +}); diff --git a/web/src/ChatStream.tsx b/web/src/ChatStream.tsx index dd36070..3b50a52 100644 --- a/web/src/ChatStream.tsx +++ b/web/src/ChatStream.tsx @@ -6,6 +6,11 @@ const COPY_FEEDBACK_MS: number = 1200; const PIN_THRESHOLD_PX: number = 80; const FAB_THRESHOLD_PX: number = 400; const FENCE: string = "```"; +const HEADING_RE: RegExp = /^(#{1,6})\s+(.*)$/; +const HR_RE: RegExp = /^(?:-{3,}|\*{3,}|_{3,})$/; +const QUOTE_RE: RegExp = /^>\s?(.*)$/; +const UL_RE: RegExp = /^[-*+]\s+(.*)$/; +const OL_RE: RegExp = /^\d{1,9}[.)]\s+(.*)$/; const ENTER_KEY: string = "Enter"; const ESCAPE_KEY: string = "Escape"; const INLINE_RE: RegExp = @@ -79,11 +84,13 @@ function inlineNodes( return out; } -/** ```fences``` →
, `code`, **bold**, *italic*,
- * [t](http…) links (http/https only). Plain segments keep the bubble's
- * pre-wrap. Unterminated fences (streaming) render the tail as code. */
+/** Block markdown: ```fences```, # headings, --- rules, > quotes, -/*
+ * lists, 1. lists; inline: `code`, **bold**, *italic*, [t](http…)
+ * links (http/https only). Plain segments keep the bubble's pre-wrap.
+ * Unterminated fences (streaming) render the tail as code. */
export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
const out: React.ReactNode[] = [];
+ const lines: string[] = text.split("\n");
let plain: string[] = [];
let code: string[] | null = null;
let n = 0;
@@ -94,7 +101,29 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
plain = [];
}
};
- for (const line of text.split("\n")) {
+ // consecutive lines sharing a marker accumulate into one block element
+ const collect = (
+ re: RegExp,
+ from: number,
+ ): { items: string[]; next: number } => {
+ const items: string[] = [];
+ let k: number = from;
+ while (k < lines.length) {
+ const raw: string | undefined = lines[k];
+ if (raw === undefined) break;
+ const m: RegExpMatchArray | null = raw.match(re);
+ if (m === null) break;
+ const item: string | undefined = m[1];
+ if (item === undefined) break;
+ items.push(item);
+ k += 1;
+ }
+ return { items, next: k };
+ };
+ let i: number = 0;
+ while (i < lines.length) {
+ const line: string | undefined = lines[i];
+ if (line === undefined) break;
if (line.trimStart().startsWith(FENCE)) {
if (code === null) {
flushPlain();
@@ -108,11 +137,77 @@ export function renderMarkdown(text: string, query = ""): React.ReactNode[] {
n += 1;
code = null;
}
- } else if (code === null) {
- plain.push(line);
- } else {
- code.push(line);
+ i += 1;
+ continue;
}
+ if (code !== null) {
+ code.push(line);
+ i += 1;
+ continue;
+ }
+ const heading: RegExpMatchArray | null = line.match(HEADING_RE);
+ if (heading !== null) {
+ flushPlain();
+ const level: number = Math.min(heading[1]?.length ?? 1, 6);
+ const Tag: keyof React.JSX.IntrinsicElements = `h${level}` as "h1";
+ out.push(
+
+ {inlineNodes(heading[2] ?? "", query, `h-${n}`)}
+ ,
+ );
+ n += 1;
+ i += 1;
+ continue;
+ }
+ if (HR_RE.test(line.trim())) {
+ flushPlain();
+ out.push(
);
+ n += 1;
+ i += 1;
+ continue;
+ }
+ if (QUOTE_RE.test(line)) {
+ flushPlain();
+ const q: { items: string[]; next: number } = collect(QUOTE_RE, i);
+ out.push(
+
+ {inlineNodes(q.items.join("\n"), query, `q-${n}`)}
+
,
+ );
+ n += 1;
+ i = q.next;
+ continue;
+ }
+ if (UL_RE.test(line)) {
+ flushPlain();
+ const l: { items: string[]; next: number } = collect(UL_RE, i);
+ out.push(
+