Skip to content

Commit 05b85ba

Browse files
committed
1.4.0, correcting template fragment support
1 parent 220e414 commit 05b85ba

9 files changed

Lines changed: 301 additions & 248 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.4.0] 2025-09-16
6+
7+
### Added
8+
9+
- `XmlNodeRenderer.renderFragment` to render template fragments based on `id` attribute lookup.
10+
11+
### Fixed
12+
13+
- Removed `XmlNode.NodeList`, `Elem.createFragment`, and `Elem.empty` to restore functionality, from 1.2.0 and correctly provide template fragment support.
14+
515
## [1.3.0] 2025-09-09
616

717
### Added
818

9-
- `XmlNode.NodeList` to represent HTML fragments.
10-
- `Elem.createFragment` to create HTML fragments from a list of `XmlNode`.
11-
- `Elem.empty` leverages `Elem.createFragment` and `XmlNode.NodeList` to represent an empty HTML fragment.
19+
- (Reverted in 1.4.0) `XmlNode.NodeList` to represent [template fragments](https://htmx.org/essays/template-fragments/).
20+
- (Reverted in 1.4.0) `Elem.createFragment` to create template fragments from a list of `XmlNode`.
21+
- (Reverted in 1.4.0) `Elem.empty` leverages `Elem.createFragment` and `XmlNode.NodeList` to represent an empty template fragment.
1222

1323
## [1.2.0] 2025-06-26
1424

@@ -28,7 +38,6 @@ All notable changes to this project will be documented in this file.
2838
```
2939
- Unified DSL module ensures backward compatibility while providing a cleaner syntax for new code.
3040
31-
3241
## [1.1.1] 2024-03-28
3342
3443
###

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ renderHtml doc
3131
- Should be simple, extensible and integrate with existing .NET libraries.
3232
- Can be easily learned.
3333
- Match HTML [spec](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference) as closely as possible.
34+
- Support rendering full documents as well as fragments.
3435

3536
## Overview
3637

@@ -47,7 +48,7 @@ Each of the primary modules can be access using the name directly, or using the
4748
| `Elem` | `_h1 [] []` |
4849
| `Attr` | `_class_ "my-class"` |
4950
| `Text` | `_text "Hello world!"` |
50-
| `Text` shortcuts | `_h1' "Hello world"` |
51+
| `Text` shortcuts | `_h1' "Hello world"` (note the trailing apostrophe) |
5152

5253

5354
```fsharp
@@ -336,6 +337,11 @@ let xmlDoc =
336337
let xml = renderXml xmlDoc
337338
```
338339

340+
## HTML Fragments
341+
342+
Sometimes you may want to create a fragment of HTML, without the surrounding `<html>`, `<head>` and `<body>` tags. Or, any wrapper element for that matter.
343+
344+
339345
## SVG
340346

341347
Much of the SVG spec has been mapped to element and attributes functions. There is also an SVG template to help initialize a new drawing with a valid viewbox.

src/Falco.Markup/Elem.fs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,6 @@ module Elem =
99
let createSelfClosing (tag : string) (attr : XmlAttribute list) =
1010
SelfClosingNode (tag, attr)
1111

12-
let createFragment (nodes : XmlNode list) =
13-
NodeList nodes
14-
15-
let empty =
16-
NodeList []
17-
1812
//
1913
// Main root
2014

src/Falco.Markup/Falco.Markup.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<PropertyGroup>
44
<AssemblyName>Falco.Markup</AssemblyName>
55
<RootNamespace>Falco</RootNamespace>
6-
<Version>1.3.0</Version>
6+
<Version>1.4.0</Version>
77

88
<!-- General info -->
99
<Description>An F# DSL for generating markup, including HTML, SVG and XML.</Description>

src/Falco.Markup/TestHelpers.fs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ module TestHelpers =
121121

122122
| ParentNode _
123123
| SelfClosingNode _
124-
| NodeList _
125124
| TextNode _ -> ()
126125

127126
// <option></option>
@@ -130,7 +129,6 @@ module TestHelpers =
130129

131130
| ParentNode _
132131
| SelfClosingNode _
133-
| NodeList _
134132
| TextNode _ -> () ]
135133

