buff that gleam lustre output to a real shine! (by removing as much "optional" markup as possible)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial version

Benjamin Thomas 08344789

+282
+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "27.1.2" 18 + gleam-version: "1.12.0-rc1" 19 + rebar3-version: "3" 20 + # elixir-version: "1" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+11
README.md
··· 1 + # buff 2 + 3 + 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. 4 + 5 + This isn't added to Hex, so you'll have to add it as a git dependency in your gleam.toml: 6 + 7 + buff = { git = "https://tangled.sh/@bthom.tngl.sh/buff", ref = "v1.0.0" } 8 + 9 + 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. 10 + 11 + 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.
+2
TODO.md
··· 1 + - sort attributes? 2 + - sort class names?
+21
gleam.toml
··· 1 + name = "buff" 2 + version = "1.0.0" 3 + 4 + # Fill out these fields if you intend to generate HTML documentation or publish 5 + # your project to the Hex package manager. 6 + # 7 + # description = "" 8 + # licences = ["Apache-2.0"] 9 + # repository = { type = "github", user = "", repo = "" } 10 + # links = [{ title = "Website", href = "" }] 11 + # 12 + # For a full reference of all the available options, you can have a look at 13 + # https://gleam.run/writing-gleam/gleam-toml/. 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 0.44.0 and < 2.0.0" 17 + houdinis_publicist = { git = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", ref = "v1.0.1" } 18 + lustre = ">= 5.3.2 and < 5.3.3" 19 + 20 + [dev-dependencies] 21 + gleeunit = ">= 1.0.0 and < 2.0.0"
+19
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 6 + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, 7 + { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, 8 + { name = "gleam_stdlib", version = "0.63.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "962B25C667DA07F4CAB32001F44D3C41C1A89E58E3BBA54F183B482CF6122150" }, 9 + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, 10 + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, 11 + { name = "houdinis_publicist", version = "1.0.1", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", commit = "b31f7dfa7b0cb2f9e8d4c15afa515214aa400800" }, 12 + { 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" }, 13 + ] 14 + 15 + [requirements] 16 + gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } 17 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 18 + houdinis_publicist = { git = "https://tangled.sh/@bthom.tngl.sh/houdinis_publicist", ref = "v1.0.1" } 19 + lustre = { version = ">= 5.3.2 and < 5.3.3" }
+189
src/buff.gleam
··· 1 + import gleam/string_tree.{type StringTree} 2 + import houdinis_publicist 3 + import lustre/element.{type Element} 4 + import lustre/vdom/vattr.{type Attribute, Attribute, attribute} 5 + import lustre/vdom/vnode.{Element, Fragment, Text, UnsafeInnerHtml} 6 + 7 + pub fn to_document_string(node: Element(msg)) -> String { 8 + "<!doctype html>" <> { node |> to_string_tree(True) |> string_tree.to_string } 9 + } 10 + 11 + pub fn to_string(node: Element(msg)) -> String { 12 + node 13 + |> to_string_tree(True) 14 + |> string_tree.to_string 15 + } 16 + 17 + pub fn to_string_tree(node: Element(msg), last_child: Bool) -> StringTree { 18 + case node, last_child { 19 + Text(content: "", ..), _ -> string_tree.new() 20 + Text(content:, ..), _ -> 21 + string_tree.from_string(houdinis_publicist.escape(content)) 22 + 23 + Fragment(children:, ..), _ -> 24 + // Need to test this carry_last_child bool 25 + children_to_string_tree(string_tree.new(), children, last_child) 26 + 27 + Element(key:, namespace:, tag:, attributes:, self_closing:, ..), _ 28 + if self_closing 29 + -> { 30 + let html = string_tree.from_string("<" <> tag) 31 + let attributes = attr_to_string_tree(key, namespace, attributes) 32 + 33 + html 34 + |> string_tree.append_tree(attributes) 35 + |> string_tree.append("/>") 36 + } 37 + 38 + Element(key:, namespace:, tag:, attributes:, void:, ..), _ if void -> { 39 + let html = string_tree.from_string("<" <> tag) 40 + let attributes = attr_to_string_tree(key, namespace, attributes) 41 + 42 + html 43 + |> string_tree.append_tree(attributes) 44 + |> string_tree.append(">") 45 + } 46 + 47 + Element(key:, namespace:, tag:, attributes:, children:, ..), False -> { 48 + // don't need to close these nodes, as they are closed by their parent or 49 + // the next child in a valid doc 50 + let close = case tag { 51 + "caption" 52 + | "dd" 53 + | "dt" 54 + | "li" 55 + | "optgroup" 56 + | "option" 57 + | "p" 58 + | "rp" 59 + | "rt" 60 + | "tbody" 61 + | "td" 62 + | "tfoot" 63 + | "th" 64 + | "thead" 65 + | "tr" 66 + | "body" 67 + | "colgroup" 68 + | "head" 69 + | "html" -> "" 70 + 71 + _ -> "</" <> tag <> ">" 72 + } 73 + 74 + open_tag(key, namespace, tag, attributes) 75 + |> children_to_string_tree(children, True) 76 + |> string_tree.append(close) 77 + } 78 + 79 + // the last children don't need to be closed 80 + Element(key:, namespace:, tag:, attributes:, children:, ..), True -> { 81 + open_tag(key, namespace, tag, attributes) 82 + |> children_to_string_tree(children, True) 83 + } 84 + 85 + UnsafeInnerHtml(key:, namespace:, tag:, attributes:, inner_html:, ..), _ -> { 86 + let html = string_tree.from_string("<" <> tag) 87 + let attributes = attr_to_string_tree(key, namespace, attributes) 88 + 89 + html 90 + |> string_tree.append_tree(attributes) 91 + |> string_tree.append(">") 92 + |> string_tree.append(inner_html) 93 + |> string_tree.append("</" <> tag <> ">") 94 + } 95 + } 96 + } 97 + 98 + fn open_tag(key, namespace, tag, attributes) { 99 + case tag, attributes { 100 + "body", [] | "colgroup", [] | "head", [] | "html", [] | "tbody", [] -> 101 + string_tree.new() 102 + _, _ -> { 103 + let html = string_tree.from_string("<" <> tag) 104 + let attributes = attr_to_string_tree(key, namespace, attributes) 105 + html 106 + |> string_tree.append_tree(attributes) 107 + |> string_tree.append(">") 108 + } 109 + } 110 + } 111 + 112 + fn children_to_string_tree( 113 + html: StringTree, 114 + children: List(Element(msg)), 115 + carry_last_child: Bool, 116 + ) -> StringTree { 117 + use html, child, last <- fold_with_last(children, html) 118 + 119 + child 120 + |> to_string_tree(last && carry_last_child) 121 + |> string_tree.append_tree(html, _) 122 + } 123 + 124 + pub fn attr_to_string_tree( 125 + key: String, 126 + namespace: String, 127 + attributes: List(Attribute(msg)), 128 + ) -> StringTree { 129 + let attributes = case key != "" { 130 + True -> [attribute("data-lustre-key", key), ..attributes] 131 + False -> attributes 132 + } 133 + let attributes = case namespace != "" { 134 + True -> [attribute("xmlns", namespace), ..attributes] 135 + False -> attributes 136 + } 137 + 138 + let attributes = { 139 + use html, attr, last <- fold_with_last(attributes, string_tree.new()) 140 + 141 + let trailing_whitespace = case last { 142 + True -> "" 143 + False -> " " 144 + } 145 + 146 + case attr { 147 + // We special-case this "virtual" attribute to stringify as a regular `"value"` 148 + // attribute. In HTML, the default value of an input is set by this value 149 + // attribute, but in Lustre users would use the `attribute.value` function 150 + // for inputs that should be controlled by their model. 151 + Attribute(name: "virtual:defaultValue", value:, ..) -> 152 + string_tree.append( 153 + html, 154 + "value=\"" <> houdinis_publicist.escape_attribute(value) <> "\"", 155 + ) 156 + 157 + Attribute(name: "", ..) -> html 158 + Attribute(name:, value: "", ..) -> 159 + string_tree.append(html, name <> trailing_whitespace) 160 + Attribute(name:, value:, ..) -> { 161 + let value = houdinis_publicist.escape_attribute(value) 162 + // don't need trailing whitespace if quoted 163 + let trailing_whitespace = case value { 164 + "\"" <> _ -> "" 165 + _ -> trailing_whitespace 166 + } 167 + string_tree.append(html, { name <> "=" <> value <> trailing_whitespace }) 168 + } 169 + _ -> html 170 + } 171 + } 172 + 173 + case string_tree.is_empty(attributes) { 174 + True -> attributes 175 + False -> string_tree.from_string(" ") |> string_tree.append_tree(attributes) 176 + } 177 + } 178 + 179 + fn fold_with_last( 180 + over list: List(a), 181 + from initial: acc, 182 + with fun: fn(acc, a, Bool) -> acc, 183 + ) -> acc { 184 + case list { 185 + [] -> initial 186 + [first] -> fun(initial, first, True) 187 + [first, ..rest] -> fold_with_last(rest, fun(initial, first, False), fun) 188 + } 189 + }
+13
test/lustre_squeeze_test.gleam
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }