Normalize site TOC heading hierarchy

This commit is contained in:
Sergey Chernov 2026-04-27 15:21:44 +03:00
parent 35f4c968a4
commit 1bababa058
3 changed files with 56 additions and 9 deletions

View File

@ -176,7 +176,7 @@ private fun TocNav(
Ul({ classes("list-unstyled", "mb-0") }) { Ul({ classes("list-unstyled", "mb-0") }) {
toc.forEach { item -> toc.forEach { item ->
Li({ classes("mb-1") }) { Li({ classes("mb-1") }) {
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" } val pad = "${(item.level - 1) * 0.75}rem"
val routeNoFrag = route.substringBefore('#') val routeNoFrag = route.substringBefore('#')
val tocHref = "#/$routeNoFrag#${item.id}" val tocHref = "#/$routeNoFrag#${item.id}"
A(attrs = { A(attrs = {

View File

@ -1016,17 +1016,21 @@ fun rewriteAnchors(
} }
} }
private data class TocTreeNode(
val headingLevel: Int,
val id: String,
val title: String,
val children: MutableList<TocTreeNode> = mutableListOf(),
)
fun buildToc(root: HTMLElement): List<TocItem> { fun buildToc(root: HTMLElement): List<TocItem> {
val out = mutableListOf<TocItem>()
val used = hashSetOf<String>() val used = hashSetOf<String>()
val hs = root.querySelectorAll("h1, h2, h3") val tocRoot = TocTreeNode(headingLevel = 0, id = "", title = "")
val stack = mutableListOf(tocRoot)
val hs = root.querySelectorAll("h1, h2, h3, h4, h5, h6")
for (i in 0 until hs.length) { for (i in 0 until hs.length) {
val h = hs.item(i) as? HTMLHeadingElement ?: continue val h = hs.item(i) as? HTMLHeadingElement ?: continue
val level = when (h.tagName.uppercase()) { val level = h.tagName.removePrefix("H").toIntOrNull() ?: continue
"H1" -> 1
"H2" -> 2
else -> 3
}
var id = h.id.ifBlank { slugify(h.textContent ?: "") } var id = h.id.ifBlank { slugify(h.textContent ?: "") }
if (id.isBlank()) id = "section-${i + 1}" if (id.isBlank()) id = "section-${i + 1}"
var unique = id var unique = id
@ -1036,8 +1040,19 @@ fun buildToc(root: HTMLElement): List<TocItem> {
n++ n++
} }
h.id = unique h.id = unique
out += TocItem(level, unique, h.textContent ?: "")
while (stack.last().headingLevel >= level) stack.removeAt(stack.lastIndex)
val node = TocTreeNode(level, unique, h.textContent ?: "")
stack.last().children += node
stack += node
} }
val out = mutableListOf<TocItem>()
fun visit(node: TocTreeNode, normalizedLevel: Int) {
if (node.id.isNotEmpty()) out += TocItem(normalizedLevel, node.id, node.title)
node.children.forEach { child -> visit(child, normalizedLevel + 1) }
}
tocRoot.children.forEach { child -> visit(child, 1) }
return out return out
} }

View File

@ -71,5 +71,37 @@ class RouteAndDomRewriteTest {
val el = root.ownerDocument?.getElementById(id) ?: root.querySelector("#${id}") val el = root.ownerDocument?.getElementById(id) ?: root.querySelector("#${id}")
assertNotNull(el, "Heading with id $id should be present in DOM") assertNotNull(el, "Heading with id $id should be present in DOM")
} }
assertEquals(listOf(1, 2, 2, 3), toc.map { it.level })
}
@Test
fun testBuildToc_normalizesStartingLevelAndGaps() {
val root = document.createElement("div") as HTMLElement
root.innerHTML = """
<h3>Top</h3>
<h5>Deep child</h5>
<h4>Middle sibling</h4>
<h6>Nested</h6>
""".trimIndent()
val toc = buildToc(root)
assertEquals(listOf("Top", "Deep child", "Middle sibling", "Nested"), toc.map { it.title })
assertEquals(listOf(1, 2, 2, 3), toc.map { it.level })
}
@Test
fun testBuildToc_supportsAllHeadingLevels() {
val root = document.createElement("div") as HTMLElement
root.innerHTML = """
<h4>Start</h4>
<h5>Child</h5>
<h6>Grandchild</h6>
""".trimIndent()
val toc = buildToc(root)
assertEquals(listOf(1, 2, 3), toc.map { it.level })
} }
} }