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
|
||||
|
||||
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.
|
||||
|
||||
- simple, compact, intuitive and elegant modern code:
|
||||
@ -171,8 +173,6 @@ Ready features:
|
||||
- [x] dynamic fields
|
||||
- [x] function annotations
|
||||
- [x] better stack reporting
|
||||
|
||||
|
||||
- [x] regular exceptions + extended `when`
|
||||
- [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 kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import kotlinx.coroutines.*
|
||||
import org.jetbrains.compose.web.dom.*
|
||||
import org.jetbrains.compose.web.renderComposable
|
||||
import org.w3c.dom.HTMLAnchorElement
|
||||
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)
|
||||
import org.w3c.dom.*
|
||||
|
||||
// --------------- Lightweight debug logging ---------------
|
||||
// Disable debug logging by default
|
||||
private var SEARCH_DEBUG: Boolean = false
|
||||
private fun dlog(tag: String, msg: String) {
|
||||
fun dlog(tag: String, msg: String) {
|
||||
if (!SEARCH_DEBUG) return
|
||||
try {
|
||||
console.log("[LYNG][$tag] $msg")
|
||||
@ -84,31 +74,11 @@ private fun exposeSearchDebugToggle() {
|
||||
} catch (_: dynamic) { }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// externals moved to Externals.kt
|
||||
|
||||
// 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() {
|
||||
fun ensureScrollOffsetStyles() {
|
||||
try {
|
||||
val doc = window.document
|
||||
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
|
||||
private fun updateNavbarOffsetVar(): Int {
|
||||
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")
|
||||
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() {
|
||||
fun ensureSearchHighlightStyles() {
|
||||
try {
|
||||
val doc = window.document
|
||||
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)
|
||||
private fun ensureDocsLayoutStyles() {
|
||||
fun ensureDocsLayoutStyles() {
|
||||
try {
|
||||
val doc = window.document
|
||||
if (doc.getElementById("docs-layout-style") == null) {
|
||||
@ -220,165 +190,9 @@ private fun ensureDocsLayoutStyles() {
|
||||
}
|
||||
}
|
||||
|
||||
@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/")
|
||||
// A stable key for the current document path (without fragment). Used by DocsPage.
|
||||
val docKey = stripFragment(route)
|
||||
// App() moved to App.kt
|
||||
|
||||
// Initialize dynamic Documentation dropdown once
|
||||
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
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
}
|
||||
// DocLink and UnsafeRawHtml moved to Components.kt
|
||||
|
||||
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.
|
||||
fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
||||
if (terms.isEmpty()) return 0
|
||||
// Make sure CSS for highlighting is injected
|
||||
ensureSearchHighlightStyles()
|
||||
// Remove previous highlights
|
||||
// Always remove previous highlights first so calling with empty terms clears them
|
||||
try {
|
||||
val prev = root.getElementsByClassName("search-hit")
|
||||
// Because HTMLCollection is live, copy to array first
|
||||
@ -422,6 +235,8 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
|
||||
if (terms.isEmpty()) return 0
|
||||
|
||||
// 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
|
||||
@ -453,12 +268,21 @@ fun highlightSearchHits(root: HTMLElement, terms: List<String>): Int {
|
||||
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) {
|
||||
// choose the longest term that is a prefix of the token
|
||||
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
|
||||
mark.className = "search-hit"
|
||||
mark.textContent = token
|
||||
mark.textContent = prefix
|
||||
container.appendChild(mark)
|
||||
if (suffix.isNotEmpty()) container.appendChild(doc.createTextNode(suffix))
|
||||
hits++
|
||||
} else {
|
||||
container.appendChild(doc.createTextNode(token))
|
||||
@ -511,240 +335,17 @@ fun renderReferenceListHtml(docs: List<String>): String {
|
||||
return "<ul class=\"list-group\">$items</ul>"
|
||||
}
|
||||
|
||||
// --------------- New Composables: PageTemplate and DocsPage ---------------
|
||||
// PageTemplate moved to Components.kt
|
||||
|
||||
@Composable
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// DocsPage moved to Pages.kt
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@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? {
|
||||
fun extractTitleFromMarkdown(md: String): String? {
|
||||
val lines = md.lines()
|
||||
val h1 = lines.firstOrNull { it.trimStart().startsWith("# ") }
|
||||
return h1?.trimStart()?.removePrefix("# ")?.trim()
|
||||
}
|
||||
|
||||
private suspend fun initDocsDropdown() {
|
||||
suspend fun initDocsDropdown() {
|
||||
try {
|
||||
dlog("docs-dd", "initDocsDropdown start")
|
||||
val menu = document.getElementById("docsDropdownMenu") ?: run {
|
||||
@ -938,18 +539,38 @@ private suspend fun buildSearchIndexOnce() {
|
||||
}
|
||||
|
||||
private fun scoreQuery(q: String, rec: DocRecord): Int {
|
||||
val query = norm(q)
|
||||
if (query.isBlank()) return 0
|
||||
val terms = q.trim().split(Regex("\\s+")).map { it.lowercase() }.filter { it.isNotEmpty() }
|
||||
if (terms.isEmpty()) return 0
|
||||
var score = 0
|
||||
val title = norm(rec.title)
|
||||
val text = rec.text
|
||||
// Title startsWith gets high score
|
||||
if (title.startsWith(query)) score += 100
|
||||
if (title.contains(query)) score += 60
|
||||
// Body occurrences (basic)
|
||||
val idx = text.indexOf(query)
|
||||
if (idx >= 0) score += 30
|
||||
// Shorter files slightly preferred
|
||||
// Title bonuses: longer prefix matches get higher score
|
||||
for (t in terms) {
|
||||
if (title.startsWith(t)) score += 120 + t.length
|
||||
else if (title.split(Regex("[A-Za-z0-9_]+")).isEmpty()) { /* no-op */ }
|
||||
else {
|
||||
// title words prefix
|
||||
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))
|
||||
return score
|
||||
}
|
||||
@ -1023,9 +644,18 @@ private suspend fun performSearch(q: String): List<DocRecord> {
|
||||
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 ->
|
||||
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")
|
||||
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
|
||||
val input = document.getElementById("topSearch") as? HTMLInputElement
|
||||
val menu = document.getElementById("topSearchMenu") as? HTMLDivElement
|
||||
@ -1063,6 +693,18 @@ private fun initTopSearch(attempt: Int = 0) {
|
||||
dlog("search", "debounced runSearch execute q='$q'")
|
||||
val results = performSearch(q)
|
||||
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
|
||||
@ -1131,155 +773,8 @@ private object MainScopeProvider {
|
||||
val scope: CoroutineScope by lazy { kotlinx.coroutines.MainScope() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private 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") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ReferencePage moved to ReferencePage.kt
|
||||
// HomePage moved to HomePage.kt
|
||||
|
||||
// ---- 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
|
||||
private fun ensureBootstrapCodeBlocks(html: String): String {
|
||||
fun ensureBootstrapCodeBlocks(html: String): String {
|
||||
// Target opening <pre ...> tags (case-insensitive)
|
||||
val preTagRegex = Regex("<pre(\\s+[^>]*)?>", 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
|
||||
private fun htmlEscape(s: String): String = buildString(s.length) {
|
||||
fun htmlEscape(s: String): String = buildString(s.length) {
|
||||
for (ch in s) when (ch) {
|
||||
'<' -> append("<")
|
||||
'>' -> append(">")
|
||||
@ -1601,7 +1096,7 @@ private fun htmlUnescape(s: String): String {
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
private fun rewriteImages(root: HTMLElement, basePath: String) {
|
||||
fun rewriteImages(root: HTMLElement, basePath: String) {
|
||||
val imgs = root.querySelectorAll("img")
|
||||
for (i in 0 until imgs.length) {
|
||||
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,
|
||||
basePath: 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 used = hashSetOf<String>()
|
||||
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