Add Lyng HTML DSL helpers
This commit is contained in:
parent
b2200e71ff
commit
c8e03d69ad
@ -93,6 +93,7 @@ Requires installing `lyngio` into the import manager from host code.
|
||||
- `import lyng.io.http.server` (minimal HTTP/1.1 and WebSocket server API)
|
||||
- `import lyng.io.ws` (WebSocket client API; currently supported on JVM, capability-gated elsewhere)
|
||||
- `import lyng.io.net` (TCP/UDP transport API; currently supported on JVM, capability-gated elsewhere)
|
||||
- `import lyng.io.html` (pure Lyng HTML builder DSL: `html { body { h3 { +"text" } } }`)
|
||||
- Shared network value-type packages are also available when installed by host code:
|
||||
- `import lyng.io.http.types` (`HttpHeaders`)
|
||||
- `import lyng.io.ws.types` (`WsMessage`)
|
||||
|
||||
164
docs/lyng.io.html.md
Normal file
164
docs/lyng.io.html.md
Normal file
@ -0,0 +1,164 @@
|
||||
# lyng.io.html
|
||||
|
||||
`lyng.io.html` provides a pure Lyng HTML builder DSL. It uses Lyng context
|
||||
receiver extensions, so text can be appended with `+"text"` inside tag blocks
|
||||
without global builder state.
|
||||
|
||||
Host code installs the package from `lyngio` with `createHtmlModule(...)`:
|
||||
|
||||
```kotlin
|
||||
val scope = Script.newScope()
|
||||
createHtmlModule(scope.importManager)
|
||||
```
|
||||
|
||||
Lyng code can then import it:
|
||||
|
||||
```lyng
|
||||
import lyng.io.html
|
||||
|
||||
val page = html {
|
||||
head {
|
||||
title { +"Demo" }
|
||||
}
|
||||
body {
|
||||
nav {
|
||||
a(href: "/") { +"Home" }
|
||||
}
|
||||
h3 { +"Heading 3" }
|
||||
p {
|
||||
attr("data-id", 123)
|
||||
+"Text is escaped: <safe>"
|
||||
}
|
||||
img(src: "/logo.png", alt: "Logo")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`html { ... }` returns a `String` beginning with `<!doctype html>`.
|
||||
|
||||
## Escaping
|
||||
|
||||
Text appended with unary `+` is HTML-escaped:
|
||||
|
||||
```lyng
|
||||
html {
|
||||
body {
|
||||
p { +"Text & <more>" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
produces:
|
||||
|
||||
```html
|
||||
<!doctype html><html><body><p>Text & <more></p></body></html>
|
||||
```
|
||||
|
||||
Attribute values are escaped with HTML attribute rules:
|
||||
|
||||
```lyng
|
||||
p {
|
||||
attr("data-x", "\"quoted\" & <tag>")
|
||||
+"content"
|
||||
}
|
||||
```
|
||||
|
||||
Use `raw(...)` only for trusted markup:
|
||||
|
||||
```lyng
|
||||
div {
|
||||
raw("<span>already escaped or trusted</span>")
|
||||
}
|
||||
```
|
||||
|
||||
## Tag Helpers
|
||||
|
||||
Current tag helpers cover common structural tags (`head`, `body`, `main`,
|
||||
`section`, `article`, `header`, `footer`, `nav`, `div`, `span`, `p`), headings
|
||||
(`h1` through `h6`), lists (`ul`, `ol`, `li`), and text/code tags (`strong`,
|
||||
`em`, `code`, `pre`, `script`, `style`).
|
||||
|
||||
```lyng
|
||||
body {
|
||||
main {
|
||||
section {
|
||||
h2 { +"News" }
|
||||
p { +"First item" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common void tags are also available: `meta`, `link`, `img`, `br`, and `input`.
|
||||
|
||||
```lyng
|
||||
head {
|
||||
meta { attr("charset", "utf-8") }
|
||||
link {
|
||||
attr("rel", "stylesheet")
|
||||
attr("href", "/site.css")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
Use `attr(name, value)` inside a tag block to set an escaped attribute value.
|
||||
`id(...)` and `classes(...)` are small aliases:
|
||||
|
||||
```lyng
|
||||
div {
|
||||
id("root")
|
||||
classes("app shell")
|
||||
}
|
||||
```
|
||||
|
||||
Use `flag(name)` for boolean attributes:
|
||||
|
||||
```lyng
|
||||
input {
|
||||
attr("type", "checkbox")
|
||||
flag("checked")
|
||||
}
|
||||
```
|
||||
|
||||
## Convenience Helpers
|
||||
|
||||
Convenience helpers include `metaCharset()`, `stylesheet(href)`,
|
||||
`a(href) { ... }`, `img(src, alt)`, and `input(type, name, value)`.
|
||||
|
||||
```lyng
|
||||
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")
|
||||
}
|
||||
```
|
||||
|
||||
## Generic Elements
|
||||
|
||||
Use `tag(name) { ... }` and `voidTag(name) { ... }` for elements that do not
|
||||
have dedicated helpers yet:
|
||||
|
||||
```lyng
|
||||
body {
|
||||
tag("custom-element") {
|
||||
flag("hidden")
|
||||
+"Secret"
|
||||
}
|
||||
voidTag("source") {
|
||||
attr("srcset", "/image.webp")
|
||||
attr("type", "image/webp")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These helpers are intentionally simple escape hatches. Prefer a dedicated helper
|
||||
when one exists because it can encode safer defaults and clearer parameter names.
|
||||
@ -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=\""quoted" & <tag>\">Text & <more></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 & mark\"><input type=\"hidden\" name=\"token\" value=\""abc"\"></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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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("&", "&")
|
||||
val lt: String = amp.replace("<", "<")
|
||||
lt.replace(">", ">")
|
||||
}
|
||||
|
||||
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("\"", """)
|
||||
quoted.replace("'", "'")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user