├── .gitignore
├── README.md
├── diff.go
├── doc.go
├── indexed_byte_reader.go
├── karma
├── bench-mac.conf.js
├── bench-windows.conf.js
├── go
│ ├── bench.go
│ └── test.go
├── js
│ └── support
│ │ └── polyfill
│ │ └── typedarray.js
├── test-mac.conf.js
└── test-windows.conf.js
├── parse.go
├── parse_bench_test.go
├── parse_test.go
├── patch.go
├── scripts
├── bench.sh
└── test.sh
└── tree.go
/.gitignore:
--------------------------------------------------------------------------------
1 | karma/js/*.js
2 | karma/js/*.js.map
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | vdom
2 | ====
3 |
4 | [](https://godoc.org/github.com/albrow/vdom)
5 |
6 | vdom is a virtual dom implementation written in go which is compatible with
7 | [gopherjs](http://www.gopherjs.org/) and inspired by
8 | [react.js](http://facebook.github.io/react/). The primary purpose of
9 | vdom is to improve the performance of view rendering in
10 | [humble](https://github.com/soroushjp/humble), a framework that lets you write
11 | frontend web apps in pure go and compile them to js to be run in the browser.
12 | However, vdom is framework agnostic, and generally will work whenever you can
13 | render html for your views as a slice of bytes.
14 |
15 |
16 | Development Status
17 | ------------------
18 |
19 | vdom is no longer actively maintained. Additionally, ad hoc testing suggests that it might currently
20 | be slower than `setInnerHTML` in at least some cases.
21 |
22 | Most users today will likely want to use WebAssembly instead of GopherJS.
23 |
24 | Still, this repo might be a decent starting point for anyone wishing to create a virtual DOM implementation
25 | in Go.
26 |
27 | Browser Compatibility
28 | ---------------------
29 |
30 | vdom has been tested and works with IE9+ and the latest versions of Chrome, Safari, and Firefox.
31 |
32 | Javascript code generated with gopherjs uses typed arrays, so in order to work with IE9, you will
33 | need a polyfill. There is one in karma/js/support/polyfill/typedarray.js which is used for the
34 | karma tests.
35 |
36 |
37 | Installing
38 | ----------
39 |
40 | Assuming you have already installed go and set up your go workspace, you can install
41 | vdom like you would any other package:
42 |
43 | `go get github.com/albrow/vdom`
44 |
45 | Import in your go code:
46 |
47 | `import "github.com/albrow/vdom"`
48 |
49 | Install the latest version of [gopherjs](https://github.com/gopherjs/gopherjs), which
50 | compiles your go code to javascript:
51 |
52 | `go get -u github.com/gopherjs/gopherjs`
53 |
54 | When you are ready, compile your go code to javascript using the `gopherjs` command line
55 | tool. Then include the resulting js file in your application.
56 |
57 |
58 | Quickstart Guide
59 | ----------------
60 |
61 | I'll update this section when all functionality is completed. For now, here's a preview
62 | of what usage will probably look like.
63 |
64 | Assuming you have a go html template called todo.tmpl:
65 |
66 | ```html
67 |
68 |
69 |
70 |
71 |
72 |
73 | ```
74 |
75 | And a Todo view/model type that looks like this:
76 |
77 | ```go
78 | type Todo struct {
79 | Title string
80 | Completed bool
81 | Root dom.Element
82 | tree *vdom.Tree
83 | }
84 | ```
85 |
86 | You could do the following:
87 |
88 | ```go
89 | var todoTmpl = template.Must(template.ParseFiles("todo.tmpl"))
90 |
91 | func (todo *Todo) Render() error {
92 | // Execute the template with the given todo and write to a buffer
93 | buf := bytes.NewBuffer([]byte{})
94 | if err := tmpl.Execute(buf, todo); err != nil {
95 | return err
96 | }
97 | // Parse the resulting html into a virtual tree
98 | newTree, err := vdom.Parse(buf.Bytes())
99 | if err != nil {
100 | return err
101 | }
102 | // Calculate the diff between this render and the last render
103 | patches, err := vdom.Diff(todo.tree, newTree)
104 | if err != nil {
105 | return err
106 | }
107 | // Effeciently apply changes to the actual DOM
108 | if err := patches.Patch(todo.Root); err != nil {
109 | return err
110 | }
111 | // Remember the virtual DOM state for the next render to diff against
112 | todo.tree = newTree
113 | }
114 | ```
115 |
116 | Testing
117 | -------
118 |
119 | vdom uses three sets of tests. If you're on a unix system, you can run all the tests
120 | in one go with `scripts/test.sh`. The script also compiles the go files to javsacript each
121 | time it runs. You will still need to install the dependencies for the script to work correctly.
122 |
123 | ### Go Tests
124 |
125 | Traditional go tests can be run with `go test .`. These tests are for code which does not
126 | interact with the DOM or depend on js-specific features.
127 |
128 | ### Gopherjs Tests
129 |
130 | You can run `gopherjs test github.com/albrow/vdom` to compile the same tests from above
131 | to javascript and tests them with node.js. This will also test some code which might depend
132 | on js-specific features (but not the DOM) and can't be tested with pure go. You will need
133 | to install [node.js](http://nodejs.org/) to run these tests.
134 |
135 | ### Karma Tests
136 |
137 | vdom uses karma and the jasmine test framework to test code that interacts with the DOM in
138 | real browsers. You will need to install these dependencies:
139 |
140 | - [node.js](http://nodejs.org/)
141 | - [karma](http://karma-runner.github.io/0.12/index.html)
142 | - [karma-jasmine](https://github.com/karma-runner/karma-jasmine)
143 |
144 | Don't forget to also install the karma command line tools with `npm install -g karma-cli`.
145 |
146 | You will also need to install a launcher for each browser you want to test with, as well as the
147 | browsers themselves. Typically you install a karma launcher with `npm install -g karma-chrome-launcher`.
148 | You can edit the config files `karma/test-mac.conf.js` and `karma/test-windows.conf.js` if you want
149 | to change the browsers that are tested on. The Mac OS config specifies Chrome, Firefox, and Safari, and
150 | the Windows config specifies IE9-11, Chrome, and Firefox. You only need to install IE11, since the
151 | older versions can be tested via emulation.
152 |
153 | Once you have installed all the dependencies, start karma with `karma start karma/test-mac.conf.js` or
154 | `karma start karma/test-windows.conf.js` depending on your operating system. If you are using a unix
155 | machine, simply copy one of the config files and edit the browsers section as needed. Once karma is
156 | running, you can keep it running in between tests.
157 |
158 | Next you need to compile the test.go file to javascript so it can run in the browsers:
159 |
160 | ```
161 | gopherjs build karma/go/test.go -o karma/js/test.js
162 | ```
163 |
164 | Finally run the tests:
165 |
166 | ```
167 | karma run
168 | ```
169 |
170 | Benchmarking
171 | ------------
172 |
173 | vdom uses three sets of benchmarks. If you're on a unix system, you can run all the benchmarks
174 | in one go with `scripts/bench.sh`. The script also compiles the go files to javsacript each
175 | time it runs. You will still need to install the dependencies for the script to work correctly.
176 |
177 | **NOTE:** There are some additional dependencies for benchmarking that are not needed for testing.
178 |
179 | ### Go Benchmarks
180 |
181 | Traditional go benchmarks can be run with `go test -bench . -run none`. I don't expect you
182 | to be using vdom in a pure go context (but there's nothing stopping you from doing so!), so
183 | these tests mainly serve as a comparison to the gopherjs benchmarks. It also helps with
184 | catching obvious performance problems early.
185 |
186 | ### Gopherjs Benchmarks
187 |
188 | To compile the library to javascript and benchmark it with node.js, you can run
189 | `gopherjs test github.com/albrow/vdom --bench=. --run=none`. These benchmarks are only
190 | for code that doesn't interact directly with the DOM. You will need to install
191 | [node.js](http://nodejs.org/) to run these benchmarks.
192 |
193 | ### Karma Benchmarks
194 |
195 | vdom uses karma and benchmark.js to test code that interacts with the DOM in real browsers.
196 | You will need to install these dependencies:
197 |
198 | - [node.js](http://nodejs.org/)
199 | - [karma](http://karma-runner.github.io/0.12/index.html)
200 | - [karma-benchmark](https://github.com/JamieMason/karma-benchmark)
201 |
202 | Don't forget to also install the karma command line tools with `npm install -g karma-cli`.
203 |
204 | Just like with the tests, you will need to install a launcher for each browser you want to test with.
205 |
206 | Once you have installed all the dependencies, start karma with `karma start karma/bench-mac.conf.js` or
207 | `karma start karma/bench-windows.conf.js` depending on your operating system. We have to use
208 | different config files because of a [limitation of karma-benchmark](https://github.com/JamieMason/karma-benchmark/issues/7).
209 | You will probably want to kill karma and restart it if you were running it with the test configuration.
210 | If you are using a unix machine, simply copy one of the config files and edit the browsers section as
211 | needed. Once karma is running, you can keep it running in between benchmarks.
212 |
213 | Next you need to compile the bench.go file to javascript so it can run in the browsers:
214 |
215 | ```
216 | gopherjs build karma/go/bench.go -o karma/js/bench.js
217 | ```
218 |
219 | Finally run the benchmarks:
220 |
221 | ```
222 | karma run
223 | ```
224 |
--------------------------------------------------------------------------------
/diff.go:
--------------------------------------------------------------------------------
1 | package vdom
2 |
3 | func Diff(t, other *Tree) (PatchSet, error) {
4 | patches := []Patcher{}
5 | if err := recursiveDiff(&patches, t.Children, other.Children); err != nil {
6 | return nil, err
7 | }
8 | return patches, nil
9 | }
10 |
11 | func recursiveDiff(patches *[]Patcher, nodes, otherNodes []Node) error {
12 | numOtherNodes := len(otherNodes)
13 | numNodes := len(nodes)
14 | minNumNodes := numOtherNodes
15 | if numOtherNodes > numNodes {
16 | // There are more otherNodes than there are nodes.
17 | // We should append the additional nodes.
18 | for _, otherNode := range otherNodes[numNodes:] {
19 | *patches = append(*patches, &Append{
20 | Parent: otherNode.Parent(),
21 | Child: otherNode,
22 | })
23 | }
24 | minNumNodes = numNodes
25 | } else if numNodes > numOtherNodes {
26 | // There are more nodes than there are otherNodes.
27 | // We should remove the additional children.
28 | for _, node := range nodes[numOtherNodes:] {
29 | *patches = append(*patches, &Remove{
30 | Node: node,
31 | })
32 | }
33 | minNumNodes = numOtherNodes
34 | }
35 | for i := 0; i < minNumNodes; i++ {
36 | otherNode := otherNodes[i]
37 | node := nodes[i]
38 | if match, _ := CompareNodes(node, otherNode, false); !match {
39 | // The nodes have different tag names or values. We should replace
40 | // node with otherNode
41 | *patches = append(*patches, &Replace{
42 | Old: node,
43 | New: otherNode,
44 | })
45 | continue
46 | }
47 | // NOTE: Since CompareNodes checks the type,
48 | // we can only reach here if the nodes are of
49 | // the same type.
50 | if otherEl, ok := otherNode.(*Element); ok {
51 | // Both nodes are elements. We need to treat them differently because
52 | // they have children and attributes.
53 | el := node.(*Element)
54 | // Add the patches needed to make the attributes match (if any)
55 | diffAttributes(patches, el, otherEl)
56 | // Recursively apply diff algorithm to each element's children
57 | recursiveDiff(patches, el.Children(), otherEl.Children())
58 | }
59 | }
60 | return nil
61 | }
62 |
63 | // diffAttributes compares the attributes in el to the attributes in otherEl
64 | // and adds the necessary patches to make the attributes in el match those in
65 | // otherEl
66 | func diffAttributes(patches *[]Patcher, el, otherEl *Element) {
67 | otherAttrs := otherEl.AttrMap()
68 | attrs := el.AttrMap()
69 | for attrName := range attrs {
70 | // Remove any attributes in el that are not found in otherEl
71 | if _, found := otherAttrs[attrName]; !found {
72 | *patches = append(*patches, &RemoveAttr{
73 | Node: el,
74 | AttrName: attrName,
75 | })
76 | }
77 | }
78 | // Now iterate through the attributes in otherEl
79 | for name, otherValue := range otherAttrs {
80 | value, found := attrs[name]
81 | if !found {
82 | // The attribute exists in otherEl but not in el,
83 | // we should add it.
84 | *patches = append(*patches, &SetAttr{
85 | Node: el,
86 | Attr: &Attr{
87 | Name: name,
88 | Value: otherValue,
89 | },
90 | })
91 | } else if value != otherValue {
92 | // The attribute exists in el but has a different value
93 | // than it does in otherEl. We should set it to the value
94 | // in otherEl.
95 | *patches = append(*patches, &SetAttr{
96 | Node: el,
97 | Attr: &Attr{
98 | Name: name,
99 | Value: otherValue,
100 | },
101 | })
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | // package vdom is a virtual dom implementation compatible with gopherjs
2 | package vdom
3 |
--------------------------------------------------------------------------------
/indexed_byte_reader.go:
--------------------------------------------------------------------------------
1 | package vdom
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | )
7 |
8 | // NewIndexedByteReader returns a new IndexedByteReader which will
9 | // read from buf.
10 | func NewIndexedByteReader(buf []byte) *IndexedByteReader {
11 | return &IndexedByteReader{buf: buf}
12 | }
13 |
14 | // IndexedByteReader satisfies io.Reader and io.ByteReader and also
15 | // adds some additional methods for searching the buffer and returning
16 | // the current offset.
17 | type IndexedByteReader struct {
18 | buf []byte
19 | off int
20 | }
21 |
22 | // Read satisfies io.Reader.
23 | func (r *IndexedByteReader) Read(p []byte) (int, error) {
24 | n := copy(p, r.buf[r.off:])
25 | r.off += n
26 | return n, nil
27 | }
28 |
29 | // ReadByte satisfies io.ByteReader. We expect that xml.Decoder will
30 | // upgrade the IndexedByteReader to a io.ByteReader and call this method
31 | // instead of Read.
32 | func (r *IndexedByteReader) ReadByte() (byte, error) {
33 | if r.off >= len(r.buf) {
34 | // Reached the end of the buffer
35 | return 0, io.EOF
36 | }
37 | c := r.buf[r.off]
38 | r.off++
39 | return c, nil
40 | }
41 |
42 | // Offset returns the current offset position for r, i.e.,
43 | // the number of bytes that have been read so far.
44 | func (r *IndexedByteReader) Offset() int {
45 | return r.off
46 | }
47 |
48 | // BackwardsSearch starts at start and iterates backwards through r.buf[min:max]
49 | // until it finds b. It returns the index of b if b was found within the given interval
50 | // and -1 if it was not. It returns an error if min or max is outside the bounds of r.buf.
51 | func (r *IndexedByteReader) BackwardsSearch(min int, max int, b byte) (int, error) {
52 | if min >= len(r.buf) || min < 0 {
53 | return -1, fmt.Errorf("Error in BackwardsSearch min %d is out of bounds. r has buf of length %d", min, len(r.buf))
54 | }
55 | if max >= len(r.buf) || max < min {
56 | return -1, fmt.Errorf("Error in BackwardsSearch max %d is out of bounds. r has buf of length %d and min was %d", min, len(r.buf), min)
57 | }
58 | for j := max; j >= min; j-- {
59 | if r.buf[j] == b {
60 | return j, nil
61 | }
62 | }
63 | return -1, nil
64 | }
65 |
--------------------------------------------------------------------------------
/karma/bench-mac.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration for benchmarking code that interactis with the DOM on
2 | // Mac OS X
3 |
4 | module.exports = function(config) {
5 | config.set({
6 |
7 | // base path that will be used to resolve all patterns (eg. files, exclude)
8 | basePath: '',
9 |
10 |
11 | // frameworks to use
12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
13 | frameworks: ['benchmark'],
14 |
15 |
16 | // list of files / patterns to load in the browser
17 | files: [
18 | 'js/bench.js'
19 | ],
20 |
21 |
22 | // list of files to exclude
23 | exclude: [],
24 |
25 |
26 | // preprocess matching files before serving them to the browser
27 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
28 | preprocessors: {},
29 |
30 |
31 | // test results reporter to use
32 | // possible values: 'dots', 'progress'
33 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
34 | reporters: ['benchmark'],
35 |
36 |
37 | // web server port
38 | port: 9876,
39 |
40 |
41 | // enable / disable colors in the output (reporters and logs)
42 | colors: true,
43 |
44 |
45 | // level of logging
46 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
47 | logLevel: config.LOG_INFO,
48 |
49 |
50 | // enable / disable watching file and executing tests whenever any file changes
51 | autoWatch: false,
52 |
53 |
54 | // start these browsers
55 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
56 | browsers: [
57 | 'Chrome-Bench',
58 | 'Firefox',
59 | 'Safari'
60 | ],
61 |
62 | customLaunchers: {
63 | 'Chrome-Bench': {
64 | base: 'Chrome',
65 | flags: ['--enable-benchmarking']
66 | }
67 | },
68 |
69 |
70 | // Continuous Integration mode
71 | // if true, Karma captures browsers, runs the tests and exits
72 | singleRun: false
73 | });
74 | };
75 |
--------------------------------------------------------------------------------
/karma/bench-windows.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration for benchmarking code that interactis with the DOM on
2 | // Windows Operating Systems
3 |
4 | module.exports = function(config) {
5 | config.set({
6 |
7 | // base path that will be used to resolve all patterns (eg. files, exclude)
8 | basePath: '',
9 |
10 |
11 | // frameworks to use
12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
13 | frameworks: ['benchmark'],
14 |
15 |
16 | // list of files / patterns to load in the browser
17 | files: [
18 | 'js/support/polyfill/*.js', // polyfill for older versions of IE
19 | 'js/bench.js'
20 | ],
21 |
22 |
23 | // list of files to exclude
24 | exclude: [],
25 |
26 |
27 | // preprocess matching files before serving them to the browser
28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
29 | preprocessors: {},
30 |
31 |
32 | // test results reporter to use
33 | // possible values: 'dots', 'progress'
34 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
35 | reporters: ['benchmark'],
36 |
37 |
38 | // web server port
39 | port: 9876,
40 |
41 |
42 | // enable / disable colors in the output (reporters and logs)
43 | colors: true,
44 |
45 |
46 | // level of logging
47 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
48 | logLevel: config.LOG_INFO,
49 |
50 |
51 | // enable / disable watching file and executing tests whenever any file changes
52 | autoWatch: false,
53 |
54 |
55 | // start these browsers
56 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
57 | browsers: [
58 | 'Chrome',
59 | 'Firefox',
60 | 'IE',
61 | 'IE10',
62 | 'IE9'
63 | ],
64 |
65 | customLaunchers: {
66 | IE10: {
67 | base: 'IE',
68 | 'x-ua-compatible': 'IE=EmulateIE10'
69 | },
70 | IE9: {
71 | base: 'IE',
72 | 'x-ua-compatible': 'IE=EmulateIE9'
73 | }
74 | },
75 |
76 |
77 | // Continuous Integration mode
78 | // if true, Karma captures browsers, runs the tests and exits
79 | singleRun: false
80 | });
81 | };
82 |
--------------------------------------------------------------------------------
/karma/go/bench.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "github.com/albrow/vdom"
6 | "github.com/gopherjs/gopherjs/js"
7 | "honnef.co/go/js/dom"
8 | "strconv"
9 | "strings"
10 | )
11 |
12 | var (
13 | document = dom.GetWindow().Document()
14 | sandbox dom.Element
15 | )
16 |
17 | func init() {
18 | sandbox = document.CreateElement("div")
19 | sandbox.SetID("sandbox")
20 | document.QuerySelector("body").AppendChild(sandbox)
21 | }
22 |
23 | func main() {
24 | benchmarkRenderList(sandbox, 3, 1)
25 | benchmarkRenderList(sandbox, 10, 1)
26 | benchmarkRenderList(sandbox, 10, 5)
27 | benchmarkRenderList(sandbox, 10, 10)
28 | benchmarkRenderList(sandbox, 100, 1)
29 | benchmarkRenderList(sandbox, 100, 50)
30 | benchmarkRenderList(sandbox, 100, 100)
31 | }
32 |
33 | // generateList returns html for an unordered list of n items.
34 | func generateList(n int) []byte {
35 | result := []byte("
")
36 | for i := 0; i < n; i++ {
37 | result = append(result, []byte("
")...)
38 | result = append(result, []byte(strconv.Itoa(i))...)
39 | result = append(result, []byte("
")...)
40 | }
41 | result = append(result, []byte("
")...)
42 | return result
43 | }
44 |
45 | // changeList accepts html that contains an unordered list of n
46 | // items and changes the first n items. It returns the new html
47 | // with the changes applied.
48 | func changeList(old []byte, n int) []byte {
49 | newHTML := string(old)
50 | for i := 0; i < n && i < len(old); i++ {
51 | oldItem := fmt.Sprintf("
%d
", i)
52 | newItem := fmt.Sprintf("
new %d
", i)
53 | newHTML = strings.Replace(newHTML, oldItem, newItem, 1)
54 | }
55 | return []byte(newHTML)
56 | }
57 |
58 | // benchmarkRenderList generates html for an unordered list of numItems
59 | // items and sets it as the inner html of root. Then it re-renders the list,
60 | // making numChanges changes. It compares re-rendering with the virtual DOM
61 | // vs. re-rendering via setHTML.
62 | func benchmarkRenderList(root dom.Element, numItems, numChanges int) {
63 | oldHTML := generateList(numItems)
64 | newHTML := changeList(oldHTML, numChanges)
65 | oldTree, err := vdom.Parse(oldHTML)
66 | if err != nil {
67 | panic(err)
68 | }
69 | suiteName := fmt.Sprintf("Re-render list with %d items after %d changes", numItems, numChanges)
70 | js.Global.Call("suite", suiteName, func() {
71 |
72 | js.Global.Call("benchmark", "with SetInnerHTML", func() {
73 | root.SetInnerHTML(string(newHTML))
74 | })
75 |
76 | js.Global.Call("benchmark", "with virtual DOM", func() {
77 | newTree, err := vdom.Parse(newHTML)
78 | if err != nil {
79 | panic(err)
80 | }
81 | patches, err := vdom.Diff(oldTree, newTree)
82 | if err != nil {
83 | panic(err)
84 | }
85 | if err := patches.Patch(root); err != nil {
86 | panic(err)
87 | }
88 | })
89 | }, js.MakeWrapper(map[string]interface{}{
90 | "setup": func() {
91 | root.SetInnerHTML(string(oldHTML))
92 | },
93 | }))
94 | }
95 |
--------------------------------------------------------------------------------
/karma/go/test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/JohannWeging/jasmine"
5 | "github.com/albrow/vdom"
6 | "honnef.co/go/js/dom"
7 | )
8 |
9 | var (
10 | document = dom.GetWindow().Document()
11 | )
12 |
13 | func main() {
14 |
15 | // The body element will be used throughout all tests, often as the
16 | // root or starting point of the virtual tree in the actual DOM.
17 | var body dom.Element
18 |
19 | // Before each test, instantiate the body variable if it is not
20 | // already instantiated.
21 | jasmine.BeforeEach(func() {
22 | if body == nil {
23 | body = document.QuerySelector("body")
24 | }
25 | })
26 |
27 | // After each test, remove everything inside the body element in order
28 | // to prepare for the next test.
29 | jasmine.AfterEach(func() {
30 | body.SetInnerHTML("")
31 | })
32 |
33 | // This test is just checks that the code cross-compiled correctly and can
34 | // be executed by the karma test runner.
35 | jasmine.Describe("Tests", func() {
36 | jasmine.It("can be loaded", func() {
37 | jasmine.Expect(true).ToBe(true)
38 | })
39 | })
40 |
41 | // Test the Element.Selector method in the actual DOM with various different
42 | // html structures.
43 | jasmine.Describe("Selector", func() {
44 |
45 | jasmine.It("works with a single root element", func() {
46 | // Parse some source html into a tree
47 | html := ""
48 | tree := setUpDOM(html, body)
49 | testSelectors(tree, body)
50 | })
51 |
52 | jasmine.It("works with a ul and nested lis", func() {
53 | // Parse some html into a tree
54 | html := "
one
two
three
"
55 | tree := setUpDOM(html, body)
56 | testSelectors(tree, body)
57 | })
58 |
59 | jasmine.It("works with a form with autoclosed tags", func() {
60 | // Parse some html into a tree
61 | html := ``
62 | tree := setUpDOM(html, body)
63 | testSelectors(tree, body)
64 | })
65 | })
66 |
67 | // Test the Append Patcher in the actual DOM with various different html
68 | // structures.
69 | jasmine.Describe("Append", func() {
70 |
71 | jasmine.It("works with a single root element", func() {
72 | testAppendRootPatcher(body, "")
73 | })
74 |
75 | jasmine.It("works with a text root", func() {
76 | testAppendRootPatcher(body, "Text")
77 | })
78 |
79 | jasmine.It("works with a comment root", func() {
80 | testAppendRootPatcher(body, "")
81 | })
82 |
83 | jasmine.It("works with nested siblings", func() {
84 | createAndApplyPatcher(body, "
one
two
", func(tree *vdom.Tree) vdom.Patcher {
85 | // Create a new tree, which only consists of a new li element
86 | // which we want to append
87 | newTree, err := vdom.Parse([]byte("
three
"))
88 | jasmine.Expect(err).ToBe(nil)
89 | // Create a patch manually
90 | return &vdom.Append{
91 | Child: newTree.Children[0],
92 | Parent: tree.Children[0].(*vdom.Element),
93 | }
94 | })
95 | // Test that the patch was applied
96 | ul := body.ChildNodes()[0].(*dom.HTMLUListElement)
97 | jasmine.Expect(ul.InnerHTML()).ToBe("
one
two
three
")
98 | })
99 | })
100 |
101 | // Test the Replace Patcher in the actual DOM with various different html
102 | // structures.
103 | jasmine.Describe("Replace", func() {
104 |
105 | jasmine.It("works with a single root element", func() {
106 | testReplaceRootPatcher(body, ``, ``)
107 | })
108 |
109 | jasmine.It("works with a text root", func() {
110 | testReplaceRootPatcher(body, "Old", "New")
111 | })
112 |
113 | jasmine.It("works with a comment root", func() {
114 | testReplaceRootPatcher(body, "", "")
115 | })
116 |
117 | jasmine.It("works with nested siblings", func() {
118 | createAndApplyPatcher(body, "
one
two
three
", func(tree *vdom.Tree) vdom.Patcher {
119 | // Create a new tree, which only consists of one of the lis
120 | // We want to change it from one to uno
121 | newTree, err := vdom.Parse([]byte("
uno
"))
122 | jasmine.Expect(err).ToBe(nil)
123 | // Create a patch manually
124 | return &vdom.Replace{
125 | Old: tree.Children[0].Children()[0],
126 | New: newTree.Children[0],
127 | }
128 | })
129 | // Test that the patch was applied
130 | ul := body.ChildNodes()[0].(*dom.HTMLUListElement)
131 | jasmine.Expect(ul.InnerHTML()).ToBe("
uno
two
three
")
132 | })
133 | })
134 |
135 | // Test the Remove Patcher in the actual DOM with various different html
136 | // structures.
137 | jasmine.Describe("Remove", func() {
138 |
139 | jasmine.It("works with a single root element", func() {
140 | testRemoveRootPatcher(body, "")
141 | })
142 |
143 | jasmine.It("works with a text root", func() {
144 | testRemoveRootPatcher(body, "Text")
145 | })
146 |
147 | jasmine.It("works with a comment root", func() {
148 | testRemoveRootPatcher(body, "")
149 | })
150 |
151 | jasmine.It("works with nested siblings", func() {
152 | createAndApplyPatcher(body, "
one
two
three
", func(tree *vdom.Tree) vdom.Patcher {
153 | return &vdom.Remove{
154 | Node: tree.Children[0].Children()[1],
155 | }
156 | })
157 | // Test that the patch was applied by checking the innerHTML
158 | // property of the ul node.
159 | ul := body.ChildNodes()[0].(*dom.HTMLUListElement)
160 | jasmine.Expect(ul.InnerHTML()).ToBe("
one
three
")
161 | })
162 | })
163 |
164 | // Test the SetAttr Patcher in the actual DOM with various different html
165 | // structures.
166 | jasmine.Describe("SetAttr", func() {
167 |
168 | jasmine.It("works on a root element", func() {
169 | createAndApplyPatcher(body, "", func(tree *vdom.Tree) vdom.Patcher {
170 | return &vdom.SetAttr{
171 | Node: tree.Children[0],
172 | Attr: &vdom.Attr{
173 | Name: "id",
174 | Value: "foo",
175 | },
176 | }
177 | })
178 | // Test that the patch was applied
179 | jasmine.Expect(body.InnerHTML()).ToBe(``)
180 | })
181 |
182 | jasmine.It("works on a nested element", func() {
183 | createAndApplyPatcher(body, "
"),
227 | testFunc: func(tree *Tree) error {
228 | {
229 | // Test the root of the tree, the ul element
230 | expectedHTML := []byte("
one
two
three
")
231 | if err := expectHTMLEquals(expectedHTML, tree.Children[0].HTML(), "the root ul element"); err != nil {
232 | return err
233 | }
234 | }
235 | lis := tree.Children[0].Children()
236 | {
237 | // Test each li element
238 | expectedHTML := [][]byte{
239 | []byte("
one
"),
240 | []byte("
two
"),
241 | []byte("
three
"),
242 | }
243 | for i, li := range lis {
244 | desc := fmt.Sprintf("li element %d", i)
245 | if err := expectHTMLEquals(expectedHTML[i], li.HTML(), desc); err != nil {
246 | return err
247 | }
248 | }
249 | }
250 | {
251 | // Test each text node inside the li elements
252 | expectedHTML := [][]byte{
253 | []byte("one"),
254 | []byte("two"),
255 | []byte("three"),
256 | }
257 | for i, li := range lis {
258 | gotHTML := li.Children()[0].HTML()
259 | desc := fmt.Sprintf("the text inside li element %d", i)
260 | if err := expectHTMLEquals(expectedHTML[i], gotHTML, desc); err != nil {
261 | return err
262 | }
263 | }
264 | }
265 | return nil
266 | },
267 | },
268 | {
269 | name: "Element with attrs",
270 | src: []byte(``),
271 | testFunc: func(tree *Tree) error {
272 | expectedHTML := []byte(``)
273 | return expectHTMLEquals(expectedHTML, tree.Children[0].HTML(), "root element")
274 | },
275 | },
276 | {
277 | name: "Script tag with escaped characters",
278 | src: []byte(``),
279 | testFunc: func(tree *Tree) error {
280 | {
281 | // Test the root element
282 | expectedHTML := []byte(``)
283 | if err := expectHTMLEquals(expectedHTML, tree.Children[0].HTML(), "root script element"); err != nil {
284 | return err
285 | }
286 | }
287 | {
288 | // Test the text node inside the root element
289 | expectedHTML := []byte(`function((){console.log("")})()`)
290 | if err := expectHTMLEquals(expectedHTML, tree.Children[0].Children()[0].HTML(), "text node inside script element"); err != nil {
291 | return err
292 | }
293 | }
294 | return nil
295 | },
296 | },
297 | {
298 | name: "Form with autoclosed tags",
299 | src: []byte(``),
300 | testFunc: func(tree *Tree) error {
301 | {
302 | // Test the root element
303 | expectedHTML := []byte(``)
304 | if err := expectHTMLEquals(expectedHTML, tree.Children[0].HTML(), "root script element"); err != nil {
305 | return err
306 | }
307 | }
308 | {
309 | inputs := tree.Children[0].Children()
310 | // Test each child input element
311 | expectedHTML := [][]byte{
312 | []byte(``),
313 | []byte(``),
314 | }
315 | for i, input := range inputs {
316 | desc := fmt.Sprintf("input element %d", i)
317 | if err := expectHTMLEquals(expectedHTML[i], input.HTML(), desc); err != nil {
318 | return err
319 | }
320 | }
321 | }
322 | return nil
323 | },
324 | },
325 | {
326 | name: "Multiple Children",
327 | src: []byte("Hello"),
328 | testFunc: func(tree *Tree) error {
329 | expectedHTML := [][]byte{
330 | []byte(``),
331 | []byte(`Hello`),
332 | []byte(``),
333 | }
334 | for i, root := range tree.Children {
335 | desc := fmt.Sprintf("root node %d of type %T", i, root)
336 | if err := expectHTMLEquals(expectedHTML[i], root.HTML(), desc); err != nil {
337 | return err
338 | }
339 | }
340 | return nil
341 | },
342 | },
343 | }
344 | // Iterate through each test case
345 | for i, tc := range testCases {
346 | // Parse the input from tc.src
347 | gotTree, err := Parse(tc.src)
348 | if err != nil {
349 | t.Errorf("Unexpected error in Parse: %s", err.Error())
350 | }
351 | // Use the testFunc to test for certain conditions
352 | if err := tc.testFunc(gotTree); err != nil {
353 | t.Errorf("Error in test case %d (%s):\n%s", i, tc.name, err.Error())
354 | }
355 | }
356 | }
357 |
358 | // expectHTMLEquals returns an error if expected does not equal got. description should be
359 | // a human-readable description of the element that was tested.
360 | func expectHTMLEquals(expected []byte, got []byte, description string) error {
361 | if string(expected) != string(got) {
362 | return fmt.Errorf("HTML for %s was not correct.\n\tExpected: %s\n\tBut got: %s", description, string(expected), string(got))
363 | }
364 | return nil
365 | }
366 |
367 | // TestInnerHTML tests the InnerHTML method for each element in a parsed tree for various different
368 | // inputs.
369 | func TestInnerHTML(t *testing.T) {
370 | // We'll use table-driven testing here.
371 | testCases := []struct {
372 | // A human-readable name describing this test case
373 | name string
374 | // The src html to be parsed
375 | src []byte
376 | // A function which should check the results of the InnerHTML method of each
377 | // node in the parsed tree, and return an error if any results are incorrect.
378 | testFunc func(*Tree) error
379 | }{
380 | {
381 | name: "Element root",
382 | src: []byte(""),
383 | testFunc: func(tree *Tree) error {
384 | expectedInner := []byte("")
385 | el := tree.Children[0].(*Element)
386 | return expectInnerHTMLEquals(expectedInner, el.InnerHTML(), "root element")
387 | },
388 | },
389 | {
390 | name: "ul with nested li's",
391 | src: []byte("
one
two
three
"),
392 | testFunc: func(tree *Tree) error {
393 | {
394 | // Test the root of the tree, the ul element
395 | expectedInner := []byte("
one
two
three
")
396 | el := tree.Children[0].(*Element)
397 | if err := expectInnerHTMLEquals(expectedInner, el.InnerHTML(), "the root ul element"); err != nil {
398 | return err
399 | }
400 | }
401 | lis := tree.Children[0].Children()
402 | {
403 | // Test each li element
404 | expectedInner := [][]byte{
405 | []byte("one"),
406 | []byte("two"),
407 | []byte("three"),
408 | }
409 | for i, li := range lis {
410 | el := li.(*Element)
411 | desc := fmt.Sprintf("li element %d", i)
412 | if err := expectInnerHTMLEquals(expectedInner[i], el.InnerHTML(), desc); err != nil {
413 | return err
414 | }
415 | }
416 | }
417 | return nil
418 | },
419 | },
420 | {
421 | name: "Inner element with attrs",
422 | src: []byte(`
`),
423 | testFunc: func(tree *Tree) error {
424 | expectedInner := []byte(``)
425 | el := tree.Children[0].(*Element)
426 | return expectInnerHTMLEquals(expectedInner, el.InnerHTML(), "root element")
427 | },
428 | },
429 | {
430 | name: "Script tag with escaped characters",
431 | src: []byte(``),
432 | testFunc: func(tree *Tree) error {
433 | expectedInner := []byte(`function((){console.log("")})()`)
434 | el := tree.Children[0].(*Element)
435 | if err := expectInnerHTMLEquals(expectedInner, el.InnerHTML(), "root script element"); err != nil {
436 | return err
437 | }
438 | return nil
439 | },
440 | },
441 | {
442 | name: "Form with autoclosed tags",
443 | src: []byte(``),
444 | testFunc: func(tree *Tree) error {
445 | {
446 | // Test the root element
447 | expectedInner := []byte(``)
448 | el := tree.Children[0].(*Element)
449 | if err := expectInnerHTMLEquals(expectedInner, el.InnerHTML(), "root script element"); err != nil {
450 | return err
451 | }
452 | }
453 | {
454 | inputs := tree.Children[0].Children()
455 | // Test each child input element
456 | for i, input := range inputs {
457 | el := input.(*Element)
458 | desc := fmt.Sprintf("input element %d", i)
459 | if err := expectInnerHTMLEquals([]byte{}, el.InnerHTML(), desc); err != nil {
460 | return err
461 | }
462 | }
463 | }
464 | return nil
465 | },
466 | },
467 | }
468 | // Iterate through each test case
469 | for i, tc := range testCases {
470 | // Parse the input from tc.src
471 | gotTree, err := Parse(tc.src)
472 | if err != nil {
473 | t.Errorf("Unexpected error in Parse: %s", err.Error())
474 | }
475 | // Use the testFunc to test for certain conditions
476 | if err := tc.testFunc(gotTree); err != nil {
477 | t.Errorf("Error in test case %d (%s):\n%s", i, tc.name, err.Error())
478 | }
479 | }
480 | }
481 |
482 | // expectInnerHTMLEquals returns an error if expected does not equal got. description should be
483 | // a human-readable description of the element that was tested.
484 | func expectInnerHTMLEquals(expected []byte, got []byte, description string) error {
485 | if string(expected) != string(got) {
486 | return fmt.Errorf("InnerHTML for %s was not correct.\n\tExpected: %s\n\tBut got: %s", description, string(expected), string(got))
487 | }
488 | return nil
489 | }
490 |
491 | // TestSelector tests the Selector method for each element in the virtual tree for various
492 | // different inputs.
493 | func TestSelector(t *testing.T) {
494 | // We'll use table-driven testing here.
495 | testCases := []struct {
496 | // A human-readable name describing this test case
497 | name string
498 | // The src html to be parsed
499 | src []byte
500 | // A function which should check the results of the Selector method of each
501 | // element in the parsed tree, and return an error if any results are incorrect.
502 | testFunc func(*Tree) error
503 | }{
504 | {
505 | name: "Element root",
506 | src: []byte(""),
507 | testFunc: func(tree *Tree) error {
508 | el := tree.Children[0].(*Element)
509 | return expectSelectorEquals(el, "*:nth-child(1)", "root element")
510 | },
511 | },
512 | {
513 | name: "ul with nested li's",
514 | src: []byte("
one
two
three
"),
515 | testFunc: func(tree *Tree) error {
516 | {
517 | // Test the root of the tree, the ul element
518 | el := tree.Children[0].(*Element)
519 | if err := expectSelectorEquals(el, "*:nth-child(1)", "the root ul element"); err != nil {
520 | return err
521 | }
522 | }
523 | lis := tree.Children[0].Children()
524 | {
525 | // Test each child li element
526 | for i, li := range lis {
527 | el := li.(*Element)
528 | expected := fmt.Sprintf("*:nth-child(1) > *:nth-child(%d)", i+1)
529 | desc := fmt.Sprintf("li element %d", i)
530 | if err := expectSelectorEquals(el, expected, desc); err != nil {
531 | return err
532 | }
533 | }
534 | }
535 | return nil
536 | },
537 | },
538 | {
539 | name: "Form with autoclosed tags",
540 | src: []byte(``),
541 | testFunc: func(tree *Tree) error {
542 | {
543 | // Test the root element
544 | el := tree.Children[0].(*Element)
545 | if err := expectSelectorEquals(el, "*:nth-child(1)", "the root ul element"); err != nil {
546 | return err
547 | }
548 | }
549 | {
550 | inputs := tree.Children[0].Children()
551 | // Test each child input element
552 | for i, input := range inputs {
553 | el := input.(*Element)
554 | expected := fmt.Sprintf("*:nth-child(1) > *:nth-child(%d)", i+1)
555 | desc := fmt.Sprintf("input element %d", i)
556 | if err := expectSelectorEquals(el, expected, desc); err != nil {
557 | return err
558 | }
559 | }
560 | }
561 | return nil
562 | },
563 | },
564 | }
565 | // Iterate through each test case
566 | for i, tc := range testCases {
567 | // Parse the input from tc.src
568 | gotTree, err := Parse(tc.src)
569 | if err != nil {
570 | t.Errorf("Unexpected error in Parse: %s", err.Error())
571 | }
572 | // Use the testFunc to test for certain conditions
573 | if err := tc.testFunc(gotTree); err != nil {
574 | t.Errorf("Error in test case %d (%s):\n%s", i, tc.name, err.Error())
575 | }
576 | }
577 | }
578 |
579 | // expectSelectorEquals returns an error if el.Selector() does not equal expected. description
580 | // should be a human-readable description of the element that was tested, e.g. "the root ul element"
581 | func expectSelectorEquals(el *Element, expected, description string) error {
582 | got := el.Selector()
583 | if expected != got {
584 | return fmt.Errorf("Selector for %s was not correct. Expected `%s` but got `%s`", description, expected, got)
585 | }
586 | return nil
587 | }
588 |
--------------------------------------------------------------------------------
/patch.go:
--------------------------------------------------------------------------------
1 | package vdom
2 |
3 | import (
4 | "fmt"
5 | "github.com/gopherjs/gopherjs/js"
6 | "honnef.co/go/js/dom"
7 | )
8 |
9 | var document dom.Document
10 |
11 | func init() {
12 | // We only want to initialize document if we are running in the browser.
13 | // We can detect this by checking if the document is defined.
14 | if js.Global != nil && js.Global.Get("document") != js.Undefined {
15 | document = dom.GetWindow().Document()
16 | }
17 | }
18 |
19 | // Patcher represents changes that can be made to the DOM.
20 | type Patcher interface {
21 | // Patch applies the given patch to the DOM. The given root
22 | // is a relative starting point for the virtual tree in the
23 | // actual DOM.
24 | Patch(root dom.Element) error
25 | }
26 |
27 | // PatchSet is a set of zero or more Patchers
28 | type PatchSet []Patcher
29 |
30 | // Patch satisfies the Patcher interface and sequentially applies
31 | // all the patches in the patch set.
32 | func (ps PatchSet) Patch(root dom.Element) error {
33 | for _, patch := range ps {
34 | if err := patch.Patch(root); err != nil {
35 | return err
36 | }
37 | }
38 | return nil
39 | }
40 |
41 | // Append is a Patcher which will append a child Node to a parent Node.
42 | type Append struct {
43 | Child Node
44 | Parent *Element
45 | }
46 |
47 | // Patch satisfies the Patcher interface and applies the change to the
48 | // actual DOM.
49 | func (p *Append) Patch(root dom.Element) error {
50 | // fmt.Println("Got parent: ", p.Parent)
51 | // fmt.Println("Parent == nil: ", p.Parent == nil)
52 | // fmt.Println("Parent != nil: ", p.Parent != nil)
53 | var parent dom.Node
54 | if p.Parent != nil {
55 | // fmt.Println("Finding parent in DOM")
56 | parent = findInDOM(p.Parent, root)
57 | } else {
58 | // fmt.Println("Setting parent as root")
59 | parent = root
60 | }
61 | // fmt.Println("Computed parent: ", parent)
62 | child := createForDOM(p.Child)
63 | // fmt.Println("Created child: ", child)
64 | parent.AppendChild(child)
65 | // fmt.Println("Successfully appended")
66 | return nil
67 | }
68 |
69 | // Replace is a Patcher will will replace an old Node with a new Node.
70 | type Replace struct {
71 | Old Node
72 | New Node
73 | }
74 |
75 | // Patch satisfies the Patcher interface and applies the change to the
76 | // actual DOM.
77 | func (p *Replace) Patch(root dom.Element) error {
78 | var parent dom.Node
79 | if p.Old.Parent() != nil {
80 | parent = findInDOM(p.Old.Parent(), root)
81 | } else {
82 | parent = root
83 | }
84 | oldChild := findInDOM(p.Old, root)
85 | newChild := createForDOM(p.New)
86 | parent.ReplaceChild(newChild, oldChild)
87 | return nil
88 | }
89 |
90 | // Remove is a Patcher which will remove the given Node.
91 | type Remove struct {
92 | Node Node
93 | }
94 |
95 | // Patch satisfies the Patcher interface and applies the change to the
96 | // actual DOM.
97 | func (p *Remove) Patch(root dom.Element) error {
98 | var parent dom.Node
99 | if p.Node.Parent() != nil {
100 | parent = findInDOM(p.Node.Parent(), root)
101 | } else {
102 | parent = root
103 | }
104 | self := findInDOM(p.Node, root)
105 | parent.RemoveChild(self)
106 |
107 | // p.Node was removed, so subtract one from the final index for all
108 | // siblings that come after it.
109 | if p.Node.Parent() != nil {
110 | lastIndex := p.Node.Index()[len(p.Node.Index())-1]
111 | for _, sibling := range p.Node.Parent().Children()[lastIndex:] {
112 | switch t := sibling.(type) {
113 | case *Element:
114 | t.index[len(t.index)-1] = t.index[len(t.index)-1] - 1
115 | case *Text:
116 | t.index[len(t.index)-1] = t.index[len(t.index)-1] - 1
117 | case *Comment:
118 | t.index[len(t.index)-1] = t.index[len(t.index)-1] - 1
119 | default:
120 | panic("unreachable")
121 | }
122 | }
123 | }
124 |
125 | return nil
126 | }
127 |
128 | // SettAttr is a Patcher which will set the attribute of the given Node to
129 | // the given Attr. It will overwrite any previous values for the given Attr.
130 | type SetAttr struct {
131 | Node Node
132 | Attr *Attr
133 | }
134 |
135 | // Patch satisfies the Patcher interface and applies the change to the
136 | // actual DOM.
137 | func (p *SetAttr) Patch(root dom.Element) error {
138 | self := findInDOM(p.Node, root).(dom.Element)
139 | self.SetAttribute(p.Attr.Name, p.Attr.Value)
140 | return nil
141 | }
142 |
143 | // RemoveAttr is a Patcher which will remove the attribute with the given
144 | // name from the given Node.
145 | type RemoveAttr struct {
146 | Node Node
147 | AttrName string
148 | }
149 |
150 | // Patch satisfies the Patcher interface and applies the change to the
151 | // actual DOM.
152 | func (p *RemoveAttr) Patch(root dom.Element) error {
153 | self := findInDOM(p.Node, root).(dom.Element)
154 | self.RemoveAttribute(p.AttrName)
155 | return nil
156 | }
157 |
158 | // findInDOM finds the node in the actual DOM corresponding
159 | // to the given virtual node, using the given root as a relative
160 | // starting point.
161 | func findInDOM(node Node, root dom.Element) dom.Node {
162 | el := root.ChildNodes()[node.Index()[0]]
163 | for _, i := range node.Index()[1:] {
164 | el = el.ChildNodes()[i]
165 | }
166 | return el
167 | }
168 |
169 | // createForDOM creates a real node corresponding to the given
170 | // virtual node. It does not insert it into the actual DOM.
171 | func createForDOM(node Node) dom.Node {
172 | switch node.(type) {
173 | case *Element:
174 | vEl := node.(*Element)
175 | el := document.CreateElement(vEl.Name)
176 | for _, attr := range vEl.Attrs {
177 | el.SetAttribute(attr.Name, attr.Value)
178 | }
179 | el.SetInnerHTML(string(vEl.InnerHTML()))
180 | return el
181 | case *Text:
182 | vText := node.(*Text)
183 | textNode := document.CreateTextNode(string(vText.Value))
184 | return textNode
185 | case *Comment:
186 | vComment := node.(*Comment)
187 | commentNode := document.Underlying().Call("createComment", string(vComment.Value))
188 | return dom.WrapNode(commentNode)
189 | default:
190 | msg := fmt.Sprintf("Don't know how to create node for type %T", node)
191 | panic(msg)
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/scripts/bench.sh:
--------------------------------------------------------------------------------
1 | echo "--> running go benchmarks..."
2 | go test . -bench . -run none | sed 's/^/ /'
3 | echo "--> running gopherjs benchmarks..."
4 | gopherjs test github.com/albrow/vdom --bench=. --run=none | sed 's/^/ /'
5 | echo " compiling karma benchmarks to js..."
6 | gopherjs build karma/go/bench.go -o karma/js/bench.js | sed 's/^/ /'
7 | echo " running benchmarks with karma..."
8 | karma run | sed 's/^/ /'
9 | echo "DONE."
10 |
--------------------------------------------------------------------------------
/scripts/test.sh:
--------------------------------------------------------------------------------
1 | echo "--> running go tests..."
2 | go test . | sed 's/^/ /'
3 | echo "--> running gopherjs tests..."
4 | gopherjs test github.com/albrow/vdom | sed 's/^/ /'
5 | echo "--> running karma tests..."
6 | echo " compiling karma tests to js..."
7 | gopherjs build karma/go/test.go -o karma/js/test.js | sed 's/^/ /'
8 | echo " running tests with karma..."
9 | karma run | sed 's/^/ /'
10 | echo "DONE."
11 |
--------------------------------------------------------------------------------
/tree.go:
--------------------------------------------------------------------------------
1 | package vdom
2 |
3 | import (
4 | "fmt"
5 | "html"
6 | "reflect"
7 | )
8 |
9 | // A Tree is a virtual, in-memory representation of a DOM tree
10 | type Tree struct {
11 | // Children is the first-level child nodes for the tree
12 | Children []Node
13 | reader *IndexedByteReader
14 | src []byte
15 | }
16 |
17 | // HTML returns the html of this tree and recursively its children
18 | // as a slice of bytes.
19 | func (t *Tree) HTML() []byte {
20 | escaped := string(t.src)
21 | return []byte(html.UnescapeString(escaped))
22 | }
23 |
24 | // A Node is an element inside a tree.
25 | type Node interface {
26 | // Parent returns the parent node or nil if there is none
27 | Parent() *Element
28 | // Children returns a slice of child nodes or nil if there
29 | // are none
30 | Children() []Node
31 | // HTML returns the unescaped html of this node and its
32 | // children as a slice of bytes.
33 | HTML() []byte
34 | // Index returns the child indexes starting at the root of the
35 | // virtual tree that can be used to get to this node. So if this
36 | // node is the second child of its parent, and its parent is the first
37 | // child of some root node, Index should return [0, 1]. This means we
38 | // can get to this node via root.ChildNodes()[0].ChildNodes()[1].
39 | Index() []int
40 | }
41 |
42 | // Attr is an html attribute
43 | type Attr struct {
44 | Name string
45 | Value string
46 | }
47 |
48 | // Element is an html element, e.g., . Name does not include the
49 | // <, >, or / symbols.
50 | type Element struct {
51 | Name string
52 | Attrs []Attr
53 | parent *Element
54 | children []Node
55 | tree *Tree
56 | srcStart int
57 | srcEnd int
58 | srcInnerStart int
59 | srcInnerEnd int
60 | autoClosed bool
61 | index []int
62 | }
63 |
64 | func (e *Element) Parent() *Element {
65 | return e.parent
66 | }
67 |
68 | func (e *Element) Children() []Node {
69 | return e.children
70 | }
71 |
72 | func (e *Element) HTML() []byte {
73 | if e.autoClosed {
74 | // If the tag was autoclosed, it has no children. Just construct the html manually
75 | result := []byte(fmt.Sprintf("<%s", e.Name))
76 | for _, attr := range e.Attrs {
77 | result = append(result, []byte(fmt.Sprintf(` %s="%s"`, attr.Name, attr.Value))...)
78 | }
79 | result = append(result, '>')
80 | return result
81 | } else {
82 | escaped := string(e.tree.src[e.srcStart:e.srcEnd])
83 | return []byte(html.UnescapeString(escaped))
84 | }
85 | }
86 |
87 | // AttrMap returns this element's attributes as a map
88 | // of attribute name to attribute value
89 | func (e *Element) AttrMap() map[string]string {
90 | m := map[string]string{}
91 | for _, attr := range e.Attrs {
92 | m[attr.Name] = attr.Value
93 | }
94 | return m
95 | }
96 |
97 | // InnerHTML returns the unescaped html inside of e. So if e
98 | // is
one
two
, it will return
99 | //
one
two
. Since Element is the only type that
100 | // can have children, this only makes sense for the Element type.
101 | func (e *Element) InnerHTML() []byte {
102 | if e.autoClosed {
103 | // If the tag was autoclosed, it has no children, and therefore no inner html.
104 | return nil
105 | } else {
106 | escaped := string(e.tree.src[e.srcInnerStart:e.srcInnerEnd])
107 | return []byte(html.UnescapeString(escaped))
108 | }
109 | }
110 |
111 | // Selector returns a css selector which can be used to find
112 | // the corresponding element in the actual DOM. The selector
113 | // should be applied to the root of the tree, i.e. the starting
114 | // point for the virtual tree in the actual DOM.
115 | func (e *Element) Selector() string {
116 | // Simply use the index field to construct a selector with nth-child.
117 | selector := fmt.Sprintf("*:nth-child(%d)", e.index[0]+1)
118 | for _, i := range e.index[1:] {
119 | selector += fmt.Sprintf(" > *:nth-child(%d)", i+1)
120 | }
121 | return selector
122 | }
123 |
124 | func (e *Element) Index() []int {
125 | return e.index
126 | }
127 |
128 | // Compare non-recursively compares e to other. It does not check
129 | // the child nodes since they can be a Node with any underlying type.
130 | // If you want to compare the parent and children fields, use CompareNodes.
131 | func (e *Element) Compare(other *Element, compareAttrs bool) (bool, string) {
132 | if e.Name != other.Name {
133 | return false, fmt.Sprintf("e.Name was %s but other.Name was %s", e.Name, other.Name)
134 | }
135 | if !compareAttrs {
136 | return true, ""
137 | }
138 | attrs := e.Attrs
139 | otherAttrs := other.Attrs
140 | if len(attrs) != len(otherAttrs) {
141 | return false, fmt.Sprintf("n has %d attrs but other has %d attrs.", len(attrs), len(otherAttrs))
142 | }
143 | for i, attr := range attrs {
144 | otherAttr := otherAttrs[i]
145 | if attr != otherAttr {
146 | return false, fmt.Sprintf("e.Attrs[%d] was %s but other.Attrs[%d] was %s", i, attr, i, otherAttr)
147 | }
148 | }
149 | return true, ""
150 | }
151 |
152 | // Text is a text node inside an xml/html document, i.e. anything
153 | // not surrounded by tags.
154 | type Text struct {
155 | Value []byte
156 | parent *Element
157 | index []int
158 | }
159 |
160 | func (t *Text) Parent() *Element {
161 | return t.parent
162 | }
163 |
164 | func (t *Text) Children() []Node {
165 | // A text node can't have any children
166 | return nil
167 | }
168 |
169 | func (t *Text) HTML() []byte {
170 | return t.Value
171 | }
172 |
173 | func (t *Text) Index() []int {
174 | return t.index
175 | }
176 |
177 | // Compare non-recursively compares t to other. It does not check
178 | // the child nodes since they can be a Node with any underlying type.
179 | // If you want to compare the parent and children fields, use CompareNodes.
180 | func (t *Text) Compare(other *Text) (bool, string) {
181 | if string(t.Value) != string(other.Value) {
182 | return false, fmt.Sprintf("t.Value was %s but other.Value was %s", string(t.Value), string(other.Value))
183 | }
184 | return true, ""
185 | }
186 |
187 | // Comment is an xml/html comment of the form .
188 | // Value does not include the markers.
189 | type Comment struct {
190 | Value []byte
191 | parent *Element
192 | index []int
193 | }
194 |
195 | func (c *Comment) Parent() *Element {
196 | return c.parent
197 | }
198 |
199 | func (c *Comment) Children() []Node {
200 | // A commet node can't have any children
201 | return nil
202 | }
203 |
204 | func (c *Comment) HTML() []byte {
205 | // Re-add the open and close for the tag
206 | result := []byte("")...)
209 | return result
210 | }
211 |
212 | func (c *Comment) Index() []int {
213 | return c.index
214 | }
215 |
216 | // Compare non-recursively compares c to other. It does not check
217 | // the child nodes since they can be a Node with any underlying type.
218 | // If you want to compare the parent and children fields, use CompareNodes.
219 | func (c *Comment) Compare(other *Comment) (bool, string) {
220 | if string(c.Value) != string(other.Value) {
221 | return false, fmt.Sprintf("c.Value was %s but other.Value was %s", string(c.Value), string(other.Value))
222 | }
223 | return true, ""
224 | }
225 |
226 | // Compare recursively compares t to other. It returns false and a detailed
227 | // message if n does not equal other. Otherwise, it returns true and an empty
228 | // string. NOTE: Comare never checks the parent properties of t's
229 | // children. This is so you can construct a comparable tree inside a literal.
230 | // (You can't set the parent field inside a literal).
231 | func (t *Tree) Compare(other *Tree, compareAttrs bool) (bool, string) {
232 | if len(t.Children) != len(other.Children) {
233 | return false, fmt.Sprintf("t had %d first-level children but other had %d", len(t.Children), len(other.Children))
234 | }
235 | for i, root := range t.Children {
236 | otherRoot := other.Children[i]
237 | if match, msg := CompareNodesRecursive(root, otherRoot, compareAttrs); !match {
238 | return false, msg
239 | }
240 | }
241 | return true, ""
242 | }
243 |
244 | // CompareNodes non-recursively compares n to other. It returns false and
245 | // a detailed message if n does not equal other. Otherwise, it returns true and
246 | // an empty string. NOTE: CompareNodes never checks the parent properties of n
247 | // or n's children. This is so you can construct a comparable tree inside a literal.
248 | // (You can't set the parent field inside a literal).
249 | func CompareNodes(n Node, other Node, compareAttrs bool) (bool, string) {
250 | if reflect.TypeOf(n) != reflect.TypeOf(other) {
251 | return false, fmt.Sprintf("n has underlying type %T but the other node has underlying type %T", n, other)
252 | }
253 | switch n.(type) {
254 | case *Element:
255 | el := n.(*Element)
256 | otherEl := other.(*Element)
257 | if match, msg := el.Compare(otherEl, compareAttrs); !match {
258 | return false, msg
259 | }
260 | case *Text:
261 | text := n.(*Text)
262 | otherText := other.(*Text)
263 | if match, msg := text.Compare(otherText); !match {
264 | return false, msg
265 | }
266 | case *Comment:
267 | comment := n.(*Comment)
268 | otherComment := other.(*Comment)
269 | if match, msg := comment.Compare(otherComment); !match {
270 | return false, msg
271 | }
272 | default:
273 | return false, fmt.Sprintf("Don't know how to compare n of underlying type %T", n)
274 | }
275 | return true, ""
276 | }
277 |
278 | // CompareNodesRecursive recursively compares n to other. It returns false and
279 | // a detailed message if n does not equal other. Otherwise, it returns true and
280 | // an empty string. NOTE: CompareNodesRecursive never checks the parent properties
281 | // of n or n's children. This is so you can construct a comparable tree inside a
282 | // literal. (You can't set the parent field inside a literal).
283 | func CompareNodesRecursive(n Node, other Node, compareAttrs bool) (bool, string) {
284 | if match, msg := CompareNodes(n, other, compareAttrs); !match {
285 | return false, msg
286 | }
287 | children := n.Children()
288 | otherChildren := other.Children()
289 | if len(children) != len(otherChildren) {
290 | return false, fmt.Sprintf("n has %d children but other has %d children.", len(children), len(otherChildren))
291 | }
292 | for i, child := range children {
293 | otherChild := otherChildren[i]
294 | if match, msg := CompareNodesRecursive(child, otherChild, compareAttrs); !match {
295 | return false, msg
296 | }
297 | }
298 | return true, ""
299 | }
300 |
--------------------------------------------------------------------------------