1
0
This commit is contained in:
Arpad Ryszka 2025-09-11 20:50:00 +02:00
commit 1df0dbe178
30 changed files with 2078 additions and 0 deletions

223
.cover Normal file
View File

@ -0,0 +1,223 @@
mode: set
code.squareroundforest.org/arpio/html/eq.go:3.31,4.26 1 1
code.squareroundforest.org/arpio/html/eq.go:4.26,6.3 1 0
code.squareroundforest.org/arpio/html/eq.go:8.2,9.24 2 1
code.squareroundforest.org/arpio/html/eq.go:9.24,11.3 1 0
code.squareroundforest.org/arpio/html/eq.go:13.2,13.23 1 1
code.squareroundforest.org/arpio/html/eq.go:13.23,16.22 3 0
code.squareroundforest.org/arpio/html/eq.go:16.22,18.4 1 0
code.squareroundforest.org/arpio/html/eq.go:21.2,22.24 2 1
code.squareroundforest.org/arpio/html/eq.go:22.24,24.3 1 0
code.squareroundforest.org/arpio/html/eq.go:26.2,26.20 1 1
code.squareroundforest.org/arpio/html/eq.go:26.20,29.17 3 0
code.squareroundforest.org/arpio/html/eq.go:29.17,31.4 1 0
code.squareroundforest.org/arpio/html/eq.go:33.3,33.27 1 0
code.squareroundforest.org/arpio/html/eq.go:33.27,35.4 1 0
code.squareroundforest.org/arpio/html/eq.go:37.3,37.11 1 0
code.squareroundforest.org/arpio/html/eq.go:37.11,38.12 1 0
code.squareroundforest.org/arpio/html/eq.go:41.3,41.21 1 0
code.squareroundforest.org/arpio/html/eq.go:41.21,43.4 1 0
code.squareroundforest.org/arpio/html/eq.go:46.2,46.13 1 1
code.squareroundforest.org/arpio/html/eq.go:49.28,50.16 1 1
code.squareroundforest.org/arpio/html/eq.go:50.16,52.3 1 1
code.squareroundforest.org/arpio/html/eq.go:54.2,54.22 1 1
code.squareroundforest.org/arpio/html/eq.go:54.22,56.3 1 0
code.squareroundforest.org/arpio/html/eq.go:58.2,58.21 1 1
code.squareroundforest.org/arpio/html/lib.go:23.73,27.2 3 1
code.squareroundforest.org/arpio/html/lib.go:29.32,33.2 3 1
code.squareroundforest.org/arpio/html/lib.go:35.42,39.35 4 1
code.squareroundforest.org/arpio/html/lib.go:39.35,41.3 1 1
code.squareroundforest.org/arpio/html/lib.go:43.2,43.10 1 1
code.squareroundforest.org/arpio/html/lib.go:46.56,50.2 3 1
code.squareroundforest.org/arpio/html/lib.go:52.55,58.2 5 0
code.squareroundforest.org/arpio/html/lib.go:60.35,66.2 5 1
code.squareroundforest.org/arpio/html/lib.go:70.32,71.19 1 1
code.squareroundforest.org/arpio/html/lib.go:71.19,73.3 1 0
code.squareroundforest.org/arpio/html/lib.go:75.2,76.33 2 1
code.squareroundforest.org/arpio/html/lib.go:76.33,78.3 1 1
code.squareroundforest.org/arpio/html/lib.go:80.2,80.11 1 1
code.squareroundforest.org/arpio/html/lib.go:84.48,85.33 1 1
code.squareroundforest.org/arpio/html/lib.go:85.33,87.3 1 1
code.squareroundforest.org/arpio/html/lib.go:89.2,89.40 1 1
code.squareroundforest.org/arpio/html/lib.go:89.40,91.3 1 1
code.squareroundforest.org/arpio/html/lib.go:95.29,97.2 1 1
code.squareroundforest.org/arpio/html/lib.go:100.42,102.2 1 1
code.squareroundforest.org/arpio/html/lib.go:105.47,108.2 2 1
code.squareroundforest.org/arpio/html/lib.go:111.62,113.2 1 0
code.squareroundforest.org/arpio/html/lib.go:117.54,119.2 1 0
code.squareroundforest.org/arpio/html/lib.go:122.30,124.2 1 0
code.squareroundforest.org/arpio/html/lib.go:127.48,129.2 1 0
code.squareroundforest.org/arpio/html/lib.go:132.48,134.19 2 0
code.squareroundforest.org/arpio/html/lib.go:134.19,136.3 1 0
code.squareroundforest.org/arpio/html/lib.go:138.2,138.40 1 0
code.squareroundforest.org/arpio/html/lib.go:142.51,147.24 4 0
code.squareroundforest.org/arpio/html/lib.go:147.24,148.18 1 0
code.squareroundforest.org/arpio/html/lib.go:148.18,150.4 1 0
code.squareroundforest.org/arpio/html/lib.go:153.2,153.44 1 0
code.squareroundforest.org/arpio/html/lib.go:157.32,159.2 1 1
code.squareroundforest.org/arpio/html/lib.go:164.78,166.2 1 1
code.squareroundforest.org/arpio/html/lib.go:169.45,171.2 1 1
code.squareroundforest.org/arpio/html/lib.go:176.34,178.2 1 1
code.squareroundforest.org/arpio/html/lib.go:181.39,183.2 1 1
code.squareroundforest.org/arpio/html/lib.go:187.32,189.2 1 1
code.squareroundforest.org/arpio/html/lib.go:193.30,195.2 1 1
code.squareroundforest.org/arpio/html/lib.go:199.28,201.19 2 1
code.squareroundforest.org/arpio/html/lib.go:201.19,203.3 1 1
code.squareroundforest.org/arpio/html/lib.go:205.2,205.18 1 1
code.squareroundforest.org/arpio/html/query.go:29.66,36.23 2 1
code.squareroundforest.org/arpio/html/query.go:36.23,37.36 1 1
code.squareroundforest.org/arpio/html/query.go:37.36,39.12 2 1
code.squareroundforest.org/arpio/html/query.go:42.3,42.38 1 1
code.squareroundforest.org/arpio/html/query.go:42.38,44.12 2 1
code.squareroundforest.org/arpio/html/query.go:47.3,47.22 1 1
code.squareroundforest.org/arpio/html/query.go:50.2,50.18 1 1
code.squareroundforest.org/arpio/html/query.go:53.42,55.17 2 1
code.squareroundforest.org/arpio/html/query.go:55.17,57.3 1 1
code.squareroundforest.org/arpio/html/query.go:59.2,60.23 2 1
code.squareroundforest.org/arpio/html/query.go:60.23,61.31 1 1
code.squareroundforest.org/arpio/html/query.go:61.31,63.4 1 1
code.squareroundforest.org/arpio/html/query.go:66.2,66.11 1 1
code.squareroundforest.org/arpio/html/query.go:69.57,71.35 2 1
code.squareroundforest.org/arpio/html/query.go:71.35,73.9 2 1
code.squareroundforest.org/arpio/html/query.go:73.9,75.4 1 1
code.squareroundforest.org/arpio/html/query.go:78.2,78.18 1 1
code.squareroundforest.org/arpio/html/query.go:81.52,82.24 1 1
code.squareroundforest.org/arpio/html/query.go:82.24,84.3 1 1
code.squareroundforest.org/arpio/html/query.go:86.2,88.41 3 1
code.squareroundforest.org/arpio/html/query.go:88.41,91.3 2 1
code.squareroundforest.org/arpio/html/query.go:93.2,93.47 1 1
code.squareroundforest.org/arpio/html/query.go:93.47,96.3 2 1
code.squareroundforest.org/arpio/html/query.go:98.2,98.46 1 1
code.squareroundforest.org/arpio/html/query.go:98.46,101.3 2 1
code.squareroundforest.org/arpio/html/query.go:103.2,103.45 1 1
code.squareroundforest.org/arpio/html/query.go:103.45,106.3 2 1
code.squareroundforest.org/arpio/html/query.go:108.2,108.49 1 1
code.squareroundforest.org/arpio/html/query.go:108.49,111.3 2 1
code.squareroundforest.org/arpio/html/query.go:113.2,113.41 1 1
code.squareroundforest.org/arpio/html/query.go:113.41,116.3 2 1
code.squareroundforest.org/arpio/html/query.go:118.2,118.40 1 1
code.squareroundforest.org/arpio/html/query.go:118.40,119.50 1 1
code.squareroundforest.org/arpio/html/query.go:119.50,122.4 2 1
code.squareroundforest.org/arpio/html/query.go:124.3,125.14 2 1
code.squareroundforest.org/arpio/html/query.go:128.2,128.14 1 1
code.squareroundforest.org/arpio/html/render.go:26.58,28.26 2 1
code.squareroundforest.org/arpio/html/render.go:28.26,33.3 4 1
code.squareroundforest.org/arpio/html/render.go:35.2,35.11 1 1
code.squareroundforest.org/arpio/html/render.go:38.43,41.19 3 1
code.squareroundforest.org/arpio/html/render.go:41.19,42.15 1 1
code.squareroundforest.org/arpio/html/render.go:43.12,44.40 1 1
code.squareroundforest.org/arpio/html/render.go:45.12,46.39 1 1
code.squareroundforest.org/arpio/html/render.go:47.11,48.25 1 1
code.squareroundforest.org/arpio/html/render.go:52.2,52.19 1 1
code.squareroundforest.org/arpio/html/render.go:55.34,62.19 3 1
code.squareroundforest.org/arpio/html/render.go:62.19,63.15 1 1
code.squareroundforest.org/arpio/html/render.go:64.12,65.38 1 1
code.squareroundforest.org/arpio/html/render.go:66.12,67.38 1 1
code.squareroundforest.org/arpio/html/render.go:68.12,69.39 1 1
code.squareroundforest.org/arpio/html/render.go:70.18,71.25 1 1
code.squareroundforest.org/arpio/html/render.go:71.25,73.5 1 1
code.squareroundforest.org/arpio/html/render.go:73.10,73.21 1 1
code.squareroundforest.org/arpio/html/render.go:73.21,75.5 1 1
code.squareroundforest.org/arpio/html/render.go:75.10,77.5 1 1
code.squareroundforest.org/arpio/html/render.go:78.11,79.25 1 1
code.squareroundforest.org/arpio/html/render.go:82.3,84.14 3 1
code.squareroundforest.org/arpio/html/render.go:87.2,87.19 1 1
code.squareroundforest.org/arpio/html/render.go:90.55,91.18 1 1
code.squareroundforest.org/arpio/html/render.go:91.18,93.3 1 1
code.squareroundforest.org/arpio/html/render.go:95.2,95.37 1 1
code.squareroundforest.org/arpio/html/render.go:95.37,96.19 1 1
code.squareroundforest.org/arpio/html/render.go:96.19,98.4 1 1
code.squareroundforest.org/arpio/html/render.go:100.3,101.19 2 1
code.squareroundforest.org/arpio/html/render.go:101.19,103.4 1 1
code.squareroundforest.org/arpio/html/render.go:106.2,108.27 3 1
code.squareroundforest.org/arpio/html/render.go:108.27,110.3 1 1
code.squareroundforest.org/arpio/html/render.go:112.2,113.23 2 1
code.squareroundforest.org/arpio/html/render.go:113.23,114.31 1 1
code.squareroundforest.org/arpio/html/render.go:114.31,116.4 1 1
code.squareroundforest.org/arpio/html/render.go:119.2,120.48 2 1
code.squareroundforest.org/arpio/html/render.go:120.48,122.3 1 1
code.squareroundforest.org/arpio/html/render.go:124.2,124.13 1 1
code.squareroundforest.org/arpio/html/render.go:124.13,126.3 1 0
code.squareroundforest.org/arpio/html/render.go:128.2,129.20 2 1
code.squareroundforest.org/arpio/html/render.go:129.20,131.3 1 1
code.squareroundforest.org/arpio/html/render.go:133.2,133.23 1 1
code.squareroundforest.org/arpio/html/render.go:133.23,134.34 1 1
code.squareroundforest.org/arpio/html/render.go:134.34,138.77 4 1
code.squareroundforest.org/arpio/html/render.go:138.77,140.15 2 1
code.squareroundforest.org/arpio/html/render.go:140.15,142.6 1 1
code.squareroundforest.org/arpio/html/render.go:144.5,145.59 2 1
code.squareroundforest.org/arpio/html/render.go:145.59,148.6 2 1
code.squareroundforest.org/arpio/html/render.go:150.5,150.40 1 0
code.squareroundforest.org/arpio/html/render.go:153.4,153.46 1 1
code.squareroundforest.org/arpio/html/render.go:153.46,155.5 1 0
code.squareroundforest.org/arpio/html/render.go:157.4,160.36 4 1
code.squareroundforest.org/arpio/html/render.go:160.36,162.5 1 1
code.squareroundforest.org/arpio/html/render.go:164.4,165.12 2 1
code.squareroundforest.org/arpio/html/render.go:168.3,169.14 2 1
code.squareroundforest.org/arpio/html/render.go:169.14,170.12 1 0
code.squareroundforest.org/arpio/html/render.go:173.3,173.33 1 1
code.squareroundforest.org/arpio/html/render.go:173.33,175.4 1 1
code.squareroundforest.org/arpio/html/render.go:177.3,177.21 1 1
code.squareroundforest.org/arpio/html/render.go:177.21,179.12 2 1
code.squareroundforest.org/arpio/html/render.go:182.3,182.30 1 1
code.squareroundforest.org/arpio/html/render.go:185.2,185.46 1 1
code.squareroundforest.org/arpio/html/render.go:185.46,187.13 2 1
code.squareroundforest.org/arpio/html/render.go:187.13,189.4 1 1
code.squareroundforest.org/arpio/html/render.go:191.3,192.31 2 1
code.squareroundforest.org/arpio/html/render.go:192.31,194.4 1 1
code.squareroundforest.org/arpio/html/render.go:196.3,197.57 2 1
code.squareroundforest.org/arpio/html/render.go:197.57,200.4 2 1
code.squareroundforest.org/arpio/html/render.go:202.3,202.17 1 1
code.squareroundforest.org/arpio/html/render.go:202.17,204.4 1 1
code.squareroundforest.org/arpio/html/render.go:207.2,208.34 2 1
code.squareroundforest.org/arpio/html/render.go:208.34,210.3 1 1
code.squareroundforest.org/arpio/html/validate.go:14.37,15.31 1 1
code.squareroundforest.org/arpio/html/validate.go:15.31,17.3 1 1
code.squareroundforest.org/arpio/html/validate.go:19.2,19.12 1 1
code.squareroundforest.org/arpio/html/validate.go:22.41,24.2 1 1
code.squareroundforest.org/arpio/html/validate.go:26.47,28.2 1 1
code.squareroundforest.org/arpio/html/validate.go:30.50,31.46 1 1
code.squareroundforest.org/arpio/html/validate.go:31.46,33.3 1 1
code.squareroundforest.org/arpio/html/validate.go:35.2,37.23 3 1
code.squareroundforest.org/arpio/html/validate.go:37.23,38.24 1 1
code.squareroundforest.org/arpio/html/validate.go:38.24,39.54 1 1
code.squareroundforest.org/arpio/html/validate.go:39.54,41.5 1 1
code.squareroundforest.org/arpio/html/validate.go:45.2,45.27 1 1
code.squareroundforest.org/arpio/html/validate.go:45.27,47.3 1 1
code.squareroundforest.org/arpio/html/validate.go:49.2,49.23 1 1
code.squareroundforest.org/arpio/html/validate.go:49.23,50.34 1 1
code.squareroundforest.org/arpio/html/validate.go:50.34,51.32 1 1
code.squareroundforest.org/arpio/html/validate.go:51.32,53.5 1 1
code.squareroundforest.org/arpio/html/validate.go:55.4,57.20 3 1
code.squareroundforest.org/arpio/html/validate.go:57.20,59.5 1 1
code.squareroundforest.org/arpio/html/validate.go:61.4,61.12 1 1
code.squareroundforest.org/arpio/html/validate.go:65.2,65.12 1 1
code.squareroundforest.org/arpio/html/wrap.go:8.40,15.6 2 1
code.squareroundforest.org/arpio/html/wrap.go:15.6,17.17 2 1
code.squareroundforest.org/arpio/html/wrap.go:17.17,18.9 1 1
code.squareroundforest.org/arpio/html/wrap.go:21.3,21.35 1 1
code.squareroundforest.org/arpio/html/wrap.go:21.35,22.12 1 1
code.squareroundforest.org/arpio/html/wrap.go:25.3,25.35 1 1
code.squareroundforest.org/arpio/html/wrap.go:25.35,26.28 1 1
code.squareroundforest.org/arpio/html/wrap.go:26.28,28.5 1 1
code.squareroundforest.org/arpio/html/wrap.go:30.4,30.12 1 1
code.squareroundforest.org/arpio/html/wrap.go:33.3,34.40 2 1
code.squareroundforest.org/arpio/html/wrap.go:37.2,37.26 1 1
code.squareroundforest.org/arpio/html/wrap.go:37.26,39.3 1 1
code.squareroundforest.org/arpio/html/wrap.go:41.2,41.14 1 1
code.squareroundforest.org/arpio/html/wrap.go:44.71,52.26 3 1
code.squareroundforest.org/arpio/html/wrap.go:52.26,53.22 1 1
code.squareroundforest.org/arpio/html/wrap.go:53.22,55.4 1 1
code.squareroundforest.org/arpio/html/wrap.go:57.3,58.50 2 1
code.squareroundforest.org/arpio/html/wrap.go:58.50,62.12 4 1
code.squareroundforest.org/arpio/html/wrap.go:65.3,65.39 1 1
code.squareroundforest.org/arpio/html/wrap.go:68.2,68.26 1 1
code.squareroundforest.org/arpio/html/wrap.go:68.26,70.3 1 1
code.squareroundforest.org/arpio/html/wrap.go:72.2,73.26 2 1
code.squareroundforest.org/arpio/html/wrap.go:73.26,74.12 1 1
code.squareroundforest.org/arpio/html/wrap.go:74.12,76.4 1 1
code.squareroundforest.org/arpio/html/wrap.go:78.3,79.23 2 1
code.squareroundforest.org/arpio/html/wrap.go:79.23,80.13 1 1
code.squareroundforest.org/arpio/html/wrap.go:80.13,82.5 1 1
code.squareroundforest.org/arpio/html/wrap.go:84.4,84.22 1 1
code.squareroundforest.org/arpio/html/wrap.go:88.2,88.12 1 1

