From ed4019ff394fc41b9dad55610e2009bf5958652d Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Mon, 7 Sep 2026 12:24:53 -0400
Subject: [PATCH] fix: Fixed issues with the deflist plugin and TOC builder
---
TODOS.md | 2 ++
markdown/deflists.odin | 13 +++++++++++++
markdown/deflists_test.odin | 11 +++++++++++
markdown/toc.odin | 18 +++++++++++++++++-
markdown/toc_test.odin | 35 +++++++++++++++++++++++++++++++++++
5 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 markdown/toc_test.odin
diff --git a/TODOS.md b/TODOS.md
index 792a6d0..1847489 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -23,6 +23,8 @@
- [ ] Reconsider `"menus": {}` opt-out: it currently discards all page frontmatter
menus (with only a warning). Decide whether empty config should instead just
suppress the auto-generated menu while still honoring frontmatter.
+- [ ] warn when heading levels are skipped in a toc.
+- [ ] extract `[TOC]` from `.md` body (or at least from the start.)
## General
diff --git a/markdown/deflists.odin b/markdown/deflists.odin
index ac542fe..d6770bc 100644
--- a/markdown/deflists.odin
+++ b/markdown/deflists.odin
@@ -96,6 +96,19 @@ convert_deflists :: proc(body: string, allocator := context.allocator) -> string
if term_pos >= 0 {
strings.write_string(&sb, body[emitted_up_to:term_pos])
group_end := write_deflist_group(&sb, body, term_pos)
+ // write_deflist_group emits a raw
HTML block with no
+ // trailing newline, so the blank line the author left after the
+ // list is consumed as the
line's terminator rather than a
+ // separating blank line. CommonMark (type-6 HTML block) then
+ // swallows the following block — e.g. a fenced code example —
+ // as raw, unescaped HTML, leaking its markup (a template's
+ // ) into the document. Re-emit a newline so stands on
+ // its own line and the author's blank line still separates the
+ // block. Only when a blank line actually follows: fused
+ // non-blank fall-through content is left as-is.
+ if line, _, ok := line_at(body, group_end); ok && is_blank_line(line) {
+ strings.write_byte(&sb, '\n')
+ }
emitted_up_to = group_end
pos = group_end
prev1_kind, prev2_kind = .None, .None
diff --git a/markdown/deflists_test.odin b/markdown/deflists_test.odin
index c076b4a..1b1fdbf 100644
--- a/markdown/deflists_test.odin
+++ b/markdown/deflists_test.odin
@@ -90,6 +90,17 @@ test_empty_body :: proc(t: ^testing.T) {
testing.expect_value(t, result, "")
}
+// Regression: a fenced code block following a definition list must stay a
+// separate block. Without a blank line after the emitted , CommonMark
+// treats the fence as raw HTML continuation of the block and passes its
+// contents through unescaped (leaking e.g. an example into the document).
+@(test)
+test_deflist_before_code_fence :: proc(t: ^testing.T) {
+ input := "term\n\n: def\n\n```\nexample
\n```"
+ result := convert_deflists(input, context.temp_allocator)
+ testing.expect(t, strings.contains(result, "
\n\n```"))
+}
+
@(test)
test_docs_md_pattern :: proc(t: ^testing.T) {
input := "content\n\n: `content` holds your pages.\n\n assets\n : `assets` contains files."
diff --git a/markdown/toc.odin b/markdown/toc.odin
index 5767406..f57969d 100644
--- a/markdown/toc.odin
+++ b/markdown/toc.odin
@@ -1,5 +1,6 @@
package markdown
+import "core:log"
import "core:strings"
// generate_toc scans rendered HTML for - tags with id attributes and
@@ -28,8 +29,23 @@ generate_toc :: proc(html: string, allocator := context.allocator) -> string {
current_level = level
strings.write_string(&b, "\n")
} else if level > current_level {
+ if level - current_level > 1 {
+ log.warnf(
+ "heading level skips from h%d to h%d (\"%s\"); " +
+ "table of contents inserts empty levels to keep valid nesting",
+ current_level,
+ level,
+ text,
+ )
+ }
+ // The first step down nests inside the current, still-open - .
+ // Each further skipped level needs its own
- to host the deeper
+ //
, since a may only contain - — otherwise a jump of
+ // more than one level emits stacked, parentless
tags.
+ strings.write_string(&b, "\n")
+ current_level += 1
for current_level < level {
- strings.write_string(&b, "\n")
+ strings.write_string(&b, "- \n
\n")
current_level += 1
}
} else if level < current_level {
diff --git a/markdown/toc_test.odin b/markdown/toc_test.odin
new file mode 100644
index 0000000..092d6a9
--- /dev/null
+++ b/markdown/toc_test.odin
@@ -0,0 +1,35 @@
+#+test
+package markdown
+
+import "core:strings"
+import "core:testing"
+
+@(test)
+test_toc_sequential_nesting :: proc(t: ^testing.T) {
+ html := `A
B
C
`
+ result := generate_toc(html, context.temp_allocator)
+ testing.expect(t, strings.contains(result, `A`))
+ testing.expect(t, strings.contains(result, `B`))
+ testing.expect(t, strings.contains(result, `C`))
+ // A well-formed list never places a directly inside a .
+ testing.expect(t, !strings.contains(result, "\n"))
+}
+
+// Regression: a heading level that jumps by more than one (h1 -> h3) must not
+// emit stacked, parentless tags. Each skipped level is wrapped in an -
+// so the list stays valid.
+@(test)
+test_toc_level_jump_stays_valid :: proc(t: ^testing.T) {
+ html := `
A
B
`
+ result := generate_toc(html, context.temp_allocator)
+ testing.expect(t, !strings.contains(result, "\n"))
+ // The intermediate level is hosted by an - .
+ testing.expect(t, strings.contains(result, "
- \n
"))
+ testing.expect(t, strings.contains(result, `B`))
+}
+
+@(test)
+test_toc_empty_when_no_headings :: proc(t: ^testing.T) {
+ result := generate_toc("no headings here
", context.temp_allocator)
+ testing.expect_value(t, result, "")
+}