Add Lyng HTML DSL helpers

This commit is contained in:
sergeych committed 2026-04-29 22:07:30 +03:00
1 parent b2200e71ff
commit c8e03d69ad
4 files changed
+441 -17

No files matched your search

@@ -21,6 +21,7 @@ import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.pacman.ImportManager
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -54,4 +55,142 @@ class LyngHtmlModuleTest {
assertEquals("42", result.inspect(scope))
}
@Test
fun testHtmlDslBuildsNestedDocument() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-dsl-test>",
"""
import lyng.io.html
html {
head {
title { +"Demo" }
}
body {
h3 { +"Heading 3" }
p {
attr("data-x", "\"quoted\" & <tag>")
+"Text & <more>"
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><title>Demo</title></head><body><h3>Heading 3</h3><p data-x=\"&quot;quoted&quot; &amp; &lt;tag&gt;\">Text &amp; &lt;more&gt;</p></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslSupportsRawAndVoidTags() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-void-test>",
"""
import lyng.io.html
html {
head {
meta { attr("charset", "utf-8") }
}
body {
div {
id("root")
classes("app shell")
raw("<span>trusted</span>")
br {}
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><meta charset=\"utf-8\"></head><body><div id=\"root\" class=\"app shell\"><span>trusted</span><br></div></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslTypedAttributeHelpers() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-typed-attrs-test>",
"""
import lyng.io.html
html {
head {
metaCharset()
stylesheet("/site.css")
}
body {
nav {
a(href: "/home") { +"Home" }
}
img(src: "/logo.png", alt: "Logo & mark")
input(type: "hidden", name: "token", value: "\"abc\"")
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><head><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"/site.css\"></head><body><nav><a href=\"/home\">Home</a></nav><img src=\"/logo.png\" alt=\"Logo &amp; mark\"><input type=\"hidden\" name=\"token\" value=\"&quot;abc&quot;\"></body></html>",
(result as ObjString).value
)
}
@Test
fun testHtmlDslGenericTagsAndFlagAttributes() = runTest {
val scope = Script.newScope()
createHtmlModule(scope.importManager)
val result = Compiler.compile(
Source(
"<html-generic-tag-test>",
"""
import lyng.io.html
html {
body {
tag("custom-element") {
flag("hidden")
+"Secret"
}
voidTag("source") {
attr("srcset", "/image.webp")
attr("type", "image/webp")
}
}
}
""".trimIndent()
),
scope.importManager
).execute(scope)
assertEquals(
"<!doctype html><html><body><custom-element hidden>Secret</custom-element><source srcset=\"/image.webp\" type=\"image/webp\"></body></html>",
(result as ObjString).value
)
}
}
+137 -17
View File
@@ -1,22 +1,142 @@
package lyng.io.html
/*
HTML helpers package.
API surface is intentionally empty for now; this package exists so Lyng code
can import `lyng.io.html` and grow declarations here incrementally.
*/
import lyng.stdlib
class HtmlBuilder {
private val head: List<String> = []
private val body: List<String> = []
fun build(): String =
"<HTML>" +
(head.isEmpty() ? "" : head.joinToString("\n")) +
(body.isEmpty() ? "" : body.joinToString("\n")) +
"</HTML>"
fun escapeHtml(text: String): String {
val amp: String = text.replace("&", "&amp;")
val lt: String = amp.replace("<", "&lt;")
lt.replace(">", "&gt;")
}
fun buildHtml(f: HtmlBuilder.()->void): String {
HtmlBuilder().apply(f).build()
fun escapeHtmlAttr(text: String): String {
val escaped: String = escapeHtml(text)
val quoted: String = escaped.replace("\"", "&quot;")
quoted.replace("'", "&#39;")
}
class HtmlTag(name: String, isVoid: Bool = false) {
val name = name
val isVoid = isVoid
var attributes = ""
var inner = ""
fun attr(name: String, value: Object): HtmlTag {
attributes += " " + name + "=\"" + escapeHtmlAttr(value.toString()) + "\""
this
}
fun flag(name: String): HtmlTag {
attributes += " " + name
this
}
fun id(value: String): HtmlTag = attr("id", value)
fun classes(value: String): HtmlTag = attr("class", value)
fun addText(text: String): void {
inner += escapeHtml(text)
}
fun raw(html: String): void {
inner += html
}
fun child(tagName: String, block: HtmlTag.()->void): void {
val child = HtmlTag(tagName)
with(child) { block(this) }
inner += child.render()
}
fun voidChild(tagName: String, block: HtmlTag.()->void): void {
val child = HtmlTag(tagName, true)
with(child) { block(this) }
inner += child.render()
}
fun tag(name: String, block: HtmlTag.()->void): void { child(name, block) }
fun voidTag(name: String, block: HtmlTag.()->void): void { voidChild(name, block) }
fun render(): String {
if (isVoid) "<" + name + attributes + ">"
else "<" + name + attributes + ">" + inner + "</" + name + ">"
}
fun head(block: HtmlTag.()->void): void { child("head", block) }
fun body(block: HtmlTag.()->void): void { child("body", block) }
fun title(block: HtmlTag.()->void): void { child("title", block) }
fun main(block: HtmlTag.()->void): void { child("main", block) }
fun section(block: HtmlTag.()->void): void { child("section", block) }
fun article(block: HtmlTag.()->void): void { child("article", block) }
fun header(block: HtmlTag.()->void): void { child("header", block) }
fun footer(block: HtmlTag.()->void): void { child("footer", block) }
fun nav(block: HtmlTag.()->void): void { child("nav", block) }
fun div(block: HtmlTag.()->void): void { child("div", block) }
fun span(block: HtmlTag.()->void): void { child("span", block) }
fun p(block: HtmlTag.()->void): void { child("p", block) }
fun a(href: String, block: HtmlTag.()->void): void {
child("a") {
attr("href", href)
block(this)
}
}
fun ul(block: HtmlTag.()->void): void { child("ul", block) }
fun ol(block: HtmlTag.()->void): void { child("ol", block) }
fun li(block: HtmlTag.()->void): void { child("li", block) }
fun h1(block: HtmlTag.()->void): void { child("h1", block) }
fun h2(block: HtmlTag.()->void): void { child("h2", block) }
fun h3(block: HtmlTag.()->void): void { child("h3", block) }
fun h4(block: HtmlTag.()->void): void { child("h4", block) }
fun h5(block: HtmlTag.()->void): void { child("h5", block) }
fun h6(block: HtmlTag.()->void): void { child("h6", block) }
fun strong(block: HtmlTag.()->void): void { child("strong", block) }
fun em(block: HtmlTag.()->void): void { child("em", block) }
fun code(block: HtmlTag.()->void): void { child("code", block) }
fun pre(block: HtmlTag.()->void): void { child("pre", block) }
fun script(block: HtmlTag.()->void): void { child("script", block) }
fun style(block: HtmlTag.()->void): void { child("style", block) }
fun meta(block: HtmlTag.()->void): void { voidChild("meta", block) }
fun link(block: HtmlTag.()->void): void { voidChild("link", block) }
fun img(block: HtmlTag.()->void): void { voidChild("img", block) }
fun br(block: HtmlTag.()->void): void { voidChild("br", block) }
fun input(block: HtmlTag.()->void): void { voidChild("input", block) }
fun metaCharset(charset: String = "utf-8"): void {
meta { attr("charset", charset) }
}
fun stylesheet(href: String): void {
link {
attr("rel", "stylesheet")
attr("href", href)
}
}
fun img(src: String, alt: String = ""): void {
voidChild("img") {
attr("src", src)
if (alt != "") attr("alt", alt)
}
}
fun input(type: String, name: String = "", value: String = ""): void {
voidChild("input") {
attr("type", type)
if (name != "") attr("name", name)
if (value != "") attr("value", value)
}
}
}
context(HtmlTag)
fun String.unaryPlus(): void {
this@HtmlTag.addText(this)
}
fun html(block: HtmlTag.()->void): String {
val root = HtmlTag("html")
with(root) { block(this) }
"<!doctype html>" + root.render()
}