diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc8a89ae1..368fdbc1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, `
`) 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**, diff --git a/scripts/file-viewer-regression.ts b/scripts/file-viewer-regression.ts index fad38e6a75..238053f218 100644 --- a/scripts/file-viewer-regression.ts +++ b/scripts/file-viewer-regression.ts @@ -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 = "

\n \"Logo\"\n

\n\n

website · bold

\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"), ''); let workspace: string | undefined; @@ -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(" getComputedStyle(node).textAlign), "center"); + await markdown.getByRole("link", { name: "website", exact: true }).waitFor(); await page.waitForFunction(() => { const image = document.querySelector(".file-viewer-markdown img"); return image?.complete && image.naturalWidth === 240; diff --git a/src/components/ChatView.css b/src/components/ChatView.css index 1a94bcc438..e48210001c 100644 --- a/src/components/ChatView.css +++ b/src/components/ChatView.css @@ -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); diff --git a/src/components/FileViewer.tsx b/src/components/FileViewer.tsx index 25b2657026..9c52b80fdf 100644 --- a/src/components/FileViewer.tsx +++ b/src/components/FileViewer.tsx @@ -132,7 +132,7 @@ export function FileViewer({ path: asked, paneId, onClose, onOpen, keyboardActiv case "text": return text === null ?

{t("Opening…")}

: <> {markdown - ? {text} + ? {text} :
{text}
} {textTruncated && (textTotal ?? 0) <= TEXT_PREVIEW_BYTES ?

{t("Only part of this file is shown. Download it to read the rest.")}

: (textTotal ?? info.size) > TEXT_PREVIEW_BYTES &&

{t("Showing the first {shown} of {total}.", { shown: formatBytes(TEXT_PREVIEW_BYTES), total: formatBytes(textTotal ?? info.size) })}

} diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 19f4975748..5fa2eed2e1 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -107,7 +107,7 @@ function MermaidDiagram({ value }: { value: string }) { type MarkdownImageResolver = (src: string) => Promise; export const MarkdownImageContext = createContext(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(() => { @@ -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 {alt}; if (preview === null) return <>{fallback}; - if (preview.kind === "inline") return {alt}; + if (preview.kind === "inline") return {alt}; const message = t("This image is {size}; download it to view.", { size: formatBytes(preview.size) }); return interactive ? {message} : {message}; } @@ -156,7 +156,7 @@ function Inline({ nodes, interactive = true }: { nodes: InlineNode[]; interactiv case "image": { return imageResolver === null ? - : } />; + : } />; } case "strong": return ; case "em": return ; @@ -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

; + return

; case "paragraph": - return

{block.lines.map((line, lineIndex) => {lineIndex < block.lines.length - 1 &&
}
)}

; + return

{block.lines.map((line, lineIndex) => {lineIndex < block.lines.length - 1 &&
}
)}

; case "list": return ; case "blockquote": return
; case "code": return block.language === "mermaid" @@ -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
; } diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index dfded8d848..84d12a4088 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,4 +1,5 @@ import { fileUriPath } from "./terminalFileLinks.ts"; +import { parseHtmlBlock, startsHtml } from "./markdownHtml.ts"; export type InlineNode = | { type: "text"; value: string } @@ -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 `` */ width?: number } /** a link to a local file (`[report](/repo/out/REPORT.md)`): its label opens the path */ | { type: "file"; path: string; children: InlineNode[] }; @@ -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 } @@ -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); } @@ -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; @@ -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] ?? "") }); @@ -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; } @@ -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; } diff --git a/src/lib/markdownHtml.test.ts b/src/lib/markdownHtml.test.ts new file mode 100644 index 0000000000..6fe172ee52 --- /dev/null +++ b/src/lib/markdownHtml.test.ts @@ -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(" 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([ + "", + "

Title & more

", + "", + "
one
two bold it kept
", + "", + "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("

� � � ©

", 0, true); + expect(blocks[0]?.type === "paragraph" && text(blocks[0].lines[0] ?? [])).toBe("\ufffd \ufffd \ufffd ©"); + const after = parseMarkdown(["", "# Heading", "", "", "```html", "kept", "```"].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: "kept" }); + }); + + it("keeps the spaces and breaks inside links and emphasis, and the centering of a wrapper", () => { + const [broken] = parseMarkdown("

one
two

", 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("

one two

", 0, true); + expect(spaced?.type === "paragraph" && text(spaced.lines[0] ?? [])).toBe("one two"); + const wrapped = parseMarkdown("

Title

Subtitle

\n

inner

\n

plain

", 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("

bad guide

", 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("

hi

"); + expect(block?.type === "paragraph" && text(block.lines.flat())).toBe("

hi

"); + }); +}); diff --git a/src/lib/markdownHtml.ts b/src/lib/markdownHtml.ts new file mode 100644 index 0000000000..8dee196339 --- /dev/null +++ b/src/lib/markdownHtml.ts @@ -0,0 +1,157 @@ +import type { InlineNode, MarkdownBlock } from "./markdown.ts"; +import { markdownFileTarget, safeMarkdownHref, webLikeHref } from "./markdown.ts"; + +/** + * The HTML a README carries (a centered logo, a row of badges, `
`), read into the blocks + * the markdown view already draws: never markup put on the page. Only what reads as text comes + * through: paragraphs and headings (centered when `align="center"` says so), links, images, + * emphasis, code and line breaks. Any other tag is left out and its text kept; a comment, a script + * and a style are dropped. + * An HTML block starts with one of these tags or a comment and ends at a blank line (CommonMark). + */ +const BLOCK_TAGS = new Set(["p", "div", "center", "details", "summary", "picture", "h1", "h2", "h3", "h4", "h5", "h6", "table", "tr", "td", "th", "tbody", "thead", "ul", "ol", "li", "blockquote", "figure", "figcaption", "section"]); +const START_TAGS = new Set([...BLOCK_TAGS, "img", "a", "br", "source", "b", "strong", "em", "i", "code", "kbd", "sub", "sup", "span", "hr"]); + +/** Whether the line opens an HTML block the file viewer reads. */ +export function startsHtml(line: string): boolean { + const match = /^ {0,3}<(!--|\/?([a-zA-Z][a-zA-Z0-9]*)(?=[\s/>]|$))/.exec(line); + return match !== null && (match[1] === "!--" || START_TAGS.has((match[2] ?? "").toLowerCase())); +} + +const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", middot: "·", copy: "©", reg: "®", mdash: "—", ndash: "–", hellip: "…" }; +/** a numeric reference outside Unicode, a surrogate or zero (`�`) is the replacement character, never a throw */ +function codePoint(value: number): string { + return Number.isInteger(value) && value > 0 && value <= 0x10ffff && !(value >= 0xd800 && value <= 0xdfff) ? String.fromCodePoint(value) : "\ufffd"; +} +function decode(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => { + if (name.startsWith("#x") || name.startsWith("#X")) return codePoint(Number.parseInt(name.slice(2), 16)); + if (name.startsWith("#")) return codePoint(Number.parseInt(name.slice(1), 10)); + return ENTITIES[name.toLowerCase()] ?? whole; + }); +} + +function attribute(attributes: string, name: string): string | null { + const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i").exec(attributes); + return match === null ? null : decode(match[1] ?? match[2] ?? match[3] ?? ""); +} + +type Holder = { kind: "link"; href: string | null; file: string | null; children: InlineNode[] } | { kind: "strong" | "em"; children: InlineNode[] }; + +/** Reads the lines of one HTML block, from `index`, into blocks; `next` is the line after it. */ +export function parseHtmlBlock(lines: readonly string[], index: number): { blocks: MarkdownBlock[]; next: number } { + let next = index; + const body: string[] = []; + // a comment runs to the line that ends it, blank lines and all, and no further: the heading or + // the fence on the next line is markdown again; anything else runs to the first blank line + if (/^ {0,3}")) break; } + } else { + while (next < lines.length && (lines[next] ?? "").trim() !== "") body.push(lines[next++] ?? ""); + } + return { blocks: readHtml(body.join("\n")), next }; +} + +function readHtml(source: string): MarkdownBlock[] { + const blocks: MarkdownBlock[] = []; + let lines: InlineNode[][] = [[]]; + let align: "center" | undefined; + // the alignment of each open block, outermost first: `

…

…

` centers both + const aligns: Array<"center" | undefined> = []; + let heading: 1 | 2 | 3 | 4 | 5 | 6 | null = null; + const holders: Holder[] = []; + const target = (): InlineNode[] => holders.at(-1)?.children ?? lines[lines.length - 1]!; + const flush = (): void => { + while (holders.length > 0) close(holders[holders.length - 1]!.kind); + const kept = lines.map(trimLine).filter((line, at, all) => line.length > 0 || (at > 0 && at < all.length - 1)); + if (kept.length > 0) { + if (heading !== null) blocks.push({ type: "heading", level: heading, content: kept.flatMap((line, at) => at === 0 ? line : [{ type: "text", value: " " }, ...line]), ...(align ? { align } : {}) }); + else blocks.push({ type: "paragraph", lines: kept, ...(align ? { align } : {}) }); + } + lines = [[]]; + heading = null; + }; + const push = (node: InlineNode): void => { + const into = target(); + const last = into[into.length - 1]; + if (node.type === "text" && last?.type === "text") into[into.length - 1] = { type: "text", value: last.value + node.value }; + else into.push(node); + }; + const close = (kind: Holder["kind"]): void => { + const at = holders.map((holder) => holder.kind).lastIndexOf(kind); + if (at === -1) return; + while (holders.length > at) { + const holder = holders.pop()!; + const children = holder.children; + if (holder.kind === "link") { + if (holder.href !== null) push({ type: "link", href: holder.href, children }); + else if (holder.file !== null) push({ type: "file", path: holder.file, children }); + else for (const child of children) push(child); + } else push({ type: holder.kind, children }); + } + }; + + const token = /|$)|<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:[^>"']|"[^"]*"|'[^']*')*)>|([^<]+)| 0) push({ type: "text", value }); + continue; + } + const name = rawName.toLowerCase(); + if (BLOCK_TAGS.has(name)) { + flush(); + if (closing) { aligns.pop(); align = aligns.at(-1); continue; } + align = /^center$/i.test(attribute(attributes, "align") ?? "") || name === "center" ? "center" : aligns.at(-1); + aligns.push(align); + if (/^h[1-6]$/.test(name)) heading = Number(name[1]) as 1 | 2 | 3 | 4 | 5 | 6; + continue; + } + if (name === "br") { + // a break inside a link or an emphasis: the open ones end on this line and go on on the next + const reopened: Holder[] = holders.map((holder) => holder.kind === "link" ? { kind: "link", href: holder.href, file: holder.file, children: [] } : { kind: holder.kind, children: [] }); + while (holders.length > 0) close(holders[holders.length - 1]!.kind); + lines.push([]); + holders.push(...reopened); + continue; + } + if (name === "hr") { flush(); blocks.push({ type: "hr" }); continue; } + if (name === "img") { + const src = attribute(attributes, "src"); + const alt = attribute(attributes, "alt") ?? ""; + const width = Number.parseInt(attribute(attributes, "width") ?? "", 10); + if (src) push({ type: "image", alt, src, fallback: alt ? [{ type: "text", value: alt }] : [], ...(width > 0 ? { width } : {}) }); + continue; + } + if (name === "a") { + if (closing) { close("link"); continue; } + const raw = attribute(attributes, "href") ?? ""; + const href = safeMarkdownHref(raw) ?? webLikeHref(raw); + holders.push({ kind: "link", href, file: href === null ? markdownFileTarget(raw) : null, children: [] }); + continue; + } + if (name === "strong" || name === "b") { if (closing) close("strong"); else holders.push({ kind: "strong", children: [] }); continue; } + if (name === "em" || name === "i") { if (closing) close("em"); else holders.push({ kind: "em", children: [] }); continue; } + // code and kbd hold text; anything else (span, sub, source…) is left out, its text kept + } + flush(); + return blocks; +} + +/** A line's text without the spaces HTML's indentation left at its ends. */ +function trimLine(line: InlineNode[]): InlineNode[] { + const out = [...line]; + const first = out[0]; + if (first?.type === "text") { const value = first.value.trimStart(); if (value === "") out.shift(); else out[0] = { type: "text", value }; } + const last = out[out.length - 1]; + if (last?.type === "text") { const value = last.value.trimEnd(); if (value === "") out.pop(); else out[out.length - 1] = { type: "text", value }; } + return out; +}