diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index 68a858f..c19f6d3 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -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`) diff --git a/docs/lyng.io.html.md b/docs/lyng.io.html.md new file mode 100644 index 0000000..967e513 --- /dev/null +++ b/docs/lyng.io.html.md @@ -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: " + } + img(src: "/logo.png", alt: "Logo") + } +} +``` + +`html { ... }` returns a `String` beginning with ``. + +## Escaping + +Text appended with unary `+` is HTML-escaped: + +```lyng +html { + body { + p { +"Text & " } + } +} +``` + +produces: + +```html +

Text & <more>

+``` + +Attribute values are escaped with HTML attribute rules: + +```lyng +p { + attr("data-x", "\"quoted\" & ") + +"content" +} +``` + +Use `raw(...)` only for trusted markup: + +```lyng +div { + raw("already escaped or trusted") +} +``` + +## 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. diff --git a/lyngio/src/commonTest/kotlin/net/sergeych/lyng/io/html/LyngHtmlModuleTest.kt b/lyngio/src/commonTest/kotlin/net/sergeych/lyng/io/html/LyngHtmlModuleTest.kt index 30a4790..b9ea462 100644 --- a/lyngio/src/commonTest/kotlin/net/sergeych/lyng/io/html/LyngHtmlModuleTest.kt +++ b/lyngio/src/commonTest/kotlin/net/sergeych/lyng/io/html/LyngHtmlModuleTest.kt @@ -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( + "", + """ + import lyng.io.html + + html { + head { + title { +"Demo" } + } + body { + h3 { +"Heading 3" } + p { + attr("data-x", "\"quoted\" & ") + +"Text & " + } + } + } + """.trimIndent() + ), + scope.importManager + ).execute(scope) + + assertEquals( + "Demo

Heading 3

Text & <more>

", + (result as ObjString).value + ) + } + + @Test + fun testHtmlDslSupportsRawAndVoidTags() = runTest { + val scope = Script.newScope() + createHtmlModule(scope.importManager) + + val result = Compiler.compile( + Source( + "", + """ + import lyng.io.html + + html { + head { + meta { attr("charset", "utf-8") } + } + body { + div { + id("root") + classes("app shell") + raw("trusted") + br {} + } + } + } + """.trimIndent() + ), + scope.importManager + ).execute(scope) + + assertEquals( + "
trusted
", + (result as ObjString).value + ) + } + + @Test + fun testHtmlDslTypedAttributeHelpers() = runTest { + val scope = Script.newScope() + createHtmlModule(scope.importManager) + + val result = Compiler.compile( + Source( + "", + """ + 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( + "\"Logo", + (result as ObjString).value + ) + } + + @Test + fun testHtmlDslGenericTagsAndFlagAttributes() = runTest { + val scope = Script.newScope() + createHtmlModule(scope.importManager) + + val result = Compiler.compile( + Source( + "", + """ + 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( + "", + (result as ObjString).value + ) + } } diff --git a/lyngio/stdlib/lyng/io/html.lyng b/lyngio/stdlib/lyng/io/html.lyng index d44260e..dd3a66d 100644 --- a/lyngio/stdlib/lyng/io/html.lyng +++ b/lyngio/stdlib/lyng/io/html.lyng @@ -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 = [] - private val body: List = [] - - fun build(): String = - "" + - (head.isEmpty() ? "" : head.joinToString("\n")) + - (body.isEmpty() ? "" : body.joinToString("\n")) + - "" +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() \ No newline at end of file +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 + "" + } + + 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) } + "" + root.render() +}