site search

This commit is contained in:
Sergey Chernov 2025-11-19 23:22:12 +01:00
parent 1fadc42414
commit b82af3dceb
3 changed files with 422 additions and 15 deletions

View File

@ -53,7 +53,8 @@ kotlin {
implementation(project(":lynglib"))
// Markdown parser (NPM)
implementation(npm("marked", "12.0.2"))
// MathJax is loaded via CDN in index.html; no npm dependency required
// Self-host MathJax via npm and bundle it with webpack
implementation(npm("mathjax", "3.2.2"))
}
// Serve project docs and images as static resources in the site
resources.srcDir(rootProject.projectDir.resolve("docs"))

View File

@ -19,23 +19,90 @@ import androidx.compose.runtime.*
import externals.marked
import kotlinx.browser.document
import kotlinx.browser.window
import kotlinx.coroutines.await
import kotlinx.coroutines.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLAnchorElement
import org.w3c.dom.HTMLDivElement
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLHeadingElement
import org.w3c.dom.HTMLImageElement
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLLinkElement
data class TocItem(val level: Int, val id: String, val title: String)
// --------------- Lightweight debug logging ---------------
// Disable debug logging by default
private var SEARCH_DEBUG: Boolean = false
private 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) { }
}
// MathJax v3 global API (loaded via CDN in index.html)
external object MathJax {
fun typesetPromise(elements: Array<dynamic> = definedExternally): dynamic
fun typeset(elements: Array<dynamic> = definedExternally)
}
// Ensure MathJax loader is bundled (self-host): importing the ES5 CHTML bundle has side effects
@JsModule("mathjax/es5/tex-chtml.js")
@JsNonModule
external val mathjaxBundle: dynamic
// JS JSON parser binding (avoid inline js("JSON.parse(...)"))
external object JSON {
fun parse(text: String): dynamic
}
// JS global encodeURI binding (to safely request paths that may contain non-ASCII)
external fun encodeURI(uri: String): String
@Composable
fun App() {
var route by remember { mutableStateOf(currentRoute()) }
@ -50,9 +117,16 @@ fun App() {
// Initialize dynamic Documentation dropdown once
LaunchedEffect(Unit) {
dlog("init", "initDocsDropdown()")
initDocsDropdown()
}
// Initialize site-wide search (lazy index build on first use)
LaunchedEffect(Unit) {
dlog("init", "initTopSearch()")
initTopSearch()
}
// Listen to hash changes (routing)
DisposableEffect(Unit) {
val listener: (org.w3c.dom.events.Event) -> Unit = {
@ -282,7 +356,9 @@ private fun DocsPage(
val path = routeToPath(route)
try {
val resp = window.fetch(path).await()
// Use encoded relative URL to handle non-ASCII filenames and ensure correct base
val url = "./" + encodeURI(path)
val resp = window.fetch(url).await()
if (!resp.ok) {
setError("Not found: $path (${resp.status})")
} else {
@ -411,28 +487,48 @@ private fun extractTitleFromMarkdown(md: String): String? {
private suspend fun initDocsDropdown() {
try {
val menu = document.getElementById("docsDropdownMenu") ?: return
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) return
if (!resp.ok) {
dlog("docs-dd", "docs-index.json fetch failed: status=${resp.status}")
return
}
val text = resp.text().await()
val arr = js("JSON.parse(text)") as Array<String>
val arr = JSON.parse(text) as Array<String>
val all = arr.toList().sorted()
dlog("docs-dd", "index entries=${all.size}")
// Filter excluded by reading each markdown and looking for the marker
val filtered = mutableListOf<String>()
var excluded = 0
var failed = 0
for (path in all) {
try {
val r = window.fetch(path).await()
if (!r.ok) continue
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)")) {
filtered.add(path)
}
} catch (_: Throwable) {}
} 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 two existing children (tutorial + divider)
var appended = 0
sortedFiltered.forEach { path ->
val name = path.substringAfterLast('/')
val li = document.createElement("li")
@ -444,13 +540,17 @@ private suspend fun initDocsDropdown() {
// 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")
}
}
@ -465,6 +565,309 @@ private fun closeNavbarCollapse() {
}
}
// ---------------- Site-wide search (client-side) ----------------
private data class DocRecord(val path: String, val title: String, val text: String)
private var searchIndex: List<DocRecord>? = null
private var searchBuilding = false
private var searchInitDone = false
private fun norm(s: String): String = s.lowercase()
.replace("`", " ")
.replace("*", " ")
.replace("#", " ")
.replace("[", " ")
.replace("]", " ")
.replace("(", " ")
.replace(")", " ")
.replace(Regex("\\n+"), " ")
.replace(Regex("\\s+"), " ").trim()
private 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<String>
val all = arr.toList().sorted()
dlog("search", "docs-index entries=${all.size}")
val list = mutableListOf<DocRecord>()
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 query = norm(q)
if (query.isBlank()) return 0
var score = 0
val title = norm(rec.title)
val text = rec.text
// Title startsWith gets high score
if (title.startsWith(query)) score += 100
if (title.contains(query)) score += 60
// Body occurrences (basic)
val idx = text.indexOf(query)
if (idx >= 0) score += 30
// Shorter files slightly preferred
score += (200 - kotlin.math.min(200, text.length / 500))
return score
}
private fun renderSearchResults(input: HTMLInputElement, menu: HTMLDivElement, q: String, results: List<DocRecord>) {
dlog("search-ui", "renderSearchResults q='$q' results=${results.size} building=$searchBuilding")
if (q.isBlank()) {
dlog("search-ui", "blank query -> hide menu")
menu.classList.remove("show")
menu.innerHTML = ""
return
}
if (results.isEmpty()) {
// If index is building, show a transient indexing indicator instead of hiding everything
if (searchBuilding) {
dlog("search-ui", "indexing in progress -> show placeholder")
menu.innerHTML = "<div class=\"dropdown-item disabled\">Indexing documentation…</div>"
menu.classList.add("show")
} else {
dlog("search-ui", "no results -> show 'No results' item")
val safeQ = q.replace("<", "&lt;").replace(">", "&gt;")
menu.innerHTML = "<div class=\"dropdown-item disabled\">No results for ‘$safeQ’</div>"
menu.classList.add("show")
}
return
}
val top = results.sortedByDescending { scoreQuery(q, it) }.take(8)
val items = buildString {
top.forEach { rec ->
append("<a href=\"#/${rec.path}\" class=\"dropdown-item\" data-path=\"${rec.path}\">")
append("<strong>")
append(rec.title)
append("</strong><br><small class=\"text-muted\">")
append(rec.path.substringAfter("docs/"))
append("</small></a>")
}
}
menu.innerHTML = items
// Position and show
menu.classList.add("show")
// Attach click handlers to enforce SPA navigation
val children = menu.getElementsByClassName("dropdown-item")
for (i in 0 until children.length) {
val a = children.item(i) as? HTMLAnchorElement ?: continue
a.onclick = { ev ->
ev.preventDefault()
val path = a.getAttribute("data-path")
if (path != null) {
dlog("nav", "search click -> navigate #/$path")
window.location.hash = "#/$path"
menu.classList.remove("show")
closeNavbarCollapse()
}
}
}
}
private fun hideSearchResults(menu: HTMLDivElement) {
dlog("search-ui", "hideSearchResults")
menu.classList.remove("show")
menu.innerHTML = ""
}
private suspend fun performSearch(q: String): List<DocRecord> {
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 query = norm(q)
val res = idx.filter { rec ->
norm(rec.title).contains(query) || rec.text.contains(query)
}
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()
}
}
}
private 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 search runner
val runSearch = debounce(scope, 120L) {
val q = input.value
dlog("search", "debounced runSearch execute q='$q'")
val results = performSearch(q)
renderSearchResults(input, menu, q, results)
}
// 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 = {
// Proactively build the index on first focus for faster first results
dlog("event", "search onfocus")
scope.launch {
if (searchIndex == null && !searchBuilding) {
dlog("search", "onfocus -> buildSearchIndexOnce")
buildSearchIndexOnce()
}
}
runSearch()
}
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" -> {
// Navigate to the best match
scope.launch {
val q = input.value
// If index is building and results would be empty, wait for it once
if (searchIndex == null || searchBuilding) {
dlog("search", "Enter -> ensure index")
buildSearchIndexOnce()
}
val results = performSearch(q)
val best = results.maxByOrNull { scoreQuery(q, it) }
if (best != null) {
dlog("nav", "Enter -> navigate #/${best.path}")
window.location.hash = "#/${best.path}"
hideSearchResults(menu)
closeNavbarCollapse()
} else {
dlog("search", "Enter -> no results for q='$q'")
}
}
}
}
}
// 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() }
}
@Composable
private fun ReferencePage() {
var docs by remember { mutableStateOf<List<String>?>(null) }

View File

@ -45,7 +45,7 @@
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
<!-- MathJax v3 configuration and loader (replace KaTeX) -->
<!-- MathJax v3 configuration (the loader is bundled via npm/webpack) -->
<script>
window.MathJax = {
tex: {
@ -62,7 +62,7 @@
}
};
</script>
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
<!-- Loader is imported in the app bundle; no external script tag required -->
<style>
/* Reserve space for fixed navbar and ensure in-page anchors don't end up hidden under it */
:root { --navbar-offset: 56px; }
@ -179,7 +179,7 @@
<div class="ms-auto d-flex align-items-center gap-2" style="min-width: 240px;">
<!-- Search widget with Bootstrap Icons magnifying glass -->
<form class="d-flex" role="search" onsubmit="return false;">
<div class="input-group">
<div class="input-group position-relative" style="min-width: 280px;">
<span class="input-group-text bg-transparent border-end-0">
<i class="bi bi-search"></i>
</span>
@ -190,10 +190,13 @@
placeholder="Search"
aria-label="Search"
/>
<!-- Results dropdown (controlled by app code) -->
<div id="topSearchMenu" class="dropdown-menu w-100 shadow" style="max-height:60vh; overflow:auto; top: calc(100% + .25rem); left: 0;">
</div>
</div>
</form>
<a class="btn btn-sm btn-outline-secondary" href="https://github.com/sergeych/lyng" target="_blank" rel="noopener" aria-label="GitHub">
<i class="bi bi-github"></i>
<a class="btn btn-sm btn-outline-secondary" href="https://gitea.sergeych.net/SergeychWorks/lyng" target="_blank" rel="noopener" aria-label="GitHub">
<i class="bi bi-git"></i>
</a>
</div>
</div>