136134
if selected.Length = 0 then
@@ -143,9 +141,6 @@ module TestHelpers =
143141
for c in children do
144142
buildNameValues c
145143

146-
| NodeList nodes ->
147-
for node in nodes do
148-
buildNameValues node
149144
| SelfClosingNode _
150145
| TextNode _ -> ()
151146

src/Falco.Markup/XmlNode.fs

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ type XmlElement =
1818
type XmlNode =
1919
| TextNode of string
2020
| SelfClosingNode of XmlElement
21-
| NodeList of XmlNode list
2221
| ParentNode of XmlElement * XmlNode list
2322

2423
[<AbstractClass; Sealed>]
@@ -64,7 +63,7 @@ module internal XmlNodeSerializer =
6463
let [<Literal>] _equals = '='
6564
let [<Literal>] _quote = '"'
6665

67-
let serialize (w : StringWriter, xml : XmlNode) =
66+
let serialize (w : TextWriter, xml : XmlNode) =
6867
let writeAttributes attrs =
6968
for attr in (attrs : XmlAttribute list) do
7069
if attrs.Length > 0 then
@@ -81,25 +80,18 @@ module internal XmlNodeSerializer =
8180
w.Write attrValue
8281
w.Write _quote
8382

84-
let inline writeElement (tag : string, attrs) =
85-
w.Write _openChar
86-
w.Write tag
87-
writeAttributes attrs
88-
w.Write _space
89-
w.Write _term
90-
w.Write _closeChar
91-
9283
let rec buildXml tag =
9384
match tag with
9485
| TextNode text ->
9586
w.Write text
9687

97-
| SelfClosingNode el ->
98-
writeElement el
99-
100-
| NodeList nodes ->
101-
for node in nodes do
102-
buildXml node
88+
| SelfClosingNode (tag, attrs) ->
89+
w.Write _openChar
90+
w.Write tag
91+
writeAttributes attrs
92+
w.Write _space
93+
w.Write _term
94+
w.Write _closeChar
10395

10496
| ParentNode ((tag, attrs), children) ->
10597
w.Write _openChar
@@ -117,26 +109,52 @@ module internal XmlNodeSerializer =
117109

118110
buildXml xml
119111

120-
StringBuilderCache.GetString(w.GetStringBuilder())
121-
122112
[<AutoOpen>]
123113
module XmlNodeRenderer =
124-
/// Render XmlNode as string
125-
let renderNode (tag : XmlNode) =
114+
let private render (tag : XmlNode) (header : string option) =
126115
let sb = StringBuilderCache.Acquire()
127116
let w = new StringWriter(sb, CultureInfo.InvariantCulture)
117+
match header with
118+
| Some x -> w.Write x
119+
| None -> ()
128120
XmlNodeSerializer.serialize(w, tag)
121+
StringBuilderCache.GetString(w.GetStringBuilder())
122+
123+
/// Render XmlNode as string
124+
let renderNode (tag : XmlNode) =
125+
render tag None
129126

130127
/// Render XmlNode as HTML string
131128
let renderHtml (tag : XmlNode) =
132-
let sb = StringBuilderCache.Acquire()
133-
let w = new StringWriter(sb, CultureInfo.InvariantCulture)
134-
w.Write "<!DOCTYPE html>"
135-
XmlNodeSerializer.serialize(w, tag)
129+
render tag (Some "<!DOCTYPE html>")
136130

