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
type Attributes map[string]string
type Attributes map[string]any
// immutable
// 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)
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
@ -109,7 +109,7 @@ func AllAttributes(t Tag) Attributes {
}
// 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}
t()(&q)
return q.value
@ -132,7 +132,7 @@ func DeleteAttribute(t Tag, name string) Tag {
// the same as Attribute(t, "class")
func Class(t Tag) string {
return Attribute(t, "class")
return fmt.Sprint(Attribute(t, "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
recommendation is not to mutate children. Ofc, creatively breaking the rules is always well appreciated by the
right audience
@ -12,5 +6,6 @@ test empty block
escape extra space between tag boundaries
declarations: <!doctype html>
comments: <!-- foo -->
attritubes, when bool true, then just the name of the attribute
implement stringer for the tag
render nil as empty
support readers
review which tags should be of type inline-children

View File

@ -10,7 +10,7 @@ type attributesQuery struct {
type attributeQuery struct {
name string
value string
value any
found bool
}
@ -66,7 +66,7 @@ func mergeAttributes(c []any) Attributes {
return to
}
func findAttribute(c []any, name string) (string, bool) {
func findAttribute(c []any, name string) (any, bool) {
a, _, _ := groupChildren(c)
for i := len(a) - 1; i >= 0; i-- {
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 {

View File

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

464
render.go
View File

@ -6,10 +6,7 @@ import (
"strings"
)
const (
defaultPWidth = 112
unicodeNBSP = 0xa0
)
const defaultPWidth = 112
type renderGuide struct {
inline bool
@ -41,60 +38,6 @@ func mergeRenderingGuides(rgs []renderGuide) renderGuide {
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 {
l := strings.Split(s, "\n")
for i := range l {
@ -121,7 +64,12 @@ func (r *renderer) renderAttributes(tagName string, a []Attributes) {
printf := r.getPrintf(tagName)
for _, ai := range a {
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
}
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
}
if !rg.verbatim && !rg.script {
s = htmlEscape(s)
if rg.verbatim || rg.script {
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)
@ -197,6 +154,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
for _, c := range children {
ct, isTag := c.(Tag)
if !isTag && rg.verbatim {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -210,6 +171,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
}
if !isTag && rg.script {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -222,6 +187,10 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
}
if !isTag {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -235,8 +204,13 @@ func (r *renderer) renderInline(name string, rg renderGuide, a []Attributes, chi
newWrapper = true
}
s = htmlEscape(s)
printf(s)
if r.err == nil {
ew := newEscapeWriter(r.out)
ew.Write([]byte(s))
ew.Flush()
r.err = ew.err
}
lastBlock = false
continue
}
@ -311,6 +285,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
for _, c := range children {
ct, isTag := c.(Tag)
if !isTag && rg.verbatim {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -324,6 +302,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
}
if !isTag && rg.script {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -336,6 +318,10 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
}
if !isTag {
if c == nil {
continue
}
s := fmt.Sprint(c)
if s == "" {
continue
@ -346,8 +332,13 @@ func (r *renderer) renderBlock(name string, rg renderGuide, a []Attributes, chil
}
r.ensureWrapper()
s = htmlEscape(s)
printf(s)
if r.err == nil {
ew := newEscapeWriter(r.out)
ew.Write([]byte(s))
ew.Flush()
r.err = ew.err
}
lastBlock = false
continue
}
@ -402,344 +393,3 @@ func (r *renderer) render(name string, children []any) {
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
}
runes := bytes.NewBuffer(p)
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) {
@ -75,8 +80,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
}
if r == unicode.ReplacementChar {
w.err = errors.New("broken unicode stream")
return len(p), w.err
continue
}
if w.inSingleQuote {
@ -95,11 +99,14 @@ func (w *wrapper) Write(p []byte) (int, error) {
w.inSingleQuote = r == '\''
w.inQuote = r == '"'
w.inTag = r != '>'
w.word.WriteRune(r)
if w.inTag || !unicode.IsSpace(r) {
w.word.WriteRune(r)
}
if !w.inTag {
if err := w.feed(); err != nil {
w.err = err
return len(p), err
return len(p), w.err
}
w.lastSpace = unicode.IsSpace(r)
@ -114,7 +121,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
if !w.inWord {
if err := w.feed(); err != nil {
w.err = err
return len(p), err
return len(p), w.err
}
w.lastSpace = unicode.IsSpace(r)
@ -137,7 +144,7 @@ func (w *wrapper) Write(p []byte) (int, error) {
w.inWord = !w.inTag
}
return len(p), nil
return len(p), w.err
}
func (w *wrapper) Flush() error {

View File

@ -8,16 +8,83 @@ import (
)
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, html.Indentation{Indent: "\t"}, span); err == nil {
t.Run("write error", func(t *testing.T) {
ew := &errorWriter{failAfter: 9}
if err := html.RenderIndent(
ew,
html.Indentation{Indent: "\t", PWidth: 12},
Span(Span("foo"), Span("bar"), Span("baz")),
); err == nil {
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) {
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) {
})
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")
}