diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 7265e8b6043..1e337f628c8 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -15,6 +15,7 @@ class FakeText { class FakeElement { readonly nodeType = ELEMENT_NODE; readonly childNodes: Array = []; + parentElement: FakeElement | null = null; readonly classList = { contains: (name: string) => this.classNames.includes(name), }; @@ -33,10 +34,24 @@ class FakeElement { } append(...children: Array): this { + for (const child of children) { + if (child instanceof FakeElement) child.parentElement = this; + } this.childNodes.push(...children); return this; } + hasChildNodes(): boolean { + return this.childNodes.length > 0; + } + + remove(): void { + if (!this.parentElement) return; + const index = this.parentElement.childNodes.indexOf(this); + if (index >= 0) this.parentElement.childNodes.splice(index, 1); + this.parentElement = null; + } + getAttribute(): string | null { return null; } @@ -44,10 +59,28 @@ class FakeElement { hasAttribute(): boolean { return false; } + + closest(): FakeElement | null { + return null; + } + + querySelector(): FakeElement | null { + return null; + } + + querySelectorAll(selector: string): FakeElement[] { + const matches: FakeElement[] = []; + for (const child of this.childNodes) { + if (!(child instanceof FakeElement)) continue; + if (child.tagName === selector.toUpperCase()) matches.push(child); + matches.push(...child.querySelectorAll(selector)); + } + return matches; + } } -function asNode(element: FakeElement): Node { - return element as unknown as Node; +function asElement(element: FakeElement): Element { + return element as unknown as Element; } function shikiCodeLine(text: string): FakeElement { @@ -72,7 +105,7 @@ describe("serializeRenderedMarkdownFragment", () => { ); const container = new FakeElement("DIV").append(paragraph); - expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe("run `git status` first"); }); it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { @@ -81,7 +114,7 @@ describe("serializeRenderedMarkdownFragment", () => { ); const container = new FakeElement("DIV").append(code); - expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe( "git show-ref --verify refs/remotes/origin/opt/deploy/dev", ); }); @@ -90,6 +123,45 @@ describe("serializeRenderedMarkdownFragment", () => { const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); const container = new FakeElement("DIV").append(code); - expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe("first line\nsecond line"); + }); + + it("keeps a code-only selection plain when its cloned range contains pre wrappers", () => { + const container = new FakeElement("DIV").append( + new FakeElement("PRE").append(new FakeText("npx vp pack --watch")), + new FakeElement("PRE"), + ); + + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe("npx vp pack --watch"); + }); + + it("preserves an empty rendered code block", () => { + const container = new FakeElement("DIV").append( + new FakeElement("PRE").append(new FakeElement("CODE")), + ); + + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe("```\n\n```"); + }); + + it("preserves fences when a selection includes prose and a code block", () => { + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Run this:")), + new FakeElement("PRE").append(new FakeText("npx vp pack --watch")), + ); + + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe( + "Run this:\n\n```\nnpx vp pack --watch\n```", + ); + }); + + it("preserves non-text content selected with a code block", () => { + const container = new FakeElement("DIV").append( + new FakeElement("PRE").append(new FakeText("npx vp pack --watch")), + new FakeElement("HR"), + ); + + expect(serializeRenderedMarkdownFragment(asElement(container))).toBe( + "```\nnpx vp pack --watch\n```\n\n---", + ); }); }); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 069d161a188..52174056de1 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -267,8 +267,17 @@ function tidyMarkdown(markdown: string): string { .trim(); } -export function serializeRenderedMarkdownFragment(container: Node): string { - return tidyMarkdown(serializeChildren(container)); +export function serializeRenderedMarkdownFragment(container: Element): string { + for (const pre of container.querySelectorAll("pre")) { + if (!pre.hasChildNodes()) pre.remove(); + } + const markdown = tidyMarkdown(serializeChildren(container)); + const codeBlocks = [...container.querySelectorAll("pre")].filter((pre) => + pre.textContent?.trim(), + ); + const codeBlock = codeBlocks.length === 1 ? codeBlocks[0] : null; + if (!codeBlock || markdown !== serializeCodeBlock(codeBlock).trim()) return markdown; + return (codeBlock.textContent ?? "").replace(/\n$/, ""); } export function serializeTableElementToMarkdown(table: Element): string {