From 2913aa4eb02f13369746e859ab04dff02adda583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Mon, 28 Sep 2026 13:34:52 +0200 Subject: [PATCH] fix(markdown): close a definition list before the paragraph that follows it --- CHANGELOG.md | 4 ++ internal/markdown/extension_corpus_test.go | 4 ++ internal/markdown/parse.go | 57 ++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4642870..d032510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [development] +### Fixed + +- A paragraph that follows a definition list, separated from it by a blank line, is no longer swallowed into the `dl` element: the list ends before it, and terms after the paragraph open a list of their own. + ### Added - diff --git a/internal/markdown/extension_corpus_test.go b/internal/markdown/extension_corpus_test.go index cf478c4..f1b49c8 100644 --- a/internal/markdown/extension_corpus_test.go +++ b/internal/markdown/extension_corpus_test.go @@ -81,6 +81,10 @@ func TestDefinitionLists(t *testing.T) { "
\n
Term
\n
\n

Def

\n

more

\n
\n
\n"}, {"definition inline content", "Term\n: A *bold* claim", "
\n
Term
\n
A bold claim
\n
\n"}, + {"definition paragraph after list", "Term\n: Definition\n\nParagraph after.", + "
\n
Term
\n
Definition
\n
\n

Paragraph after.

\n"}, + {"definition paragraph between lists", "Term\n: Def\n\nBetween\n\nTerm 2\n: Def 2", + "
\n
Term
\n
Def
\n
\n

Between

\n
\n
Term 2
\n
Def 2
\n
\n"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/markdown/parse.go b/internal/markdown/parse.go index ff961ae..ae6a6d4 100644 --- a/internal/markdown/parse.go +++ b/internal/markdown/parse.go @@ -614,6 +614,9 @@ func (p *parser) finalise(n *Node) { n.content = trimTrailingBlankLines(n.content) } case kindList, kindDefList: + if n.kind == kindDefList { + p.liftLeakedTerms(n) + } n.tight = !blocksAreLoose(n.children) case kindDocument: p.gatherFootnotes(n) @@ -623,6 +626,60 @@ func (p *parser) finalise(n *Node) { } } +// liftLeakedTerms moves the paragraphs that never became terms out of a +// definition list. A paragraph may sit directly inside the list as a +// term waiting for its definition line; when that line never arrives the +// paragraph is prose after the list, and the list ends before it, since +// a dl element carries nothing but terms and definitions. Terms and +// definitions after the leak form a list of their own. +func (p *parser) liftLeakedTerms(dl *Node) { + leak := -1 + for i, c := range dl.children { + if c.kind == kindParagraph { + leak = i + break + } + } + if leak < 0 { + return + } + parent := dl.parent + base := 0 + for i, c := range parent.children { + if c == dl { + base = i + 1 + break + } + } + tail := dl.children[leak:] + dl.children = dl.children[:leak] + insert := func(n *Node) { + parent.children = append(parent.children, nil) + copy(parent.children[base+1:], parent.children[base:]) + parent.children[base] = n + base++ + n.parent = parent + } + var regroup *Node + for _, c := range tail { + switch c.kind { + case kindParagraph: + insert(c) + regroup = nil + case kindDefTerm, kindDefItem: + if regroup == nil { + regroup = &Node{kind: kindDefList, parent: parent} + insert(regroup) + } + c.parent = regroup + regroup.children = append(regroup.children, c) + } + } + if regroup != nil { + regroup.tight = !blocksAreLoose(regroup.children) + } +} + // trimTrailingBlankLines removes the trailing blank lines of an indented // code block. Every content line carries its newline, so the result of a // non-empty block ends with exactly one.