1
0

refactor escaping

This commit is contained in:
Arpad Ryszka 2025-10-05 20:06:39 +02:00
parent 1308c164a7
commit 7610db6e71
12 changed files with 410 additions and 666 deletions

223
.cover
View File

@ -1,223 +0,0 @@
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

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.cover

119
escape.go Normal file
View File

@ -0,0 +1,119 @@
package html
import (
"io"
"bufio"
"bytes"
"errors"
"unicode"
)
const unicodeNBSP = 0xa0
type escapeWriter struct {
out *bufio.Writer
spaceStarted, lastSpace bool
err error
}
func attributeEscape(value string) string {
var rr []rune
r := []rune(value)
for i := range r {
switch r[i] {
case '"':
rr = append(rr, []rune(""")...)
case '&':
rr = append(rr, []rune("&")...)
default:
rr = append(rr, r[i])
}
}
return string(rr)
}
func newEscapeWriter(out io.Writer) *escapeWriter {
return &escapeWriter{out: bufio.NewWriter(out)}
}
func (w *escapeWriter) write(r ...rune) {
if w.err != nil {
return
}
for _, ri := range r {
if _, err := w.out.WriteRune(ri); err != nil {
w.err = err
return
}
}
}
func (w *escapeWriter) Write(p []byte) (int, error) {
if w.err != nil {
return 0, w.err
}
runes := bytes.NewBuffer(nil)
if n, err := runes.Write(p); err != nil {
w.err = err
return n, w.err
}
for {
r, _, err := runes.ReadRune()
if errors.Is(err, io.EOF) {
return len(p), nil
}
if r == unicode.ReplacementChar {
continue
}
space := r == ' '
switch {
case space && w.spaceStarted:
w.write([]rune("  ")...)
case space && w.lastSpace:
w.write([]rune(" ")...)
case w.spaceStarted:
w.write(' ')
}
w.spaceStarted = space && !w.lastSpace
w.lastSpace = space
if space {
continue
}
switch r {
case '<':
w.write([]rune("&lt;")...)
case '>':
w.write([]rune("&gt;")...)
case '&':
w.write([]rune("&amp;")...)
case unicodeNBSP:
w.write([]rune("&nbsp;")...)
default:
w.write(r)
}
}
return len(p), w.err
}
func (w *escapeWriter) Flush() error {
if w.err != nil {
return w.err
}
if w.spaceStarted {
w.write(' ')
w.spaceStarted = false
}
w.err = w.out.Flush()
return w.err
}

96
escape_test.go Normal file
View File

@ -0,0 +1,96 @@
package html_test
import (
"code.squareroundforest.org/arpio/html"
. "code.squareroundforest.org/arpio/html/tags"
"testing"
"bytes"
)
func TestEscape(t *testing.T) {
t.Run("attribute escape", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Div(Attr("foo", "\"bar\" & \"baz\""))); err != nil {
t.Fatal(err)
}
if buf.String() != "<div foo=\"&quot;bar&quot; &amp; &quot;baz&quot;\"></div>" {
t.Fatal(buf.String())
}
})
t.Run("failing writer", func(t *testing.T) {
ew := &errorWriter{}
if err := html.Render(ew, Span("foo bar baz")); err == nil {
t.Fatal("failed to fail")
}
})
t.Run("broken unicode", func(t *testing.T) {
b := []byte{'f', 0xc2, 'o', 'o'}
var buf bytes.Buffer
if err := html.Render(&buf, Span(string(b))); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo</span>" {
t.Fatal(buf.String())
}
})
t.Run("multiple spaces", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Span("foo bar")); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo&nbsp;&nbsp;&nbsp;bar</span>" {
t.Fatal(buf.String())
}
})
t.Run("single space", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Span("foo bar")); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo bar</span>" {
t.Fatal(buf.String())
}
})
t.Run("tailing space", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Span("foo ")); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>foo </span>" {
t.Fatal(buf.String())
}
})
t.Run("unicode non-break space", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Span(string([]rune{0xa0}))); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>&nbsp;</span>" {
t.Fatal(buf.String())
}
})
t.Run("html characters", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Span("<foo&bar>")); err != nil {
t.Fatal(err)
}
if buf.String() != "<span>&lt;foo&amp;bar&gt;</span>" {
t.Fatal(buf.String())
}
})
}

8
lib.go
View File

@ -9,7 +9,7 @@ import (
) )
// when composing html, the Attr convenience function is recommended to construct input attributes // when composing html, the Attr convenience function is recommended to construct input attributes
type Attributes map[string]string type Attributes map[string]any
// immutable // immutable
// calling creates a new copy with the passed in attributes and child nodes applied only to the copy // calling creates a new copy with the passed in attributes and child nodes applied only to the copy
@ -47,7 +47,7 @@ func Attr(a ...any) Attributes {
am := make(Attributes) am := make(Attributes)
for i := 0; i < len(a); i += 2 { for i := 0; i < len(a); i += 2 {
am[fmt.Sprint(a[i])] = fmt.Sprint(a[i+1]) am[fmt.Sprint(a[i])] = a[i+1]
} }
return am return am
@ -109,7 +109,7 @@ func AllAttributes(t Tag) Attributes {
} }
// returns the value of a named attribute if exists, empty string otherwise // returns the value of a named attribute if exists, empty string otherwise
func Attribute(t Tag, name string) string { func Attribute(t Tag, name string) any {
q := attributeQuery{name: name} q := attributeQuery{name: name}
t()(&q) t()(&q)
return q.value return q.value
@ -132,7 +132,7 @@ func DeleteAttribute(t Tag, name string) Tag {
// the same as Attribute(t, "class") // the same as Attribute(t, "class")
func Class(t Tag) string { func Class(t Tag) string {
return Attribute(t, "class") return fmt.Sprint(Attribute(t, "class"))
} }
// the same as SetAttribute(t, "class", class) // the same as SetAttribute(t, "class", class)

View File

@ -1,9 +1,3 @@
rendering types:
like <br>: inline void
inline <hr>: block void
like script: no escaping
split the validation from the rendering
take copies of the children to ensure immutability
explain the immutability guarantee in the Go docs: for children yes, for children references no. The general explain the immutability guarantee in the Go docs: for children yes, for children references no. The general
recommendation is not to mutate children. Ofc, creatively breaking the rules is always well appreciated by the recommendation is not to mutate children. Ofc, creatively breaking the rules is always well appreciated by the
right audience right audience
@ -12,5 +6,6 @@ test empty block
escape extra space between tag boundaries escape extra space between tag boundaries
declarations: <!doctype html> declarations: <!doctype html>
comments: <!-- foo --> comments: <!-- foo -->
attritubes, when bool true, then just the name of the attribute render nil as empty
implement stringer for the tag support readers
review which tags should be of type inline-children

View File

@ -10,7 +10,7 @@ type attributesQuery struct {
type attributeQuery struct { type attributeQuery struct {
name string name string
value string value any
found bool found bool
} }
@ -66,7 +66,7 @@ func mergeAttributes(c []any) Attributes {
return to return to
} }
func findAttribute(c []any, name string) (string, bool) { func findAttribute(c []any, name string) (any, bool) {
a, _, _ := groupChildren(c) a, _, _ := groupChildren(c)
for i := len(a) - 1; i >= 0; i-- { for i := len(a) - 1; i >= 0; i-- {
value, ok := a[i][name] value, ok := a[i][name]
@ -75,7 +75,7 @@ func findAttribute(c []any, name string) (string, bool) {
} }
} }
return "", false return nil, false
} }
func handleQuery(name string, children []any) bool { func handleQuery(name string, children []any) bool {

View File

@ -59,7 +59,7 @@ func TestQuery(t *testing.T) {
t.Run("does not exist", func(t *testing.T) { t.Run("does not exist", func(t *testing.T) {
div := Div(Attr("foo", "bar")) div := Div(Attr("foo", "bar"))
if html.Attribute(div, "qux") != "" { if html.Attribute(div, "qux") != nil {
t.Fatal() t.Fatal()
} }
}) })

464
render.go
View File

@ -6,10 +6,7 @@ import (
"strings" "strings"
) )
const ( const defaultPWidth = 112
defaultPWidth = 112
unicodeNBSP = 0xa0
)
type renderGuide struct { type renderGuide struct {
inline bool inline bool
@ -41,60 +38,6 @@ func mergeRenderingGuides(rgs []renderGuide) renderGuide {
return rg 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 unicodeNBSP:
rr = append(rr, []rune("&nbsp;")...)
case ' ':
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] == ' '
wsStart = ws && !lastWS
lastWS = ws
}
return string(rr)
}
func indentLines(indent string, s string) string { func indentLines(indent string, s string) string {
l := strings.Split(s, "\n") l := strings.Split(s, "\n")
for i := range l { for i := range l {
@ -121,7 +64,12 @@ func (r *renderer) renderAttributes(tagName string, a []Attributes) {
printf := r.getPrintf(tagName) printf := r.getPrintf(tagName)
for _, ai := range a { for _, ai := range a {
for name, value := range ai { for name, value := range ai {
printf(" %s=\"%s\"", name, attributeEscape(value)) if isTrue, _ := value.(bool); isTrue {
printf(" &s", name)
continue
}
printf(" %s=\"%s\"", name, attributeEscape(fmt.Sprint(value)))
} }
} }
} }
@ -141,16 +89,25 @@ func (r *renderer) renderUnindented(name string, rg renderGuide, a []Attributes,
continue continue
} }
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
} }
if !rg.verbatim && !rg.script { if rg.verbatim || rg.script {
s = htmlEscape(s) printf(s)
} }
printf(s) if r.err == nil {
ew := newEscapeWriter(r.out)
ew.Write([]byte(s))
ew.Flush()
r.err = ew.err
}
} }
printf("</%s>", name) printf("</%s>", name)
@ -197,6 +154,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
for _, c := range children { for _, c := range children {
ct, isTag := c.(Tag) ct, isTag := c.(Tag)
if !isTag && rg.verbatim { if !isTag && rg.verbatim {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -210,6 +171,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
} }
if !isTag && rg.script { if !isTag && rg.script {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -222,6 +187,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
} }
if !isTag { if !isTag {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -235,8 +204,13 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
newWrapper = true newWrapper = true
} }
s = htmlEscape(s) if r.err == nil {
printf(s) ew := newEscapeWriter(r.out)
ew.Write([]byte(s))
ew.Flush()
r.err = ew.err
}
lastBlock = false lastBlock = false
continue continue
} }
@ -311,6 +285,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
for _, c := range children { for _, c := range children {
ct, isTag := c.(Tag) ct, isTag := c.(Tag)
if !isTag && rg.verbatim { if !isTag && rg.verbatim {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -324,6 +302,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
} }
if !isTag && rg.script { if !isTag && rg.script {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -336,6 +318,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
} }
if !isTag { if !isTag {
if c == nil {
continue
}
s := fmt.Sprint(c) s := fmt.Sprint(c)
if s == "" { if s == "" {
continue continue
@ -346,8 +332,13 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
} }
r.ensureWrapper() r.ensureWrapper()
s = htmlEscape(s) if r.err == nil {
printf(s) ew := newEscapeWriter(r.out)
ew.Write([]byte(s))
ew.Flush()
r.err = ew.err
}
lastBlock = false lastBlock = false
continue continue
} }
@ -402,344 +393,3 @@ func (r *renderer) render(name string, children []any) {
r.renderBlock(name, rg, a, c) r.renderBlock(name, rg, a, c)
} }
/*
func getPrintf(out io.Writer) func(f string, a ...any) {
return 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)
}
}
}
func renderAttributes(out io.Writer, a []Attributes) {
printf := getPrintf(out)
for _, ai := range a {
for name, value := range ai {
printf(" %s=\"%s\"", name, attributeEscape(value))
}
}
}
func renderUnindented(r *renderer, name string, rg renderGuide, a []Attributes, children []any) {
printf := getPrintf(r.out)
printf("<%s", name)
renderAttributes(r.out, a)
printf(">")
if rg.void {
return
}
for _, c := range children {
if ct, ok := c.(Tag); ok {
ct(r)
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
}
if !rg.verbatim && !rg.script {
s = htmlEscape(s)
}
printf(s)
}
printf("</%s>", name)
}
func renderInline(r *renderer, name string, rg renderGuide, a []Attributes, children []any) {
printf := getPrintf(r.out)
printf("<%s", name)
renderAttributes(r.out, a)
printf(">")
if rg.void {
return
}
for _, c := range children {
if ct, ok := c.(Tag); ok {
var rgq renderGuidesQuery
ct(&rgq)
crg := mergeRenderingGuides(rgq.value)
if crg.inline {
ct(r)
continue
}
printf("\n")
cr := new(renderer)
*cr = *r
cr.currentIndent += cr.indent
ct(cr)
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
}
if !rg.verbatim && !rg.script {
s = htmlEscape(s)
}
printf(s)
}
printf("</%s>", name)
}
func renderBlock(r *renderer, name string, rg renderGuide, a []Attributes, children []any) {
if r.direct == nil {
r.direct = r.out
}
printf := getPrintf(r.direct)
printf(r.currentIndent)
printf("<%s", name)
renderAttributes(r.direct, a)
printf(">")
if len(c) == 0 {
printf("</%s>", name)
return
}
if r.indent != "" {
printf("\n")
}
var (
inlineBuffer bytes.Buffer
cr *renderer
lastInline bool
)
for i, c := range children {
if ct, ok := c.(Tag); ok {
var rgq renderGuidesQuery
ct(&rgq)
crg := mergeRenderingGuides(rgq.value)
if crg.inline {
if cr == nil {
cr = new(renderer)
*cr = *r
cr.currentIndent += cr.indent
}
cr.out = &inlineBuffer
if !lastInline {
printf(r.currentIndent + r.indent)
}
ct(cr)
lastInline = true
continue
}
inline := inlineBuffer.String()
if inline != "" {
// flush
// newline
}
continue
}
lastInline = true
}
inline := inlineBuffer.String()
if inline != "" {
// flush inline
}
if r.indent != "" {
printf("\n")
printf(r.currentIndent)
}
printf("</%s>", name)
if r.indent != "" {
printf("\n")
}
}
func render(r *renderer, name string, children []any) {
if r.err != nil {
return
}
a, c, rgs := groupChildren(children)
rg := mergeRenderingGuides(rgs)
if r.indent == "" {
renderUnindented(r, name, rg, a, c)
return
}
if rg.inline {
// TODO:
// - may need to wrap it here
// - could use a wrapping buffer
renderInline(r, name, rg, a, c)
return
}
renderBlock(r, name, rg, a, c)
// --
printf("<%s", name)
printf(">")
if r.indent != "" && !rg.inline && len(c) > 0 {
printf("\n")
}
if rg.void {
return
}
var inlineBuffer *bytes.Buffer
// 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 {
// tag && rg.inline && crg.inline
// tag && rg.inline && !crg.inline
// tag && !rg.inline && crg.inline
// tag && !rg.inline && !crg.inline
// !tag && rg.inline && crg.inline
// !tag && rg.inline && !crg.inline
// !tag && !rg.inline && crg.inline
// !tag && !rg.inline && !crg.inline
if tag, ok := ci.(Tag); ok {
if rg.inline {
if inlineBuffer == nil {
inlineBuffer = bytes.NewBuffer(nil)
}
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, "")
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)
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")
}
}
*/

21
wrap.go
View File

@ -67,7 +67,12 @@ func (w *wrapper) Write(p []byte) (int, error) {
return 0, w.err return 0, w.err
} }
runes := bytes.NewBuffer(p) runes := bytes.NewBuffer(nil)
if n, err := runes.Write(p); err != nil {
w.err = err
return n, w.err
}
for { for {
r, _, err := runes.ReadRune() r, _, err := runes.ReadRune()
if errors.Is(err, io.EOF) { if errors.Is(err, io.EOF) {
@ -75,8 +80,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
} }
if r == unicode.ReplacementChar { if r == unicode.ReplacementChar {
w.err = errors.New("broken unicode stream") continue
return len(p), w.err
} }
if w.inSingleQuote { if w.inSingleQuote {
@ -95,11 +99,14 @@ func (w *wrapper) Write(p []byte) (int, error) {
w.inSingleQuote = r == '\'' w.inSingleQuote = r == '\''
w.inQuote = r == '"' w.inQuote = r == '"'
w.inTag = r != '>' w.inTag = r != '>'
w.word.WriteRune(r) if w.inTag || !unicode.IsSpace(r) {
w.word.WriteRune(r)
}
if !w.inTag { if !w.inTag {
if err := w.feed(); err != nil { if err := w.feed(); err != nil {
w.err = err w.err = err
return len(p), err return len(p), w.err
} }
w.lastSpace = unicode.IsSpace(r) w.lastSpace = unicode.IsSpace(r)
@ -114,7 +121,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
if !w.inWord { if !w.inWord {
if err := w.feed(); err != nil { if err := w.feed(); err != nil {
w.err = err w.err = err
return len(p), err return len(p), w.err
} }
w.lastSpace = unicode.IsSpace(r) w.lastSpace = unicode.IsSpace(r)
@ -137,7 +144,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
w.inWord = !w.inTag w.inWord = !w.inTag
} }
return len(p), nil return len(p), w.err
} }
func (w *wrapper) Flush() error { func (w *wrapper) Flush() error {

View File

@ -8,16 +8,83 @@ import (
) )
func TestWrap(t *testing.T) { func TestWrap(t *testing.T) {
t.Run("broken unicode", func(t *testing.T) { t.Run("write error", func(t *testing.T) {
b := []byte{'f', 0xc2, 'o', 'o'} ew := &errorWriter{failAfter: 9}
span := Span(string(b)) if err := html.RenderIndent(
ew,
var buf bytes.Buffer html.Indentation{Indent: "\t", PWidth: 12},
if err := html.RenderIndent(&buf, html.Indentation{Indent: "\t"}, span); err == nil { Span(Span("foo"), Span("bar"), Span("baz")),
); err == nil {
t.Fatal() t.Fatal()
} }
}) })
t.Run("write error in text", func(t *testing.T) {
ew := &errorWriter{}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t", PWidth: 2},
Span("foo", "bar", "baz"),
); err == nil {
t.Fatal()
}
})
t.Run("write error on line feed", func(t *testing.T) {
ew := &errorWriter{failAfter: 7}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t", PWidth: 3},
Span("foo"),
); err == nil {
t.Fatal()
}
})
t.Run("write error on indentation", func(t *testing.T) {
ew := &errorWriter{failAfter: 15}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t", PWidth: 3},
Div(Span("foo")),
); err == nil {
t.Fatal()
}
})
t.Run("write error on flush", func(t *testing.T) {
ew := &errorWriter{}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t"},
Span(Span("foo"), Span("bar"), Span("baz")),
); err == nil {
t.Fatal()
}
})
t.Run("write error on flush during line feed", func(t *testing.T) {
ew := &errorWriter{}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t", PWidth: 10},
Span("foo bar", Div),
); err == nil {
t.Fatal()
}
})
t.Run("null write", func(t *testing.T) {
var buf bytes.Buffer
if err := html.Render(&buf, Div("")); err != nil {
t.Fatal(err)
}
if buf.String() != "<div></div>" {
t.Fatal(buf.String())
}
})
t.Run("multiple words", func(t *testing.T) { t.Run("multiple words", func(t *testing.T) {
span := Span("foo bar baz") span := Span("foo bar baz")
@ -72,15 +139,16 @@ func TestWrap(t *testing.T) {
} }
}) })
t.Run("multiple lines", func(t *testing.T) {
})
t.Run("special whitespace characters", func(t *testing.T) { t.Run("special whitespace characters", func(t *testing.T) {
}) div := Div("foo\nbar\tbaz")
t.Run("spaces around tags", func(t *testing.T) { var buf bytes.Buffer
}) if err := html.RenderIndent(&buf, html.Indentation{Indent: "\t"}, div); err != nil {
t.Fatal(err)
}
t.Run("one line primitives", func(t *testing.T) { if buf.String() != "<div>\n\tfoo bar baz\n</div>" {
t.Fatal(buf.String())
}
}) })
} }

31
writer_test.go Normal file
View File

@ -0,0 +1,31 @@
package html_test
import (
"errors"
"io"
)
type errorWriter struct{
out io.Writer
failAfter int
}
func(ew *errorWriter) Write(p []byte) (int, error) {
wp := p
if len(wp) > ew.failAfter {
wp = wp[:ew.failAfter]
}
ew.failAfter -= len(wp)
if ew.out != nil {
if n, err := ew.out.Write(wp); err != nil {
return n, err
}
}
if ew.failAfter > 0 {
return len(wp), nil
}
return len(wp), errors.New("test write error")
}