added DocsPage, improved navbar with dynamic height handling, MathJax integration, new TOC features, and extensive markdown processing
This commit is contained in:
parent
918534afb5
commit
1fadc42414
24
.run/lyng_site [jsBrowserDevelopmentRun].run.xml
Normal file
24
.run/lyng_site [jsBrowserDevelopmentRun].run.xml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/site" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="scriptParameters" value="--continuous" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value="jsBrowserDevelopmentRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# Classes
|
|
||||||
|
|
||||||
## Declaring
|
|
||||||
|
|
||||||
class Foo1
|
|
||||||
class Foo2() // same, empty constructor
|
|
||||||
class Foo3() { // full
|
|
||||||
}
|
|
||||||
class Foo4 { // Only body
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
class_declaration = ["abstract",] "class" [, constructor] [, body]
|
|
||||||
constructor = "(", [field [, field]] ")
|
|
||||||
field = [visibility ,] [access ,] name [, typedecl]
|
|
||||||
body = [visibility] ("var", vardecl) | ("val", vardecl) | ("fun", fundecl)
|
|
||||||
visibility = "private" | "protected" | "internal"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Abstract classes
|
|
||||||
|
|
||||||
Contain one pr more abstract methods which must be implemented; though they
|
|
||||||
can have constructors, the instances of the abstract classes could not be
|
|
||||||
created independently
|
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
# String
|
# String
|
||||||
|
|
||||||
# This document is for developer notes only
|
# This document is for developer notes only
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# Modules inclusion
|
# Modules inclusion
|
||||||
|
|
||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
Module is, at the low level, a statement that modifies a given context by adding
|
Module is, at the low level, a statement that modifies a given context by adding
|
||||||
here local and exported symbols, performing some tasks and even returning some value
|
here local and exported symbols, performing some tasks and even returning some value
|
||||||
we don't need for now.
|
we don't need for now.
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
|
|
||||||
Provide:
|
Provide:
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
This document explains how to enable and measure the performance optimizations added to the Lyng interpreter. The focus is JVM‑first with safe, flag‑guarded rollouts and quick A/B testing. Other targets (JS/Wasm/Native) keep conservative defaults until validated.
|
This document explains how to enable and measure the performance optimizations added to the Lyng interpreter. The focus is JVM‑first with safe, flag‑guarded rollouts and quick A/B testing. Other targets (JS/Wasm/Native) keep conservative defaults until validated.
|
||||||
|
|
||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Optimizations are controlled by runtime‑mutable flags in `net.sergeych.lyng.PerfFlags`, initialized from platform‑specific static defaults `net.sergeych.lyng.PerfDefaults` (KMP `expect/actual`).
|
Optimizations are controlled by runtime‑mutable flags in `net.sergeych.lyng.PerfFlags`, initialized from platform‑specific static defaults `net.sergeych.lyng.PerfDefaults` (KMP `expect/actual`).
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# JVM-only Performance Optimization Plan (Saved)
|
# JVM-only Performance Optimization Plan (Saved)
|
||||||
|
|
||||||
|
[//]: # (excludeFromIndex)
|
||||||
|
|
||||||
Date: 2025-11-10 22:14 (local)
|
Date: 2025-11-10 22:14 (local)
|
||||||
|
|
||||||
This document captures the agreed next optimization steps so we can restore the plan later if needed.
|
This document captures the agreed next optimization steps so we can restore the plan later if needed.
|
||||||
|
|||||||
@ -1,3 +1,22 @@
|
|||||||
|
# Lyng tutorial
|
||||||
|
|
||||||
|
Lyng is a very simple language, where we take only most important and popular features from
|
||||||
|
other scripts and languages. In particular, we adopt _principle of minimal confusion_[^1].
|
||||||
|
In other word, the code usually works as expected when you see it. So, nothing unusual.
|
||||||
|
|
||||||
|
__Other documents to read__ maybe after this one:
|
||||||
|
|
||||||
|
- [Advanced topics](advanced_topics.md), [declaring arguments](declaring_arguments.md)
|
||||||
|
- [OOP notes](OOP.md), [exception handling](exceptions_handling.md)
|
||||||
|
- [math in Lyng](math.md)
|
||||||
|
- [time](time.md) and [parallelism](parallelism.md)
|
||||||
|
- [parallelism] - multithreaded code, coroutines, etc.
|
||||||
|
- Some class
|
||||||
|
references: [List], [Set], [Map], [Real], [Range], [Iterable], [Iterator], [time manipulation](time.md), [Array], [RingBuffer], [Buffer].
|
||||||
|
- Some samples: [combinatorics](samples/combinatorics.lyng.md), national vars and
|
||||||
|
loops: [сумма ряда](samples/сумма_ряда.lyng.md). More at [samples folder](samples)
|
||||||
|
|
||||||
|
# Expressions
|
||||||
|
|
||||||
Everything is an expression in Lyng. Even an empty block:
|
Everything is an expression in Lyng. Even an empty block:
|
||||||
|
|
||||||
@ -1465,3 +1484,5 @@ Notes:
|
|||||||
- Resolution order uses C3 MRO (active): deterministic, monotonic order suitable for diamonds and complex hierarchies. Example: for `class D() : B(), C()` where both `B()` and `C()` derive from `A()`, the C3 order is `D → B → C → A`. The first visible match wins.
|
- Resolution order uses C3 MRO (active): deterministic, monotonic order suitable for diamonds and complex hierarchies. Example: for `class D() : B(), C()` where both `B()` and `C()` derive from `A()`, the C3 order is `D → B → C → A`. The first visible match wins.
|
||||||
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualification (`this@Type`) or casts do not bypass visibility.
|
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualification (`this@Type`) or casts do not bypass visibility.
|
||||||
- Safe‑call `?.` works with `as?` for optional dispatch.
|
- Safe‑call `?.` works with `as?` for optional dispatch.
|
||||||
|
|
||||||
|
To get details on OOP in Lyng, see [OOP notes](oop.md).
|
||||||
@ -34,4 +34,4 @@ kotlin.native.cacheKind.linuxX64=none
|
|||||||
# On this environment, the system JDK 21 installation lacks `jlink`, causing
|
# On this environment, the system JDK 21 installation lacks `jlink`, causing
|
||||||
# :lynglib:androidJdkImage to fail. Point Gradle to JDK 17 which includes `jlink`.
|
# :lynglib:androidJdkImage to fail. Point Gradle to JDK 17 which includes `jlink`.
|
||||||
# This affects only the JDK Gradle runs with; Kotlin/JVM target remains compatible.
|
# This affects only the JDK Gradle runs with; Kotlin/JVM target remains compatible.
|
||||||
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
#org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
||||||
@ -53,6 +53,7 @@ kotlin {
|
|||||||
implementation(project(":lynglib"))
|
implementation(project(":lynglib"))
|
||||||
// Markdown parser (NPM)
|
// Markdown parser (NPM)
|
||||||
implementation(npm("marked", "12.0.2"))
|
implementation(npm("marked", "12.0.2"))
|
||||||
|
// MathJax is loaded via CDN in index.html; no npm dependency required
|
||||||
}
|
}
|
||||||
// Serve project docs and images as static resources in the site
|
// Serve project docs and images as static resources in the site
|
||||||
resources.srcDir(rootProject.projectDir.resolve("docs"))
|
resources.srcDir(rootProject.projectDir.resolve("docs"))
|
||||||
|
|||||||
@ -30,6 +30,12 @@ import org.w3c.dom.HTMLLinkElement
|
|||||||
|
|
||||||
data class TocItem(val level: Int, val id: String, val title: String)
|
data class TocItem(val level: Int, val id: String, val title: String)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
var route by remember { mutableStateOf(currentRoute()) }
|
var route by remember { mutableStateOf(currentRoute()) }
|
||||||
@ -39,10 +45,14 @@ fun App() {
|
|||||||
var activeTocId by remember { mutableStateOf<String?>(null) }
|
var activeTocId by remember { mutableStateOf<String?>(null) }
|
||||||
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
|
||||||
val isDocsRoute = route.startsWith("docs/")
|
val isDocsRoute = route.startsWith("docs/")
|
||||||
// A stable key for the current document path (without fragment). Used to avoid
|
// A stable key for the current document path (without fragment). Used by DocsPage.
|
||||||
// re-fetching when only the in-page anchor changes.
|
|
||||||
val docKey = stripFragment(route)
|
val docKey = stripFragment(route)
|
||||||
|
|
||||||
|
// Initialize dynamic Documentation dropdown once
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
initDocsDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
// Listen to hash changes (routing)
|
// Listen to hash changes (routing)
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val listener: (org.w3c.dom.events.Event) -> Unit = {
|
val listener: (org.w3c.dom.events.Event) -> Unit = {
|
||||||
@ -52,110 +62,25 @@ fun App() {
|
|||||||
onDispose { window.removeEventListener("hashchange", listener) }
|
onDispose { window.removeEventListener("hashchange", listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and render markdown whenever the document path changes (ignore fragment-only changes)
|
// Docs-specific fetching and effects are handled inside DocsPage now
|
||||||
LaunchedEffect(docKey) {
|
|
||||||
error = null
|
|
||||||
html = null
|
|
||||||
if (!isDocsRoute) return@LaunchedEffect
|
|
||||||
val path = routeToPath(route)
|
|
||||||
try {
|
|
||||||
val resp = window.fetch(path).await()
|
|
||||||
if (!resp.ok) {
|
|
||||||
error = "Not found: $path (${resp.status})"
|
|
||||||
} else {
|
|
||||||
val text = resp.text().await()
|
|
||||||
html = renderMarkdown(text)
|
|
||||||
}
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
error = "Failed to load: $path — ${t.message}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post-process links, images and build TOC after html injection
|
|
||||||
LaunchedEffect(html) {
|
|
||||||
if (!isDocsRoute) return@LaunchedEffect
|
|
||||||
val el = contentEl ?: return@LaunchedEffect
|
|
||||||
// Wait next tick so DOM has the HTML
|
|
||||||
window.setTimeout({
|
|
||||||
val basePath = routeToPath(route).substringBeforeLast('/', "docs")
|
|
||||||
rewriteImages(el, basePath)
|
|
||||||
rewriteAnchors(el, basePath) { newRoute ->
|
|
||||||
// Preserve potential anchor contained in newRoute and set SPA hash
|
|
||||||
window.location.hash = "#/$newRoute"
|
|
||||||
}
|
|
||||||
toc = buildToc(el)
|
|
||||||
// Reset active TOC id on new content
|
|
||||||
activeTocId = toc.firstOrNull()?.id
|
|
||||||
|
|
||||||
// If the current hash includes an anchor (e.g., #/docs/file.md#section), scroll to it
|
|
||||||
val frag = anchorFromHash(window.location.hash)
|
|
||||||
if (!frag.isNullOrBlank()) {
|
|
||||||
val target = el.ownerDocument?.getElementById(frag)
|
|
||||||
(target as? HTMLElement)?.scrollIntoView()
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When only the fragment changes on the same document, scroll to the target without re-fetching
|
|
||||||
LaunchedEffect(route) {
|
|
||||||
if (!isDocsRoute) return@LaunchedEffect
|
|
||||||
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
|
|
||||||
DisposableEffect(toc, contentEl) {
|
|
||||||
if (toc.isEmpty() || contentEl == null || !isDocsRoute) return@DisposableEffect onDispose {}
|
|
||||||
|
|
||||||
var scheduled = false
|
|
||||||
fun computeActive() {
|
|
||||||
scheduled = false
|
|
||||||
// Determine tops relative to viewport for each heading
|
|
||||||
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) {
|
|
||||||
activeTocId = newId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val scrollListener: (org.w3c.dom.events.Event) -> Unit = {
|
|
||||||
if (!scheduled) {
|
|
||||||
scheduled = true
|
|
||||||
window.requestAnimationFrame { computeActive() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val resizeListener = scrollListener
|
|
||||||
|
|
||||||
// Initial compute
|
|
||||||
computeActive()
|
|
||||||
window.addEventListener("scroll", scrollListener)
|
|
||||||
window.addEventListener("resize", resizeListener)
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
window.removeEventListener("scroll", scrollListener)
|
|
||||||
window.removeEventListener("resize", resizeListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
Div({ classes("container", "py-4") }) {
|
PageTemplate(title = when {
|
||||||
H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") }
|
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") }) {
|
Div({ classes("row", "gy-4") }) {
|
||||||
// Sidebar TOC
|
// Sidebar TOC: show only on docs pages
|
||||||
|
if (isDocsRoute) {
|
||||||
Div({ classes("col-12", "col-lg-3") }) {
|
Div({ classes("col-12", "col-lg-3") }) {
|
||||||
Nav({ classes("position-sticky"); attr("style", "top: 1rem") }) {
|
// 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") }
|
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") }
|
||||||
Ul({ classes("list-unstyled") }) {
|
Ul({ classes("list-unstyled") }) {
|
||||||
toc.forEach { item ->
|
toc.forEach { item ->
|
||||||
@ -190,66 +115,26 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main content
|
// Main content
|
||||||
Div({ classes("col-12", "col-lg-9") }) {
|
Div({ classes("col-12", if (isDocsRoute) "col-lg-9" else "col-lg-12") }) {
|
||||||
// Top actions
|
when {
|
||||||
Div({ classes("mb-3", "d-flex", "gap-2", "flex-wrap", "align-items-center") }) {
|
route.isBlank() -> HomePage()
|
||||||
// Reference page link
|
!isDocsRoute -> ReferencePage()
|
||||||
A(attrs = {
|
else -> DocsPage(
|
||||||
classes("btn", "btn-sm", "btn-primary")
|
route = route,
|
||||||
attr("href", "#/reference")
|
html = html,
|
||||||
onClick { it.preventDefault(); window.location.hash = "#/reference" }
|
error = error,
|
||||||
}) { Text("Reference") }
|
contentEl = contentEl,
|
||||||
|
onContentEl = { contentEl = it },
|
||||||
// Sample quick links
|
setError = { error = it },
|
||||||
DocLink("Iterable.md")
|
setHtml = { html = it },
|
||||||
DocLink("Iterator.md")
|
toc = toc,
|
||||||
DocLink("perf_guide.md")
|
setToc = { toc = it },
|
||||||
}
|
activeTocId = activeTocId,
|
||||||
|
setActiveTocId = { activeTocId = it },
|
||||||
if (!isDocsRoute) {
|
)
|
||||||
ReferencePage()
|
|
||||||
} else if (error != null) {
|
|
||||||
Div({ classes("alert", "alert-danger") }) { Text(error!!) }
|
|
||||||
} else if (html == null) {
|
|
||||||
P { Text("Loading…") }
|
|
||||||
} else {
|
|
||||||
// Back button
|
|
||||||
Div({ classes("mb-3") }) {
|
|
||||||
A(attrs = {
|
|
||||||
classes("btn", "btn-outline-secondary", "btn-sm")
|
|
||||||
onClick {
|
|
||||||
it.preventDefault()
|
|
||||||
// Try browser history back; if not possible, go to reference
|
|
||||||
try {
|
|
||||||
if (window.history.length > 1) window.history.back()
|
|
||||||
else window.location.hash = "#/reference"
|
|
||||||
} catch (e: dynamic) {
|
|
||||||
window.location.hash = "#/reference"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
attr("href", "#/reference")
|
|
||||||
}) {
|
|
||||||
I({ classes("bi", "bi-arrow-left", "me-1") })
|
|
||||||
Text("Back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inject rendered HTML
|
|
||||||
Div({
|
|
||||||
classes("markdown-body")
|
|
||||||
ref {
|
|
||||||
contentEl = it
|
|
||||||
onDispose {
|
|
||||||
if (contentEl === it) contentEl = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
// Unsafe raw HTML is needed to render markdown output
|
|
||||||
// Compose for Web allows raw HTML injection via Text API in unsafe context
|
|
||||||
// but the simpler way is to use the deprecated attribute; instead use raw
|
|
||||||
UnsafeRawHtml(html!!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,7 +171,7 @@ private fun UnsafeRawHtml(html: String) {
|
|||||||
}) {}
|
}) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentRoute(): String = window.location.hash.removePrefix("#/").ifBlank { "docs/Iterator.md" }
|
fun currentRoute(): String = window.location.hash.removePrefix("#/")
|
||||||
|
|
||||||
fun routeToPath(route: String): String {
|
fun routeToPath(route: String): String {
|
||||||
val noFrag = stripFragment(route)
|
val noFrag = stripFragment(route)
|
||||||
@ -333,6 +218,253 @@ 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 ---------------
|
||||||
|
|
||||||
|
@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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
val resp = window.fetch(path).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 = title, showBack = true) {
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 h1 = lines.firstOrNull { it.trimStart().startsWith("# ") }
|
||||||
|
return h1?.trimStart()?.removePrefix("# ")?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initDocsDropdown() {
|
||||||
|
try {
|
||||||
|
val menu = document.getElementById("docsDropdownMenu") ?: return
|
||||||
|
// Fetch docs index
|
||||||
|
val resp = window.fetch("docs-index.json").await()
|
||||||
|
if (!resp.ok) return
|
||||||
|
val text = resp.text().await()
|
||||||
|
val arr = js("JSON.parse(text)") as Array<String>
|
||||||
|
val all = arr.toList().sorted()
|
||||||
|
// Filter excluded by reading each markdown and looking for the marker
|
||||||
|
val filtered = mutableListOf<String>()
|
||||||
|
for (path in all) {
|
||||||
|
try {
|
||||||
|
val r = window.fetch(path).await()
|
||||||
|
if (!r.ok) continue
|
||||||
|
val body = r.text().await()
|
||||||
|
if (!body.contains("[//]: # (excludeFromIndex)")) {
|
||||||
|
filtered.add(path)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
// Sort entries by display name (file name) case-insensitively
|
||||||
|
val sortedFiltered = filtered.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.substringAfterLast('/') })
|
||||||
|
// Build items after the static first two existing children (tutorial + divider)
|
||||||
|
sortedFiltered.forEach { path ->
|
||||||
|
val name = path.substringAfterLast('/')
|
||||||
|
val li = document.createElement("li")
|
||||||
|
val a = document.createElement("a") as HTMLAnchorElement
|
||||||
|
a.className = "dropdown-item"
|
||||||
|
a.href = "#/$path"
|
||||||
|
a.setAttribute("data-route", "docs")
|
||||||
|
a.textContent = name
|
||||||
|
// Ensure SPA navigation and close navbar collapse on small screens
|
||||||
|
a.onclick = { ev ->
|
||||||
|
ev.preventDefault()
|
||||||
|
window.location.hash = "#/$path"
|
||||||
|
closeNavbarCollapse()
|
||||||
|
}
|
||||||
|
li.appendChild(a)
|
||||||
|
menu.appendChild(li)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeNavbarCollapse() {
|
||||||
|
val collapse = document.getElementById("topbarNav") as? HTMLElement
|
||||||
|
collapse?.classList?.remove("show")
|
||||||
|
// update toggler aria-expanded if present
|
||||||
|
val togglers = document.getElementsByClassName("navbar-toggler")
|
||||||
|
if (togglers.length > 0) {
|
||||||
|
val t = togglers.item(0) as? HTMLElement
|
||||||
|
t?.setAttribute("aria-expanded", "false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReferencePage() {
|
private fun ReferencePage() {
|
||||||
var docs by remember { mutableStateOf<List<String>?>(null) }
|
var docs by remember { mutableStateOf<List<String>?>(null) }
|
||||||
@ -366,6 +498,82 @@ private fun ReferencePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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.", "bolt"),
|
||||||
|
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 ----
|
||||||
|
|
||||||
private fun applyTheme(isDark: Boolean) {
|
private fun applyTheme(isDark: Boolean) {
|
||||||
@ -696,18 +904,43 @@ private fun rewriteImages(root: HTMLElement, basePath: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rewriteAnchors(root: HTMLElement, basePath: String, navigate: (String) -> Unit) {
|
internal fun rewriteAnchors(
|
||||||
|
root: HTMLElement,
|
||||||
|
basePath: String,
|
||||||
|
currentDocPath: String,
|
||||||
|
navigate: (String) -> Unit
|
||||||
|
) {
|
||||||
val asEl = root.querySelectorAll("a")
|
val asEl = root.querySelectorAll("a")
|
||||||
for (i in 0 until asEl.length) {
|
for (i in 0 until asEl.length) {
|
||||||
val a = asEl.item(i) as? HTMLAnchorElement ?: continue
|
val a = asEl.item(i) as? HTMLAnchorElement ?: continue
|
||||||
val href = a.getAttribute("href") ?: continue
|
val href = a.getAttribute("href") ?: continue
|
||||||
if (href.startsWith("http") || href.startsWith("/")) continue
|
// Skip external and already-SPA hashes
|
||||||
if (href.startsWith("#")) continue // intra-page
|
if (
|
||||||
|
href.startsWith("http:") || href.startsWith("https:") ||
|
||||||
|
href.startsWith("mailto:") || href.startsWith("tel:") ||
|
||||||
|
href.startsWith("javascript:") || href.startsWith("/") ||
|
||||||
|
href.startsWith("#/")
|
||||||
|
) continue
|
||||||
|
if (href.startsWith("#")) {
|
||||||
|
// Intra-page link: convert to SPA hash including current document route
|
||||||
|
val frag = href.removePrefix("#")
|
||||||
|
val route = "$currentDocPath#$frag"
|
||||||
|
a.setAttribute("href", "#/$route")
|
||||||
|
a.onclick = { ev ->
|
||||||
|
ev.preventDefault()
|
||||||
|
navigate(route)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (href.contains(".md")) {
|
if (href.contains(".md")) {
|
||||||
val parts = href.split('#', limit = 2)
|
val parts = href.split('#', limit = 2)
|
||||||
val mdPath = parts[0]
|
val mdPath = parts[0]
|
||||||
val frag = if (parts.size > 1) parts[1] else null
|
val frag = if (parts.size > 1) parts[1] else null
|
||||||
val target = normalizePath("$basePath/$mdPath")
|
val target = if (mdPath.startsWith("docs/")) {
|
||||||
|
normalizePath(mdPath)
|
||||||
|
} else {
|
||||||
|
normalizePath("$basePath/$mdPath")
|
||||||
|
}
|
||||||
val route = if (frag.isNullOrBlank()) {
|
val route = if (frag.isNullOrBlank()) {
|
||||||
target
|
target
|
||||||
} else {
|
} else {
|
||||||
@ -725,7 +958,7 @@ private fun rewriteAnchors(root: HTMLElement, basePath: String, navigate: (Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildToc(root: HTMLElement): List<TocItem> {
|
internal 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")
|
||||||
@ -750,12 +983,12 @@ private fun buildToc(root: HTMLElement): List<TocItem> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun slugify(s: String): String = s.lowercase()
|
internal fun slugify(s: String): String = s.lowercase()
|
||||||
.replace("[^a-z0-9 _-]".toRegex(), "")
|
.replace("[^a-z0-9 _-]".toRegex(), "")
|
||||||
.trim()
|
.trim()
|
||||||
.replace("[\n\r\t ]+".toRegex(), "-")
|
.replace("[\n\r\t ]+".toRegex(), "-")
|
||||||
|
|
||||||
private fun normalizePath(path: String): String {
|
internal fun normalizePath(path: String): String {
|
||||||
val parts = mutableListOf<String>()
|
val parts = mutableListOf<String>()
|
||||||
val raw = path.split('/')
|
val raw = path.split('/')
|
||||||
for (p in raw) {
|
for (p in raw) {
|
||||||
|
|||||||
@ -45,7 +45,43 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||||
/>
|
/>
|
||||||
|
<!-- MathJax v3 configuration and loader (replace KaTeX) -->
|
||||||
|
<script>
|
||||||
|
window.MathJax = {
|
||||||
|
tex: {
|
||||||
|
inlineMath: [['$', '$'], ['\\(', '\\)']],
|
||||||
|
displayMath: [['$$', '$$'], ['\\[', '\\]']],
|
||||||
|
processEscapes: true
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
skipHtmlTags: ['script','noscript','style','textarea','pre','code']
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
// We'll trigger typesetting manually from the app after markdown is mounted
|
||||||
|
typeset: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
|
||||||
<style>
|
<style>
|
||||||
|
/* Reserve space for fixed navbar and ensure in-page anchors don't end up hidden under it */
|
||||||
|
:root { --navbar-offset: 56px; }
|
||||||
|
body {
|
||||||
|
/* Fallback padding; JS will keep this equal to the real navbar height */
|
||||||
|
padding-top: var(--navbar-offset);
|
||||||
|
/* Make native hash jumps account for the fixed header */
|
||||||
|
scroll-padding-top: calc(var(--navbar-offset) + 8px);
|
||||||
|
}
|
||||||
|
/* Also offset scroll for headings and any element targeted by an id */
|
||||||
|
.markdown-body h1,
|
||||||
|
.markdown-body h2,
|
||||||
|
.markdown-body h3,
|
||||||
|
.markdown-body h4,
|
||||||
|
.markdown-body h5,
|
||||||
|
.markdown-body h6,
|
||||||
|
[id] {
|
||||||
|
scroll-margin-top: calc(var(--navbar-offset) + 8px);
|
||||||
|
}
|
||||||
/* Unify markdown and page backgrounds with Bootstrap theme variables */
|
/* Unify markdown and page backgrounds with Bootstrap theme variables */
|
||||||
.markdown-body {
|
.markdown-body {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -102,7 +138,70 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<!-- Fixed top navbar for the whole site -->
|
||||||
|
<a href="#root" class="visually-hidden-focusable position-absolute top-0 start-0 m-2 px-2 py-1 bg-body border rounded">Skip to content</a>
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top border-bottom" role="navigation" aria-label="Primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand d-flex align-items-center gap-2" href="#">
|
||||||
|
<i class="bi bi-braces-asterisk"></i>
|
||||||
|
Lyng
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#topbarNav"
|
||||||
|
aria-controls="topbarNav"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="topbarNav">
|
||||||
|
<!-- Left-aligned main menu -->
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#/docs/Iterator.md" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
<ul id="docsDropdownMenu" class="dropdown-menu">
|
||||||
|
<!-- Will be populated at runtime -->
|
||||||
|
<li><a class="dropdown-item" href="#/docs/tutorial.md" data-route="docs">tutorial</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#/reference" data-route="reference">Reference</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Right utilities: search, GitHub link -->
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2" style="min-width: 240px;">
|
||||||
|
<!-- Search widget with Bootstrap Icons magnifying glass -->
|
||||||
|
<form class="d-flex" role="search" onsubmit="return false;">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-transparent border-end-0">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id="topSearch"
|
||||||
|
class="form-control border-start-0"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="https://github.com/sergeych/lyng" target="_blank" rel="noopener" aria-label="GitHub">
|
||||||
|
<i class="bi bi-github"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- App root; extra top padding so content is not hidden behind fixed navbar -->
|
||||||
|
<div id="root" class="pt-4" tabindex="-1" aria-live="polite" aria-atomic="false"></div>
|
||||||
|
|
||||||
<!-- App bundle (produced by Kotlin/JS). The Gradle config forces this name. -->
|
<!-- App bundle (produced by Kotlin/JS). The Gradle config forces this name. -->
|
||||||
<script src="site.js"></script>
|
<script src="site.js"></script>
|
||||||
@ -113,5 +212,51 @@
|
|||||||
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
></script>
|
></script>
|
||||||
|
<script>
|
||||||
|
// Adjust body padding to account for fixed-top navbar height (handles dynamic font sizes)
|
||||||
|
(function () {
|
||||||
|
function adjustPadding() {
|
||||||
|
var nav = document.querySelector('.navbar.fixed-top');
|
||||||
|
if (!nav) return;
|
||||||
|
var h = nav.getBoundingClientRect().height;
|
||||||
|
var px = Math.ceil(h) + 'px';
|
||||||
|
// Keep legacy inline padding for older browsers
|
||||||
|
document.body.style.paddingTop = px;
|
||||||
|
// And expose as a CSS variable used by scroll-padding/scroll-margin rules
|
||||||
|
document.documentElement.style.setProperty('--navbar-offset', px);
|
||||||
|
}
|
||||||
|
window.addEventListener('load', adjustPadding);
|
||||||
|
window.addEventListener('resize', adjustPadding);
|
||||||
|
// Also listen to Bootstrap collapse events which may change navbar height on small screens
|
||||||
|
document.addEventListener('shown.bs.collapse', adjustPadding);
|
||||||
|
document.addEventListener('hidden.bs.collapse', adjustPadding);
|
||||||
|
|
||||||
|
// Basic active state management for topbar links
|
||||||
|
function updateActive() {
|
||||||
|
var hash = location.hash;
|
||||||
|
// Clear previous actives
|
||||||
|
document.querySelectorAll('#topbarNav .nav-link').forEach(function(a){
|
||||||
|
a.classList.remove('active');
|
||||||
|
a.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
// Mark active for simple routes
|
||||||
|
var activeLink = null;
|
||||||
|
if (!hash || hash === '#' || hash === '#/') {
|
||||||
|
activeLink = document.querySelector('#topbarNav .nav-link[data-route="home"]');
|
||||||
|
} else if (hash.startsWith('#/docs/')) {
|
||||||
|
// Mark Docs menu root as active
|
||||||
|
activeLink = document.querySelector('#topbarNav .nav-link.dropdown-toggle');
|
||||||
|
} else if (hash.startsWith('#/reference')) {
|
||||||
|
activeLink = document.querySelector('#topbarNav .nav-link[data-route="reference"]');
|
||||||
|
}
|
||||||
|
if (activeLink) {
|
||||||
|
activeLink.classList.add('active');
|
||||||
|
activeLink.setAttribute('aria-current', 'page');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', updateActive);
|
||||||
|
window.addEventListener('load', updateActive);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
75
site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt
Normal file
75
site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Tests for routing helpers, anchor rewriting, and TOC building
|
||||||
|
*/
|
||||||
|
|
||||||
|
import kotlinx.browser.document
|
||||||
|
import org.w3c.dom.HTMLElement
|
||||||
|
import kotlin.test.*
|
||||||
|
|
||||||
|
class RouteAndDomRewriteTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAnchorFromHash() {
|
||||||
|
assertNull(anchorFromHash("#"))
|
||||||
|
assertNull(anchorFromHash("") )
|
||||||
|
assertNull(anchorFromHash("#/docs/Iterator.md"))
|
||||||
|
assertEquals("section-1", anchorFromHash("#/docs/Iterator.md#section-1"))
|
||||||
|
assertEquals("a", anchorFromHash("#/docs/x/y.md#a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNormalizePath() {
|
||||||
|
assertEquals("docs/a/b.md", normalizePath("docs/./a/../a/b.md"))
|
||||||
|
assertEquals("docs/a.md", normalizePath("docs/x/../a.md"))
|
||||||
|
assertEquals("a/b", normalizePath("a//b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRewriteAnchors_forIntraPageAndMdLinks() {
|
||||||
|
val root = document.createElement("div") as HTMLElement
|
||||||
|
root.innerHTML = """
|
||||||
|
<p>
|
||||||
|
<a id=one href="#local">Local</a>
|
||||||
|
<a id=two href="Sibling.md#sec">Sibling</a>
|
||||||
|
<a id=three href="image.png">Img</a>
|
||||||
|
</p>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val basePath = "docs" // current doc in docs root
|
||||||
|
val currentDoc = "docs/Iterator.md"
|
||||||
|
rewriteAnchors(root, basePath, currentDoc) { /* noop for test */ }
|
||||||
|
|
||||||
|
val one = root.querySelector("#one") as HTMLElement
|
||||||
|
assertEquals("#/${currentDoc}#local", one.getAttribute("href"))
|
||||||
|
|
||||||
|
val two = root.querySelector("#two") as HTMLElement
|
||||||
|
assertEquals("#/docs/Sibling.md#sec", two.getAttribute("href"))
|
||||||
|
|
||||||
|
val three = root.querySelector("#three") as HTMLElement
|
||||||
|
// non-md stays relative to base path
|
||||||
|
assertEquals("docs/image.png", three.getAttribute("href"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBuildToc_assignsIdsAndLevels() {
|
||||||
|
val root = document.createElement("div") as HTMLElement
|
||||||
|
root.innerHTML = """
|
||||||
|
<h1>Title</h1>
|
||||||
|
<h2>Section</h2>
|
||||||
|
<h2>Section</h2>
|
||||||
|
<h3>Sub</h3>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val toc = buildToc(root)
|
||||||
|
assertTrue(toc.isNotEmpty(), "TOC should not be empty")
|
||||||
|
// Ensure we produced unique IDs for duplicate headings
|
||||||
|
val ids = toc.map { it.id }
|
||||||
|
assertEquals(ids.toSet().size, ids.size)
|
||||||
|
|
||||||
|
// Also verify IDs are actually set on DOM
|
||||||
|
ids.forEach { id ->
|
||||||
|
val el = root.ownerDocument?.getElementById(id) ?: root.querySelector("#${id}")
|
||||||
|
assertNotNull(el, "Heading with id $id should be present in DOM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user