137131
/// Render XmlNode as XML string
138132
let renderXml (tag : XmlNode) =
139-
let sb = StringBuilderCache.Acquire()
140-
let w = new StringWriter(sb, CultureInfo.InvariantCulture)
141-
w.Write "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
142-
XmlNodeSerializer.serialize(w, tag)
133+
render tag (Some "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")
134+
135+
/// Render a fragment of XmlNode by id as string
136+
let renderFragment (tag : XmlNode) (id : string) =
137+
let isIdMatch attr =
138+
match attr with
139+
| KeyValueAttr ("id", v) when v = id -> true
140+
| _ -> false
141+
142+
let rec findId tag =
143+
match tag with
144+
| TextNode _ ->
145+
None
146+
147+
| SelfClosingNode ((_, attrs)) ->
148+
attrs
149+
|> List.tryFind isIdMatch
150+
|> Option.map (fun _ -> tag)
151+
152+
| ParentNode ((_, attrs), children) ->
153+
attrs
154+
|> List.tryFind isIdMatch
155+
|> Option.map (fun _ -> tag)
156+
|> Option.orElse (children |> List.tryPick findId)
157+
158+
match findId tag with
159+
| Some node -> render node None
160+
| None -> String.Empty

test/Falco.Markup.Tests/ElemTests.fs

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,45 @@ let ``Self-closing tag should render with trailing slash`` () =
1212
[<Fact>]
1313
let ``Self-closing tag with attrs should render with trailing slash`` () =
1414
let t = Elem.createSelfClosing "hr" [ _class_ "my-class" ]
15-
renderNode t |> should equal "<hr class=\"my-class\" />"
15+
renderNode t |> should equal """<hr class="my-class" />"""
1616

1717
[<Fact>]
1818
let ``Standard tag should render with multiple attributes`` () =
1919
let t = Elem.create "div" [ Attr.create "class" "my-class"; _autofocus_; Attr.create "data-bind" "slider" ] []
20-
renderNode t |> should equal "<div class=\"my-class\" autofocus data-bind=\"slider\"></div>"
20+
renderNode t |> should equal """<div class="my-class" autofocus data-bind="slider"></div>"""
2121

22+
[<Fact>]
23+
let ``Script should contain src, lang and async`` () =
24+
let t = _script [ _src_ "http://example.org/example.js"; _lang_ "javascript"; _async_ ] []
25+
renderNode t |> should equal """<script src="http://example.org/example.js" lang="javascript" async></script>"""
2226

2327
[<Fact>]
24-
let ``Can render NodeList with items``() =
25-
Elem.createFragment [
26-
for i = 1 to 3 do
27-
yield _div [] [ _textf "%i" i ]
28-
]
29-
|> renderNode
30-
|> should equal "<div>1</div><div>2</div><div>3</div>"
28+
let ``Should render text node with special characters`` () =
29+
let doc = _div [] [ _text "5 < 10 & 10 > 5 \"quoted\"" ]
30+
renderNode doc |> should equal """<div>5 < 10 & 10 > 5 "quoted"</div>"""
3131

3232
[<Fact>]
33-
let ``Can render empty NodeList``() =
34-
Elem.empty
35-
|> renderNode
36-
|> should equal ""
33+
let ``Should render attribute with empty value`` () =
34+
let doc = _input [ Attr.create "value" "" ]
35+
renderNode doc |> should equal """<input value="" />"""
3736

3837
[<Fact>]
39-
let ``Script should contain src, lang and async`` () =
40-
let t = _script [ _src_ "http://example.org/example.js"; _lang_ "javascript"; _async_ ] []
41-
renderNode t |> should equal "<script src=\"http://example.org/example.js\" lang=\"javascript\" async></script>"
38+
let ``Should render deeply nested elements`` () =
39+
let rec nest n acc =
40+
if n = 0 then acc
41+
else nest (n - 1) (_div [] [ acc ])
42+
let doc = nest 10 (_span [] [ _text "deep" ])
43+
renderNode doc |> should equal "<div><div><div><div><div><div><div><div><div><div><span>deep</span></div></div></div></div></div></div></div></div></div></div>"
44+
45+
[<Fact>]
46+
let ``Should render mixed content (text and child elements)`` () =
47+
let doc = _div [] [ _text "Hello "; _span [] [ _text "world" ]; _text "!" ]
48+
renderNode doc |> should equal """<div>Hello <span>world</span>!</div>"""
49+
50+
[<Fact>]
51+
let ``Should render unicode characters in text and attributes`` () =
52+
let doc = _div [ _title_ "Привет мир" ] [ _text "こんにちは世界" ]
53+
renderNode doc |> should equal """<div title="Привет мир">こんにちは世界</div>"""
4254

