fix(matrix): compact loose list HTML for consistent Element rendering

Loose lists (blank lines between items) produce <li><p>...</p></li> via
markdown-it, causing Element to render list numbers on separate lines
from their content. Fix by setting hidden=true on paragraph tokens
inside list items before rendering, mirroring what markdown-it already
does for tight lists.

Closes #60997. Thanks @gucasbrg.

Co-Authored-By: Claude claude-opus-4-6 <noreply@anthropic.com>
Signed-off-by: Jakub Rusz <jrusz@proton.me>
This commit is contained in:
Jakub Rusz
2026-04-06 15:39:39 +02:00
committed by Peter Steinberger
parent 26a5ab1c6f
commit be5eebd3d4
2 changed files with 71 additions and 2 deletions

View File

@@ -50,6 +50,55 @@ describe("markdownToMatrixHtml", () => {
expect(html).toContain("<br");
});
it("compacts loose ordered lists without paragraph tags", () => {
const html = markdownToMatrixHtml("1. first\n\n2. second\n\n3. third");
expect(html).toContain("<ol>");
expect(html).toContain("<li>");
expect(html).not.toContain("<p>");
});
it("compacts loose unordered lists without paragraph tags", () => {
const html = markdownToMatrixHtml("- one\n\n- two\n\n- three");
expect(html).toContain("<ul>");
expect(html).not.toContain("<p>");
});
it("keeps tight lists unchanged", () => {
const html = markdownToMatrixHtml("- one\n- two");
expect(html).toContain("<ul>");
expect(html).not.toContain("<p>");
});
it("preserves inline formatting in loose lists", () => {
const html = markdownToMatrixHtml("1. **bold**\n\n2. _italic_");
expect(html).toContain("<strong>bold</strong>");
expect(html).toContain("<em>italic</em>");
expect(html).not.toContain("<p>");
});
it("does not strip paragraph tags outside lists", () => {
const html = markdownToMatrixHtml("Hello\n\nWorld");
expect(html).toContain("<p>Hello</p>");
expect(html).toContain("<p>World</p>");
});
it("compacts nested sublists without paragraph tags", () => {
const html = markdownToMatrixHtml("1. parent\n\n - child\n\n2. other");
expect(html).toContain("<ol>");
expect(html).toContain("<ul>");
expect(html).not.toContain("<p>");
});
it("compacts loose lists with mentions via renderMarkdownToMatrixHtmlWithMentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "1. hello @alice:example.org\n\n2. bye",
client: createMentionClient(),
});
expect(result.html).not.toContain("<p>");
expect(result.html).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"');
expect(result.mentions).toEqual({ user_ids: ["@alice:example.org"] });
});
it("renders qualified Matrix user mentions as matrix.to links and m.mentions metadata", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:example.org",

View File

@@ -309,9 +309,28 @@ function mutateInlineTokensWithMentions(params: {
return { children: nextChildren, roomMentioned };
}
// Compact loose lists by hiding paragraph tokens inside list items,
// mirroring what markdown-it already does for tight lists. Without this
// Element renders <p> margins inside <li>, splitting numbers from content.
function compactLooseListTokens(tokens: MarkdownToken[]): void {
let insideListItem = 0;
for (const token of tokens) {
if (token.type === "list_item_open") {
insideListItem++;
} else if (token.type === "list_item_close") {
insideListItem--;
} else if (insideListItem > 0) {
if (token.type === "paragraph_open" || token.type === "paragraph_close") {
token.hidden = true;
}
}
}
}
export function markdownToMatrixHtml(markdown: string): string {
const rendered = md.render(markdown ?? "");
return rendered.trimEnd();
const tokens = md.parse(markdown ?? "", {});
compactLooseListTokens(tokens);
return md.renderer.render(tokens, md.options, {}).trimEnd();
}
async function resolveMarkdownMentionState(params: {
@@ -366,6 +385,7 @@ export async function renderMarkdownToMatrixHtmlWithMentions(params: {
client: MatrixClient;
}): Promise<{ html?: string; mentions: MatrixMentions }> {
const state = await resolveMarkdownMentionState(params);
compactLooseListTokens(state.tokens);
const html = md.renderer.render(state.tokens, md.options, {}).trimEnd();
return {
html: html || undefined,