site search
This commit is contained in:
parent
1fadc42414
commit
b82af3dceb
@ -53,7 +53,8 @@ kotlin {
|
|||||||
implementation(project(":lynglib"))
|
implementation(project(":lynglib"))
|
||||||
// Markdown parser (NPM)
|
// Markdown parser (NPM)
|
||||||
implementation(npm("marked", "12.0.2"))
|
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
|
// Serve project docs and images as static resources in the site
|
||||||
resources.srcDir(rootProject.projectDir.resolve("docs"))
|
resources.srcDir(rootProject.projectDir.resolve("docs"))
|
||||||
|
|||||||
@ -19,23 +19,90 @@ import androidx.compose.runtime.*
|
|||||||
import externals.marked
|
import externals.marked
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
import kotlinx.browser.window
|
import kotlinx.browser.window
|
||||||
import kotlinx.coroutines.await
|
import kotlinx.coroutines.*
|
||||||
import org.jetbrains.compose.web.dom.*
|
import org.jetbrains.compose.web.dom.*
|
||||||
import org.jetbrains.compose.web.renderComposable
|
import org.jetbrains.compose.web.renderComposable
|
||||||
import org.w3c.dom.HTMLAnchorElement
|
import org.w3c.dom.HTMLAnchorElement
|
||||||
|
import org.w3c.dom.HTMLDivElement
|
||||||
import org.w3c.dom.HTMLElement
|
import org.w3c.dom.HTMLElement
|
||||||
import org.w3c.dom.HTMLHeadingElement
|
import org.w3c.dom.HTMLHeadingElement
|
||||||
import org.w3c.dom.HTMLImageElement
|
import org.w3c.dom.HTMLImageElement
|
||||||
|
import org.w3c.dom.HTMLInputElement
|
||||||
import org.w3c.dom.HTMLLinkElement
|
import org.w3c.dom.HTMLLinkElement
|
||||||
|
|
||||||
data class TocItem(val level: Int, val id: String, val title: String)
|
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)
|
// MathJax v3 global API (loaded via CDN in index.html)
|
||||||
external object MathJax {
|
external object MathJax {
|
||||||
fun typesetPromise(elements: Array<dynamic> = definedExternally): dynamic
|
fun typesetPromise(elements: Array<dynamic> = definedExternally): dynamic
|
||||||
fun typeset(elements: Array<dynamic> = definedExternally)
|
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
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
var route by remember { mutableStateOf(currentRoute()) }
|
var route by remember { mutableStateOf(currentRoute()) }
|
||||||
@ -50,9 +117,16 @@ fun App() {
|
|||||||
|
|
||||||
// Initialize dynamic Documentation dropdown once
|
// Initialize dynamic Documentation dropdown once
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
dlog("init", "initDocsDropdown()")
|
||||||
initDocsDropdown()
|
initDocsDropdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize site-wide search (lazy index build on first use)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dlog("init", "initTopSearch()")
|
||||||
|
initTopSearch()
|
||||||
|
}
|
||||||
|
|
||||||
// Listen to hash changes (routing)
|
// Listen to hash changes (routing)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val listener: (org.w3c.dom.events.Event) -> Unit = {
|
val listener: (org.w3c.dom.events.Event) -> Unit = {
|
||||||
@ -282,7 +356,9 @@ private fun DocsPage(
|
|||||||
|
|
||||||
val path = routeToPath(route)
|
val path = routeToPath(route)
|
||||||
try {
|
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) {
|
if (!resp.ok) {
|
||||||
setError("Not found: $path (${resp.status})")
|
setError("Not found: $path (${resp.status})")
|
||||||
} else {
|
} else {
|
||||||
@ -411,28 +487,48 @@ private fun extractTitleFromMarkdown(md: String): String? {
|
|||||||
|
|
||||||
private suspend fun initDocsDropdown() {
|
private suspend fun initDocsDropdown() {
|
||||||
try {
|
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
|
// Fetch docs index
|
||||||
val resp = window.fetch("docs-index.json").await()
|
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 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()
|
val all = arr.toList().sorted()
|
||||||
|
dlog("docs-dd", "index entries=${all.size}")
|
||||||
// Filter excluded by reading each markdown and looking for the marker
|
// Filter excluded by reading each markdown and looking for the marker
|
||||||
val filtered = mutableListOf<String>()
|
val filtered = mutableListOf<String>()
|
||||||
|
var excluded = 0
|
||||||
|
var failed = 0
|
||||||
for (path in all) {
|
for (path in all) {
|
||||||
try {
|
try {
|
||||||
val r = window.fetch(path).await()
|
val url = "./" + encodeURI(path)
|
||||||
if (!r.ok) continue
|
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()
|
val body = r.text().await()
|
||||||
if (!body.contains("[//]: # (excludeFromIndex)")) {
|
if (!body.contains("[//]: # (excludeFromIndex)")) {
|
||||||
filtered.add(path)
|
filtered.add(path)
|
||||||
}
|
} else excluded++
|
||||||
} catch (_: Throwable) {}
|
} 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
|
// Sort entries by display name (file name) case-insensitively
|
||||||
val sortedFiltered = filtered.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.substringAfterLast('/') })
|
val sortedFiltered = filtered.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.substringAfterLast('/') })
|
||||||
// Build items after the static first two existing children (tutorial + divider)
|
// Build items after the static first two existing children (tutorial + divider)
|
||||||
|
var appended = 0
|
||||||
sortedFiltered.forEach { path ->
|
sortedFiltered.forEach { path ->
|
||||||
val name = path.substringAfterLast('/')
|
val name = path.substringAfterLast('/')
|
||||||
val li = document.createElement("li")
|
val li = document.createElement("li")
|
||||||
@ -444,13 +540,17 @@ private suspend fun initDocsDropdown() {
|
|||||||
// Ensure SPA navigation and close navbar collapse on small screens
|
// Ensure SPA navigation and close navbar collapse on small screens
|
||||||
a.onclick = { ev ->
|
a.onclick = { ev ->
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
dlog("nav", "docs dropdown -> navigate #/$path")
|
||||||
window.location.hash = "#/$path"
|
window.location.hash = "#/$path"
|
||||||
closeNavbarCollapse()
|
closeNavbarCollapse()
|
||||||
}
|
}
|
||||||
li.appendChild(a)
|
li.appendChild(a)
|
||||||
menu.appendChild(li)
|
menu.appendChild(li)
|
||||||
|
appended++
|
||||||
}
|
}
|
||||||
|
dlog("docs-dd", "appended=$appended docs to dropdown")
|
||||||
} catch (_: Throwable) {
|
} 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:  — 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("<", "<").replace(">", ">")
|
||||||
|
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
|
@Composable
|
||||||
private fun ReferencePage() {
|
private fun ReferencePage() {
|
||||||
var docs by remember { mutableStateOf<List<String>?>(null) }
|
var docs by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
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>
|
<script>
|
||||||
window.MathJax = {
|
window.MathJax = {
|
||||||
tex: {
|
tex: {
|
||||||
@ -62,7 +62,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
<style>
|
||||||
/* Reserve space for fixed navbar and ensure in-page anchors don't end up hidden under it */
|
/* Reserve space for fixed navbar and ensure in-page anchors don't end up hidden under it */
|
||||||
:root { --navbar-offset: 56px; }
|
:root { --navbar-offset: 56px; }
|
||||||
@ -179,7 +179,7 @@
|
|||||||
<div class="ms-auto d-flex align-items-center gap-2" style="min-width: 240px;">
|
<div class="ms-auto d-flex align-items-center gap-2" style="min-width: 240px;">
|
||||||
<!-- Search widget with Bootstrap Icons magnifying glass -->
|
<!-- Search widget with Bootstrap Icons magnifying glass -->
|
||||||
<form class="d-flex" role="search" onsubmit="return false;">
|
<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">
|
<span class="input-group-text bg-transparent border-end-0">
|
||||||
<i class="bi bi-search"></i>
|
<i class="bi bi-search"></i>
|
||||||
</span>
|
</span>
|
||||||
@ -190,10 +190,13 @@
|
|||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
aria-label="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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="https://github.com/sergeych/lyng" target="_blank" rel="noopener" aria-label="GitHub">
|
<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-github"></i>
|
<i class="bi bi-git"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user