From 7610db6e710c0f044fddbce3cb707f03d9b5aeca Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Sun, 5 Oct 2025 20:06:39 +0200 Subject: [PATCH] refactor escaping --- .cover | 223 ------------------------ .gitignore | 1 + escape.go | 119 +++++++++++++ escape_test.go | 96 ++++++++++ lib.go | 8 +- notes.txt | 11 +- query.go | 6 +- query_test.go | 2 +- render.go | 464 ++++++------------------------------------------- wrap.go | 21 ++- wrap_test.go | 94 ++++++++-- writer_test.go | 31 ++++ 12 files changed, 410 insertions(+), 666 deletions(-) delete mode 100644 .cover create mode 100644 .gitignore create mode 100644 escape.go create mode 100644 escape_test.go create mode 100644 writer_test.go diff --git a/.cover b/.cover deleted file mode 100644 index f7c50cf..0000000 --- a/.cover +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebf0f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.cover diff --git a/escape.go b/escape.go new file mode 100644 index 0000000..2b9e96d --- /dev/null +++ b/escape.go @@ -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("<")...) + case '>': + w.write([]rune(">")...) + case '&': + w.write([]rune("&")...) + case unicodeNBSP: + w.write([]rune(" ")...) + 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 +} diff --git a/escape_test.go b/escape_test.go new file mode 100644 index 0000000..553c281 --- /dev/null +++ b/escape_test.go @@ -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() != "
" { + 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() != "foo" { + 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() != "foo   bar" { + 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() != "foo bar" { + 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() != "foo " { + 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() != " " { + t.Fatal(buf.String()) + } + }) + + t.Run("html characters", func(t *testing.T) { + var buf bytes.Buffer + if err := html.Render(&buf, Span("")); err != nil { + t.Fatal(err) + } + + if buf.String() != "<foo&bar>" { + t.Fatal(buf.String()) + } + }) +} diff --git a/lib.go b/lib.go index 405c844..076bfcc 100644 --- a/lib.go +++ b/lib.go @@ -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) diff --git a/notes.txt b/notes.txt index c3f9f9a..60ba713 100644 --- a/notes.txt +++ b/notes.txt @@ -1,9 +1,3 @@ -rendering types: -like
: inline void -inline
: 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: comments: -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 diff --git a/query.go b/query.go index c7c678d..bfd1988 100644 --- a/query.go +++ b/query.go @@ -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 { diff --git a/query_test.go b/query_test.go index ad605f0..047e029 100644 --- a/query_test.go +++ b/query_test.go @@ -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() } }) diff --git a/render.go b/render.go index 31421dd..650fc49 100644 --- a/render.go +++ b/render.go @@ -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(""")...) - case '&': - rr = append(rr, []rune("&")...) - 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("<")...) - case '>': - rr = append(rr, []rune(">")...) - case '&': - rr = append(rr, []rune("&")...) - case unicodeNBSP: - rr = append(rr, []rune(" ")...) - case ' ': - if wsStart && lastWS { - rr = append(rr[:len(rr)-1], []rune("  ")...) - } else if lastWS { - rr = append(rr, []rune(" ")...) - } 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("", 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("", 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("", 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("", 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("", 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("", name) - if r.indent != "" && !rg.inline { - printf("\n") - } -} -*/ diff --git a/wrap.go b/wrap.go index df2adae..eed2c0b 100644 --- a/wrap.go +++ b/wrap.go @@ -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 { diff --git a/wrap_test.go b/wrap_test.go index 1c4bb37..3c7d7c5 100644 --- a/wrap_test.go +++ b/wrap_test.go @@ -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() != "
" { + 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() != "
\n\tfoo bar baz\n
" { + t.Fatal(buf.String()) + } }) } diff --git a/writer_test.go b/writer_test.go new file mode 100644 index 0000000..0c7c750 --- /dev/null +++ b/writer_test.go @@ -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") +}