| ... | @@ -38,6 +38,10 @@ class FakeElement { | ... | @@ -38,6 +38,10 @@ class FakeElement { |
| 38 | return this.childNodes.map((child) => child.textContent).join(""); | 38 | return this.childNodes.map((child) => child.textContent).join(""); |
| 39 | } | 39 | } |
| 40 | | 40 | |
| | 41 | get children(): ReadonlyArray<FakeElement> { |
| | 42 | return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement); |
| | 43 | } |
| | 44 | |
| 41 | append(...children: Array<FakeElement | FakeText>): this { | 45 | append(...children: Array<FakeElement | FakeText>): this { |
| 42 | this.childNodes.push(...children); | 46 | this.childNodes.push(...children); |
| 43 | return this; | 47 | return this; |
| ... | @@ -50,6 +54,34 @@ class FakeElement { | ... | @@ -50,6 +54,34 @@ class FakeElement { |
| 50 | hasAttribute(name: string): boolean { | 54 | hasAttribute(name: string): boolean { |
| 51 | return Object.hasOwn(this.attributes, name); | 55 | return Object.hasOwn(this.attributes, name); |
| 52 | } | 56 | } |
| | 57 | |
| | 58 | closest(): FakeElement | null { |
| | 59 | return null; |
| | 60 | } |
| | 61 | |
| | 62 | /** Supports only the selectors markdown-clipboard actually asks for. */ |
| | 63 | querySelector(selector: string): FakeElement | null { |
| | 64 | const childOnly = selector.startsWith(":scope > "); |
| | 65 | const target = childOnly ? selector.slice(":scope > ".length) : selector; |
| | 66 | const matches = (element: FakeElement): boolean => { |
| | 67 | if (target === 'input[type="checkbox"]') { |
| | 68 | return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; |
| | 69 | } |
| | 70 | return element.tagName === target.toUpperCase(); |
| | 71 | }; |
| | 72 | const search = (parent: FakeElement): FakeElement | null => { |
| | 73 | for (const child of parent.childNodes) { |
| | 74 | if (!(child instanceof FakeElement)) continue; |
| | 75 | if (matches(child)) return child; |
| | 76 | if (!childOnly) { |
| | 77 | const nested = search(child); |
| | 78 | if (nested) return nested; |
| | 79 | } |
| | 80 | } |
| | 81 | return null; |
| | 82 | }; |
| | 83 | return search(this); |
| | 84 | } |
| 53 | } | 85 | } |
| 54 | | 86 | |
| 55 | function asNode(element: FakeElement): Node { | 87 | function asNode(element: FakeElement): Node { |
| ... | @@ -61,6 +93,21 @@ function shikiCodeLine(text: string): FakeElement { | ... | @@ -61,6 +93,21 @@ function shikiCodeLine(text: string): FakeElement { |
| 61 | return new FakeElement("SPAN", ["line"]).append(token); | 93 | return new FakeElement("SPAN", ["line"]).append(token); |
| 62 | } | 94 | } |
| 63 | | 95 | |
| | 96 | /** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */ |
| | 97 | function renderedCodeBlock(lines: ReadonlyArray<string>): FakeElement { |
| | 98 | const code = new FakeElement("CODE"); |
| | 99 | lines.forEach((line, index) => { |
| | 100 | if (index > 0) code.append(new FakeText("\n")); |
| | 101 | code.append(shikiCodeLine(line)); |
| | 102 | }); |
| | 103 | return new FakeElement("DIV", ["chat-markdown-codeblock"]).append( |
| | 104 | new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append( |
| | 105 | new FakeText("sh"), |
| | 106 | ), |
| | 107 | new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)), |
| | 108 | ); |
| | 109 | } |
| | 110 | |
| 64 | describe("serializeRenderedMarkdownFragment", () => { | 111 | describe("serializeRenderedMarkdownFragment", () => { |
| 65 | beforeEach(() => { | 112 | beforeEach(() => { |
| 66 | vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); | 113 | vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); |
| ... | @@ -125,6 +172,82 @@ describe("serializeRenderedMarkdownFragment", () => { | ... | @@ -125,6 +172,82 @@ describe("serializeRenderedMarkdownFragment", () => { |
| 125 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); | 172 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); |
| 126 | }); | 173 | }); |
| 127 | | 174 | |
| | 175 | it("keeps fences when a bare list item sits alongside the code block", () => { |
| | 176 | // serializeListItem emits "- " for an item with no text, so the item is |
| | 177 | // content the plain-code path would drop. |
| | 178 | const container = new FakeElement("DIV").append( |
| | 179 | new FakeElement("UL").append(new FakeElement("LI")), |
| | 180 | renderedCodeBlock(["pnpm test"]), |
| | 181 | ); |
| | 182 | |
| | 183 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```"); |
| | 184 | }); |
| | 185 | |
| | 186 | it("keeps fences when a checkbox-only task item sits alongside the code block", () => { |
| | 187 | // The checkbox is a skipped tag, so the item renders no text of its own, but |
| | 188 | // it still carries the task state. |
| | 189 | const container = new FakeElement("DIV").append( |
| | 190 | new FakeElement("UL").append( |
| | 191 | new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })), |
| | 192 | ), |
| | 193 | renderedCodeBlock(["pnpm test"]), |
| | 194 | ); |
| | 195 | |
| | 196 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( |
| | 197 | "- [ ]\n\n```\npnpm test\n```", |
| | 198 | ); |
| | 199 | }); |
| | 200 | |
| | 201 | it("still drops fences for a code block that is the whole list item", () => { |
| | 202 | // The item only wraps the block, so a selection that never left the pre |
| | 203 | // would drop the marker too. |
| | 204 | const container = new FakeElement("DIV").append( |
| | 205 | new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))), |
| | 206 | new FakeText("\n"), |
| | 207 | ); |
| | 208 | |
| | 209 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test"); |
| | 210 | }); |
| | 211 | |
| | 212 | it("keeps fences when a file chip sits alongside the code block", () => { |
| | 213 | // The chip renders as a button, a skipped tag, but its data-markdown-copy |
| | 214 | // still contributes markdown, so the block is not the only visible content. |
| | 215 | const container = new FakeElement("DIV").append( |
| | 216 | renderedCodeBlock(["pnpm test"]), |
| | 217 | new FakeText("\n"), |
| | 218 | new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }), |
| | 219 | ); |
| | 220 | |
| | 221 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( |
| | 222 | "```\npnpm test\n```\n\n`src/foo.ts`", |
| | 223 | ); |
| | 224 | }); |
| | 225 | |
| | 226 | it("omits fences when a selection past the last line drags in the whole code block", () => { |
| | 227 | // Dragging over the final newline ends the range after the pre, so the |
| | 228 | // fragment carries the block plus the empty head of the next paragraph. |
| | 229 | const container = new FakeElement("DIV").append( |
| | 230 | renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]), |
| | 231 | new FakeText("\n"), |
| | 232 | new FakeElement("P").append(new FakeText("")), |
| | 233 | ); |
| | 234 | |
| | 235 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( |
| | 236 | "printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN", |
| | 237 | ); |
| | 238 | }); |
| | 239 | |
| | 240 | it("still fences a code block copied alongside prose", () => { |
| | 241 | const container = new FakeElement("DIV").append( |
| | 242 | new FakeElement("P").append(new FakeText("Run this:")), |
| | 243 | renderedCodeBlock(["gh workflow run Deploy --ref main"]), |
| | 244 | ); |
| | 245 | |
| | 246 | expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( |
| | 247 | "Run this:\n\n```\ngh workflow run Deploy --ref main\n```", |
| | 248 | ); |
| | 249 | }); |
| | 250 | |
| 128 | it("uses a rendered card's explicit Markdown copy representation", () => { | 251 | it("uses a rendered card's explicit Markdown copy representation", () => { |
| 129 | const card = new FakeElement("DIV", [], { | 252 | const card = new FakeElement("DIV", [], { |
| 130 | "data-markdown-copy": "Hello World (Document template)\n\n", | 253 | "data-markdown-copy": "Hello World (Document template)\n\n", |