Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa

## [Unreleased]

### Fixed
- The file viewer draws the HTML a README is written with (a centered logo, a row of badges and
links, `<details>`) as markdown instead of showing its tags as text: paragraphs and headings,
centered where `align="center"` says so, links, images with their width, emphasis and line breaks.
Other tags are left out with their text kept; comments, scripts and styles are dropped. Images from
other sites still show their alt text, as before. An agent's chat message keeps HTML as text.

### Added
- **Settings → Composer → Send queued messages automatically** (on by default). Turned off, a
message sent while the agent works waits under the message box until you press **Send now**,
Expand Down
7 changes: 6 additions & 1 deletion scripts/file-viewer-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ chmodSync(standIn, 0o755);
copyFileSync(join(import.meta.dir, "fixtures", "file-preview.webm"), join(root, "preview.webm"));
writeFileSync(join(root, "notes.txt"), "File preview history regression\n");
const markdownPath = join(root, "preview #1 & 한글.MD");
const markdownSource = "# Markdown preview\n\n한글 문서도 새 탭에서 읽을 수 있습니다.\n\n| Item | State |\n| --- | --- |\n| Preview | Ready |\n\n- [x] Rendered\n\n```ts\nconst answer = 42;\n```\n\n![Local image](./preview.svg)\n";
const markdownSource = "<p align=\"center\">\n <img src=\"./preview.svg\" alt=\"Logo\" width=\"64\">\n</p>\n\n<p align=\"center\"><a href=\"https://example.com/\">website</a> &middot; <strong>bold</strong></p>\n\n# Markdown preview\n\n한글 문서도 새 탭에서 읽을 수 있습니다.\n\n| Item | State |\n| --- | --- |\n| Preview | Ready |\n\n- [x] Rendered\n\n```ts\nconst answer = 42;\n```\n\n![Local image](./preview.svg)\n";
writeFileSync(markdownPath, markdownSource);
writeFileSync(join(root, "preview.svg"), '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"><rect width="240" height="80" fill="teal"/></svg>');
let workspace: string | undefined;
Expand Down Expand Up @@ -363,6 +363,11 @@ try {
const markdown = page.locator(".file-viewer-markdown");
await markdown.getByRole("heading", { name: "Markdown preview", exact: true }).waitFor();
await markdown.getByRole("cell", { name: "Ready", exact: true }).waitFor();
// a README's HTML header is drawn, centered, never shown as tags
assert.equal((await markdown.innerText()).includes("<p"), false, "HTML tags are not shown as text");
assert.equal(await markdown.locator('.markdown-center img[alt="Logo"]').getAttribute("width"), "64");
assert.equal(await markdown.locator(".markdown-center").first().evaluate((node) => getComputedStyle(node).textAlign), "center");
await markdown.getByRole("link", { name: "website", exact: true }).waitFor();
await page.waitForFunction(() => {
const image = document.querySelector<HTMLImageElement>(".file-viewer-markdown img");
return image?.complete && image.naturalWidth === 240;
Expand Down
5 changes: 5 additions & 0 deletions src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@
height: auto;
}

/* a README's centered logo and badges (lib/markdownHtml.ts) */
.markdown .markdown-center {
text-align: center;
}

.markdown-mermaid-frame {
position: relative;
margin-bottom: var(--space-3);
Expand Down
2 changes: 1 addition & 1 deletion src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv
case "text":
return text === null ? <p className="file-viewer-note">{t("Opening…")}</p> : <>
{markdown
? <MarkdownImageContext.Provider value={imagePreview}><Markdown className="file-viewer-markdown">{text}</Markdown></MarkdownImageContext.Provider>
? <MarkdownImageContext.Provider value={imagePreview}><Markdown className="file-viewer-markdown" html>{text}</Markdown></MarkdownImageContext.Provider>
: <pre className="file-viewer-text">{text}</pre>}
{textTruncated && (textTotal ?? 0) <= TEXT_PREVIEW_BYTES ? <p className="file-viewer-note">{t("Only part of this file is shown. Download it to read the rest.")}</p>
: (textTotal ?? info.size) > TEXT_PREVIEW_BYTES && <p className="file-viewer-note">{t("Showing the first {shown} of {total}.", { shown: formatBytes(TEXT_PREVIEW_BYTES), total: formatBytes(textTotal ?? info.size) })}</p>}
Expand Down
15 changes: 8 additions & 7 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function MermaidDiagram({ value }: { value: string }) {
type MarkdownImageResolver = (src: string) => Promise<MarkdownImagePreview | null>;
export const MarkdownImageContext = createContext<MarkdownImageResolver | null>(null);

function MarkdownImage({ src, alt, fallback, resolve, interactive }: { src: string; alt: string; fallback: ReactNode; resolve: MarkdownImageResolver; interactive: boolean }) {
function MarkdownImage({ src, alt, width, fallback, resolve, interactive }: { src: string; alt: string; width?: number; fallback: ReactNode; resolve: MarkdownImageResolver; interactive: boolean }) {
const t = useT();
const [loaded, setLoaded] = useState<{ src: string; resolve: MarkdownImageResolver; preview: MarkdownImagePreview | null } | null>(null);
useEffect(() => {
Expand All @@ -121,7 +121,7 @@ function MarkdownImage({ src, alt, fallback, resolve, interactive }: { src: stri
const preview = loaded?.src === src && loaded.resolve === resolve ? loaded.preview : undefined;
if (preview === undefined) return <span>{alt}</span>;
if (preview === null) return <>{fallback}</>;
if (preview.kind === "inline") return <img className="markdown-image" src={preview.url} alt={alt} loading="lazy" />;
if (preview.kind === "inline") return <img className="markdown-image" src={preview.url} alt={alt} width={width} loading="lazy" />;
const message = t("This image is {size}; download it to view.", { size: formatBytes(preview.size) });
return interactive ? <a href={preview.downloadUrl} download>{message}</a> : <span>{message}</span>;
}
Expand Down Expand Up @@ -156,7 +156,7 @@ function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactiv
case "image": {
return imageResolver === null
? <Inline key={key} nodes={node.fallback} interactive={interactive} />
: <MarkdownImage key={key} src={node.src} alt={node.alt} resolve={imageResolver} interactive={interactive} fallback={<Inline nodes={node.fallback} interactive={interactive} />} />;
: <MarkdownImage key={key} src={node.src} alt={node.alt} width={node.width} resolve={imageResolver} interactive={interactive} fallback={<Inline nodes={node.fallback} interactive={interactive} />} />;
}
case "strong": return <strong key={key}><Inline nodes={node.children} interactive={interactive} /></strong>;
case "em": return <em key={key}><Inline nodes={node.children} interactive={interactive} /></em>;
Expand Down Expand Up @@ -250,9 +250,9 @@ function Blocks({ blocks }: { blocks: MarkdownBlock[] }) {
// agent headings are h3 whatever the agent wrote: the app's own h1/h2 (Brand, the dialog
// titles) stay the outline above them. The level rides on a class, so the stylesheet
// keeps drawing each level as it did (ChatView.css `.markdown h1`...`.markdown h6`).
return <h3 key={key} className={`markdown-h${block.level}`}><Inline nodes={block.content} /></h3>;
return <h3 key={key} className={`markdown-h${block.level}${block.align ? " markdown-center" : ""}`}><Inline nodes={block.content} /></h3>;
case "paragraph":
return <p key={key}>{block.lines.map((line, lineIndex) => <span key={lineIndex}><Inline nodes={line} />{lineIndex < block.lines.length - 1 && <br />}</span>)}</p>;
return <p key={key} className={block.align ? "markdown-center" : undefined}>{block.lines.map((line, lineIndex) => <span key={lineIndex}><Inline nodes={line} />{lineIndex < block.lines.length - 1 && <br />}</span>)}</p>;
case "list": return <List key={key} block={block} />;
case "blockquote": return <blockquote key={key}><Blocks blocks={block.blocks} /></blockquote>;
case "code": return block.language === "mermaid"
Expand All @@ -271,7 +271,8 @@ function Blocks({ blocks }: { blocks: MarkdownBlock[] }) {
})}</>;
}

export function Markdown({ children, className }: { children: string; className?: string }) {
const blocks = useMemo(() => parseMarkdown(children), [children]);
/** `html`: a markdown file's HTML blocks (a README's centered logo and badges) are drawn as markdown */
export function Markdown({ children, className, html = false }: { children: string; className?: string; html?: boolean }) {
const blocks = useMemo(() => parseMarkdown(children, 0, html), [children, html]);
return <div className={className === undefined ? "markdown" : `markdown ${className}`}><Blocks blocks={blocks} /></div>;
}
26 changes: 18 additions & 8 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fileUriPath } from "./terminalFileLinks.ts";
import { parseHtmlBlock, startsHtml } from "./markdownHtml.ts";

export type InlineNode =
| { type: "text"; value: string }
Expand All @@ -7,7 +8,7 @@ export type InlineNode =
| { type: "strong" | "em" | "del"; children: InlineNode[] }
| { type: "link"; href: string; children: InlineNode[] }
/** `![alt](src)`; `fallback` is what a surface that draws no images shows after the `!` */
| { type: "image"; alt: string; src: string; fallback: InlineNode[] }
| { type: "image"; alt: string; src: string; fallback: InlineNode[]; /** CSS pixels, from an HTML `<img width>` */ width?: number }
/** a link to a local file (`[report](/repo/out/REPORT.md)`): its label opens the path */
| { type: "file"; path: string; children: InlineNode[] };

Expand All @@ -29,8 +30,9 @@ export interface ListBlock {

export type MarkdownBlock =
| { type: "math"; value: string }
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; content: InlineNode[] }
| { type: "paragraph"; lines: InlineNode[][] }
/** `align`: centered by the HTML a README wrote it in (lib/markdownHtml.ts) */
| { type: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6; content: InlineNode[]; align?: "center" }
| { type: "paragraph"; lines: InlineNode[][]; align?: "center" }
| ListBlock
| { type: "blockquote"; blocks: MarkdownBlock[] }
| { type: "code"; language: string; value: string }
Expand Down Expand Up @@ -259,9 +261,9 @@ function parseTable(lines: string[], start: number, within = 0): { block: Markdo
}


function startsBlock(lines: string[], index: number): boolean {
function startsBlock(lines: string[], index: number, html = false): boolean {
const line = lines[index] ?? "";
return /^\s*\\\[/.test(line) || /^\s{0,3}```/.test(line) || /^#{1,6}\s+/.test(line) || /^\s*>/.test(line) || /^(?:\s*[-*_]){3,}\s*$/.test(line) || listLine.test(line)
return (html && startsHtml(line)) || /^\s*\\\[/.test(line) || /^\s{0,3}```/.test(line) || /^#{1,6}\s+/.test(line) || /^\s*>/.test(line) || /^(?:\s*[-*_]){3,}\s*$/.test(line) || listLine.test(line)
|| startsTable(lines, index);
}

Expand Down Expand Up @@ -368,7 +370,8 @@ export function foldCode(value: string): { head: string; lines: number } | null
/** Quotes nest by one call each: past this many levels, what is left is read as plain lines. */
const MAX_QUOTE_DEPTH = 32;

export function parseMarkdown(source: string, depth = 0): MarkdownBlock[] {
/** `html`: a file's markdown, whose HTML blocks are read as markdown (lib/markdownHtml.ts); an agent's text keeps them as text */
export function parseMarkdown(source: string, depth = 0, html = false): MarkdownBlock[] {
const lines = source.replace(/\r\n?/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
Expand Down Expand Up @@ -419,6 +422,13 @@ export function parseMarkdown(source: string, depth = 0): MarkdownBlock[] {
unclosedMath = next;
}

if (html && startsHtml(line)) {
const read = parseHtmlBlock(lines, index);
blocks.push(...read.blocks);
index = read.next;
continue;
}

const heading = /^(#{1,6})\s+(.+)$/s.exec(line);
if (heading !== null) {
blocks.push({ type: "heading", level: (heading[1] ?? "#").length as 1 | 2 | 3 | 4 | 5 | 6, content: parseInline(heading[2] ?? "") });
Expand All @@ -436,7 +446,7 @@ export function parseMarkdown(source: string, depth = 0): MarkdownBlock[] {
const quoted: string[] = [];
while (index < lines.length && /^\s*>/.test(lineAt(lines, index))) quoted.push(lineAt(lines, index++).replace(/^\s*>\s?/, ""));
// a line of thousands of ">" (text nobody wrote by hand) would otherwise overflow the stack
blocks.push({ type: "blockquote", blocks: depth < MAX_QUOTE_DEPTH ? parseMarkdown(quoted.join("\n"), depth + 1)
blocks.push({ type: "blockquote", blocks: depth < MAX_QUOTE_DEPTH ? parseMarkdown(quoted.join("\n"), depth + 1, html)
: [{ type: "paragraph", lines: quoted.map((quotedLine) => parseInline(quotedLine)) }] });
continue;
}
Expand All @@ -456,7 +466,7 @@ export function parseMarkdown(source: string, depth = 0): MarkdownBlock[] {
}

const paragraph: InlineNode[][] = [];
while (index < lines.length && lineAt(lines, index).trim() !== "" && (paragraph.length === 0 || !startsBlock(lines, index))) {
while (index < lines.length && lineAt(lines, index).trim() !== "" && (paragraph.length === 0 || !startsBlock(lines, index, html))) {
paragraph.push(parseInline(lineAt(lines, index)));
index += 1;
}
Expand Down
78 changes: 78 additions & 0 deletions src/lib/markdownHtml.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { parseMarkdown, type InlineNode, type MarkdownBlock } from "./markdown.ts";

const text = (nodes: InlineNode[]): string => nodes.map((node) => node.type === "text" || node.type === "code" || node.type === "math" ? node.value
: node.type === "image" ? `[img ${node.alt}]` : text(node.children)).join("");
const shown = (blocks: MarkdownBlock[]): string => JSON.stringify(blocks);

describe("HTML blocks in a markdown file", () => {
it("reads the README's header as centered markdown, never as tag text", () => {
const readme = readFileSync(join(import.meta.dir, "../../README.md"), "utf8");
const head = readme.slice(0, readme.indexOf("\n## "));
const blocks = parseMarkdown(head, 0, true);
expect(shown(blocks)).not.toContain("<p");
expect(shown(blocks)).not.toContain("<a ");
expect(shown(blocks)).not.toContain("<img");
const logo = blocks.find((block) => block.type === "paragraph" && block.lines.flat().some((node) => node.type === "image"));
expect(logo).toMatchObject({ type: "paragraph", align: "center" });
const image = logo?.type === "paragraph" ? logo.lines.flat().find((node) => node.type === "image") : undefined;
expect(image).toMatchObject({ type: "image", src: "public/icons/icon-192.png", alt: "herdr web ui", width: 100 });
const languages = blocks.find((block) => block.type === "paragraph" && text(block.lines.flat()).includes("English"));
expect(languages?.type === "paragraph" && languages.lines.flat().some((node) => node.type === "file" && node.path === "README.ko.md")).toBe(true);
const site = blocks.find((block) => block.type === "paragraph" && text(block.lines.flat()).includes("website"));
expect(site?.type === "paragraph" && site.lines.flat().some((node) => node.type === "link" && node.href === "https://herdrweb.dev/")).toBe(true);
});

it("keeps text, drops comments and unknown tags, and reads headings, breaks, emphasis and entities", () => {
const blocks = parseMarkdown([
"<!-- a note",
"",
"still the note -->",
"<h2 align=\"center\">Title &amp; more</h2>",
"",
"<div>one<br>two <b>bold</b> <i>it</i> <span class=\"x\">kept</span> <script>alert(1)</script></div>",
"",
"after **markdown**",
].join("\n"), 0, true);
expect(blocks[0]).toMatchObject({ type: "heading", level: 2, align: "center" });
expect(blocks[0]?.type === "heading" && text(blocks[0].content)).toBe("Title & more");
expect(blocks[1]?.type === "paragraph" && blocks[1].lines.map(text)).toEqual(["one", "two bold it kept"]);
expect(blocks[1]?.type === "paragraph" && blocks[1].lines[1]?.some((node) => node.type === "strong")).toBe(true);
expect(blocks[2]?.type === "paragraph" && blocks[2].lines[0]?.some((node) => node.type === "strong")).toBe(true);
expect(blocks).toHaveLength(3);
});

it("never throws on a numeric reference outside Unicode, and ends a comment where it ends", () => {
const blocks = parseMarkdown("<p>&#1114112; &#xD800; &#0; &#169;</p>", 0, true);
expect(blocks[0]?.type === "paragraph" && text(blocks[0].lines[0] ?? [])).toBe("\ufffd \ufffd \ufffd ©");
const after = parseMarkdown(["<!-- note -->", "# Heading", "", "<!-- a", "b -->", "```html", "<span>kept</span>", "```"].join("\n"), 0, true);
expect(after).toHaveLength(2);
expect(after[0]).toMatchObject({ type: "heading", level: 1 });
expect(after[1]).toMatchObject({ type: "code", language: "html", value: "<span>kept</span>" });
});

it("keeps the spaces and breaks inside links and emphasis, and the centering of a wrapper", () => {
const [broken] = parseMarkdown("<p><strong>one<br>two</strong></p>", 0, true);
expect(broken?.type === "paragraph" && broken.lines.map(text)).toEqual(["one", "two"]);
expect(broken?.type === "paragraph" && broken.lines.every((line) => line.some((node) => node.type === "strong"))).toBe(true);
const [spaced] = parseMarkdown("<p><a href=\"https://example.com/\"><b>one</b> <b>two</b></a></p>", 0, true);
expect(spaced?.type === "paragraph" && text(spaced.lines[0] ?? [])).toBe("one two");
const wrapped = parseMarkdown("<center><h1>Title</h1><p>Subtitle</p></center>\n<div align=\"center\"><p>inner</p></div>\n<p>plain</p>", 0, true);
expect(wrapped.map((block) => [block.type, block.type === "heading" || block.type === "paragraph" ? block.align ?? null : null])).toEqual([["heading", "center"], ["paragraph", "center"], ["paragraph", "center"], ["paragraph", null]]);
});

it("links only to safe addresses and files", () => {
const [block] = parseMarkdown("<p><a href=\"javascript:alert(1)\">bad</a> <a href=\"docs/guide.md#x\">guide</a></p>", 0, true);
const nodes = block?.type === "paragraph" ? block.lines.flat() : [];
expect(nodes.some((node) => node.type === "link")).toBe(false);
expect(nodes.find((node) => node.type === "file")).toMatchObject({ path: "docs/guide.md" });
expect(text(nodes)).toBe("bad guide");
});

it("is text in an agent's message", () => {
const [block] = parseMarkdown("<p align=\"center\">hi</p>");
expect(block?.type === "paragraph" && text(block.lines.flat())).toBe("<p align=\"center\">hi</p>");
});
});
Loading
Loading