From 215c7245a05ff557992a1555198794b449ad0aa7 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 20 Nov 2025 01:00:32 +0100 Subject: [PATCH] Enhance site navigation with improved search highlighting, refined navbar offset handling, query-based search term extraction, and additional docs layout adjustments. Also, add new resources and simplify deployment script paths. --- bin/deploy_site | 10 +- site/src/jsMain/kotlin/Main.kt | 250 +++++++++++++++++- site/src/jsMain/resources/index.html | 8 +- .../jsMain/resources/student-cap-black.svg | 37 +++ .../jsMain/resources/student-cap-white.svg | 37 +++ 5 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 site/src/jsMain/resources/student-cap-black.svg create mode 100644 site/src/jsMain/resources/student-cap-white.svg diff --git a/bin/deploy_site b/bin/deploy_site index 6fc4342..89b3d94 100755 --- a/bin/deploy_site +++ b/bin/deploy_site @@ -28,7 +28,7 @@ function checkState() { } # default target settings -case $1 in +case "com" in com) SSH_HOST=sergeych@lynglang.com # host to deploy to SSH_PORT=22 # ssh port on it @@ -48,11 +48,7 @@ die() { echo "ERROR: $*" 1>&2 ; exit 1; } #rm build/distributions/*.js > /dev/null #rm build/distributions/*.js.map > /dev/null -#./gradlew clean incrementRevision jsBrowserDistribution || die "compilation failed" -#./gradlew incrementRevision jsBrowserDistribution -#./gradlew jsBrowserDistribution -./gradlew incrementRevision jsBrowserDistribution -#./gradlew jsBrowserDistribution +./gradlew site:clean site:jsBrowserDistribution || die "compilation failed" if [[ $? != 0 ]]; then echo @@ -78,7 +74,7 @@ ssh -p ${SSH_PORT} ${SSH_HOST} " "; # sync files -SRC=./build/dist/js/productionExecutable +SRC=./site/build/dist/js/productionExecutable rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete ${SRC}/* ${SSH_HOST}:${ROOT}/build/dist checkState rsync -e "ssh -p ${SSH_PORT}" -avz ./static/* ${SSH_HOST}:${ROOT}/build/dist diff --git a/site/src/jsMain/kotlin/Main.kt b/site/src/jsMain/kotlin/Main.kt index 85ccd97..d3778e3 100644 --- a/site/src/jsMain/kotlin/Main.kt +++ b/site/src/jsMain/kotlin/Main.kt @@ -102,6 +102,123 @@ external object JSON { // JS global encodeURI binding (to safely request paths that may contain non-ASCII) external fun encodeURI(uri: String): String +// URL encoding helpers for query parameters +external fun encodeURIComponent(s: String): String +external fun decodeURIComponent(s: String): String + +// 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. +private 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 +private 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) +private 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) +private 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 + } +} @Composable fun App() { @@ -127,6 +244,24 @@ fun App() { initTopSearch() } + // Ensure global docs layout styles are present once + LaunchedEffect(Unit) { + ensureDocsLayoutStyles() + } + + // Ensure scroll offset styles exist and keep navbar offset in sync + LaunchedEffect(Unit) { + ensureScrollOffsetStyles() + updateNavbarOffsetVar() + } + + // Recompute navbar offset on resize and when effect is disposed + DisposableEffect(Unit) { + val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() } + window.addEventListener("resize", handler) + onDispose { window.removeEventListener("resize", handler) } + } + // Listen to hash changes (routing) DisposableEffect(Unit) { val listener: (org.w3c.dom.events.Event) -> Unit = { @@ -248,13 +383,97 @@ private fun UnsafeRawHtml(html: String) { fun currentRoute(): String = window.location.hash.removePrefix("#/") fun routeToPath(route: String): String { - val noFrag = stripFragment(route) - return if (noFrag.startsWith("docs/")) noFrag else "docs/$noFrag" + 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 { + if (terms.isEmpty()) return 0 + // Make sure CSS for highlighting is injected + ensureSearchHighlightStyles() + // Remove previous highlights + 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) {} + + // 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() + val match = terms.any { tokenLower.startsWith(it) } + if (match) { + val mark = doc.createElement("mark") as HTMLElement + mark.className = "search-hit" + mark.textContent = token + container.appendChild(mark) + 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 = highlightLyngHtml( ensureBootstrapCodeBlocks( @@ -450,6 +669,19 @@ private fun DocsPage( val initialId = frag ?: newToc.firstOrNull()?.id setActiveTocId(initialId) + // Highlight search hits if navigated via search (?q=...) and scroll to the first occurrence + val terms = extractSearchTerms(route) + if (terms.isNotEmpty()) { + // Perform highlighting after typesetting and TOC build + val count = try { highlightSearchHits(el, terms) } catch (_: Throwable) { 0 } + if (count > 0 && frag == null) { + try { + val first = el.getElementsByClassName("search-hit").item(0) as? HTMLElement + first?.scrollIntoView() + } catch (_: Throwable) {} + } + } + if (!frag.isNullOrBlank()) { val target = el.ownerDocument?.getElementById(frag) (target as? HTMLElement)?.scrollIntoView() @@ -766,8 +998,11 @@ private fun renderSearchResults(input: HTMLInputElement, menu: HTMLDivElement, q ev.preventDefault() val path = a.getAttribute("data-path") if (path != null) { - dlog("nav", "search click -> navigate #/$path") - window.location.hash = "#/$path" + val inputEl = input + val q = inputEl.value + val suffix = if (q.isNotBlank()) "?q=" + encodeURIComponent(q) else "" + dlog("nav", "search click -> navigate #/$path$suffix") + window.location.hash = "#/$path$suffix" menu.classList.remove("show") closeNavbarCollapse() } @@ -870,8 +1105,9 @@ private fun initTopSearch(attempt: Int = 0) { 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}" + val suffix = if (q.isNotBlank()) "?q=" + encodeURIComponent(q) else "" + dlog("nav", "Enter -> navigate #/${best.path}$suffix") + window.location.hash = "#/${best.path}$suffix" hideSearchResults(menu) closeNavbarCollapse() } else { @@ -1028,7 +1264,7 @@ assertEquals([4, 16], evens) // Short features list Div({ classes("row", "g-4", "mt-1") }) { listOf( - Triple("Fast to learn", "Familiar constructs and readable patterns — be productive in minutes.", "bolt"), + Triple("Fast to learn", "Familiar constructs and readable patterns — be productive in minutes.", "lightning"), Triple("Portable", "Runs wherever Kotlin runs: reuse logic across platforms.", "globe2"), Triple("Pragmatic", "A standard library that solves real problems without ceremony.", "gear-fill") ).forEach { (title, text, icon) -> diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html index 8be9c68..f5a6b55 100644 --- a/site/src/jsMain/resources/index.html +++ b/site/src/jsMain/resources/index.html @@ -68,9 +68,9 @@ :root { --navbar-offset: 56px; } body { /* Fallback padding; JS will keep this equal to the real navbar height */ - padding-top: var(--navbar-offset); + /*padding-top: var(--navbar-offset);*/ /* Make native hash jumps account for the fixed header */ - scroll-padding-top: calc(var(--navbar-offset) + 8px); + /*scroll-padding-top: calc(var(--navbar-offset) + 8px);*/ } /* Also offset scroll for headings and any element targeted by an id */ .markdown-body h1, @@ -80,7 +80,7 @@ .markdown-body h5, .markdown-body h6, [id] { - scroll-margin-top: calc(var(--navbar-offset) + 8px); + /*scroll-margin-top: calc(var(--navbar-offset) + 8px);*/ } /* Unify markdown and page backgrounds with Bootstrap theme variables */ .markdown-body { @@ -226,7 +226,7 @@ // Keep legacy inline padding for older browsers document.body.style.paddingTop = px; // And expose as a CSS variable used by scroll-padding/scroll-margin rules - document.documentElement.style.setProperty('--navbar-offset', px); + // document.documentElement.style.setProperty('--navbar-offset', px); } window.addEventListener('load', adjustPadding); window.addEventListener('resize', adjustPadding); diff --git a/site/src/jsMain/resources/student-cap-black.svg b/site/src/jsMain/resources/student-cap-black.svg new file mode 100644 index 0000000..1e2070d --- /dev/null +++ b/site/src/jsMain/resources/student-cap-black.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/site/src/jsMain/resources/student-cap-white.svg b/site/src/jsMain/resources/student-cap-white.svg new file mode 100644 index 0000000..1e2070d --- /dev/null +++ b/site/src/jsMain/resources/student-cap-white.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file