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:
parent
d307ed2a04
commit
215c7245a0
@ -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
|
||||
|
||||
@ -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<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 =
|
||||
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) ->
|
||||
|
||||
@ -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);
|
||||
|
||||
37
site/src/jsMain/resources/student-cap-black.svg
Normal file
37
site/src/jsMain/resources/student-cap-black.svg
Normal 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 |
37
site/src/jsMain/resources/student-cap-white.svg
Normal file
37
site/src/jsMain/resources/student-cap-white.svg
Normal 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 |
Loading…
x
Reference in New Issue
Block a user