/* * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import externals.marked import kotlinx.browser.document import kotlinx.browser.window import kotlinx.coroutines.* import org.jetbrains.compose.web.renderComposable import org.w3c.dom.* // --------------- Lightweight debug logging --------------- // Disable debug logging by default private var SEARCH_DEBUG: Boolean = false fun dlog(tag: String, msg: String) { if (!SEARCH_DEBUG) return try { console.log("[LYNG][$tag] $msg") } catch (_: dynamic) { } } @Suppress("unused") private fun exposeSearchDebugToggle() { try { val w = window.asDynamic() w.setLyngSearchDebug = { enabled: Boolean -> SEARCH_DEBUG = enabled dlog("debug", "SEARCH_DEBUG=$enabled") } // Extra runtime helpers to diagnose search at runtime w.lyngSearchForceReindex = { try { dlog("debug", "lyngSearchForceReindex() called") searchIndex = null searchBuilding = false MainScopeProvider.scope.launch { buildSearchIndexOnce() val count = searchIndex?.size ?: -1 dlog("search", "forceReindex complete: indexed=$count") searchIndex?.take(5)?.forEachIndexed { i, rec -> dlog("search", "sample[$i]: ${rec.path} | ${rec.title}") } } } catch (_: dynamic) { } } w.lyngSearchQuery = { q: String -> try { dlog("debug", "lyngSearchQuery('$q') called") MainScopeProvider.scope.launch { if (searchIndex == null) buildSearchIndexOnce() val idxSize = searchIndex?.size ?: -1 val res = performSearch(q) dlog("search", "query '$q' on idx=$idxSize -> ${res.size} hits") res.take(5).forEachIndexed { i, r -> dlog("search", "hit[$i]: score=${scoreQuery(q, r)} path=${r.path} title=${r.title}") } } } catch (_: dynamic) { } } dlog("debug", "window.setLyngSearchDebug(Boolean) is available in console") } catch (_: dynamic) { } } // externals moved to Externals.kt // Ensure global scroll offset styles and keep a CSS var with the real fixed-top navbar height. // This guarantees that scrolling to anchors or search hits is not hidden underneath the top bar. fun ensureScrollOffsetStyles() { try { val doc = window.document if (doc.getElementById("scroll-offset-style") == null) { val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement style.id = "scroll-offset-style" style.textContent = ( """ /* Keep a dynamic CSS variable with the measured navbar height */ :root { --navbar-offset: 56px; } /* Make native hash jumps and programmatic scroll account for the fixed header */ html, body { scroll-padding-top: calc(var(--navbar-offset) + 8px); } /* When scrolled into view, keep headings and any id-targeted element below the topbar */ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6, .markdown-body [id] { scroll-margin-top: calc(var(--navbar-offset) + 8px); } /* Also offset search highlights as they can be the initial scroll target */ mark.search-hit { scroll-margin-top: calc(var(--navbar-offset) + 8px) !important; } """ .trimIndent() ) doc.head?.appendChild(style) } } catch (_: Throwable) { // Best-effort } } // Measure the current fixed-top navbar height and update the CSS variable fun updateNavbarOffsetVar(): Int { return try { val doc = window.document val nav = doc.querySelector("nav.navbar.fixed-top") as? HTMLElement val px = if (nav != null) kotlin.math.round(nav.getBoundingClientRect().height).toInt() else 0 doc.documentElement?.let { root -> root.asDynamic().style?.setProperty?.invoke(root.asDynamic().style, "--navbar-offset", "${px}px") } px } catch (_: Throwable) { 0 } } // Ensure global CSS for search highlights is present (bright, visible everywhere) fun ensureSearchHighlightStyles() { try { val doc = window.document if (doc.getElementById("search-hit-style") == null) { val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement style.id = "search-hit-style" // Use strong colors and !important to outshine theme/code styles style.textContent = ( """ mark.search-hit { background: #ffeb3b !important; /* bright yellow */ color: #000 !important; padding: 0 .1em; border-radius: 2px; box-shadow: 0 0 0 2px #ffeb3b inset !important; } code mark.search-hit, pre mark.search-hit { background: #ffd54f !important; /* slightly deeper in code blocks */ color: #000 !important; box-shadow: 0 0 0 2px #ffd54f inset !important; } """ .trimIndent() ) doc.head?.appendChild(style) } } catch (_: Throwable) { // Best-effort; if styles can't be injected we still proceed } } // Ensure docs layout tweaks (reduce markdown body top margin to align with TOC) fun ensureDocsLayoutStyles() { try { val doc = window.document if (doc.getElementById("docs-layout-style") == null) { val style = doc.createElement("style") as org.w3c.dom.HTMLStyleElement style.id = "docs-layout-style" style.textContent = ( """ /* Align the markdown content top edge with the TOC */ .markdown-body { margin-top: 0 !important; /* remove extra outer spacing */ padding-top: 0 !important; /* override GitHub markdown default top padding */ } /* Ensure the first element inside markdown body doesn't add extra space */ .markdown-body > :first-child { margin-top: 0 !important; } /* Some markdown renderers give H1 extra top margin; neutralize when first */ .markdown-body h1:first-child { margin-top: 0 !important; } """ .trimIndent() ) doc.head?.appendChild(style) } } catch (_: Throwable) { // Best-effort } } // App() moved to App.kt // DocLink and UnsafeRawHtml moved to Components.kt fun currentRoute(): String { val h = window.location.hash // Support both "#/path" and "#path" formats val noHash = if (h.startsWith("#")) h.substring(1) else h return noHash.removePrefix("/") } fun routeToPath(route: String): String { val noParams = stripQuery(stripFragment(route)) return if (noParams.startsWith("docs/")) noParams else "docs/$noParams" } // Strip trailing fragment from a route like "docs/file.md#anchor" -> "docs/file.md" fun stripFragment(route: String): String = route.substringBefore('#') // Strip query from a route like "docs/file.md?q=term" -> "docs/file.md" fun stripQuery(route: String): String = route.substringBefore('?') // Extract lowercase search terms from route query string (?q=...) fun extractSearchTerms(route: String): List { val queryPart = route.substringAfter('?', "") if (queryPart.isEmpty()) return emptyList() val params = queryPart.split('&') val qParam = params.firstOrNull { it.startsWith("q=") }?.substringAfter('=') ?: return emptyList() val decoded = try { decodeURIComponent(qParam) } catch (_: dynamic) { qParam } return decoded.trim().split(Regex("\\s+")) .map { it.lowercase() } .filter { it.isNotEmpty() } } // Highlight words in root that start with any of the terms (case-insensitive). Returns count of hits. fun highlightSearchHits(root: HTMLElement, terms: List): Int { // Make sure CSS for highlighting is injected ensureSearchHighlightStyles() // Always remove previous highlights first so calling with empty terms clears them try { val prev = root.getElementsByClassName("search-hit") // Because HTMLCollection is live, copy to array first val arr = (0 until prev.length).mapNotNull { prev.item(it) as? HTMLElement }.toList() arr.forEach { mark -> val parent = mark.parentNode val textNode = root.ownerDocument!!.createTextNode(mark.textContent ?: "") parent?.replaceChild(textNode, mark) } } catch (_: Throwable) {} if (terms.isEmpty()) return 0 // Allow highlighting even inside CODE and PRE per request; still skip scripts, styles, and keyboard samples val skipTags = setOf("SCRIPT", "STYLE", "KBD", "SAMP") var hits = 0 fun processNode(node: org.w3c.dom.Node) { when (node.nodeType) { org.w3c.dom.Node.ELEMENT_NODE -> { val el = node.unsafeCast() if (skipTags.contains(el.tagName)) return if (el.classList.contains("no-search")) return // copy list as array, because modifying tree during iteration val children = mutableListOf() val cn = el.childNodes for (i in 0 until cn.length) children.add(cn.item(i)!!) children.forEach { processNode(it) } } org.w3c.dom.Node.TEXT_NODE -> { val text = node.nodeValue ?: return if (text.isBlank()) return // Tokenize by words and rebuild val parent = node.parentNode ?: return val doc = root.ownerDocument!! val container = doc.createDocumentFragment() var pos = 0 val wordRegex = Regex("[A-Za-z0-9_]+") for (m in wordRegex.findAll(text)) { val start = m.range.first val end = m.range.last + 1 if (start > pos) container.appendChild(doc.createTextNode(text.substring(pos, start))) val token = m.value val tokenLower = token.lowercase() // choose the longest term that is a prefix of the token var bestLen = 0 if (terms.isNotEmpty()) { for (t in terms) { if (t.isNotEmpty() && tokenLower.startsWith(t) && t.length > bestLen) bestLen = t.length } } if (bestLen > 0 && bestLen <= token.length) { val prefix = token.substring(0, bestLen) val suffix = token.substring(bestLen) val mark = doc.createElement("mark") as HTMLElement mark.className = "search-hit" mark.textContent = prefix container.appendChild(mark) if (suffix.isNotEmpty()) container.appendChild(doc.createTextNode(suffix)) hits++ } else { container.appendChild(doc.createTextNode(token)) } pos = end } if (pos < text.length) container.appendChild(doc.createTextNode(text.substring(pos))) parent.replaceChild(container, node) } } } processNode(root) return hits } fun renderMarkdown(src: String): String = net.sergeych.lyngweb.highlightLyngHtml( net.sergeych.lyngweb.ensureBootstrapCodeBlocks( ensureBootstrapTables( ensureDefinitionLists( marked.parse(src) ) ) ) ) // Suspend variant that uses the AST-backed highlighter for Lyng code blocks. suspend fun renderMarkdownAsync(src: String): String = net.sergeych.lyngweb.highlightLyngHtmlAsync( net.sergeych.lyngweb.ensureBootstrapCodeBlocks( ensureBootstrapTables( ensureDefinitionLists( marked.parse(src) ) ) ) ) // Pure function to render the Reference list HTML from a list of doc paths. // Returns a Bootstrap-styled
    list with links to the docs routes. fun renderReferenceListHtml(docs: List): String { if (docs.isEmpty()) return "

    No documents found.

    " val items = docs.sorted().joinToString(separator = "") { path -> val name = path.substringAfterLast('/') val dir = path.substringBeforeLast('/', "") buildString { append("
  • ") append("
    ") append("") append(name) append("") if (dir.isNotEmpty()) { append("
    ") append(dir) append("") } append("
    ") append("") append("
  • ") } } return "
      $items
    " } // PageTemplate moved to Components.kt // DocsPage moved to Pages.kt fun extractTitleFromMarkdown(md: String): String? { val lines = md.lines() val h1 = lines.firstOrNull { it.trimStart().startsWith("# ") } return h1?.trimStart()?.removePrefix("# ")?.trim() } suspend fun initDocsDropdown() { try { dlog("docs-dd", "initDocsDropdown start") val menu = document.getElementById("docsDropdownMenu") ?: run { dlog("docs-dd", "#docsDropdownMenu not found") return } // Fetch docs index val resp = window.fetch("docs-index.json").await() if (!resp.ok) { dlog("docs-dd", "docs-index.json fetch failed: status=${resp.status}") return } val text = resp.text().await() val arr = JSON.parse(text) as Array val all = arr.toList().sorted() dlog("docs-dd", "index entries=${all.size}") // Filter excluded by reading each markdown and looking for the marker, and collect titles val filtered = mutableListOf() val titles = mutableMapOf() var excluded = 0 var failed = 0 for (path in all) { try { val url = "./" + encodeURI(path) val r = window.fetch(url).await() if (!r.ok) { failed++ dlog("docs-dd", "fetch fail ${r.status} for $url") continue } val body = r.text().await() if (!body.contains("[//]: # (excludeFromIndex)") && body.contains("[//]: # (topMenu)") ) { filtered.add(path) // Reuse shared title extractor: first H1 or fallback to file name val title = extractTitleFromMarkdown(body) ?: path.substringAfterLast('/') titles[path] = title } else excluded++ } catch (t: Throwable) { failed++ dlog("docs-dd", "exception fetching $path : ${t.message}") } } dlog("docs-dd", "filtered=${filtered.size} excluded=$excluded failed=$failed") // Sort entries by display name (file name) case-insensitively val sortedFiltered = filtered.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.substringAfterLast('/') }) // Build items after the static first items (tutorial, optional static entries, divider) var appended = 0 sortedFiltered.forEach { path -> val name = titles[path] ?: path.substringAfterLast('/') val li = document.createElement("li") val a = document.createElement("a") as HTMLAnchorElement a.className = "dropdown-item" a.href = "#/$path" a.setAttribute("data-route", "docs") a.textContent = name // Ensure SPA navigation and close navbar collapse on small screens a.onclick = { ev -> ev.preventDefault() dlog("nav", "docs dropdown -> navigate #/$path") window.location.hash = "#/$path" closeNavbarCollapse() } li.appendChild(a) menu.appendChild(li) appended++ } dlog("docs-dd", "appended=$appended docs to dropdown") } catch (_: Throwable) { dlog("docs-dd", "exception during initDocsDropdown") } } private fun closeNavbarCollapse() { val collapse = document.getElementById("topbarNav") as? HTMLElement collapse?.classList?.remove("show") // update toggler aria-expanded if present val togglers = document.getElementsByClassName("navbar-toggler") if (togglers.length > 0) { val t = togglers.item(0) as? HTMLElement t?.setAttribute("aria-expanded", "false") } } // ---------------- Site-wide search (client-side) ---------------- internal data class DocRecord(val path: String, val title: String, val text: String) private var searchIndex: List? = null private var searchBuilding = false private var searchInitDone = false // ---- Search history (last 7 entries) ---- private const val SEARCH_HISTORY_KEY = "lyng.search.history" private fun loadSearchHistory(): MutableList { return try { val raw = window.localStorage.getItem(SEARCH_HISTORY_KEY) ?: return mutableListOf() // Stored as newline-separated values to avoid JSON pitfalls across browsers raw.split('\n').mapNotNull { it.trim() }.filter { it.isNotEmpty() }.toMutableList() } catch (_: Throwable) { mutableListOf() } } private fun saveSearchHistory(list: List) { try { window.localStorage.setItem(SEARCH_HISTORY_KEY, list.joinToString("\n")) } catch (_: Throwable) { } } internal fun rememberSearchQuery(q: String) { val query = q.trim() if (query.isBlank()) return val list = loadSearchHistory() list.removeAll { it.equals(query, ignoreCase = true) } list.add(0, query) while (list.size > 7) list.removeAt(list.lastIndex) saveSearchHistory(list) } internal fun norm(s: String): String = s.lowercase() .replace("`", " ") .replace("*", " ") .replace("#", " ") .replace("[", " ") .replace("]", " ") .replace("(", " ") .replace(")", " ") .replace(Regex("\\n+"), " ") .replace(Regex("\\s+"), " ").trim() internal fun plainFromMarkdown(md: String): String { // Construct Regex instances at call time inside try/catch to avoid module init crashes // in browsers that are strict about Unicode RegExp parsing (Safari/Chrome). // Use non-greedy dot-all equivalents ("[\n\r\s\S]") instead of character classes with ']' where possible. try { // Safer patterns (avoid unescaped ']' inside character classes): val reCodeBlocks = Regex("```[\\s\\S]*?```") val reInlineCode = Regex("`[^`]*`") val reBlockquote = Regex("^> +", setOf(RegexOption.MULTILINE)) val reHeadings = Regex("^#+ +", setOf(RegexOption.MULTILINE)) // Images: ![alt](url) — capture alt lazily with [\s\S]*? to avoid character class pitfalls val reImage = Regex("!\\[([\\s\\S]*?)]\\([^)]*\\)") // Links: [text](url) — same approach, keep the link text in group 1 val reLink = Regex("\\[([\\s\\S]*?)]\\([^)]*\\)") var t = md // Triple-backtick code blocks across lines t = t.replace(reCodeBlocks, " ") // Inline code t = t.replace(reInlineCode, " ") // Strip blockquotes and headings markers t = t.replace(reBlockquote, "") t = t.replace(reHeadings, "") // Images and links t = t.replace(reImage, " ") // Keep link text (group 1). Kotlin string needs "$" escaped once to pass "$1" t = t.replace(reLink, "\$1") return norm(t) } catch (e: Throwable) { dlog("search", "plainFromMarkdown error: ${e.message}") // Minimal safe fallback: strip code blocks and inline code, then normalize var t = md t = t.replace(Regex("```[\\s\\S]*?```"), " ") t = t.replace(Regex("`[^`]*`"), " ") return norm(t) } } private suspend fun buildSearchIndexOnce() { if (searchIndex != null || searchBuilding) return searchBuilding = true dlog("search", "buildSearchIndexOnce: start") try { val resp = window.fetch("docs-index.json").await() if (!resp.ok) { dlog("search", "docs-index.json fetch failed: status=${resp.status}") return } val text = resp.text().await() val arr = JSON.parse(text) as Array val all = arr.toList().sorted() dlog("search", "docs-index entries=${all.size}") val list = mutableListOf() var excluded = 0 var failed = 0 var loggedFailures = 0 for (path in all) { try { // Always fetch via a relative URL from site root and encode non-ASCII safely val url = "./" + encodeURI(path) val r = window.fetch(url).await() if (!r.ok) { failed++ if (loggedFailures < 3) { dlog("search", "fetch fail ${r.status} for $url") loggedFailures++ } continue } val body = r.text().await() if (body.contains("[//]: # (excludeFromIndex)")) { excluded++; continue } val title = extractTitleFromMarkdown(body) ?: path.substringAfterLast('/') val plain = plainFromMarkdown(body) list += DocRecord(path = path, title = title, text = plain) } catch (t: Throwable) { failed++ if (loggedFailures < 3) { dlog("search", "exception processing $path : ${t.message}") loggedFailures++ } } } searchIndex = list dlog("search", "buildSearchIndexOnce: done, indexed=${list.size} excluded=$excluded failed=$failed") list.take(5).forEachIndexed { i, rec -> dlog("search", "indexed[$i]: ${rec.path} | ${rec.title} (len=${rec.text.length})") } } catch (_: Throwable) { dlog("search", "buildSearchIndexOnce: exception") } finally { searchBuilding = false } } private fun scoreQuery(q: String, rec: DocRecord): Int { val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() } if (terms.isEmpty()) return 0 var score = 0 val title = norm(rec.title) val text = rec.text // Title bonuses: longer prefix matches get higher score for (t in terms) { if (title.startsWith(t)) score += 120 + t.length else if (title.split(Regex("[A-Za-z0-9_]+")).isEmpty()) { /* no-op */ } else { // title words prefix val wr = Regex("[A-Za-z0-9_]+") if (wr.findAll(title).any { it.value.startsWith(t) }) score += 70 + t.length } } // Body: count how many tokens start with any term, weight by term length (cap to avoid giant docs dominating) run { val wr = Regex("[A-Za-z0-9_]+") var matches = 0 for (m in wr.findAll(text)) { val token = m.value val tl = token.lowercase() var best = 0 for (t in terms) if (tl.startsWith(t) && t.length > best) best = t.length if (best > 0) { matches++ score += 2 * best if (matches >= 50) break // cap } } } // Prefer shorter files a bit score += (200 - kotlin.math.min(200, text.length / 500)) return score } private fun renderSearchHistoryDropdown(menu: HTMLDivElement) { val hist = loadSearchHistory() if (hist.isEmpty()) { menu.innerHTML = "
    No recent searches
    " } else { val items = buildString { hist.take(7).forEach { hq -> val safe = hq.replace("<", "<").replace(">", ">") append("") append(safe) append("") } } menu.innerHTML = items } menu.classList.add("show") } private fun hideSearchResults(menu: HTMLDivElement) { dlog("search-ui", "hideSearchResults") menu.classList.remove("show") menu.innerHTML = "" } private suspend fun performSearch(q: String): List { if (searchIndex == null) buildSearchIndexOnce() val idx = searchIndex ?: run { dlog("search", "performSearch: index is null after build attempt") return emptyList() } if (q.isBlank()) return emptyList() val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() } if (terms.isEmpty()) return emptyList() val wordRegex = Regex("[A-Za-z0-9_]+") fun hasPrefixWord(s: String): Boolean { for (m in wordRegex.findAll(s)) { val tl = m.value.lowercase() for (t in terms) if (tl.startsWith(t)) return true } return false } val res = idx.filter { rec -> hasPrefixWord(norm(rec.title)) || hasPrefixWord(rec.text) } dlog("search", "performSearch: q='$q' idx=${idx.size} -> ${res.size} results") return res } private fun debounce(scope: CoroutineScope, delayMs: Long, block: suspend () -> Unit): () -> Unit { var job: Job? = null return { job?.cancel() job = scope.launch { delay(delayMs) block() } } } fun initTopSearch(attempt: Int = 0) { if (searchInitDone) return val input = document.getElementById("topSearch") as? HTMLInputElement val menu = document.getElementById("topSearchMenu") as? HTMLDivElement if (input == null || menu == null) { // Retry a few times in case DOM is not fully ready yet if (attempt < 10) { dlog("init", "initTopSearch: missing nodes (input=$input, menu=$menu) retry $attempt") window.setTimeout({ initTopSearch(attempt + 1) }, 50) } return } dlog("init", "initTopSearch: wiring handlers") val scope = MainScopeProvider.scope // Debounced dropdown history refresher val runSearch = debounce(scope, 120L) { renderSearchHistoryDropdown(menu) } // Keep the input focused when interacting with the dropdown so it doesn't blur/close menu.onmousedown = { ev -> ev.preventDefault() input.focus() } input.oninput = { dlog("event", "search oninput value='${input.value}'") runSearch() } input.onfocus = { dlog("event", "search onfocus: show history") renderSearchHistoryDropdown(menu) } input.onkeydown = { ev -> val key = ev.asDynamic().key as String dlog("event", "search onkeydown key='$key'") when (ev.asDynamic().key as String) { "Escape" -> { hideSearchResults(menu) } "Enter" -> { val q = input.value.trim() if (q.isNotBlank()) { rememberSearchQuery(q) val url = "#/search?q=" + encodeURIComponent(q) dlog("nav", "Enter -> navigate $url") window.location.hash = url hideSearchResults(menu) closeNavbarCollapse() } } } } // Hide on blur after a short delay to allow click input.onblur = { dlog("event", "search onblur -> hide after delay") window.setTimeout({ hideSearchResults(menu) }, 150) } searchInitDone = true dlog("init", "initTopSearch: done") } // Provide a global coroutine scope for utilities without introducing a framework private object MainScopeProvider { val scope: CoroutineScope by lazy { kotlinx.coroutines.MainScope() } } // ReferencePage moved to ReferencePage.kt // HomePage moved to HomePage.kt // ---- Theme handling: follow system theme automatically ---- private fun applyTheme(isDark: Boolean) { // Toggle Bootstrap theme attribute document.body?.setAttribute("data-bs-theme", if (isDark) "dark" else "light") // Toggle GitHub Markdown CSS light/dark val light = document.getElementById("md-light") as? HTMLLinkElement val dark = document.getElementById("md-dark") as? HTMLLinkElement if (isDark) { light?.setAttribute("disabled", "") dark?.removeAttribute("disabled") } else { dark?.setAttribute("disabled", "") light?.removeAttribute("disabled") } } private fun initAutoTheme() { val mql = try { window.matchMedia("(prefers-color-scheme: dark)") } catch (_: Throwable) { null } if (mql == null) { applyTheme(false) return } // Set initial applyTheme(mql.matches) // React to changes (modern browsers) try { mql.addEventListener("change", { ev -> val isDark = try { (ev.asDynamic().matches as Boolean) } catch (_: Throwable) { mql.matches } applyTheme(isDark) }) } catch (_: Throwable) { // Legacy API fallback try { (mql.asDynamic()).addListener { mq: dynamic -> val isDark = try { mq.matches as Boolean } catch (_: Throwable) { false } applyTheme(isDark) } } catch (_: Throwable) {} } } // Convert pseudo Markdown definition lists rendered by marked as paragraphs into proper
    structures. // Pattern supported (common in many MD flavors): // Term\n // : Definition paragraph 1\n // : Definition paragraph 2 ... // After marked parses it, it becomes: //

    Term

    \n

    : Definition paragraph 1

    \n

    : Definition paragraph 2

    // We transform such consecutive blocks into: //
    Term
    Definition paragraph 1
    Definition paragraph 2
    private fun ensureDefinitionLists(html: String): String { // We operate per

    block, and if its inner HTML contains newline-separated lines // in the form: // Term\n: Def1\n: Def2 // we convert this

    into a

    ...
    val pBlock = Regex("""

    ([\s\S]*?)

    """, setOf(RegexOption.IGNORE_CASE)) return pBlock.replace(html) { match -> val inner = match.groupValues[1] val lines = inner.split(Regex("\r?\n")) if (lines.isEmpty()) return@replace match.value val term = lines.first().trim() if (term.startsWith(":")) return@replace match.value // cannot start with ':' val defs = lines.drop(1) .map { it.trim() } .filter { it.startsWith(":") } .map { s -> // remove leading ':' and optional single leading space val t = s.removePrefix(":") if (t.startsWith(' ')) t.substring(1) else t } if (defs.isEmpty()) return@replace match.value buildString { append("
    ") append(term) append("
    ") defs.forEach { d -> append("
    ") append(d) append("
    ") } append("
    ") } } } // Ensure all markdown-rendered tables use Bootstrap styling private fun ensureBootstrapTables(html: String): String { // Match opening tags (case-insensitive) val tableTagRegex = Regex("]*)?>", RegexOption.IGNORE_CASE) val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE) return tableTagRegex.replace(html) { match -> val attrs = match.groups[1]?.value ?: "" if (attrs.isBlank()) return@replace "
    " // If class attribute exists, append 'table' if not already present var newAttrs = attrs val m = classAttrRegex.find(attrs) if (m != null) { val quote = m.groupValues[1] val classes = m.groupValues[2] val hasTable = classes.split("\\s+".toRegex()).any { it.equals("table", ignoreCase = false) } if (!hasTable) { val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "table" else "$it table" }) + quote newAttrs = attrs.replaceRange(m.range, updated) } } else { // No class attribute, insert one at the beginning newAttrs = " class=\"table\"" + attrs } "" } } // Ensure all markdown-rendered code blocks (
    ...
    ) have Bootstrap-like `.code` class fun ensureBootstrapCodeBlocks(html: String): String { // Target opening
     tags (case-insensitive)
        val preTagRegex = Regex("]*)?>", RegexOption.IGNORE_CASE)
        val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)
    
        return preTagRegex.replace(html) { match ->
            val attrs = match.groups[1]?.value ?: ""
            if (attrs.isBlank()) return@replace "
    "
    
            var newAttrs = attrs
            val m = classAttrRegex.find(attrs)
            if (m != null) {
                val quote = m.groupValues[1]
                val classes = m.groupValues[2]
                val hasCode = classes.split("\\s+".toRegex()).any { it.equals("code", ignoreCase = false) }
                if (!hasCode) {
                    val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
                    newAttrs = attrs.replaceRange(m.range, updated)
                }
            } else {
                // No class attribute, insert one at the beginning
                newAttrs = " class=\"code\"" + attrs
            }
            ""
        }
    }
    
    // moved highlighting utilities to :lyngweb
    
    fun rewriteImages(root: HTMLElement, basePath: String) {
        val imgs = root.querySelectorAll("img")
        for (i in 0 until imgs.length) {
            val el = imgs.item(i) as? HTMLImageElement ?: continue
            val src = el.getAttribute("src") ?: continue
            if (src.startsWith("http") || src.startsWith("/") || src.startsWith("#")) continue
            el.setAttribute("src", normalizePath("$basePath/$src"))
        }
    }
    
    fun rewriteAnchors(
        root: HTMLElement,
        basePath: String,
        currentDocPath: String,
        navigate: (String) -> Unit
    ) {
        val asEl = root.querySelectorAll("a")
        for (i in 0 until asEl.length) {
            val a = asEl.item(i) as? HTMLAnchorElement ?: continue
            // Skip the inline Docs back button we inject before the first H1
            if (a.classList.contains("doc-back-btn") || a.getAttribute("data-no-spa") == "true") continue
            val href = a.getAttribute("href") ?: continue
            // Skip external and already-SPA hashes
            if (
                href.startsWith("http:") || href.startsWith("https:") ||
                href.startsWith("mailto:") || href.startsWith("tel:") ||
                href.startsWith("javascript:") || href.startsWith("/") ||
                href.startsWith("#/")
            ) continue
            if (href.startsWith("#")) {
                // Intra-page link: convert to SPA hash including current document route
                val frag = href.removePrefix("#")
                val route = "$currentDocPath#$frag"
                a.setAttribute("href", "#/$route")
                a.onclick = { ev ->
                    ev.preventDefault()
                    navigate(route)
                }
                continue
            }
            if (href.contains(".md")) {
                val parts = href.split('#', limit = 2)
                val mdPath = parts[0]
                val frag = if (parts.size > 1) parts[1] else null
                val target = if (mdPath.startsWith("docs/")) {
                    normalizePath(mdPath)
                } else {
                    normalizePath("$basePath/$mdPath")
                }
                val route = if (frag.isNullOrBlank()) {
                    target
                } else {
                    "$target#$frag"
                }
                a.setAttribute("href", "#/$route")
                a.onclick = { ev ->
                    ev.preventDefault()
                    navigate(route)
                }
            } else {
                // Non-md relative link: make it relative to the md file location
                a.setAttribute("href", normalizePath("$basePath/$href"))
            }
        }
    }
    
    fun buildToc(root: HTMLElement): List {
        val out = mutableListOf()
        val used = hashSetOf()
        val hs = root.querySelectorAll("h1, h2, h3")
        for (i in 0 until hs.length) {
            val h = hs.item(i) as? HTMLHeadingElement ?: continue
            val level = when (h.tagName.uppercase()) {
                "H1" -> 1
                "H2" -> 2
                else -> 3
            }
            var id = h.id.ifBlank { slugify(h.textContent ?: "") }
            if (id.isBlank()) id = "section-${i + 1}"
            var unique = id
            var n = 2
            while (!used.add(unique)) {
                unique = "$id-$n"
                n++
            }
            h.id = unique
            out += TocItem(level, unique, h.textContent ?: "")
        }
        return out
    }
    
    internal fun slugify(s: String): String = s.lowercase()
        .replace("[^a-z0-9 _-]".toRegex(), "")
        .trim()
        .replace("[\n\r\t ]+".toRegex(), "-")
    
    internal fun normalizePath(path: String): String {
        val parts = mutableListOf()
        val raw = path.split('/')
        for (p in raw) {
            when (p) {
                "", "." -> {}
                ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.size - 1)
                else -> parts += p
            }
        }
        return parts.joinToString("/")
    }
    
    // ---- Scrollspy helpers ----
    // Given a list of heading top positions relative to viewport (in px),
    // returns the index of the active section using an offset. The active section
    // is the last heading whose top is above or at the offset line.
    // If none are above the offset, returns 0. If list is empty, returns 0.
    fun activeIndexForTops(tops: List, offsetPx: Double): Int {
        if (tops.isEmpty()) return 0
        for (i in tops.indices) {
            if (tops[i] - offsetPx > 0.0) return i
        }
        // If all headings are above the offset, select the last one
        return tops.size - 1
    }
    
    fun main() {
        // Initialize automatic system theme before rendering UI
        initAutoTheme()
        renderComposable(rootElementId = "root") { App() }
    }
    
    // Extract anchor fragment from a window location hash of the form
    // "#/docs/path.md#anchor" -> "anchor"; returns null if none
    fun anchorFromHash(hash: String): String? {
        if (!hash.startsWith("#/")) return null
        val idx = hash.indexOf('#', startIndex = 2) // look for second '#'
        return if (idx >= 0 && idx + 1 < hash.length) hash.substring(idx + 1) else null
    }