added extensive markdown rendering tests and Kotlin/JS externals for marked library
This commit is contained in:
parent
67e4d76f59
commit
f4d1a77496
@ -47,7 +47,17 @@ kotlin {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
|
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
|
||||||
implementation("org.jetbrains.compose.html:html-core: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 {
|
val jsTest by getting {
|
||||||
dependencies {
|
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<String>()
|
||||||
|
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
|
// Optional: configure toolchain if needed by the project; uses root Kotlin version from version catalog
|
||||||
@ -15,33 +15,502 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import org.jetbrains.compose.web.dom.*
|
import org.jetbrains.compose.web.dom.*
|
||||||
import org.jetbrains.compose.web.renderComposable
|
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() {
|
data class TocItem(val level: Int, val id: String, val title: String)
|
||||||
renderComposable(rootElementId = "root") {
|
|
||||||
// Minimal SPA shell
|
@Composable
|
||||||
Div({ classes("container", "py-4") }) {
|
fun App() {
|
||||||
H1({ classes("display-6", "mb-3") }) { Text("Compose HTML SPA") }
|
var route by remember { mutableStateOf(currentRoute()) }
|
||||||
P({ classes("lead") }) {
|
var html by remember { mutableStateOf<String?>(null) }
|
||||||
Text("This static site is powered by Compose for Web (JS-only) and Bootstrap 5.3.")
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
var toc by remember { mutableStateOf<List<TocItem>>(emptyList()) }
|
||||||
|
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.
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
Hr()
|
// 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Example of interactive state to show SPA behavior
|
// Post-process links, images and build TOC after html injection
|
||||||
var count by remember { mutableStateOf(0) }
|
LaunchedEffect(html) {
|
||||||
Div({ classes("d-flex", "gap-2", "align-items-center") }) {
|
if (!isDocsRoute) return@LaunchedEffect
|
||||||
Button(attrs = {
|
val el = contentEl ?: return@LaunchedEffect
|
||||||
classes("btn", "btn-primary")
|
// Wait next tick so DOM has the HTML
|
||||||
onClick { count++ }
|
window.setTimeout({
|
||||||
}) { Text("Increment") }
|
val basePath = routeToPath(route).substringBeforeLast('/', "docs")
|
||||||
Span({ classes("fw-bold") }) { Text("Count: $count") }
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 <div> and set its innerHTML via side effect
|
||||||
|
val holder = remember { mutableStateOf<HTMLElement?>(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 <ul> list with links to the docs routes.
|
||||||
|
fun renderReferenceListHtml(docs: List<String>): String {
|
||||||
|
if (docs.isEmpty()) return "<p>No documents found.</p>"
|
||||||
|
val items = docs.sorted().joinToString(separator = "") { path ->
|
||||||
|
val name = path.substringAfterLast('/')
|
||||||
|
val dir = path.substringBeforeLast('/', "")
|
||||||
|
buildString {
|
||||||
|
append("<li class=\"list-group-item d-flex justify-content-between align-items-center\">")
|
||||||
|
append("<div>")
|
||||||
|
append("<a href=\"#/$path\" class=\"link-body-emphasis text-decoration-none\">")
|
||||||
|
append(name)
|
||||||
|
append("</a>")
|
||||||
|
if (dir.isNotEmpty()) {
|
||||||
|
append("<br><small class=\"text-muted\">")
|
||||||
|
append(dir)
|
||||||
|
append("</small>")
|
||||||
|
}
|
||||||
|
append("</div>")
|
||||||
|
append("<i class=\"bi bi-chevron-right\"></i>")
|
||||||
|
append("</li>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "<ul class=\"list-group\">$items</ul>"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReferencePage() {
|
||||||
|
var docs by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
var error by remember { mutableStateOf<String?>(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<String>
|
||||||
|
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 <dl><dt><dd> structures.
|
||||||
|
// Pattern supported (common in many MD flavors):
|
||||||
|
// Term\n
|
||||||
|
// : Definition paragraph 1\n
|
||||||
|
// : Definition paragraph 2 ...
|
||||||
|
// After marked parses it, it becomes:
|
||||||
|
// <p>Term</p>\n<p>: Definition paragraph 1</p>\n<p>: Definition paragraph 2</p>
|
||||||
|
// We transform such consecutive blocks into:
|
||||||
|
// <dl><dt>Term</dt><dd>Definition paragraph 1</dd><dd>Definition paragraph 2</dd></dl>
|
||||||
|
private fun ensureDefinitionLists(html: String): String {
|
||||||
|
// We operate per <p> block, and if its inner HTML contains newline-separated lines
|
||||||
|
// in the form:
|
||||||
|
// Term\n: Def1\n: Def2
|
||||||
|
// we convert this <p> into a <dl>...</dl>
|
||||||
|
val pBlock = Regex("""<p>([\s\S]*?)</p>""", 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("<dl><dt>")
|
||||||
|
append(term)
|
||||||
|
append("</dt>")
|
||||||
|
defs.forEach { d ->
|
||||||
|
append("<dd>")
|
||||||
|
append(d)
|
||||||
|
append("</dd>")
|
||||||
|
}
|
||||||
|
append("</dl>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all markdown-rendered tables use Bootstrap styling
|
||||||
|
private fun ensureBootstrapTables(html: String): String {
|
||||||
|
// Match <table ...> opening tags (case-insensitive)
|
||||||
|
val tableTagRegex = Regex("<table(\\s+[^>]*)?>", 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 "<table class=\"table\">"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
"<table$newAttrs>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all markdown-rendered code blocks (<pre>...</pre>) have Bootstrap-like `.code` class
|
||||||
|
private fun ensureBootstrapCodeBlocks(html: String): String {
|
||||||
|
// Target opening <pre ...> tags (case-insensitive)
|
||||||
|
val preTagRegex = Regex("<pre(\\s+[^>]*)?>", 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 "<pre class=\"code\">"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
"<pre$newAttrs>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TocItem> {
|
||||||
|
val out = mutableListOf<TocItem>()
|
||||||
|
val used = hashSetOf<String>()
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
29
site/src/jsMain/kotlin/externals/Marked.kt
vendored
Normal file
29
site/src/jsMain/kotlin/externals/Marked.kt
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -28,6 +28,11 @@
|
|||||||
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
/>
|
/>
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
40
site/src/jsTest/kotlin/CodeBlockStyleTest.kt
Normal file
40
site/src/jsTest/kotlin/CodeBlockStyleTest.kt
Normal file
@ -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 <pre>
|
||||||
|
*/
|
||||||
|
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("<pre", ignoreCase = true), "Rendered HTML should contain a <pre> tag. Got: $html")
|
||||||
|
val hasClass = html.contains("<pre class=\"code\"", ignoreCase = true) ||
|
||||||
|
html.contains(" class=\"code ", ignoreCase = true) ||
|
||||||
|
html.contains(" class=\"code\"", ignoreCase = true) ||
|
||||||
|
Regex("""<pre[^>]*class=['"][^'"]*\bcode\b[^'"]*['"]""", RegexOption.IGNORE_CASE).containsMatchIn(html)
|
||||||
|
assertTrue(hasClass, "<pre> should have 'code' class. Got: $html")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
site/src/jsTest/kotlin/DefinitionListTest.kt
Normal file
55
site/src/jsTest/kotlin/DefinitionListTest.kt
Normal file
@ -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("<dl>", ignoreCase = true), "Expected <dl> in rendered HTML. Got: $html")
|
||||||
|
assertTrue(html.contains("<dt>Term</dt>", ignoreCase = true), "Term should be inside <dt>. Got: $html")
|
||||||
|
// There should be two <dd> entries
|
||||||
|
val ddCount = Regex("<dd>", RegexOption.IGNORE_CASE).findAll(html).count()
|
||||||
|
assertTrue(ddCount == 2, "Expected two <dd> elements, got $ddCount. HTML: $html")
|
||||||
|
// No leading ':' should remain inside definitions
|
||||||
|
assertFalse(Regex("<dd>\\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 <dl>
|
||||||
|
assertFalse(html.contains("<dl>", ignoreCase = true), "Should not create <dl> when first paragraph starts with ':'. HTML: $html")
|
||||||
|
}
|
||||||
|
}
|
||||||
31
site/src/jsTest/kotlin/MarkdownRenderTest.kt
Normal file
31
site/src/jsTest/kotlin/MarkdownRenderTest.kt
Normal file
@ -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("<h1", ignoreCase = true), "Expected <h1> in rendered HTML, got: $html")
|
||||||
|
assertTrue(html.contains("Hello"), "Rendered HTML should contain the heading text")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
site/src/jsTest/kotlin/ReferencePageTest.kt
Normal file
46
site/src/jsTest/kotlin/ReferencePageTest.kt
Normal file
@ -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("<ul", ignoreCase = true), "Expected <ul> 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
48
site/src/jsTest/kotlin/RouteParsingTest.kt
Normal file
48
site/src/jsTest/kotlin/RouteParsingTest.kt
Normal file
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
37
site/src/jsTest/kotlin/TableStyleTest.kt
Normal file
37
site/src/jsTest/kotlin/TableStyleTest.kt
Normal file
@ -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("<table", ignoreCase = true), "Rendered HTML should contain a <table> tag. Got: $html")
|
||||||
|
assertTrue(html.contains("class=\"table\"") || html.contains("class=\"table ") || html.contains(" class=\"table\""),
|
||||||
|
"Rendered <table> should have Bootstrap 'table' class. Got: $html")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user