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.

This commit is contained in:
Sergey Chernov 2025-11-20 01:00:32 +01:00
parent d307ed2a04
commit 215c7245a0
5 changed files with 324 additions and 18 deletions

View File

@ -28,7 +28,7 @@ function checkState() {
} }
# default target settings # default target settings
case $1 in case "com" in
com) com)
SSH_HOST=sergeych@lynglang.com # host to deploy to SSH_HOST=sergeych@lynglang.com # host to deploy to
SSH_PORT=22 # ssh port on it 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 > /dev/null
#rm build/distributions/*.js.map > /dev/null #rm build/distributions/*.js.map > /dev/null
#./gradlew clean incrementRevision jsBrowserDistribution || die "compilation failed" ./gradlew site:clean site:jsBrowserDistribution || die "compilation failed"
#./gradlew incrementRevision jsBrowserDistribution
#./gradlew jsBrowserDistribution
./gradlew incrementRevision jsBrowserDistribution
#./gradlew jsBrowserDistribution
if [[ $? != 0 ]]; then if [[ $? != 0 ]]; then
echo echo
@ -78,7 +74,7 @@ ssh -p ${SSH_PORT} ${SSH_HOST} "
"; ";
# sync files # 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 rsync -e "ssh -p ${SSH_PORT}" -avz -r -d --delete ${SRC}/* ${SSH_HOST}:${ROOT}/build/dist
checkState checkState
rsync -e "ssh -p ${SSH_PORT}" -avz ./static/* ${SSH_HOST}:${ROOT}/build/dist rsync -e "ssh -p ${SSH_PORT}" -avz ./static/* ${SSH_HOST}:${ROOT}/build/dist

View File

@ -102,6 +102,123 @@ external object JSON {
// JS global encodeURI binding (to safely request paths that may contain non-ASCII) // JS global encodeURI binding (to safely request paths that may contain non-ASCII)
external fun encodeURI(uri: String): String 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 @Composable
fun App() { fun App() {
@ -127,6 +244,24 @@ fun App() {
initTopSearch() 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) // 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 = {
@ -248,13 +383,97 @@ private fun UnsafeRawHtml(html: String) {
fun currentRoute(): String = window.location.hash.removePrefix("#/") fun currentRoute(): String = window.location.hash.removePrefix("#/")
fun routeToPath(route: String): String { fun routeToPath(route: String): String {
val noFrag = stripFragment(route) val noParams = stripQuery(stripFragment(route))
return if (noFrag.startsWith("docs/")) noFrag else "docs/$noFrag" return if (noParams.startsWith("docs/")) noParams else "docs/$noParams"
} }
// Strip trailing fragment from a route like "docs/file.md#anchor" -> "docs/file.md" // Strip trailing fragment from a route like "docs/file.md#anchor" -> "docs/file.md"
fun stripFragment(route: String): String = route.substringBefore('#') 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<String> {
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<String>): 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<HTMLElement>()
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<org.w3c.dom.Node>()
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 = fun renderMarkdown(src: String): String =
highlightLyngHtml( highlightLyngHtml(
ensureBootstrapCodeBlocks( ensureBootstrapCodeBlocks(
@ -450,6 +669,19 @@ private fun DocsPage(
val initialId = frag ?: newToc.firstOrNull()?.id val initialId = frag ?: newToc.firstOrNull()?.id
setActiveTocId(initialId) 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()) { if (!frag.isNullOrBlank()) {
val target = el.ownerDocument?.getElementById(frag) val target = el.ownerDocument?.getElementById(frag)
(target as? HTMLElement)?.scrollIntoView() (target as? HTMLElement)?.scrollIntoView()
@ -766,8 +998,11 @@ private fun renderSearchResults(input: HTMLInputElement, menu: HTMLDivElement, q
ev.preventDefault() ev.preventDefault()
val path = a.getAttribute("data-path") val path = a.getAttribute("data-path")
if (path != null) { if (path != null) {
dlog("nav", "search click -> navigate #/$path") val inputEl = input
window.location.hash = "#/$path" 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") menu.classList.remove("show")
closeNavbarCollapse() closeNavbarCollapse()
} }
@ -870,8 +1105,9 @@ private fun initTopSearch(attempt: Int = 0) {
val results = performSearch(q) val results = performSearch(q)
val best = results.maxByOrNull { scoreQuery(q, it) } val best = results.maxByOrNull { scoreQuery(q, it) }
if (best != null) { if (best != null) {
dlog("nav", "Enter -> navigate #/${best.path}") val suffix = if (q.isNotBlank()) "?q=" + encodeURIComponent(q) else ""
window.location.hash = "#/${best.path}" dlog("nav", "Enter -> navigate #/${best.path}$suffix")
window.location.hash = "#/${best.path}$suffix"
hideSearchResults(menu) hideSearchResults(menu)
closeNavbarCollapse() closeNavbarCollapse()
} else { } else {
@ -1028,7 +1264,7 @@ assertEquals([4, 16], evens)
// Short features list // Short features list
Div({ classes("row", "g-4", "mt-1") }) { Div({ classes("row", "g-4", "mt-1") }) {
listOf( 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("Portable", "Runs wherever Kotlin runs: reuse logic across platforms.", "globe2"),
Triple("Pragmatic", "A standard library that solves real problems without ceremony.", "gear-fill") Triple("Pragmatic", "A standard library that solves real problems without ceremony.", "gear-fill")
).forEach { (title, text, icon) -> ).forEach { (title, text, icon) ->

View File

@ -68,9 +68,9 @@
:root { --navbar-offset: 56px; } :root { --navbar-offset: 56px; }
body { body {
/* Fallback padding; JS will keep this equal to the real navbar height */ /* 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 */ /* 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 */ /* Also offset scroll for headings and any element targeted by an id */
.markdown-body h1, .markdown-body h1,
@ -80,7 +80,7 @@
.markdown-body h5, .markdown-body h5,
.markdown-body h6, .markdown-body h6,
[id] { [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 */ /* Unify markdown and page backgrounds with Bootstrap theme variables */
.markdown-body { .markdown-body {
@ -226,7 +226,7 @@
// Keep legacy inline padding for older browsers // Keep legacy inline padding for older browsers
document.body.style.paddingTop = px; document.body.style.paddingTop = px;
// And expose as a CSS variable used by scroll-padding/scroll-margin rules // 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('load', adjustPadding);
window.addEventListener('resize', adjustPadding); window.addEventListener('resize', adjustPadding);

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- 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.
-
-->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M505.837,180.418L279.265,76.124c-7.349-3.385-15.177-5.093-23.265-5.093c-8.088,0-15.914,1.708-23.265,5.093
L6.163,180.418C2.418,182.149,0,185.922,0,190.045s2.418,7.896,6.163,9.627l226.572,104.294c7.349,3.385,15.177,5.101,23.265,5.101
c8.088,0,15.916-1.716,23.267-5.101l178.812-82.306v82.881c-7.096,0.8-12.63,6.84-12.63,14.138c0,6.359,4.208,11.864,10.206,13.618
l-12.092,79.791h55.676l-12.09-79.791c5.996-1.754,10.204-7.259,10.204-13.618c0-7.298-5.534-13.338-12.63-14.138v-95.148
l21.116-9.721c3.744-1.731,6.163-5.504,6.163-9.627S509.582,182.149,505.837,180.418z"/>
<path class="st0" d="M256,346.831c-11.246,0-22.143-2.391-32.386-7.104L112.793,288.71v101.638
c0,22.314,67.426,50.621,143.207,50.621c75.782,0,143.209-28.308,143.209-50.621V288.71l-110.827,51.017
C278.145,344.44,267.25,346.831,256,346.831z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- 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.
-
-->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512" xml:space="preserve">
<style type="text/css">
.st0{fill:#000000;}
</style>
<g>
<path class="st0" d="M505.837,180.418L279.265,76.124c-7.349-3.385-15.177-5.093-23.265-5.093c-8.088,0-15.914,1.708-23.265,5.093
L6.163,180.418C2.418,182.149,0,185.922,0,190.045s2.418,7.896,6.163,9.627l226.572,104.294c7.349,3.385,15.177,5.101,23.265,5.101
c8.088,0,15.916-1.716,23.267-5.101l178.812-82.306v82.881c-7.096,0.8-12.63,6.84-12.63,14.138c0,6.359,4.208,11.864,10.206,13.618
l-12.092,79.791h55.676l-12.09-79.791c5.996-1.754,10.204-7.259,10.204-13.618c0-7.298-5.534-13.338-12.63-14.138v-95.148
l21.116-9.721c3.744-1.731,6.163-5.504,6.163-9.627S509.582,182.149,505.837,180.418z"/>
<path class="st0" d="M256,346.831c-11.246,0-22.143-2.391-32.386-7.104L112.793,288.71v101.638
c0,22.314,67.426,50.621,143.207,50.621c75.782,0,143.209-28.308,143.209-50.621V288.71l-110.827,51.017
C278.145,344.44,267.25,346.831,256,346.831z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB