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.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.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.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:
|
- Shared network value-type packages are also available when installed by host code:
|
||||||
- `import lyng.io.http.types` (`HttpHeaders`)
|
- `import lyng.io.http.types` (`HttpHeaders`)
|
||||||
- `import lyng.io.ws.types` (`WsMessage`)
|
- `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.Compiler
|
||||||
import net.sergeych.lyng.Script
|
import net.sergeych.lyng.Script
|
||||||
import net.sergeych.lyng.Source
|
import net.sergeych.lyng.Source
|
||||||
|
import net.sergeych.lyng.obj.ObjString
|
||||||
import net.sergeych.lyng.pacman.ImportManager
|
import net.sergeych.lyng.pacman.ImportManager
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@ -54,4 +55,142 @@ class LyngHtmlModuleTest {
|
|||||||
|
|
||||||
assertEquals("42", result.inspect(scope))
|
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
|
package lyng.io.html
|
||||||
|
|
||||||
/*
|
import lyng.stdlib
|
||||||
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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class HtmlBuilder {
|
fun escapeHtml(text: String): String {
|
||||||
|
val amp: String = text.replace("&", "&")
|
||||||
private val head: List<String> = []
|
val lt: String = amp.replace("<", "<")
|
||||||
private val body: List<String> = []
|
lt.replace(">", ">")
|
||||||
|
|
||||||
fun build(): String =
|
|
||||||
"<HTML>" +
|
|
||||||
(head.isEmpty() ? "" : head.joinToString("\n")) +
|
|
||||||
(body.isEmpty() ? "" : body.joinToString("\n")) +
|
|
||||||
"</HTML>"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildHtml(f: HtmlBuilder.()->void): String {
|
fun escapeHtmlAttr(text: String): String {
|
||||||
HtmlBuilder().apply(f).build()
|
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