added DocsPage, improved navbar with dynamic height handling, MathJax integration, new TOC features, and extensive markdown processing

This commit is contained in:
Sergey Chernov 2025-11-19 21:52:58 +01:00
parent 918534afb5
commit 1fadc42414
13 changed files with 707 additions and 223 deletions

View 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>

View File

@ -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

View File

@ -1,3 +1,5 @@
[//]: # (excludeFromIndex)
# String
# This document is for developer notes only

View File

@ -1,5 +1,7 @@
# Modules inclusion
[//]: # (excludeFromIndex)
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
we don't need for now.

View File

@ -1,3 +1,4 @@
[//]: # (excludeFromIndex)
Provide:

View File

@ -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.
[//]: # (excludeFromIndex)
## 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`).

View File

@ -1,5 +1,7 @@
# JVM-only Performance Optimization Plan (Saved)
[//]: # (excludeFromIndex)
Date: 2025-11-10 22:14 (local)
This document captures the agreed next optimization steps so we can restore the plan later if needed.

View File

@ -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:
@ -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.
- `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.
To get details on OOP in Lyng, see [OOP notes](oop.md).

View File

@ -34,4 +34,4 @@ kotlin.native.cacheKind.linuxX64=none
# On this environment, the system JDK 21 installation lacks `jlink`, causing
# :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.
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
#org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64

View File

@ -53,6 +53,7 @@ kotlin {
implementation(project(":lynglib"))
// Markdown parser (NPM)
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
resources.srcDir(rootProject.projectDir.resolve("docs"))

View File

@ -30,6 +30,12 @@ import org.w3c.dom.HTMLLinkElement
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
fun App() {
var route by remember { mutableStateOf(currentRoute()) }
@ -39,10 +45,14 @@ fun App() {
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 to avoid
// re-fetching when only the in-page anchor changes.
// A stable key for the current document path (without fragment). Used by DocsPage.
val docKey = stripFragment(route)
// Initialize dynamic Documentation dropdown once
LaunchedEffect(Unit) {
initDocsDropdown()
}
// Listen to hash changes (routing)
DisposableEffect(Unit) {
val listener: (org.w3c.dom.events.Event) -> Unit = {
@ -52,110 +62,25 @@ fun App() {
onDispose { window.removeEventListener("hashchange", listener) }
}
// Fetch and render markdown whenever the document path changes (ignore fragment-only changes)
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)
}
}
// Docs-specific fetching and effects are handled inside DocsPage now
// Layout
Div({ classes("container", "py-4") }) {
H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") }
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
// Sidebar TOC: show only on docs pages
if (isDocsRoute) {
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") }
Ul({ classes("list-unstyled") }) {
toc.forEach { item ->
@ -190,66 +115,26 @@ fun App() {
}
}
}
}
// Main content
Div({ classes("col-12", "col-lg-9") }) {
// Top actions
Div({ classes("mb-3", "d-flex", "gap-2", "flex-wrap", "align-items-center") }) {
// Reference page link
A(attrs = {
classes("btn", "btn-sm", "btn-primary")
attr("href", "#/reference")
onClick { it.preventDefault(); window.location.hash = "#/reference" }
}) { Text("Reference") }
// Sample quick links
DocLink("Iterable.md")
DocLink("Iterator.md")
DocLink("perf_guide.md")
}
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!!)
}
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 },
)
}
}
}
@ -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 {
val noFrag = stripFragment(route)
@ -333,6 +218,253 @@ fun renderReferenceListHtml(docs: List<String>): String {
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
private fun ReferencePage() {
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 ----
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")
for (i in 0 until asEl.length) {
val a = asEl.item(i) as? HTMLAnchorElement ?: continue
val href = a.getAttribute("href") ?: continue
if (href.startsWith("http") || href.startsWith("/")) continue
if (href.startsWith("#")) continue // intra-page
// Skip external and already-SPA hashes
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")) {
val parts = href.split('#', limit = 2)
val mdPath = parts[0]
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()) {
target
} 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 used = hashSetOf<String>()
val hs = root.querySelectorAll("h1, h2, h3")
@ -750,12 +983,12 @@ private fun buildToc(root: HTMLElement): List<TocItem> {
return out
}
private fun slugify(s: String): String = s.lowercase()
internal fun slugify(s: String): String = s.lowercase()
.replace("[^a-z0-9 _-]".toRegex(), "")
.trim()
.replace("[\n\r\t ]+".toRegex(), "-")
private fun normalizePath(path: String): String {
internal fun normalizePath(path: String): String {
val parts = mutableListOf<String>()
val raw = path.split('/')
for (p in raw) {

View File

@ -45,7 +45,43 @@
rel="stylesheet"
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>
/* 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 */
.markdown-body {
box-sizing: border-box;
@ -102,7 +138,70 @@
</style>
</head>
<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. -->
<script src="site.js"></script>
@ -113,5 +212,51 @@
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous"
></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>
</html>

View 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")
}
}
}