diff --git a/extensions/matrix/src/matrix/format.test.ts b/extensions/matrix/src/matrix/format.test.ts index bad6ecbbd2d..02c2ae9ed02 100644 --- a/extensions/matrix/src/matrix/format.test.ts +++ b/extensions/matrix/src/matrix/format.test.ts @@ -50,6 +50,55 @@ describe("markdownToMatrixHtml", () => { expect(html).toContain(" { + const html = markdownToMatrixHtml("1. first\n\n2. second\n\n3. third"); + expect(html).toContain("
    "); + expect(html).toContain("
  1. "); + expect(html).not.toContain("

    "); + }); + + it("compacts loose unordered lists without paragraph tags", () => { + const html = markdownToMatrixHtml("- one\n\n- two\n\n- three"); + expect(html).toContain("

      "); + expect(html).not.toContain("

      "); + }); + + it("keeps tight lists unchanged", () => { + const html = markdownToMatrixHtml("- one\n- two"); + expect(html).toContain("

        "); + expect(html).not.toContain("

        "); + }); + + it("preserves inline formatting in loose lists", () => { + const html = markdownToMatrixHtml("1. **bold**\n\n2. _italic_"); + expect(html).toContain("bold"); + expect(html).toContain("italic"); + expect(html).not.toContain("

        "); + }); + + it("does not strip paragraph tags outside lists", () => { + const html = markdownToMatrixHtml("Hello\n\nWorld"); + expect(html).toContain("

        Hello

        "); + expect(html).toContain("

        World

        "); + }); + + it("compacts nested sublists without paragraph tags", () => { + const html = markdownToMatrixHtml("1. parent\n\n - child\n\n2. other"); + expect(html).toContain("
          "); + expect(html).toContain("
            "); + expect(html).not.toContain("

            "); + }); + + 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("

            "); + 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", diff --git a/extensions/matrix/src/matrix/format.ts b/extensions/matrix/src/matrix/format.ts index 5d640f13995..b134a4526dc 100644 --- a/extensions/matrix/src/matrix/format.ts +++ b/extensions/matrix/src/matrix/format.ts @@ -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

            margins inside

          • , 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,