From 1fadc424146496dbf2b45eb0581aec993d998494 Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 19 Nov 2025 21:52:58 +0100 Subject: [PATCH] added DocsPage, improved navbar with dynamic height handling, MathJax integration, new TOC features, and extensive markdown processing --- ...yng_site [jsBrowserDevelopmentRun].run.xml | 24 + docs/class_reference.md | 24 - docs/development/String.md | 2 + docs/development/including_modules.md | 2 + docs/development/scope_resolution.md | 1 + docs/perf_guide.md | 2 + docs/perf_plan_jvm.md | 2 + docs/tutorial.md | 21 + gradle.properties | 2 +- site/build.gradle.kts | 1 + site/src/jsMain/kotlin/Main.kt | 627 ++++++++++++------ site/src/jsMain/resources/index.html | 147 +++- .../jsTest/kotlin/RouteAndDomRewriteTest.kt | 75 +++ 13 files changed, 707 insertions(+), 223 deletions(-) create mode 100644 .run/lyng_site [jsBrowserDevelopmentRun].run.xml delete mode 100644 docs/class_reference.md create mode 100644 site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt diff --git a/.run/lyng_site [jsBrowserDevelopmentRun].run.xml b/.run/lyng_site [jsBrowserDevelopmentRun].run.xml new file mode 100644 index 0000000..548e319 --- /dev/null +++ b/.run/lyng_site [jsBrowserDevelopmentRun].run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/docs/class_reference.md b/docs/class_reference.md deleted file mode 100644 index e1825fb..0000000 --- a/docs/class_reference.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/development/String.md b/docs/development/String.md index d5d9f16..98e87a5 100644 --- a/docs/development/String.md +++ b/docs/development/String.md @@ -1,3 +1,5 @@ +[//]: # (excludeFromIndex) + # String # This document is for developer notes only diff --git a/docs/development/including_modules.md b/docs/development/including_modules.md index f00e16c..8d5b9ed 100644 --- a/docs/development/including_modules.md +++ b/docs/development/including_modules.md @@ -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. diff --git a/docs/development/scope_resolution.md b/docs/development/scope_resolution.md index a5a7a17..0410d3a 100644 --- a/docs/development/scope_resolution.md +++ b/docs/development/scope_resolution.md @@ -1,3 +1,4 @@ +[//]: # (excludeFromIndex) Provide: diff --git a/docs/perf_guide.md b/docs/perf_guide.md index 488be66..fa58286 100644 --- a/docs/perf_guide.md +++ b/docs/perf_guide.md @@ -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`). diff --git a/docs/perf_plan_jvm.md b/docs/perf_plan_jvm.md index 87af6b4..201e801 100644 --- a/docs/perf_plan_jvm.md +++ b/docs/perf_plan_jvm.md @@ -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. diff --git a/docs/tutorial.md b/docs/tutorial.md index 2fb9cf5..d65560b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -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). \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 18434fa..8f3388c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 \ No newline at end of file +#org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64 \ No newline at end of file diff --git a/site/build.gradle.kts b/site/build.gradle.kts index 941e23a..254036e 100644 --- a/site/build.gradle.kts +++ b/site/build.gradle.kts @@ -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")) diff --git a/site/src/jsMain/kotlin/Main.kt b/site/src/jsMain/kotlin/Main.kt index 8e28860..964ad5e 100644 --- a/site/src/jsMain/kotlin/Main.kt +++ b/site/src/jsMain/kotlin/Main.kt @@ -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 = definedExternally): dynamic + fun typeset(elements: Array = definedExternally) +} + @Composable fun App() { var route by remember { mutableStateOf(currentRoute()) } @@ -39,10 +45,14 @@ fun App() { var activeTocId by remember { mutableStateOf(null) } var contentEl by remember { mutableStateOf(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,139 +62,55 @@ 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 - Div({ classes("col-12", "col-lg-3") }) { - Nav({ classes("position-sticky"); attr("style", "top: 1rem") }) { - H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") } - Ul({ classes("list-unstyled") }) { - toc.forEach { item -> - Li({ classes("mb-1") }) { - val pad = when (item.level) { - 1 -> "0" - 2 -> "0.75rem" - else -> "1.5rem" + // Sidebar TOC: show only on docs pages + if (isDocsRoute) { + Div({ classes("col-12", "col-lg-3") }) { + // Keep the TOC nav sticky below the fixed navbar. Use the same CSS var that + // drives body padding so the offsets always match the real navbar height. + Nav({ + classes("position-sticky") + attr("style", "top: calc(var(--navbar-offset) + 1rem)") + }) { + H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") } + Ul({ classes("list-unstyled") }) { + toc.forEach { item -> + Li({ classes("mb-1") }) { + val pad = when (item.level) { + 1 -> "0" + 2 -> "0.75rem" + else -> "1.5rem" + } + val routeNoFrag = route.substringBefore('#') + val tocHref = "#/$routeNoFrag#${item.id}" + A(attrs = { + attr("href", tocHref) + attr("style", "padding-left: $pad") + classes("link-body-emphasis", "text-decoration-none") + // Highlight active item + if (activeTocId == item.id) { + classes("fw-semibold", "text-primary") + attr("aria-current", "true") + } + onClick { + it.preventDefault() + // Update location hash to include the document route and section id + window.location.hash = tocHref + // Perform immediate scroll for snappier UX (effects will also handle it) + contentEl?.ownerDocument?.getElementById(item.id) + ?.let { (it as? HTMLElement)?.scrollIntoView() } + } + }) { Text(item.title) } } - val routeNoFrag = route.substringBefore('#') - val tocHref = "#/$routeNoFrag#${item.id}" - A(attrs = { - attr("href", tocHref) - attr("style", "padding-left: $pad") - classes("link-body-emphasis", "text-decoration-none") - // Highlight active item - if (activeTocId == item.id) { - classes("fw-semibold", "text-primary") - attr("aria-current", "true") - } - onClick { - it.preventDefault() - // Update location hash to include the document route and section id - window.location.hash = tocHref - // Perform immediate scroll for snappier UX (effects will also handle it) - contentEl?.ownerDocument?.getElementById(item.id) - ?.let { (it as? HTMLElement)?.scrollIntoView() } - } - }) { Text(item.title) } } } } @@ -192,64 +118,23 @@ 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 { return "
    $items
" } +// --------------- 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, + setToc: (List) -> Unit, + activeTocId: String?, + setActiveTocId: (String?) -> Unit, +) { + // Title is extracted from the first H1 in markdown + var title by remember { mutableStateOf(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 + val all = arr.toList().sorted() + // Filter excluded by reading each markdown and looking for the marker + val filtered = mutableListOf() + 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?>(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 = "
" + htmlEscape(code) + "
" + 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 { +internal fun buildToc(root: HTMLElement): List { val out = mutableListOf() val used = hashSetOf() val hs = root.querySelectorAll("h1, h2, h3") @@ -750,12 +983,12 @@ private fun buildToc(root: HTMLElement): List { 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() val raw = path.split('/') for (p in raw) { diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html index d76b73a..8178a47 100644 --- a/site/src/jsMain/resources/index.html +++ b/site/src/jsMain/resources/index.html @@ -45,7 +45,43 @@ rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> + + + -
+ + Skip to content + + + +
@@ -113,5 +212,51 @@ integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" > + diff --git a/site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt b/site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt new file mode 100644 index 0000000..4177c94 --- /dev/null +++ b/site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt @@ -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 = """ +

+ Local + Sibling + Img +

+ """.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 = """ +

Title

+

Section

+

Section

+

Sub

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