···11+# buff
22+33+Buff your lustre output to a real shine! By removing as much "optional" markup as possible. It does this by omitting opening and closing tags if the browsers' error handling algorithms would automatically generate the same DOM. It also uses [houdinis_publicist](https://tangled.org/@bthom.tngl.sh/houdinis_publicist) for attributes.
44+55+This isn't added to Hex, so you'll have to add it as a git dependency in your gleam.toml:
66+77+buff = { git = "https://tangled.sh/@bthom.tngl.sh/buff", ref = "v1.0.0" }
88+99+Please let me know if you discover any issues with this not generating the expected DOM, and let me know if you have any other ideas for other ways to cut down on the generated HTML.
1010+1111+Note, this depends on lustre internals, so for the time being has a very narrow range of lustre that it supports to be sure that everything will work. I don't know how this'll change going forward.
···11+name = "buff"
22+version = "1.0.0"
33+44+# Fill out these fields if you intend to generate HTML documentation or publish
55+# your project to the Hex package manager.
66+#
77+# description = ""
88+# licences = ["Apache-2.0"]
99+# repository = { type = "github", user = "", repo = "" }
1010+# links = [{ title = "Website", href = "" }]
1111+#
1212+# For a full reference of all the available options, you can have a look at
1313+# https://gleam.run/writing-gleam/gleam-toml/.
1414+1515+[dependencies]
1616+gleam_stdlib = ">= 0.44.0 and < 2.0.0"
1717+houdinis_publicist = { git = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", ref = "v1.0.1" }
1818+lustre = ">= 5.3.2 and < 5.3.3"
1919+2020+[dev-dependencies]
2121+gleeunit = ">= 1.0.0 and < 2.0.0"
+19
manifest.toml
···11+# This file was generated by Gleam
22+# You typically do not need to edit this file
33+44+packages = [
55+ { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
66+ { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
77+ { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" },
88+ { name = "gleam_stdlib", version = "0.63.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "962B25C667DA07F4CAB32001F44D3C41C1A89E58E3BBA54F183B482CF6122150" },
99+ { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
1010+ { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
1111+ { name = "houdinis_publicist", version = "1.0.1", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", commit = "b31f7dfa7b0cb2f9e8d4c15afa515214aa400800" },
1212+ { name = "lustre", version = "5.3.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "6A13B167C0090B6A62ED429D50DC0EE8C34DEFF19F6C1F7C88CC58CFC46F94D4" },
1313+]
1414+1515+[requirements]
1616+gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
1717+gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
1818+houdinis_publicist = { git = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", ref = "v1.0.1" }
1919+lustre = { version = ">= 5.3.2 and < 5.3.3" }
+189
src/buff.gleam
···11+import gleam/string_tree.{type StringTree}
22+import houdinis_publicist
33+import lustre/element.{type Element}
44+import lustre/vdom/vattr.{type Attribute, Attribute, attribute}
55+import lustre/vdom/vnode.{Element, Fragment, Text, UnsafeInnerHtml}
66+77+pub fn to_document_string(node: Element(msg)) -> String {
88+ "<!doctype html>" <> { node |> to_string_tree(True) |> string_tree.to_string }
99+}
1010+1111+pub fn to_string(node: Element(msg)) -> String {
1212+ node
1313+ |> to_string_tree(True)
1414+ |> string_tree.to_string
1515+}
1616+1717+pub fn to_string_tree(node: Element(msg), last_child: Bool) -> StringTree {
1818+ case node, last_child {
1919+ Text(content: "", ..), _ -> string_tree.new()
2020+ Text(content:, ..), _ ->
2121+ string_tree.from_string(houdinis_publicist.escape(content))
2222+2323+ Fragment(children:, ..), _ ->
2424+ // Need to test this carry_last_child bool
2525+ children_to_string_tree(string_tree.new(), children, last_child)
2626+2727+ Element(key:, namespace:, tag:, attributes:, self_closing:, ..), _
2828+ if self_closing
2929+ -> {
3030+ let html = string_tree.from_string("<" <> tag)
3131+ let attributes = attr_to_string_tree(key, namespace, attributes)
3232+3333+ html
3434+ |> string_tree.append_tree(attributes)
3535+ |> string_tree.append("/>")
3636+ }
3737+3838+ Element(key:, namespace:, tag:, attributes:, void:, ..), _ if void -> {
3939+ let html = string_tree.from_string("<" <> tag)
4040+ let attributes = attr_to_string_tree(key, namespace, attributes)
4141+4242+ html
4343+ |> string_tree.append_tree(attributes)
4444+ |> string_tree.append(">")
4545+ }
4646+4747+ Element(key:, namespace:, tag:, attributes:, children:, ..), False -> {
4848+ // don't need to close these nodes, as they are closed by their parent or
4949+ // the next child in a valid doc
5050+ let close = case tag {
5151+ "caption"
5252+ | "dd"
5353+ | "dt"
5454+ | "li"
5555+ | "optgroup"
5656+ | "option"
5757+ | "p"
5858+ | "rp"
5959+ | "rt"
6060+ | "tbody"
6161+ | "td"
6262+ | "tfoot"
6363+ | "th"
6464+ | "thead"
6565+ | "tr"
6666+ | "body"
6767+ | "colgroup"
6868+ | "head"
6969+ | "html" -> ""
7070+7171+ _ -> "</" <> tag <> ">"
7272+ }
7373+7474+ open_tag(key, namespace, tag, attributes)
7575+ |> children_to_string_tree(children, True)
7676+ |> string_tree.append(close)
7777+ }
7878+7979+ // the last children don't need to be closed
8080+ Element(key:, namespace:, tag:, attributes:, children:, ..), True -> {
8181+ open_tag(key, namespace, tag, attributes)
8282+ |> children_to_string_tree(children, True)
8383+ }
8484+8585+ UnsafeInnerHtml(key:, namespace:, tag:, attributes:, inner_html:, ..), _ -> {
8686+ let html = string_tree.from_string("<" <> tag)
8787+ let attributes = attr_to_string_tree(key, namespace, attributes)
8888+8989+ html
9090+ |> string_tree.append_tree(attributes)
9191+ |> string_tree.append(">")
9292+ |> string_tree.append(inner_html)
9393+ |> string_tree.append("</" <> tag <> ">")
9494+ }
9595+ }
9696+}
9797+9898+fn open_tag(key, namespace, tag, attributes) {
9999+ case tag, attributes {
100100+ "body", [] | "colgroup", [] | "head", [] | "html", [] | "tbody", [] ->
101101+ string_tree.new()
102102+ _, _ -> {
103103+ let html = string_tree.from_string("<" <> tag)
104104+ let attributes = attr_to_string_tree(key, namespace, attributes)
105105+ html
106106+ |> string_tree.append_tree(attributes)
107107+ |> string_tree.append(">")
108108+ }
109109+ }
110110+}
111111+112112+fn children_to_string_tree(
113113+ html: StringTree,
114114+ children: List(Element(msg)),
115115+ carry_last_child: Bool,
116116+) -> StringTree {
117117+ use html, child, last <- fold_with_last(children, html)
118118+119119+ child
120120+ |> to_string_tree(last && carry_last_child)
121121+ |> string_tree.append_tree(html, _)
122122+}
123123+124124+pub fn attr_to_string_tree(
125125+ key: String,
126126+ namespace: String,
127127+ attributes: List(Attribute(msg)),
128128+) -> StringTree {
129129+ let attributes = case key != "" {
130130+ True -> [attribute("data-lustre-key", key), ..attributes]
131131+ False -> attributes
132132+ }
133133+ let attributes = case namespace != "" {
134134+ True -> [attribute("xmlns", namespace), ..attributes]
135135+ False -> attributes
136136+ }
137137+138138+ let attributes = {
139139+ use html, attr, last <- fold_with_last(attributes, string_tree.new())
140140+141141+ let trailing_whitespace = case last {
142142+ True -> ""
143143+ False -> " "
144144+ }
145145+146146+ case attr {
147147+ // We special-case this "virtual" attribute to stringify as a regular `"value"`
148148+ // attribute. In HTML, the default value of an input is set by this value
149149+ // attribute, but in Lustre users would use the `attribute.value` function
150150+ // for inputs that should be controlled by their model.
151151+ Attribute(name: "virtual:defaultValue", value:, ..) ->
152152+ string_tree.append(
153153+ html,
154154+ "value=\"" <> houdinis_publicist.escape_attribute(value) <> "\"",
155155+ )
156156+157157+ Attribute(name: "", ..) -> html
158158+ Attribute(name:, value: "", ..) ->
159159+ string_tree.append(html, name <> trailing_whitespace)
160160+ Attribute(name:, value:, ..) -> {
161161+ let value = houdinis_publicist.escape_attribute(value)
162162+ // don't need trailing whitespace if quoted
163163+ let trailing_whitespace = case value {
164164+ "\"" <> _ -> ""
165165+ _ -> trailing_whitespace
166166+ }
167167+ string_tree.append(html, { name <> "=" <> value <> trailing_whitespace })
168168+ }
169169+ _ -> html
170170+ }
171171+ }
172172+173173+ case string_tree.is_empty(attributes) {
174174+ True -> attributes
175175+ False -> string_tree.from_string(" ") |> string_tree.append_tree(attributes)
176176+ }
177177+}
178178+179179+fn fold_with_last(
180180+ over list: List(a),
181181+ from initial: acc,
182182+ with fun: fn(acc, a, Bool) -> acc,
183183+) -> acc {
184184+ case list {
185185+ [] -> initial
186186+ [first] -> fun(initial, first, True)
187187+ [first, ..rest] -> fold_with_last(rest, fun(initial, first, False), fun)
188188+ }
189189+}
+13
test/lustre_squeeze_test.gleam
···11+import gleeunit
22+33+pub fn main() -> Nil {
44+ gleeunit.main()
55+}
66+77+// gleeunit test functions end in `_test`
88+pub fn hello_world_test() {
99+ let name = "Joe"
1010+ let greeting = "Hello, " <> name <> "!"
1111+1212+ assert greeting == "Hello, Joe!"
1313+}