49
Makefile Normal file
View File

@ -0,0 +1,49 @@
SOURCES = $(shell find . -name "*.go" | grep -v [.]gen[.]go)
default: build
all: clean fmt build cover
build: $(SOURCES) tags promote-to-tags
go build
tags: tags/block.gen.go tags/inline.gen.go tags/void.block.gen.go tags/void.inline.gen.go tags/script.gen.go
promote-to-tags: tags/promote.gen.go
tags/block.gen.go: $(SOURCES) tags.block.txt
go run script/generate-tags.go < tags.block.txt > tags/block.gen.go
tags/inline.gen.go: $(SOURCES) tags.inline.txt
go run script/generate-tags.go Inline < tags.inline.txt > tags/inline.gen.go
tags/void.block.gen.go: $(SOURCES) tags.void.block.txt
go run script/generate-tags.go Void < tags.void.block.txt > tags/void.block.gen.go
tags/void.inline.gen.go: $(SOURCES) tags.void.inline.txt
go run script/generate-tags.go Void Inline < tags.void.inline.txt > tags/void.inline.gen.go
tags/script.gen.go: $(SOURCES) tags.script.txt
go run script/generate-tags.go ScriptContent < tags.script.txt > tags/script.gen.go
tags/promote.gen.go: $(SOURCES) promote-to-tags.txt
go run script/promote-to-tags.go < promote-to-tags.txt > tags/promote.gen.go
fmt: $(SOURCES) tags
go fmt ./...
check: $(SOURCES) tags promote-to-tags
go test -count 1
.cover: $(SOURCES) tags promote-to-tags
go test -count 1 -coverprofile .cover
cover: .cover
go tool cover -func .cover
showcover: .cover
go tool cover -html .cover
clean:
go clean
rm -f tags/*.gen.go

59
eq.go Normal file
View File

@ -0,0 +1,59 @@
package html
func eq2(t1, t2 Tag) bool {
if Name(t1) != Name(t2) {
return false
}
a1, a2 := AllAttributes(t1), AllAttributes(t2)
if len(a1) != len(a2) {
return false
}
for name := range a1 {
v1 := a1[name]
v2, ok := a2[name]
if !ok || v1 != v2 {
return false
}
}
c1, c2 := Children(t1), Children(t2)
if len(c1) != len(c2) {
return false
}
for i := range c1 {
ct1, ok1 := c1[i].(Tag)
ct2, ok2 := c2[i].(Tag)
if ok1 != ok2 {
return false
}
if ok1 && !Eq(ct1, ct2) {
return false
}
if !ok1 {
continue
}
if c1[i] != c2[i] {
return false
}
}
return true
}
func eq(t ...Tag) bool {
if len(t) < 2 {
return true
}
if !eq2(t[0], t[1]) {
return false
}
return eq(t[1:]...)
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module code.squareroundforest.org/arpio/html
go 1.25.0
require code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 // indirect

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I=
code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY=

220
lib.go Normal file
View File

@ -0,0 +1,220 @@
// Package html provides functions for programmatically composing and rendering HTML.
package html
import (
"fmt"
"io"
"strings"
)
// when composing html, the Attr convenience function is recommended to construct input attributes
type Attributes map[string]string
// immutable
// calling creates a new copy with the passed in attributes and child nodes applied only to the copy
// input parameters
// rendering of child nodes
// instances of tags can be used to create further tags with extended set of attributes and child tags
// builtin tags in the tags sub-package
// custom tags are supported via the NewTag constructor. Functions with the same signature, but created by other
// means, will not be rendered, unless they return a tag created by NewTag() or a builtin tag
type Tag func(...any) Tag
type Template[Data any] func(Data) Tag
// convenience function primarily aimed to help with construction of html with tags
// the names and values are applied using fmt.Sprint, tolerating fmt.Stringer implementations
func Attr(a ...any) Attributes {
if len(a)%2 != 0 {
a = append(a, "")
}
am := make(Attributes)
for i := 0; i < len(a); i += 2 {
am[fmt.Sprint(a[i])] = fmt.Sprint(a[i+1])
}
return am
}
// defines a new tag with name and initial attributes and child nodes
func NewTag(name string, children ...any) Tag {
if handleQuery(name, children) {
children = children[:len(children)-1]
}
return func(children1 ...any) Tag {
if name == "br" {
}
return NewTag(name, append(children, children1...)...)
}
}
// returns the name of a tag
func Name(t Tag) string {
q := nameQuery{}
t()(&q)
return q.value
}
// returns all attributes of a tag
func AllAttributes(t Tag) Attributes {
q := attributesQuery{}
t()(&q)
a := make(Attributes)
for name, value := range q.value {
a[name] = value
}
return a
}
// returns the value of a named attribute if exists, empty string otherwise
func Attribute(t Tag, name string) string {
q := attributeQuery{name: name}
t()(&q)
return q.value
}
// creates a new tag with all the existing attributes and child nodes of the input tag, and the new attribute value
func SetAttribute(t Tag, name string, value any) Tag {
return t()(Attr(name, value))
}
// creates a new tag with all the existing attributes and child nodes of the input tag, except the attribute to
// be deleted
func DeleteAttribute(t Tag, name string) Tag {
n := Name(t)
a := AllAttributes(t)
c := Children(t)
delete(a, name)
return NewTag(n, append(c, a)...)
}
// the same as Attribute(t, "class")
func Class(t Tag) string {
return Attribute(t, "class")
}
// the same as SetAttribute(t, "class", class)
func SetClass(t Tag, class string) Tag {
return SetAttribute(t, "class", class)
}
// like SetClass, but it appends the new class to the existing classes, regardless if the same class exists
func AddClass(t Tag, class string) Tag {
current := Class(t)
if current != "" {
class = fmt.Sprintf("%s %s", current, class)
}
return SetClass(t, class)
}
// like DeleteAttribute, but it only deletes the specified class from the class attribute
func DeleteClass(t Tag, class string) Tag {
c := Class(t)
cc := strings.Split(c, " ")
var ccc []string
for _, ci := range cc {
if ci != class {
ccc = append(ccc, ci)
}
}
return SetClass(t, strings.Join(ccc, " "))
}
// returns the child nodes of a tag
func Children(t Tag) []any {
var q childrenQuery
t()(&q)
c := make([]any, len(q.value))
copy(c, q.value)
return c
}
// renders html with t as the root node with indentation
// child nodes are rendered via fmt.Sprint, tolerating fmt.Stringer implementations
// consecutive spaces are considered to be so on purpose, and are converted into &nbsp;
// spaces around tags can behave different from when using unindented rendering
// as a last resort, one can use rendered html inside a verbatim tag
func RenderIndent(out io.Writer, indent string, pwidth int, t Tag) error {
r := renderer{out: out, indent: indent, pwidth: pwidth}
t()(&r)
return r.err
}
// renders html with t as the root node without indentation
func Render(out io.Writer, t Tag) error {
return RenderIndent(out, "", 0, t)
}
// creates a new tag from t marking it verbatim. The content of verbatim tags is rendered without HTML escaping.
// This may cause security issues when using it in an incosiderate way. The tag can contain non-tag child nodes.
// Verbatim content gets indented when rendering with indentation
func Verbatim(t Tag) Tag {
return t()(renderGuide{verbatim: true})
}
// marks a tag as script-style content for rendering. Script-style content is not escaped and not indented
func ScriptContent(t Tag) Tag {
return t()(renderGuide{script: true})
}
// inline tags are not broken into separate lines when rendering with indentation
// deprecated in HTML, but only used for indentation
func Inline(t Tag) Tag {
return t()(renderGuide{inline: true})
}
// void tags do not accept child nodes
// deprecated in HTML, but only used for indentation
func Void(t Tag) Tag {
return t()(renderGuide{void: true})
}
// same name, same attributes, same child tags, child nodes in the same order and equal by reference or value
// depending on the child node type
func Eq(t ...Tag) bool {
tt := make([]Tag, len(t))
for i := range t {
tt[i] = t[i]()
}
return eq(tt...)
}
// turns a template into a tag for composition
func FromTemplate[Data any](f Template[Data]) Tag {
return func(a ...any) Tag {
var (
t Data
ok bool
)
for i := range a {
t, ok = a[0].(Data)
if !ok {
continue
}
a = append(a[:i], a[i+1:]...)
break
}
return f(t)(a...)
}
}
// in the functional programming sense
func Map(data []any, tag Tag) []Tag {
var tags []Tag
for _, d := range data {
tags = append(tags, tag(d))
}
return tags
}

101
lib_test.go Normal file
View File

@ -0,0 +1,101 @@
package html_test
import (
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"testing"
)
func TestLib(t *testing.T) {
t.Run("templated tag", func(t *testing.T) {
type (
member struct {
name string
level int
}
team struct {
name string
rank int
members []member
}
)
memberHTML := html.FromTemplate(
func(m member) Tag {
return Li(
Div("Name: ", m.name),
Div("Level: ", m.level),
)
},
)
teamHTML := html.FromTemplate(
func(t team) Tag {
return Div(
H3(t.name),
P("Rank: ", t.rank),
Ul(html.Map(t.members, memberHTML)...),
)
},
)
myTeam := team{
name: "Foo",
rank: 3,
members: []member{{
name: "Bar",
level: 4,
}, {
name: "Baz",
level: 1,
}, {
name: "Qux",
level: 4,
}},
}
var b bytes.Buffer
if html.RenderIndent(&b, "\t", 0, teamHTML(myTeam)); err != nil {
t.Fatal(err)
}
if b.String() != `<div>
<h3>
Foo
</h3>
<p>
Rank: 3
</p>
<ul>
<li>
<div>
Name: Bar
</div>
<div>
Level: 4
</div>
</li>
<li>
<div>
Name: Baz
</div>
<div>
Level: 1
</div>
</li>
<li>
<div>
Name: Qux
</div>
<div>
Level: 4
</div>
</li>
</ul>
</div>
` {
t.Fatal(b.String())
}
})
}

5
notes.txt Normal file
View File

@ -0,0 +1,5 @@
rendering types:
like <br>: inline void
inline <hr>: block void
like script: no escaping
split the validation from the rendering

2
promote-to-tags.txt Normal file
View File

@ -0,0 +1,2 @@
Attr
NewTag

129
query.go Normal file
View File

@ -0,0 +1,129 @@
package html
type nameQuery struct {
value string
}
type attributesQuery struct {
value Attributes
}
type attributeQuery struct {
name string
value string
found bool
}
type childrenQuery struct {
value []any
}
type validator struct {
err error
}
type renderGuidesQuery struct {
value []renderGuide
}
func groupChildren(c []any) ([]Attributes, []any, []renderGuide) {
var (
a []Attributes
cc []any
rg []renderGuide
)
for _, ci := range c {
if ai, ok := ci.(Attributes); ok {
a = append(a, ai)
continue
}
if rgi, ok := ci.(renderGuide); ok {
rg = append(rg, rgi)
continue
}
cc = append(cc, ci)
}
return a, cc, rg
}
func mergeAttributes(c []any) Attributes {
a, _, _ := groupChildren(c)
if len(a) == 0 {
return nil
}
to := make(Attributes)
for _, ai := range a {
for name, value := range ai {
to[name] = value
}
}
return to
}
func findAttribute(c []any, name string) (string, bool) {
a, _, _ := groupChildren(c)
for i := len(a) - 1; i >= 0; i-- {
value, ok := a[i][name]
if ok {
return value, true
}
}
return "", false
}
func handleQuery(name string, children []any) bool {
if len(children) == 0 {
return false
}
last := len(children) - 1
lastChild := children[last]
if q, ok := lastChild.(*nameQuery); ok {
q.value = name
return true
}
if q, ok := lastChild.(*attributesQuery); ok {
q.value = mergeAttributes(children[:last])
return true
}
if q, ok := lastChild.(*attributeQuery); ok {
q.value, q.found = findAttribute(children[:last], q.name)
return true
}
if q, ok := lastChild.(*childrenQuery); ok {
_, q.value, _ = groupChildren(children[:last])
return true
}
if q, ok := lastChild.(*renderGuidesQuery); ok {
_, _, q.value = groupChildren(children[:last])
return true
}
if v, ok := lastChild.(*validator); ok {
v.err = validate(name, children[:last])
return true
}
if r, ok := lastChild.(*renderer); ok {
if err := validate(name, children[:last]); err != nil {
r.err = err
return true
}
render(r, name, children[:last])
return true
}
return false
}

150
query_test.go Normal file
View File

@ -0,0 +1,150 @@
package html_test
import (
"bytes"
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"testing"
)
func TestQuery(t *testing.T) {
t.Run("group children", func(t *testing.T) {
inlineDiv := html.Inline(Div(Attr("foo", "bar"), "baz"))
attr := html.AllAttributes(inlineDiv)
if len(attr) != 1 || attr["foo"] != "bar" {
t.Fatal()
}
c := html.Children(inlineDiv)
if len(c) != 1 || c[0] != "baz" {
t.Fatal()
}
var b bytes.Buffer
if err := html.Render(&b, inlineDiv); err != nil {
t.Fatal(err)
}
h := b.String()
if h != `<div foo="bar">baz</div>` {
t.Fatal()
}
})
t.Run("merge attributes", func(t *testing.T) {
t.Run("has attributes", func(t *testing.T) {
div := Div(Attr("foo", "bar"))
attr := html.AllAttributes(div)
if len(attr) != 1 || attr["foo"] != "bar" {
t.Fatal()
}
})
t.Run("no attributes", func(t *testing.T) {
div := Div()
attr := html.AllAttributes(div)
if len(attr) != 0 {
t.Fatal()
}
})
})
t.Run("find attributes", func(t *testing.T) {
t.Run("exists", func(t *testing.T) {
div := Div(Attr("foo", "bar"))
if html.Attribute(div, "foo") != "bar" {
t.Fatal()
}
})
t.Run("does not exist", func(t *testing.T) {
div := Div(Attr("foo", "bar"))
if html.Attribute(div, "qux") != "" {
t.Fatal()
}
})
})
t.Run("handle query", func(t *testing.T) {
t.Run("no chlidren", func(t *testing.T) {
div := Div()
div2 := div()
if html.Name(div2) != "div" || !html.Eq(div, div2) {
t.Fatal(html.Name(div2))
}
})
t.Run("name", func(t *testing.T) {
div := Div()
if html.Name(div) != "div" {
t.Fatal()
}
})
t.Run("all attributes", func(t *testing.T) {
div := Div(Attr("foo", "bar", "baz", "qux"))
attr := html.AllAttributes(div)
if len(attr) != 2 || attr["foo"] != "bar" || attr["baz"] != "qux" {
t.Fatal()
}
})
t.Run("one attribute", func(t *testing.T) {
div := Div(Attr("foo", "bar", "baz", "qux"))
foo := html.Attribute(div, "foo")
if foo != "bar" {
t.Fatal()
}
})
t.Run("children", func(t *testing.T) {
div := Div("foo", "bar", "baz")
c := html.Children(div)
if len(c) != 3 || c[0] != "foo" || c[1] != "bar" || c[2] != "baz" {
t.Fatal()
}
})
t.Run("render guides", func(t *testing.T) {
div := Div(Span("foo"))
var b bytes.Buffer
if err := html.RenderIndent(&b, "\t", 0, div); err != nil {
t.Fatal(err)
}
if b.String() != "<div>\n\t<span>foo</span>\n</div>\n" {
t.Fatal(b.String())
}
})
t.Run("validate and render", func(t *testing.T) {
t.Run("valid", func(t *testing.T) {
script := Script(`function() { return "Hello, world!" }`)
var b bytes.Buffer
if err := html.Render(&b, script); err != nil {
t.Fatal(err)
}
})
t.Run("invalid", func(t *testing.T) {
div := Div(Attr("foo+", "bar"))
var b bytes.Buffer
if err := html.Render(&b, div); err == nil {
t.Fatal()
}
})
t.Run("invalid child", func(t *testing.T) {
div := Div(Div(Attr("foo+", "bar")))
var b bytes.Buffer
if err := html.Render(&b, div); err == nil {
t.Fatal()
}
})
})
})
}

250
render.go Normal file
View File

@ -0,0 +1,250 @@
package html
import (
"bytes"
"fmt"
"io"
)
const defaultPWidth = 112
type renderGuide struct {
inline bool
void bool
script bool
verbatim bool
}
type renderer struct {
out io.Writer
indent string
pwidth int
currentIndent string
err error
}
func mergeRenderingGuides(rgs []renderGuide) renderGuide {
var rg renderGuide
for _, rgi := range rgs {
rg.inline = rg.inline || rgi.inline
rg.void = rg.void || rgi.void
rg.script = rg.script || rgi.script
rg.verbatim = rg.verbatim || rgi.verbatim
}
return rg
}
func attributeEscape(value string) string {
var rr []rune
r := []rune(value)
for i := range r {
switch r[i] {
case '"':
rr = append(rr, []rune("&quot;")...)
case '&':
rr = append(rr, []rune("&amp;")...)
default:
rr = append(rr, r[i])
}
}
return string(rr)
}
func htmlEscape(s string) string {
var (
rr []rune
lastWS, wsStart bool
)
r := []rune(s)
for i := range r {
switch r[i] {
case '<':
rr = append(rr, []rune("&lt;")...)
case '>':
rr = append(rr, []rune("&gt;")...)
case '&':
rr = append(rr, []rune("&amp;")...)
case ' ', 0xA0:
if wsStart && lastWS {
rr = append(rr[:len(rr)-1], []rune("&nbsp;&nbsp;")...)
} else if lastWS {
rr = append(rr, []rune("&nbsp;")...)
} else {
rr = append(rr, r[i])
}
default:
rr = append(rr, r[i])
}
ws := r[i] == ' ' || r[i] == 0xA0
wsStart = ws && !lastWS
lastWS = ws
}
return string(rr)
}
func render(r *renderer, name string, children []any) {
if r.err != nil {
return
}
printf := func(f string, a ...any) {
if r.err != nil {
return
}
_, r.err = fmt.Fprintf(r.out, f, a...)
if r.err != nil {
r.err = fmt.Errorf("tag %s: %w", name, r.err)
}
}
a, c, rgs := groupChildren(children)
rg := mergeRenderingGuides(rgs)
printf(r.currentIndent)
printf("<%s", name)
for _, ai := range a {
for name, value := range ai {
printf(" %s=\"%s\"", name, attributeEscape(value))
}
}
printf(">")
if r.indent != "" && !rg.inline && len(c) > 0 {
printf("\n")
}
if rg.void {
return
}
var inlineBuffer *bytes.Buffer
if r.indent != "" {
inlineBuffer = bytes.NewBuffer(nil)
}
// TODO:
// - avoid rendering an inline buffer into another inline buffer
// - why?
// - or, if inline, just use the inline buffer without indentation
// - check the wrapping again, if it preserves or eliminates the spaces the right way
for i, ci := range c {
if tag, ok := ci.(Tag); ok {
if rg.inline {
var rgq renderGuidesQuery
tag(&rgq)
crg := mergeRenderingGuides(rgq.value)
if r.indent != "" && !crg.inline && inlineBuffer.Len() > 0 {
w := r.pwidth
if w == 0 {
w = defaultPWidth
}
inlineBuffer = wrap(inlineBuffer, w, "")
println(inlineBuffer.String())
if _, err := io.Copy(r.out, inlineBuffer); err != nil {
r.err = err
return
}
inlineBuffer = bytes.NewBuffer(nil)
}
if i > 0 && r.indent != "" && !crg.inline {
printf("\n")
}
rr := new(renderer)
*rr = *r
rr.indent = ""
rr.currentIndent = ""
tag(rr)
} else {
var rgq renderGuidesQuery
tag(&rgq)
crg := mergeRenderingGuides(rgq.value)
if r.indent != "" && !crg.inline && inlineBuffer.Len() > 0 {
w := r.pwidth
if w == 0 {
w = defaultPWidth
}
inlineBuffer = wrap(inlineBuffer, w, r.currentIndent+r.indent)
println(inlineBuffer.String())
if _, err := io.Copy(r.out, inlineBuffer); err != nil {
r.err = err
return
}
inlineBuffer = bytes.NewBuffer(nil)
}
if i > 0 && r.indent != "" && !crg.inline {
printf("\n")
}
rr := new(renderer)
*rr = *r
rr.currentIndent += r.indent
if r.indent != "" && crg.inline {
rr.out = inlineBuffer
}
tag(rr)
}
continue
}
s := fmt.Sprint(ci)
if s == "" {
continue
}
if !rg.verbatim && !rg.script {
s = htmlEscape(s)
}
if r.indent == "" {
printf(s)
continue
}
inlineBuffer.WriteString(s)
}
if r.indent != "" && inlineBuffer.Len() > 0 {
w := r.pwidth
if w == 0 {
w = defaultPWidth
}
var indent string
if !rg.inline && !rg.script {
indent = r.currentIndent + r.indent
}
inlineBuffer = wrap(inlineBuffer, w, indent)
if _, err := io.Copy(r.out, inlineBuffer); err != nil {
r.err = err
return
}
if !rg.inline {
printf("\n")
}
}
if !rg.inline {
printf(r.currentIndent)
}
printf("</%s>", name)
if r.indent != "" && !rg.inline {
printf("\n")
}
}

172
render_test.go Normal file
View File

@ -0,0 +1,172 @@
package html_test
import (
"bytes"
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"errors"
"strings"
"testing"
)
type failingWriter struct {
after int
}
func failWriteAfter(n int) *failingWriter {
return &failingWriter{n}
}
func (w *failingWriter) Write(p []byte) (int, error) {
w.after -= len(p)
if w.after < 0 {
return len(p) + w.after, errors.New("test error")
}
return len(p), nil
}
func TestRender(t *testing.T) {
t.Run("merge render guides", func(t *testing.T) {
foo := html.Inline(html.Verbatim(NewTag("foo")))
foo = foo("<bar><baz></bar>")
var b bytes.Buffer
if err := html.RenderIndent(&b, "\t", 0, foo); err != nil {
t.Fatal(err)
}
if b.String() != "<foo><bar><baz></bar></foo>" {
t.Fatal(b.String())
}
})
t.Run("attribute escaping", func(t *testing.T) {
span := Span(Attr("foo", "bar=\"&\""))
var b bytes.Buffer
if err := html.Render(&b, span); err != nil {
t.Fatal(err)
}
if b.String() != "<span foo=\"bar=&quot;&amp;&quot;\"></span>" {
t.Fatal(b.String())
}
})
t.Run("html escape", func(t *testing.T) {
t.Run("basic escape", func(t *testing.T) {
span := Span("<foo>bar&baz</foo>")
var b bytes.Buffer
if err := html.Render(&b, span); err != nil {
t.Fatal(err)
}
if b.String() != "<span>&lt;foo&gt;bar&amp;baz&lt;/foo&gt;</span>" {
t.Fatal(b.String())
}
})
t.Run("consecutive spaces", func(t *testing.T) {
span := Span("consecutive spaces: \" \"")
var b bytes.Buffer
if err := html.Render(&b, span); err != nil {
t.Fatal(err)
}
if b.String() != "<span>consecutive spaces: \"&nbsp;&nbsp;&nbsp;\"</span>" {
t.Fatal(b.String())
}
})
})
t.Run("write error", func(t *testing.T) {
t.Run("fail immediately", func(t *testing.T) {
div := Div(Span("foo"))
w := failWriteAfter(0)
if err := html.Render(w, div); err == nil || !strings.Contains(err.Error(), "test error") {
t.Fatal()
}
})
t.Run("fail in tag", func(t *testing.T) {
div := Div(Span("foo"))
w := failWriteAfter(6)
if err := html.Render(w, div); err == nil || !strings.Contains(err.Error(), "test error") {
t.Fatal()
}
})
t.Run("partial text children", func(t *testing.T) {
div := Div("foo", Div("bar"), "baz")
w := failWriteAfter(5)
if err := html.RenderIndent(w, "\t", 0, div); err == nil || !strings.Contains(err.Error(), "test error") {
t.Fatal()
}
})
t.Run("text children", func(t *testing.T) {
div := Div("foo", "bar", "baz")
w := failWriteAfter(5)
if err := html.RenderIndent(w, "\t", 0, div); err == nil || !strings.Contains(err.Error(), "test error") {
t.Fatal()
}
})
})
t.Run("indent", func(t *testing.T) {
t.Run("simple tag", func(t *testing.T) {
div := Div(Span("foo"))
var b bytes.Buffer
if err := html.RenderIndent(&b, "\t", 0, div); err != nil {
t.Fatal(err)
}
if b.String() != "<div>\n\t<span>foo</span>\n</div>\n" {
t.Fatal(b.String())
}
})
t.Run("empty tag", func(t *testing.T) {
div := Div(Br())
var b bytes.Buffer
if err := html.RenderIndent(&b, "\t", 0, div); err != nil {
t.Fatal(err)
}
if b.String() != "<div>\n\t<br>\n</div>\n" {
t.Fatal(b.String())
}
})
t.Run("inline fragment between blocks", func(t *testing.T) {
div := Div("foo bar baz", Div("qux quux"), "corge")
var b bytes.Buffer
if err := html.RenderIndent(&b, "\t", 0, div); err != nil {
t.Fatal(err)
}
if b.String() != "<div>\n\tfoo bar baz\n\t<div>\n\t\tqux quux\n\t</div>\n\tcorge\n</div>\n" {
t.Fatal(b.String())
}
})
t.Run("block inside inline", func(t *testing.T) {
div := Div(Span("foo bar baz", Div("qux quux"), "corge"))
var b bytes.Buffer
if err := html.RenderIndent(&b, "XYZ", 0, div); err != nil {
t.Fatal(err)
}
if b.String() != "" {
t.Fatal(b.String())
}
})
})
}

62
script/generate-tags.go Normal file
View File

@ -0,0 +1,62 @@
package main
import (
"fmt"
"io"
"log"
"os"
"strings"
"unicode"
)
func splitAllBy(s string, ss ...string) []string {
var sss []string
for _, si := range ss {
sis := strings.Split(si, s)
for i := range sis {
sis[i] = strings.TrimSpace(sis[i])
}
for _, sisi := range sis {
if sisi != "" {
sss = append(sss, sisi)
}
}
}
return sss
}
func main() {
b, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalln(err)
}
s := string(b)
ss := splitAllBy("\n", s)
ss = splitAllBy(",", ss...)
ss = splitAllBy(" ", ss...)
printf := func(f string, a ...any) {
if err != nil {
return
}
_, err = fmt.Fprintf(os.Stdout, f, a...)
}
printf("// generated by ../script/generate-tags.go\n")
printf("\n")
printf("package tags\n")
printf("import \"code.squareroundforest.org/arpio/html\"\n")
for _, si := range ss {
exp := fmt.Sprintf("html.NewTag(\"%s\")", si)
for _, a := range os.Args[1:] {
exp = fmt.Sprintf("html.%s(%s)", a, exp)
}
rname := []rune(si)
rname[0] = unicode.ToUpper(rname[0])
printf("var %s = %s\n", string(rname), exp)
}
}

54
script/promote-to-tags.go Normal file
View File

@ -0,0 +1,54 @@
package main
import (
"fmt"
"io"
"log"
"os"
"strings"
)
func splitAllBy(s string, ss ...string) []string {
var sss []string
for _, si := range ss {
sis := strings.Split(si, s)
for i := range sis {
sis[i] = strings.TrimSpace(sis[i])
}
for _, sisi := range sis {
if sisi != "" {
sss = append(sss, sisi)
}
}
}
return sss
}
func main() {
b, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalln(err)
}
s := string(b)
ss := splitAllBy("\n", s)
ss = splitAllBy(",", ss...)
ss = splitAllBy(" ", ss...)
printf := func(f string, a ...any) {
if err != nil {
return
}
_, err = fmt.Fprintf(os.Stdout, f, a...)
}
printf("// generated by ../script/generate-tags.go\n")
printf("\n")
printf("package tags\n")
printf("import \"code.squareroundforest.org/arpio/html\"\n")
for _, si := range ss {
printf("var %s = html.%s\n", si, si)
}
}

58
tags.block.txt Normal file
View File

@ -0,0 +1,58 @@
address
article
audio
aside
blockquote
body
canvas
caption
center
col
colgroup
datalist
dd
del
details
dialog
div
dl
dt
fieldset
figcaption
figure
footer
form
head
header
hgroup
html
ins
li
link
main
map
math
menu
nav
noscript
ol
optgroup
p
picture
pre
rp
search
section
summary
table
tbody
td
template
textarea
tfoot
th
thead
title
tr
ul
video

39
tags.inline.txt Normal file
View File

@ -0,0 +1,39 @@
a
abbr
b
bdi
bdo
button
cite
code
data
dfn
em
h1, h2, h3, h4, h5, h6
i
kbd
label
legend
mark
meter
object
option
output
progress
q
rt
ruby
s
samp
select
selectedcontent
slot
small
span
strong
sub
sup
svg
time
u
var

2
tags.script.txt Normal file
View File

@ -0,0 +1,2 @@
script
style

7
tags.void.block.txt Normal file
View File

@ -0,0 +1,7 @@
area
base
hr
iframe
meta
source
track

5
tags.void.inline.txt Normal file
View File

@ -0,0 +1,5 @@
br
embed
img
input
wbr

62
tags/block.gen.go Normal file
View File

@ -0,0 +1,62 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var Address = html.NewTag("address")
var Article = html.NewTag("article")
var Audio = html.NewTag("audio")
var Aside = html.NewTag("aside")
var Blockquote = html.NewTag("blockquote")
var Body = html.NewTag("body")
var Canvas = html.NewTag("canvas")
var Caption = html.NewTag("caption")
var Center = html.NewTag("center")
var Col = html.NewTag("col")
var Colgroup = html.NewTag("colgroup")
var Datalist = html.NewTag("datalist")
var Dd = html.NewTag("dd")
var Del = html.NewTag("del")
var Details = html.NewTag("details")
var Dialog = html.NewTag("dialog")
var Div = html.NewTag("div")
var Dl = html.NewTag("dl")
var Dt = html.NewTag("dt")
var Fieldset = html.NewTag("fieldset")
var Figcaption = html.NewTag("figcaption")
var Figure = html.NewTag("figure")
var Footer = html.NewTag("footer")
var Form = html.NewTag("form")
var Head = html.NewTag("head")
var Header = html.NewTag("header")
var Hgroup = html.NewTag("hgroup")
var Html = html.NewTag("html")
var Ins = html.NewTag("ins")
var Li = html.NewTag("li")
var Link = html.NewTag("link")
var Main = html.NewTag("main")
var Map = html.NewTag("map")
var Math = html.NewTag("math")
var Menu = html.NewTag("menu")
var Nav = html.NewTag("nav")
var Noscript = html.NewTag("noscript")
var Ol = html.NewTag("ol")
var Optgroup = html.NewTag("optgroup")
var P = html.NewTag("p")
var Picture = html.NewTag("picture")
var Pre = html.NewTag("pre")
var Rp = html.NewTag("rp")
var Search = html.NewTag("search")
var Section = html.NewTag("section")
var Summary = html.NewTag("summary")
var Table = html.NewTag("table")
var Tbody = html.NewTag("tbody")
var Td = html.NewTag("td")
var Template = html.NewTag("template")
var Textarea = html.NewTag("textarea")
var Tfoot = html.NewTag("tfoot")
var Th = html.NewTag("th")
var Thead = html.NewTag("thead")
var Title = html.NewTag("title")
var Tr = html.NewTag("tr")
var Ul = html.NewTag("ul")
var Video = html.NewTag("video")

48
tags/inline.gen.go Normal file
View File

@ -0,0 +1,48 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var A = html.Inline(html.NewTag("a"))
var Abbr = html.Inline(html.NewTag("abbr"))
var B = html.Inline(html.NewTag("b"))
var Bdi = html.Inline(html.NewTag("bdi"))
var Bdo = html.Inline(html.NewTag("bdo"))
var Button = html.Inline(html.NewTag("button"))
var Cite = html.Inline(html.NewTag("cite"))
var Code = html.Inline(html.NewTag("code"))
var Data = html.Inline(html.NewTag("data"))
var Dfn = html.Inline(html.NewTag("dfn"))
var Em = html.Inline(html.NewTag("em"))
var H1 = html.Inline(html.NewTag("h1"))
var H2 = html.Inline(html.NewTag("h2"))
var H3 = html.Inline(html.NewTag("h3"))
var H4 = html.Inline(html.NewTag("h4"))
var H5 = html.Inline(html.NewTag("h5"))
var H6 = html.Inline(html.NewTag("h6"))
var I = html.Inline(html.NewTag("i"))
var Kbd = html.Inline(html.NewTag("kbd"))
var Label = html.Inline(html.NewTag("label"))
var Legend = html.Inline(html.NewTag("legend"))
var Mark = html.Inline(html.NewTag("mark"))
var Meter = html.Inline(html.NewTag("meter"))
var Object = html.Inline(html.NewTag("object"))
var Option = html.Inline(html.NewTag("option"))
var Output = html.Inline(html.NewTag("output"))
var Progress = html.Inline(html.NewTag("progress"))
var Q = html.Inline(html.NewTag("q"))
var Rt = html.Inline(html.NewTag("rt"))
var Ruby = html.Inline(html.NewTag("ruby"))
var S = html.Inline(html.NewTag("s"))
var Samp = html.Inline(html.NewTag("samp"))
var Select = html.Inline(html.NewTag("select"))
var Selectedcontent = html.Inline(html.NewTag("selectedcontent"))
var Slot = html.Inline(html.NewTag("slot"))
var Small = html.Inline(html.NewTag("small"))
var Span = html.Inline(html.NewTag("span"))
var Strong = html.Inline(html.NewTag("strong"))
var Sub = html.Inline(html.NewTag("sub"))
var Sup = html.Inline(html.NewTag("sup"))
var Svg = html.Inline(html.NewTag("svg"))
var Time = html.Inline(html.NewTag("time"))
var U = html.Inline(html.NewTag("u"))
var Var = html.Inline(html.NewTag("var"))

6
tags/promote.gen.go Normal file
View File

@ -0,0 +1,6 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var Attr = html.Attr
var NewTag = html.NewTag

6
tags/script.gen.go Normal file
View File

@ -0,0 +1,6 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var Script = html.ScriptContent(html.NewTag("script"))
var Style = html.ScriptContent(html.NewTag("style"))

11
tags/void.block.gen.go Normal file
View File

@ -0,0 +1,11 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var Area = html.Void(html.NewTag("area"))
var Base = html.Void(html.NewTag("base"))
var Hr = html.Void(html.NewTag("hr"))
var Iframe = html.Void(html.NewTag("iframe"))
var Meta = html.Void(html.NewTag("meta"))
var Source = html.Void(html.NewTag("source"))
var Track = html.Void(html.NewTag("track"))

9
tags/void.inline.gen.go Normal file
View File

@ -0,0 +1,9 @@
// generated by ../script/generate-tags.go
package tags
import "code.squareroundforest.org/arpio/html"
var Br = html.Inline(html.Void(html.NewTag("br")))
var Embed = html.Inline(html.Void(html.NewTag("embed")))
var Img = html.Inline(html.Void(html.NewTag("img")))
var Input = html.Inline(html.Void(html.NewTag("input")))
var Wbr = html.Inline(html.Void(html.NewTag("wbr")))

66
validate.go Normal file
View File

@ -0,0 +1,66 @@
package html
import (
"errors"
"fmt"
"regexp"
)
var (
symbolExp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-_.]*$`)
scriptTagExp = regexp.MustCompile(`<\s*/?\s*[sS][cC][rR][iI][pP][tT]([^a-zA-Z0-9]+|$)`)
)
func validateSymbol(s string) error {
if !symbolExp.MatchString(s) {
return errors.New("invalid symbol")
}
return nil
}
func validateTagName(name string) error {
return validateSymbol(name)
}
func validateAttributeName(name string) error {
return validateSymbol(name)
}
func validate(name string, children []any) error {
if err := validateTagName(name); err != nil {
return err
}
a, c, rgs := groupChildren(children)
rg := mergeRenderingGuides(rgs)
if rg.void && len(c) > 0 {
return fmt.Errorf("tag %s is void but it has children", name)
}
for _, ai := range a {
for name := range ai {
if err := validateAttributeName(name); err != nil {
return fmt.Errorf("tag %s: %w", name, err)
}
}
}
for _, ci := range c {
if tag, ok := ci.(Tag); ok {
if rg.verbatim || rg.script {
return fmt.Errorf("tag %s does not allow child elements", name)
}
var v validator
tag(&v)
if v.err != nil {
return v.err
}
continue
}
}
return nil
}

111
validate_test.go Normal file
View File

@ -0,0 +1,111 @@
package html_test
import (
"bytes"
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"testing"
)
func TestValidate(t *testing.T) {
t.Run("symbol", func(t *testing.T) {
t.Run("invalid", func(t *testing.T) {
mytag := html.NewTag("foo+bar")
var b bytes.Buffer
if err := html.Render(&b, mytag); err == nil {
t.Fatal()
}
})
t.Run("invalid with allowed chars number", func(t *testing.T) {
mytag := html.NewTag("0foo")
var b bytes.Buffer
if err := html.Render(&b, mytag); err == nil {
t.Fatal()
}
})
t.Run("invalid with allowed chars delimiter", func(t *testing.T) {
mytag := html.NewTag("-foo")
var b bytes.Buffer
if err := html.Render(&b, mytag); err == nil {
t.Fatal()
}
})
t.Run("valid", func(t *testing.T) {
mytag := html.NewTag("foo")
var b bytes.Buffer
if err := html.Render(&b, mytag); err != nil {
t.Fatal(err)
}
})
t.Run("valid with special chars", func(t *testing.T) {
mytag := html.NewTag("foo-bar-1")
var b bytes.Buffer
if err := html.Render(&b, mytag); err != nil {
t.Fatal(err)
}
})
})
t.Run("invalid attribute name", func(t *testing.T) {
div := Div(Attr("foo+", "bar"))
var b bytes.Buffer
if err := html.Render(&b, div); err == nil {
t.Fatal()
}
})
t.Run("void tag with children", func(t *testing.T) {
br := Br("foo")
var b bytes.Buffer
if err := html.Render(&b, br); err == nil {
t.Fatal()
}
})
t.Run("verbatim with child tag", func(t *testing.T) {
div := html.Verbatim(Div(Br()))
var b bytes.Buffer
if err := html.Render(&b, div); err == nil {
t.Fatal()
}
})
t.Run("script with child tag", func(t *testing.T) {
script := Script(Br())
var b bytes.Buffer
if err := html.Render(&b, script); err == nil {
t.Fatal()
}
})
t.Run("invalid child tag", func(t *testing.T) {
div := Div(Div(Attr("foo+", "bar")))
var b bytes.Buffer
if err := html.Render(&b, div); err == nil {
t.Fatal()
}
})
t.Run("valid child tag", func(t *testing.T) {
div := Div(Div(Attr("foo", "bar")))
var b bytes.Buffer
if err := html.Render(&b, div); err != nil {
t.Fatal()
}
})
}

89
wrap.go Normal file
View File

@ -0,0 +1,89 @@
package html
import (
"bytes"
"unicode"
)
func words(buf *bytes.Buffer) []string {
var (
words []string
currentWord []rune
inTag bool
)
for {
r, _, err := buf.ReadRune()
if err != nil {
break
}
if r == unicode.ReplacementChar {
continue
}
if !inTag && unicode.IsSpace(r) {
if len(currentWord) > 0 {
words, currentWord = append(words, string(currentWord)), nil
}
continue
}
currentWord = append(currentWord, r)
inTag = inTag && r != '>' || r == '<'
}
if len(currentWord) > 0 {
words = append(words, string(currentWord))
}
return words
}
func wrap(buf *bytes.Buffer, pwidth int, indent string) *bytes.Buffer {
var (
lines [][]string
currentLine []string
currentLen int
)
words := words(buf)
for _, w := range words {
if currentLen != 0 {
currentLen++
}
currentLen += len(w)
if currentLen > pwidth && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []string{w}
currentLen = len(w)
continue
}
currentLine = append(currentLine, w)
}
if len(currentLine) > 0 {
lines = append(lines, currentLine)
}
ret := bytes.NewBuffer(nil)
for i, l := range lines {
if i > 0 {
ret.WriteRune('\n')
}
ret.WriteString(indent)
for j, w := range l {
if j > 0 {
ret.WriteRune(' ')
}
ret.WriteString(w)
}
}
return ret
}

76
wrap_test.go Normal file
View File

@ -0,0 +1,76 @@
package html_test
import (
"bytes"
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"testing"
)
func TestWrap(t *testing.T) {
t.Run("broken unicode", func(t *testing.T) {
b := []byte{'f', 0xc2, 'o', 'o'}
span := Span(string(b))
var buf bytes.Buffer
if err := html.RenderIndent(&buf, "\t", 0, span); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo</span>" {
t.Fatal(buf.String(), buf.Len(), len("<span>foo</span>"), buf.Bytes(), []byte("<span>foo</span>"))
}
})
t.Run("multiple words", func(t *testing.T) {
span := Span("foo bar baz")
var buf bytes.Buffer
if err := html.RenderIndent(&buf, "\t", 2, span); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo\nbar\nbaz</span>" {
t.Fatal(buf.String())
}
})
t.Run("tag not split", func(t *testing.T) {
span := Span("foo ", Span("bar"), " baz")
var buf bytes.Buffer
if err := html.RenderIndent(&buf, "\t", 2, span); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo\n<span>bar</span>\nbaz</span>" {
t.Fatal()
}
})
t.Run("normal text", func(t *testing.T) {
div := Div(Span("foo bar baz qux quux corge"))
var buf bytes.Buffer
if err := html.RenderIndent(&buf, "\t", 9, div); err != nil {
t.Fatal(err)
}
if buf.String() != "<div>\n\t<span>foo\n\tbar baz\n\tqux quux\n\tcorge</span>\n</div>\n" {
t.Fatal(buf.String())
}
})
t.Run("inline space preserved", func(t *testing.T) {
div := Div(Span("foo"), " ", Span("bar"))
var buf bytes.Buffer
if err := html.RenderIndent(&buf, "\t", 0, div); err != nil {
t.Fatal(err)
}
if buf.String() != "<div>\n\t<span>foo</span> <span>bar</span>\n</div>\n" {
t.Fatal(buf.String())
}
})
}