Skip to content
Open
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
130 changes: 127 additions & 3 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class FakeElement {
constructor(
readonly tagName: string,
private readonly classNames: ReadonlyArray<string> = [],
private readonly attributes: Readonly<Record<string, string>> = {},
) {}

get localName(): string {
Expand All @@ -32,17 +33,49 @@ class FakeElement {
return this.childNodes.map((child) => child.textContent).join("");
}

get children(): ReadonlyArray<FakeElement> {
return this.childNodes.filter((child): child is FakeElement => child instanceof FakeElement);
}

append(...children: Array<FakeElement | FakeText>): this {
this.childNodes.push(...children);
return this;
}

getAttribute(): string | null {
getAttribute(name: string): string | null {
return this.attributes[name] ?? null;
}

hasAttribute(name: string): boolean {
return name in this.attributes;
}

closest(): FakeElement | null {
return null;
}

hasAttribute(): boolean {
return false;
/** Supports only the selectors markdown-clipboard actually asks for. */
querySelector(selector: string): FakeElement | null {
const childOnly = selector.startsWith(":scope > ");
const target = childOnly ? selector.slice(":scope > ".length) : selector;
const matches = (element: FakeElement): boolean => {
if (target === 'input[type="checkbox"]') {
return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox";
}
return element.tagName === target.toUpperCase();
};
const search = (parent: FakeElement): FakeElement | null => {
for (const child of parent.childNodes) {
if (!(child instanceof FakeElement)) continue;
if (matches(child)) return child;
if (!childOnly) {
const nested = search(child);
if (nested) return nested;
}
}
return null;
};
return search(this);
}
}

Expand All @@ -55,6 +88,21 @@ function shikiCodeLine(text: string): FakeElement {
return new FakeElement("SPAN", ["line"]).append(token);
}

/** Mirrors a rendered code block: select-none header chrome plus a shiki pre. */
function renderedCodeBlock(lines: ReadonlyArray<string>): FakeElement {
const code = new FakeElement("CODE");
lines.forEach((line, index) => {
if (index > 0) code.append(new FakeText("\n"));
code.append(shikiCodeLine(line));
});
return new FakeElement("DIV", ["chat-markdown-codeblock"]).append(
new FakeElement("DIV", ["chat-markdown-codeblock-header", "select-none"]).append(
new FakeText("sh"),
),
new FakeElement("DIV", ["chat-markdown-shiki"]).append(new FakeElement("PRE").append(code)),
);
}

describe("serializeRenderedMarkdownFragment", () => {
beforeEach(() => {
vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
Expand Down Expand Up @@ -92,4 +140,80 @@ describe("serializeRenderedMarkdownFragment", () => {

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line");
});

it("keeps fences when a bare list item sits alongside the code block", () => {
// serializeListItem emits "- " for an item with no text, so the item is
// content the plain-code path would drop.
const container = new FakeElement("DIV").append(
new FakeElement("UL").append(new FakeElement("LI")),
renderedCodeBlock(["pnpm test"]),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("-\n\n```\npnpm test\n```");
});

it("keeps fences when a checkbox-only task item sits alongside the code block", () => {
// The checkbox is a skipped tag, so the item renders no text of its own, but
// it still carries the task state.
const container = new FakeElement("DIV").append(
new FakeElement("UL").append(
new FakeElement("LI").append(new FakeElement("INPUT", [], { type: "checkbox" })),
),
renderedCodeBlock(["pnpm test"]),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"- [ ]\n\n```\npnpm test\n```",
);
});

it("still drops fences for a code block that is the whole list item", () => {
// The item only wraps the block, so a selection that never left the pre
// would drop the marker too.
const container = new FakeElement("DIV").append(
new FakeElement("UL").append(new FakeElement("LI").append(renderedCodeBlock(["pnpm test"]))),
new FakeText("\n"),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("pnpm test");
});

it("keeps fences when a file chip sits alongside the code block", () => {
// The chip renders as a button, a skipped tag, but its data-markdown-copy
// still contributes markdown, so the block is not the only visible content.
const container = new FakeElement("DIV").append(
renderedCodeBlock(["pnpm test"]),
new FakeText("\n"),
new FakeElement("BUTTON", [], { "data-markdown-copy": "`src/foo.ts`" }),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"```\npnpm test\n```\n\n`src/foo.ts`",
);
});

it("omits fences when a selection past the last line drags in the whole code block", () => {
// Dragging over the final newline ends the range after the pre, so the
// fragment carries the block plus the empty head of the next paragraph.
const container = new FakeElement("DIV").append(
renderedCodeBlock(["printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN"]),
new FakeText("\n"),
new FakeElement("P").append(new FakeText("")),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"printf '%s' 'TOKEN' | gh secret set CLOUDFLARE_API_TOKEN",
);
});

it("still fences a code block copied alongside prose", () => {
const container = new FakeElement("DIV").append(
new FakeElement("P").append(new FakeText("Run this:")),
renderedCodeBlock(["gh workflow run Deploy --ref main"]),
);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"Run this:\n\n```\ngh workflow run Deploy --ref main\n```",
);
});
});
69 changes: 69 additions & 0 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,73 @@ function serializeNode(node: Node): string {
}
}

/**
* Tracks whether a fragment carries exactly one code block and nothing else a
* reader would see.
*/
interface SoleCodeBlockScan {
pre: Element | null;
other: boolean;
}

function scanForSoleCodeBlock(node: Node, scan: SoleCodeBlockScan): void {
for (const child of node.childNodes) {
if (scan.other) return;
if (child.nodeType === Node.TEXT_NODE) {
if ((child.textContent ?? "").trim().length > 0) scan.other = true;
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const element = child as Element;
// Mirrors serializeNode's order: an element carrying markdown of its own
// still contributes it even when its tag is otherwise skipped, as a file
// chip rendered as a button does.
if (element.hasAttribute("data-markdown-details")) {
scan.other = true;
continue;
}
const markdownCopy = element.getAttribute("data-markdown-copy");
if (markdownCopy !== null) {
if (markdownCopy.trim().length > 0) scan.other = true;
continue;
}
if (isSkippedElement(element)) continue;
if (element.tagName === "PRE") {
if (scan.pre) scan.other = true;
else scan.pre = element;
continue;
}
if (element.tagName === "IMG" || element.tagName === "HR") {
scan.other = true;
continue;
}
if (element.tagName === "LI") {
// serializeListItem emits a marker ("- ", "1. ", "[x] ") for every item,
// so an item that does not hold the block carries content of its own even
// when it renders no text. An item that wraps the block is just the
// structure around it, and a pre-only selection would drop the marker too.
const preBeforeItem = scan.pre;
scanForSoleCodeBlock(element, scan);
if (!scan.other && scan.pre === preBeforeItem) scan.other = true;
continue;
}
scanForSoleCodeBlock(element, scan);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

/**
* A drag that ends on a block's final newline pulls the closing `pre` into the
* range, so the fragment holds the whole block even though the user only
* highlighted code. Re-fencing that pastes stray backticks, so a fragment whose
* only visible content is one code block copies as plain code, matching a
* selection that never left the `pre`.
*/
function soleCodeBlock(container: Node): Element | null {
const scan: SoleCodeBlockScan = { pre: null, other: false };
scanForSoleCodeBlock(container, scan);
return scan.other ? null : scan.pre;
}

/** Collapses serializer spacing artifacts without touching fenced code content. */
function tidyMarkdown(markdown: string): string {
return markdown
Expand All @@ -268,6 +335,8 @@ function tidyMarkdown(markdown: string): string {
}

export function serializeRenderedMarkdownFragment(container: Node): string {
const codeBlock = soleCodeBlock(container);
if (codeBlock) return (codeBlock.textContent ?? "").replace(/\n$/, "");
return tidyMarkdown(serializeChildren(container));
}

Expand Down
Loading