From 1df0dbe17844110867003880e3d6280b49dbbbca Mon Sep 17 00:00:00 2001 From: Arpad Ryszka Date: Thu, 11 Sep 2025 20:50:00 +0200 Subject: [PATCH] wip --- .cover | 223 ++++++++++++++++++++++++++++++++++ Makefile | 49 ++++++++ eq.go | 59 +++++++++ go.mod | 5 + go.sum | 2 + lib.go | 220 +++++++++++++++++++++++++++++++++ lib_test.go | 101 +++++++++++++++ notes.txt | 5 + promote-to-tags.txt | 2 + query.go | 129 ++++++++++++++++++++ query_test.go | 150 +++++++++++++++++++++++ render.go | 250 ++++++++++++++++++++++++++++++++++++++ render_test.go | 172 ++++++++++++++++++++++++++ script/generate-tags.go | 62 ++++++++++ script/promote-to-tags.go | 54 ++++++++ tags.block.txt | 58 +++++++++ tags.inline.txt | 39 ++++++ tags.script.txt | 2 + tags.void.block.txt | 7 ++ tags.void.inline.txt | 5 + tags/block.gen.go | 62 ++++++++++ tags/inline.gen.go | 48 ++++++++ tags/promote.gen.go | 6 + tags/script.gen.go | 6 + tags/void.block.gen.go | 11 ++ tags/void.inline.gen.go | 9 ++ validate.go | 66 ++++++++++ validate_test.go | 111 +++++++++++++++++ wrap.go | 89 ++++++++++++++ wrap_test.go | 76 ++++++++++++ 30 files changed, 2078 insertions(+) create mode 100644 .cover create mode 100644 Makefile create mode 100644 eq.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 lib.go create mode 100644 lib_test.go create mode 100644 notes.txt create mode 100644 promote-to-tags.txt create mode 100644 query.go create mode 100644 query_test.go create mode 100644 render.go create mode 100644 render_test.go create mode 100644 script/generate-tags.go create mode 100644 script/promote-to-tags.go create mode 100644 tags.block.txt create mode 100644 tags.inline.txt create mode 100644 tags.script.txt create mode 100644 tags.void.block.txt create mode 100644 tags.void.inline.txt create mode 100644 tags/block.gen.go create mode 100644 tags/inline.gen.go create mode 100644 tags/promote.gen.go create mode 100644 tags/script.gen.go create mode 100644 tags/void.block.gen.go create mode 100644 tags/void.inline.gen.go create mode 100644 validate.go create mode 100644 validate_test.go create mode 100644 wrap.go create mode 100644 wrap_test.go diff --git a/.cover b/.cover new file mode 100644 index 0000000..f7c50cf --- /dev/null +++ b/.cover @@ -0,0 +1,223 @@ +mode: set +code.squareroundforest.org/arpio/html/eq.go:3.31,4.26 1 1 +code.squareroundforest.org/arpio/html/eq.go:4.26,6.3 1 0 +code.squareroundforest.org/arpio/html/eq.go:8.2,9.24 2 1 +code.squareroundforest.org/arpio/html/eq.go:9.24,11.3 1 0 +code.squareroundforest.org/arpio/html/eq.go:13.2,13.23 1 1 +code.squareroundforest.org/arpio/html/eq.go:13.23,16.22 3 0 +code.squareroundforest.org/arpio/html/eq.go:16.22,18.4 1 0 +code.squareroundforest.org/arpio/html/eq.go:21.2,22.24 2 1 +code.squareroundforest.org/arpio/html/eq.go:22.24,24.3 1 0 +code.squareroundforest.org/arpio/html/eq.go:26.2,26.20 1 1 +code.squareroundforest.org/arpio/html/eq.go:26.20,29.17 3 0 +code.squareroundforest.org/arpio/html/eq.go:29.17,31.4 1 0 +code.squareroundforest.org/arpio/html/eq.go:33.3,33.27 1 0 +code.squareroundforest.org/arpio/html/eq.go:33.27,35.4 1 0 +code.squareroundforest.org/arpio/html/eq.go:37.3,37.11 1 0 +code.squareroundforest.org/arpio/html/eq.go:37.11,38.12 1 0 +code.squareroundforest.org/arpio/html/eq.go:41.3,41.21 1 0 +code.squareroundforest.org/arpio/html/eq.go:41.21,43.4 1 0 +code.squareroundforest.org/arpio/html/eq.go:46.2,46.13 1 1 +code.squareroundforest.org/arpio/html/eq.go:49.28,50.16 1 1 +code.squareroundforest.org/arpio/html/eq.go:50.16,52.3 1 1 +code.squareroundforest.org/arpio/html/eq.go:54.2,54.22 1 1 +code.squareroundforest.org/arpio/html/eq.go:54.22,56.3 1 0 +code.squareroundforest.org/arpio/html/eq.go:58.2,58.21 1 1 +code.squareroundforest.org/arpio/html/lib.go:23.73,27.2 3 1 +code.squareroundforest.org/arpio/html/lib.go:29.32,33.2 3 1 +code.squareroundforest.org/arpio/html/lib.go:35.42,39.35 4 1 +code.squareroundforest.org/arpio/html/lib.go:39.35,41.3 1 1 +code.squareroundforest.org/arpio/html/lib.go:43.2,43.10 1 1 +code.squareroundforest.org/arpio/html/lib.go:46.56,50.2 3 1 +code.squareroundforest.org/arpio/html/lib.go:52.55,58.2 5 0 +code.squareroundforest.org/arpio/html/lib.go:60.35,66.2 5 1 +code.squareroundforest.org/arpio/html/lib.go:70.32,71.19 1 1 +code.squareroundforest.org/arpio/html/lib.go:71.19,73.3 1 0 +code.squareroundforest.org/arpio/html/lib.go:75.2,76.33 2 1 +code.squareroundforest.org/arpio/html/lib.go:76.33,78.3 1 1 +code.squareroundforest.org/arpio/html/lib.go:80.2,80.11 1 1 +code.squareroundforest.org/arpio/html/lib.go:84.48,85.33 1 1 +code.squareroundforest.org/arpio/html/lib.go:85.33,87.3 1 1 +code.squareroundforest.org/arpio/html/lib.go:89.2,89.40 1 1 +code.squareroundforest.org/arpio/html/lib.go:89.40,91.3 1 1 +code.squareroundforest.org/arpio/html/lib.go:95.29,97.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:100.42,102.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:105.47,108.2 2 1 +code.squareroundforest.org/arpio/html/lib.go:111.62,113.2 1 0 +code.squareroundforest.org/arpio/html/lib.go:117.54,119.2 1 0 +code.squareroundforest.org/arpio/html/lib.go:122.30,124.2 1 0 +code.squareroundforest.org/arpio/html/lib.go:127.48,129.2 1 0 +code.squareroundforest.org/arpio/html/lib.go:132.48,134.19 2 0 +code.squareroundforest.org/arpio/html/lib.go:134.19,136.3 1 0 +code.squareroundforest.org/arpio/html/lib.go:138.2,138.40 1 0 +code.squareroundforest.org/arpio/html/lib.go:142.51,147.24 4 0 +code.squareroundforest.org/arpio/html/lib.go:147.24,148.18 1 0 +code.squareroundforest.org/arpio/html/lib.go:148.18,150.4 1 0 +code.squareroundforest.org/arpio/html/lib.go:153.2,153.44 1 0 +code.squareroundforest.org/arpio/html/lib.go:157.32,159.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:164.78,166.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:169.45,171.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:176.34,178.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:181.39,183.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:187.32,189.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:193.30,195.2 1 1 +code.squareroundforest.org/arpio/html/lib.go:199.28,201.19 2 1 +code.squareroundforest.org/arpio/html/lib.go:201.19,203.3 1 1 +code.squareroundforest.org/arpio/html/lib.go:205.2,205.18 1 1 +code.squareroundforest.org/arpio/html/query.go:29.66,36.23 2 1 +code.squareroundforest.org/arpio/html/query.go:36.23,37.36 1 1 +code.squareroundforest.org/arpio/html/query.go:37.36,39.12 2 1 +code.squareroundforest.org/arpio/html/query.go:42.3,42.38 1 1 +code.squareroundforest.org/arpio/html/query.go:42.38,44.12 2 1 +code.squareroundforest.org/arpio/html/query.go:47.3,47.22 1 1 +code.squareroundforest.org/arpio/html/query.go:50.2,50.18 1 1 +code.squareroundforest.org/arpio/html/query.go:53.42,55.17 2 1 +code.squareroundforest.org/arpio/html/query.go:55.17,57.3 1 1 +code.squareroundforest.org/arpio/html/query.go:59.2,60.23 2 1 +code.squareroundforest.org/arpio/html/query.go:60.23,61.31 1 1 +code.squareroundforest.org/arpio/html/query.go:61.31,63.4 1 1 +code.squareroundforest.org/arpio/html/query.go:66.2,66.11 1 1 +code.squareroundforest.org/arpio/html/query.go:69.57,71.35 2 1 +code.squareroundforest.org/arpio/html/query.go:71.35,73.9 2 1 +code.squareroundforest.org/arpio/html/query.go:73.9,75.4 1 1 +code.squareroundforest.org/arpio/html/query.go:78.2,78.18 1 1 +code.squareroundforest.org/arpio/html/query.go:81.52,82.24 1 1 +code.squareroundforest.org/arpio/html/query.go:82.24,84.3 1 1 +code.squareroundforest.org/arpio/html/query.go:86.2,88.41 3 1 +code.squareroundforest.org/arpio/html/query.go:88.41,91.3 2 1 +code.squareroundforest.org/arpio/html/query.go:93.2,93.47 1 1 +code.squareroundforest.org/arpio/html/query.go:93.47,96.3 2 1 +code.squareroundforest.org/arpio/html/query.go:98.2,98.46 1 1 +code.squareroundforest.org/arpio/html/query.go:98.46,101.3 2 1 +code.squareroundforest.org/arpio/html/query.go:103.2,103.45 1 1 +code.squareroundforest.org/arpio/html/query.go:103.45,106.3 2 1 +code.squareroundforest.org/arpio/html/query.go:108.2,108.49 1 1 +code.squareroundforest.org/arpio/html/query.go:108.49,111.3 2 1 +code.squareroundforest.org/arpio/html/query.go:113.2,113.41 1 1 +code.squareroundforest.org/arpio/html/query.go:113.41,116.3 2 1 +code.squareroundforest.org/arpio/html/query.go:118.2,118.40 1 1 +code.squareroundforest.org/arpio/html/query.go:118.40,119.50 1 1 +code.squareroundforest.org/arpio/html/query.go:119.50,122.4 2 1 +code.squareroundforest.org/arpio/html/query.go:124.3,125.14 2 1 +code.squareroundforest.org/arpio/html/query.go:128.2,128.14 1 1 +code.squareroundforest.org/arpio/html/render.go:26.58,28.26 2 1 +code.squareroundforest.org/arpio/html/render.go:28.26,33.3 4 1 +code.squareroundforest.org/arpio/html/render.go:35.2,35.11 1 1 +code.squareroundforest.org/arpio/html/render.go:38.43,41.19 3 1 +code.squareroundforest.org/arpio/html/render.go:41.19,42.15 1 1 +code.squareroundforest.org/arpio/html/render.go:43.12,44.40 1 1 +code.squareroundforest.org/arpio/html/render.go:45.12,46.39 1 1 +code.squareroundforest.org/arpio/html/render.go:47.11,48.25 1 1 +code.squareroundforest.org/arpio/html/render.go:52.2,52.19 1 1 +code.squareroundforest.org/arpio/html/render.go:55.34,62.19 3 1 +code.squareroundforest.org/arpio/html/render.go:62.19,63.15 1 1 +code.squareroundforest.org/arpio/html/render.go:64.12,65.38 1 1 +code.squareroundforest.org/arpio/html/render.go:66.12,67.38 1 1 +code.squareroundforest.org/arpio/html/render.go:68.12,69.39 1 1 +code.squareroundforest.org/arpio/html/render.go:70.18,71.25 1 1 +code.squareroundforest.org/arpio/html/render.go:71.25,73.5 1 1 +code.squareroundforest.org/arpio/html/render.go:73.10,73.21 1 1 +code.squareroundforest.org/arpio/html/render.go:73.21,75.5 1 1 +code.squareroundforest.org/arpio/html/render.go:75.10,77.5 1 1 +code.squareroundforest.org/arpio/html/render.go:78.11,79.25 1 1 +code.squareroundforest.org/arpio/html/render.go:82.3,84.14 3 1 +code.squareroundforest.org/arpio/html/render.go:87.2,87.19 1 1 +code.squareroundforest.org/arpio/html/render.go:90.55,91.18 1 1 +code.squareroundforest.org/arpio/html/render.go:91.18,93.3 1 1 +code.squareroundforest.org/arpio/html/render.go:95.2,95.37 1 1 +code.squareroundforest.org/arpio/html/render.go:95.37,96.19 1 1 +code.squareroundforest.org/arpio/html/render.go:96.19,98.4 1 1 +code.squareroundforest.org/arpio/html/render.go:100.3,101.19 2 1 +code.squareroundforest.org/arpio/html/render.go:101.19,103.4 1 1 +code.squareroundforest.org/arpio/html/render.go:106.2,108.27 3 1 +code.squareroundforest.org/arpio/html/render.go:108.27,110.3 1 1 +code.squareroundforest.org/arpio/html/render.go:112.2,113.23 2 1 +code.squareroundforest.org/arpio/html/render.go:113.23,114.31 1 1 +code.squareroundforest.org/arpio/html/render.go:114.31,116.4 1 1 +code.squareroundforest.org/arpio/html/render.go:119.2,120.48 2 1 +code.squareroundforest.org/arpio/html/render.go:120.48,122.3 1 1 +code.squareroundforest.org/arpio/html/render.go:124.2,124.13 1 1 +code.squareroundforest.org/arpio/html/render.go:124.13,126.3 1 0 +code.squareroundforest.org/arpio/html/render.go:128.2,129.20 2 1 +code.squareroundforest.org/arpio/html/render.go:129.20,131.3 1 1 +code.squareroundforest.org/arpio/html/render.go:133.2,133.23 1 1 +code.squareroundforest.org/arpio/html/render.go:133.23,134.34 1 1 +code.squareroundforest.org/arpio/html/render.go:134.34,138.77 4 1 +code.squareroundforest.org/arpio/html/render.go:138.77,140.15 2 1 +code.squareroundforest.org/arpio/html/render.go:140.15,142.6 1 1 +code.squareroundforest.org/arpio/html/render.go:144.5,145.59 2 1 +code.squareroundforest.org/arpio/html/render.go:145.59,148.6 2 1 +code.squareroundforest.org/arpio/html/render.go:150.5,150.40 1 0 +code.squareroundforest.org/arpio/html/render.go:153.4,153.46 1 1 +code.squareroundforest.org/arpio/html/render.go:153.46,155.5 1 0 +code.squareroundforest.org/arpio/html/render.go:157.4,160.36 4 1 +code.squareroundforest.org/arpio/html/render.go:160.36,162.5 1 1 +code.squareroundforest.org/arpio/html/render.go:164.4,165.12 2 1 +code.squareroundforest.org/arpio/html/render.go:168.3,169.14 2 1 +code.squareroundforest.org/arpio/html/render.go:169.14,170.12 1 0 +code.squareroundforest.org/arpio/html/render.go:173.3,173.33 1 1 +code.squareroundforest.org/arpio/html/render.go:173.33,175.4 1 1 +code.squareroundforest.org/arpio/html/render.go:177.3,177.21 1 1 +code.squareroundforest.org/arpio/html/render.go:177.21,179.12 2 1 +code.squareroundforest.org/arpio/html/render.go:182.3,182.30 1 1 +code.squareroundforest.org/arpio/html/render.go:185.2,185.46 1 1 +code.squareroundforest.org/arpio/html/render.go:185.46,187.13 2 1 +code.squareroundforest.org/arpio/html/render.go:187.13,189.4 1 1 +code.squareroundforest.org/arpio/html/render.go:191.3,192.31 2 1 +code.squareroundforest.org/arpio/html/render.go:192.31,194.4 1 1 +code.squareroundforest.org/arpio/html/render.go:196.3,197.57 2 1 +code.squareroundforest.org/arpio/html/render.go:197.57,200.4 2 1 +code.squareroundforest.org/arpio/html/render.go:202.3,202.17 1 1 +code.squareroundforest.org/arpio/html/render.go:202.17,204.4 1 1 +code.squareroundforest.org/arpio/html/render.go:207.2,208.34 2 1 +code.squareroundforest.org/arpio/html/render.go:208.34,210.3 1 1 +code.squareroundforest.org/arpio/html/validate.go:14.37,15.31 1 1 +code.squareroundforest.org/arpio/html/validate.go:15.31,17.3 1 1 +code.squareroundforest.org/arpio/html/validate.go:19.2,19.12 1 1 +code.squareroundforest.org/arpio/html/validate.go:22.41,24.2 1 1 +code.squareroundforest.org/arpio/html/validate.go:26.47,28.2 1 1 +code.squareroundforest.org/arpio/html/validate.go:30.50,31.46 1 1 +code.squareroundforest.org/arpio/html/validate.go:31.46,33.3 1 1 +code.squareroundforest.org/arpio/html/validate.go:35.2,37.23 3 1 +code.squareroundforest.org/arpio/html/validate.go:37.23,38.24 1 1 +code.squareroundforest.org/arpio/html/validate.go:38.24,39.54 1 1 +code.squareroundforest.org/arpio/html/validate.go:39.54,41.5 1 1 +code.squareroundforest.org/arpio/html/validate.go:45.2,45.27 1 1 +code.squareroundforest.org/arpio/html/validate.go:45.27,47.3 1 1 +code.squareroundforest.org/arpio/html/validate.go:49.2,49.23 1 1 +code.squareroundforest.org/arpio/html/validate.go:49.23,50.34 1 1 +code.squareroundforest.org/arpio/html/validate.go:50.34,51.32 1 1 +code.squareroundforest.org/arpio/html/validate.go:51.32,53.5 1 1 +code.squareroundforest.org/arpio/html/validate.go:55.4,57.20 3 1 +code.squareroundforest.org/arpio/html/validate.go:57.20,59.5 1 1 +code.squareroundforest.org/arpio/html/validate.go:61.4,61.12 1 1 +code.squareroundforest.org/arpio/html/validate.go:65.2,65.12 1 1 +code.squareroundforest.org/arpio/html/wrap.go:8.40,15.6 2 1 +code.squareroundforest.org/arpio/html/wrap.go:15.6,17.17 2 1 +code.squareroundforest.org/arpio/html/wrap.go:17.17,18.9 1 1 +code.squareroundforest.org/arpio/html/wrap.go:21.3,21.35 1 1 +code.squareroundforest.org/arpio/html/wrap.go:21.35,22.12 1 1 +code.squareroundforest.org/arpio/html/wrap.go:25.3,25.35 1 1 +code.squareroundforest.org/arpio/html/wrap.go:25.35,26.28 1 1 +code.squareroundforest.org/arpio/html/wrap.go:26.28,28.5 1 1 +code.squareroundforest.org/arpio/html/wrap.go:30.4,30.12 1 1 +code.squareroundforest.org/arpio/html/wrap.go:33.3,34.40 2 1 +code.squareroundforest.org/arpio/html/wrap.go:37.2,37.26 1 1 +code.squareroundforest.org/arpio/html/wrap.go:37.26,39.3 1 1 +code.squareroundforest.org/arpio/html/wrap.go:41.2,41.14 1 1 +code.squareroundforest.org/arpio/html/wrap.go:44.71,52.26 3 1 +code.squareroundforest.org/arpio/html/wrap.go:52.26,53.22 1 1 +code.squareroundforest.org/arpio/html/wrap.go:53.22,55.4 1 1 +code.squareroundforest.org/arpio/html/wrap.go:57.3,58.50 2 1 +code.squareroundforest.org/arpio/html/wrap.go:58.50,62.12 4 1 +code.squareroundforest.org/arpio/html/wrap.go:65.3,65.39 1 1 +code.squareroundforest.org/arpio/html/wrap.go:68.2,68.26 1 1 +code.squareroundforest.org/arpio/html/wrap.go:68.26,70.3 1 1 +code.squareroundforest.org/arpio/html/wrap.go:72.2,73.26 2 1 +code.squareroundforest.org/arpio/html/wrap.go:73.26,74.12 1 1 +code.squareroundforest.org/arpio/html/wrap.go:74.12,76.4 1 1 +code.squareroundforest.org/arpio/html/wrap.go:78.3,79.23 2 1 +code.squareroundforest.org/arpio/html/wrap.go:79.23,80.13 1 1 +code.squareroundforest.org/arpio/html/wrap.go:80.13,82.5 1 1 +code.squareroundforest.org/arpio/html/wrap.go:84.4,84.22 1 1 +code.squareroundforest.org/arpio/html/wrap.go:88.2,88.12 1 1 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..93d55d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +SOURCES = $(shell find . -name "*.go" | grep -v [.]gen[.]go) + +default: build + +all: clean fmt build cover + +build: $(SOURCES) tags promote-to-tags + go build + +tags: tags/block.gen.go tags/inline.gen.go tags/void.block.gen.go tags/void.inline.gen.go tags/script.gen.go + +promote-to-tags: tags/promote.gen.go + +tags/block.gen.go: $(SOURCES) tags.block.txt + go run script/generate-tags.go < tags.block.txt > tags/block.gen.go + +tags/inline.gen.go: $(SOURCES) tags.inline.txt + go run script/generate-tags.go Inline < tags.inline.txt > tags/inline.gen.go + +tags/void.block.gen.go: $(SOURCES) tags.void.block.txt + go run script/generate-tags.go Void < tags.void.block.txt > tags/void.block.gen.go + +tags/void.inline.gen.go: $(SOURCES) tags.void.inline.txt + go run script/generate-tags.go Void Inline < tags.void.inline.txt > tags/void.inline.gen.go + +tags/script.gen.go: $(SOURCES) tags.script.txt + go run script/generate-tags.go ScriptContent < tags.script.txt > tags/script.gen.go + +tags/promote.gen.go: $(SOURCES) promote-to-tags.txt + go run script/promote-to-tags.go < promote-to-tags.txt > tags/promote.gen.go + +fmt: $(SOURCES) tags + go fmt ./... + +check: $(SOURCES) tags promote-to-tags + go test -count 1 + +.cover: $(SOURCES) tags promote-to-tags + go test -count 1 -coverprofile .cover + +cover: .cover + go tool cover -func .cover + +showcover: .cover + go tool cover -html .cover + +clean: + go clean + rm -f tags/*.gen.go diff --git a/eq.go b/eq.go new file mode 100644 index 0000000..336cb6b --- /dev/null +++ b/eq.go @@ -0,0 +1,59 @@ +package html + +func eq2(t1, t2 Tag) bool { + if Name(t1) != Name(t2) { + return false + } + + a1, a2 := AllAttributes(t1), AllAttributes(t2) + if len(a1) != len(a2) { + return false + } + + for name := range a1 { + v1 := a1[name] + v2, ok := a2[name] + if !ok || v1 != v2 { + return false + } + } + + c1, c2 := Children(t1), Children(t2) + if len(c1) != len(c2) { + return false + } + + for i := range c1 { + ct1, ok1 := c1[i].(Tag) + ct2, ok2 := c2[i].(Tag) + if ok1 != ok2 { + return false + } + + if ok1 && !Eq(ct1, ct2) { + return false + } + + if !ok1 { + continue + } + + if c1[i] != c2[i] { + return false + } + } + + return true +} + +func eq(t ...Tag) bool { + if len(t) < 2 { + return true + } + + if !eq2(t[0], t[1]) { + return false + } + + return eq(t[1:]...) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b555565 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.squareroundforest.org/arpio/html + +go 1.25.0 + +require code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a66f3d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2 h1:S4mjQHL70CuzFg1AGkr0o0d+4M+ZWM0sbnlYq6f0b3I= +code.squareroundforest.org/arpio/notation v0.0.0-20250826181910-5140794b16b2/go.mod h1:ait4Fvg9o0+bq5hlxi9dAcPL5a+/sr33qsZPNpToMLY= diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..e6f40e1 --- /dev/null +++ b/lib.go @@ -0,0 +1,220 @@ +// Package html provides functions for programmatically composing and rendering HTML. +package html + +import ( + "fmt" + "io" + "strings" +) + +// when composing html, the Attr convenience function is recommended to construct input attributes +type Attributes map[string]string + +// immutable +// calling creates a new copy with the passed in attributes and child nodes applied only to the copy +// input parameters +// rendering of child nodes +// instances of tags can be used to create further tags with extended set of attributes and child tags +// builtin tags in the tags sub-package +// custom tags are supported via the NewTag constructor. Functions with the same signature, but created by other +// means, will not be rendered, unless they return a tag created by NewTag() or a builtin tag +type Tag func(...any) Tag + +type Template[Data any] func(Data) Tag + +// convenience function primarily aimed to help with construction of html with tags +// the names and values are applied using fmt.Sprint, tolerating fmt.Stringer implementations +func Attr(a ...any) Attributes { + if len(a)%2 != 0 { + a = append(a, "") + } + + am := make(Attributes) + for i := 0; i < len(a); i += 2 { + am[fmt.Sprint(a[i])] = fmt.Sprint(a[i+1]) + } + + return am +} + +// defines a new tag with name and initial attributes and child nodes +func NewTag(name string, children ...any) Tag { + if handleQuery(name, children) { + children = children[:len(children)-1] + } + + return func(children1 ...any) Tag { + if name == "br" { + } + + return NewTag(name, append(children, children1...)...) + } +} + +// returns the name of a tag +func Name(t Tag) string { + q := nameQuery{} + t()(&q) + return q.value +} + +// returns all attributes of a tag +func AllAttributes(t Tag) Attributes { + q := attributesQuery{} + t()(&q) + a := make(Attributes) + for name, value := range q.value { + a[name] = value + } + + return a +} + +// returns the value of a named attribute if exists, empty string otherwise +func Attribute(t Tag, name string) string { + q := attributeQuery{name: name} + t()(&q) + return q.value +} + +// creates a new tag with all the existing attributes and child nodes of the input tag, and the new attribute value +func SetAttribute(t Tag, name string, value any) Tag { + return t()(Attr(name, value)) +} + +// creates a new tag with all the existing attributes and child nodes of the input tag, except the attribute to +// be deleted +func DeleteAttribute(t Tag, name string) Tag { + n := Name(t) + a := AllAttributes(t) + c := Children(t) + delete(a, name) + return NewTag(n, append(c, a)...) +} + +// the same as Attribute(t, "class") +func Class(t Tag) string { + return Attribute(t, "class") +} + +// the same as SetAttribute(t, "class", class) +func SetClass(t Tag, class string) Tag { + return SetAttribute(t, "class", class) +} + +// like SetClass, but it appends the new class to the existing classes, regardless if the same class exists +func AddClass(t Tag, class string) Tag { + current := Class(t) + if current != "" { + class = fmt.Sprintf("%s %s", current, class) + } + + return SetClass(t, class) +} + +// like DeleteAttribute, but it only deletes the specified class from the class attribute +func DeleteClass(t Tag, class string) Tag { + c := Class(t) + cc := strings.Split(c, " ") + + var ccc []string + for _, ci := range cc { + if ci != class { + ccc = append(ccc, ci) + } + } + + return SetClass(t, strings.Join(ccc, " ")) +} + +// returns the child nodes of a tag +func Children(t Tag) []any { + var q childrenQuery + t()(&q) + c := make([]any, len(q.value)) + copy(c, q.value) + return c +} + +// renders html with t as the root node with indentation +// child nodes are rendered via fmt.Sprint, tolerating fmt.Stringer implementations +// consecutive spaces are considered to be so on purpose, and are converted into   +// spaces around tags can behave different from when using unindented rendering +// as a last resort, one can use rendered html inside a verbatim tag +func RenderIndent(out io.Writer, indent string, pwidth int, t Tag) error { + r := renderer{out: out, indent: indent, pwidth: pwidth} + t()(&r) + return r.err +} + +// renders html with t as the root node without indentation +func Render(out io.Writer, t Tag) error { + return RenderIndent(out, "", 0, t) +} + +// creates a new tag from t marking it verbatim. The content of verbatim tags is rendered without HTML escaping. +// This may cause security issues when using it in an incosiderate way. The tag can contain non-tag child nodes. +// Verbatim content gets indented when rendering with indentation +func Verbatim(t Tag) Tag { + return t()(renderGuide{verbatim: true}) +} + +// marks a tag as script-style content for rendering. Script-style content is not escaped and not indented +func ScriptContent(t Tag) Tag { + return t()(renderGuide{script: true}) +} + +// inline tags are not broken into separate lines when rendering with indentation +// deprecated in HTML, but only used for indentation +func Inline(t Tag) Tag { + return t()(renderGuide{inline: true}) +} + +// void tags do not accept child nodes +// deprecated in HTML, but only used for indentation +func Void(t Tag) Tag { + return t()(renderGuide{void: true}) +} + +// same name, same attributes, same child tags, child nodes in the same order and equal by reference or value +// depending on the child node type +func Eq(t ...Tag) bool { + tt := make([]Tag, len(t)) + for i := range t { + tt[i] = t[i]() + } + + return eq(tt...) +} + +// turns a template into a tag for composition +func FromTemplate[Data any](f Template[Data]) Tag { + return func(a ...any) Tag { + var ( + t Data + ok bool + ) + + for i := range a { + t, ok = a[0].(Data) + if !ok { + continue + } + + a = append(a[:i], a[i+1:]...) + break + } + + return f(t)(a...) + } +} + +// in the functional programming sense +func Map(data []any, tag Tag) []Tag { + var tags []Tag + for _, d := range data { + tags = append(tags, tag(d)) + } + + return tags +} diff --git a/lib_test.go b/lib_test.go new file mode 100644 index 0000000..23a52c8 --- /dev/null +++ b/lib_test.go @@ -0,0 +1,101 @@ +package html_test + +import ( + "code.squareroundforest.org/arpio/html" + . "code.squareroundforest.org/arpio/html/tags" + "testing" +) + +func TestLib(t *testing.T) { + t.Run("templated tag", func(t *testing.T) { + type ( + member struct { + name string + level int + } + + team struct { + name string + rank int + members []member + } + ) + + memberHTML := html.FromTemplate( + func(m member) Tag { + return Li( + Div("Name: ", m.name), + Div("Level: ", m.level), + ) + }, + ) + + teamHTML := html.FromTemplate( + func(t team) Tag { + return Div( + H3(t.name), + P("Rank: ", t.rank), + Ul(html.Map(t.members, memberHTML)...), + ) + }, + ) + + myTeam := team{ + name: "Foo", + rank: 3, + members: []member{{ + name: "Bar", + level: 4, + }, { + name: "Baz", + level: 1, + }, { + name: "Qux", + level: 4, + }}, + } + + var b bytes.Buffer + if html.RenderIndent(&b, "\t", 0, teamHTML(myTeam)); err != nil { + t.Fatal(err) + } + + if b.String() != `
+

+ Foo +

+

+ Rank: 3 +

+ +
+` { + t.Fatal(b.String()) + } + }) +} diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..1c44364 --- /dev/null +++ b/notes.txt @@ -0,0 +1,5 @@ +rendering types: +like
: inline void +inline
: block void +like script: no escaping +split the validation from the rendering diff --git a/promote-to-tags.txt b/promote-to-tags.txt new file mode 100644 index 0000000..ce2aad6 --- /dev/null +++ b/promote-to-tags.txt @@ -0,0 +1,2 @@ +Attr +NewTag diff --git a/query.go b/query.go new file mode 100644 index 0000000..29278cb --- /dev/null +++ b/query.go @@ -0,0 +1,129 @@ +package html + +type nameQuery struct { + value string +} + +type attributesQuery struct { + value Attributes +} + +type attributeQuery struct { + name string + value string + found bool +} + +type childrenQuery struct { + value []any +} + +type validator struct { + err error +} + +type renderGuidesQuery struct { + value []renderGuide +} + +func groupChildren(c []any) ([]Attributes, []any, []renderGuide) { + var ( + a []Attributes + cc []any + rg []renderGuide + ) + + for _, ci := range c { + if ai, ok := ci.(Attributes); ok { + a = append(a, ai) + continue + } + + if rgi, ok := ci.(renderGuide); ok { + rg = append(rg, rgi) + continue + } + + cc = append(cc, ci) + } + + return a, cc, rg +} + +func mergeAttributes(c []any) Attributes { + a, _, _ := groupChildren(c) + if len(a) == 0 { + return nil + } + + to := make(Attributes) + for _, ai := range a { + for name, value := range ai { + to[name] = value + } + } + + return to +} + +func findAttribute(c []any, name string) (string, bool) { + a, _, _ := groupChildren(c) + for i := len(a) - 1; i >= 0; i-- { + value, ok := a[i][name] + if ok { + return value, true + } + } + + return "", false +} + +func handleQuery(name string, children []any) bool { + if len(children) == 0 { + return false + } + + last := len(children) - 1 + lastChild := children[last] + if q, ok := lastChild.(*nameQuery); ok { + q.value = name + return true + } + + if q, ok := lastChild.(*attributesQuery); ok { + q.value = mergeAttributes(children[:last]) + return true + } + + if q, ok := lastChild.(*attributeQuery); ok { + q.value, q.found = findAttribute(children[:last], q.name) + return true + } + + if q, ok := lastChild.(*childrenQuery); ok { + _, q.value, _ = groupChildren(children[:last]) + return true + } + + if q, ok := lastChild.(*renderGuidesQuery); ok { + _, _, q.value = groupChildren(children[:last]) + return true + } + + if v, ok := lastChild.(*validator); ok { + v.err = validate(name, children[:last]) + return true + } + + if r, ok := lastChild.(*renderer); ok { + if err := validate(name, children[:last]); err != nil { + r.err = err + return true + } + + render(r, name, children[:last]) + return true + } + + return false +} diff --git a/query_test.go b/query_test.go new file mode 100644 index 0000000..de3f911 --- /dev/null +++ b/query_test.go @@ -0,0 +1,150 @@ +package html_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/html" + . "code.squareroundforest.org/arpio/html/tags" + "testing" +) + +func TestQuery(t *testing.T) { + t.Run("group children", func(t *testing.T) { + inlineDiv := html.Inline(Div(Attr("foo", "bar"), "baz")) + attr := html.AllAttributes(inlineDiv) + if len(attr) != 1 || attr["foo"] != "bar" { + t.Fatal() + } + + c := html.Children(inlineDiv) + if len(c) != 1 || c[0] != "baz" { + t.Fatal() + } + + var b bytes.Buffer + if err := html.Render(&b, inlineDiv); err != nil { + t.Fatal(err) + } + + h := b.String() + if h != `
baz
` { + t.Fatal() + } + }) + + t.Run("merge attributes", func(t *testing.T) { + t.Run("has attributes", func(t *testing.T) { + div := Div(Attr("foo", "bar")) + attr := html.AllAttributes(div) + if len(attr) != 1 || attr["foo"] != "bar" { + t.Fatal() + } + }) + + t.Run("no attributes", func(t *testing.T) { + div := Div() + attr := html.AllAttributes(div) + if len(attr) != 0 { + t.Fatal() + } + }) + }) + + t.Run("find attributes", func(t *testing.T) { + t.Run("exists", func(t *testing.T) { + div := Div(Attr("foo", "bar")) + if html.Attribute(div, "foo") != "bar" { + t.Fatal() + } + }) + + t.Run("does not exist", func(t *testing.T) { + div := Div(Attr("foo", "bar")) + if html.Attribute(div, "qux") != "" { + t.Fatal() + } + }) + }) + + t.Run("handle query", func(t *testing.T) { + t.Run("no chlidren", func(t *testing.T) { + div := Div() + div2 := div() + if html.Name(div2) != "div" || !html.Eq(div, div2) { + t.Fatal(html.Name(div2)) + } + }) + + t.Run("name", func(t *testing.T) { + div := Div() + if html.Name(div) != "div" { + t.Fatal() + } + }) + + t.Run("all attributes", func(t *testing.T) { + div := Div(Attr("foo", "bar", "baz", "qux")) + attr := html.AllAttributes(div) + if len(attr) != 2 || attr["foo"] != "bar" || attr["baz"] != "qux" { + t.Fatal() + } + }) + + t.Run("one attribute", func(t *testing.T) { + div := Div(Attr("foo", "bar", "baz", "qux")) + foo := html.Attribute(div, "foo") + if foo != "bar" { + t.Fatal() + } + }) + + t.Run("children", func(t *testing.T) { + div := Div("foo", "bar", "baz") + c := html.Children(div) + if len(c) != 3 || c[0] != "foo" || c[1] != "bar" || c[2] != "baz" { + t.Fatal() + } + }) + + t.Run("render guides", func(t *testing.T) { + div := Div(Span("foo")) + + var b bytes.Buffer + if err := html.RenderIndent(&b, "\t", 0, div); err != nil { + t.Fatal(err) + } + + if b.String() != "
\n\tfoo\n
\n" { + t.Fatal(b.String()) + } + }) + + t.Run("validate and render", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { + script := Script(`function() { return "Hello, world!" }`) + + var b bytes.Buffer + if err := html.Render(&b, script); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid", func(t *testing.T) { + div := Div(Attr("foo+", "bar")) + + var b bytes.Buffer + if err := html.Render(&b, div); err == nil { + t.Fatal() + } + }) + + t.Run("invalid child", func(t *testing.T) { + div := Div(Div(Attr("foo+", "bar"))) + + var b bytes.Buffer + if err := html.Render(&b, div); err == nil { + t.Fatal() + } + }) + }) + }) +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..fbbf3d8 --- /dev/null +++ b/render.go @@ -0,0 +1,250 @@ +package html + +import ( + "bytes" + "fmt" + "io" +) + +const defaultPWidth = 112 + +type renderGuide struct { + inline bool + void bool + script bool + verbatim bool +} + +type renderer struct { + out io.Writer + indent string + pwidth int + currentIndent string + err error +} + +func mergeRenderingGuides(rgs []renderGuide) renderGuide { + var rg renderGuide + for _, rgi := range rgs { + rg.inline = rg.inline || rgi.inline + rg.void = rg.void || rgi.void + rg.script = rg.script || rgi.script + rg.verbatim = rg.verbatim || rgi.verbatim + } + + return rg +} + +func attributeEscape(value string) string { + var rr []rune + r := []rune(value) + for i := range r { + switch r[i] { + case '"': + rr = append(rr, []rune(""")...) + 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 ' ', 0xA0: + 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] == ' ' || r[i] == 0xA0 + wsStart = ws && !lastWS + lastWS = ws + } + + return string(rr) +} + +func render(r *renderer, name string, children []any) { + if r.err != nil { + return + } + + printf := func(f string, a ...any) { + if r.err != nil { + return + } + + _, r.err = fmt.Fprintf(r.out, f, a...) + if r.err != nil { + r.err = fmt.Errorf("tag %s: %w", name, r.err) + } + } + + a, c, rgs := groupChildren(children) + rg := mergeRenderingGuides(rgs) + printf(r.currentIndent) + printf("<%s", name) + for _, ai := range a { + for name, value := range ai { + printf(" %s=\"%s\"", name, attributeEscape(value)) + } + } + + printf(">") + if r.indent != "" && !rg.inline && len(c) > 0 { + printf("\n") + } + + if rg.void { + return + } + + var inlineBuffer *bytes.Buffer + if r.indent != "" { + inlineBuffer = bytes.NewBuffer(nil) + } + + // TODO: + // - avoid rendering an inline buffer into another inline buffer + // - why? + // - or, if inline, just use the inline buffer without indentation + // - check the wrapping again, if it preserves or eliminates the spaces the right way + for i, ci := range c { + if tag, ok := ci.(Tag); ok { + if rg.inline { + var rgq renderGuidesQuery + tag(&rgq) + crg := mergeRenderingGuides(rgq.value) + if r.indent != "" && !crg.inline && inlineBuffer.Len() > 0 { + w := r.pwidth + if w == 0 { + w = defaultPWidth + } + + inlineBuffer = wrap(inlineBuffer, w, "") + println(inlineBuffer.String()) + if _, err := io.Copy(r.out, inlineBuffer); err != nil { + r.err = err + return + } + + inlineBuffer = bytes.NewBuffer(nil) + } + + if i > 0 && r.indent != "" && !crg.inline { + printf("\n") + } + + rr := new(renderer) + *rr = *r + rr.indent = "" + rr.currentIndent = "" + tag(rr) + } else { + var rgq renderGuidesQuery + tag(&rgq) + crg := mergeRenderingGuides(rgq.value) + if r.indent != "" && !crg.inline && inlineBuffer.Len() > 0 { + w := r.pwidth + if w == 0 { + w = defaultPWidth + } + + inlineBuffer = wrap(inlineBuffer, w, r.currentIndent+r.indent) + println(inlineBuffer.String()) + if _, err := io.Copy(r.out, inlineBuffer); err != nil { + r.err = err + return + } + + inlineBuffer = bytes.NewBuffer(nil) + } + + if i > 0 && r.indent != "" && !crg.inline { + printf("\n") + } + + rr := new(renderer) + *rr = *r + rr.currentIndent += r.indent + if r.indent != "" && crg.inline { + rr.out = inlineBuffer + } + + tag(rr) + } + + continue + } + + s := fmt.Sprint(ci) + if s == "" { + continue + } + + if !rg.verbatim && !rg.script { + s = htmlEscape(s) + } + + if r.indent == "" { + printf(s) + continue + } + + inlineBuffer.WriteString(s) + } + + if r.indent != "" && inlineBuffer.Len() > 0 { + w := r.pwidth + if w == 0 { + w = defaultPWidth + } + + var indent string + if !rg.inline && !rg.script { + indent = r.currentIndent + r.indent + } + + inlineBuffer = wrap(inlineBuffer, w, indent) + if _, err := io.Copy(r.out, inlineBuffer); err != nil { + r.err = err + return + } + + if !rg.inline { + printf("\n") + } + } + + if !rg.inline { + printf(r.currentIndent) + } + + printf("", name) + if r.indent != "" && !rg.inline { + printf("\n") + } +} diff --git a/render_test.go b/render_test.go new file mode 100644 index 0000000..eee466e --- /dev/null +++ b/render_test.go @@ -0,0 +1,172 @@ +package html_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/html" + . "code.squareroundforest.org/arpio/html/tags" + "errors" + "strings" + "testing" +) + +type failingWriter struct { + after int +} + +func failWriteAfter(n int) *failingWriter { + return &failingWriter{n} +} + +func (w *failingWriter) Write(p []byte) (int, error) { + w.after -= len(p) + if w.after < 0 { + return len(p) + w.after, errors.New("test error") + } + + return len(p), nil +} + +func TestRender(t *testing.T) { + t.Run("merge render guides", func(t *testing.T) { + foo := html.Inline(html.Verbatim(NewTag("foo"))) + foo = foo("") + + var b bytes.Buffer + if err := html.RenderIndent(&b, "\t", 0, foo); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("attribute escaping", func(t *testing.T) { + span := Span(Attr("foo", "bar=\"&\"")) + + var b bytes.Buffer + if err := html.Render(&b, span); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + + t.Run("html escape", func(t *testing.T) { + t.Run("basic escape", func(t *testing.T) { + span := Span("bar&baz") + + var b bytes.Buffer + if err := html.Render(&b, span); err != nil { + t.Fatal(err) + } + + if b.String() != "<foo>bar&baz</foo>" { + t.Fatal(b.String()) + } + }) + + t.Run("consecutive spaces", func(t *testing.T) { + span := Span("consecutive spaces: \" \"") + + var b bytes.Buffer + if err := html.Render(&b, span); err != nil { + t.Fatal(err) + } + + if b.String() != "consecutive spaces: \"   \"" { + t.Fatal(b.String()) + } + }) + }) + + t.Run("write error", func(t *testing.T) { + t.Run("fail immediately", func(t *testing.T) { + div := Div(Span("foo")) + w := failWriteAfter(0) + if err := html.Render(w, div); err == nil || !strings.Contains(err.Error(), "test error") { + t.Fatal() + } + }) + + t.Run("fail in tag", func(t *testing.T) { + div := Div(Span("foo")) + w := failWriteAfter(6) + if err := html.Render(w, div); err == nil || !strings.Contains(err.Error(), "test error") { + t.Fatal() + } + }) + + t.Run("partial text children", func(t *testing.T) { + div := Div("foo", Div("bar"), "baz") + w := failWriteAfter(5) + if err := html.RenderIndent(w, "\t", 0, div); err == nil || !strings.Contains(err.Error(), "test error") { + t.Fatal() + } + }) + + t.Run("text children", func(t *testing.T) { + div := Div("foo", "bar", "baz") + w := failWriteAfter(5) + if err := html.RenderIndent(w, "\t", 0, div); err == nil || !strings.Contains(err.Error(), "test error") { + t.Fatal() + } + }) + }) + + t.Run("indent", func(t *testing.T) { + t.Run("simple tag", func(t *testing.T) { + div := Div(Span("foo")) + + var b bytes.Buffer + if err := html.RenderIndent(&b, "\t", 0, div); err != nil { + t.Fatal(err) + } + + if b.String() != "
\n\tfoo\n
\n" { + t.Fatal(b.String()) + } + }) + + t.Run("empty tag", func(t *testing.T) { + div := Div(Br()) + + var b bytes.Buffer + if err := html.RenderIndent(&b, "\t", 0, div); err != nil { + t.Fatal(err) + } + + if b.String() != "
\n\t
\n
\n" { + t.Fatal(b.String()) + } + }) + + t.Run("inline fragment between blocks", func(t *testing.T) { + div := Div("foo bar baz", Div("qux quux"), "corge") + + var b bytes.Buffer + if err := html.RenderIndent(&b, "\t", 0, div); err != nil { + t.Fatal(err) + } + + if b.String() != "
\n\tfoo bar baz\n\t
\n\t\tqux quux\n\t
\n\tcorge\n
\n" { + t.Fatal(b.String()) + } + }) + + t.Run("block inside inline", func(t *testing.T) { + div := Div(Span("foo bar baz", Div("qux quux"), "corge")) + + var b bytes.Buffer + if err := html.RenderIndent(&b, "XYZ", 0, div); err != nil { + t.Fatal(err) + } + + if b.String() != "" { + t.Fatal(b.String()) + } + }) + }) +} diff --git a/script/generate-tags.go b/script/generate-tags.go new file mode 100644 index 0000000..6637bc2 --- /dev/null +++ b/script/generate-tags.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "strings" + "unicode" +) + +func splitAllBy(s string, ss ...string) []string { + var sss []string + for _, si := range ss { + sis := strings.Split(si, s) + for i := range sis { + sis[i] = strings.TrimSpace(sis[i]) + } + + for _, sisi := range sis { + if sisi != "" { + sss = append(sss, sisi) + } + } + } + + return sss +} + +func main() { + b, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalln(err) + } + + s := string(b) + ss := splitAllBy("\n", s) + ss = splitAllBy(",", ss...) + ss = splitAllBy(" ", ss...) + printf := func(f string, a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintf(os.Stdout, f, a...) + } + + printf("// generated by ../script/generate-tags.go\n") + printf("\n") + printf("package tags\n") + printf("import \"code.squareroundforest.org/arpio/html\"\n") + for _, si := range ss { + exp := fmt.Sprintf("html.NewTag(\"%s\")", si) + for _, a := range os.Args[1:] { + exp = fmt.Sprintf("html.%s(%s)", a, exp) + } + + rname := []rune(si) + rname[0] = unicode.ToUpper(rname[0]) + printf("var %s = %s\n", string(rname), exp) + } +} diff --git a/script/promote-to-tags.go b/script/promote-to-tags.go new file mode 100644 index 0000000..3832459 --- /dev/null +++ b/script/promote-to-tags.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "strings" +) + +func splitAllBy(s string, ss ...string) []string { + var sss []string + for _, si := range ss { + sis := strings.Split(si, s) + for i := range sis { + sis[i] = strings.TrimSpace(sis[i]) + } + + for _, sisi := range sis { + if sisi != "" { + sss = append(sss, sisi) + } + } + } + + return sss +} + +func main() { + b, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalln(err) + } + + s := string(b) + ss := splitAllBy("\n", s) + ss = splitAllBy(",", ss...) + ss = splitAllBy(" ", ss...) + printf := func(f string, a ...any) { + if err != nil { + return + } + + _, err = fmt.Fprintf(os.Stdout, f, a...) + } + + printf("// generated by ../script/generate-tags.go\n") + printf("\n") + printf("package tags\n") + printf("import \"code.squareroundforest.org/arpio/html\"\n") + for _, si := range ss { + printf("var %s = html.%s\n", si, si) + } +} diff --git a/tags.block.txt b/tags.block.txt new file mode 100644 index 0000000..4628ef5 --- /dev/null +++ b/tags.block.txt @@ -0,0 +1,58 @@ +address +article +audio +aside +blockquote +body +canvas +caption +center +col +colgroup +datalist +dd +del +details +dialog +div +dl +dt +fieldset +figcaption +figure +footer +form +head +header +hgroup +html +ins +li +link +main +map +math +menu +nav +noscript +ol +optgroup +p +picture +pre +rp +search +section +summary +table +tbody +td +template +textarea +tfoot +th +thead +title +tr +ul +video diff --git a/tags.inline.txt b/tags.inline.txt new file mode 100644 index 0000000..9cf359b --- /dev/null +++ b/tags.inline.txt @@ -0,0 +1,39 @@ +a +abbr +b +bdi +bdo +button +cite +code +data +dfn +em +h1, h2, h3, h4, h5, h6 +i +kbd +label +legend +mark +meter +object +option +output +progress +q +rt +ruby +s +samp +select +selectedcontent +slot +small +span +strong +sub +sup +svg +time +u +var diff --git a/tags.script.txt b/tags.script.txt new file mode 100644 index 0000000..047d790 --- /dev/null +++ b/tags.script.txt @@ -0,0 +1,2 @@ +script +style diff --git a/tags.void.block.txt b/tags.void.block.txt new file mode 100644 index 0000000..0be925c --- /dev/null +++ b/tags.void.block.txt @@ -0,0 +1,7 @@ +area +base +hr +iframe +meta +source +track diff --git a/tags.void.inline.txt b/tags.void.inline.txt new file mode 100644 index 0000000..8d9d616 --- /dev/null +++ b/tags.void.inline.txt @@ -0,0 +1,5 @@ +br +embed +img +input +wbr diff --git a/tags/block.gen.go b/tags/block.gen.go new file mode 100644 index 0000000..bd830ed --- /dev/null +++ b/tags/block.gen.go @@ -0,0 +1,62 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var Address = html.NewTag("address") +var Article = html.NewTag("article") +var Audio = html.NewTag("audio") +var Aside = html.NewTag("aside") +var Blockquote = html.NewTag("blockquote") +var Body = html.NewTag("body") +var Canvas = html.NewTag("canvas") +var Caption = html.NewTag("caption") +var Center = html.NewTag("center") +var Col = html.NewTag("col") +var Colgroup = html.NewTag("colgroup") +var Datalist = html.NewTag("datalist") +var Dd = html.NewTag("dd") +var Del = html.NewTag("del") +var Details = html.NewTag("details") +var Dialog = html.NewTag("dialog") +var Div = html.NewTag("div") +var Dl = html.NewTag("dl") +var Dt = html.NewTag("dt") +var Fieldset = html.NewTag("fieldset") +var Figcaption = html.NewTag("figcaption") +var Figure = html.NewTag("figure") +var Footer = html.NewTag("footer") +var Form = html.NewTag("form") +var Head = html.NewTag("head") +var Header = html.NewTag("header") +var Hgroup = html.NewTag("hgroup") +var Html = html.NewTag("html") +var Ins = html.NewTag("ins") +var Li = html.NewTag("li") +var Link = html.NewTag("link") +var Main = html.NewTag("main") +var Map = html.NewTag("map") +var Math = html.NewTag("math") +var Menu = html.NewTag("menu") +var Nav = html.NewTag("nav") +var Noscript = html.NewTag("noscript") +var Ol = html.NewTag("ol") +var Optgroup = html.NewTag("optgroup") +var P = html.NewTag("p") +var Picture = html.NewTag("picture") +var Pre = html.NewTag("pre") +var Rp = html.NewTag("rp") +var Search = html.NewTag("search") +var Section = html.NewTag("section") +var Summary = html.NewTag("summary") +var Table = html.NewTag("table") +var Tbody = html.NewTag("tbody") +var Td = html.NewTag("td") +var Template = html.NewTag("template") +var Textarea = html.NewTag("textarea") +var Tfoot = html.NewTag("tfoot") +var Th = html.NewTag("th") +var Thead = html.NewTag("thead") +var Title = html.NewTag("title") +var Tr = html.NewTag("tr") +var Ul = html.NewTag("ul") +var Video = html.NewTag("video") diff --git a/tags/inline.gen.go b/tags/inline.gen.go new file mode 100644 index 0000000..9f2cc2c --- /dev/null +++ b/tags/inline.gen.go @@ -0,0 +1,48 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var A = html.Inline(html.NewTag("a")) +var Abbr = html.Inline(html.NewTag("abbr")) +var B = html.Inline(html.NewTag("b")) +var Bdi = html.Inline(html.NewTag("bdi")) +var Bdo = html.Inline(html.NewTag("bdo")) +var Button = html.Inline(html.NewTag("button")) +var Cite = html.Inline(html.NewTag("cite")) +var Code = html.Inline(html.NewTag("code")) +var Data = html.Inline(html.NewTag("data")) +var Dfn = html.Inline(html.NewTag("dfn")) +var Em = html.Inline(html.NewTag("em")) +var H1 = html.Inline(html.NewTag("h1")) +var H2 = html.Inline(html.NewTag("h2")) +var H3 = html.Inline(html.NewTag("h3")) +var H4 = html.Inline(html.NewTag("h4")) +var H5 = html.Inline(html.NewTag("h5")) +var H6 = html.Inline(html.NewTag("h6")) +var I = html.Inline(html.NewTag("i")) +var Kbd = html.Inline(html.NewTag("kbd")) +var Label = html.Inline(html.NewTag("label")) +var Legend = html.Inline(html.NewTag("legend")) +var Mark = html.Inline(html.NewTag("mark")) +var Meter = html.Inline(html.NewTag("meter")) +var Object = html.Inline(html.NewTag("object")) +var Option = html.Inline(html.NewTag("option")) +var Output = html.Inline(html.NewTag("output")) +var Progress = html.Inline(html.NewTag("progress")) +var Q = html.Inline(html.NewTag("q")) +var Rt = html.Inline(html.NewTag("rt")) +var Ruby = html.Inline(html.NewTag("ruby")) +var S = html.Inline(html.NewTag("s")) +var Samp = html.Inline(html.NewTag("samp")) +var Select = html.Inline(html.NewTag("select")) +var Selectedcontent = html.Inline(html.NewTag("selectedcontent")) +var Slot = html.Inline(html.NewTag("slot")) +var Small = html.Inline(html.NewTag("small")) +var Span = html.Inline(html.NewTag("span")) +var Strong = html.Inline(html.NewTag("strong")) +var Sub = html.Inline(html.NewTag("sub")) +var Sup = html.Inline(html.NewTag("sup")) +var Svg = html.Inline(html.NewTag("svg")) +var Time = html.Inline(html.NewTag("time")) +var U = html.Inline(html.NewTag("u")) +var Var = html.Inline(html.NewTag("var")) diff --git a/tags/promote.gen.go b/tags/promote.gen.go new file mode 100644 index 0000000..b396c03 --- /dev/null +++ b/tags/promote.gen.go @@ -0,0 +1,6 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var Attr = html.Attr +var NewTag = html.NewTag diff --git a/tags/script.gen.go b/tags/script.gen.go new file mode 100644 index 0000000..05f32ca --- /dev/null +++ b/tags/script.gen.go @@ -0,0 +1,6 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var Script = html.ScriptContent(html.NewTag("script")) +var Style = html.ScriptContent(html.NewTag("style")) diff --git a/tags/void.block.gen.go b/tags/void.block.gen.go new file mode 100644 index 0000000..ec15556 --- /dev/null +++ b/tags/void.block.gen.go @@ -0,0 +1,11 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var Area = html.Void(html.NewTag("area")) +var Base = html.Void(html.NewTag("base")) +var Hr = html.Void(html.NewTag("hr")) +var Iframe = html.Void(html.NewTag("iframe")) +var Meta = html.Void(html.NewTag("meta")) +var Source = html.Void(html.NewTag("source")) +var Track = html.Void(html.NewTag("track")) diff --git a/tags/void.inline.gen.go b/tags/void.inline.gen.go new file mode 100644 index 0000000..cb605c6 --- /dev/null +++ b/tags/void.inline.gen.go @@ -0,0 +1,9 @@ +// generated by ../script/generate-tags.go + +package tags +import "code.squareroundforest.org/arpio/html" +var Br = html.Inline(html.Void(html.NewTag("br"))) +var Embed = html.Inline(html.Void(html.NewTag("embed"))) +var Img = html.Inline(html.Void(html.NewTag("img"))) +var Input = html.Inline(html.Void(html.NewTag("input"))) +var Wbr = html.Inline(html.Void(html.NewTag("wbr"))) diff --git a/validate.go b/validate.go new file mode 100644 index 0000000..de0ea0d --- /dev/null +++ b/validate.go @@ -0,0 +1,66 @@ +package html + +import ( + "errors" + "fmt" + "regexp" +) + +var ( + symbolExp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-_.]*$`) + scriptTagExp = regexp.MustCompile(`<\s*/?\s*[sS][cC][rR][iI][pP][tT]([^a-zA-Z0-9]+|$)`) +) + +func validateSymbol(s string) error { + if !symbolExp.MatchString(s) { + return errors.New("invalid symbol") + } + + return nil +} + +func validateTagName(name string) error { + return validateSymbol(name) +} + +func validateAttributeName(name string) error { + return validateSymbol(name) +} + +func validate(name string, children []any) error { + if err := validateTagName(name); err != nil { + return err + } + + a, c, rgs := groupChildren(children) + rg := mergeRenderingGuides(rgs) + if rg.void && len(c) > 0 { + return fmt.Errorf("tag %s is void but it has children", name) + } + + for _, ai := range a { + for name := range ai { + if err := validateAttributeName(name); err != nil { + return fmt.Errorf("tag %s: %w", name, err) + } + } + } + + for _, ci := range c { + if tag, ok := ci.(Tag); ok { + if rg.verbatim || rg.script { + return fmt.Errorf("tag %s does not allow child elements", name) + } + + var v validator + tag(&v) + if v.err != nil { + return v.err + } + + continue + } + } + + return nil +} diff --git a/validate_test.go b/validate_test.go new file mode 100644 index 0000000..8e307c6 --- /dev/null +++ b/validate_test.go @@ -0,0 +1,111 @@ +package html_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/html" + . "code.squareroundforest.org/arpio/html/tags" + "testing" +) + +func TestValidate(t *testing.T) { + t.Run("symbol", func(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + mytag := html.NewTag("foo+bar") + + var b bytes.Buffer + if err := html.Render(&b, mytag); err == nil { + t.Fatal() + } + }) + + t.Run("invalid with allowed chars number", func(t *testing.T) { + mytag := html.NewTag("0foo") + + var b bytes.Buffer + if err := html.Render(&b, mytag); err == nil { + t.Fatal() + } + }) + + t.Run("invalid with allowed chars delimiter", func(t *testing.T) { + mytag := html.NewTag("-foo") + + var b bytes.Buffer + if err := html.Render(&b, mytag); err == nil { + t.Fatal() + } + }) + + t.Run("valid", func(t *testing.T) { + mytag := html.NewTag("foo") + + var b bytes.Buffer + if err := html.Render(&b, mytag); err != nil { + t.Fatal(err) + } + }) + + t.Run("valid with special chars", func(t *testing.T) { + mytag := html.NewTag("foo-bar-1") + + var b bytes.Buffer + if err := html.Render(&b, mytag); err != nil { + t.Fatal(err) + } + }) + }) + + t.Run("invalid attribute name", func(t *testing.T) { + div := Div(Attr("foo+", "bar")) + + var b bytes.Buffer + if err := html.Render(&b, div); err == nil { + t.Fatal() + } + }) + + t.Run("void tag with children", func(t *testing.T) { + br := Br("foo") + + var b bytes.Buffer + if err := html.Render(&b, br); err == nil { + t.Fatal() + } + }) + + t.Run("verbatim with child tag", func(t *testing.T) { + div := html.Verbatim(Div(Br())) + + var b bytes.Buffer + if err := html.Render(&b, div); err == nil { + t.Fatal() + } + }) + + t.Run("script with child tag", func(t *testing.T) { + script := Script(Br()) + + var b bytes.Buffer + if err := html.Render(&b, script); err == nil { + t.Fatal() + } + }) + + t.Run("invalid child tag", func(t *testing.T) { + div := Div(Div(Attr("foo+", "bar"))) + + var b bytes.Buffer + if err := html.Render(&b, div); err == nil { + t.Fatal() + } + }) + + t.Run("valid child tag", func(t *testing.T) { + div := Div(Div(Attr("foo", "bar"))) + + var b bytes.Buffer + if err := html.Render(&b, div); err != nil { + t.Fatal() + } + }) +} diff --git a/wrap.go b/wrap.go new file mode 100644 index 0000000..bbdf077 --- /dev/null +++ b/wrap.go @@ -0,0 +1,89 @@ +package html + +import ( + "bytes" + "unicode" +) + +func words(buf *bytes.Buffer) []string { + var ( + words []string + currentWord []rune + inTag bool + ) + + for { + r, _, err := buf.ReadRune() + if err != nil { + break + } + + if r == unicode.ReplacementChar { + continue + } + + if !inTag && unicode.IsSpace(r) { + if len(currentWord) > 0 { + words, currentWord = append(words, string(currentWord)), nil + } + + continue + } + + currentWord = append(currentWord, r) + inTag = inTag && r != '>' || r == '<' + } + + if len(currentWord) > 0 { + words = append(words, string(currentWord)) + } + + return words +} + +func wrap(buf *bytes.Buffer, pwidth int, indent string) *bytes.Buffer { + var ( + lines [][]string + currentLine []string + currentLen int + ) + + words := words(buf) + for _, w := range words { + if currentLen != 0 { + currentLen++ + } + + currentLen += len(w) + if currentLen > pwidth && len(currentLine) > 0 { + lines = append(lines, currentLine) + currentLine = []string{w} + currentLen = len(w) + continue + } + + currentLine = append(currentLine, w) + } + + if len(currentLine) > 0 { + lines = append(lines, currentLine) + } + + ret := bytes.NewBuffer(nil) + for i, l := range lines { + if i > 0 { + ret.WriteRune('\n') + } + + ret.WriteString(indent) + for j, w := range l { + if j > 0 { + ret.WriteRune(' ') + } + + ret.WriteString(w) + } + } + + return ret +} diff --git a/wrap_test.go b/wrap_test.go new file mode 100644 index 0000000..e19a6b1 --- /dev/null +++ b/wrap_test.go @@ -0,0 +1,76 @@ +package html_test + +import ( + "bytes" + "code.squareroundforest.org/arpio/html" + . "code.squareroundforest.org/arpio/html/tags" + "testing" +) + +func TestWrap(t *testing.T) { + t.Run("broken unicode", func(t *testing.T) { + b := []byte{'f', 0xc2, 'o', 'o'} + span := Span(string(b)) + + var buf bytes.Buffer + if err := html.RenderIndent(&buf, "\t", 0, span); err != nil { + t.Fatal(err) + } + + if buf.String() != "foo" { + t.Fatal(buf.String(), buf.Len(), len("foo"), buf.Bytes(), []byte("foo")) + } + }) + + t.Run("multiple words", func(t *testing.T) { + span := Span("foo bar baz") + + var buf bytes.Buffer + if err := html.RenderIndent(&buf, "\t", 2, span); err != nil { + t.Fatal(err) + } + + if buf.String() != "foo\nbar\nbaz" { + t.Fatal(buf.String()) + } + }) + + t.Run("tag not split", func(t *testing.T) { + span := Span("foo ", Span("bar"), " baz") + + var buf bytes.Buffer + if err := html.RenderIndent(&buf, "\t", 2, span); err != nil { + t.Fatal(err) + } + + if buf.String() != "foo\nbar\nbaz" { + t.Fatal() + } + }) + + t.Run("normal text", func(t *testing.T) { + div := Div(Span("foo bar baz qux quux corge")) + + var buf bytes.Buffer + if err := html.RenderIndent(&buf, "\t", 9, div); err != nil { + t.Fatal(err) + } + + if buf.String() != "
\n\tfoo\n\tbar baz\n\tqux quux\n\tcorge\n
\n" { + t.Fatal(buf.String()) + } + }) + + t.Run("inline space preserved", func(t *testing.T) { + div := Div(Span("foo"), " ", Span("bar")) + + var buf bytes.Buffer + if err := html.RenderIndent(&buf, "\t", 0, div); err != nil { + t.Fatal(err) + } + + if buf.String() != "
\n\tfoo bar\n
\n" { + t.Fatal(buf.String()) + } + }) +}