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
95 changes: 95 additions & 0 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";

const TEXT_NODE = 3;
const ELEMENT_NODE = 1;

class FakeText {
readonly nodeType = TEXT_NODE;
readonly childNodes: ReadonlyArray<never> = [];

constructor(readonly textContent: string) {}
}

class FakeElement {
readonly nodeType = ELEMENT_NODE;
readonly childNodes: Array<FakeElement | FakeText> = [];
readonly classList = {
contains: (name: string) => this.classNames.includes(name),
};

constructor(
readonly tagName: string,
private readonly classNames: ReadonlyArray<string> = [],
) {}

get localName(): string {
return this.tagName.toLowerCase();
}

get textContent(): string {
return this.childNodes.map((child) => child.textContent).join("");
}

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

getAttribute(): string | null {
return null;
}

hasAttribute(): boolean {
return false;
}
}

function asNode(element: FakeElement): Node {
return element as unknown as Node;
}

function shikiCodeLine(text: string): FakeElement {
const token = new FakeElement("SPAN").append(new FakeText(text));
return new FakeElement("SPAN", ["line"]).append(token);
}

describe("serializeRenderedMarkdownFragment", () => {
beforeEach(() => {
vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE });
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("wraps inline code in backticks", () => {
const paragraph = new FakeElement("P").append(
new FakeText("run "),
new FakeElement("CODE").append(new FakeText("git status")),
new FakeText(" first"),
);
const container = new FakeElement("DIV").append(paragraph);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first");
});

it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => {
const code = new FakeElement("CODE").append(
shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"),
);
const container = new FakeElement("DIV").append(code);

expect(serializeRenderedMarkdownFragment(asNode(container))).toBe(
"git show-ref --verify refs/remotes/origin/opt/deploy/dev",
);
});

it("keeps a multi-line code selection plain instead of inline-wrapping it", () => {
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");
});
});
22 changes: 20 additions & 2 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string {
return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`;
}

/**
* A code element whose pre wrapper fell outside the copied range is still
* block code, recognizable by its highlighter line spans or embedded
* newlines. Wrapping it like inline code produces backtick-surrounded
* shell commands on paste.
*/
function isBlockCodeElement(element: Element, content: string): boolean {
if (content.includes("\n")) return true;
for (const child of element.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) {
return true;
}
}
return false;
}

function wrapInlineCode(code: string): string {
const longestRun = [...(code.match(/`+/g) ?? [])].reduce(
(max, run) => Math.max(max, run.length),
Expand Down Expand Up @@ -201,8 +217,10 @@ function serializeNode(node: Node): string {
return `${serializeChildren(element).trim()}\n\n`;
case "PRE":
return serializeCodeBlock(element);
case "CODE":
return wrapInlineCode(element.textContent ?? "");
case "CODE": {
const content = element.textContent ?? "";
return isBlockCodeElement(element, content) ? content : wrapInlineCode(content);
}
case "STRONG":
case "B":
return wrapInlineMarker(serializeChildren(element), "**");
Expand Down
Loading