Skip to content
Merged
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
63 changes: 63 additions & 0 deletions src-mdviewer/src/components/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,43 @@
}
});

// Anchor wrapping only <img> children (e.g. badge rows). The default Turndown
// path leaks the inter-image space text nodes outside the link as flanking
// whitespace, producing N-1 leading spaces before the [. The corresponding
// DOM preprocessing in convertToMarkdown strips those text nodes so the
// anchor's textContent is empty; this rule then re-emits the joining spaces
// inside the [...] so they stay contained.
td.addRule("imageOnlyLink", {
filter(node) {
if (node.nodeName !== "A" || !node.getAttribute("href")) {
return false;
}
if (node.childNodes.length === 0) {
return false;
}
for (const child of node.childNodes) {
if (!(child.nodeType === 1 && child.nodeName === "IMG")) {
return false;
}
}
return true;
},
replacement(content, node) {
const href = node.getAttribute("href") || "";
const title = node.getAttribute("title");
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.
const parts = [];
for (const child of node.childNodes) {
const alt = child.getAttribute("alt") || "";
const src = child.getAttribute("src") || "";
const imgTitle = child.getAttribute("title");
const imgTitlePart = imgTitle ? ` "${imgTitle.replace(/"/g, '\\"')}"` : "";

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This does not escape backslash characters in the input.
parts.push(`![${alt}](${src}${imgTitlePart})`);
}
return `[${parts.join(" ")}](${href}${titlePart})`;
}
});

return td;
}

Expand Down Expand Up @@ -1717,6 +1754,32 @@
parent.replaceChild(document.createTextNode(mark.textContent), mark);
parent.normalize();
});
// Image-only links (e.g. badge rows): drop whitespace text nodes between
// <img> siblings. Otherwise Turndown's flanking-whitespace handling reads
// the anchor's textContent (N-1 spaces for N images) and prepends those
// spaces before the [ in the output, indenting the line. The imageOnlyLink
// Turndown rule re-emits the joining single space inside the brackets.
clone.querySelectorAll("a[href]").forEach((a) => {
if (a.childNodes.length === 0) {
return;
}
for (const child of a.childNodes) {
if (child.nodeType === 1 && child.nodeName === "IMG") {
continue;
}
if (child.nodeType === 3 && /^\s*$/.test(child.nodeValue)) {
continue;
}
return;
}
const textNodes = [];
for (const child of a.childNodes) {
if (child.nodeType === 3) {
textNodes.push(child);
}
}
textNodes.forEach((t) => t.remove());
});
return turndown.turndown(clone.innerHTML);
}

Expand Down
45 changes: 45 additions & 0 deletions test/spec/md-editor-integ-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1874,6 +1874,51 @@ define(function (require, exports, module) {
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }),
"force close doc3.md");
}, 15000);

it("should image-only link with multiple images round-trip without leaking whitespace", async function () {
// Regression: Turndown's flanking-whitespace handling used to bubble
// the inter-<img> spaces inside an <a> out before the [, indenting
// the line by N-1 spaces (N images). README's sonarcloud badge row
// had 9 images, gaining 8 leading spaces on every save.
await _openMdFile("doc1.md");
const editor = EditorManager.getActiveEditor();

const badgesLine = "[![One](https://example.com/1.png) " +
"![Two](https://example.com/2.png) " +
"![Three](https://example.com/3.png) " +
"![Four](https://example.com/4.png)](https://example.com/link)" +
"[![Five](https://example.com/5.png)](https://example.com/link2)";
editor.document.setText("# Badges\n\n" + badgesLine + "\n");

await awaitsFor(() => {
const win = _getMdIFrameWin();
return win && win.__getCurrentContent &&
win.__getCurrentContent() === editor.document.getText();
}, "viewer to sync with badges content");

await _enterEditMode();

// Force HTML → markdown round-trip through convertToMarkdown
const win = _getMdIFrameWin();
win.__triggerContentSync();

// Wait for the debounced content sync to flow back to CM.
// Use awaitsFor on a stable condition rather than a fixed wait.
await awaitsFor(() => {
const text = editor.document.getText();
const lines = text.split("\n");
const line = lines[2] || "";
return line.includes("example.com/1.png") &&
line.includes("example.com/5.png");
}, "CM doc to contain the round-tripped badges line");

const roundTripped = editor.document.getText().split("\n")[2];
expect(roundTripped.startsWith(" ")).toBe(false);
expect(roundTripped).toBe(badgesLine);

await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }),
"force close doc1.md");
}, 15000);
});

describe("Empty Line Placeholder", function () {
Expand Down
Loading