authorgravatar for hello@ipanasenko.meIllia Panasenko <hello@ipanasenko.me> 2026-09-02 08:04:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 23:04:22-07:00
log535c83dea5025e49df12df4a14b4d1dfb7d373ce
treef23ece93b1d2a5c07dcfc55f2db55db3d53d9437
parentf9d1c65d475807f70d29deff71b16a922a61c4c0
signature Signed by PGP key B5690EEEBB952194

fix(web): copying a code block no longer copies triple backticks (#8448)


2 files changed, 192 insertions(+), 0 deletions(-)

apps/web/src/markdown-clipboard.test.ts+123
...@@ -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 }
4040
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}
5486
55function asNode(element: FakeElement): Node {87function 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}
6395
96/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */
97function 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
64describe("serializeRenderedMarkdownFragment", () => {111describe("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 });
127174
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",
apps/web/src/markdown-clipboard.ts+69
...@@ -256,6 +256,73 @@ function serializeNode(node: Node): string {...@@ -256,6 +256,73 @@ function serializeNode(node: Node): string {
256 }256 }
257}257}
258258
259/**
260 * Tracks whether a fragment carries exactly one code block and nothing else a
261 * reader would see.
262 */
263interface SoleCodeBlockScan {
264 pre: Element | null;
265 other: boolean;
266}
267
268function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void {
269 for (const child of node.childNodes) {
270 if (scan.other) return;
271 if (child.nodeType === Node.TEXT_NODE) {
272 if ((child.textContent ?? "").trim().length > 0) scan.other = true;
273 continue;
274 }
275 if (child.nodeType !== Node.ELEMENT_NODE) continue;
276 const element = child as Element;
277 // Mirrors serializeNode's order: an element carrying markdown of its own
278 // still contributes it even when its tag is otherwise skipped, as a file
279 // chip rendered as a button does.
280 if (element.hasAttribute("data-markdown-details")) {
281 scan.other = true;
282 continue;
283 }
284 const markdownCopy = element.getAttribute("data-markdown-copy");
285 if (markdownCopy !== null) {
286 if (markdownCopy.trim().length > 0) scan.other = true;
287 continue;
288 }
289 if (isSkippedElement(element)) continue;
290 if (element.tagName === "PRE") {
291 if (scan.pre) scan.other = true;
292 else scan.pre = element;
293 continue;
294 }
295 if (element.tagName === "IMG" || element.tagName === "HR") {
296 scan.other = true;
297 continue;
298 }
299 if (element.tagName === "LI") {
300 // serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item,
301 // so an item that does not hold the block carries content of its own even
302 // when it renders no text. An item that wraps the block is just the
303 // structure around it, and a pre-only selection would drop the marker too.
304 const preBeforeItem = scan.pre;
305 scanForSoleCodeBlock(element, scan);
306 if (!scan.other && scan.pre === preBeforeItem) scan.other = true;
307 continue;
308 }
309 scanForSoleCodeBlock(element, scan);
310 }
311}
312
313/**
314 * A drag that ends on a block's final newline pulls the closing `pre` into the
315 * range, so the fragment holds the whole block even though the user only
316 * highlighted code. Re-fencing that pastes stray backticks, so a fragment whose
317 * only visible content is one code block copies as plain code, matching a
318 * selection that never left the `pre`.
319 */
320function soleCodeBlock(container: Node): Element | null {
321 const scan: SoleCodeBlockScan = { pre: null, other: false };
322 scanForSoleCodeBlock(container, scan);
323 return scan.other ? null : scan.pre;
324}
325
259/** Collapses serializer spacing artifacts without touching fenced code content. */326/** Collapses serializer spacing artifacts without touching fenced code content. */
260function tidyMarkdown(markdown: string): string {327function tidyMarkdown(markdown: string): string {
261 return markdown328 return markdown
...@@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string {...@@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string {
268}335}
269336
270export function serializeRenderedMarkdownFragment(container: Node): string {337export function serializeRenderedMarkdownFragment(container: Node): string {
338 const codeBlock = soleCodeBlock(container);
339 if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, "");
271 return tidyMarkdown(serializeChildren(container));340 return tidyMarkdown(serializeChildren(container));
272}341}
273342