diff --git a/apps/desktop/e2e/conversation-rendering.spec.ts b/apps/desktop/e2e/conversation-rendering.spec.ts new file mode 100644 index 00000000..95f8bc75 --- /dev/null +++ b/apps/desktop/e2e/conversation-rendering.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { currentRow, items, launch, seedHome, tid } from "./support"; + +/** + * How a reply reads in the transcript (#277). This part covers its Markdown: GitHub-flavoured blocks + * render as elements, raw HTML stays text, and nothing in a reply runs, fetches or navigates. + */ +let app: ElectronApplication; +let page: Page; +let home: string; +let repo: string; +const dialogs: string[] = []; + +const REPLY = [ + "## Result", + "", + "| File | Lines |", + "| :--- | ---: |", + "| a.ts | 12 |", + "", + "> Run the suite before pushing.", + "", + "- [x] parser", + "- [ ] renderer", + " 1. nested step", + "", + "```ts", + "export const answer = 42;", + "```", + "", + 'Inert: and [a script](javascript:alert(2)) and ![chart](https://example.com/c.png).', +].join("\n"); + +test.beforeAll(async () => { + ({ home, repo } = seedHome()); + ({ app, page } = await launch(home)); + page.on("dialog", (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss(); }); + const script = path.join(home, "rendering-script.json"); + fs.writeFileSync(script, JSON.stringify([{ content: REPLY }])); + await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script); + await page.keyboard.press("Meta+n"); + await expect(tid(page, "composer-input")).toBeFocused(); + await tid(page, "composer-input").fill("summarize the change"); + await page.keyboard.press("Meta+Enter"); + await expect(tid(page, "thread-view")).toBeVisible({ timeout: 15_000 }); + await expect(items(page, "assistant")).toHaveCount(1); + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); +}); + +test.afterAll(async () => { + await app?.close(); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); +}); + +test("a reply's tables, quotes, task lists and code render as elements", async () => { + const answer = items(page, "assistant").first(); + await expect(answer.getByRole("heading", { name: "Result" })).toBeVisible(); + const table = answer.locator("table"); + await expect(table.getByRole("columnheader")).toHaveText(["File", "Lines"]); + await expect(table.getByRole("cell")).toHaveText(["a.ts", "12"]); + await expect(answer.locator("blockquote")).toHaveText("Run the suite before pushing."); + const boxes = answer.getByRole("checkbox"); + await expect(boxes).toHaveCount(2); + await expect(boxes.nth(0)).toBeChecked(); + await expect(boxes.nth(1)).not.toBeChecked(); + await expect(boxes.nth(0)).toBeDisabled(); + await expect(answer.locator("li ol li")).toHaveText("nested step"); + await expect(answer.locator('pre.code[data-lang="ts"]')).toHaveText("export const answer = 42;"); +}); + +test("raw HTML, script links and images in a reply stay inert", async () => { + const answer = items(page, "assistant").first(); + await expect(answer).toContainText(''); + await expect(answer).toContainText("[a script](javascript:alert(2))"); + await expect(answer.locator("img")).toHaveCount(0); + await expect(answer.locator('a[href^="javascript:"]')).toHaveCount(0); + await expect(answer.getByRole("link", { name: "Image: chart" })).toHaveAttribute("href", "https://example.com/c.png"); + expect(dialogs).toEqual([]); +}); + +test("Copy on a code block puts exactly its code on the clipboard and starts nothing", async () => { + const answer = items(page, "assistant").first(); + const block = tid(answer, "code-block"); + await expect(block).toContainText("ts"); + // A follow-up suggestion can arrive after the turn ends and grow the composer, which moves the + // transcript under the pointer mid-click: the likely cause of the one CI run (39112253838) where + // Copied never showed after a 42 ms click. A missed click copies nothing, so clicking again until + // Copied shows cannot hide a wrong copy. + const copy = tid(block, "code-copy"); + await expect(async () => { + await copy.click(); + await expect(copy).toHaveText("Copied", { timeout: 1000 }); + }).toPass({ timeout: 15_000 }); + expect(await app.evaluate(({ clipboard }) => clipboard.readText())).toBe("export const answer = 42;"); + await expect(items(page, "tool")).toHaveCount(0); + await expect(items(page, "approval")).toHaveCount(0); + await expect(items(page, "user")).toHaveCount(1); + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); +}); diff --git a/apps/desktop/e2e/markdown-narrow.spec.ts b/apps/desktop/e2e/markdown-narrow.spec.ts new file mode 100644 index 00000000..4aebb73f --- /dev/null +++ b/apps/desktop/e2e/markdown-narrow.spec.ts @@ -0,0 +1,70 @@ +import { test, expect, type ElectronApplication, type Page } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; +import { currentRow, items, launch, resizeWindow, seedHome, tid } from "./support"; + +/** + * Markdown at the 900x600 minimum (#277): with the Review panel open the transcript is about 265 px + * wide. A wide table and a long code line scroll inside their own boxes, and no table column is + * squeezed to a word, in every theme. + */ +let app: ElectronApplication; +let page: Page; +let home: string; +let repo: string; + +test.beforeAll(async () => { + ({ home, repo } = seedHome()); + ({ app, page } = await launch(home)); + await page.keyboard.press("Meta+n"); + await expect(tid(page, "composer-input")).toBeFocused(); +}); + +test.afterAll(async () => { + await app?.close(); + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); +}); + +const WIDE = [ + "| Path | Change | Detail |", + "| --- | --- | --- |", + "| apps/desktop/src/renderer/components/transcript/CodeBlock.tsx | added | " + "a long cell that keeps going ".repeat(8) + "|", + "", + "```ts", + "export const aVeryLongLineOfCode = [" + Array.from({ length: 30 }, (_, i) => `"item-${i}"`).join(", ") + "];", + "```", +].join("\n"); + +test("a reply's tables and code stay readable in every theme at the 900×600 minimum", async ({}, testInfo) => { + const script = path.join(home, "wide-script.json"); + fs.writeFileSync(script, JSON.stringify([{ content: WIDE }])); + await page.evaluate((mock_script) => window.modex!.invoke("settings:update", { mock_script }), script); + const replies = await items(page, "assistant").count(); + await tid(page, "composer-input").fill("show the wide table"); + await page.keyboard.press("Meta+Enter"); + // Wait for this reply itself, then for the turn to end: the thread was already idle before it. + await expect(items(page, "assistant")).toHaveCount(replies + 1, { timeout: 20_000 }); + await expect(items(page, "assistant").last()).toContainText("aVeryLongLineOfCode"); + await expect(currentRow(page)).toHaveAttribute("data-status", "idle", { timeout: 20_000 }); + const answer = items(page, "assistant").last(); + await expect(answer.locator("table")).toBeVisible(); + await resizeWindow(app, page, 900, 600); + for (const theme of ["graphite", "jev", "coven"]) { + await page.evaluate((value) => window.modex!.invoke("settings:update", { theme: value }), theme); + await answer.scrollIntoViewIfNeeded(); + // The page never scrolls sideways: a wide table and a long code line scroll inside their own boxes. + expect(await tid(page, "transcript").evaluate((el) => el.scrollWidth - el.clientWidth), `${theme}: no sideways page scroll`).toBeLessThanOrEqual(0); + expect(await answer.locator(".md-table").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the table scrolls in its box`).toBe(true); + // No column is squeezed to a word: before cells had a minimum width, Detail measured 58 px and its row 1,009 px tall. + const narrowest = await answer.locator("td").evaluateAll((cells) => Math.min(...cells.map((c) => c.getBoundingClientRect().width / parseFloat(getComputedStyle(c).fontSize)))); + expect(narrowest, `${theme}: every cell is at least about ten characters wide`).toBeGreaterThanOrEqual(9.5); + expect(await answer.locator("pre.code").evaluate((el) => el.scrollWidth > el.clientWidth), `${theme}: the code scrolls in its box`).toBe(true); + await expect(tid(answer, "code-copy")).toBeVisible(); + await answer.locator("table").evaluate((table) => table.scrollIntoView({ block: "start" })); + const shot = await page.screenshot(); + await testInfo.attach(`markdown-${theme}-900x600`, { body: shot, contentType: "image/png" }); + // MODEX_SCREENSHOTS= keeps them for review evidence. + if (process.env.MODEX_SCREENSHOTS) fs.writeFileSync(path.join(process.env.MODEX_SCREENSHOTS, `markdown-${theme}-900x600.png`), shot); + } +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 27531935..3cb8bc7c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,12 +38,16 @@ }, "devDependencies": { "@playwright/test": "^1.63.0", + "@types/mdast": "4.0.4", "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.1.1", "@xterm/addon-fit": "0.11.0", "@xterm/xterm": "6.0.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-gfm": "3.1.0", + "micromark-extension-gfm": "3.0.0", "electron": "^44.4.5", "electron-builder": "26.15.3", "react": "^19.3.0", diff --git a/apps/desktop/src/renderer/components/Markdown.tsx b/apps/desktop/src/renderer/components/Markdown.tsx index b2146721..a1d741dc 100644 --- a/apps/desktop/src/renderer/components/Markdown.tsx +++ b/apps/desktop/src/renderer/components/Markdown.tsx @@ -1,59 +1,228 @@ -import { memo, type ReactNode } from "react"; -import { tokenizeInline, type InlineToken } from "../../shared/inline"; +import { memo, useEffect, useState, type ReactNode } from "react"; +import type { Definition, ListItem, Nodes, Parents, PhrasingContent, RootContent, TableRow } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { safeHref } from "../../shared/inline"; +import { CodeBlock } from "./transcript/CodeBlock"; -/** Small, dependency-free Markdown subset: headings, fenced code, inline code, bold, links, lists, paragraphs. */ +/** + * Markdown as the transcript shows it: CommonMark with GitHub's extensions (tables, task lists, + * strikethrough, autolinks, footnotes), parsed by micromark and rendered as React elements only. + * Raw HTML stays text, only http(s) links are links (main hands them to the system browser), and + * images are never fetched. An unclosed code fence runs to the end, so a streaming fence shows its + * code so far. + */ export const Markdown = memo(function Markdown({ text }: { text: string }) { - const blocks: ReactNode[] = []; - const lines = text.replace(/\r\n/g, "\n").split("\n"); - let i = 0; - let key = 0; - while (i < lines.length) { - const line = lines[i]!; - if (line.startsWith("```")) { - const lang = line.slice(3).trim(); - const code: string[] = []; - i++; - while (i < lines.length && !lines[i]!.startsWith("```")) code.push(lines[i++]!); - i++; - blocks.push(
{code.join("\n")}
); - continue; + // Text that changed in the last moment is still streaming: parse only its last block again, and + // the whole text once it has rested, so the settled rendering is always the exact one. + const [settled, setSettled] = useState(text); + useEffect(() => { + if (text === settled) return; + const timer = setTimeout(() => setSettled(text), STREAM_SETTLE_MS); + return () => clearTimeout(timer); + }, [text, settled]); + if (text === settled) return
{settledBlocks(text)}
; + return
{streamingChunks(text).map((chunk, i) => )}
; +}); + +/** How long text must stop changing before it is parsed whole again. */ +const STREAM_SETTLE_MS = 300; + +const Blocks = memo(function Blocks({ text }: { text: string }) { + return <>{render(text)}; +}); + +function render(text: string): ReactNode[] { + const tree = fromMarkdown(text, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }); + const definitions = new Map(); + collectDefinitions(tree, definitions); + return blocks(tree.children, { source: text, definitions, tight: false }); +} + +/** Renders by text, keeping the most recent `entries` results for texts of at most `maxChars`. */ +export class RenderCache { + private readonly kept = new Map(); + constructor(private readonly entries: number, private readonly maxChars: number) {} + get size(): number { return this.kept.size; } + get(text: string, make: () => T): T { + if (text.length > this.maxChars) return make(); + if (this.kept.has(text)) { + const hit = this.kept.get(text) as T; + this.kept.delete(text); + this.kept.set(text, hit); + return hit; } - const h = /^(#{1,3})\s+(.*)$/.exec(line); - if (h) { - const level = h[1]!.length; - const content = inline(h[2]!); - blocks.push(level === 1 ?

{content}

: level === 2 ?

{content}

:

{content}

); - i++; - continue; + const made = make(); + this.kept.set(text, made); + if (this.kept.size > this.entries) this.kept.delete(this.kept.keys().next().value!); + return made; + } +} + +/** + * Settled text, rendered once per app session: reopening a thread or switching back to one reuses + * the elements instead of parsing every message again. Streaming pieces are never cached, nor is + * very long text such as a fetched page. + */ +const settledRenders = new RenderCache(400, 64_000); +function settledBlocks(text: string): ReactNode[] { + return settledRenders.get(text, () => render(text)); +} + +/** + * Splits text after each blank line that is outside a fenced code block, so a streaming reply + * re-parses only its last piece. Joining the pieces gives the text back. A piece can read slightly + * differently from the whole (a loose list shows as two lists) until the text settles. + */ +export function streamingChunks(text: string): string[] { + const chunks: string[] = []; + let start = 0; + let fence: { char: string; length: number } | null = null; + for (let at = 0; at < text.length;) { + const newline = text.indexOf("\n", at); + const end = newline < 0 ? text.length : newline + 1; + const line = text.slice(at, newline < 0 ? text.length : newline); + const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (fence) { + if (marker && marker[1]![0] === fence.char && marker[1]!.length >= fence.length && !marker[2]!.trim()) fence = null; + } else if (marker && !(marker[1]![0] === "`" && marker[2]!.includes("`"))) { + fence = { char: marker[1]![0]!, length: marker[1]!.length }; + } else if (!line.trim() && at > start) { + chunks.push(text.slice(start, end)); + start = end; } - if (/^\s*([-*]|\d+\.)\s+/.test(line)) { - const items: ReactNode[] = []; - const ordered = /^\s*\d+\./.test(line); - while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) items.push(
  • {inline(lines[i]!.replace(/^\s*([-*]|\d+\.)\s+/, ""))}
  • ), i++; - blocks.push(ordered ?
      {items}
    :
      {items}
    ); - continue; + at = end; + } + if (start < text.length) chunks.push(text.slice(start)); + return chunks; +} + +interface Context { + source: string; + definitions: Map; + /** Inside a tight list item, paragraphs render as bare text. */ + tight: boolean; +} + +function collectDefinitions(node: Nodes, into: Map): void { + if (node.type === "definition" && !into.has(node.identifier)) into.set(node.identifier, node); + if ("children" in node) for (const child of node.children) collectDefinitions(child, into); +} + +/** The Markdown exactly as written, for constructs Modex will not turn into elements. */ +function written(node: Nodes, ctx: Context): string { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + return start === undefined || end === undefined ? "" : ctx.source.slice(start, end); +} + +function blocks(nodes: RootContent[], ctx: Context): ReactNode[] { + return nodes.map((node, key) => block(node, key, ctx)); +} + +function block(node: RootContent, key: number, ctx: Context): ReactNode { + switch (node.type) { + case "paragraph": + return ctx.tight ? {inline(node.children, ctx)} :

    {inline(node.children, ctx)}

    ; + case "heading": { + // The transcript's own titles sit above a reply's headings: `#` reads as a section, not the page. + const Tag = (["h2", "h3", "h4", "h5", "h6", "h6"] as const)[node.depth - 1]!; + return {inline(node.children, ctx)}; } - if (!line.trim()) { - i++; - continue; + case "code": + return ; + case "blockquote": + return
    {blocks(node.children, { ...ctx, tight: false })}
    ; + case "list": { + const items = node.children.map((item, i) => listItem(item, i, { ...ctx, tight: !node.spread && !item.spread })); + return node.ordered ?
      {items}
    :
      {items}
    ; } - const para: string[] = []; - while (i < lines.length && lines[i]!.trim() && !lines[i]!.startsWith("```") && !/^(#{1,3})\s/.test(lines[i]!) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]!)) para.push(lines[i++]!); - blocks.push(

    {inline(para.join(" "))}

    ); + case "thematicBreak": + return
    ; + case "table": { + const [head, ...body] = node.children; + const align = node.align ?? []; + const row = (r: TableRow, i: number, Cell: "th" | "td") => ( + {r.children.map((cell, c) => {inline(cell.children, ctx)})} + ); + return ( +
    + + {head && {row(head, 0, "th")}} + {body.length > 0 && {body.map((r, i) => row(r, i, "td"))}} +
    +
    + ); + } + case "html": + // Raw HTML is shown, never interpreted. + return

    {node.value}

    ; + case "footnoteDefinition": + return
    {node.label ?? node.identifier} {blocks(node.children, { ...ctx, tight: true })}
    ; + case "definition": + return null; + default: + return "children" in node ?
    {blocks((node as Parents).children as RootContent[], ctx)}
    : null; } - return
    {blocks}
    ; -}); +} + +function listItem(item: ListItem, key: number, ctx: Context): ReactNode { + const children = blocks(item.children, ctx); + if (typeof item.checked !== "boolean") return
  • {children}
  • ; + // A task list's boxes are a record of the text, not controls: they cannot be ticked. + return
  • {children}
  • ; +} + +function inline(nodes: PhrasingContent[], ctx: Context): ReactNode[] { + return nodes.map((node, key) => phrase(node, key, ctx)); +} + +function phrase(node: PhrasingContent, key: number, ctx: Context): ReactNode { + switch (node.type) { + case "text": + return node.value; + case "emphasis": + return {inline(node.children, ctx)}; + case "strong": + return {inline(node.children, ctx)}; + case "delete": + return {inline(node.children, ctx)}; + case "inlineCode": + return {node.value}; + case "break": + return
    ; + case "html": + return node.value; + case "link": + return link(node.url, inline(node.children, ctx), node, key, ctx); + case "linkReference": { + const target = ctx.definitions.get(node.identifier); + return target ? link(target.url, inline(node.children, ctx), node, key, ctx) : written(node, ctx); + } + case "image": + return image(node.url, node.alt, node, key, ctx); + case "imageReference": { + const target = ctx.definitions.get(node.identifier); + return target ? image(target.url, node.alt, node, key, ctx) : written(node, ctx); + } + case "footnoteReference": + return [{node.label ?? node.identifier}]; + default: + return written(node, ctx); + } +} -function inline(s: string): ReactNode[] { - return nodes(tokenizeInline(s)); +/** Links open in the system browser: main denies the new window and hands http(s) URLs to the OS. Other targets stay as written. */ +function link(url: string, children: ReactNode, node: Nodes, key: number, ctx: Context): ReactNode { + const href = safeHref(url); + if (!href) return written(node, ctx); + return {children}; } -/** Links open in the system browser: main denies the new window and hands http(s) URLs to the OS. */ -function nodes(tokens: InlineToken[]): ReactNode[] { - return tokens.map((t, k) => - t.type === "text" ? t.text - : t.type === "code" ? {t.text} - : t.type === "bold" ? {nodes(t.children)} - : {nodes(t.children)}, - ); +/** An image is a link to it, named by its alt text: nothing is fetched while reading. */ +function image(url: string, alt: string | null | undefined, node: Nodes, key: number, ctx: Context): ReactNode { + const href = safeHref(url); + if (!href) return written(node, ctx); + return {alt ? `Image: ${alt}` : "Image"}; } diff --git a/apps/desktop/src/renderer/components/transcript/CodeBlock.tsx b/apps/desktop/src/renderer/components/transcript/CodeBlock.tsx new file mode 100644 index 00000000..5ccf862c --- /dev/null +++ b/apps/desktop/src/renderer/components/transcript/CodeBlock.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { bridge } from "../../bridge"; + +/** + * A fenced code block in the transcript: its language, the code, and Copy. Copy puts the code on the + * clipboard through main and does nothing else; nothing in a reply runs from here. + */ +export function CodeBlock({ code, lang }: { code: string; lang?: string }) { + const [copied, setCopied] = useState(false); + const [failed, setFailed] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + const name = lang ? `${lang} code` : "code"; + const copy = async () => { + setFailed(false); + try { await bridge.invoke("clipboard:write", { text: code }); setCopied(true); } + catch { setFailed(true); } + }; + return ( +
    +
    + {lang || "text"} + +
    +
    {code}
    + {failed ? "Could not copy. Select the code to copy it." : copied ? `Copied ${name}` : ""} +
    + ); +} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 5d7c13d1..3e14ccac 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -530,11 +530,32 @@ a.thread-pr:hover { text-decoration: underline; } .msg.user { display: flex; flex-direction: column; align-items: flex-end; } .msg.user .bubble { max-width: 70%; padding: 8px 16px; border: 1px solid var(--bubble-border); border-radius: 12px; background: var(--bg-user-bubble); white-space: pre-wrap; overflow-wrap: anywhere; } .md p { margin: 0 0 12px; } -.md h2, .md h3, .md h4 { margin: 18px 0 8px; font-weight: var(--weight-semibold); } +.md h2, .md h3, .md h4, .md h5, .md h6 { margin: 18px 0 8px; font-weight: var(--weight-semibold); } +.md h5, .md h6 { font-size: var(--text-md); } .md ul, .md ol { margin: 0 0 12px; padding-left: 28px; } +.md li > ul, .md li > ol { margin-bottom: 0; } +.md li.task { list-style: none; } +.md li.task > input { margin: 0 6px 0 -20px; vertical-align: -1px; } +.md blockquote { margin: 0 0 12px; padding: 0 0 0 12px; border-left: 3px solid var(--border-strong); color: var(--text-3); } +.md hr { margin: 16px 0; border: 0; border-top: 1px solid var(--border-pane); } +.md .md-table { margin: 0 0 12px; overflow-x: auto; } +.md table { border-collapse: collapse; font-size: var(--text-sm); } +/* A cell is never narrower than about ten characters, and a long path or URL wraps inside it: in a + narrow transcript the table scrolls sideways in its box instead of squeezing one column to a word. */ +.md th, .md td { min-width: 10em; padding: 5px 10px; border: 1px solid var(--border-pane); text-align: left; vertical-align: top; overflow-wrap: anywhere; } +.md th { font-weight: var(--weight-semibold); background: var(--bg-sunken); } +.md .footnote { margin: 0 0 6px; color: var(--text-3); font-size: var(--text-sm); } +.md sup { font-size: 0.75em; } .md strong { font-weight: var(--weight-semibold); } .md code { padding: 1px 5px; border-radius: 5px; background: var(--bg-row-selected); font-size: 12.5px; } .md pre.code { margin: 8px 0 14px; padding: 12px 14px; overflow: auto; border: 1px solid var(--border-pane); border-radius: var(--radius-md); background: var(--bg-sunken); } +.md .code-block { position: relative; margin: 8px 0 14px; } +.md .code-block pre.code { margin: 0; padding-top: 30px; } +.md .code-head { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; height: 26px; padding: 0 8px 0 14px; color: var(--text-3); font-size: var(--text-xs); } +.md .code-copy { padding: 2px 8px; border: 1px solid transparent; border-radius: 5px; background: none; color: var(--text-3); font: inherit; cursor: pointer; } +.md .code-copy:hover { border-color: var(--border-pane); color: var(--text-1); } +.md .code-copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.md .code-copy-error { display: block; margin-top: 4px; color: var(--text-3); font-size: var(--text-xs); } .md pre.code code { padding: 0; background: none; } .md a, .tool-link { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; } .md a:hover, .tool-link:hover { text-decoration: underline; } diff --git a/apps/desktop/test/markdown-render.test.ts b/apps/desktop/test/markdown-render.test.ts new file mode 100644 index 00000000..d14e0fc4 --- /dev/null +++ b/apps/desktop/test/markdown-render.test.ts @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { createElement, type ComponentType } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import react from "@vitejs/plugin-react"; +import { createServer, type ViteDevServer } from "vite"; + +// The transcript's Markdown (#277): CommonMark with GitHub's extensions, rendered as React elements only. +const appDir = fileURLToPath(new URL("../../", import.meta.url)); +let server: ViteDevServer | undefined; +let Markdown: ComponentType<{ text: string }>; + +async function render(text: string): Promise { + if (!server) { + // Code blocks reach the clipboard through the bridge, which reads `window.modex` when it loads. + Object.assign(globalThis, { window: { modex: { platform: "web", invoke: async () => {}, onEvent: () => () => {}, onTerminalEvent: () => () => {} } } }); + server = await createServer({ configFile: false, root: appDir, plugins: [react()], logLevel: "error", appType: "custom", server: { middlewareMode: true, hmr: false, ws: false } }); + ({ Markdown } = await server.ssrLoadModule("/src/renderer/components/Markdown.tsx") as { Markdown: ComponentType<{ text: string }> }); + } + return renderToStaticMarkup(createElement(Markdown, { text })); +} + +/** The visible text of React's static markup: the characters outside tags, with the entities React writes decoded once. */ +function visible(html: string): string { + let text = ""; + let inTag = false; + for (const ch of html) { + if (inTag) inTag = ch !== ">"; + else if (ch === "<") inTag = true; + else text += ch; + } + const entities: Record = { "<": "<", ">": ">", """: '"', "'": "'", "&": "&" }; + return text.replace(/&(?:lt|gt|quot|#x27|amp);/g, (entity) => entities[entity]!); +} + +test.after(async () => { await server?.close(); }); + +test("tables, blockquotes, nested and task lists, emphasis and strikethrough render as their elements", async () => { + const html = await render([ + "| File | Lines |", "| :--- | ---: |", "| a.ts | 12 |", "| b.ts | 3 |", "", + "> Quoted **advice**", "", + "- one", " 1. inner first", " 2. inner second", "- two", "", + "- [x] done", "- [ ] todo", "", + "*soft* and ~~gone~~ and `code`", + ].join("\n")); + assert.match(html, /.*.*]*>File<\/th>.*]*>Lines<\/th>.*.*]*>a\.ts<\/td>.*]*>12<\/td>/s); + assert.match(html, /
    ]*>Lines<\/th>/); + assert.match(html, /
    .*Quoted advice<\/strong>.*<\/blockquote>/s); + assert.match(html, /