added DocsPage, improved navbar with dynamic height handling, MathJax integration, new TOC features, and extensive markdown processing
This commit is contained in:
parent
918534afb5
commit
1fadc42414
24
.run/lyng_site [jsBrowserDevelopmentRun].run.xml
Normal file
24
.run/lyng_site [jsBrowserDevelopmentRun].run.xml
Normal file
@ -0,0 +1,24 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/site" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="--continuous" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="jsBrowserDevelopmentRun" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@ -1,24 +0,0 @@
|
||||
# Classes
|
||||
|
||||
## Declaring
|
||||
|
||||
class Foo1
|
||||
class Foo2() // same, empty constructor
|
||||
class Foo3() { // full
|
||||
}
|
||||
class Foo4 { // Only body
|
||||
}
|
||||
|
||||
```
|
||||
class_declaration = ["abstract",] "class" [, constructor] [, body]
|
||||
constructor = "(", [field [, field]] ")
|
||||
field = [visibility ,] [access ,] name [, typedecl]
|
||||
body = [visibility] ("var", vardecl) | ("val", vardecl) | ("fun", fundecl)
|
||||
visibility = "private" | "protected" | "internal"
|
||||
```
|
||||
|
||||
### Abstract classes
|
||||
|
||||
Contain one pr more abstract methods which must be implemented; though they
|
||||
can have constructors, the instances of the abstract classes could not be
|
||||
created independently
|
||||
@ -1,3 +1,5 @@
|
||||
[//]: # (excludeFromIndex)
|
||||
|
||||
# String
|
||||
|
||||
# This document is for developer notes only
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
[//]: # (excludeFromIndex)
|
||||
|
||||
|
||||
Provide:
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
|
||||
This document explains how to enable and measure the performance optimizations added to the Lyng interpreter. The focus is JVM‑first with safe, flag‑guarded rollouts and quick A/B testing. Other targets (JS/Wasm/Native) keep conservative defaults until validated.
|
||||
|
||||
[//]: # (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`).
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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).
|
||||
@ -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
|
||||
@ -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"))
|
||||
|
||||
@ -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,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>): 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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
75
site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt
Normal file
75
site/src/jsTest/kotlin/RouteAndDomRewriteTest.kt
Normal file
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Tests for routing helpers, anchor rewriting, and TOC building
|
||||
*/
|
||||
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
import kotlin.test.*
|
||||
|
||||
class RouteAndDomRewriteTest {
|
||||
|
||||
@Test
|
||||
fun testAnchorFromHash() {
|
||||
assertNull(anchorFromHash("#"))
|
||||
assertNull(anchorFromHash("") )
|
||||
assertNull(anchorFromHash("#/docs/Iterator.md"))
|
||||
assertEquals("section-1", anchorFromHash("#/docs/Iterator.md#section-1"))
|
||||
assertEquals("a", anchorFromHash("#/docs/x/y.md#a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNormalizePath() {
|
||||
assertEquals("docs/a/b.md", normalizePath("docs/./a/../a/b.md"))
|
||||
assertEquals("docs/a.md", normalizePath("docs/x/../a.md"))
|
||||
assertEquals("a/b", normalizePath("a//b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRewriteAnchors_forIntraPageAndMdLinks() {
|
||||
val root = document.createElement("div") as HTMLElement
|
||||
root.innerHTML = """
|
||||
<p>
|
||||
<a id=one href="#local">Local</a>
|
||||
<a id=two href="Sibling.md#sec">Sibling</a>
|
||||
<a id=three href="image.png">Img</a>
|
||||
</p>
|
||||
""".trimIndent()
|
||||
|
||||
val basePath = "docs" // current doc in docs root
|
||||
val currentDoc = "docs/Iterator.md"
|
||||
rewriteAnchors(root, basePath, currentDoc) { /* noop for test */ }
|
||||
|
||||
val one = root.querySelector("#one") as HTMLElement
|
||||
assertEquals("#/${currentDoc}#local", one.getAttribute("href"))
|
||||
|
||||
val two = root.querySelector("#two") as HTMLElement
|
||||
assertEquals("#/docs/Sibling.md#sec", two.getAttribute("href"))
|
||||
|
||||
val three = root.querySelector("#three") as HTMLElement
|
||||
// non-md stays relative to base path
|
||||
assertEquals("docs/image.png", three.getAttribute("href"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBuildToc_assignsIdsAndLevels() {
|
||||
val root = document.createElement("div") as HTMLElement
|
||||
root.innerHTML = """
|
||||
<h1>Title</h1>
|
||||
<h2>Section</h2>
|
||||
<h2>Section</h2>
|
||||
<h3>Sub</h3>
|
||||
""".trimIndent()
|
||||
|
||||
val toc = buildToc(root)
|
||||
assertTrue(toc.isNotEmpty(), "TOC should not be empty")
|
||||
// Ensure we produced unique IDs for duplicate headings
|
||||
val ids = toc.map { it.id }
|
||||
assertEquals(ids.toSet().size, ids.size)
|
||||
|
||||
// Also verify IDs are actually set on DOM
|
||||
ids.forEach { id ->
|
||||
val el = root.ownerDocument?.getElementById(id) ?: root.querySelector("#${id}")
|
||||
assertNotNull(el, "Heading with id $id should be present in DOM")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user