Skip to content

Commit 0da2c61

Browse files
committed
fix(mdviewer): prevent Turndown from indenting image-only links on save
An <a href> wrapping multiple <img> children (e.g. badge rows) leaked the inter-image whitespace text nodes outside the link as flanking whitespace during the Turndown HTML->markdown roundtrip, producing N-1 leading spaces before the [ for N images. The phoenix README badge row (9 images) gained 8 leading spaces on every md editor save. Fix in two parts: - convertToMarkdown preprocessing strips the whitespace text nodes between <img> siblings inside an <a href>, so the anchor's textContent is empty and Turndown's flanking-whitespace handler has nothing to bubble out. - A new imageOnlyLink Turndown rule renders such anchors directly as [![alt](src) ![alt](src) ...](href), re-emitting the single joining space inside the brackets. Adds a regression test in test/spec/md-editor-integ-test.js that builds a 5-image badges-style line, forces an HTML->markdown roundtrip via __triggerContentSync, and asserts the CM source is byte-identical with no leading whitespace.
1 parent e8b83ce commit 0da2c61

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

src-mdviewer/src/components/editor.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,43 @@ function createTurndown() {
16671667
}
16681668
});
16691669

1670+
// Anchor wrapping only <img> children (e.g. badge rows). The default Turndown
1671+
// path leaks the inter-image space text nodes outside the link as flanking
1672+
// whitespace, producing N-1 leading spaces before the [. The corresponding
1673+
// DOM preprocessing in convertToMarkdown strips those text nodes so the
1674+
// anchor's textContent is empty; this rule then re-emits the joining spaces
1675+
// inside the [...] so they stay contained.
1676+
td.addRule("imageOnlyLink", {
1677+
filter(node) {
1678+
if (node.nodeName !== "A" || !node.getAttribute("href")) {
1679+
return false;
1680+
}
1681+
if (node.childNodes.length === 0) {
1682+
return false;
1683+
}
1684+
for (const child of node.childNodes) {
1685+
if (!(child.nodeType === 1 && child.nodeName === "IMG")) {
1686+
return false;
1687+
}
1688+
}
1689+
return true;
1690+
},
1691+
replacement(content, node) {
1692+
const href = node.getAttribute("href") || "";
1693+
const title = node.getAttribute("title");
1694+
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
1695+
const parts = [];
1696+
for (const child of node.childNodes) {
1697+
const alt = child.getAttribute("alt") || "";
1698+
const src = child.getAttribute("src") || "";
1699+
const imgTitle = child.getAttribute("title");
1700+
const imgTitlePart = imgTitle ? ` "${imgTitle.replace(/"/g, '\\"')}"` : "";
1701+
parts.push(`![${alt}](${src}${imgTitlePart})`);
1702+
}
1703+
return `[${parts.join(" ")}](${href}${titlePart})`;
1704+
}
1705+
});
1706+
16701707
return td;
16711708
}
16721709

@@ -1717,6 +1754,32 @@ export function convertToMarkdown(contentEl) {
17171754
parent.replaceChild(document.createTextNode(mark.textContent), mark);
17181755
parent.normalize();
17191756
});
1757+
// Image-only links (e.g. badge rows): drop whitespace text nodes between
1758+
// <img> siblings. Otherwise Turndown's flanking-whitespace handling reads
1759+
// the anchor's textContent (N-1 spaces for N images) and prepends those
1760+
// spaces before the [ in the output, indenting the line. The imageOnlyLink
1761+
// Turndown rule re-emits the joining single space inside the brackets.
1762+
clone.querySelectorAll("a[href]").forEach((a) => {
1763+
if (a.childNodes.length === 0) {
1764+
return;
1765+
}
1766+
for (const child of a.childNodes) {
1767+
if (child.nodeType === 1 && child.nodeName === "IMG") {
1768+
continue;
1769+
}
1770+
if (child.nodeType === 3 && /^\s*$/.test(child.nodeValue)) {
1771+
continue;
1772+
}
1773+
return;
1774+
}
1775+
const textNodes = [];
1776+
for (const child of a.childNodes) {
1777+
if (child.nodeType === 3) {
1778+
textNodes.push(child);
1779+
}
1780+
}
1781+
textNodes.forEach((t) => t.remove());
1782+
});
17201783
return turndown.turndown(clone.innerHTML);
17211784
}
17221785

test/spec/md-editor-integ-test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,6 +1874,51 @@ define(function (require, exports, module) {
18741874
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }),
18751875
"force close doc3.md");
18761876
}, 15000);
1877+
1878+
it("should image-only link with multiple images round-trip without leaking whitespace", async function () {
1879+
// Regression: Turndown's flanking-whitespace handling used to bubble
1880+
// the inter-<img> spaces inside an <a> out before the [, indenting
1881+
// the line by N-1 spaces (N images). README's sonarcloud badge row
1882+
// had 9 images, gaining 8 leading spaces on every save.
1883+
await _openMdFile("doc1.md");
1884+
const editor = EditorManager.getActiveEditor();
1885+
1886+
const badgesLine = "[![One](https://example.com/1.png) " +
1887+
"![Two](https://example.com/2.png) " +
1888+
"![Three](https://example.com/3.png) " +
1889+
"![Four](https://example.com/4.png)](https://example.com/link)" +
1890+
"[![Five](https://example.com/5.png)](https://example.com/link2)";
1891+
editor.document.setText("# Badges\n\n" + badgesLine + "\n");
1892+
1893+
await awaitsFor(() => {
1894+
const win = _getMdIFrameWin();
1895+
return win && win.__getCurrentContent &&
1896+
win.__getCurrentContent() === editor.document.getText();
1897+
}, "viewer to sync with badges content");
1898+
1899+
await _enterEditMode();
1900+
1901+
// Force HTML → markdown round-trip through convertToMarkdown
1902+
const win = _getMdIFrameWin();
1903+
win.__triggerContentSync();
1904+
1905+
// Wait for the debounced content sync to flow back to CM.
1906+
// Use awaitsFor on a stable condition rather than a fixed wait.
1907+
await awaitsFor(() => {
1908+
const text = editor.document.getText();
1909+
const lines = text.split("\n");
1910+
const line = lines[2] || "";
1911+
return line.includes("example.com/1.png") &&
1912+
line.includes("example.com/5.png");
1913+
}, "CM doc to contain the round-tripped badges line");
1914+
1915+
const roundTripped = editor.document.getText().split("\n")[2];
1916+
expect(roundTripped.startsWith(" ")).toBe(false);
1917+
expect(roundTripped).toBe(badgesLine);
1918+
1919+
await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }),
1920+
"force close doc1.md");
1921+
}, 15000);
18771922
});
18781923

18791924
describe("Empty Line Placeholder", function () {

0 commit comments

Comments
 (0)