lyngweb: introduce reusable editor and highlighting utilities

This commit is contained in:
sergeych committed 2025-11-22 00:34:45 +01:00
1 parent 72c6dc2bde
commit faead76688
11 files changed
+853 -636

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
### Lyng Web utilities (`:lyngweb`)
Reusable JS/Compose for Web utilities and UI pieces for Lyng-powered sites. The module is self-sufficient: adding it as a dependency is enough — no external CSS classes are required for its editor overlay to render correctly.
#### What’s inside
- `EditorWithOverlay` — a pure code editor Composable with a syntax-highlight overlay. It keeps a native `<textarea>` for input/caret while rendering highlighted HTML on top, staying in perfect glyph alignment. No built-in buttons or actions.
- HTML utilities for Markdown pipelines:
- `ensureBootstrapCodeBlocks(html: String): String` — adds `class="code"` to `<pre>` blocks (for Bootstrap-like styling).
- `highlightLyngHtml(html: String): String` — transforms Lyng code blocks inside HTML into highlighted spans using `hl-*` classes.
- `htmlEscape(s: String): String` — HTML-escape utility used by the highlighter.
- Backward-compatible `net.sergeych.site.SiteHighlight.renderHtml(text)` that renders a highlighted string with the same `hl-*` classes used by the site.
All essential styles for the editor are injected inline; there is no dependency on external CSS class names (e.g., `editor-overlay`). You can still override visuals with your own CSS if desired.
---
#### Quick start
1) Add the dependency in your JS `build.gradle.kts`:
```kotlin
dependencies {
implementation(project(":lyngweb"))
}
```
2) Use the editor in a Compose HTML page:
```kotlin
@Composable
fun TryLyngSnippet() {
var code by remember { mutableStateOf("""
// Type Lyng code here
import lyng.stdlib
[1,2,3].map { it * 10 }
""".trimIndent()) }
fun runCode() { /* evaluate code in your Scope */ }
Div({ classes("mb-3") }) {
Div({ classes("form-label") }) { Text("Code") }
EditorWithOverlay(
code = code,
setCode = { code = it },
// Optionally handle keyboard shortcuts (e.g., Ctrl/Cmd+Enter to run):
onKeyDown = { ev ->
val ctrlOrMeta = ev.ctrlKey || ev.metaKey
if (ctrlOrMeta && ev.key.lowercase() == "enter") {
ev.preventDefault()
runCode()
}
}
)
}
// Your own action buttons
// ...your own action buttons...
}
```
The editor provides:
- Tab insertion with configurable `tabSize` (default 4)
- Smart newline indentation (copies the leading spaces of the current line)
- Scroll sync and 1:1 glyph alignment between overlay and textarea
- Inline styles for overlay/textarea; no external CSS required
---
#### Highlight Lyng code inside Markdown HTML
If you render Markdown to HTML first (e.g., with `marked`), you can post-process it with `lyngweb` to highlight Lyng code blocks:
```kotlin
fun renderMarkdownLyng(mdHtml: String): String {
// 1) ensure <pre> blocks have class="code"
val withPre = ensureBootstrapCodeBlocks(mdHtml)
// 2) highlight <code class="language-lyng"> blocks
return highlightLyngHtml(withPre)
}
```
Lyng tokens are wrapped into spans with classes like `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`, etc. You can style them as you wish, for example:
```css
.hl-kw { color: #6f42c1; font-weight: 600; }
.hl-id { color: #1f2328; }
.hl-num { color: #0a3069; }
.hl-str { color: #015b2f; }
.hl-cmt { color: #6a737d; font-style: italic; }
```
---
#### API Summary
- `@Composable fun EditorWithOverlay(code: String, setCode: (String) -> Unit, tabSize: Int = 4, onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null)`
- Pure editor, no actions. Wire your own buttons and shortcuts.
- Self-contained styling, adjustable with your own CSS if desired.
- `fun ensureBootstrapCodeBlocks(html: String): String`
- Adds `class="code"` to `<pre>` tags if not present.
- `fun highlightLyngHtml(html: String): String`
- Highlights Lyng `<code class="language-lyng">...</code>` blocks inside the provided HTML.
- `fun htmlEscape(s: String): String`
- Escapes special HTML characters.
- `object net.sergeych.site.SiteHighlight`
- `fun renderHtml(text: String): String` — renders highlighted spans with `hl-*` classes; kept for compatibility with existing site code/tests.
---
#### Notes
- The editor does not ship default color styles for `hl-*` classes. Provide your own CSS to match your theme.
- If you want a minimal look without Bootstrap, the editor still works out of the box due to inline styles.
+51
View File
@@ -0,0 +1,51 @@
/*
* 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.
*
*/
/*
* JS library module providing Lyng web UI components and HTML highlighter
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21"
id("org.jetbrains.compose") version "1.9.3"
}
kotlin {
js(IR) {
browser {
commonWebpackConfig {
cssSupport { enabled.set(true) }
}
}
binaries.library()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation("org.jetbrains.compose.runtime:runtime:1.9.3")
implementation("org.jetbrains.compose.html:html-core:1.9.3")
implementation(libs.kotlinx.coroutines.core)
implementation(project(":lynglib"))
}
}
val jsTest by getting {
dependencies { implementation(libs.kotlin.test) }
}
}
}
@@ -0,0 +1,310 @@
/*
* 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.
*
*/
package net.sergeych.lyngweb
import androidx.compose.runtime.*
import kotlinx.browser.window
import net.sergeych.site.SiteHighlight
import org.jetbrains.compose.web.attributes.placeholder
import org.jetbrains.compose.web.dom.Div
import org.jetbrains.compose.web.events.SyntheticKeyboardEvent
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLTextAreaElement
/**
* A lightweight, dependency-free code editor for Compose HTML that renders syntax highlight
* in an overlay while keeping the native textarea for input and caret/selection.
*
* Features:
* - Pure editor: no built-in buttons or actions; wire shortcuts via [onKeyDown].
* - Tab insertion and smart newline indentation.
* - Keeps overlay scroll, paddings, and line-height in sync with the textarea for glyph alignment.
* - No external CSS dependency: all essential styles are injected inline.
*
* Parameters:
* - [code]: current text value.
* - [setCode]: callback to update text.
* - [tabSize]: number of spaces to insert on Tab and used for visual tab width.
* - [onKeyDown]: optional raw keydown hook to handle shortcuts like Ctrl/Cmd+Enter.
*/
@Composable
fun EditorWithOverlay(
code: String,
setCode: (String) -> Unit,
tabSize: Int = 4,
onKeyDown: ((SyntheticKeyboardEvent) -> Unit)? = null,
) {
var overlayEl by remember { mutableStateOf<HTMLElement?>(null) }
var taEl by remember { mutableStateOf<HTMLTextAreaElement?>(null) }
var lastGoodHtml by remember { mutableStateOf<String?>(null) }
var lastGoodText by remember { mutableStateOf<String?>(null) }
var pendingSelStart by remember { mutableStateOf<Int?>(null) }
var pendingSelEnd by remember { mutableStateOf<Int?>(null) }
var pendingScrollTop by remember { mutableStateOf<Double?>(null) }
var pendingScrollLeft by remember { mutableStateOf<Double?>(null) }
// Update overlay HTML whenever code changes
LaunchedEffect(code) {
fun htmlEscapeLocal(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
'>' -> append("&gt;")
'&' -> append("&amp;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(ch)
}
}
fun trimHtmlToTextPrefix(html: String, prefixChars: Int): String {
if (prefixChars <= 0) return ""
var i = 0
var textCount = 0
val n = html.length
val out = StringBuilder(prefixChars + 64)
val stack = mutableListOf<String>()
while (i < n && textCount < prefixChars) {
val ch = html[i]
if (ch == '<') {
val close = html.indexOf('>', i)
if (close == -1) break
val tag = html.substring(i, close + 1)
out.append(tag)
val tagLower = tag.lowercase()
if (tagLower.startsWith("<span")) {
stack.add("</span>")
} else if (tagLower.startsWith("</span")) {
if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex)
}
i = close + 1
} else if (ch == '&') {
val semi = html.indexOf(';', i + 1).let { if (it == -1) n - 1 else it }
val entity = html.substring(i, semi + 1)
out.append(entity)
textCount += 1
i = semi + 1
} else {
out.append(ch)
textCount += 1
i += 1
}
}
for (j in stack.size - 1 downTo 0) out.append(stack[j])
return out.toString()
}
fun appendSentinel(html: String): String =
html + "<span data-sentinel=\"1\">&#8203;</span>"
try {
val html = SiteHighlight.renderHtml(code)
overlayEl?.innerHTML = appendSentinel(html)
lastGoodHtml = html
lastGoodText = code
} catch (_: Throwable) {
val prevHtml = lastGoodHtml
val prevText = lastGoodText
if (prevHtml != null && prevText != null) {
val max = minOf(prevText.length, code.length)
var k = 0
while (k < max && prevText[k] == code[k]) k++
val prefixLen = k
val trimmed = trimHtmlToTextPrefix(prevHtml, prefixLen)
val tail = code.substring(prefixLen)
val combined = trimmed + htmlEscapeLocal(tail)
overlayEl?.innerHTML = appendSentinel(combined)
} else {
overlayEl?.innerHTML = appendSentinel(htmlEscapeLocal(code))
}
}
val st = pendingScrollTop ?: (taEl?.scrollTop ?: 0.0)
val sl = pendingScrollLeft ?: (taEl?.scrollLeft ?: 0.0)
overlayEl?.scrollTop = st
overlayEl?.scrollLeft = sl
pendingScrollTop = null
pendingScrollLeft = null
}
fun setSelection(start: Int, end: Int = start) {
(taEl ?: return).apply {
selectionStart = start
selectionEnd = end
focus()
}
}
Div({
// avoid external CSS dependency: ensure base positioning inline
classes("position-relative")
attr("style", "position:relative;")
}) {
// Overlay: highlighted code
org.jetbrains.compose.web.dom.Div({
// Do not depend on any external class name like "editor-overlay"
// Provide fully inline styling; classes left empty to avoid external deps
attr(
"style",
buildString {
append("position:absolute; left:0; top:0; right:0; bottom:0;")
append("overflow:auto; box-sizing:border-box; white-space:pre-wrap; word-break:break-word; tab-size:")
append(tabSize)
append("; margin:0; pointer-events:none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
}
)
ref { it ->
overlayEl = it
onDispose { if (overlayEl === it) overlayEl = null }
}
}) {}
// Textarea: user input with transparent text
org.jetbrains.compose.web.dom.TextArea(value = code, attrs = {
ref { ta ->
taEl = ta
onDispose { if (taEl === ta) taEl = null }
}
// Avoid relying on external classes; still allow host app to override via CSS
// Make typed text transparent (overlay renders the colored text), but keep caret visible
attr(
"style",
buildString {
append("width:100%; min-height:220px; background:transparent; position:relative; z-index:1; tab-size:")
append(tabSize)
append("; color:transparent; -webkit-text-fill-color:transparent; ")
// Make caret visible even though text color is transparent
append("caret-color: var(--bs-body-color, #212529);")
// Basic input look without relying on external CSS
append(" border: 1px solid var(--bs-border-color, #ced4da); border-radius: .375rem;")
append(" padding: .5rem .75rem; box-sizing: border-box;")
// Remove UA focus outline that may appear as a red border in some themes
append(" outline: none; box-shadow: none;")
// Typography and rendering
append(" font-variant-ligatures: none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;")
}
)
// Disable browser corrections for a code editor
attr("spellcheck", "false")
attr("autocorrect", "off")
attr("autocapitalize", "off")
attr("autocomplete", "off")
placeholder("Enter Lyng code here…")
onInput { ev ->
val v = (ev.target as HTMLTextAreaElement).value
setCode(v)
}
onKeyDown { ev ->
// bubble to caller first so they may intercept shortcuts
onKeyDown?.invoke(ev)
val ta = taEl ?: return@onKeyDown
val key = ev.key
if (key == "Tab") {
ev.preventDefault()
val start = ta.selectionStart ?: 0
val end = ta.selectionEnd ?: start
val current = ta.value
val before = current.substring(0, start)
val after = current.substring(end)
val spaces = " ".repeat(tabSize)
val updated = before + spaces + after
pendingSelStart = start + spaces.length
pendingSelEnd = pendingSelStart
setCode(updated)
} else if (key == "Enter") {
// Smart indent: copy leading spaces from current line
val start = ta.selectionStart ?: 0
val cur = ta.value
val lineStart = run {
var i = start - 1
while (i >= 0 && cur[i] != '\n') i--
i + 1
}
var indent = 0
while (lineStart + indent < cur.length && cur[lineStart + indent] == ' ') indent++
val before = cur.substring(0, start)
val after = cur.substring(start)
val insertion = "\n" + " ".repeat(indent)
pendingSelStart = start + insertion.length
pendingSelEnd = pendingSelStart
setCode(before + insertion + after)
ev.preventDefault()
}
}
onScroll { ev ->
val src = ev.target as? HTMLTextAreaElement ?: return@onScroll
overlayEl?.scrollTop = src.scrollTop
overlayEl?.scrollLeft = src.scrollLeft
}
})
// No built-in action buttons: EditorWithOverlay is a pure editor now
}
// Ensure overlay typography and paddings mirror the textarea so characters line up 1:1
LaunchedEffect(taEl, overlayEl) {
try {
val ta = taEl ?: return@LaunchedEffect
val ov = overlayEl ?: return@LaunchedEffect
val cs = window.getComputedStyle(ta)
// Best-effort concrete line-height
val lineHeight = cs.lineHeight.takeIf { it.endsWith("px") } ?: cs.fontSize
val style = buildString {
append("position:absolute; inset:0; overflow:auto; pointer-events:none; box-sizing:border-box;")
append(" white-space:pre-wrap; word-break:break-word; tab-size:")
append(tabSize)
append(";")
append("font-family:").append(cs.fontFamily).append(';')
append("font-size:").append(cs.fontSize).append(';')
if (!lineHeight.isNullOrBlank()) append("line-height:").append(lineHeight).append(';')
append("letter-spacing:").append(cs.letterSpacing).append(';')
// keep visual rendering close to textarea
append("font-variant-ligatures:none; -webkit-font-smoothing:antialiased; text-rendering:optimizeSpeed;")
// mirror paddings
append("padding-top:").append(cs.paddingTop).append(';')
append("padding-right:").append(cs.paddingRight).append(';')
append("padding-bottom:").append(cs.paddingBottom).append(';')
append("padding-left:").append(cs.paddingLeft).append(';')
// base color in case we render plain text fallback
append("color: var(--bs-body-color);")
}
ov.setAttribute("style", style)
// also enforce concrete line-height on textarea to stabilize caret metrics
val existing = ta.getAttribute("style") ?: ""
if (!existing.contains("line-height") && !lineHeight.isNullOrBlank()) {
ta.setAttribute("style", existing + " line-height: " + lineHeight + ";")
}
} catch (_: Throwable) {
}
}
// Apply pending selection when value updates
LaunchedEffect(code, pendingSelStart, pendingSelEnd) {
val s = pendingSelStart
val e = pendingSelEnd
if (s != null && e != null) {
pendingSelStart = null
pendingSelEnd = null
window.setTimeout({ setSelection(s, e) }, 0)
}
}
}
@@ -0,0 +1,265 @@
/*
* 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.
*
*/
/*
* Shared Lyng HTML highlighting utilities for Compose HTML apps
*/
package net.sergeych.lyngweb
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
/**
* Adds a Bootstrap-friendly `code` class to every opening `<pre>` tag in the provided HTML.
*
* This is a lightweight post-processing step for Markdown-rendered HTML to ensure that
* code blocks (wrapped in `<pre>...</pre>`) receive consistent styling in Bootstrap-based
* sites without requiring changes in the Markdown renderer.
*
* Behavior:
* - If a `<pre>` has no `class` attribute, a `class="code"` attribute is added.
* - If a `<pre>` already has a `class` attribute but not `code`, the word `code` is appended.
* - Other attributes and their order are preserved.
*
* Example:
* ```kotlin
* val withClasses = ensureBootstrapCodeBlocks("<pre><code>println(1)</code></pre>")
* // => "<pre class=\"code\"><code>println(1)</code></pre>"
* ```
*
* @param html HTML text to transform.
* @return HTML with `<pre>` tags normalized to include the `code` class.
*/
fun ensureBootstrapCodeBlocks(html: String): String {
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 == "code" }
if (!hasCode) {
val updated = "class=" + quote + (classes.trim().let { if (it.isEmpty()) "code" else "$it code" }) + quote
newAttrs = attrs.replaceRange(m.range, updated)
}
} else {
newAttrs = " class=\"code\"" + attrs
}
"<pre$newAttrs>"
}
}
/**
* Highlights Lyng code blocks inside Markdown-produced HTML.
*
* Searches for sequences of `<pre><code ...>...</code></pre>` and, if the `<code>` element
* carries class `language-lyng` (or if it has no `language-*` class at all), applies Lyng
* syntax highlighting, replacing the inner HTML with spans that use `hl-*` CSS classes
* (e.g. `hl-kw`, `hl-id`, `hl-num`, `hl-cmt`).
*
* Special handling:
* - If a block has no explicit language class, doctest-style result lines starting with
* `>>>` at the end of the block are rendered as comments (`hl-cmt`).
* - If the block specifies another language (e.g. `language-kotlin`), the block is left
* unchanged.
*
* Example:
* ```kotlin
* val mdHtml = """
* <pre><code class=\"language-lyng\">and or not\n</code></pre>
* """.trimIndent()
* val highlighted = highlightLyngHtml(mdHtml)
* ```
*
* @param html HTML produced by a Markdown renderer.
* @return HTML with Lyng code blocks highlighted using `hl-*` classes.
*/
fun highlightLyngHtml(html: String): String {
// Regex to find <pre> ... <code class="language-lyng ...">(content)</code> ... </pre>
val preCodeRegex = Regex(
pattern = """<pre(\s+[^>]*)?>\s*<code([^>]*)>([\s\S]*?)</code>\s*</pre>""",
options = setOf(RegexOption.IGNORE_CASE)
)
val classAttrRegex = Regex("""\bclass\s*=\s*(["'])(.*?)\1""", RegexOption.IGNORE_CASE)
return preCodeRegex.replace(html) { m ->
val preAttrs = m.groups[1]?.value ?: ""
val codeAttrs = m.groups[2]?.value ?: ""
val codeHtml = m.groups[3]?.value ?: ""
val codeHasLyng = run {
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
cls.split("\\s+".toRegex()).any { it.equals("language-lyng", ignoreCase = true) }
}
val hasAnyLanguage = run {
val cls = classAttrRegex.find(codeAttrs)?.groupValues?.getOrNull(2) ?: ""
cls.split("\\s+".toRegex()).any { it.startsWith("language-", ignoreCase = true) }
}
val treatAsLyng = codeHasLyng || !hasAnyLanguage
if (!treatAsLyng) return@replace m.value
val text = htmlUnescape(codeHtml)
val (headText, tailTextOrNull) = if (!codeHasLyng && !hasAnyLanguage) splitDoctestTail(text) else text to null
val headHighlighted = try {
applyLyngHighlightToText(headText)
} catch (_: Throwable) {
return@replace m.value
}
val tailHighlighted = tailTextOrNull?.let { renderDoctestTailAsComments(it) } ?: ""
val highlighted = headHighlighted + tailHighlighted
"<pre$preAttrs><code$codeAttrs>$highlighted</code></pre>"
}
}
private fun splitDoctestTail(text: String): Pair<String, String?> {
if (text.isEmpty()) return "" to null
val hasTrailingNewline = text.endsWith("\n")
val lines = text.split("\n")
var i = lines.size - 1
while (i >= 0 && lines[i].isEmpty()) i--
var count = 0
while (i >= 0) {
val line = lines[i]
val trimmed = line.trimStart()
if (trimmed.isNotEmpty() && !trimmed.startsWith(">>>")) break
if (trimmed.isEmpty()) {
if (count == 0) break else { count++; i--; continue }
}
count++
i--
}
if (count == 0) return text to null
val splitIndex = lines.size - count
val head = buildString {
for (idx in 0 until splitIndex) {
append(lines[idx])
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
}
}
val tail = buildString {
for (idx in splitIndex until lines.size) {
append(lines[idx])
if (idx < lines.size - 1 || hasTrailingNewline) append('\n')
}
}
return head to tail
}
private fun renderDoctestTailAsComments(tail: String): String {
if (tail.isEmpty()) return ""
val sb = StringBuilder(tail.length + 32)
var start = 0
while (start <= tail.lastIndex) {
val nl = tail.indexOf('\n', start)
val line = if (nl >= 0) tail.substring(start, nl) else tail.substring(start)
sb.append("<span class=\"hl-cmt\">")
sb.append(htmlEscape(line))
sb.append("</span>")
if (nl >= 0) sb.append('\n')
if (nl < 0) break else start = nl + 1
}
return sb.toString()
}
/**
* Converts plain Lyng source text into HTML with syntax-highlight spans.
*
* Tokens are wrapped in `<span>` elements with `hl-*` classes (e.g., `hl-kw`, `hl-id`).
* Text between tokens is HTML-escaped and preserved. If no tokens are detected,
* the whole text is returned HTML-escaped.
*
* This is a low-level utility used by [highlightLyngHtml]. If you already have
* Markdown-produced HTML with `<pre><code>` blocks, prefer calling [highlightLyngHtml].
*
* @param text Lyng source code (plain text, not HTML-escaped).
* @return HTML string with `hl-*` spans.
*/
fun applyLyngHighlightToText(text: String): String {
val spans = SimpleLyngHighlighter().highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = cssClassForKind(s.kind)
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
HighlightKind.Identifier -> "hl-id"
HighlightKind.Number -> "hl-num"
HighlightKind.String -> "hl-str"
HighlightKind.Char -> "hl-ch"
HighlightKind.Regex -> "hl-rx"
HighlightKind.Comment -> "hl-cmt"
HighlightKind.Operator -> "hl-op"
HighlightKind.Punctuation -> "hl-punc"
HighlightKind.Label -> "hl-lbl"
HighlightKind.Directive -> "hl-dir"
HighlightKind.Error -> "hl-err"
}
/**
* Escapes special HTML characters in a plain text string.
*
* Replacements:
* - `&` → `&amp;`
* - `<` → `&lt;`
* - `>` → `&gt;`
* - `"` → `&quot;`
* - `'` → `&#39;`
*
* @param s Text to escape.
* @return HTML-escaped text safe to insert into an HTML context.
*/
fun htmlEscape(s: String): String = buildString(s.length) {
for (ch in s) when (ch) {
'<' -> append("&lt;")
'>' -> append("&gt;")
'&' -> append("&amp;")
'"' -> append("&quot;")
'\'' -> append("&#39;")
else -> append(ch)
}
}
private fun htmlUnescape(s: String): String {
return s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
}
@@ -0,0 +1,87 @@
/*
* 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.
*
*/
/*
* Site-like wrapper for Lyng highlighter that renders HTML spans with CSS classes
* compatible with the site styles. Kept in package net.sergeych.site for backwards
* compatibility with existing code and tests.
*/
package net.sergeych.site
import net.sergeych.lyng.highlight.HighlightKind
import net.sergeych.lyng.highlight.SimpleLyngHighlighter
import net.sergeych.lyngweb.htmlEscape
/**
* Minimal HTML renderer for Lyng syntax highlighting, compatible with the site CSS.
*
* This object is kept in the legacy package `net.sergeych.site` to preserve
* backward compatibility with existing imports and tests in dependent modules.
* It renders spans with the `hl-*` classes used by the site (e.g., `hl-kw`,
* `hl-id`, `hl-num`).
*/
object SiteHighlight {
private fun cssClassForKind(kind: HighlightKind): String = when (kind) {
HighlightKind.Keyword -> "hl-kw"
HighlightKind.TypeName -> "hl-ty"
HighlightKind.Identifier -> "hl-id"
HighlightKind.Number -> "hl-num"
HighlightKind.String -> "hl-str"
HighlightKind.Char -> "hl-ch"
HighlightKind.Regex -> "hl-rx"
HighlightKind.Comment -> "hl-cmt"
HighlightKind.Operator -> "hl-op"
HighlightKind.Punctuation -> "hl-punc"
HighlightKind.Label -> "hl-lbl"
HighlightKind.Directive -> "hl-dir"
HighlightKind.Error -> "hl-err"
}
/**
* Converts plain Lyng source [text] into HTML with `<span>` wrappers using
* site-compatible `hl-*` classes.
*
* Non-highlighted parts are HTML-escaped. If the highlighter returns no
* tokens, the entire string is returned as an escaped plain text.
*
* Example:
* ```kotlin
* val html = SiteHighlight.renderHtml("assertEquals(1, 1)")
* // => "<span class=\"hl-id\">assertEquals</span><span class=\"hl-punc\">(</span>..."
* ```
*
* @param text Lyng code to render (plain text).
* @return HTML string with `hl-*` styled tokens.
*/
fun renderHtml(text: String): String {
val highlighter = SimpleLyngHighlighter()
val spans = highlighter.highlight(text)
if (spans.isEmpty()) return htmlEscape(text)
val sb = StringBuilder(text.length + spans.size * 16)
var pos = 0
for (s in spans) {
if (s.range.start > pos) sb.append(htmlEscape(text.substring(pos, s.range.start)))
val cls = cssClassForKind(s.kind)
sb.append('<').append("span class=\"").append(cls).append('\"').append('>')
sb.append(htmlEscape(text.substring(s.range.start, s.range.endExclusive)))
sb.append("</span>")
pos = s.range.endExclusive
}
if (pos < text.length) sb.append(htmlEscape(text.substring(pos)))
return sb.toString()
}
}