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 # String
# This document is for developer notes only # This document is for developer notes only

View File

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

View File

@ -1,3 +1,4 @@
[//]: # (excludeFromIndex)
Provide: 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. 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`).

View File

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

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: 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).

View File

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

View File

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

View File

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

View File

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

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