fix: Fixed issues with the deflist plugin and TOC builder

This commit is contained in:
Spencer Brower
2026-09-07 12:56:15 -04:00
parent d334a6ed9f
commit ed4019ff39
5 changed files with 78 additions and 1 deletions
+2
View File
@@ -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
+13
View File
@@ -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 <dl> HTML block with no
// trailing newline, so the blank line the author left after the
// list is consumed as the </dl> 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
// <h1>) into the document. Re-emit a newline so </dl> 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
+11
View File
@@ -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 </dl>, CommonMark
// treats the fence as raw HTML continuation of the <dl> block and passes its
// contents through unescaped (leaking e.g. an example <h1> into the document).
@(test)
test_deflist_before_code_fence :: proc(t: ^testing.T) {
input := "term\n\n: def\n\n```\n<h1>example</h1>\n```"
result := convert_deflists(input, context.temp_allocator)
testing.expect(t, strings.contains(result, "</dl>\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."
+17 -1
View File
@@ -1,5 +1,6 @@
package markdown
import "core:log"
import "core:strings"
// generate_toc scans rendered HTML for <h1>-<h6> 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, "<ul>\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 <li>.
// Each further skipped level needs its own <li> to host the deeper
// <ul>, since a <ul> may only contain <li> — otherwise a jump of
// more than one level emits stacked, parentless <ul> tags.
strings.write_string(&b, "<ul>\n")
current_level += 1
for current_level < level {
strings.write_string(&b, "<ul>\n")
strings.write_string(&b, "<li>\n<ul>\n")
current_level += 1
}
} else if level < current_level {
+35
View File
@@ -0,0 +1,35 @@
#+test
package markdown
import "core:strings"
import "core:testing"
@(test)
test_toc_sequential_nesting :: proc(t: ^testing.T) {
html := `<h1 id="a">A</h1><h2 id="b">B</h2><h1 id="c">C</h1>`
result := generate_toc(html, context.temp_allocator)
testing.expect(t, strings.contains(result, `<a href="#a">A</a>`))
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
testing.expect(t, strings.contains(result, `<a href="#c">C</a>`))
// A well-formed list never places a <ul> directly inside a <ul>.
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
}
// Regression: a heading level that jumps by more than one (h1 -> h3) must not
// emit stacked, parentless <ul> tags. Each skipped level is wrapped in an <li>
// so the list stays valid.
@(test)
test_toc_level_jump_stays_valid :: proc(t: ^testing.T) {
html := `<h1 id="a">A</h1><h3 id="b">B</h3>`
result := generate_toc(html, context.temp_allocator)
testing.expect(t, !strings.contains(result, "<ul>\n<ul>"))
// The intermediate level is hosted by an <li>.
testing.expect(t, strings.contains(result, "<li>\n<ul>"))
testing.expect(t, strings.contains(result, `<a href="#b">B</a>`))
}
@(test)
test_toc_empty_when_no_headings :: proc(t: ^testing.T) {
result := generate_toc("<p>no headings here</p>", context.temp_allocator)
testing.expect_value(t, result, "")
}