4355
[<Fact>]
4456
let ``Should produce valid html doc`` () =
@@ -47,7 +59,41 @@ let ``Should produce valid html doc`` () =
4759
_body [] [
4860
_div [ _class_ "my-class" ] [
4961
_h1 [] [ _text "hello" ] ] ] ]
50-
renderHtml doc |> should equal "<!DOCTYPE html><html><body><div class=\"my-class\"><h1>hello</h1></div></body></html>"
62+
renderHtml doc |> should equal """<!DOCTYPE html><html><body><div class="my-class"><h1>hello</h1></div></body></html>"""
63+
64+
[<Fact>]
65+
let ``Should produce valid template fragment from XmlNode and id``()
66+
=
67+
let doc =
68+
_div [ _id_ "my-div"; _class_ "my-class" ] [
69+
_h1 [ _id_ "my-heading" ] [ _text "hello" ] ]
70+
71+
renderFragment doc "my-heading" |> should equal """<h1 id="my-heading">hello</h1>"""
72+
73+
[<Fact>]
74+
let ``Should produce empty string template fragment from XmlNode and invalid/missing id``()
75+
=
76+
let doc =
77+
_div [ _id_ "my-div"; _class_ "my-class" ] [
78+
_h1 [ _id_ "my-heading" ] [ _text "hello" ] ]
79+
80+
renderFragment doc "not-found" |> should equal """"""
81+
82+
[<Fact>]
83+
let ``Should produce self-closing tag fragment from XmlNode and id`` () =
84+
let doc =
85+
_div [] [
86+
_img [ _id_ "img-1"; _src_ "foo.png" ]
87+
]
88+
renderFragment doc "img-1" |> should equal """<img id="img-1" src="foo.png" />"""
89+
90+
[<Fact>]
91+
let ``Should produce fragment when id is on root node`` () =
92+
let doc =
93+
_div [ _id_ "root" ] [
94+
_span [] [ _text "child" ]
95+
]
96+
renderFragment doc "root" |> should equal """<div id="root"><span>child</span></div>"""
5197

5298
[<Fact>]
5399
let ``_control should render label with nested input`` () =
@@ -63,8 +109,8 @@ let ``_control should render label with nested input`` () =
63109

64110
[<Fact>]
65111
let ``Should create valid html button`` () =
66-
let doc = _button [ _onclick_ "console.log(\"test\")"] [ _text "click me" ]
67-
renderNode doc |> should equal "<button onclick=\"console.log(\"test\")\">click me</button>";
112+
let doc = _button [ _onclick_ """console.log("test")""" ] [ _text "click me" ]
113+
renderNode doc |> should equal """<button onclick="console.log("test")">click me</button>""";
68114

69115
[<Fact>]
70116
let ``Should produce valid xml doc`` () =
@@ -75,7 +121,7 @@ let ``Should produce valid xml doc`` () =
75121
]
76122
]
77123

78-
renderXml doc |> should equal "<?xml version=\"1.0\" encoding=\"UTF-8\"?><books><book><name>To Kill A Mockingbird</name></book></books>"
124+
renderXml doc |> should equal """<?xml version="1.0" encoding="UTF-8"?><books><book><name>To Kill A Mockingbird</name></book></books>"""
79125

80126
type Product =
81127
{ Name : string

0 commit comments

Comments
 (0)