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
82 changes: 77 additions & 5 deletions apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class FakeText {
class FakeElement {
readonly nodeType = ELEMENT_NODE;
readonly childNodes: Array<FakeElement | FakeText> = [];
parentElement: FakeElement | null = null;
readonly classList = {
contains: (name: string) => this.classNames.includes(name),
};
Expand All @@ -33,21 +34,53 @@ class FakeElement {
}

append(...children: Array<FakeElement | FakeText>): 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;
}

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 {
Expand All @@ -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", () => {
Expand All @@ -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",
);
});
Expand All @@ -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---",
);
});
});
13 changes: 11 additions & 2 deletions apps/web/src/markdown-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading