From f4d1a774962672f99360605f86f190438cc0302e Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 18 Nov 2025 23:10:22 +0100 Subject: [PATCH] added extensive markdown rendering tests and Kotlin/JS externals for `marked` library --- site/build.gradle.kts | 68 +++ site/src/jsMain/kotlin/Main.kt | 509 ++++++++++++++++++- site/src/jsMain/kotlin/externals/Marked.kt | 29 ++ site/src/jsMain/resources/index.html | 5 + site/src/jsTest/kotlin/CodeBlockStyleTest.kt | 40 ++ site/src/jsTest/kotlin/DefinitionListTest.kt | 55 ++ site/src/jsTest/kotlin/MarkdownRenderTest.kt | 31 ++ site/src/jsTest/kotlin/ReferencePageTest.kt | 46 ++ site/src/jsTest/kotlin/RouteParsingTest.kt | 48 ++ site/src/jsTest/kotlin/TableStyleTest.kt | 37 ++ 10 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 site/src/jsMain/kotlin/externals/Marked.kt create mode 100644 site/src/jsTest/kotlin/CodeBlockStyleTest.kt create mode 100644 site/src/jsTest/kotlin/DefinitionListTest.kt create mode 100644 site/src/jsTest/kotlin/MarkdownRenderTest.kt create mode 100644 site/src/jsTest/kotlin/ReferencePageTest.kt create mode 100644 site/src/jsTest/kotlin/RouteParsingTest.kt create mode 100644 site/src/jsTest/kotlin/TableStyleTest.kt diff --git a/site/build.gradle.kts b/site/build.gradle.kts index 82998ed..ce51005 100644 --- a/site/build.gradle.kts +++ b/site/build.gradle.kts @@ -47,7 +47,17 @@ kotlin { dependencies { implementation("org.jetbrains.compose.runtime:runtime:1.9.3") implementation("org.jetbrains.compose.html:html-core:1.9.3") + // Coroutines for JS (used for fetching docs) + implementation(libs.kotlinx.coroutines.core) + // Markdown parser (NPM) + implementation(npm("marked", "12.0.2")) } + // Serve project docs and images as static resources in the site + resources.srcDir(rootProject.projectDir.resolve("docs")) + resources.srcDir(rootProject.projectDir.resolve("images")) + // Also include generated resources (e.g., docs index JSON) + // Use Gradle's layout to properly reference the build directory provider + resources.srcDir(layout.buildDirectory.dir("generated-resources")) } val jsTest by getting { dependencies { @@ -57,4 +67,62 @@ kotlin { } } +// Generate an index of markdown documents under project /docs as a JSON array +val generateDocsIndex by tasks.registering { + group = "documentation" + description = "Generates docs-index.json listing all Markdown files under /docs" + + val docsDir = rootProject.projectDir.resolve("docs") + val outDir = layout.buildDirectory.dir("generated-resources") + + inputs.dir(docsDir) + outputs.dir(outDir) + + doLast { + val docs = mutableListOf() + if (docsDir.exists()) { + docsDir.walkTopDown() + .filter { it.isFile && it.extension.equals("md", ignoreCase = true) } + .forEach { f -> + val rel = docsDir.toPath().relativize(f.toPath()).toString() + .replace('\\', '/') + // store paths relative to site root, e.g. "docs/Iterator.md" + docs += "docs/$rel" + } + } + val out = outDir.get().asFile + out.mkdirs() + val file = out.resolve("docs-index.json") + val json = buildString { + append('[') + docs.forEachIndexed { i, s -> + if (i > 0) append(',') + append('"').append(s.replace("\"", "\\\"")) + .append('"') + } + append(']') + } + file.writeText(json) + println("Generated ${'$'}{file.absolutePath} with ${'$'}{docs.size} entries") + } +} + +// Ensure any ProcessResources task depends on docs index generation so the JSON is packaged +tasks.configureEach { + if (name.endsWith("ProcessResources")) { + dependsOn(generateDocsIndex) + } +} + +// Also make common dev/prod tasks depend on docs index generation to avoid 404 during dev server +listOf( + "browserDevelopmentRun", + "browserProductionWebpack", + "jsProcessResources" +).forEach { taskName -> + tasks.matching { it.name == taskName }.configureEach { + dependsOn(generateDocsIndex) + } +} + // Optional: configure toolchain if needed by the project; uses root Kotlin version from version catalog \ No newline at end of file diff --git a/site/src/jsMain/kotlin/Main.kt b/site/src/jsMain/kotlin/Main.kt index ebeb94d..cbfb8a8 100644 --- a/site/src/jsMain/kotlin/Main.kt +++ b/site/src/jsMain/kotlin/Main.kt @@ -15,33 +15,502 @@ * */ -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import org.jetbrains.compose.web.dom.* import org.jetbrains.compose.web.renderComposable +import kotlinx.browser.window +import kotlinx.coroutines.await +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLHeadingElement +import org.w3c.dom.HTMLAnchorElement +import org.w3c.dom.HTMLImageElement +import externals.marked -fun main() { - renderComposable(rootElementId = "root") { - // Minimal SPA shell - Div({ classes("container", "py-4") }) { - H1({ classes("display-6", "mb-3") }) { Text("Compose HTML SPA") } - P({ classes("lead") }) { - Text("This static site is powered by Compose for Web (JS-only) and Bootstrap 5.3.") +data class TocItem(val level: Int, val id: String, val title: String) + +@Composable +fun App() { + var route by remember { mutableStateOf(currentRoute()) } + var html by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var toc by remember { mutableStateOf>(emptyList()) } + 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. + val docKey = stripFragment(route) + + // Listen to hash changes (routing) + DisposableEffect(Unit) { + val listener: (org.w3c.dom.events.Event) -> Unit = { + route = currentRoute() + } + window.addEventListener("hashchange", listener) + 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) + + // 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) + } + + // Layout + Div({ classes("container", "py-4") }) { + H1({ classes("display-6", "mb-3") }) { Text("Ling Lib Docs") } + + 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" + } + 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") + 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) } + } + } + } + } } - Hr() + // 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") } - // Example of interactive state to show SPA behavior - var count by remember { mutableStateOf(0) } - Div({ classes("d-flex", "gap-2", "align-items-center") }) { - Button(attrs = { - classes("btn", "btn-primary") - onClick { count++ } - }) { Text("Increment") } - Span({ classes("fw-bold") }) { Text("Count: $count") } + // 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!!) + } + } } } } } + +@Composable +private fun DocLink(name: String) { + A(attrs = { + classes("btn", "btn-sm", "btn-outline-secondary") + onClick { + window.location.hash = "#/docs/$name" + it.preventDefault() + } + attr("href", "#/docs/$name") + }) { Text(name) } +} + +@Composable +private fun UnsafeRawHtml(html: String) { + // Compose HTML lacks a direct element for raw insertion; set innerHTML via ref + // Use a
and set its innerHTML via side effect + val holder = remember { mutableStateOf(null) } + LaunchedEffect(html) { + holder.value?.innerHTML = html + } + Div({ + ref { + holder.value = it + onDispose { + if (holder.value === it) holder.value = null + } + } + }) {} +} + +fun currentRoute(): String = window.location.hash.removePrefix("#/").ifBlank { "docs/Iterator.md" } + +fun routeToPath(route: String): String { + val noFrag = stripFragment(route) + return if (noFrag.startsWith("docs/")) noFrag else "docs/$noFrag" +} + +// Strip trailing fragment from a route like "docs/file.md#anchor" -> "docs/file.md" +fun stripFragment(route: String): String = route.substringBefore('#') + +fun renderMarkdown(src: String): String = + ensureBootstrapCodeBlocks( + ensureBootstrapTables( + ensureDefinitionLists( + marked.parse(src) + ) + ) + ) + +// Pure function to render the Reference list HTML from a list of doc paths. +// Returns a Bootstrap-styled
    list with links to the docs routes. +fun renderReferenceListHtml(docs: List): String { + if (docs.isEmpty()) return "

    No documents found.

    " + val items = docs.sorted().joinToString(separator = "") { path -> + val name = path.substringAfterLast('/') + val dir = path.substringBeforeLast('/', "") + buildString { + append("
  • ") + append("
    ") + append("") + append(name) + append("") + if (dir.isNotEmpty()) { + append("
    ") + append(dir) + append("") + } + append("
    ") + append("") + append("
  • ") + } + } + return "
      $items
    " +} + +@Composable +private fun ReferencePage() { + var docs by remember { mutableStateOf?>(null) } + var error by remember { mutableStateOf(null) } + + // Load docs index once + LaunchedEffect(Unit) { + try { + val resp = window.fetch("docs-index.json").await() + if (!resp.ok) { + error = "Failed to load docs index (${resp.status})" + } else { + val text = resp.text().await() + // Simple JSON parse into dynamic array of strings + val arr = js("JSON.parse(text)") as Array + docs = arr.toList() + } + } catch (t: Throwable) { + error = t.message + } + } + + H2({ classes("h5", "mb-3") }) { Text("Reference") } + P({ classes("text-muted") }) { Text("Browse all documentation pages included in this build.") } + + when { + error != null -> Div({ classes("alert", "alert-danger") }) { Text(error!!) } + docs == null -> P { Text("Loading index…") } + docs!!.isEmpty() -> P { Text("No documents found.") } + else -> UnsafeRawHtml(renderReferenceListHtml(docs!!)) + } +} + +// Convert pseudo Markdown definition lists rendered by marked as paragraphs into proper
    structures. +// Pattern supported (common in many MD flavors): +// Term\n +// : Definition paragraph 1\n +// : Definition paragraph 2 ... +// After marked parses it, it becomes: +//

    Term

    \n

    : Definition paragraph 1

    \n

    : Definition paragraph 2

    +// We transform such consecutive blocks into: +//
    Term
    Definition paragraph 1
    Definition paragraph 2
    +private fun ensureDefinitionLists(html: String): String { + // We operate per

    block, and if its inner HTML contains newline-separated lines + // in the form: + // Term\n: Def1\n: Def2 + // we convert this

    into a

    ...
    + val pBlock = Regex("""

    ([\s\S]*?)

    """, setOf(RegexOption.IGNORE_CASE)) + + return pBlock.replace(html) { match -> + val inner = match.groupValues[1] + val lines = inner.split(Regex("\r?\n")) + if (lines.isEmpty()) return@replace match.value + + val term = lines.first().trim() + if (term.startsWith(":")) return@replace match.value // cannot start with ':' + + val defs = lines.drop(1) + .map { it.trim() } + .filter { it.startsWith(":") } + .map { s -> + // remove leading ':' and optional single leading space + val t = s.removePrefix(":") + if (t.startsWith(' ')) t.substring(1) else t + } + + if (defs.isEmpty()) return@replace match.value + + buildString { + append("
    ") + append(term) + append("
    ") + defs.forEach { d -> + append("
    ") + append(d) + append("
    ") + } + append("
    ") + } + } +} + +// Ensure all markdown-rendered tables use Bootstrap styling +private fun ensureBootstrapTables(html: String): String { + // Match opening tags (case-insensitive) + val tableTagRegex = Regex("]*)?>", RegexOption.IGNORE_CASE) + val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE) + + return tableTagRegex.replace(html) { match -> + val attrs = match.groups[1]?.value ?: "" + if (attrs.isBlank()) return@replace "
    " + + // If class attribute exists, append 'table' if not already present + var newAttrs = attrs + val m = classAttrRegex.find(attrs) + if (m != null) { + val quote = m.groupValues[1] + val classes = m.groupValues[2] + val hasTable = classes.split("\\s+".toRegex()).any { it.equals("table", ignoreCase = false) } + if (!hasTable) { + val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "table" else "$it table" }) + quote + newAttrs = attrs.replaceRange(m.range, updated) + } + } else { + // No class attribute, insert one at the beginning + newAttrs = " class=\"table\"" + attrs + } + "" + } +} + +// Ensure all markdown-rendered code blocks (
    ...
    ) have Bootstrap-like `.code` class +private fun ensureBootstrapCodeBlocks(html: String): String { + // Target opening
     tags (case-insensitive)
    +    val preTagRegex = Regex("]*)?>", RegexOption.IGNORE_CASE)
    +    val classAttrRegex = Regex("\\bclass\\s*=\\s*([\"'])(.*?)\\1", RegexOption.IGNORE_CASE)
    +
    +    return preTagRegex.replace(html) { match ->
    +        val attrs = match.groups[1]?.value ?: ""
    +        if (attrs.isBlank()) return@replace "
    "
    +
    +        var newAttrs = attrs
    +        val m = classAttrRegex.find(attrs)
    +        if (m != null) {
    +            val quote = m.groupValues[1]
    +            val classes = m.groupValues[2]
    +            val hasCode = classes.split("\\s+".toRegex()).any { it.equals("code", ignoreCase = false) }
    +            if (!hasCode) {
    +                val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
    +                newAttrs = attrs.replaceRange(m.range, updated)
    +            }
    +        } else {
    +            // No class attribute, insert one at the beginning
    +            newAttrs = " class=\"code\"" + attrs
    +        }
    +        ""
    +    }
    +}
    +
    +private fun rewriteImages(root: HTMLElement, basePath: String) {
    +    val imgs = root.querySelectorAll("img")
    +    for (i in 0 until imgs.length) {
    +        val el = imgs.item(i) as? HTMLImageElement ?: continue
    +        val src = el.getAttribute("src") ?: continue
    +        if (src.startsWith("http") || src.startsWith("/") || src.startsWith("#")) continue
    +        el.setAttribute("src", normalizePath("$basePath/$src"))
    +    }
    +}
    +
    +private fun rewriteAnchors(root: HTMLElement, basePath: 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
    +        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 route = if (frag.isNullOrBlank()) {
    +                target
    +            } else {
    +                "$target#$frag"
    +            }
    +            a.setAttribute("href", "#/$route")
    +            a.onclick = { ev ->
    +                ev.preventDefault()
    +                navigate(route)
    +            }
    +        } else {
    +            // Non-md relative link: make it relative to the md file location
    +            a.setAttribute("href", normalizePath("$basePath/$href"))
    +        }
    +    }
    +}
    +
    +private fun buildToc(root: HTMLElement): List {
    +    val out = mutableListOf()
    +    val used = hashSetOf()
    +    val hs = root.querySelectorAll("h1, h2, h3")
    +    for (i in 0 until hs.length) {
    +        val h = hs.item(i) as? HTMLHeadingElement ?: continue
    +        val level = when (h.tagName.uppercase()) {
    +            "H1" -> 1
    +            "H2" -> 2
    +            else -> 3
    +        }
    +        var id = h.id.ifBlank { slugify(h.textContent ?: "") }
    +        if (id.isBlank()) id = "section-${i + 1}"
    +        var unique = id
    +        var n = 2
    +        while (!used.add(unique)) {
    +            unique = "$id-$n"
    +            n++
    +        }
    +        h.id = unique
    +        out += TocItem(level, unique, h.textContent ?: "")
    +    }
    +    return out
    +}
    +
    +private fun slugify(s: String): String = s.lowercase()
    +    .replace("[^a-z0-9 _-]".toRegex(), "")
    +    .trim()
    +    .replace("[\n\r\t ]+".toRegex(), "-")
    +
    +private fun normalizePath(path: String): String {
    +    val parts = mutableListOf()
    +    val raw = path.split('/')
    +    for (p in raw) {
    +        when (p) {
    +            "", "." -> {}
    +            ".." -> if (parts.isNotEmpty()) parts.removeAt(parts.size - 1)
    +            else -> parts += p
    +        }
    +    }
    +    return parts.joinToString("/")
    +}
    +
    +fun main() {
    +    renderComposable(rootElementId = "root") { App() }
    +}
    +
    +// Extract anchor fragment from a window location hash of the form
    +// "#/docs/path.md#anchor" -> "anchor"; returns null if none
    +fun anchorFromHash(hash: String): String? {
    +    if (!hash.startsWith("#/")) return null
    +    val idx = hash.indexOf('#', startIndex = 2) // look for second '#'
    +    return if (idx >= 0 && idx + 1 < hash.length) hash.substring(idx + 1) else null
    +}
    diff --git a/site/src/jsMain/kotlin/externals/Marked.kt b/site/src/jsMain/kotlin/externals/Marked.kt
    new file mode 100644
    index 0000000..1806559
    --- /dev/null
    +++ b/site/src/jsMain/kotlin/externals/Marked.kt
    @@ -0,0 +1,29 @@
    +/*
    + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + *
    + */
    +
    +@file:JsModule("marked")
    +@file:JsNonModule
    +@file:Suppress("UnsafeCastFromDynamic")
    +
    +package externals
    +
    +// Kotlin/JS externals for the ESM `marked` package (v12+)
    +// Usage in Kotlin: `marked.parse(src)`
    +// JS equivalent: `import { marked } from 'marked'; marked.parse(src)`
    +external object marked {
    +    fun parse(src: String): String
    +}
    diff --git a/site/src/jsMain/resources/index.html b/site/src/jsMain/resources/index.html
    index 723081f..540a714 100644
    --- a/site/src/jsMain/resources/index.html
    +++ b/site/src/jsMain/resources/index.html
    @@ -28,6 +28,11 @@
           integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
           crossorigin="anonymous"
         />
    +    
    +    
       
       
         
    diff --git a/site/src/jsTest/kotlin/CodeBlockStyleTest.kt b/site/src/jsTest/kotlin/CodeBlockStyleTest.kt new file mode 100644 index 0000000..71bdfa1 --- /dev/null +++ b/site/src/jsTest/kotlin/CodeBlockStyleTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Test that markdown fenced code blocks render with `.code` class on
    + */
    +import kotlin.test.Test
    +import kotlin.test.assertTrue
    +
    +class CodeBlockStyleTest {
    +    @Test
    +    fun codeBlocksGetBootstrapClass() {
    +        val md = """
    +            ```kotlin
    +            println("Hi")
    +            ```
    +        """.trimIndent()
    +        val html = renderMarkdown(md)
    +        assertTrue(html.contains(" tag. Got: $html")
    +        val hasClass = html.contains("
    ]*class=['"][^'"]*\bcode\b[^'"]*['"]""", RegexOption.IGNORE_CASE).containsMatchIn(html)
    +        assertTrue(hasClass, "
     should have 'code' class. Got: $html")
    +    }
    +}
    diff --git a/site/src/jsTest/kotlin/DefinitionListTest.kt b/site/src/jsTest/kotlin/DefinitionListTest.kt
    new file mode 100644
    index 0000000..51e085f
    --- /dev/null
    +++ b/site/src/jsTest/kotlin/DefinitionListTest.kt
    @@ -0,0 +1,55 @@
    +/*
    + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
    + *
    + * Licensed under the Apache License, Version 2.0 (the "License");
    + * you may not use this file except in compliance with the License.
    + * You may obtain a copy of the License at
    + *
    + *     http://www.apache.org/licenses/LICENSE-2.0
    + *
    + * Unless required by applicable law or agreed to in writing, software
    + * distributed under the License is distributed on an "AS IS" BASIS,
    + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    + * See the License for the specific language governing permissions and
    + * limitations under the License.
    + *
    + */
    +
    +/*
    + * Tests for definition list post-processing
    + */
    +import kotlin.test.Test
    +import kotlin.test.assertFalse
    +import kotlin.test.assertTrue
    +
    +class DefinitionListTest {
    +    @Test
    +    fun singleTermMultipleDefinitions() {
    +        val md = """
    +            Term
    +            : First definition
    +            : Second definition with an [inline link](#here)
    +        """.trimIndent()
    +
    +        val html = renderMarkdown(md)
    +        assertTrue(html.contains("
    ", ignoreCase = true), "Expected
    in rendered HTML. Got: $html") + assertTrue(html.contains("
    Term
    ", ignoreCase = true), "Term should be inside
    . Got: $html") + // There should be two
    entries + val ddCount = Regex("
    ", RegexOption.IGNORE_CASE).findAll(html).count() + assertTrue(ddCount == 2, "Expected two
    elements, got $ddCount. HTML: $html") + // No leading ':' should remain inside definitions + assertFalse(Regex("
    \\s*:", RegexOption.IGNORE_CASE).containsMatchIn(html), "Definition should not start with ':'. HTML: $html") + } + + @Test + fun notADefListWhenStartsWithColon() { + val md = """ + : Not a term paragraph + Next paragraph + """.trimIndent() + + val html = renderMarkdown(md) + // Should not produce a
    + assertFalse(html.contains("
    ", ignoreCase = true), "Should not create
    when first paragraph starts with ':'. HTML: $html") + } +} diff --git a/site/src/jsTest/kotlin/MarkdownRenderTest.kt b/site/src/jsTest/kotlin/MarkdownRenderTest.kt new file mode 100644 index 0000000..b7654c2 --- /dev/null +++ b/site/src/jsTest/kotlin/MarkdownRenderTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Basic test to ensure markdown is actually rendered by the ESM `marked` import + */ +import kotlin.test.Test +import kotlin.test.assertTrue + +class MarkdownRenderTest { + @Test + fun rendersHeading() { + val html = renderMarkdown("# Hello") + assertTrue(html.contains(" in rendered HTML, got: $html") + assertTrue(html.contains("Hello"), "Rendered HTML should contain the heading text") + } +} diff --git a/site/src/jsTest/kotlin/ReferencePageTest.kt b/site/src/jsTest/kotlin/ReferencePageTest.kt new file mode 100644 index 0000000..c45e006 --- /dev/null +++ b/site/src/jsTest/kotlin/ReferencePageTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Tests for Reference page rendering helpers + */ +import kotlin.test.Test +import kotlin.test.assertTrue + +class ReferencePageTest { + @Test + fun rendersReferenceListWithLinks() { + val docs = listOf( + "docs/Iterator.md", + "docs/guides/perf_guide.md" + ) + val html = renderReferenceListHtml(docs) + + // Basic structure + assertTrue(html.contains(" in reference list HTML: $html") + + // Contains links to the docs routes + assertTrue(html.contains("href=\"#/docs/Iterator.md\""), "Should link to #/docs/Iterator.md: $html") + assertTrue(html.contains("Iterator.md"), "Should display file name Iterator.md: $html") + + // Nested path should display directory info + assertTrue(html.contains("guides"), "Should include directory name for nested docs: $html") + + // Chevron icon present + assertTrue(html.contains("bi-chevron-right"), "Should include chevron icon class: $html") + } +} diff --git a/site/src/jsTest/kotlin/RouteParsingTest.kt b/site/src/jsTest/kotlin/RouteParsingTest.kt new file mode 100644 index 0000000..0cab5a2 --- /dev/null +++ b/site/src/jsTest/kotlin/RouteParsingTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Tests for route/anchor parsing utilities to support TOC navigation. + */ +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RouteParsingTest { + @Test + fun stripFragmentRemovesAnchor() { + assertEquals("docs/Iterator.md", stripFragment("docs/Iterator.md#sec")) + assertEquals("docs/Iterator.md", stripFragment("docs/Iterator.md")) + assertEquals("docs/dir/file.md", stripFragment("docs/dir/file.md#x-y")) + } + + @Test + fun routeToPathDropsFragmentAndNormalizes() { + assertEquals("docs/Iterator.md", routeToPath("docs/Iterator.md#part")) + assertEquals("docs/Iterator.md", routeToPath("Iterator.md#part")) + assertEquals("docs/guides/perf_guide.md", routeToPath("guides/perf_guide.md#toc")) + } + + @Test + fun anchorFromHashParsesSecondHash() { + assertEquals("part", anchorFromHash("#/docs/Iterator.md#part")) + assertEquals("sec-2", anchorFromHash("#/docs/g/it.md#sec-2")) + assertNull(anchorFromHash("#/docs/Iterator.md")) + assertNull(anchorFromHash("#just-a-frag")) + assertNull(anchorFromHash("/not-a-hash")) + } +} diff --git a/site/src/jsTest/kotlin/TableStyleTest.kt b/site/src/jsTest/kotlin/TableStyleTest.kt new file mode 100644 index 0000000..2f72143 --- /dev/null +++ b/site/src/jsTest/kotlin/TableStyleTest.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +/* + * Test that markdown tables are rendered with Bootstrap `.table` class + */ +import kotlin.test.Test +import kotlin.test.assertTrue + +class TableStyleTest { + @Test + fun tablesGetBootstrapClass() { + val md = """ + |Col A|Col B| + |-----|-----| + | 1 | 2 | + """.trimMargin() + val html = renderMarkdown(md) + assertTrue(html.contains(" tag. Got: $html") + assertTrue(html.contains("class=\"table\"") || html.contains("class=\"table ") || html.contains(" class=\"table\""), + "Rendered
    should have Bootstrap 'table' class. Got: $html") + } +}