restructured site: split to separate sources. Search improved
This commit is contained in:
parent
f1e978599c
commit
2b320ab52a
@ -1,5 +1,7 @@
|
|||||||
# Lyng: modern scripting for kotlin multiplatform
|
# Lyng: modern scripting for kotlin multiplatform
|
||||||
|
|
||||||
|
Please visit the project homepage: [https://lynglang.com](https://lynglang.com)
|
||||||
|
|
||||||
A KMP library and a standalone interpreter. v1.0.0-SNAPSHOT is now available.
|
A KMP library and a standalone interpreter. v1.0.0-SNAPSHOT is now available.
|
||||||
|
|
||||||
- simple, compact, intuitive and elegant modern code:
|
- simple, compact, intuitive and elegant modern code:
|
||||||
@ -171,8 +173,6 @@ Ready features:
|
|||||||
- [x] dynamic fields
|
- [x] dynamic fields
|
||||||
- [x] function annotations
|
- [x] function annotations
|
||||||
- [x] better stack reporting
|
- [x] better stack reporting
|
||||||
|
|
||||||
|
|
||||||
- [x] regular exceptions + extended `when`
|
- [x] regular exceptions + extended `when`
|
||||||
- [x] multiple inheritance for user classes
|
- [x] multiple inheritance for user classes
|
||||||
|
|
||||||
|
|||||||
126
site/src/jsMain/kotlin/App.kt
Normal file
126
site/src/jsMain/kotlin/App.kt
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun App() {
|
||||||
|
var route by remember { mutableStateOf(currentRoute()) }
|
||||||
|
var html by remember { mutableStateOf<String?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
|
||||||
|
var activeTocId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
|
val isDocsRoute = route.startsWith("docs/")
|
||||||
|
val docKey = stripFragment(route)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dlog("init", "initDocsDropdown()")
|
||||||
|
initDocsDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
dlog("init", "initTopSearch()")
|
||||||
|
initTopSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { ensureDocsLayoutStyles() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
ensureScrollOffsetStyles()
|
||||||
|
updateNavbarOffsetVar()
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() }
|
||||||
|
window.addEventListener("resize", handler)
|
||||||
|
onDispose { window.removeEventListener("resize", handler) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val listener: (org.w3c.dom.events.Event) -> Unit = { route = currentRoute() }
|
||||||
|
window.addEventListener("hashchange", listener)
|
||||||
|
onDispose { window.removeEventListener("hashchange", listener) }
|
||||||
|
}
|
||||||
|
|
||||||
|
PageTemplate(title = when {
|
||||||
|
isDocsRoute -> null
|
||||||
|
route.startsWith("reference") -> "Reference"
|
||||||
|
route.isBlank() -> null
|
||||||
|
else -> null
|
||||||
|
}) {
|
||||||
|
Div({ classes("row", "gy-4") }) {
|
||||||
|
if (isDocsRoute) {
|
||||||
|
Div({ classes("col-12", "col-lg-3") }) {
|
||||||
|
Nav({
|
||||||
|
classes("position-sticky")
|
||||||
|
attr("style", "top: calc(var(--navbar-offset) + 1rem)")
|
||||||
|
}) {
|
||||||
|
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
|
||||||
|
Ul({ classes("list-unstyled") }) {
|
||||||
|
toc.forEach { item ->
|
||||||
|
Li({ classes("mb-1") }) {
|
||||||
|
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
|
||||||
|
val routeNoFrag = route.substringBefore('#')
|
||||||
|
val tocHref = "#/$routeNoFrag#${item.id}"
|
||||||
|
A(attrs = {
|
||||||
|
attr("href", tocHref)
|
||||||
|
attr("style", "padding-left: $pad")
|
||||||
|
classes("link-body-emphasis", "text-decoration-none")
|
||||||
|
if (activeTocId == item.id) {
|
||||||
|
classes("fw-semibold", "text-primary")
|
||||||
|
attr("aria-current", "true")
|
||||||
|
}
|
||||||
|
onClick {
|
||||||
|
it.preventDefault()
|
||||||
|
window.location.hash = tocHref
|
||||||
|
contentEl?.ownerDocument?.getElementById(item.id)
|
||||||
|
?.let { (it as? HTMLElement)?.scrollIntoView() }
|
||||||
|
}
|
||||||
|
}) { Text(item.title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Div({ classes("col-12", if (isDocsRoute) "col-lg-9" else "col-lg-12") }) {
|
||||||
|
when {
|
||||||
|
route.isBlank() -> HomePage()
|
||||||
|
!isDocsRoute -> ReferencePage()
|
||||||
|
else -> DocsPage(
|
||||||
|
route = route,
|
||||||
|
html = html,
|
||||||
|
error = error,
|
||||||
|
contentEl = contentEl,
|
||||||
|
onContentEl = { contentEl = it },
|
||||||
|
setError = { error = it },
|
||||||
|
setHtml = { html = it },
|
||||||
|
toc = toc,
|
||||||
|
setToc = { toc = it },
|
||||||
|
activeTocId = activeTocId,
|
||||||
|
setActiveTocId = { activeTocId = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
site/src/jsMain/kotlin/Components.kt
Normal file
78
site/src/jsMain/kotlin/Components.kt
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DocLink(name: String) {
|
||||||
|
A(attrs = {
|
||||||
|
classes("btn", "btn-sm", "btn-outline-secondary")
|
||||||
|
onClick {
|
||||||
|
window.location.hash = "#/docs/$name"
|
||||||
|
it.preventDefault()
|
||||||
|
}
|
||||||
|
attr("href", "#/docs/$name")
|
||||||
|
}) { Text(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnsafeRawHtml(html: String) {
|
||||||
|
val holder = remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
|
LaunchedEffect(html) { holder.value?.innerHTML = html }
|
||||||
|
Div({
|
||||||
|
ref {
|
||||||
|
holder.value = it
|
||||||
|
onDispose { if (holder.value === it) holder.value = null }
|
||||||
|
}
|
||||||
|
}) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PageTemplate(title: String?, showBack: Boolean = false, content: @Composable () -> Unit) {
|
||||||
|
Div({ classes("container", "py-4") }) {
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
Div({ classes("d-flex", "align-items-center", "gap-2", "mb-3") }) {
|
||||||
|
if (showBack) {
|
||||||
|
A(attrs = {
|
||||||
|
classes("btn", "btn-outline", "btn-sm")
|
||||||
|
attr("href", "#")
|
||||||
|
attr("aria-label", "Back")
|
||||||
|
onClick {
|
||||||
|
it.preventDefault()
|
||||||
|
try {
|
||||||
|
if (window.history.length > 1) window.history.back()
|
||||||
|
else window.location.hash = "#"
|
||||||
|
} catch (e: dynamic) {
|
||||||
|
window.location.hash = "#"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) { I({ classes("bi", "bi-arrow-left") }) }
|
||||||
|
}
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
H1({ classes("h4", "mb-0") }) { Text(title) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
207
site/src/jsMain/kotlin/DocsPage.kt
Normal file
207
site/src/jsMain/kotlin/DocsPage.kt
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import kotlinx.coroutines.await
|
||||||
|
import org.jetbrains.compose.web.dom.Div
|
||||||
|
import org.jetbrains.compose.web.dom.P
|
||||||
|
import org.jetbrains.compose.web.dom.Text
|
||||||
|
import org.w3c.dom.HTMLDivElement
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import org.w3c.dom.HTMLHeadingElement
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DocsPage(
|
||||||
|
route: String,
|
||||||
|
html: String?,
|
||||||
|
error: String?,
|
||||||
|
contentEl: HTMLElement?,
|
||||||
|
onContentEl: (HTMLElement?) -> Unit,
|
||||||
|
setError: (String?) -> Unit,
|
||||||
|
setHtml: (String?) -> Unit,
|
||||||
|
toc: List<TocItem>,
|
||||||
|
setToc: (List<TocItem>) -> Unit,
|
||||||
|
activeTocId: String?,
|
||||||
|
setActiveTocId: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
var title by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val docKey = stripFragment(route)
|
||||||
|
LaunchedEffect(docKey) {
|
||||||
|
setError(null)
|
||||||
|
setHtml(null)
|
||||||
|
setToc(emptyList())
|
||||||
|
setActiveTocId(null)
|
||||||
|
|
||||||
|
val path = routeToPath(route)
|
||||||
|
try {
|
||||||
|
val url = "./" + encodeURI(path)
|
||||||
|
val resp = window.fetch(url).await()
|
||||||
|
if (!resp.ok) {
|
||||||
|
setError("Not found: $path (${resp.status})")
|
||||||
|
} else {
|
||||||
|
val text = resp.text().await()
|
||||||
|
title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
|
||||||
|
setHtml(renderMarkdown(text))
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
setError("Failed to load: $path — ${t.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PageTemplate(title = null, showBack = false) {
|
||||||
|
if (error != null) {
|
||||||
|
Div({ classes("alert", "alert-danger") }) { Text(error) }
|
||||||
|
} else if (html == null) {
|
||||||
|
P { Text("Loading…") }
|
||||||
|
} else {
|
||||||
|
Div({
|
||||||
|
classes("markdown-body")
|
||||||
|
ref {
|
||||||
|
onContentEl(it)
|
||||||
|
onDispose { onContentEl(null) }
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
UnsafeRawHtml(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(html, contentEl) {
|
||||||
|
val el = contentEl ?: return@LaunchedEffect
|
||||||
|
if (html == null) return@LaunchedEffect
|
||||||
|
window.requestAnimationFrame {
|
||||||
|
try {
|
||||||
|
val firstH1 = el.querySelector("h1") as? HTMLElement
|
||||||
|
if (firstH1 != null && firstH1.querySelector(".doc-back-btn") == null) {
|
||||||
|
val back = el.ownerDocument!!.createElement("div") as HTMLDivElement
|
||||||
|
back.className = "btn btn-outline btn-sm me-2 align-middle doc-back-btn "
|
||||||
|
back.setAttribute("aria-label","Back")
|
||||||
|
back.onclick = { ev ->
|
||||||
|
ev.preventDefault()
|
||||||
|
try {
|
||||||
|
if (window.history.length > 1) window.history.back() else window.location.hash = "#"
|
||||||
|
} catch (e: dynamic) {
|
||||||
|
window.location.hash = "#"
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val icon = el.ownerDocument!!.createElement("i") as HTMLElement
|
||||||
|
icon.className = "bi bi-arrow-left"
|
||||||
|
back.appendChild(icon)
|
||||||
|
firstH1.insertBefore(back, firstH1.firstChild)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the current document directory as base for relative links and images
|
||||||
|
val currentDoc = routeToPath(route) // e.g. "docs/tutorial.md"
|
||||||
|
val basePath = currentDoc.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
rewriteImages(el, basePath = basePath)
|
||||||
|
rewriteAnchors(
|
||||||
|
el,
|
||||||
|
basePath = basePath,
|
||||||
|
currentDocPath = currentDoc
|
||||||
|
) { newRoute ->
|
||||||
|
window.location.hash = "#/$newRoute"
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val tocItems = buildToc(el)
|
||||||
|
setToc(tocItems)
|
||||||
|
} catch (_: Throwable) { setToc(emptyList()) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val terms = extractSearchTerms(route)
|
||||||
|
val hits = highlightSearchHits(el, terms)
|
||||||
|
dlog("search", "highlighted $hits hits for terms=$terms")
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
|
||||||
|
// After highlighting, if navigated via search (?q=...) and there is no fragment in the route,
|
||||||
|
// scroll to the first search hit accounting for sticky navbar offset. Do this only once per load.
|
||||||
|
try {
|
||||||
|
val hasQueryHits = extractSearchTerms(route).isNotEmpty()
|
||||||
|
val hasFragment = route.contains('#')
|
||||||
|
val alreadyScrolled = (el.getAttribute("data-search-scrolled") == "1")
|
||||||
|
if (hasQueryHits && !hasFragment && !alreadyScrolled) {
|
||||||
|
val firstHit = el.querySelector(".search-hit") as? HTMLElement
|
||||||
|
if (firstHit != null) {
|
||||||
|
el.setAttribute("data-search-scrolled", "1")
|
||||||
|
// compute top with offset
|
||||||
|
val rectTop = firstHit.getBoundingClientRect().top + window.scrollY
|
||||||
|
val offset = (updateNavbarOffsetVar() + 16).toDouble()
|
||||||
|
val targetY = rectTop - offset
|
||||||
|
try {
|
||||||
|
window.scrollTo(js("({top: targetY, behavior: 'instant'})").unsafeCast<dynamic>())
|
||||||
|
} catch (_: dynamic) {
|
||||||
|
window.scrollTo(0.0, targetY)
|
||||||
|
}
|
||||||
|
dlog("scroll", "auto-scrolled to first hit at y=$targetY")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
|
||||||
|
// MathJax typeset is triggered similarly to original code in Main.kt
|
||||||
|
try {
|
||||||
|
val ready = try { js("typeof MathJax !== 'undefined' && MathJax.typeset") as Boolean } catch (_: dynamic) { false }
|
||||||
|
if (ready) {
|
||||||
|
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { }
|
||||||
|
} else {
|
||||||
|
window.setTimeout({
|
||||||
|
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { }
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
} catch (_: dynamic) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(toc, contentEl) {
|
||||||
|
val el = contentEl ?: return@DisposableEffect onDispose {}
|
||||||
|
dlog("toc", "have ${toc.size} items, recomputing active")
|
||||||
|
|
||||||
|
var scheduled = false
|
||||||
|
fun computeActive() {
|
||||||
|
scheduled = false
|
||||||
|
try {
|
||||||
|
val heads = toc.mapNotNull { id -> el.querySelector("#${id.id}") as? HTMLHeadingElement }
|
||||||
|
val tops = heads.map { it.getBoundingClientRect().top + window.scrollY }
|
||||||
|
val offset = (updateNavbarOffsetVar() + 16).toDouble()
|
||||||
|
val idx = activeIndexForTops(tops, offset)
|
||||||
|
setActiveTocId(if (idx in toc.indices) toc[idx].id else null)
|
||||||
|
} catch (_: Throwable) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollListener: (org.w3c.dom.events.Event) -> Unit = {
|
||||||
|
if (!scheduled) {
|
||||||
|
scheduled = true
|
||||||
|
window.requestAnimationFrame { computeActive() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val resizeListener = scrollListener
|
||||||
|
|
||||||
|
computeActive()
|
||||||
|
window.addEventListener("scroll", scrollListener)
|
||||||
|
window.addEventListener("resize", resizeListener)
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
window.removeEventListener("scroll", scrollListener)
|
||||||
|
window.removeEventListener("resize", resizeListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
site/src/jsMain/kotlin/Externals.kt
Normal file
41
site/src/jsMain/kotlin/Externals.kt
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* External/JS interop declarations used across the site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL encoding helpers
|
||||||
|
external fun encodeURI(uri: String): String
|
||||||
|
external fun encodeURIComponent(s: String): String
|
||||||
|
external fun decodeURIComponent(s: String): String
|
||||||
95
site/src/jsMain/kotlin/HomePage.kt
Normal file
95
site/src/jsMain/kotlin/HomePage.kt
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomePage() {
|
||||||
|
// Hero section
|
||||||
|
Section({ classes("py-4", "py-lg-5") }) {
|
||||||
|
Div({ classes("text-center") }) {
|
||||||
|
H1({ classes("display-5", "fw-bold", "mb-3") }) { Text("Welcome to Lyng") }
|
||||||
|
P({ classes("lead", "text-muted", "mb-4") }) {
|
||||||
|
Text("A lightweight, expressive scripting language designed for clarity, composability, and fun. ")
|
||||||
|
Br()
|
||||||
|
Text("Run it anywhere Kotlin runs — share logic across JS, JVM, and more.")
|
||||||
|
}
|
||||||
|
Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) {
|
||||||
|
// Benefits pills
|
||||||
|
listOf(
|
||||||
|
"Clean, familiar syntax",
|
||||||
|
"Immutable-first collections",
|
||||||
|
"Batteries-included standard library",
|
||||||
|
"Embeddable and testable"
|
||||||
|
).forEach { b ->
|
||||||
|
Span({ classes("badge", "text-bg-secondary", "rounded-pill") }) { Text(b) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CTA buttons
|
||||||
|
Div({ classes("d-flex", "justify-content-center", "gap-2", "mb-4") }) {
|
||||||
|
A(attrs = {
|
||||||
|
classes("btn", "btn-primary", "btn-lg")
|
||||||
|
attr("href", "#/docs/tutorial.md")
|
||||||
|
}) {
|
||||||
|
I({ classes("bi", "bi-play-fill", "me-1") })
|
||||||
|
Text("Start the tutorial")
|
||||||
|
}
|
||||||
|
A(attrs = {
|
||||||
|
classes("btn", "btn-outline-secondary", "btn-lg")
|
||||||
|
attr("href", "#/reference")
|
||||||
|
}) {
|
||||||
|
I({ classes("bi", "bi-journal-text", "me-1") })
|
||||||
|
Text("Browse reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code sample
|
||||||
|
val code = """
|
||||||
|
// Create, transform, and verify — the Lyng way
|
||||||
|
val data = [1, 2, 3, 4, 5]
|
||||||
|
val evens = data.filter { it % 2 == 0 }.map { it * it }
|
||||||
|
assertEquals([4, 16], evens)
|
||||||
|
>>> void
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val codeHtml = "<pre><code>" + htmlEscape(code) + "</code></pre>"
|
||||||
|
Div({ classes("markdown-body") }) {
|
||||||
|
UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(codeHtml)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short features list
|
||||||
|
Div({ classes("row", "g-4", "mt-1") }) {
|
||||||
|
listOf(
|
||||||
|
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) ->
|
||||||
|
Div({ classes("col-12", "col-md-4") }) {
|
||||||
|
Div({ classes("h-100", "p-3", "border", "rounded-3", "bg-body-tertiary") }) {
|
||||||
|
Div({ classes("d-flex", "align-items-center", "mb-2", "fs-4") }) {
|
||||||
|
I({ classes("bi", "bi-$icon", "me-2") })
|
||||||
|
Span({ classes("fw-semibold") }) { Text(title) }
|
||||||
|
}
|
||||||
|
P({ classes("mb-0", "text-muted") }) { Text(text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,27 +15,17 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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.*
|
import kotlinx.coroutines.*
|
||||||
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.*
|
||||||
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 ---------------
|
// --------------- Lightweight debug logging ---------------
|
||||||
// Disable debug logging by default
|
// Disable debug logging by default
|
||||||
private var SEARCH_DEBUG: Boolean = false
|
private var SEARCH_DEBUG: Boolean = false
|
||||||
private fun dlog(tag: String, msg: String) {
|
fun dlog(tag: String, msg: String) {
|
||||||
if (!SEARCH_DEBUG) return
|
if (!SEARCH_DEBUG) return
|
||||||
try {
|
try {
|
||||||
console.log("[LYNG][$tag] $msg")
|
console.log("[LYNG][$tag] $msg")
|
||||||
@ -84,31 +74,11 @@ private fun exposeSearchDebugToggle() {
|
|||||||
} catch (_: dynamic) { }
|
} catch (_: dynamic) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MathJax v3 global API (loaded via CDN in index.html)
|
// externals moved to Externals.kt
|
||||||
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
|
|
||||||
// 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.
|
// 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.
|
// This guarantees that scrolling to anchors or search hits is not hidden underneath the top bar.
|
||||||
private fun ensureScrollOffsetStyles() {
|
fun ensureScrollOffsetStyles() {
|
||||||
try {
|
try {
|
||||||
val doc = window.document
|
val doc = window.document
|
||||||
if (doc.getElementById("scroll-offset-style") == null) {
|
if (doc.getElementById("scroll-offset-style") == null) {
|
||||||
@ -144,20 +114,20 @@ private fun ensureScrollOffsetStyles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Measure the current fixed-top navbar height and update the CSS variable
|
// Measure the current fixed-top navbar height and update the CSS variable
|
||||||
private fun updateNavbarOffsetVar(): Int {
|
fun updateNavbarOffsetVar(): Int {
|
||||||
return try {
|
return try {
|
||||||
val doc = window.document
|
val doc = window.document
|
||||||
val nav = doc.querySelector("nav.navbar.fixed-top") as? HTMLElement
|
val nav = doc.querySelector("nav.navbar.fixed-top") as? HTMLElement
|
||||||
val px = if (nav != null) kotlin.math.round(nav.getBoundingClientRect().height).toInt() else 0
|
val px = if (nav != null) kotlin.math.round(nav.getBoundingClientRect().height).toInt() else 0
|
||||||
doc.documentElement?.let { root ->
|
doc.documentElement?.let { root ->
|
||||||
root.asDynamic().style?.setProperty?.invoke(root.asDynamic().style, "--navbar-offset", "${'$'}{px}px")
|
root.asDynamic().style?.setProperty?.invoke(root.asDynamic().style, "--navbar-offset", "${px}px")
|
||||||
}
|
}
|
||||||
px
|
px
|
||||||
} catch (_: Throwable) { 0 }
|
} catch (_: Throwable) { 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure global CSS for search highlights is present (bright, visible everywhere)
|
// Ensure global CSS for search highlights is present (bright, visible everywhere)
|
||||||
private fun ensureSearchHighlightStyles() {
|
fun ensureSearchHighlightStyles() {
|
||||||
try {
|
try {
|
||||||
val doc = window.document
|
val doc = window.document
|
||||||
if (doc.getElementById("search-hit-style") == null) {
|
if (doc.getElementById("search-hit-style") == null) {
|
||||||
@ -189,7 +159,7 @@ private fun ensureSearchHighlightStyles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure docs layout tweaks (reduce markdown body top margin to align with TOC)
|
// Ensure docs layout tweaks (reduce markdown body top margin to align with TOC)
|
||||||
private fun ensureDocsLayoutStyles() {
|
fun ensureDocsLayoutStyles() {
|
||||||
try {
|
try {
|
||||||
val doc = window.document
|
val doc = window.document
|
||||||
if (doc.getElementById("docs-layout-style") == null) {
|
if (doc.getElementById("docs-layout-style") == null) {
|
||||||
@ -220,165 +190,9 @@ private fun ensureDocsLayoutStyles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
// App() moved to App.kt
|
||||||
fun App() {
|
|
||||||
var route by remember { mutableStateOf(currentRoute()) }
|
|
||||||
var html by remember { mutableStateOf<String?>(null) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
|
|
||||||
var activeTocId by remember { mutableStateOf<String?>(null) }
|
|
||||||
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
|
||||||
val isDocsRoute = route.startsWith("docs/")
|
|
||||||
// A stable key for the current document path (without fragment). Used by DocsPage.
|
|
||||||
val docKey = stripFragment(route)
|
|
||||||
|
|
||||||
// Initialize dynamic Documentation dropdown once
|
// DocLink and UnsafeRawHtml moved to Components.kt
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
dlog("init", "initDocsDropdown()")
|
|
||||||
initDocsDropdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize site-wide search (lazy index build on first use)
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
dlog("init", "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)
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val listener: (org.w3c.dom.events.Event) -> Unit = {
|
|
||||||
route = currentRoute()
|
|
||||||
}
|
|
||||||
window.addEventListener("hashchange", listener)
|
|
||||||
onDispose { window.removeEventListener("hashchange", listener) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Docs-specific fetching and effects are handled inside DocsPage now
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
PageTemplate(title = when {
|
|
||||||
isDocsRoute -> null // title will be shown inside DocsPage from MD H1
|
|
||||||
route.startsWith("reference") -> "Reference"
|
|
||||||
route.isBlank() -> null // Home has its own big title/hero
|
|
||||||
else -> null
|
|
||||||
}) {
|
|
||||||
Div({ classes("row", "gy-4") }) {
|
|
||||||
// Sidebar TOC: show only on docs pages
|
|
||||||
if (isDocsRoute) {
|
|
||||||
Div({ classes("col-12", "col-lg-3") }) {
|
|
||||||
// Keep the TOC nav sticky below the fixed navbar. Use the same CSS var that
|
|
||||||
// drives body padding so the offsets always match the real navbar height.
|
|
||||||
Nav({
|
|
||||||
classes("position-sticky")
|
|
||||||
attr("style", "top: calc(var(--navbar-offset) + 1rem)")
|
|
||||||
}) {
|
|
||||||
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
|
|
||||||
Ul({ classes("list-unstyled") }) {
|
|
||||||
toc.forEach { item ->
|
|
||||||
Li({ classes("mb-1") }) {
|
|
||||||
val pad = when (item.level) {
|
|
||||||
1 -> "0"
|
|
||||||
2 -> "0.75rem"
|
|
||||||
else -> "1.5rem"
|
|
||||||
}
|
|
||||||
val routeNoFrag = route.substringBefore('#')
|
|
||||||
val tocHref = "#/$routeNoFrag#${item.id}"
|
|
||||||
A(attrs = {
|
|
||||||
attr("href", tocHref)
|
|
||||||
attr("style", "padding-left: $pad")
|
|
||||||
classes("link-body-emphasis", "text-decoration-none")
|
|
||||||
// Highlight active item
|
|
||||||
if (activeTocId == item.id) {
|
|
||||||
classes("fw-semibold", "text-primary")
|
|
||||||
attr("aria-current", "true")
|
|
||||||
}
|
|
||||||
onClick {
|
|
||||||
it.preventDefault()
|
|
||||||
// Update location hash to include the document route and section id
|
|
||||||
window.location.hash = tocHref
|
|
||||||
// Perform immediate scroll for snappier UX (effects will also handle it)
|
|
||||||
contentEl?.ownerDocument?.getElementById(item.id)
|
|
||||||
?.let { (it as? HTMLElement)?.scrollIntoView() }
|
|
||||||
}
|
|
||||||
}) { Text(item.title) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main content
|
|
||||||
Div({ classes("col-12", if (isDocsRoute) "col-lg-9" else "col-lg-12") }) {
|
|
||||||
when {
|
|
||||||
route.isBlank() -> HomePage()
|
|
||||||
!isDocsRoute -> ReferencePage()
|
|
||||||
else -> DocsPage(
|
|
||||||
route = route,
|
|
||||||
html = html,
|
|
||||||
error = error,
|
|
||||||
contentEl = contentEl,
|
|
||||||
onContentEl = { contentEl = it },
|
|
||||||
setError = { error = it },
|
|
||||||
setHtml = { html = it },
|
|
||||||
toc = toc,
|
|
||||||
setToc = { toc = it },
|
|
||||||
activeTocId = activeTocId,
|
|
||||||
setActiveTocId = { activeTocId = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DocLink(name: String) {
|
|
||||||
A(attrs = {
|
|
||||||
classes("btn", "btn-sm", "btn-outline-secondary")
|
|
||||||
onClick {
|
|
||||||
window.location.hash = "#/docs/$name"
|
|
||||||
it.preventDefault()
|
|
||||||
}
|
|
||||||
attr("href", "#/docs/$name")
|
|
||||||
}) { Text(name) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UnsafeRawHtml(html: String) {
|
|
||||||
// Compose HTML lacks a direct element for raw insertion; set innerHTML via ref
|
|
||||||
// Use a <div> and set its innerHTML via side effect
|
|
||||||
val holder = remember { mutableStateOf<HTMLElement?>(null) }
|
|
||||||
LaunchedEffect(html) {
|
|
||||||
holder.value?.innerHTML = html
|
|
||||||
}
|
|
||||||
Div({
|
|
||||||
ref {
|
|
||||||
holder.value = it
|
|
||||||
onDispose {
|
|
||||||
if (holder.value === it) holder.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun currentRoute(): String = window.location.hash.removePrefix("#/")
|
fun currentRoute(): String = window.location.hash.removePrefix("#/")
|
||||||
|
|
||||||
@ -407,10 +221,9 @@ fun extractSearchTerms(route: String): List<String> {
|
|||||||
|
|
||||||
// Highlight words in root that start with any of the terms (case-insensitive). Returns count of hits.
|
// 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 {
|
fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
||||||
if (terms.isEmpty()) return 0
|
|
||||||
// Make sure CSS for highlighting is injected
|
// Make sure CSS for highlighting is injected
|
||||||
ensureSearchHighlightStyles()
|
ensureSearchHighlightStyles()
|
||||||
// Remove previous highlights
|
// Always remove previous highlights first so calling with empty terms clears them
|
||||||
try {
|
try {
|
||||||
val prev = root.getElementsByClassName("search-hit")
|
val prev = root.getElementsByClassName("search-hit")
|
||||||
// Because HTMLCollection is live, copy to array first
|
// Because HTMLCollection is live, copy to array first
|
||||||
@ -422,6 +235,8 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
|||||||
}
|
}
|
||||||
} catch (_: Throwable) {}
|
} catch (_: Throwable) {}
|
||||||
|
|
||||||
|
if (terms.isEmpty()) return 0
|
||||||
|
|
||||||
// Allow highlighting even inside CODE and PRE per request; still skip scripts, styles, and keyboard samples
|
// Allow highlighting even inside CODE and PRE per request; still skip scripts, styles, and keyboard samples
|
||||||
val skipTags = setOf("SCRIPT", "STYLE", "KBD", "SAMP")
|
val skipTags = setOf("SCRIPT", "STYLE", "KBD", "SAMP")
|
||||||
var hits = 0
|
var hits = 0
|
||||||
@ -453,12 +268,21 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
|||||||
if (start > pos) container.appendChild(doc.createTextNode(text.substring(pos, start)))
|
if (start > pos) container.appendChild(doc.createTextNode(text.substring(pos, start)))
|
||||||
val token = m.value
|
val token = m.value
|
||||||
val tokenLower = token.lowercase()
|
val tokenLower = token.lowercase()
|
||||||
val match = terms.any { tokenLower.startsWith(it) }
|
// choose the longest term that is a prefix of the token
|
||||||
if (match) {
|
var bestLen = 0
|
||||||
|
if (terms.isNotEmpty()) {
|
||||||
|
for (t in terms) {
|
||||||
|
if (t.isNotEmpty() && tokenLower.startsWith(t) && t.length > bestLen) bestLen = t.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestLen > 0 && bestLen <= token.length) {
|
||||||
|
val prefix = token.substring(0, bestLen)
|
||||||
|
val suffix = token.substring(bestLen)
|
||||||
val mark = doc.createElement("mark") as HTMLElement
|
val mark = doc.createElement("mark") as HTMLElement
|
||||||
mark.className = "search-hit"
|
mark.className = "search-hit"
|
||||||
mark.textContent = token
|
mark.textContent = prefix
|
||||||
container.appendChild(mark)
|
container.appendChild(mark)
|
||||||
|
if (suffix.isNotEmpty()) container.appendChild(doc.createTextNode(suffix))
|
||||||
hits++
|
hits++
|
||||||
} else {
|
} else {
|
||||||
container.appendChild(doc.createTextNode(token))
|
container.appendChild(doc.createTextNode(token))
|
||||||
@ -511,240 +335,17 @@ fun renderReferenceListHtml(docs: List<String>): String {
|
|||||||
return "<ul class=\"list-group\">$items</ul>"
|
return "<ul class=\"list-group\">$items</ul>"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------- New Composables: PageTemplate and DocsPage ---------------
|
// PageTemplate moved to Components.kt
|
||||||
|
|
||||||
@Composable
|
// DocsPage moved to Pages.kt
|
||||||
private fun PageTemplate(title: String?, showBack: Boolean = false, content: @Composable () -> Unit) {
|
|
||||||
Div({ classes("container", "py-4") }) {
|
|
||||||
// Render header row only when we have a title, to avoid a floating back icon before data loads
|
|
||||||
if (!title.isNullOrBlank()) {
|
|
||||||
Div({ classes("d-flex", "align-items-center", "gap-2", "mb-3") }) {
|
|
||||||
if (showBack) {
|
|
||||||
A(attrs = {
|
|
||||||
classes("btn", "btn-outline", "btn-sm")
|
|
||||||
attr("href", "#")
|
|
||||||
attr("aria-label", "Back")
|
|
||||||
onClick {
|
|
||||||
it.preventDefault()
|
|
||||||
try {
|
|
||||||
if (window.history.length > 1) window.history.back()
|
|
||||||
else window.location.hash = "#"
|
|
||||||
} catch (e: dynamic) {
|
|
||||||
window.location.hash = "#"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
I({ classes("bi", "bi-arrow-left") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!title.isNullOrBlank()) {
|
|
||||||
H1({ classes("h4", "mb-0") }) { Text(title) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content()
|
fun extractTitleFromMarkdown(md: String): String? {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DocsPage(
|
|
||||||
route: String,
|
|
||||||
html: String?,
|
|
||||||
error: String?,
|
|
||||||
contentEl: HTMLElement?,
|
|
||||||
onContentEl: (HTMLElement?) -> Unit,
|
|
||||||
setError: (String?) -> Unit,
|
|
||||||
setHtml: (String?) -> Unit,
|
|
||||||
toc: List<TocItem>,
|
|
||||||
setToc: (List<TocItem>) -> Unit,
|
|
||||||
activeTocId: String?,
|
|
||||||
setActiveTocId: (String?) -> Unit,
|
|
||||||
) {
|
|
||||||
// Title is extracted from the first H1 in markdown
|
|
||||||
var title by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
// Fetch markdown and compute title
|
|
||||||
val docKey = stripFragment(route)
|
|
||||||
LaunchedEffect(docKey) {
|
|
||||||
// Reset page-specific state early to avoid stale UI (e.g., empty TOC persisting)
|
|
||||||
setError(null)
|
|
||||||
setHtml(null)
|
|
||||||
setToc(emptyList())
|
|
||||||
setActiveTocId(null)
|
|
||||||
|
|
||||||
val path = routeToPath(route)
|
|
||||||
try {
|
|
||||||
// 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 {
|
|
||||||
val text = resp.text().await()
|
|
||||||
title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
|
|
||||||
setHtml(renderMarkdown(text))
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
setError("Failed to load: $path — ${t.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not render a separate header here. We will show the markdown's own H1
|
|
||||||
// and inject a back button inline to that H1 after the content mounts.
|
|
||||||
PageTemplate(title = null, showBack = false) {
|
|
||||||
if (error != null) {
|
|
||||||
Div({ classes("alert", "alert-danger") }) { Text(error) }
|
|
||||||
} else if (html == null) {
|
|
||||||
P { Text("Loading…") }
|
|
||||||
} else {
|
|
||||||
Div({
|
|
||||||
classes("markdown-body")
|
|
||||||
ref {
|
|
||||||
onContentEl(it)
|
|
||||||
onDispose { onContentEl(null) }
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
UnsafeRawHtml(html)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post-process links, images and build TOC after html injection
|
|
||||||
// Run when both html is present and content element is mounted to avoid races (e.g., Home → Tutorial)
|
|
||||||
LaunchedEffect(html, contentEl) {
|
|
||||||
val el = contentEl ?: return@LaunchedEffect
|
|
||||||
if (html == null) return@LaunchedEffect
|
|
||||||
window.requestAnimationFrame {
|
|
||||||
// Insert an inline back button to the left of the first H1
|
|
||||||
try {
|
|
||||||
val firstH1 = el.querySelector("h1") as? HTMLElement
|
|
||||||
if (firstH1 != null && firstH1.querySelector(".doc-back-btn") == null) {
|
|
||||||
val back = el.ownerDocument!!.createElement("div") as HTMLDivElement
|
|
||||||
back.className = "btn btn-outline btn-sm me-2 align-middle doc-back-btn "
|
|
||||||
back.setAttribute("aria-label","Back")
|
|
||||||
back.onclick = { ev ->
|
|
||||||
ev.preventDefault()
|
|
||||||
try {
|
|
||||||
if (window.history.length > 1) window.history.back() else window.location.hash = "#"
|
|
||||||
} catch (e: dynamic) {
|
|
||||||
window.location.hash = "#"
|
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val icon = el.ownerDocument!!.createElement("i") as HTMLElement
|
|
||||||
icon.className = "bi bi-arrow-left"
|
|
||||||
back.appendChild(icon)
|
|
||||||
// Insert at the start of H1 content
|
|
||||||
firstH1.insertBefore(back, firstH1.firstChild)
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// best-effort; ignore DOM issues
|
|
||||||
}
|
|
||||||
val currentPath = routeToPath(route) // without fragment
|
|
||||||
val basePath = currentPath.substringBeforeLast('/', "docs")
|
|
||||||
rewriteImages(el, basePath)
|
|
||||||
rewriteAnchors(el, basePath, currentPath) { newRoute ->
|
|
||||||
window.location.hash = "#/$newRoute"
|
|
||||||
}
|
|
||||||
// Render math using MathJax v3 when available; retry shortly if still initializing.
|
|
||||||
val tryTypeset: () -> Unit = {
|
|
||||||
try {
|
|
||||||
val ready = try { js("typeof MathJax !== 'undefined' && MathJax.typeset") as Boolean } catch (_: dynamic) { false }
|
|
||||||
if (ready) {
|
|
||||||
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { /* ignore */ }
|
|
||||||
} else {
|
|
||||||
// retry once after a short delay
|
|
||||||
window.setTimeout({
|
|
||||||
try { MathJax.typeset(arrayOf(el)) } catch (_: dynamic) { /* ignore */ }
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
} catch (_: dynamic) { /* ignore */ }
|
|
||||||
}
|
|
||||||
tryTypeset()
|
|
||||||
val newToc = buildToc(el)
|
|
||||||
setToc(newToc)
|
|
||||||
// Set initial active section: prefer fragment if present, else first heading
|
|
||||||
val frag = anchorFromHash(window.location.hash)
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When only the fragment changes on the same document, scroll to the target without re-fetching
|
|
||||||
LaunchedEffect(route) {
|
|
||||||
val el = contentEl ?: return@LaunchedEffect
|
|
||||||
window.setTimeout({
|
|
||||||
val frag = anchorFromHash(window.location.hash)
|
|
||||||
if (!frag.isNullOrBlank()) {
|
|
||||||
val target = el.ownerDocument?.getElementById(frag)
|
|
||||||
(target as? HTMLElement)?.scrollIntoView()
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrollspy: highlight active heading in TOC while scrolling (reuse existing logic)
|
|
||||||
DisposableEffect(toc, contentEl) {
|
|
||||||
if (toc.isEmpty() || contentEl == null) return@DisposableEffect onDispose {}
|
|
||||||
var scheduled = false
|
|
||||||
fun computeActive() {
|
|
||||||
scheduled = false
|
|
||||||
val tops = toc.mapNotNull { item ->
|
|
||||||
contentEl.ownerDocument?.getElementById(item.id)
|
|
||||||
?.let { (it as? HTMLElement)?.getBoundingClientRect()?.top?.toDouble() }
|
|
||||||
}
|
|
||||||
if (tops.isEmpty()) return
|
|
||||||
val idx = activeIndexForTops(tops, offsetPx = 80.0)
|
|
||||||
val newId = toc.getOrNull(idx)?.id
|
|
||||||
if (newId != null && newId != activeTocId) {
|
|
||||||
setActiveTocId(newId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollListener: (org.w3c.dom.events.Event) -> Unit = {
|
|
||||||
if (!scheduled) {
|
|
||||||
scheduled = true
|
|
||||||
window.requestAnimationFrame { computeActive() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val resizeListener = scrollListener
|
|
||||||
|
|
||||||
computeActive()
|
|
||||||
window.addEventListener("scroll", scrollListener)
|
|
||||||
window.addEventListener("resize", resizeListener)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
window.removeEventListener("scroll", scrollListener)
|
|
||||||
window.removeEventListener("resize", resizeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractTitleFromMarkdown(md: String): String? {
|
|
||||||
val lines = md.lines()
|
val lines = md.lines()
|
||||||
val h1 = lines.firstOrNull { it.trimStart().startsWith("# ") }
|
val h1 = lines.firstOrNull { it.trimStart().startsWith("# ") }
|
||||||
return h1?.trimStart()?.removePrefix("# ")?.trim()
|
return h1?.trimStart()?.removePrefix("# ")?.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun initDocsDropdown() {
|
suspend fun initDocsDropdown() {
|
||||||
try {
|
try {
|
||||||
dlog("docs-dd", "initDocsDropdown start")
|
dlog("docs-dd", "initDocsDropdown start")
|
||||||
val menu = document.getElementById("docsDropdownMenu") ?: run {
|
val menu = document.getElementById("docsDropdownMenu") ?: run {
|
||||||
@ -938,18 +539,38 @@ private suspend fun buildSearchIndexOnce() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun scoreQuery(q: String, rec: DocRecord): Int {
|
private fun scoreQuery(q: String, rec: DocRecord): Int {
|
||||||
val query = norm(q)
|
val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() }
|
||||||
if (query.isBlank()) return 0
|
if (terms.isEmpty()) return 0
|
||||||
var score = 0
|
var score = 0
|
||||||
val title = norm(rec.title)
|
val title = norm(rec.title)
|
||||||
val text = rec.text
|
val text = rec.text
|
||||||
// Title startsWith gets high score
|
// Title bonuses: longer prefix matches get higher score
|
||||||
if (title.startsWith(query)) score += 100
|
for (t in terms) {
|
||||||
if (title.contains(query)) score += 60
|
if (title.startsWith(t)) score += 120 + t.length
|
||||||
// Body occurrences (basic)
|
else if (title.split(Regex("[A-Za-z0-9_]+")).isEmpty()) { /* no-op */ }
|
||||||
val idx = text.indexOf(query)
|
else {
|
||||||
if (idx >= 0) score += 30
|
// title words prefix
|
||||||
// Shorter files slightly preferred
|
val wr = Regex("[A-Za-z0-9_]+")
|
||||||
|
if (wr.findAll(title).any { it.value.startsWith(t) }) score += 70 + t.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Body: count how many tokens start with any term, weight by term length (cap to avoid giant docs dominating)
|
||||||
|
run {
|
||||||
|
val wr = Regex("[A-Za-z0-9_]+")
|
||||||
|
var matches = 0
|
||||||
|
for (m in wr.findAll(text)) {
|
||||||
|
val token = m.value
|
||||||
|
val tl = token.lowercase()
|
||||||
|
var best = 0
|
||||||
|
for (t in terms) if (tl.startsWith(t) && t.length > best) best = t.length
|
||||||
|
if (best > 0) {
|
||||||
|
matches++
|
||||||
|
score += 2 * best
|
||||||
|
if (matches >= 50) break // cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefer shorter files a bit
|
||||||
score += (200 - kotlin.math.min(200, text.length / 500))
|
score += (200 - kotlin.math.min(200, text.length / 500))
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
@ -1023,9 +644,18 @@ private suspend fun performSearch(q: String): List<DocRecord> {
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
if (q.isBlank()) return emptyList()
|
if (q.isBlank()) return emptyList()
|
||||||
val query = norm(q)
|
val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() }
|
||||||
|
if (terms.isEmpty()) return emptyList()
|
||||||
|
val wordRegex = Regex("[A-Za-z0-9_]+")
|
||||||
|
fun hasPrefixWord(s: String): Boolean {
|
||||||
|
for (m in wordRegex.findAll(s)) {
|
||||||
|
val tl = m.value.lowercase()
|
||||||
|
for (t in terms) if (tl.startsWith(t)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
val res = idx.filter { rec ->
|
val res = idx.filter { rec ->
|
||||||
norm(rec.title).contains(query) || rec.text.contains(query)
|
hasPrefixWord(norm(rec.title)) || hasPrefixWord(rec.text)
|
||||||
}
|
}
|
||||||
dlog("search", "performSearch: q='$q' idx=${idx.size} -> ${res.size} results")
|
dlog("search", "performSearch: q='$q' idx=${idx.size} -> ${res.size} results")
|
||||||
return res
|
return res
|
||||||
@ -1042,7 +672,7 @@ private fun debounce(scope: CoroutineScope, delayMs: Long, block: suspend () ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initTopSearch(attempt: Int = 0) {
|
fun initTopSearch(attempt: Int = 0) {
|
||||||
if (searchInitDone) return
|
if (searchInitDone) return
|
||||||
val input = document.getElementById("topSearch") as? HTMLInputElement
|
val input = document.getElementById("topSearch") as? HTMLInputElement
|
||||||
val menu = document.getElementById("topSearchMenu") as? HTMLDivElement
|
val menu = document.getElementById("topSearchMenu") as? HTMLDivElement
|
||||||
@ -1063,6 +693,18 @@ private fun initTopSearch(attempt: Int = 0) {
|
|||||||
dlog("search", "debounced runSearch execute q='$q'")
|
dlog("search", "debounced runSearch execute q='$q'")
|
||||||
val results = performSearch(q)
|
val results = performSearch(q)
|
||||||
renderSearchResults(input, menu, q, results)
|
renderSearchResults(input, menu, q, results)
|
||||||
|
// Also update highlights on the currently visible page content
|
||||||
|
try {
|
||||||
|
val root = document.querySelector(".markdown-body") as? HTMLElement
|
||||||
|
if (root != null) {
|
||||||
|
val terms = q.trim()
|
||||||
|
.split(Regex("\\s+"))
|
||||||
|
.map { it.lowercase() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
val hits = highlightSearchHits(root, terms)
|
||||||
|
dlog("search", "live highlight updated, hits=$hits, terms=$terms")
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the input focused when interacting with the dropdown so it doesn't blur/close
|
// Keep the input focused when interacting with the dropdown so it doesn't blur/close
|
||||||
@ -1131,155 +773,8 @@ private object MainScopeProvider {
|
|||||||
val scope: CoroutineScope by lazy { kotlinx.coroutines.MainScope() }
|
val scope: CoroutineScope by lazy { kotlinx.coroutines.MainScope() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
// ReferencePage moved to ReferencePage.kt
|
||||||
private fun ReferencePage() {
|
// HomePage moved to HomePage.kt
|
||||||
var docs by remember { mutableStateOf<List<String>?>(null) }
|
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
|
||||||
// Titles resolved from the first H1 of each markdown document
|
|
||||||
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
|
||||||
|
|
||||||
// Load docs index once
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
try {
|
|
||||||
val resp = window.fetch("docs-index.json").await()
|
|
||||||
if (!resp.ok) {
|
|
||||||
error = "Failed to load docs index (${resp.status})"
|
|
||||||
} else {
|
|
||||||
val text = resp.text().await()
|
|
||||||
// Simple JSON parse into dynamic array of strings
|
|
||||||
val arr = js("JSON.parse(text)") as Array<String>
|
|
||||||
docs = arr.toList()
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
error = t.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once we have the docs list, fetch their titles (H1) progressively
|
|
||||||
LaunchedEffect(docs) {
|
|
||||||
val list = docs ?: return@LaunchedEffect
|
|
||||||
// Reset titles when list changes
|
|
||||||
titles = emptyMap()
|
|
||||||
// Fetch sequentially to avoid flooding; fast enough for small/medium doc sets
|
|
||||||
for (path in list) {
|
|
||||||
try {
|
|
||||||
val mdPath = if (path.startsWith("docs/")) path else "docs/$path"
|
|
||||||
val url = "./" + encodeURI(mdPath)
|
|
||||||
val resp = window.fetch(url).await()
|
|
||||||
if (!resp.ok) continue
|
|
||||||
val text = resp.text().await()
|
|
||||||
val title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
|
|
||||||
// Update state incrementally
|
|
||||||
titles = titles + (path to title)
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore individual failures; fallback will be filename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
H2({ classes("h5", "mb-3") }) { Text("Reference") }
|
|
||||||
P({ classes("text-muted") }) { Text("Browse all documentation pages included in this build.") }
|
|
||||||
|
|
||||||
when {
|
|
||||||
error != null -> Div({ classes("alert", "alert-danger") }) { Text(error!!) }
|
|
||||||
docs == null -> P { Text("Loading index…") }
|
|
||||||
docs!!.isEmpty() -> P { Text("No documents found.") }
|
|
||||||
else -> {
|
|
||||||
Ul({ classes("list-group") }) {
|
|
||||||
docs!!.sorted().forEach { path ->
|
|
||||||
val displayTitle = titles[path] ?: path.substringAfterLast('/')
|
|
||||||
Li({ classes("list-group-item", "d-flex", "justify-content-between", "align-items-center") }) {
|
|
||||||
Div({}) {
|
|
||||||
A(attrs = {
|
|
||||||
classes("link-body-emphasis", "text-decoration-none")
|
|
||||||
attr("href", "#/$path")
|
|
||||||
}) { Text(displayTitle) }
|
|
||||||
Br()
|
|
||||||
Small({ classes("text-muted") }) { Text(path) }
|
|
||||||
}
|
|
||||||
I({ classes("bi", "bi-chevron-right") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun HomePage() {
|
|
||||||
// Hero section
|
|
||||||
Section({ classes("py-4", "py-lg-5") }) {
|
|
||||||
Div({ classes("text-center") }) {
|
|
||||||
H1({ classes("display-5", "fw-bold", "mb-3") }) { Text("Welcome to Lyng") }
|
|
||||||
P({ classes("lead", "text-muted", "mb-4") }) {
|
|
||||||
Text("A lightweight, expressive scripting language designed for clarity, composability, and fun. ")
|
|
||||||
Br()
|
|
||||||
Text("Run it anywhere Kotlin runs — share logic across JS, JVM, and more.")
|
|
||||||
}
|
|
||||||
Div({ classes("d-flex", "justify-content-center", "gap-2", "flex-wrap", "mb-4") }) {
|
|
||||||
// Benefits pills
|
|
||||||
listOf(
|
|
||||||
"Clean, familiar syntax",
|
|
||||||
"Immutable-first collections",
|
|
||||||
"Batteries-included standard library",
|
|
||||||
"Embeddable and testable"
|
|
||||||
).forEach { b ->
|
|
||||||
Span({ classes("badge", "text-bg-secondary", "rounded-pill") }) { Text(b) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// CTA buttons
|
|
||||||
Div({ classes("d-flex", "justify-content-center", "gap-2", "mb-4") }) {
|
|
||||||
A(attrs = {
|
|
||||||
classes("btn", "btn-primary", "btn-lg")
|
|
||||||
attr("href", "#/docs/tutorial.md")
|
|
||||||
}) {
|
|
||||||
I({ classes("bi", "bi-play-fill", "me-1") })
|
|
||||||
Text("Start the tutorial")
|
|
||||||
}
|
|
||||||
A(attrs = {
|
|
||||||
classes("btn", "btn-outline-secondary", "btn-lg")
|
|
||||||
attr("href", "#/reference")
|
|
||||||
}) {
|
|
||||||
I({ classes("bi", "bi-journal-text", "me-1") })
|
|
||||||
Text("Browse reference")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code sample
|
|
||||||
val code = """
|
|
||||||
// Create, transform, and verify — the Lyng way
|
|
||||||
val data = [1, 2, 3, 4, 5]
|
|
||||||
val evens = data.filter { it % 2 == 0 }.map { it * it }
|
|
||||||
assertEquals([4, 16], evens)
|
|
||||||
>>> void
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val codeHtml = "<pre><code>" + htmlEscape(code) + "</code></pre>"
|
|
||||||
Div({ classes("markdown-body") }) {
|
|
||||||
UnsafeRawHtml(highlightLyngHtml(ensureBootstrapCodeBlocks(codeHtml)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short features list
|
|
||||||
Div({ classes("row", "g-4", "mt-1") }) {
|
|
||||||
listOf(
|
|
||||||
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) ->
|
|
||||||
Div({ classes("col-12", "col-md-4") }) {
|
|
||||||
Div({ classes("h-100", "p-3", "border", "rounded-3", "bg-body-tertiary") }) {
|
|
||||||
Div({ classes("d-flex", "align-items-center", "mb-2", "fs-4") }) {
|
|
||||||
I({ classes("bi", "bi-$icon", "me-2") })
|
|
||||||
Span({ classes("fw-semibold") }) { Text(title) }
|
|
||||||
}
|
|
||||||
P({ classes("mb-0", "text-muted") }) { Text(text) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Theme handling: follow system theme automatically ----
|
// ---- Theme handling: follow system theme automatically ----
|
||||||
|
|
||||||
@ -1402,7 +897,7 @@ private fun ensureBootstrapTables(html: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure all markdown-rendered code blocks (<pre>...</pre>) have Bootstrap-like `.code` class
|
// Ensure all markdown-rendered code blocks (<pre>...</pre>) have Bootstrap-like `.code` class
|
||||||
private fun ensureBootstrapCodeBlocks(html: String): String {
|
fun ensureBootstrapCodeBlocks(html: String): String {
|
||||||
// Target opening <pre ...> tags (case-insensitive)
|
// Target opening <pre ...> tags (case-insensitive)
|
||||||
val preTagRegex = Regex("<pre(\\s+[^>]*)?>", RegexOption.IGNORE_CASE)
|
val preTagRegex = Regex("<pre(\\s+[^>]*)?>", RegexOption.IGNORE_CASE)
|
||||||
val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)
|
val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)
|
||||||
@ -1579,7 +1074,7 @@ private fun cssClassForKind(kind: net.sergeych.lyng.highlight.HighlightKind): St
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minimal HTML escaping for text nodes
|
// Minimal HTML escaping for text nodes
|
||||||
private fun htmlEscape(s: String): String = buildString(s.length) {
|
fun htmlEscape(s: String): String = buildString(s.length) {
|
||||||
for (ch in s) when (ch) {
|
for (ch in s) when (ch) {
|
||||||
'<' -> append("<")
|
'<' -> append("<")
|
||||||
'>' -> append(">")
|
'>' -> append(">")
|
||||||
@ -1601,7 +1096,7 @@ private fun htmlUnescape(s: String): String {
|
|||||||
.replace("'", "'")
|
.replace("'", "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rewriteImages(root: HTMLElement, basePath: String) {
|
fun rewriteImages(root: HTMLElement, basePath: String) {
|
||||||
val imgs = root.querySelectorAll("img")
|
val imgs = root.querySelectorAll("img")
|
||||||
for (i in 0 until imgs.length) {
|
for (i in 0 until imgs.length) {
|
||||||
val el = imgs.item(i) as? HTMLImageElement ?: continue
|
val el = imgs.item(i) as? HTMLImageElement ?: continue
|
||||||
@ -1611,7 +1106,7 @@ private fun rewriteImages(root: HTMLElement, basePath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun rewriteAnchors(
|
fun rewriteAnchors(
|
||||||
root: HTMLElement,
|
root: HTMLElement,
|
||||||
basePath: String,
|
basePath: String,
|
||||||
currentDocPath: String,
|
currentDocPath: String,
|
||||||
@ -1667,7 +1162,7 @@ internal fun rewriteAnchors(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun buildToc(root: HTMLElement): List<TocItem> {
|
fun buildToc(root: HTMLElement): List<TocItem> {
|
||||||
val out = mutableListOf<TocItem>()
|
val out = mutableListOf<TocItem>()
|
||||||
val used = hashSetOf<String>()
|
val used = hashSetOf<String>()
|
||||||
val hs = root.querySelectorAll("h1, h2, h3")
|
val hs = root.querySelectorAll("h1, h2, h3")
|
||||||
|
|||||||
18
site/src/jsMain/kotlin/Pages.kt
Normal file
18
site/src/jsMain/kotlin/Pages.kt
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Pages moved to separate files: DocsPage.kt, HomePage.kt, ReferencePage.kt
|
||||||
95
site/src/jsMain/kotlin/ReferencePage.kt
Normal file
95
site/src/jsMain/kotlin/ReferencePage.kt
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import kotlinx.browser.window
|
||||||
|
import kotlinx.coroutines.await
|
||||||
|
import org.jetbrains.compose.web.dom.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReferencePage() {
|
||||||
|
var docs by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
// Titles resolved from the first H1 of each markdown document
|
||||||
|
var titles by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||||
|
|
||||||
|
// Load docs index once
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
val resp = window.fetch("docs-index.json").await()
|
||||||
|
if (!resp.ok) {
|
||||||
|
error = "Failed to load docs index (${resp.status})"
|
||||||
|
} else {
|
||||||
|
val text = resp.text().await()
|
||||||
|
// Simple JSON parse into dynamic array of strings
|
||||||
|
val arr = js("JSON.parse(text)") as Array<String>
|
||||||
|
docs = arr.toList()
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
error = t.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once we have the docs list, fetch their titles (H1) progressively
|
||||||
|
LaunchedEffect(docs) {
|
||||||
|
val list = docs ?: return@LaunchedEffect
|
||||||
|
// Reset titles when list changes
|
||||||
|
titles = emptyMap()
|
||||||
|
// Fetch sequentially to avoid flooding; fast enough for small/medium doc sets
|
||||||
|
for (path in list) {
|
||||||
|
try {
|
||||||
|
val mdPath = if (path.startsWith("docs/")) path else "docs/$path"
|
||||||
|
val url = "./" + encodeURI(mdPath)
|
||||||
|
val resp = window.fetch(url).await()
|
||||||
|
if (!resp.ok) continue
|
||||||
|
val text = resp.text().await()
|
||||||
|
val title = extractTitleFromMarkdown(text) ?: path.substringAfterLast('/')
|
||||||
|
// Update state incrementally
|
||||||
|
titles = titles + (path to title)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore individual failures; fallback will be filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
H2({ classes("h5", "mb-3") }) { Text("Reference") }
|
||||||
|
P({ classes("text-muted") }) { Text("Browse all documentation pages included in this build.") }
|
||||||
|
|
||||||
|
when {
|
||||||
|
error != null -> Div({ classes("alert", "alert-danger") }) { Text(error!!) }
|
||||||
|
docs == null -> P { Text("Loading index…") }
|
||||||
|
docs!!.isEmpty() -> P { Text("No documents found.") }
|
||||||
|
else -> {
|
||||||
|
Ul({ classes("list-group") }) {
|
||||||
|
docs!!.sorted().forEach { path ->
|
||||||
|
val displayTitle = titles[path] ?: path.substringAfterLast('/')
|
||||||
|
Li({ classes("list-group-item", "d-flex", "justify-content-between", "align-items-center") }) {
|
||||||
|
Div({}) {
|
||||||
|
A(attrs = {
|
||||||
|
classes("link-body-emphasis", "text-decoration-none")
|
||||||
|
attr("href", "#/$path")
|
||||||
|
}) { Text(displayTitle) }
|
||||||
|
Br()
|
||||||
|
Small({ classes("text-muted") }) { Text(path) }
|
||||||
|
}
|
||||||
|
I({ classes("bi", "bi-chevron-right") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
site/src/jsMain/kotlin/Types.kt
Normal file
20
site/src/jsMain/kotlin/Types.kt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Common small data types used across site */
|
||||||
|
|
||||||
|
data class TocItem(val level: Int, val id: String, val title: String)
|
||||||
83
site/src/jsTest/kotlin/LinkRewriteTest.kt
Normal file
83
site/src/jsTest/kotlin/LinkRewriteTest.kt
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tests for link and image rewriting in rendered markdown HTML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import org.w3c.dom.HTMLAnchorElement
|
||||||
|
import org.w3c.dom.HTMLDivElement
|
||||||
|
import org.w3c.dom.HTMLImageElement
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class LinkRewriteTest {
|
||||||
|
private fun makeContainer(html: String): HTMLDivElement {
|
||||||
|
val div = document.createElement("div") as HTMLDivElement
|
||||||
|
div.innerHTML = html.trimIndent()
|
||||||
|
return div
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRewriteAnchorsAndImagesUsingDocBasePath() {
|
||||||
|
val html = """
|
||||||
|
<div class="markdown-body">
|
||||||
|
<p>
|
||||||
|
<a id="a1" href="Iterator.md">iterator page</a>
|
||||||
|
<a id="a2" href="Iterator.md#intro">iterator with frag</a>
|
||||||
|
<a id="a3" href="#install">install section</a>
|
||||||
|
<a id="a4" href="https://example.com">external</a>
|
||||||
|
<a id="a5" href="img/p.png">asset</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<img id="i1" src="images/pic.png" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
val root = makeContainer(html)
|
||||||
|
|
||||||
|
val currentDoc = "docs/tutorial.md"
|
||||||
|
val basePath = currentDoc.substringBeforeLast('/')
|
||||||
|
|
||||||
|
// exercise rewrites
|
||||||
|
rewriteImages(root, basePath)
|
||||||
|
rewriteAnchors(root, basePath, currentDoc) { /* no-op for tests */ }
|
||||||
|
|
||||||
|
// Validate anchors
|
||||||
|
val a1 = root.querySelector("#a1") as HTMLAnchorElement
|
||||||
|
assertEquals("#/docs/Iterator.md", a1.getAttribute("href"))
|
||||||
|
|
||||||
|
val a2 = root.querySelector("#a2") as HTMLAnchorElement
|
||||||
|
assertEquals("#/docs/Iterator.md#intro", a2.getAttribute("href"))
|
||||||
|
|
||||||
|
val a3 = root.querySelector("#a3") as HTMLAnchorElement
|
||||||
|
assertEquals("#/docs/tutorial.md#install", a3.getAttribute("href"))
|
||||||
|
|
||||||
|
val a4 = root.querySelector("#a4") as HTMLAnchorElement
|
||||||
|
// external should remain unchanged
|
||||||
|
assertEquals("https://example.com", a4.getAttribute("href"))
|
||||||
|
|
||||||
|
val a5 = root.querySelector("#a5") as HTMLAnchorElement
|
||||||
|
// non-md relative assets should become relative to doc directory (no SPA hash)
|
||||||
|
assertEquals("docs/img/p.png", a5.getAttribute("href"))
|
||||||
|
|
||||||
|
// Validate image src
|
||||||
|
val i1 = root.querySelector("#i1") as HTMLImageElement
|
||||||
|
assertEquals("docs/images/pic.png", i1.getAttribute("src"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user