gromer

#golang#htmx#ssr

git clone https://git.pyrossh.dev/gromer

gromer is a framework and cli to build multipage web apps in golang using htmx and alpinejs.


4970ae1pyros2097 2020-11-13T19:22:46+05:30
initial commit
Files changed (44) hide show
  1. .gitattributes +2 -0
  2. .gitignore +21 -0
  3. LICENSE +21 -0
  4. app.go +55 -0
  5. app_nowasm.go +32 -0
  6. app_wasm.go +100 -0
  7. cmd/wapp/main.go +310 -0
  8. component.go +380 -0
  9. component_test.go +277 -0
  10. concurrency.go +15 -0
  11. condition.go +121 -0
  12. condition_test.go +175 -0
  13. context.go +20 -0
  14. element.go +456 -0
  15. element_test.go +367 -0
  16. errors/README.md +17 -0
  17. errors/errors.go +208 -0
  18. errors/errors_test.go +153 -0
  19. go.mod +12 -0
  20. go.sum +31 -0
  21. html.go +65 -0
  22. js.go +224 -0
  23. js_nowasm.go +150 -0
  24. js_wasm.go +247 -0
  25. log.go +48 -0
  26. makefile +44 -0
  27. node.go +203 -0
  28. node_test.go +177 -0
  29. range.go +151 -0
  30. range_test.go +91 -0
  31. raw.go +173 -0
  32. raw_test.go +116 -0
  33. readme.md +64 -0
  34. resource.go +151 -0
  35. resource_test.go +85 -0
  36. storage.go +29 -0
  37. storage_nowasm.go +39 -0
  38. storage_test.go +154 -0
  39. storage_wasm.go +72 -0
  40. strings.go +45 -0
  41. testing.go +289 -0
  42. testing_test.go +46 -0
  43. text.go +120 -0
  44. text_test.go +103 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
.gitignore ADDED
@@ -0,0 +1,21 @@
1
+ # Binaries for programs and plugins
2
+ *.exe
3
+ *.exe~
4
+ *.dll
5
+ *.so
6
+ *.dylib
7
+ *.app
8
+
9
+ # Test binary, build with `go test -c`
10
+ *.test
11
+ test
12
+
13
+
14
+ # Output of the go coverage tool, specifically when used with LiteIDE
15
+ *.out
16
+
17
+ # Static generated files
18
+ *.gz
19
+ *.gcloudignore
20
+ *.wasm
21
+ main
LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016 Maxence Charriere
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
app.go ADDED
@@ -0,0 +1,55 @@
1
+ package app
2
+
3
+ import (
4
+ "strings"
5
+ )
6
+
7
+ var (
8
+ staticResourcesURL string
9
+ )
10
+
11
+ // KeepBodyClean prevents third-party Javascript libraries to add nodes to the
12
+ // body element.
13
+ func KeepBodyClean() (close func()) {
14
+ return keepBodyClean()
15
+ }
16
+
17
+ // Reload reloads the current page.
18
+ func Reload() {
19
+ dispatch(func() {
20
+ reload()
21
+ })
22
+ }
23
+
24
+ // Run starts the wasm app and displays the UI node associated with the
25
+ // requested URL path.
26
+ //
27
+ // It panics if Go architecture is not wasm.
28
+ func Run(r RenderFunc) {
29
+ run(r)
30
+ }
31
+
32
+ // StaticResource makes a static resource path point to the right
33
+ // location whether the root directory is remote or not.
34
+ //
35
+ // Static resources are resources located in the web directory.
36
+ //
37
+ // This call is used internally to resolve paths within Cite, Data, Href, Src,
38
+ // and SrcSet. Paths already resolved are skipped.
39
+ func StaticResource(path string) string {
40
+ if !strings.HasPrefix(path, "/web/") &&
41
+ !strings.HasPrefix(path, "web/") {
42
+ return path
43
+ }
44
+
45
+ if !strings.HasPrefix(path, "/") {
46
+ path = "/" + path
47
+ }
48
+
49
+ return staticResourcesURL + path
50
+ }
51
+
52
+ // Window returns the JavaScript "window" object.
53
+ func Window() BrowserWindow {
54
+ return window
55
+ }
app_nowasm.go ADDED
@@ -0,0 +1,32 @@
1
+ // +build !wasm
2
+
3
+ package app
4
+
5
+ import (
6
+ "net/url"
7
+ "os"
8
+ )
9
+
10
+ var (
11
+ window *browserWindow
12
+ )
13
+
14
+ func getenv(k string) string {
15
+ return os.Getenv(k)
16
+ }
17
+
18
+ func keepBodyClean() func() {
19
+ panic(errNoWasm)
20
+ }
21
+
22
+ func navigate(u *url.URL, updateHistory bool) error {
23
+ panic(errNoWasm)
24
+ }
25
+
26
+ func reload() {
27
+ panic(errNoWasm)
28
+ }
29
+
30
+ func run(r RenderFunc) {
31
+ panic(errNoWasm)
32
+ }
app_wasm.go ADDED
@@ -0,0 +1,100 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/url"
7
+ "syscall/js"
8
+ )
9
+
10
+ var (
11
+ body *elem
12
+ content UI
13
+ rootPrefix string
14
+ window = &browserWindow{value: value{Value: js.Global()}}
15
+ )
16
+
17
+ func run(render RenderFunc) {
18
+ defer func() {
19
+ err := recover()
20
+ displayLoadError(err)
21
+ panic(err)
22
+ }()
23
+
24
+ initBody()
25
+ initContent()
26
+ if err := body.replaceChildAt(0, render); err != nil {
27
+ panic("replacing content failed")
28
+ }
29
+ content = render
30
+
31
+ for {
32
+ select {
33
+ case f := <-uiChan:
34
+ f()
35
+ }
36
+ }
37
+ }
38
+
39
+ func initBody() {
40
+ ctx, cancel := context.WithCancel(context.Background())
41
+ body = &elem{
42
+ ctx: ctx,
43
+ ctxCancel: cancel,
44
+ jsvalue: Window().Get("document").Get("body"),
45
+ tag: "body",
46
+ }
47
+ body.setSelf(body)
48
+ }
49
+
50
+ func initContent() {
51
+ ctx, cancel := context.WithCancel(context.Background())
52
+
53
+ content := &elem{
54
+ ctx: ctx,
55
+ ctxCancel: cancel,
56
+ jsvalue: body.JSValue().Get("firstElementChild"),
57
+ tag: "div",
58
+ }
59
+
60
+ content.setSelf(content)
61
+ content.setParent(body)
62
+ body.body = append(body.body, content)
63
+ }
64
+
65
+ func displayLoadError(err interface{}) {
66
+ loadingLabel := Window().
67
+ Get("document").
68
+ Call("getElementById", "app-wasm-loader-label")
69
+ if !loadingLabel.Truthy() {
70
+ return
71
+ }
72
+ loadingLabel.Set("innerText", fmt.Sprint(err))
73
+ }
74
+
75
+ func onPopState(this Value, args []Value) interface{} {
76
+ dispatch(func() {
77
+ // navigate(Window().URL(), false)
78
+ })
79
+ return nil
80
+ }
81
+
82
+ func isExternalNavigation(u *url.URL) bool {
83
+ return u.Host != "" && u.Host != Window().URL().Host
84
+ }
85
+
86
+ func isFragmentNavigation(u *url.URL) bool {
87
+ return u.Fragment != ""
88
+ }
89
+
90
+ func reload() {
91
+ Window().Get("location").Call("reload")
92
+ }
93
+
94
+ func keepBodyClean() func() {
95
+ close := Window().Call("goappKeepBodyClean")
96
+
97
+ return func() {
98
+ close.Invoke()
99
+ }
100
+ }
cmd/wapp/main.go ADDED
@@ -0,0 +1,310 @@
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "log"
7
+ "net/http"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "plugin"
12
+ "strconv"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/markbates/pkger"
17
+ app "github.com/pyros2097/wapp"
18
+ "gopkg.in/fsnotify.v1"
19
+ )
20
+
21
+ const wasmExecTemplate = `const enosys=()=>{const a=new Error("not implemented");return a.code="ENOSYS",a};let outputBuf="";window.fs={constants:{O_WRONLY:-1,O_RDWR:-1,O_CREAT:-1,O_TRUNC:-1,O_APPEND:-1,O_EXCL:-1},writeSync(a,b){outputBuf+=decoder.decode(b);const c=outputBuf.lastIndexOf("\n");return-1!=c&&(console.log(outputBuf.substr(0,c)),outputBuf=outputBuf.substr(c+1)),b.length},write(a,b,c,d,e,f){if(0!==c||d!==b.length||null!==e)return void f(enosys());const g=this.writeSync(a,b);f(null,g)}};const encoder=new TextEncoder("utf-8"),decoder=new TextDecoder("utf-8");class Go{constructor(){this.argv=["js"],this.env={},this.exit=a=>{0!==a&&console.warn("exit code:",a)},this._exitPromise=new Promise(a=>{this._resolveExitPromise=a}),this._pendingEvent=null,this._scheduledTimeouts=new Map,this._nextCallbackTimeoutID=1;const a=(a,b)=>{this.mem.setUint32(a+0,b,!0),this.mem.setUint32(a+4,Math.floor(b/4294967296),!0)},b=a=>{const b=this.mem.getUint32(a+0,!0),c=this.mem.getInt32(a+4,!0);return b+4294967296*c},c=a=>{const b=this.mem.getFloat64(a,!0);if(0!==b){if(!isNaN(b))return b;const c=this.mem.getUint32(a,!0);return this._values[c]}},d=(a,b)=>{const c=2146959360;if("number"==typeof b)return isNaN(b)?(this.mem.setUint32(a+4,2146959360,!0),void this.mem.setUint32(a,0,!0)):0===b?(this.mem.setUint32(a+4,2146959360,!0),void this.mem.setUint32(a,1,!0)):void this.mem.setFloat64(a,b,!0);switch(b){case void 0:return void this.mem.setFloat64(a,0,!0);case null:return this.mem.setUint32(a+4,c,!0),void this.mem.setUint32(a,2,!0);case!0:return this.mem.setUint32(a+4,c,!0),void this.mem.setUint32(a,3,!0);case!1:return this.mem.setUint32(a+4,c,!0),void this.mem.setUint32(a,4,!0);}let d=this._ids.get(b);d===void 0&&(d=this._idPool.pop(),d===void 0&&(d=this._values.length),this._values[d]=b,this._goRefCounts[d]=0,this._ids.set(b,d)),this._goRefCounts[d]++;let e=1;switch(typeof b){case"string":e=2;break;case"symbol":e=3;break;case"function":e=4;}this.mem.setUint32(a+4,2146959360|e,!0),this.mem.setUint32(a,d,!0)},e=a=>{const c=b(a+0),d=b(a+8);return new Uint8Array(this._inst.exports.mem.buffer,c,d)},f=d=>{const e=b(d+0),f=b(d+8),g=Array(f);for(let a=0;a<f;a++)g[a]=c(e+8*a);return g},g=a=>{const c=b(a+0),d=b(a+8);return decoder.decode(new DataView(this._inst.exports.mem.buffer,c,d))},h=Date.now()-performance.now();this.importObject={go:{"runtime.wasmExit":a=>{const b=this.mem.getInt32(a+8,!0);this.exited=!0,delete this._inst,delete this._values,delete this._goRefCounts,delete this._ids,delete this._idPool,this.exit(b)},"runtime.wasmWrite":a=>{const c=b(a+8),d=b(a+16),e=this.mem.getInt32(a+24,!0);fs.writeSync(c,new Uint8Array(this._inst.exports.mem.buffer,d,e))},"runtime.resetMemoryDataView":()=>{this.mem=new DataView(this._inst.exports.mem.buffer)},"runtime.nanotime1":b=>{a(b+8,1e6*(h+performance.now()))},"runtime.walltime1":b=>{const c=new Date().getTime();a(b+8,c/1e3),this.mem.setInt32(b+16,1e6*(c%1e3),!0)},"runtime.scheduleTimeoutEvent":a=>{const c=this._nextCallbackTimeoutID;this._nextCallbackTimeoutID++,this._scheduledTimeouts.set(c,setTimeout(()=>{for(this._resume();this._scheduledTimeouts.has(c);)console.warn("scheduleTimeoutEvent: missed timeout event"),this._resume()},b(a+8)+1)),this.mem.setInt32(a+16,c,!0)},"runtime.clearTimeoutEvent":a=>{const b=this.mem.getInt32(a+8,!0);clearTimeout(this._scheduledTimeouts.get(b)),this._scheduledTimeouts.delete(b)},"runtime.getRandomData":a=>{crypto.getRandomValues(e(a+8))},"syscall/js.finalizeRef":a=>{const b=this.mem.getUint32(a+8,!0);if(this._goRefCounts[b]--,0===this._goRefCounts[b]){const a=this._values[b];this._values[b]=null,this._ids.delete(a),this._idPool.push(b)}},"syscall/js.stringVal":a=>{d(a+24,g(a+8))},"syscall/js.valueGet":a=>{const b=Reflect.get(c(a+8),g(a+16));a=this._inst.exports.getsp(),d(a+32,b)},"syscall/js.valueSet":a=>{Reflect.set(c(a+8),g(a+16),c(a+32))},"syscall/js.valueDelete":a=>{Reflect.deleteProperty(c(a+8),g(a+16))},"syscall/js.valueIndex":a=>{d(a+24,Reflect.get(c(a+8),b(a+16)))},"syscall/js.valueSetIndex":a=>{Reflect.set(c(a+8),b(a+16),c(a+24))},"syscall/js.valueCall":a=>{try{const b=c(a+8),e=Reflect.get(b,g(a+16)),h=f(a+32),i=Reflect.apply(e,b,h);a=this._inst.exports.getsp(),d(a+56,i),this.mem.setUint8(a+64,1)}catch(b){d(a+56,b),this.mem.setUint8(a+64,0)}},"syscall/js.valueInvoke":a=>{try{const b=c(a+8),e=f(a+16),g=Reflect.apply(b,void 0,e);a=this._inst.exports.getsp(),d(a+40,g),this.mem.setUint8(a+48,1)}catch(b){d(a+40,b),this.mem.setUint8(a+48,0)}},"syscall/js.valueNew":a=>{try{const b=c(a+8),e=f(a+16),g=Reflect.construct(b,e);a=this._inst.exports.getsp(),d(a+40,g),this.mem.setUint8(a+48,1)}catch(b){d(a+40,b),this.mem.setUint8(a+48,0)}},"syscall/js.valueLength":b=>{a(b+16,parseInt(c(b+8).length))},"syscall/js.valuePrepareString":b=>{const e=encoder.encode(c(b+8)+"");d(b+16,e),a(b+24,e.length)},"syscall/js.valueLoadString":a=>{const b=c(a+8);e(a+16).set(b)},"syscall/js.valueInstanceOf":a=>{this.mem.setUint8(a+24,c(a+8)instanceof c(a+16))},"syscall/js.copyBytesToGo":b=>{const d=e(b+8),f=c(b+32);if(!(f instanceof Uint8Array))return void this.mem.setUint8(b+48,0);const g=f.subarray(0,d.length);d.set(g),a(b+40,g.length),this.mem.setUint8(b+48,1)},"syscall/js.copyBytesToJS":b=>{const d=c(b+8),f=e(b+16);if(!(d instanceof Uint8Array))return void this.mem.setUint8(b+48,0);const g=f.subarray(0,d.length);d.set(g),a(b+40,g.length),this.mem.setUint8(b+48,1)},debug:a=>{console.log(a)}}}}async run(a){this._inst=a,this.mem=new DataView(this._inst.exports.mem.buffer),this._values=[NaN,0,null,!0,!1,window,this],this._goRefCounts=[],this._ids=new Map,this._idPool=[],this.exited=!1;let b=4096;const c=a=>{const c=b,d=encoder.encode(a+"\0");return new Uint8Array(this.mem.buffer,b,d.length).set(d),b+=d.length,0!=b%8&&(b+=8-b%8),c},d=this.argv.length,e=[];this.argv.forEach(a=>{e.push(c(a))}),e.push(0);const f=Object.keys(this.env).sort();f.forEach(a=>{e.push(c(a+"="+this.env[a]))}),e.push(0);const g=b;e.forEach(a=>{this.mem.setUint32(b,a,!0),this.mem.setUint32(b+4,0,!0),b+=8}),this._inst.exports.run(d,g),this.exited&&this._resolveExitPromise(),await this._exitPromise}_resume(){if(this.exited)throw new Error("Go program has already exited");this._inst.exports.resume(),this.exited&&this._resolveExitPromise()}_makeFuncWrapper(a){const b=this;return function(){const c={id:a,this:this,args:arguments};return b._pendingEvent=c,b._resume(),c.result}}}const go=new Go;WebAssembly.instantiateStreaming(fetch("__path__"),go.importObject).then(a=>go.run(a.instance)).catch(a=>console.error("could not load wasm",a));`
22
+
23
+ func wasmExecJs(path string) string {
24
+ return strings.Replace(wasmExecTemplate, "__path__", path, 1)
25
+ }
26
+
27
+ var watchDelta = 1000 * time.Millisecond
28
+
29
+ type Watcher struct {
30
+ rootdir string
31
+ watcher *fsnotify.Watcher
32
+ watchVendor bool
33
+ update chan string
34
+ }
35
+
36
+ // MustRegisterWatcher creates a new Watcher and starts listening to
37
+ // given folders
38
+ func MustRegisterWatcher() *Watcher {
39
+ w := &Watcher{
40
+ update: make(chan string),
41
+ watchVendor: false,
42
+ }
43
+ var err error
44
+ w.watcher, err = fsnotify.NewWatcher()
45
+ if err != nil {
46
+ log.Fatalf("Could not register watcher: %s", err)
47
+ }
48
+ w.watchFolders()
49
+ return w
50
+ }
51
+
52
+ // Watch listens file updates, and sends signal to
53
+ // update channel when .go and .tmpl files are updated
54
+ func (w *Watcher) Watch() {
55
+ eventSent := false
56
+ for {
57
+ select {
58
+ case event := <-w.watcher.Events:
59
+ // discard chmod events
60
+ if event.Op&fsnotify.Chmod != fsnotify.Chmod {
61
+ // test files do not need a rebuild
62
+ if isTestFile(event.Name) {
63
+ continue
64
+ }
65
+ if !isWatchedFileType(event.Name) {
66
+ continue
67
+ }
68
+ if eventSent {
69
+ continue
70
+ }
71
+ eventSent = true
72
+ // prevent consequent builds
73
+ go func() {
74
+ w.update <- event.Name
75
+ time.Sleep(watchDelta)
76
+ eventSent = false
77
+ }()
78
+
79
+ }
80
+ case err := <-w.watcher.Errors:
81
+ if err != nil {
82
+ log.Fatalf("Watcher error: %s", err)
83
+ }
84
+ return
85
+ }
86
+ }
87
+ }
88
+
89
+ func isTestFile(fileName string) bool {
90
+ return strings.HasSuffix(filepath.Base(fileName), "_test.go")
91
+ }
92
+
93
+ func isWatchedFileType(fileName string) bool {
94
+ ext := filepath.Ext(fileName)
95
+
96
+ return ext == ".go"
97
+ }
98
+
99
+ // Close closes the fsnotify watcher channel
100
+ func (w *Watcher) Close() {
101
+ w.watcher.Close()
102
+ close(w.update)
103
+ }
104
+
105
+ // watchFolders recursively adds folders that will be watched against the changes,
106
+ // starting from the working directory
107
+ func (w *Watcher) watchFolders() {
108
+ wd, err := os.Getwd()
109
+
110
+ if err != nil {
111
+ log.Fatalf("Could not get root working directory: %s", err)
112
+ }
113
+
114
+ filepath.Walk(wd, func(path string, info os.FileInfo, err error) error {
115
+ // skip files
116
+ if info == nil {
117
+ log.Fatalf("wrong watcher package: %s", path)
118
+ }
119
+
120
+ if !info.IsDir() {
121
+ return nil
122
+ }
123
+
124
+ if !w.watchVendor {
125
+ // skip vendor directory
126
+ vendor := fmt.Sprintf("%s/vendor", wd)
127
+ if strings.HasPrefix(path, vendor) {
128
+ return filepath.SkipDir
129
+ }
130
+ }
131
+
132
+ // skip hidden folders
133
+ if len(path) > 1 && strings.HasPrefix(filepath.Base(path), ".") {
134
+ return filepath.SkipDir
135
+ }
136
+
137
+ w.addFolder(path)
138
+
139
+ return err
140
+ })
141
+ }
142
+
143
+ // addFolder adds given folder name to the watched folders, and starts
144
+ // watching it for further changes
145
+ func (w *Watcher) addFolder(name string) {
146
+ println("watch: " + name)
147
+ if err := w.watcher.Add(name); err != nil {
148
+ log.Fatalf("Could not watch folder: %s", err)
149
+ }
150
+ }
151
+
152
+ var routesMap = map[string]func(*app.RenderContext) app.UI{}
153
+
154
+ func getSOPath(p string) string {
155
+ return "build/" + filepath.Base(filepath.Dir(p)) + ".so"
156
+ }
157
+
158
+ func getWasmPath(p string) string {
159
+ return "build/" + filepath.Base(filepath.Dir(p)) + ".wasm"
160
+ }
161
+
162
+ func build(path string) (string, error) {
163
+ soPath := getSOPath(path)
164
+ out, err := exec.Command("go", "build", "-buildmode=plugin", "-o", soPath, path).CombinedOutput()
165
+ if err != nil {
166
+ println(string(out))
167
+ println(err.Error())
168
+ return "", err
169
+ }
170
+ fmt.Printf("wrote: %s\n", soPath)
171
+ return soPath, nil
172
+ }
173
+
174
+ func buildWasm(path string) (string, error) {
175
+ wasmPath := getWasmPath(path)
176
+ cmd := exec.Command("go", "build", "-o", wasmPath, path)
177
+ cmd.Env = os.Environ()
178
+ cmd.Env = append(cmd.Env, "GOOS=js", "GOARCH=wasm")
179
+ out, err := cmd.CombinedOutput()
180
+ if err != nil {
181
+ println(string(out))
182
+ println(err.Error())
183
+ return "", err
184
+ }
185
+ fmt.Printf("wrote: %s\n", wasmPath)
186
+ return wasmPath, nil
187
+ }
188
+
189
+ func getRoute(wd, p string) string {
190
+ basePath := filepath.Join(wd, "pages")
191
+ routePath := strings.Replace(p, basePath, "", 1)
192
+ routePath = strings.Replace(routePath, "/main.go", "", -1)
193
+ routePath = strings.Replace(routePath, "index", "", -1)
194
+ return routePath
195
+ }
196
+
197
+ func buildAll(wd string) {
198
+ basePath := filepath.Join(wd, "pages")
199
+ err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error {
200
+ if err != nil {
201
+ fmt.Printf("prevent panic by handling failure accessing a path %q: %v\n", path, err)
202
+ return err
203
+ }
204
+ if !info.IsDir() {
205
+ fmt.Printf("route: %s\n", getRoute(wd, path))
206
+ soPath, err := build(path)
207
+ load(wd, path, soPath)
208
+ buildWasm(path)
209
+ if err != nil {
210
+ println("could not build")
211
+ return err
212
+ }
213
+ }
214
+ return nil
215
+ })
216
+ if err != nil {
217
+ fmt.Printf("error walking the path %q: %v\n", wd, err)
218
+ return
219
+ }
220
+ }
221
+
222
+ func load(wd, file, soPath string) {
223
+ routePath := getRoute(wd, file)
224
+ p, err := plugin.Open(soPath)
225
+ if err != nil {
226
+ panic(err)
227
+ }
228
+ renderFn, err := p.Lookup("Route")
229
+ if err != nil {
230
+ panic(err)
231
+ }
232
+ routesMap[routePath] = renderFn.(func(*app.RenderContext) app.UI)
233
+ // println(createPage(routesMap[routePath](app.NewRenderContext())).String())
234
+ }
235
+
236
+ func createPage(ui app.UI, wasmPath string) *bytes.Buffer {
237
+ page := bytes.NewBuffer(nil)
238
+ page.WriteString("<!DOCTYPE html>\n")
239
+ app.Html(
240
+ app.Head(
241
+ app.Title("Title"),
242
+ app.Meta("author", "pyros2097"),
243
+ app.Meta("description", "Description"),
244
+ app.Meta("keywords", ""),
245
+ app.Meta("theme-color", ""),
246
+ app.Meta("viewport", "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, viewport-fit=cover"),
247
+ app.Link("icon", "/assets/icon.png"),
248
+ app.Link("apple-touch-icon", "/assets/icon.png"),
249
+ app.Link("manifest", "manifest"),
250
+ app.Script(wasmExecJs(wasmPath)),
251
+ ),
252
+ app.Body(ui),
253
+ ).Html(page)
254
+ return page
255
+ }
256
+
257
+ func serve(wd string) {
258
+ assetsFileServer := http.FileServer(pkger.Dir("./assets"))
259
+ buildFileServer := http.FileServer(pkger.Dir("./build"))
260
+
261
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
262
+ if render, ok := routesMap[r.URL.Path]; ok {
263
+ wasmPath := "/build/"
264
+ if r.URL.Path == "/" {
265
+ wasmPath += "index.wasm"
266
+ } else {
267
+ wasmPath += strings.ReplaceAll(r.URL.Path, "/", "") + ".wasm"
268
+ }
269
+ page := createPage(render(app.NewRenderContext()), wasmPath)
270
+ w.Header().Set("Content-Length", strconv.Itoa(page.Len()))
271
+ w.Header().Set("Content-Type", "text/html")
272
+ w.WriteHeader(http.StatusOK)
273
+ w.Write(page.Bytes())
274
+ } else if strings.Contains(r.URL.Path, "/build") {
275
+ r.URL.Path = strings.Replace(r.URL.Path, "/build", "", 1)
276
+ buildFileServer.ServeHTTP(w, r)
277
+ } else {
278
+ r.URL.Path = strings.Replace(r.URL.Path, "/assets", "", 1)
279
+ assetsFileServer.ServeHTTP(w, r)
280
+ }
281
+ })
282
+ log.Printf("Serving on HTTP port: 1234")
283
+ log.Fatal(http.ListenAndServe(":1234", nil))
284
+ }
285
+
286
+ func main() {
287
+ wd, err := os.Getwd()
288
+ if err != nil {
289
+ fmt.Printf("could not get wd")
290
+ return
291
+ }
292
+ buildAll(wd)
293
+ watcher := MustRegisterWatcher()
294
+ go watcher.Watch()
295
+ go serve(wd)
296
+ for file := range watcher.update {
297
+ println("changed: " + file)
298
+ soPath, err := build(file)
299
+ if err != nil {
300
+ println("go build error: " + soPath)
301
+ continue
302
+ }
303
+ load(wd, file, soPath)
304
+ wasmPath, err := buildWasm(file)
305
+ if err != nil {
306
+ println("wasm build error: " + wasmPath)
307
+ continue
308
+ }
309
+ }
310
+ }
component.go ADDED
@@ -0,0 +1,380 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "reflect"
6
+ "strings"
7
+
8
+ "github.com/pyros2097/wapp/errors"
9
+ )
10
+
11
+ // Composer is the interface that describes a customized, independent and
12
+ // reusable UI element.
13
+ //
14
+ // Satisfying this interface is done by embedding app.Compo into a struct and
15
+ // implementing the Render function.
16
+ //
17
+ // Example:
18
+ // type Hello struct {
19
+ // app.Compo
20
+ // }
21
+ //
22
+ // func (c *Hello) Render() app.UI {
23
+ // return app.Text("hello")
24
+ // }
25
+ type Composer interface {
26
+ UI
27
+
28
+ // Render returns the node tree that define how the component is desplayed.
29
+ Render() UI
30
+
31
+ // Update update the component appearance. It should be called when a field
32
+ // used to render the component has been modified.
33
+ Update()
34
+ }
35
+
36
+ var contextMap = map[int]*RenderContext{}
37
+ var contextIndex = 0
38
+
39
+ func getCurrentContext() *RenderContext {
40
+ return contextMap[contextIndex]
41
+ }
42
+
43
+ type RenderFunc func(ctx *RenderContext) UI
44
+
45
+ func (r RenderFunc) Kind() Kind {
46
+ return FunctionalComponent
47
+ }
48
+
49
+ func (r RenderFunc) JSValue() Value {
50
+ c := getCurrentContext()
51
+ return c.root.JSValue()
52
+ }
53
+
54
+ func (r RenderFunc) Mounted() bool {
55
+ c := getCurrentContext()
56
+ return c.root != nil && c.root.Mounted() &&
57
+ r.self() != nil
58
+ }
59
+
60
+ func (r RenderFunc) Render() UI {
61
+ c := getCurrentContext()
62
+ c.index = 0
63
+ c.eindex = 0
64
+ println("render")
65
+ elems := FilterUIElems(r(c))
66
+ return elems[0]
67
+ }
68
+
69
+ func (r RenderFunc) Update() {
70
+ dispatch(func() {
71
+ if !r.Mounted() {
72
+ return
73
+ }
74
+ println("update")
75
+
76
+ if err := r.updateRoot(); err != nil {
77
+ panic(err)
78
+ }
79
+ })
80
+ }
81
+
82
+ func (r RenderFunc) name() string {
83
+ name := reflect.TypeOf(r.self()).String()
84
+ name = strings.ReplaceAll(name, "main.", "")
85
+ return name
86
+ }
87
+
88
+ func (r RenderFunc) self() UI {
89
+ c := getCurrentContext()
90
+ return c.this
91
+ }
92
+
93
+ func (r RenderFunc) setSelf(n UI) {
94
+ c := getCurrentContext()
95
+ if n != nil {
96
+ println("new context")
97
+ c := NewRenderContext()
98
+ c.this = n.(Composer)
99
+ return
100
+ }
101
+
102
+ c.this = nil
103
+ }
104
+
105
+ func (r RenderFunc) context() context.Context {
106
+ return nil
107
+ }
108
+
109
+ func (r RenderFunc) attributes() map[string]string {
110
+ return nil
111
+ }
112
+
113
+ func (r RenderFunc) eventHandlers() map[string]eventHandler {
114
+ return nil
115
+ }
116
+
117
+ func (r RenderFunc) parent() UI {
118
+ c := getCurrentContext()
119
+ return c.parentElem
120
+ }
121
+
122
+ func (r RenderFunc) setParent(p UI) {
123
+ c := getCurrentContext()
124
+ c.parentElem = p
125
+ }
126
+
127
+ func (r RenderFunc) children() []UI {
128
+ c := getCurrentContext()
129
+ return []UI{c.root}
130
+ }
131
+
132
+ func (r RenderFunc) mount() error {
133
+ c := getCurrentContext()
134
+ if r.Mounted() {
135
+ return errors.New("mounting component failed").
136
+ Tag("reason", "already mounted").
137
+ Tag("name", r.name()).
138
+ Tag("kind", r.Kind())
139
+ }
140
+
141
+ root := r.Render()
142
+ if err := mount(root); err != nil {
143
+ return errors.New("mounting component failed").
144
+ Tag("name", r.name()).
145
+ Tag("kind", r.Kind()).
146
+ Wrap(err)
147
+ }
148
+ root.setParent(c.this)
149
+ c.root = root
150
+ return nil
151
+ }
152
+
153
+ func (r RenderFunc) dismount() {
154
+ c := getCurrentContext()
155
+ for _, v := range c.effectsUnsub {
156
+ if v != nil {
157
+ v()
158
+ }
159
+ }
160
+ dismount(c.root)
161
+ delete(contextMap, c.contextMapIndex)
162
+ contextIndex--
163
+ }
164
+
165
+ func (r RenderFunc) update(n UI) error {
166
+ if r.self() == n || !r.Mounted() {
167
+ return nil
168
+ }
169
+
170
+ if r.Kind() != r.Kind() || n.name() != n.name() {
171
+ return errors.New("updating ui element failed").
172
+ Tag("replace", true).
173
+ Tag("reason", "different element types").
174
+ Tag("current-kind", r.Kind()).
175
+ Tag("current-name", r.name()).
176
+ Tag("updated-kind", n.Kind()).
177
+ Tag("updated-name", n.name())
178
+ }
179
+
180
+ aval := reflect.Indirect(reflect.ValueOf(r.self()))
181
+ bval := reflect.Indirect(reflect.ValueOf(n))
182
+ compotype := reflect.ValueOf(r).Elem().Type()
183
+
184
+ for i := 0; i < aval.NumField(); i++ {
185
+ a := aval.Field(i)
186
+ b := bval.Field(i)
187
+
188
+ if a.Type() == compotype {
189
+ continue
190
+ }
191
+
192
+ if !a.CanSet() {
193
+ continue
194
+ }
195
+
196
+ if !reflect.DeepEqual(a.Interface(), b.Interface()) {
197
+ a.Set(b)
198
+ }
199
+ }
200
+
201
+ return r.updateRoot()
202
+ }
203
+
204
+ func (r RenderFunc) updateRoot() error {
205
+ c := getCurrentContext()
206
+ a := c.root
207
+ println("updateRoot")
208
+ b := r.Render()
209
+
210
+ err := update(a, b)
211
+ if isErrReplace(err) {
212
+ err = r.replaceRoot(b)
213
+ }
214
+
215
+ if err != nil {
216
+ return errors.New("updating component failed").
217
+ Tag("kind", r.Kind()).
218
+ Tag("name", r.name()).
219
+ Wrap(err)
220
+ }
221
+
222
+ return nil
223
+ }
224
+
225
+ func (r RenderFunc) replaceRoot(n UI) error {
226
+ c := getCurrentContext()
227
+ old := c.root
228
+ new := n
229
+
230
+ if err := mount(new); err != nil {
231
+ return errors.New("replacing component root failed").
232
+ Tag("kind", r.Kind()).
233
+ Tag("name", r.name()).
234
+ Tag("root-kind", old.Kind()).
235
+ Tag("root-name", old.name()).
236
+ Tag("new-root-kind", new.Kind()).
237
+ Tag("new-root-name", new.name()).
238
+ Wrap(err)
239
+ }
240
+
241
+ var parent UI
242
+ for {
243
+ parent = r.parent()
244
+ if parent == nil || parent.Kind() == HTML {
245
+ break
246
+ }
247
+ }
248
+
249
+ if parent == nil {
250
+ return errors.New("replacing component root failed").
251
+ Tag("kind", r.Kind()).
252
+ Tag("name", r.name()).
253
+ Tag("reason", "coponent does not have html element parents")
254
+ }
255
+
256
+ c.root = new
257
+ new.setParent(r.self())
258
+
259
+ oldjs := old.JSValue()
260
+ newjs := n.JSValue()
261
+ parent.JSValue().Call("replaceChild", newjs, oldjs)
262
+
263
+ dismount(old)
264
+ return nil
265
+ }
266
+
267
+ type RenderContext struct {
268
+ contextMapIndex int
269
+ parentElem UI
270
+ root UI
271
+ this Composer
272
+ index int
273
+ values map[int]interface{}
274
+ eindex int
275
+ effects map[int][]interface{}
276
+ effectsUnsub map[int]func()
277
+ }
278
+
279
+ func NewRenderContext() *RenderContext {
280
+ c := &RenderContext{
281
+ contextMapIndex: contextIndex,
282
+ values: map[int]interface{}{},
283
+ effects: map[int][]interface{}{},
284
+ effectsUnsub: map[int]func(){},
285
+ }
286
+ contextMap[contextIndex] = c
287
+ // contextIndex++
288
+ return c
289
+ }
290
+
291
+ func (c *RenderContext) UseState(initial interface{}) (func() interface{}, func(v interface{})) {
292
+ i := c.index
293
+ c.index++
294
+ if _, ok := c.values[i]; !ok {
295
+ c.values[i] = initial
296
+ }
297
+ return func() interface{} {
298
+ return c.values[i].(interface{})
299
+ }, func(v interface{}) {
300
+ c.values[i] = v
301
+ // special check so that the backend doesn't crash
302
+ if c.this != nil {
303
+ c.this.Update()
304
+ }
305
+ }
306
+ }
307
+
308
+ func (c *RenderContext) UseInt(initial int) (func() int, func(v int)) {
309
+ getState, setState := c.UseState(initial)
310
+ return func() int {
311
+ return getState().(int)
312
+ }, func(v int) {
313
+ setState(v)
314
+ }
315
+ }
316
+
317
+ func (c *RenderContext) UseEffect(f func() func(), deps ...interface{}) {
318
+ i := c.eindex
319
+ c.eindex++
320
+ if _, ok := c.effects[i]; !ok {
321
+ println("initial deps")
322
+ c.effects[i] = deps
323
+ c.effectsUnsub[i] = f()
324
+ return
325
+ }
326
+ hasChanged := false
327
+ for di, ndv := range deps {
328
+ odv := c.effects[i][di]
329
+ if odv != ndv {
330
+ c.effects[i] = deps
331
+ hasChanged = true
332
+ break
333
+ }
334
+ }
335
+ println("hasChanged", hasChanged)
336
+ if hasChanged {
337
+ f()
338
+ }
339
+ }
340
+
341
+ func (c *RenderContext) UseAtom(a *Atom) interface{} {
342
+ c.UseEffect(func() func() {
343
+ return a.Subscribe(func(v interface{}) {
344
+ c.this.Update()
345
+ })
346
+ })
347
+ return a.Get()
348
+ }
349
+
350
+ type Subscriber func(v interface{})
351
+
352
+ type Atom struct {
353
+ value interface{}
354
+ subscribers []Subscriber
355
+ }
356
+
357
+ func NewAtom(v interface{}) *Atom {
358
+ return &Atom{
359
+ value: v,
360
+ }
361
+ }
362
+
363
+ func (a *Atom) Subscribe(v Subscriber) func() {
364
+ a.subscribers = append(a.subscribers, v)
365
+ i := len(a.subscribers)
366
+ return func() {
367
+ a.subscribers = append(a.subscribers[:i], a.subscribers[i+1:]...)
368
+ }
369
+ }
370
+
371
+ func (a *Atom) Get() interface{} {
372
+ return a.value
373
+ }
374
+
375
+ func (a *Atom) Set(v interface{}) {
376
+ a.value = v
377
+ for _, s := range a.subscribers {
378
+ s(v)
379
+ }
380
+ }
component_test.go ADDED
@@ -0,0 +1,277 @@
1
+ package app
2
+
3
+ // func TestCompoMountDismount(t *testing.T) {
4
+ // testMountDismount(t, []mountTest{
5
+ // {
6
+ // scenario: "component",
7
+ // node: &hello{},
8
+ // },
9
+ // })
10
+ // }
11
+
12
+ // func TestCompoUpdate(t *testing.T) {
13
+ // testUpdate(t, []updateTest{
14
+ // {
15
+ // scenario: "component is updated",
16
+ // a: &bar{Value: "rab"},
17
+ // b: &bar{Value: "bar"},
18
+ // matches: []TestUIDescriptor{
19
+ // {
20
+ // Path: TestPath(),
21
+ // Expected: &bar{Value: "bar"},
22
+ // },
23
+ // {
24
+ // Path: TestPath(0),
25
+ // Expected: Text("bar"),
26
+ // },
27
+ // },
28
+ // },
29
+ // {
30
+ // scenario: "component returns replace error when updated with a non component-element",
31
+ // a: &hello{},
32
+ // b: Text("hello"),
33
+ // replaceErr: true,
34
+ // },
35
+ // {
36
+ // scenario: "component is updated",
37
+ // a: &hello{},
38
+ // b: &hello{Greeting: "world"},
39
+ // matches: []TestUIDescriptor{
40
+ // {
41
+ // Path: TestPath(),
42
+ // Expected: &hello{Greeting: "world"},
43
+ // },
44
+ // {
45
+ // Path: TestPath(0),
46
+ // Expected: Div(),
47
+ // },
48
+ // {
49
+ // Path: TestPath(0, 0),
50
+ // Expected: H1(),
51
+ // },
52
+ // {
53
+ // Path: TestPath(0, 0, 0),
54
+ // Expected: Text("hello, "),
55
+ // },
56
+ // {
57
+ // Path: TestPath(0, 0, 1),
58
+ // Expected: Text("world"),
59
+ // },
60
+ // },
61
+ // },
62
+ // {
63
+ // scenario: "component is replaced by a text",
64
+ // a: Div().Body(
65
+ // &hello{},
66
+ // ),
67
+ // b: Div().Body(
68
+ // Text("hello"),
69
+ // ),
70
+ // matches: []TestUIDescriptor{
71
+ // {
72
+ // Path: TestPath(),
73
+ // Expected: Div(),
74
+ // },
75
+ // {
76
+ // Path: TestPath(0),
77
+ // Expected: Text("hello"),
78
+ // },
79
+ // },
80
+ // },
81
+ // {
82
+ // scenario: "component is replaced by an html element",
83
+ // a: Div().Body(
84
+ // &hello{},
85
+ // ),
86
+ // b: Div().Body(
87
+ // H2().Text("hello"),
88
+ // ),
89
+ // matches: []TestUIDescriptor{
90
+ // {
91
+ // Path: TestPath(),
92
+ // Expected: Div(),
93
+ // },
94
+ // {
95
+ // Path: TestPath(0),
96
+ // Expected: H2(),
97
+ // },
98
+ // {
99
+ // Path: TestPath(0, 0),
100
+ // Expected: Text("hello"),
101
+ // },
102
+ // },
103
+ // },
104
+ // {
105
+ // scenario: "component is replaced by a raw html element",
106
+ // a: Div().Body(
107
+ // &hello{},
108
+ // ),
109
+ // b: Div().Body(
110
+ // Raw("<svg></svg>"),
111
+ // ),
112
+ // matches: []TestUIDescriptor{
113
+ // {
114
+ // Path: TestPath(),
115
+ // Expected: Div(),
116
+ // },
117
+ // {
118
+ // Path: TestPath(0),
119
+ // Expected: Raw("<svg></svg>"),
120
+ // },
121
+ // },
122
+ // },
123
+ // {
124
+ // scenario: "component is replaced by another component",
125
+ // a: Div().Body(
126
+ // &hello{},
127
+ // ),
128
+ // b: Div().Body(
129
+ // &bar{},
130
+ // ),
131
+ // matches: []TestUIDescriptor{
132
+ // {
133
+ // Path: TestPath(),
134
+ // Expected: Div(),
135
+ // },
136
+ // {
137
+ // Path: TestPath(0),
138
+ // Expected: &bar{},
139
+ // },
140
+ // {
141
+ // Path: TestPath(0, 0),
142
+ // Expected: Text(""),
143
+ // },
144
+ // },
145
+ // },
146
+ // {
147
+ // scenario: "component root is updated",
148
+ // a: Div().Body(
149
+ // &foo{Bar: "hello"},
150
+ // ),
151
+ // b: Div().Body(
152
+ // &foo{Bar: "goodbye"},
153
+ // ),
154
+ // matches: []TestUIDescriptor{
155
+ // {
156
+ // Path: TestPath(),
157
+ // Expected: Div(),
158
+ // },
159
+ // {
160
+ // Path: TestPath(0),
161
+ // Expected: &foo{Bar: "goodbye"},
162
+ // },
163
+ // {
164
+ // Path: TestPath(0, 0),
165
+ // Expected: &bar{Value: "goodbye"},
166
+ // },
167
+ // {
168
+ // Path: TestPath(0, 0, 0),
169
+ // Expected: Text("goodbye"),
170
+ // },
171
+ // },
172
+ // },
173
+ // {
174
+ // scenario: "component root is replaced by a component",
175
+ // a: Div().Body(
176
+ // &foo{},
177
+ // ),
178
+ // b: Div().Body(
179
+ // &foo{Bar: "test"},
180
+ // ),
181
+ // matches: []TestUIDescriptor{
182
+ // {
183
+ // Path: TestPath(),
184
+ // Expected: Div(),
185
+ // },
186
+ // {
187
+ // Path: TestPath(0),
188
+ // Expected: &foo{Bar: "test"},
189
+ // },
190
+ // {
191
+ // Path: TestPath(0, 0),
192
+ // Expected: &bar{Value: "test"},
193
+ // },
194
+ // {
195
+ // Path: TestPath(0, 0, 0),
196
+ // Expected: Text("test"),
197
+ // },
198
+ // },
199
+ // },
200
+ // {
201
+ // scenario: "component root is replaced by a non-component",
202
+ // a: Div().Body(
203
+ // &foo{Bar: "test"},
204
+ // ),
205
+ // b: Div().Body(
206
+ // &foo{},
207
+ // ),
208
+ // matches: []TestUIDescriptor{
209
+ // {
210
+ // Path: TestPath(),
211
+ // Expected: Div(),
212
+ // },
213
+ // {
214
+ // Path: TestPath(0),
215
+ // Expected: &foo{},
216
+ // },
217
+ // {
218
+ // Path: TestPath(0, 0),
219
+ // Expected: Text("bar"),
220
+ // },
221
+ // },
222
+ // },
223
+ // })
224
+ // }
225
+
226
+ // type hello struct {
227
+ // Compo
228
+
229
+ // Greeting string
230
+ // onNavURL string
231
+ // }
232
+
233
+ // func (h *hello) OnMount(Context) {
234
+ // }
235
+
236
+ // func (h *hello) OnNav(ctx Context, u *url.URL) {
237
+ // h.onNavURL = u.String()
238
+ // }
239
+
240
+ // func (h *hello) OnDismount(Context) {
241
+ // }
242
+
243
+ // func (h *hello) Render() UI {
244
+ // return Div().Body(
245
+ // H1().Body(
246
+ // Text("hello, "),
247
+ // Text(h.Greeting),
248
+ // ),
249
+ // )
250
+ // }
251
+
252
+ // type foo struct {
253
+ // Compo
254
+ // Bar string
255
+ // }
256
+
257
+ // func (f *foo) Render() UI {
258
+ // return If(f.Bar != "",
259
+ // &bar{Value: f.Bar},
260
+ // ).Else(
261
+ // Text("bar"),
262
+ // )
263
+ // }
264
+
265
+ // type bar struct {
266
+ // Compo
267
+ // Value string
268
+ // onNavURL string
269
+ // }
270
+
271
+ // func (b *bar) OnNav(ctx Context, u *url.URL) {
272
+ // b.onNavURL = u.String()
273
+ // }
274
+
275
+ // func (b *bar) Render() UI {
276
+ // return Text(b.Value)
277
+ // }
concurrency.go ADDED
@@ -0,0 +1,15 @@
1
+ package app
2
+
3
+ var (
4
+ dispatch Dispatcher = Dispatch
5
+ uiChan = make(chan func(), 512)
6
+ )
7
+
8
+ // Dispatcher is a function that executes the given function on the goroutine
9
+ // dedicated to UI.
10
+ type Dispatcher func(func())
11
+
12
+ // Dispatch executes the given function on the UI goroutine.
13
+ func Dispatch(f func()) {
14
+ uiChan <- f
15
+ }
condition.go ADDED
@@ -0,0 +1,121 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "net/url"
6
+
7
+ "github.com/pyros2097/wapp/errors"
8
+ )
9
+
10
+ // Condition represents a control structure that displays nodes depending on a
11
+ // given expression.
12
+ type Condition interface {
13
+ UI
14
+
15
+ // ElseIf sets the condition with the given nodes if previous expressions
16
+ // were not met and given expression is true.
17
+ ElseIf(expr bool, elems ...UI) Condition
18
+
19
+ // Else sets the condition with the given UI elements if previous
20
+ // expressions were not met.
21
+ Else(elems ...UI) Condition
22
+ }
23
+
24
+ // If returns a condition that filters the given elements according to the given
25
+ // expression.
26
+ func If(expr bool, elems ...UI) Condition {
27
+ if !expr {
28
+ elems = nil
29
+ }
30
+
31
+ return condition{
32
+ body: FilterUIElems(elems...),
33
+ satisfied: expr,
34
+ }
35
+ }
36
+
37
+ type condition struct {
38
+ body []UI
39
+ satisfied bool
40
+ }
41
+
42
+ func (c condition) ElseIf(expr bool, elems ...UI) Condition {
43
+ if c.satisfied {
44
+ return c
45
+ }
46
+
47
+ if expr {
48
+ c.body = FilterUIElems(elems...)
49
+ c.satisfied = expr
50
+ }
51
+
52
+ return c
53
+ }
54
+
55
+ func (c condition) Else(elems ...UI) Condition {
56
+ return c.ElseIf(true, elems...)
57
+ }
58
+
59
+ func (c condition) Kind() Kind {
60
+ return Selector
61
+ }
62
+
63
+ func (c condition) JSValue() Value {
64
+ return nil
65
+ }
66
+
67
+ func (c condition) Mounted() bool {
68
+ return false
69
+ }
70
+
71
+ func (c condition) name() string {
72
+ return "if.else"
73
+ }
74
+
75
+ func (c condition) self() UI {
76
+ return c
77
+ }
78
+
79
+ func (c condition) setSelf(UI) {
80
+ }
81
+
82
+ func (c condition) context() context.Context {
83
+ return nil
84
+ }
85
+
86
+ func (c condition) attributes() map[string]string {
87
+ return nil
88
+ }
89
+
90
+ func (c condition) eventHandlers() map[string]eventHandler {
91
+ return nil
92
+ }
93
+
94
+ func (c condition) parent() UI {
95
+ return nil
96
+ }
97
+
98
+ func (c condition) setParent(UI) {
99
+ }
100
+
101
+ func (c condition) children() []UI {
102
+ return c.body
103
+ }
104
+
105
+ func (c condition) mount() error {
106
+ return errors.New("condition is not mountable").
107
+ Tag("name", c.name()).
108
+ Tag("kind", c.Kind())
109
+ }
110
+
111
+ func (c condition) dismount() {
112
+ }
113
+
114
+ func (c condition) update(UI) error {
115
+ return errors.New("condition cannot be updated").
116
+ Tag("name", c.name()).
117
+ Tag("kind", c.Kind())
118
+ }
119
+
120
+ func (c condition) onNav(*url.URL) {
121
+ }
condition_test.go ADDED
@@ -0,0 +1,175 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestCondition(t *testing.T) {
6
+ testUpdate(t, []updateTest{
7
+ {
8
+ scenario: "if is interpreted",
9
+ a: Div().Body(
10
+ If(false,
11
+ H1(),
12
+ ),
13
+ ),
14
+ b: Div().Body(
15
+ If(true,
16
+ H1(),
17
+ ),
18
+ ),
19
+ matches: []TestUIDescriptor{
20
+ {
21
+ Path: TestPath(),
22
+ Expected: Div(),
23
+ },
24
+
25
+ {
26
+ Path: TestPath(0),
27
+ Expected: H1(),
28
+ },
29
+ },
30
+ },
31
+ {
32
+ scenario: "if is not interpreted",
33
+ a: Div().Body(
34
+ If(true,
35
+ H1(),
36
+ ),
37
+ ),
38
+ b: Div().Body(
39
+ If(false,
40
+ H1(),
41
+ ),
42
+ ),
43
+ matches: []TestUIDescriptor{
44
+ {
45
+ Path: TestPath(),
46
+ Expected: Div(),
47
+ },
48
+ {
49
+ Path: TestPath(0),
50
+ Expected: nil,
51
+ },
52
+ },
53
+ },
54
+ {
55
+ scenario: "else if is interpreted",
56
+ a: Div().Body(
57
+ If(true,
58
+ H1(),
59
+ ).ElseIf(false,
60
+ H2(),
61
+ ),
62
+ ),
63
+ b: Div().Body(
64
+ If(false,
65
+ H1(),
66
+ ).ElseIf(true,
67
+ H2(),
68
+ ),
69
+ ),
70
+ matches: []TestUIDescriptor{
71
+ {
72
+ Path: TestPath(),
73
+ Expected: Div(),
74
+ },
75
+
76
+ {
77
+ Path: TestPath(0),
78
+ Expected: H2(),
79
+ },
80
+ },
81
+ },
82
+ {
83
+ scenario: "else if is not interpreted",
84
+ a: Div().Body(
85
+ If(false,
86
+ H1(),
87
+ ).ElseIf(true,
88
+ H2(),
89
+ ),
90
+ ),
91
+ b: Div().Body(
92
+ If(false,
93
+ H1(),
94
+ ).ElseIf(false,
95
+ H2(),
96
+ ),
97
+ ),
98
+ matches: []TestUIDescriptor{
99
+ {
100
+ Path: TestPath(),
101
+ Expected: Div(),
102
+ },
103
+
104
+ {
105
+ Path: TestPath(0),
106
+ Expected: nil,
107
+ },
108
+ },
109
+ },
110
+ {
111
+ scenario: "else is interpreted",
112
+ a: Div().Body(
113
+ If(false,
114
+ H1(),
115
+ ).ElseIf(true,
116
+ H2(),
117
+ ).Else(
118
+ H3(),
119
+ ),
120
+ ),
121
+ b: Div().Body(
122
+ If(false,
123
+ H1(),
124
+ ).ElseIf(false,
125
+ H2(),
126
+ ).Else(
127
+ H3(),
128
+ ),
129
+ ),
130
+ matches: []TestUIDescriptor{
131
+ {
132
+ Path: TestPath(),
133
+ Expected: Div(),
134
+ },
135
+
136
+ {
137
+ Path: TestPath(0),
138
+ Expected: H3(),
139
+ },
140
+ },
141
+ },
142
+ {
143
+ scenario: "else is not interpreted",
144
+ a: Div().Body(
145
+ If(false,
146
+ H1(),
147
+ ).ElseIf(true,
148
+ H2(),
149
+ ).Else(
150
+ H3(),
151
+ ),
152
+ ),
153
+ b: Div().Body(
154
+ If(true,
155
+ H1(),
156
+ ).ElseIf(false,
157
+ H2(),
158
+ ).Else(
159
+ H3(),
160
+ ),
161
+ ),
162
+ matches: []TestUIDescriptor{
163
+ {
164
+ Path: TestPath(),
165
+ Expected: Div(),
166
+ },
167
+
168
+ {
169
+ Path: TestPath(0),
170
+ Expected: H1(),
171
+ },
172
+ },
173
+ },
174
+ })
175
+ }
context.go ADDED
@@ -0,0 +1,20 @@
1
+ package app
2
+
3
+ import "context"
4
+
5
+ // Context represents a context that is tied to a UI element. It is canceled
6
+ // when the element is dismounted.
7
+ //
8
+ // It implements the context.Context interface.
9
+ // https://golang.org/pkg/context/#Context
10
+ type Context struct {
11
+ context.Context
12
+
13
+ // The UI element tied to the context.
14
+ Src UI
15
+
16
+ // The JavaScript value of the element tied to the context. This is a
17
+ // shorthand for:
18
+ // ctx.Src.JSValue()
19
+ JSSrc Value
20
+ }
element.go ADDED
@@ -0,0 +1,456 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+
7
+ "github.com/pyros2097/wapp/errors"
8
+ )
9
+
10
+ type elem struct {
11
+ attrs map[string]string
12
+ body []UI
13
+ ctx context.Context
14
+ ctxCancel func()
15
+ events map[string]eventHandler
16
+ jsvalue Value
17
+ parentElem UI
18
+ selfClosing bool
19
+ tag string
20
+ this UI
21
+ }
22
+
23
+ func (e *elem) Kind() Kind {
24
+ return HTML
25
+ }
26
+
27
+ func (e *elem) JSValue() Value {
28
+ return e.jsvalue
29
+ }
30
+
31
+ func (e *elem) Mounted() bool {
32
+ return e.ctx != nil && e.ctx.Err() == nil &&
33
+ e.self() != nil &&
34
+ e.jsvalue != nil
35
+ }
36
+
37
+ func (e *elem) name() string {
38
+ return e.tag
39
+ }
40
+
41
+ func (e *elem) self() UI {
42
+ return e.this
43
+ }
44
+
45
+ func (e *elem) setSelf(n UI) {
46
+ e.this = n
47
+ }
48
+
49
+ func (e *elem) context() context.Context {
50
+ return e.ctx
51
+ }
52
+
53
+ func (e *elem) attributes() map[string]string {
54
+ return e.attrs
55
+ }
56
+
57
+ func (e *elem) eventHandlers() map[string]eventHandler {
58
+ return e.events
59
+ }
60
+
61
+ func (e *elem) parent() UI {
62
+ return e.parentElem
63
+ }
64
+
65
+ func (e *elem) setParent(p UI) {
66
+ e.parentElem = p
67
+ }
68
+
69
+ func (e *elem) children() []UI {
70
+ return e.body
71
+ }
72
+
73
+ func (e *elem) mount() error {
74
+ if e.Mounted() {
75
+ return errors.New("mounting ui element failed").
76
+ Tag("reason", "already mounted").
77
+ Tag("name", e.name()).
78
+ Tag("kind", e.Kind())
79
+ }
80
+
81
+ e.ctx, e.ctxCancel = context.WithCancel(context.Background())
82
+
83
+ v := Window().Get("document").Call("createElement", e.tag)
84
+ if !v.Truthy() {
85
+ return errors.New("mounting ui element failed").
86
+ Tag("reason", "create javascript node returned nil").
87
+ Tag("name", e.name()).
88
+ Tag("kind", e.Kind())
89
+ }
90
+ e.jsvalue = v
91
+
92
+ for k, v := range e.attrs {
93
+ e.setJsAttr(k, v)
94
+ }
95
+
96
+ for k, v := range e.events {
97
+ e.setJsEventHandler(k, v)
98
+ }
99
+
100
+ for _, c := range e.children() {
101
+ if err := e.appendChild(c, true); err != nil {
102
+ return errors.New("mounting ui element failed").
103
+ Tag("name", e.name()).
104
+ Tag("kind", e.Kind()).
105
+ Wrap(err)
106
+ }
107
+ }
108
+
109
+ return nil
110
+ }
111
+
112
+ func (e *elem) dismount() {
113
+ for _, c := range e.children() {
114
+ dismount(c)
115
+ }
116
+
117
+ for k, v := range e.events {
118
+ e.delJsEventHandler(k, v)
119
+ }
120
+
121
+ e.ctxCancel()
122
+ e.jsvalue = nil
123
+ }
124
+
125
+ func (e *elem) update(n UI) error {
126
+ if !e.Mounted() {
127
+ return nil
128
+ }
129
+
130
+ if n.Kind() != e.Kind() || n.name() != e.name() {
131
+ return errors.New("updating ui element failed").
132
+ Tag("replace", true).
133
+ Tag("reason", "different element types").
134
+ Tag("current-kind", e.Kind()).
135
+ Tag("current-name", e.name()).
136
+ Tag("updated-kind", n.Kind()).
137
+ Tag("updated-name", n.name())
138
+ }
139
+
140
+ e.updateAttrs(n.attributes())
141
+ e.updateEventHandler(n.eventHandlers())
142
+
143
+ achildren := e.children()
144
+ bchildren := n.children()
145
+ i := 0
146
+
147
+ // Update children:
148
+ for len(achildren) != 0 && len(bchildren) != 0 {
149
+ a := achildren[0]
150
+ b := bchildren[0]
151
+
152
+ err := update(a, b)
153
+ if isErrReplace(err) {
154
+ err = e.replaceChildAt(i, b)
155
+ }
156
+
157
+ if err != nil {
158
+ return errors.New("updating ui element failed").
159
+ Tag("kind", e.Kind()).
160
+ Tag("name", e.name()).
161
+ Wrap(err)
162
+ }
163
+
164
+ achildren = achildren[1:]
165
+ bchildren = bchildren[1:]
166
+ i++
167
+ }
168
+
169
+ // Remove children:
170
+ for len(achildren) != 0 {
171
+ if err := e.removeChildAt(i); err != nil {
172
+ return errors.New("updating ui element failed").
173
+ Tag("kind", e.Kind()).
174
+ Tag("name", e.name()).
175
+ Wrap(err)
176
+ }
177
+
178
+ achildren = achildren[1:]
179
+ }
180
+
181
+ // Add children:
182
+ for len(bchildren) != 0 {
183
+ c := bchildren[0]
184
+
185
+ if err := e.appendChild(c, false); err != nil {
186
+ return errors.New("updating ui element failed").
187
+ Tag("kind", e.Kind()).
188
+ Tag("name", e.name()).
189
+ Wrap(err)
190
+ }
191
+
192
+ bchildren = bchildren[1:]
193
+ }
194
+
195
+ return nil
196
+ }
197
+
198
+ func (e *elem) appendChild(c UI, onlyJsValue bool) error {
199
+ if err := mount(c); err != nil {
200
+ return errors.New("appending child failed").
201
+ Tag("name", e.name()).
202
+ Tag("kind", e.Kind()).
203
+ Tag("child-name", c.name()).
204
+ Tag("child-kind", c.Kind()).
205
+ Wrap(err)
206
+ }
207
+
208
+ if !onlyJsValue {
209
+ e.body = append(e.body, c)
210
+ }
211
+
212
+ c.setParent(e.self())
213
+ e.JSValue().Call("appendChild", c)
214
+ return nil
215
+ }
216
+
217
+ func (e *elem) replaceChildAt(idx int, new UI) error {
218
+ old := e.body[idx]
219
+
220
+ if err := mount(new); err != nil {
221
+ return errors.New("replacing child failed").
222
+ Tag("name", e.name()).
223
+ Tag("kind", e.Kind()).
224
+ Tag("index", idx).
225
+ Tag("old-name", old.name()).
226
+ Tag("old-kind", old.Kind()).
227
+ Tag("new-name", new.name()).
228
+ Tag("new-kind", new.Kind()).
229
+ Wrap(err)
230
+ }
231
+
232
+ e.body[idx] = new
233
+ new.setParent(e.self())
234
+ e.JSValue().Call("replaceChild", new, old)
235
+
236
+ dismount(old)
237
+ return nil
238
+ }
239
+
240
+ func (e *elem) removeChildAt(idx int) error {
241
+ body := e.body
242
+ if idx < 0 || idx >= len(body) {
243
+ return errors.New("removing child failed").
244
+ Tag("reason", "index out of range").
245
+ Tag("index", idx).
246
+ Tag("name", e.name()).
247
+ Tag("kind", e.Kind())
248
+ }
249
+
250
+ c := body[idx]
251
+
252
+ copy(body[idx:], body[idx+1:])
253
+ body[len(body)-1] = nil
254
+ body = body[:len(body)-1]
255
+ e.body = body
256
+
257
+ e.JSValue().Call("removeChild", c)
258
+ dismount(c)
259
+ return nil
260
+ }
261
+
262
+ func (e *elem) updateAttrs(attrs map[string]string) {
263
+ for k := range e.attrs {
264
+ if _, exists := attrs[k]; !exists {
265
+ e.delAttr(k)
266
+ }
267
+ }
268
+
269
+ if e.attrs == nil && len(attrs) != 0 {
270
+ e.attrs = make(map[string]string, len(attrs))
271
+ }
272
+
273
+ for k, v := range attrs {
274
+ if curval, exists := e.attrs[k]; !exists || curval != v {
275
+ e.attrs[k] = v
276
+ e.setJsAttr(k, v)
277
+ }
278
+ }
279
+ }
280
+
281
+ func (e *elem) setAttr(k string, v interface{}) {
282
+ if e.attrs == nil {
283
+ e.attrs = make(map[string]string)
284
+ }
285
+
286
+ switch k {
287
+ case "style", "allow":
288
+ s := e.attrs[k] + toString(v) + ";"
289
+ e.attrs[k] = s
290
+ return
291
+
292
+ case "class":
293
+ s := e.attrs[k]
294
+ if s != "" {
295
+ s += " "
296
+ }
297
+ s += toString(v)
298
+ e.attrs[k] = s
299
+ return
300
+ }
301
+
302
+ switch v := v.(type) {
303
+ case bool:
304
+ if !v {
305
+ delete(e.attrs, k)
306
+ return
307
+ }
308
+ e.attrs[k] = ""
309
+
310
+ default:
311
+ e.attrs[k] = toString(v)
312
+ }
313
+ }
314
+
315
+ func (e *elem) setJsAttr(k, v string) {
316
+ e.JSValue().Call("setAttribute", k, v)
317
+ }
318
+
319
+ func (e *elem) delAttr(k string) {
320
+ e.JSValue().Call("removeAttribute", k)
321
+ delete(e.attrs, k)
322
+ }
323
+
324
+ func (e *elem) updateEventHandler(handlers map[string]eventHandler) {
325
+ for k, current := range e.events {
326
+ if _, exists := handlers[k]; !exists {
327
+ e.delJsEventHandler(k, current)
328
+ }
329
+ }
330
+
331
+ if e.events == nil && len(handlers) != 0 {
332
+ e.events = make(map[string]eventHandler, len(handlers))
333
+ }
334
+
335
+ for k, new := range handlers {
336
+ if current, exists := e.events[k]; !current.equal(new) {
337
+ if exists {
338
+ e.delJsEventHandler(k, current)
339
+ }
340
+
341
+ e.events[k] = new
342
+ e.setJsEventHandler(k, new)
343
+ }
344
+ }
345
+ }
346
+
347
+ func (e *elem) setEventHandler(k string, h EventHandler) {
348
+ if e.events == nil {
349
+ e.events = make(map[string]eventHandler)
350
+ }
351
+
352
+ e.events[k] = eventHandler{
353
+ event: k,
354
+ value: h,
355
+ }
356
+ }
357
+
358
+ func (e *elem) setJsEventHandler(k string, h eventHandler) {
359
+ jshandler := makeJsEventHandler(e.self(), h.value)
360
+ h.jsvalue = jshandler
361
+ e.events[k] = h
362
+ e.JSValue().Call("addEventListener", k, jshandler)
363
+ }
364
+
365
+ func (e *elem) delJsEventHandler(k string, h eventHandler) {
366
+ e.JSValue().Call("removeEventListener", k, h.jsvalue)
367
+ h.jsvalue.Release()
368
+ delete(e.events, k)
369
+ }
370
+
371
+ func (e *elem) setBody(body ...UI) {
372
+ if e.selfClosing {
373
+ panic(errors.New("setting html element body failed").
374
+ Tag("reason", "self closing element can't have children").
375
+ Tag("name", e.name()),
376
+ )
377
+ }
378
+
379
+ e.body = FilterUIElems(body...)
380
+ }
381
+
382
+ func (e *elem) Html(w io.Writer) {
383
+ e.HtmlWithIndent(w, 0)
384
+ }
385
+
386
+ func (e *elem) HtmlWithIndent(w io.Writer, indent int) {
387
+ writeIndent(w, indent)
388
+ w.Write(stob("<"))
389
+ w.Write(stob(e.tag))
390
+
391
+ for k, v := range e.attrs {
392
+ w.Write(stob(" "))
393
+ w.Write(stob(k))
394
+
395
+ if v != "" {
396
+ w.Write(stob(`="`))
397
+ w.Write(stob(v))
398
+ w.Write(stob(`"`))
399
+ }
400
+ }
401
+
402
+ w.Write(stob(">"))
403
+
404
+ if e.selfClosing {
405
+ return
406
+ }
407
+
408
+ for _, c := range e.body {
409
+ w.Write(ln())
410
+ c.(WritableNode).HtmlWithIndent(w, indent+1)
411
+ }
412
+
413
+ if len(e.body) != 0 {
414
+ w.Write(ln())
415
+ writeIndent(w, indent)
416
+ }
417
+
418
+ w.Write(stob("</"))
419
+ w.Write(stob(e.tag))
420
+ w.Write(stob(">"))
421
+ }
422
+
423
+ func (e *elem) ID(v string) *elem {
424
+ e.setAttr("id", v)
425
+ return e
426
+ }
427
+
428
+ func (e *elem) Style(k, v string) *elem {
429
+ e.setAttr("style", k+":"+v)
430
+ return e
431
+ }
432
+
433
+ func (e *elem) OnBlur(h EventHandler) *elem {
434
+ e.setEventHandler("blur", h)
435
+ return e
436
+ }
437
+
438
+ func (e *elem) OnChange(h EventHandler) *elem {
439
+ e.setEventHandler("change", h)
440
+ return e
441
+ }
442
+
443
+ func (e *elem) OnClick(h EventHandler) *elem {
444
+ e.setEventHandler("click", h)
445
+ return e
446
+ }
447
+
448
+ func (e *elem) OnFocus(h EventHandler) *elem {
449
+ e.setEventHandler("focus", h)
450
+ return e
451
+ }
452
+
453
+ func (e *elem) OnInput(h EventHandler) *elem {
454
+ e.setEventHandler("input", h)
455
+ return e
456
+ }
element_test.go ADDED
@@ -0,0 +1,367 @@
1
+ package app
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestElemSetAttr(t *testing.T) {
10
+ utests := []struct {
11
+ scenario string
12
+ key string
13
+ value interface{}
14
+ explectedValue string
15
+ valueNotSet bool
16
+ }{
17
+ {
18
+ scenario: "string",
19
+ key: "title",
20
+ value: "test",
21
+ explectedValue: "test",
22
+ },
23
+ {
24
+ scenario: "int",
25
+ key: "max",
26
+ value: 42,
27
+ explectedValue: "42",
28
+ },
29
+ {
30
+ scenario: "bool true",
31
+ key: "hidden",
32
+ value: true,
33
+ explectedValue: "",
34
+ },
35
+ {
36
+ scenario: "bool false",
37
+ key: "hidden",
38
+ value: false,
39
+ valueNotSet: true,
40
+ },
41
+ {
42
+ scenario: "style",
43
+ key: "style",
44
+ value: "margin:42",
45
+ explectedValue: "margin:42;",
46
+ },
47
+ {
48
+ scenario: "set successive styles",
49
+ key: "style",
50
+ value: "padding:42",
51
+ explectedValue: "margin:42;padding:42;",
52
+ },
53
+ {
54
+ scenario: "class",
55
+ key: "class",
56
+ value: "hello",
57
+ explectedValue: "hello",
58
+ },
59
+ {
60
+ scenario: "set successive classes",
61
+ key: "class",
62
+ value: "world",
63
+ explectedValue: "hello world",
64
+ },
65
+ }
66
+
67
+ e := &elem{}
68
+
69
+ for _, u := range utests {
70
+ t.Run(u.scenario, func(t *testing.T) {
71
+ e.setAttr(u.key, u.value)
72
+ v, exists := e.attrs[u.key]
73
+ require.Equal(t, u.explectedValue, v)
74
+ require.Equal(t, u.valueNotSet, !exists)
75
+ })
76
+ }
77
+ }
78
+
79
+ func TestElemUpdateAttrs(t *testing.T) {
80
+ utests := []struct {
81
+ scenario string
82
+ current map[string]string
83
+ incoming map[string]string
84
+ }{
85
+ {
86
+ scenario: "attributes are removed",
87
+ current: map[string]string{
88
+ "foo": "bar",
89
+ "hello": "world",
90
+ },
91
+ incoming: nil,
92
+ },
93
+ {
94
+ scenario: "attributes are added",
95
+ current: nil,
96
+ incoming: map[string]string{
97
+ "foo": "bar",
98
+ "hello": "world",
99
+ },
100
+ },
101
+ {
102
+ scenario: "attributes are updated",
103
+ current: map[string]string{
104
+ "foo": "bar",
105
+ "hello": "world",
106
+ },
107
+ incoming: map[string]string{
108
+ "foo": "boo",
109
+ "hello": "there",
110
+ },
111
+ },
112
+ {
113
+ scenario: "attributes are synced",
114
+ current: map[string]string{
115
+ "foo": "bar",
116
+ "hello": "world",
117
+ },
118
+ incoming: map[string]string{
119
+ "foo": "boo",
120
+ "goodbye": "world",
121
+ },
122
+ },
123
+ }
124
+
125
+ for _, u := range utests {
126
+ t.Run(u.scenario, func(t *testing.T) {
127
+ testSkipNonWasm(t)
128
+
129
+ n := Div().(*htmlDiv)
130
+ err := mount(n)
131
+ require.NoError(t, err)
132
+ defer dismount(n)
133
+
134
+ n.attrs = u.current
135
+ n.updateAttrs(u.incoming)
136
+
137
+ if len(u.incoming) == 0 {
138
+ require.Empty(t, n.attributes())
139
+ return
140
+ }
141
+
142
+ require.Equal(t, u.incoming, n.attributes())
143
+ })
144
+ }
145
+ }
146
+
147
+ func TestElemSetEventHandler(t *testing.T) {
148
+ e := &elem{}
149
+ h := func(Context, Event) {}
150
+ e.setEventHandler("click", h)
151
+
152
+ expectedHandler := eventHandler{
153
+ event: "click",
154
+ value: h,
155
+ }
156
+
157
+ registeredHandler := e.events["click"]
158
+ require.True(t, expectedHandler.equal(registeredHandler))
159
+ }
160
+
161
+ func TestElemUpdateEventHandlers(t *testing.T) {
162
+ utests := []struct {
163
+ scenario string
164
+ current EventHandler
165
+ incoming EventHandler
166
+ }{
167
+ {
168
+ scenario: "handler is removed",
169
+ current: func(Context, Event) {},
170
+ incoming: nil,
171
+ },
172
+ {
173
+ scenario: "handler is added",
174
+ current: nil,
175
+ incoming: func(Context, Event) {},
176
+ },
177
+ {
178
+ scenario: "handler is updated",
179
+ current: func(Context, Event) {},
180
+ incoming: func(Context, Event) {},
181
+ },
182
+ }
183
+
184
+ for _, u := range utests {
185
+ t.Run(u.scenario, func(t *testing.T) {
186
+ testSkipNonWasm(t)
187
+
188
+ var current map[string]eventHandler
189
+ var incoming map[string]eventHandler
190
+
191
+ if u.current != nil {
192
+ current = map[string]eventHandler{
193
+ "click": {
194
+ event: "click",
195
+ value: u.current,
196
+ },
197
+ }
198
+ }
199
+
200
+ if u.incoming != nil {
201
+ incoming = map[string]eventHandler{
202
+ "click": {
203
+ event: "click",
204
+ value: u.incoming,
205
+ },
206
+ }
207
+ }
208
+
209
+ n := Div().(*htmlDiv)
210
+ n.events = current
211
+ err := mount(n)
212
+ require.NoError(t, err)
213
+ defer dismount(n)
214
+
215
+ n.updateEventHandler(incoming)
216
+
217
+ if len(incoming) == 0 {
218
+ require.Empty(t, n.attributes())
219
+ return
220
+ }
221
+
222
+ h := n.eventHandlers()["click"]
223
+ require.True(t, h.equal(incoming["click"]))
224
+ })
225
+ }
226
+ }
227
+
228
+ func TestElemMountDismount(t *testing.T) {
229
+ testMountDismount(t, []mountTest{
230
+ {
231
+ scenario: "html element",
232
+ node: Div().
233
+ Class("hello").
234
+ OnClick(func(Context, Event) {}),
235
+ },
236
+ })
237
+ }
238
+
239
+ // func TestElemUpdate(t *testing.T) {
240
+ // testUpdate(t, []updateTest{
241
+ // {
242
+ // scenario: "html element returns replace error when updated with a non html-element",
243
+ // a: Div(),
244
+ // b: Text("hello"),
245
+ // replaceErr: true,
246
+ // },
247
+ // {
248
+ // scenario: "html element attributes are updated",
249
+ // a: Div().
250
+ // ID("max").
251
+ // Class("foo").
252
+ // AccessKey("test"),
253
+ // b: Div().
254
+ // ID("max").
255
+ // Class("bar").
256
+ // Lang("fr"),
257
+ // matches: []TestUIDescriptor{
258
+ // {
259
+ // Expected: Div().
260
+ // ID("max").
261
+ // Class("bar").
262
+ // Lang("fr"),
263
+ // },
264
+ // },
265
+ // },
266
+ // {
267
+ // scenario: "html element event handlers are updated",
268
+ // a: Div().
269
+ // OnClick(func(Context, Event) {}).
270
+ // OnBlur(func(Context, Event) {}),
271
+ // b: Div().
272
+ // OnClick(func(Context, Event) {}).
273
+ // OnChange(func(Context, Event) {}),
274
+ // matches: []TestUIDescriptor{
275
+ // {
276
+ // Expected: Div().
277
+ // OnClick(nil).
278
+ // OnChange(nil),
279
+ // },
280
+ // },
281
+ // },
282
+ // {
283
+ // scenario: "html element is replaced by a text",
284
+ // a: Div().Body(
285
+ // H2().Text("hello"),
286
+ // ),
287
+ // b: Div().Body(
288
+ // Text("hello"),
289
+ // ),
290
+ // matches: []TestUIDescriptor{
291
+ // {
292
+ // Path: TestPath(),
293
+ // Expected: Div(),
294
+ // },
295
+ // {
296
+ // Path: TestPath(0),
297
+ // Expected: Text("hello"),
298
+ // },
299
+ // },
300
+ // },
301
+ // {
302
+ // scenario: "html element is replaced by a component",
303
+ // a: Div().Body(
304
+ // H2().Text("hello"),
305
+ // ),
306
+ // b: Div().Body(
307
+ // &hello{},
308
+ // ),
309
+ // matches: []TestUIDescriptor{
310
+ // {
311
+ // Path: TestPath(),
312
+ // Expected: Div(),
313
+ // },
314
+ // {
315
+ // Path: TestPath(0),
316
+ // Expected: &hello{},
317
+ // },
318
+ // {
319
+ // Path: TestPath(0, 0, 0),
320
+ // Expected: H1(),
321
+ // },
322
+ // {
323
+ // Path: TestPath(0, 0, 0, 0),
324
+ // Expected: Text("hello, "),
325
+ // },
326
+ // },
327
+ // },
328
+ // {
329
+ // scenario: "html element is replaced by another html element",
330
+ // a: Div().Body(
331
+ // H2(),
332
+ // ),
333
+ // b: Div().Body(
334
+ // H1(),
335
+ // ),
336
+ // matches: []TestUIDescriptor{
337
+ // {
338
+ // Path: TestPath(),
339
+ // Expected: Div(),
340
+ // },
341
+ // {
342
+ // Path: TestPath(0),
343
+ // Expected: H1(),
344
+ // },
345
+ // },
346
+ // },
347
+ // {
348
+ // scenario: "html element is replaced by raw html element",
349
+ // a: Div().Body(
350
+ // H2().Text("hello"),
351
+ // ),
352
+ // b: Div().Body(
353
+ // Raw("<svg></svg>"),
354
+ // ),
355
+ // matches: []TestUIDescriptor{
356
+ // {
357
+ // Path: TestPath(),
358
+ // Expected: Div(),
359
+ // },
360
+ // {
361
+ // Path: TestPath(0),
362
+ // Expected: Raw("<svg></svg>"),
363
+ // },
364
+ // },
365
+ // },
366
+ // })
367
+ // }
errors/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # errors
2
+
3
+ Package errors implements functions to manipulate errors.
4
+
5
+ Errors created are taggable and wrappable.
6
+
7
+ ```go
8
+ errWithTags := errors.New("an error with tags").
9
+ Tag("a", 42).
10
+ Tag("b", 21)
11
+
12
+ errWithWrap := errors.New("error").
13
+ Tag("a", 42).
14
+ Wrap(errors.New("wrapped error"))
15
+ ```
16
+
17
+ The package mirrors https:golang.org/pkg/errors package.
errors/errors.go ADDED
@@ -0,0 +1,208 @@
1
+ // Package errors implements functions to manipulate errors.
2
+ //
3
+ // Errors created are taggable and wrappable.
4
+ //
5
+ // errWithTags := errors.New("an error with tags").
6
+ // Tag("a", 42).
7
+ // Tag("b", 21)
8
+ //
9
+ // errWithWrap := errors.New("error").
10
+ // Tag("a", 42).
11
+ // Wrap(errors.New("wrapped error"))
12
+ //
13
+ // The package mirrors https://golang.org/pkg/errors package.
14
+ package errors
15
+
16
+ import (
17
+ "bytes"
18
+ "errors"
19
+ "fmt"
20
+ "reflect"
21
+ "sort"
22
+ "strings"
23
+ "unsafe"
24
+ )
25
+
26
+ // As is documented at https://golang.org/pkg/errors/#As.
27
+ func As(err error, target interface{}) bool {
28
+ return errors.As(err, target)
29
+ }
30
+
31
+ // Is is documented at https://golang.org/pkg/errors/#Is.
32
+ func Is(err, target error) bool {
33
+ return errors.Is(err, target)
34
+ }
35
+
36
+ // Unwrap is documented at https://golang.org/pkg/errors/#Unwrap.
37
+ func Unwrap(err error) error {
38
+ return errors.Unwrap(err)
39
+ }
40
+
41
+ // Tag retrieves the value of the tag named by the key. If the tag exists,
42
+ // its value (which may be empty) is returned and the boolean is true. Otherwise
43
+ // the returned value will be empty and the boolean will be false.
44
+ func Tag(err error, k string) (string, bool) {
45
+ ierr, ok := err.(Error)
46
+ if !ok {
47
+ return "", false
48
+ }
49
+ return ierr.Lookup(k)
50
+ }
51
+
52
+ // New returns an error with the given description that can be tagged.
53
+ func New(v string) Error {
54
+ return Error{
55
+ description: v,
56
+ }
57
+ }
58
+
59
+ // Newf returns an error with the given formatted description that can be
60
+ // tagged.
61
+ func Newf(format string, v ...interface{}) Error {
62
+ return New(fmt.Sprintf(format, v...))
63
+ }
64
+
65
+ // Error is an error implementation that supports tagging and wrapping.
66
+ type Error struct {
67
+ description string
68
+ tags []tag
69
+ maxKeyLen int
70
+ wrap error
71
+ }
72
+
73
+ // Tag sets the named tag with the given value.
74
+ func (e Error) Tag(k string, v interface{}) Error {
75
+ if e.tags == nil {
76
+ e.tags = make([]tag, 0, 8)
77
+ }
78
+
79
+ if l := len(k); l > e.maxKeyLen {
80
+ e.maxKeyLen = l
81
+ }
82
+
83
+ switch v := v.(type) {
84
+ case string:
85
+ e.tags = append(e.tags, tag{key: k, value: v})
86
+
87
+ default:
88
+ e.tags = append(e.tags, tag{key: k, value: fmt.Sprintf("%+v", v)})
89
+ }
90
+
91
+ return e
92
+ }
93
+
94
+ // Lookup retrieves the value of the tag named by the key. If the tag exists,
95
+ // its value (which may be empty) is returned and the boolean is true. Otherwise
96
+ // the returned value will be empty and the boolean will be false.
97
+ func (e Error) Lookup(tag string) (string, bool) {
98
+ for _, t := range e.tags {
99
+ if t.key == tag {
100
+ return t.value, true
101
+ }
102
+ }
103
+ return "", false
104
+ }
105
+
106
+ // Wrap wraps the given error. Nil errors are ingnored.
107
+ func (e Error) Wrap(err error) Error {
108
+ if err == nil {
109
+ return e
110
+ }
111
+
112
+ if e.maxKeyLen < 5 {
113
+ e.maxKeyLen = 5
114
+ }
115
+
116
+ if e.wrap == nil {
117
+ e.wrap = err
118
+ return e
119
+ }
120
+
121
+ description := ""
122
+ if perr, ok := err.(Error); ok {
123
+ description = perr.description
124
+ } else {
125
+ description = err.Error()
126
+ }
127
+
128
+ e.wrap = New(description).Wrap(e.wrap)
129
+ return e
130
+ }
131
+
132
+ // Unwrap unwraps the given error.
133
+ func (e Error) Unwrap() error {
134
+ return e.wrap
135
+ }
136
+
137
+ // Is reports if the target matches the error or its wrapped values.
138
+ func (e Error) Is(target error) bool {
139
+ o, ok := target.(Error)
140
+ if !ok {
141
+ return false
142
+ }
143
+
144
+ return e.description == o.description &&
145
+ reflect.DeepEqual(e.tags, o.tags)
146
+ }
147
+
148
+ func (e Error) Error() string {
149
+ w := bytes.NewBuffer(make([]byte, 0, len(e.description)+len(e.tags)*(e.maxKeyLen+11)))
150
+ e.format(w, 0)
151
+ return bytesToString(w.Bytes())
152
+ }
153
+
154
+ func (e Error) format(w *bytes.Buffer, indent int) {
155
+ w.WriteString(e.description)
156
+ if e.wrap != nil || len(e.tags) != 0 {
157
+ w.WriteByte(':')
158
+ }
159
+
160
+ tags := e.tags
161
+ sort.Slice(tags, func(a, b int) bool {
162
+ return strings.Compare(tags[a].key, tags[b].key) < 0
163
+ })
164
+
165
+ for _, t := range e.tags {
166
+ k := t.key
167
+ v := t.value
168
+
169
+ w.WriteByte('\n')
170
+ e.indent(w, indent+4)
171
+ w.WriteString(k)
172
+ w.WriteByte(':')
173
+ e.indent(w, e.maxKeyLen-len(k)+1)
174
+ w.WriteString(v)
175
+ }
176
+
177
+ if e.wrap == nil {
178
+ return
179
+ }
180
+
181
+ w.WriteByte('\n')
182
+ e.indent(w, indent+4)
183
+ w.WriteString("error")
184
+ w.WriteByte(':')
185
+ e.indent(w, e.maxKeyLen-5+1)
186
+
187
+ if err, ok := e.wrap.(Error); ok {
188
+ err.format(w, indent+4)
189
+ return
190
+ }
191
+
192
+ w.WriteString(e.wrap.Error())
193
+ }
194
+
195
+ func (e Error) indent(w *bytes.Buffer, n int) {
196
+ for i := 0; i < n; i++ {
197
+ w.WriteByte(' ')
198
+ }
199
+ }
200
+
201
+ type tag struct {
202
+ key string
203
+ value string
204
+ }
205
+
206
+ func bytesToString(b []byte) string {
207
+ return *(*string)(unsafe.Pointer(&b))
208
+ }
errors/errors_test.go ADDED
@@ -0,0 +1,153 @@
1
+ package errors
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/stretchr/testify/require"
9
+ )
10
+
11
+ func TestError(t *testing.T) {
12
+ err := New("a simple error")
13
+ t.Log(err)
14
+ }
15
+
16
+ func TestErrorWithTags(t *testing.T) {
17
+ err := New("an error with tags").
18
+ Tag("string", "hello world").
19
+ Tag("go-stringer", goStringer{}).
20
+ Tag("duration", time.Duration(3600000000)).
21
+ Tag("int", 42).
22
+ Tag("int8", int8(8)).
23
+ Tag("int16", int16(16)).
24
+ Tag("int32", int32(32)).
25
+ Tag("int64", int64(64)).
26
+ Tag("uint", uint(42)).
27
+ Tag("uint8", uint8(8)).
28
+ Tag("uint16", uint16(16)).
29
+ Tag("uint32", uint32(32)).
30
+ Tag("uint64", uint64(64)).
31
+ Tag("float32", float32(32.42)).
32
+ Tag("float64", float64(64.42)).
33
+ Tag("slice", []string{"hello", "world"})
34
+ t.Log("\n", err)
35
+ }
36
+
37
+ func TestErrorWithWrap(t *testing.T) {
38
+ b := New("b").Wrap(errors.New("c"))
39
+ a := New("a").
40
+ Wrap(b).
41
+ Wrap(nil)
42
+ require.True(t, b.Is(a.wrap))
43
+
44
+ d := New("d").
45
+ Wrap(a).
46
+ Wrap(b).
47
+ Wrap(errors.New("f"))
48
+ t.Log("\n", d)
49
+ }
50
+
51
+ func TestErrorWithTagsAndWrap(t *testing.T) {
52
+ err := New("an error with tags").
53
+ Tag("uint64", uint64(64)).
54
+ Tag("float64", float64(64.42)).
55
+ Tag("slice", []string{"hello", "world"}).
56
+ Wrap(errors.New("another error"))
57
+ t.Log("\n", err)
58
+ }
59
+
60
+ func TestLookup(t *testing.T) {
61
+ err := New("error").Tag("foo", 42)
62
+
63
+ v, found := err.Lookup("foo")
64
+ require.True(t, found)
65
+ require.Equal(t, "42", v)
66
+
67
+ v, found = err.Lookup("bar")
68
+ require.False(t, found)
69
+ require.Empty(t, v)
70
+ }
71
+
72
+ func TestErrorUwrap(t *testing.T) {
73
+ a := New("a").Tag("wrap", true)
74
+ b := New("b").Wrap(a)
75
+
76
+ err := b.Unwrap()
77
+ require.Equal(t, a, err)
78
+
79
+ err = Unwrap(b)
80
+ require.Equal(t, a, err)
81
+ }
82
+
83
+ func TestAs(t *testing.T) {
84
+ a := New("a").Tag("wrap", true)
85
+ b := New("b").Wrap(a)
86
+ c := New("c").Wrap(b)
87
+ d := New("d")
88
+
89
+ require.True(t, As(c, &a))
90
+ require.True(t, As(c, &d))
91
+ }
92
+
93
+ func TestIs(t *testing.T) {
94
+ a := New("a").Tag("wrap", true)
95
+ b := New("b").Wrap(a)
96
+ c := New("c").Wrap(b)
97
+ d := New("d")
98
+ e := errors.New("e")
99
+
100
+ require.True(t, Is(c, a))
101
+ require.False(t, Is(c, d))
102
+ require.False(t, Is(d, e))
103
+ }
104
+
105
+ func TestTag(t *testing.T) {
106
+ a := errors.New("a")
107
+ v, isTagged := Tag(a, "test")
108
+ require.Empty(t, v)
109
+ require.False(t, isTagged)
110
+
111
+ b := New("b").Tag("test", "true")
112
+ v, isTagged = Tag(b, "test")
113
+ require.Equal(t, "true", v)
114
+ require.True(t, isTagged)
115
+ }
116
+
117
+ func TestNewf(t *testing.T) {
118
+ err := Newf("hello %q", "world")
119
+ t.Log(err)
120
+ }
121
+
122
+ type goStringer struct{}
123
+
124
+ func (s goStringer) GoString() string {
125
+ return "go stringer !"
126
+ }
127
+
128
+ func BenchmarkNew(b *testing.B) {
129
+ for n := 0; n < b.N; n++ {
130
+ New("an error with tags").
131
+ Tag("string", "hello world").
132
+ Tag("int8", int8(8)).
133
+ Tag("int16", int16(16)).
134
+ Tag("int32", int32(32)).
135
+ Tag("int64", int64(64))
136
+ }
137
+ }
138
+
139
+ func BenchmarkError(b *testing.B) {
140
+ var s string
141
+
142
+ for n := 0; n < b.N; n++ {
143
+ s = New("an error with tags").
144
+ Tag("string", "hello world").
145
+ Tag("int8", int8(8)).
146
+ Tag("int16", int16(16)).
147
+ Tag("int32", int32(32)).
148
+ Tag("int64", int64(64)).
149
+ Error()
150
+ }
151
+
152
+ b.Log(s)
153
+ }
go.mod ADDED
@@ -0,0 +1,12 @@
1
+ module github.com/pyros2097/wapp
2
+
3
+ go 1.15
4
+
5
+ require (
6
+ github.com/fsnotify/fsnotify v1.4.7 // indirect
7
+ github.com/markbates/pkger v0.17.1
8
+ github.com/stretchr/testify v1.5.1
9
+ golang.org/x/sys v0.0.0-20201112073958-5cba982894dd // indirect
10
+ gopkg.in/fsnotify.v1 v1.4.7
11
+ gopkg.in/yaml.v2 v2.2.8 // indirect
12
+ )
go.sum ADDED
@@ -0,0 +1,31 @@
1
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4
+ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
5
+ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
6
+ github.com/gobuffalo/here v0.6.0 h1:hYrd0a6gDmWxBM4TnrGw8mQg24iSVoIkHEk7FodQcBI=
7
+ github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM=
8
+ github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
9
+ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
10
+ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
11
+ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
12
+ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
13
+ github.com/markbates/pkger v0.17.1 h1:/MKEtWqtc0mZvu9OinB9UzVN9iYCwLWuyUv4Bw+PCno=
14
+ github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
15
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17
+ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
18
+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
19
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
20
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
21
+ golang.org/x/sys v0.0.0-20201112073958-5cba982894dd h1:5CtCZbICpIOFdgO940moixOPjc0178IU44m4EjOO5IY=
22
+ golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
23
+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
24
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
25
+ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
26
+ gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
27
+ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
28
+ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
29
+ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
30
+ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
31
+ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
html.go ADDED
@@ -0,0 +1,65 @@
1
+ package app
2
+
3
+ func Html(elems ...UI) *elem {
4
+ return &elem{tag: "html", body: elems}
5
+ }
6
+
7
+ func Head(elems ...UI) *elem {
8
+ basic := []UI{
9
+ &elem{tag: "meta", selfClosing: true, attrs: map[string]string{"charset": "UTF-8"}},
10
+ &elem{tag: "meta", selfClosing: true, attrs: map[string]string{"http-equiv": "Content-Type", "content": "text/html;charset=utf-8"}},
11
+ &elem{tag: "meta", selfClosing: true, attrs: map[string]string{"http-equiv": "encoding", "content": "utf-8"}},
12
+ }
13
+ return &elem{tag: "head", body: append(basic, elems...)}
14
+ }
15
+
16
+ func Body(elems ...UI) *elem {
17
+ return &elem{tag: "head", body: elems}
18
+ }
19
+
20
+ func Title(v string) *elem {
21
+ return &elem{tag: "title", body: []UI{Text(v)}}
22
+ }
23
+
24
+ func Meta(name, content string) *elem {
25
+ e := &elem{
26
+ tag: "meta",
27
+ selfClosing: true,
28
+ }
29
+ e.setAttr("name", name)
30
+ e.setAttr("content", content)
31
+ return e
32
+ }
33
+
34
+ func Link(rel, href string) *elem {
35
+ e := &elem{
36
+ tag: "link",
37
+ selfClosing: true,
38
+ }
39
+ e.setAttr("rel", rel)
40
+ e.setAttr("href", href)
41
+ return e
42
+ }
43
+
44
+ func Script(str string) *elem {
45
+ return &elem{
46
+ tag: "script",
47
+ body: []UI{Text(str)},
48
+ }
49
+ }
50
+
51
+ func Div(elems ...UI) *elem {
52
+ return &elem{tag: "div", body: elems}
53
+ }
54
+
55
+ func Row(elems ...UI) *elem {
56
+ return &elem{tag: "div", body: elems, attrs: map[string]string{
57
+ "style": "display: flex;flex: 1;flex-direction: row;align-items: center;justify-content: center;",
58
+ }}
59
+ }
60
+
61
+ func Col(elems ...UI) *elem {
62
+ return &elem{tag: "div", body: elems, attrs: map[string]string{
63
+ "style": "display: flex;flex: 1;flex-direction: column;align-items: center;justify-content: center;",
64
+ }}
65
+ }
js.go ADDED
@@ -0,0 +1,224 @@
1
+ package app
2
+
3
+ import "net/url"
4
+
5
+ // Type represents the JavaScript type of a Value.
6
+ type Type int
7
+
8
+ // Constants that enumerates the JavaScript types.
9
+ const (
10
+ TypeUndefined Type = iota
11
+ TypeNull
12
+ TypeBoolean
13
+ TypeNumber
14
+ TypeString
15
+ TypeSymbol
16
+ TypeObject
17
+ TypeFunction
18
+ )
19
+
20
+ // Wrapper is implemented by types that are backed by a JavaScript value.
21
+ type Wrapper interface {
22
+ JSValue() Value
23
+ }
24
+
25
+ // Value is the interface that represents a JavaScript value. On wasm
26
+ // architecture, it wraps the Value from https://golang.org/pkg/syscall/js/
27
+ // package.
28
+ type Value interface {
29
+ // Bool returns the value v as a bool. It panics if v is not a JavaScript
30
+ // boolean.
31
+ Bool() bool
32
+
33
+ // Call does a JavaScript call to the method m of value v with the given
34
+ // arguments. It panics if v has no method m. The arguments get mapped to
35
+ // JavaScript values according to the ValueOf function.
36
+ Call(m string, args ...interface{}) Value
37
+
38
+ // Float returns the value v as a float64. It panics if v is not a
39
+ // JavaScript number.
40
+ Float() float64
41
+
42
+ // Get returns the JavaScript property p of value v. It panics if v is not a
43
+ // JavaScript object.
44
+ Get(p string) Value
45
+
46
+ // Index returns JavaScript index i of value v. It panics if v is not a
47
+ // JavaScript object.
48
+ Index(i int) Value
49
+
50
+ // InstanceOf reports whether v is an instance of type t according to
51
+ // JavaScript's instanceof operator.
52
+ InstanceOf(t Value) bool
53
+
54
+ // Int returns the value v truncated to an int. It panics if v is not a
55
+ // JavaScript number.
56
+ Int() int
57
+
58
+ // Invoke does a JavaScript call of the value v with the given arguments. It
59
+ // panics if v is not a JavaScript function. The arguments get mapped to
60
+ // JavaScript values according to the ValueOf function.
61
+ Invoke(args ...interface{}) Value
62
+
63
+ // IsNaN reports whether v is the JavaScript value "NaN".
64
+ IsNaN() bool
65
+
66
+ // IsNull reports whether v is the JavaScript value "null".
67
+ IsNull() bool
68
+
69
+ // IsUndefined reports whether v is the JavaScript value "undefined".
70
+ IsUndefined() bool
71
+
72
+ // JSValue implements Wrapper interface.
73
+ JSValue() Value
74
+
75
+ // Length returns the JavaScript property "length" of v. It panics if v is
76
+ // not a JavaScript object.
77
+ Length() int
78
+
79
+ // New uses JavaScript's "new" operator with value v as constructor and the
80
+ // given arguments. It panics if v is not a JavaScript function. The
81
+ // arguments get mapped to JavaScript values according to the ValueOf
82
+ // function.
83
+ New(args ...interface{}) Value
84
+
85
+ // Set sets the JavaScript property p of value v to ValueOf(x). It panics if
86
+ // v is not a JavaScript object.
87
+ Set(p string, x interface{})
88
+
89
+ // SetIndex sets the JavaScript index i of value v to ValueOf(x). It panics
90
+ // if v is not a JavaScript object.
91
+ SetIndex(i int, x interface{})
92
+
93
+ // String returns the value v as a string. String is a special case because
94
+ // of Go's String method convention. Unlike the other getters, it does not
95
+ // panic if v's Type is not TypeString. Instead, it returns a string of the
96
+ // form "<T>" or "<T: V>" where T is v's type and V is a string
97
+ // representation of v's value.
98
+ String() string
99
+
100
+ // Truthy returns the JavaScript "truthiness" of the value v. In JavaScript,
101
+ // false, 0, "", null, undefined, and NaN are "falsy", and everything else
102
+ // is "truthy". See
103
+ // https://developer.mozilla.org/en-US/docs/Glossary/Truthy.
104
+ Truthy() bool
105
+
106
+ // Type returns the JavaScript type of the value v. It is similar to
107
+ // JavaScript's typeof operator, except that it returns TypeNull instead of
108
+ // TypeObject for null.
109
+ Type() Type
110
+ }
111
+
112
+ // Null returns the JavaScript value "null".
113
+ func Null() Value {
114
+ return null()
115
+ }
116
+
117
+ // Undefined returns the JavaScript value "undefined".
118
+ func Undefined() Value {
119
+ return undefined()
120
+ }
121
+
122
+ // ValueOf returns x as a JavaScript value:
123
+ //
124
+ // | Go | JavaScript |
125
+ // | ---------------------- | ---------------------- |
126
+ // | js.Value | [its value] |
127
+ // | js.Func | function |
128
+ // | nil | null |
129
+ // | bool | boolean |
130
+ // | integers and floats | number |
131
+ // | string | string |
132
+ // | []interface{} | new array |
133
+ // | map[string]interface{} | new object |
134
+ //
135
+ // Panics if x is not one of the expected types.
136
+ func ValueOf(x interface{}) Value {
137
+ return valueOf(x)
138
+ }
139
+
140
+ // Func is the interface that describes a wrapped Go function to be called by
141
+ // JavaScript.
142
+ type Func interface {
143
+ Value
144
+
145
+ // Release frees up resources allocated for the function. The function must
146
+ // not be invoked after calling Release.
147
+ Release()
148
+ }
149
+
150
+ // FuncOf returns a wrapped function.
151
+ //
152
+ // Invoking the JavaScript function will synchronously call the Go function fn
153
+ // with the value of JavaScript's "this" keyword and the arguments of the
154
+ // invocation. The return value of the invocation is the result of the Go
155
+ // function mapped back to JavaScript according to ValueOf.
156
+ //
157
+ // A wrapped function triggered during a call from Go to JavaScript gets
158
+ // executed on the same goroutine. A wrapped function triggered by JavaScript's
159
+ // event loop gets executed on an extra goroutine. Blocking operations in the
160
+ // wrapped function will block the event loop. As a consequence, if one wrapped
161
+ // function blocks, other wrapped funcs will not be processed. A blocking
162
+ // function should therefore explicitly start a new goroutine.
163
+ //
164
+ // Func.Release must be called to free up resources when the function will not
165
+ // be used any more.
166
+ func FuncOf(fn func(this Value, args []Value) interface{}) Func {
167
+ return funcOf(fn)
168
+ }
169
+
170
+ // BrowserWindow is the interface that describes the browser window.
171
+ type BrowserWindow interface {
172
+ Value
173
+
174
+ // The window current url (window.location.href).
175
+ URL() *url.URL
176
+
177
+ // The window size.
178
+ Size() (w, h int)
179
+
180
+ // The position of the cursor (mouse or touch).
181
+ CursorPosition() (x, y int)
182
+
183
+ setCursorPosition(x, y int)
184
+
185
+ // Returns the HTML element with the id property that matches the given id.
186
+ GetElementByID(id string) Value
187
+
188
+ // Scrolls to the HTML element with the given id.
189
+ ScrollToID(id string)
190
+
191
+ // AddEventListener subscribes a given handler to the specified event. It
192
+ // returns a function that must be called to unsubscribe the handler and
193
+ // release allocated resources.
194
+ AddEventListener(event string, h EventHandler) func()
195
+ }
196
+
197
+ // Event is the interface that describes a javascript event.
198
+ type Event struct {
199
+ Value
200
+ }
201
+
202
+ // PreventDefault cancels the event if it is cancelable. The default action that
203
+ // belongs to the event will not occur.
204
+ func (e Event) PreventDefault() {
205
+ e.Call("preventDefault")
206
+ }
207
+
208
+ // CopyBytesToGo copies bytes from the Uint8Array src to dst. It returns the
209
+ // number of bytes copied, which will be the minimum of the lengths of src and
210
+ // dst.
211
+ //
212
+ // CopyBytesToGo panics if src is not an Uint8Array.
213
+ func CopyBytesToGo(dst []byte, src Value) int {
214
+ return copyBytesToGo(dst, src)
215
+ }
216
+
217
+ // CopyBytesToJS copies bytes from src to the Uint8Array dst. It returns the
218
+ // number of bytes copied, which will be the minimum of the lengths of src and
219
+ // dst.
220
+ //
221
+ // CopyBytesToJS panics if dst is not an Uint8Array.
222
+ func CopyBytesToJS(dst Value, src []byte) int {
223
+ return copyBytesToJS(dst, src)
224
+ }
js_nowasm.go ADDED
@@ -0,0 +1,150 @@
1
+ // +build !wasm
2
+
3
+ package app
4
+
5
+ import (
6
+ "net/url"
7
+ "runtime"
8
+
9
+ "github.com/pyros2097/wapp/errors"
10
+ )
11
+
12
+ var (
13
+ errNoWasm = errors.New("unsupported instruction").
14
+ Tag("required-architecture", "wasm").
15
+ Tag("current-architecture", runtime.GOARCH)
16
+ )
17
+
18
+ type value struct{}
19
+
20
+ func (v value) Bool() bool {
21
+ panic(errNoWasm)
22
+ }
23
+
24
+ func (v value) Call(m string, args ...interface{}) Value {
25
+ panic(errNoWasm)
26
+ }
27
+
28
+ func (v value) Float() float64 {
29
+ panic(errNoWasm)
30
+ }
31
+
32
+ func (v value) Get(p string) Value {
33
+ panic(errNoWasm)
34
+ }
35
+
36
+ func (v value) Index(i int) Value {
37
+ panic(errNoWasm)
38
+ }
39
+
40
+ func (v value) InstanceOf(t Value) bool {
41
+ panic(errNoWasm)
42
+ }
43
+
44
+ func (v value) Int() int {
45
+ panic(errNoWasm)
46
+ }
47
+
48
+ func (v value) Invoke(args ...interface{}) Value {
49
+ panic(errNoWasm)
50
+ }
51
+
52
+ func (v value) IsNaN() bool {
53
+ panic(errNoWasm)
54
+ }
55
+
56
+ func (v value) IsNull() bool {
57
+ panic(errNoWasm)
58
+ }
59
+
60
+ func (v value) IsUndefined() bool {
61
+ panic(errNoWasm)
62
+ }
63
+
64
+ func (v value) JSValue() Value {
65
+ panic(errNoWasm)
66
+ }
67
+
68
+ func (v value) Length() int {
69
+ panic(errNoWasm)
70
+ }
71
+
72
+ func (v value) New(args ...interface{}) Value {
73
+ panic(errNoWasm)
74
+ }
75
+
76
+ func (v value) Set(p string, x interface{}) {
77
+ panic(errNoWasm)
78
+ }
79
+
80
+ func (v value) SetIndex(i int, x interface{}) {
81
+ panic(errNoWasm)
82
+ }
83
+
84
+ func (v value) String() string {
85
+ panic(errNoWasm)
86
+ }
87
+
88
+ func (v value) Truthy() bool {
89
+ panic(errNoWasm)
90
+ }
91
+
92
+ func (v value) Type() Type {
93
+ panic(errNoWasm)
94
+ }
95
+
96
+ func null() Value {
97
+ panic(errNoWasm)
98
+ }
99
+
100
+ func undefined() Value {
101
+ panic(errNoWasm)
102
+ }
103
+
104
+ func valueOf(x interface{}) Value {
105
+ panic(errNoWasm)
106
+ }
107
+
108
+ func funcOf(fn func(this Value, args []Value) interface{}) Func {
109
+ panic(errNoWasm)
110
+ }
111
+
112
+ type browserWindow struct {
113
+ value
114
+ }
115
+
116
+ func (w browserWindow) URL() *url.URL {
117
+ panic(errNoWasm)
118
+ }
119
+
120
+ func (w browserWindow) Size() (width, height int) {
121
+ panic(errNoWasm)
122
+ }
123
+
124
+ func (w browserWindow) CursorPosition() (x, y int) {
125
+ panic(errNoWasm)
126
+ }
127
+
128
+ func (w browserWindow) setCursorPosition(x, y int) {
129
+ panic(errNoWasm)
130
+ }
131
+
132
+ func (w *browserWindow) GetElementByID(id string) Value {
133
+ panic(errNoWasm)
134
+ }
135
+
136
+ func (w *browserWindow) ScrollToID(id string) {
137
+ panic(errNoWasm)
138
+ }
139
+
140
+ func (w *browserWindow) AddEventListener(event string, h EventHandler) func() {
141
+ panic(errNoWasm)
142
+ }
143
+
144
+ func copyBytesToGo(dst []byte, src Value) int {
145
+ panic(errNoWasm)
146
+ }
147
+
148
+ func copyBytesToJS(dst Value, src []byte) int {
149
+ panic(errNoWasm)
150
+ }
js_wasm.go ADDED
@@ -0,0 +1,247 @@
1
+ package app
2
+
3
+ import (
4
+ "net/url"
5
+ "reflect"
6
+ "syscall/js"
7
+
8
+ "github.com/pyros2097/wapp/errors"
9
+ )
10
+
11
+ type value struct {
12
+ js.Value
13
+ }
14
+
15
+ func (v value) Call(m string, args ...interface{}) Value {
16
+ args = cleanArgs(args...)
17
+ return val(v.Value.Call(m, args...))
18
+ }
19
+
20
+ func (v value) Get(p string) Value {
21
+ return val(v.Value.Get(p))
22
+ }
23
+
24
+ func (v value) Set(p string, x interface{}) {
25
+ if wrapper, ok := x.(Wrapper); ok {
26
+ x = jsval(wrapper.JSValue())
27
+ }
28
+ v.Value.Set(p, x)
29
+ }
30
+
31
+ func (v value) Index(i int) Value {
32
+ return val(v.Value.Index(i))
33
+ }
34
+
35
+ func (v value) InstanceOf(t Value) bool {
36
+ return v.Value.InstanceOf(jsval(t))
37
+ }
38
+
39
+ func (v value) Invoke(args ...interface{}) Value {
40
+ return val(v.Value.Invoke(args...))
41
+ }
42
+
43
+ func (v value) JSValue() Value {
44
+ return v
45
+ }
46
+
47
+ func (v value) New(args ...interface{}) Value {
48
+ args = cleanArgs(args...)
49
+ return val(v.Value.New(args...))
50
+ }
51
+
52
+ func (v value) Type() Type {
53
+ return Type(v.Value.Type())
54
+ }
55
+
56
+ func null() Value {
57
+ return val(js.Null())
58
+ }
59
+
60
+ func undefined() Value {
61
+ return val(js.Undefined())
62
+ }
63
+
64
+ func valueOf(x interface{}) Value {
65
+ switch t := x.(type) {
66
+ case value:
67
+ x = t.Value
68
+
69
+ case function:
70
+ x = t.fn
71
+
72
+ case *browserWindow:
73
+ x = t.Value
74
+
75
+ case Event:
76
+ return valueOf(t.Value)
77
+ }
78
+
79
+ return val(js.ValueOf(x))
80
+ }
81
+
82
+ type function struct {
83
+ value
84
+ fn js.Func
85
+ }
86
+
87
+ func (f function) Release() {
88
+ f.fn.Release()
89
+ }
90
+
91
+ func funcOf(fn func(this Value, args []Value) interface{}) Func {
92
+ f := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
93
+ wargs := make([]Value, len(args))
94
+ for i, a := range args {
95
+ wargs[i] = val(a)
96
+ }
97
+
98
+ return fn(val(this), wargs)
99
+ })
100
+
101
+ return function{
102
+ value: value{Value: f.Value},
103
+ fn: f,
104
+ }
105
+ }
106
+
107
+ type browserWindow struct {
108
+ value
109
+
110
+ cursorX int
111
+ cursorY int
112
+ }
113
+
114
+ func (w *browserWindow) URL() *url.URL {
115
+ rawurl := w.
116
+ Get("location").
117
+ Get("href").
118
+ String()
119
+
120
+ u, _ := url.Parse(rawurl)
121
+ return u
122
+ }
123
+
124
+ func (w *browserWindow) Size() (width int, height int) {
125
+ getSize := func(axis string) int {
126
+ size := w.Get("inner" + axis)
127
+ if !size.Truthy() {
128
+ size = w.
129
+ Get("document").
130
+ Get("documentElement").
131
+ Get("client" + axis)
132
+ }
133
+ if !size.Truthy() {
134
+ size = w.
135
+ Get("document").
136
+ Get("body").
137
+ Get("client" + axis)
138
+ }
139
+ if size.Type() != TypeNumber {
140
+ return 0
141
+ }
142
+ return size.Int()
143
+ }
144
+
145
+ return getSize("Width"), getSize("Height")
146
+ }
147
+
148
+ func (w *browserWindow) CursorPosition() (x, y int) {
149
+ return w.cursorX, w.cursorY
150
+ }
151
+
152
+ func (w *browserWindow) setCursorPosition(x, y int) {
153
+ w.cursorX = x
154
+ w.cursorY = y
155
+ }
156
+
157
+ func (w *browserWindow) GetElementByID(id string) Value {
158
+ return w.Get("document").Call("getElementById", id)
159
+ }
160
+
161
+ func (w *browserWindow) ScrollToID(id string) {
162
+ if elem := w.GetElementByID(id); elem.Truthy() {
163
+ elem.Call("scrollIntoView")
164
+ }
165
+ }
166
+
167
+ func (w *browserWindow) AddEventListener(event string, h EventHandler) func() {
168
+ callback := makeJsEventHandler(body, h)
169
+ w.Call("addEventListener", event, callback)
170
+
171
+ return func() {
172
+ w.Call("removeEventListener", event, callback)
173
+ callback.Release()
174
+ }
175
+ }
176
+
177
+ func val(v js.Value) Value {
178
+ return value{Value: v}
179
+ }
180
+
181
+ func jsval(v Value) js.Value {
182
+ switch v := v.(type) {
183
+ case value:
184
+ return v.Value
185
+
186
+ case function:
187
+ return v.Value
188
+
189
+ case *browserWindow:
190
+ return v.Value
191
+
192
+ case Event:
193
+ return jsval(v.Value)
194
+
195
+ default:
196
+ Log("%s", errors.New("syscall/js value conversion failed").
197
+ Tag("type", reflect.TypeOf(v)),
198
+ )
199
+ return js.Undefined()
200
+ }
201
+ }
202
+
203
+ // JSValue returns the underlying syscall/js value of the given Javascript
204
+ // value.
205
+ func JSValue(v Value) js.Value {
206
+ return jsval(v)
207
+ }
208
+
209
+ func copyBytesToGo(dst []byte, src Value) int {
210
+ return js.CopyBytesToGo(dst, jsval(src))
211
+ }
212
+
213
+ func copyBytesToJS(dst Value, src []byte) int {
214
+ return js.CopyBytesToJS(jsval(dst), src)
215
+ }
216
+
217
+ func cleanArgs(args ...interface{}) []interface{} {
218
+ for i, a := range args {
219
+
220
+ args[i] = cleanArg(a)
221
+ }
222
+
223
+ return args
224
+ }
225
+
226
+ func cleanArg(v interface{}) interface{} {
227
+ switch v := v.(type) {
228
+ case map[string]interface{}:
229
+ m := make(map[string]interface{}, len(v))
230
+ for key, val := range v {
231
+ m[key] = cleanArg(val)
232
+ }
233
+ return m
234
+
235
+ case []interface{}:
236
+ s := make([]interface{}, len(v))
237
+ for i, val := range v {
238
+ s[i] = cleanArgs(val)
239
+ }
240
+
241
+ case Wrapper:
242
+ return jsval(v.JSValue())
243
+ }
244
+
245
+ return v
246
+
247
+ }
log.go ADDED
@@ -0,0 +1,48 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "runtime"
6
+ )
7
+
8
+ var (
9
+ // DefaultLogger is the logger used to log info and errors.
10
+ DefaultLogger func(format string, v ...interface{}) = log
11
+
12
+ defaultColor string
13
+ errorColor string
14
+ infoColor string
15
+ )
16
+
17
+ func init() {
18
+ switch runtime.GOARCH {
19
+ case "wasm", "window":
20
+ default:
21
+ defaultColor = "\033[00m"
22
+ errorColor = "\033[91m"
23
+ infoColor = "\033[94m"
24
+ }
25
+ }
26
+
27
+ // Log logs according to a format specifier using the default logger.
28
+ func Log(format string, v ...interface{}) {
29
+ DefaultLogger(format, v...)
30
+ }
31
+
32
+ func log(format string, v ...interface{}) {
33
+ errorLevel := false
34
+
35
+ for _, a := range v {
36
+ if _, ok := a.(error); ok {
37
+ errorLevel = true
38
+ break
39
+ }
40
+ }
41
+
42
+ if errorLevel {
43
+ fmt.Printf(errorColor+"ERROR ‣ "+defaultColor+format+"\n", v...)
44
+ return
45
+ }
46
+
47
+ fmt.Printf(infoColor+"INFO ‣ "+defaultColor+format+"\n", v...)
48
+ }
makefile ADDED
@@ -0,0 +1,44 @@
1
+ bootstrap:
2
+ @echo "\033[94m• Setting up go test for wasm to run in the browser\033[00m"
3
+ go get -u github.com/agnivade/wasmbrowsertest
4
+ mv ${GOPATH}/bin/wasmbrowsertest ${GOPATH}/bin/go_js_wasm_exec
5
+
6
+ .PHONY: test
7
+ test:
8
+ @echo "\033[94m• Running Go vet\033[00m"
9
+ go vet ./...
10
+ @echo "\033[94m\n• Running Go tests\033[00m"
11
+ go test -race ./...
12
+ @echo "\033[94m\n• Running go wasm tests\033[00m"
13
+ GOARCH=wasm GOOS=js go test ./pkg/app
14
+
15
+ release: test
16
+ ifdef VERSION
17
+ @echo "\033[94m\n• Releasing ${VERSION}\033[00m"
18
+ @git tag ${VERSION}
19
+ @git push origin ${VERSION}
20
+
21
+ else
22
+ @echo "\033[94m\n• Releasing version\033[00m"
23
+ @echo "\033[91mVERSION is not defided\033[00m"
24
+ @echo "~> make VERSION=\033[90mv6.0.0\033[00m release"
25
+ endif
26
+
27
+
28
+ build:
29
+ @echo "\033[94m• Building go-app documentation PWA\033[00m"
30
+ @GOARCH=wasm GOOS=js go build -o docs/web/app.wasm ./docs/src
31
+ @echo "\033[94m• Building go-app documentation\033[00m"
32
+ @go build -o docs/documentation ./docs/src
33
+
34
+ run: build
35
+ @echo "\033[94m• Running go-app documentation server\033[00m"
36
+ @cd docs && ./documentation local
37
+
38
+ github: build
39
+ @echo "\033[94m• Generating GitHub Pages\033[00m"
40
+ @cd docs && ./documentation github
41
+
42
+ clean:
43
+ @go clean -v ./...
44
+ -@rm docs/documentation
node.go ADDED
@@ -0,0 +1,203 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "reflect"
8
+
9
+ "github.com/pyros2097/wapp/errors"
10
+ )
11
+
12
+ // UI is the interface that describes a user interface element such as
13
+ // components and HTML elements.
14
+ type UI interface {
15
+ // Kind represents the specific kind of a UI element.
16
+ Kind() Kind
17
+
18
+ // JSValue returns the javascript value linked to the element.
19
+ JSValue() Value
20
+
21
+ // Reports whether the element is mounted.
22
+ Mounted() bool
23
+
24
+ name() string
25
+ self() UI
26
+ setSelf(UI)
27
+ context() context.Context
28
+ attributes() map[string]string
29
+ eventHandlers() map[string]eventHandler
30
+ parent() UI
31
+ setParent(UI)
32
+ children() []UI
33
+ mount() error
34
+ dismount()
35
+ update(UI) error
36
+ }
37
+
38
+ // Kind represents the specific kind of a user interface element.
39
+ type Kind uint
40
+
41
+ func (k Kind) String() string {
42
+ switch k {
43
+ case SimpleText:
44
+ return "text"
45
+
46
+ case HTML:
47
+ return "html"
48
+
49
+ case Selector:
50
+ return "selector"
51
+
52
+ case RawHTML:
53
+ return "raw"
54
+
55
+ case FunctionalComponent:
56
+ return "function"
57
+
58
+ default:
59
+ return "undefined"
60
+ }
61
+ }
62
+
63
+ const (
64
+ // UndefinedElem represents an undefined UI element.
65
+ UndefinedElem Kind = iota
66
+
67
+ // SimpleText represents a simple text element.
68
+ SimpleText
69
+
70
+ // HTML represents an HTML element.
71
+ HTML
72
+
73
+ // Component represents a customized, independent and reusable UI element.
74
+ Component
75
+
76
+ // Selector represents an element that is used to select a subset of
77
+ // elements within a given list.
78
+ Selector
79
+
80
+ // RawHTML represents an HTML element obtained from a raw HTML code snippet.
81
+ RawHTML
82
+
83
+ FunctionalComponent
84
+ )
85
+
86
+ // FilterUIElems returns a filtered version of the given UI elements where
87
+ // selector elements such as If and Range are interpreted and removed. It also
88
+ // remove nil elements.
89
+ //
90
+ // It should be used only when implementing components that can accept content
91
+ // with variadic arguments like HTML elements Body method.
92
+ func FilterUIElems(uis ...UI) []UI {
93
+ if len(uis) == 0 {
94
+ return nil
95
+ }
96
+
97
+ elems := make([]UI, 0, len(uis))
98
+
99
+ for _, n := range uis {
100
+ // Ignore nil elements:
101
+ if v := reflect.ValueOf(n); n == nil ||
102
+ v.Kind() == reflect.Ptr && v.IsNil() {
103
+ continue
104
+ }
105
+
106
+ switch n.Kind() {
107
+ case SimpleText, HTML, Component, RawHTML:
108
+ elems = append(elems, n)
109
+
110
+ case Selector:
111
+ elems = append(elems, n.children()...)
112
+
113
+ default:
114
+ panic(errors.New("filtering ui elements failed").
115
+ Tag("reason", "unexpected element type found").
116
+ Tag("kind", n.Kind()).
117
+ Tag("name", n.name()),
118
+ )
119
+ }
120
+ }
121
+
122
+ return elems
123
+ }
124
+
125
+ // EventHandler represents a function that can handle HTML events. They are
126
+ // always called on the UI goroutine.
127
+ type EventHandler func(ctx Context, e Event)
128
+
129
+ type eventHandler struct {
130
+ event string
131
+ jsvalue Func
132
+ value EventHandler
133
+ }
134
+
135
+ func (h eventHandler) equal(o eventHandler) bool {
136
+ return h.event == o.event &&
137
+ fmt.Sprintf("%p", h.value) == fmt.Sprintf("%p", o.value)
138
+ }
139
+
140
+ func makeJsEventHandler(src UI, h EventHandler) Func {
141
+ return FuncOf(func(this Value, args []Value) interface{} {
142
+ dispatch(func() {
143
+ if !src.Mounted() {
144
+ return
145
+ }
146
+
147
+ ctx := Context{
148
+ Context: src.context(),
149
+ Src: src,
150
+ JSSrc: src.JSValue(),
151
+ }
152
+
153
+ event := Event{
154
+ Value: args[0],
155
+ }
156
+
157
+ trackMousePosition(event)
158
+ h(ctx, event)
159
+ })
160
+
161
+ return nil
162
+ })
163
+ }
164
+
165
+ func trackMousePosition(e Event) {
166
+ x := e.Get("clientX")
167
+ if !x.Truthy() {
168
+ return
169
+ }
170
+
171
+ y := e.Get("clientY")
172
+ if !y.Truthy() {
173
+ return
174
+ }
175
+
176
+ Window().setCursorPosition(x.Int(), y.Int())
177
+ }
178
+
179
+ func isErrReplace(err error) bool {
180
+ _, replace := errors.Tag(err, "replace")
181
+ return replace
182
+ }
183
+
184
+ func mount(n UI) error {
185
+ n.setSelf(n)
186
+ return n.mount()
187
+ }
188
+
189
+ func dismount(n UI) {
190
+ n.dismount()
191
+ n.setSelf(nil)
192
+ }
193
+
194
+ func update(a, b UI) error {
195
+ a.setSelf(a)
196
+ b.setSelf(b)
197
+ return a.update(b)
198
+ }
199
+
200
+ type WritableNode interface {
201
+ Html(w io.Writer)
202
+ HtmlWithIndent(w io.Writer, indent int)
203
+ }
node_test.go ADDED
@@ -0,0 +1,177 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "testing"
6
+
7
+ "github.com/pyros2097/wapp/errors"
8
+ "github.com/stretchr/testify/require"
9
+ )
10
+
11
+ func TestKindString(t *testing.T) {
12
+ utests := []struct {
13
+ kind Kind
14
+ expectedString string
15
+ }{
16
+ {
17
+ kind: UndefinedElem,
18
+ expectedString: "undefined",
19
+ },
20
+ {
21
+ kind: SimpleText,
22
+ expectedString: "text",
23
+ },
24
+ {
25
+ kind: HTML,
26
+ expectedString: "html",
27
+ },
28
+ // {
29
+ // kind: Component,
30
+ // expectedString: "component",
31
+ // },
32
+ {
33
+ kind: Selector,
34
+ expectedString: "selector",
35
+ },
36
+ }
37
+
38
+ for _, u := range utests {
39
+ t.Run(u.expectedString, func(t *testing.T) {
40
+ require.Equal(t, u.expectedString, u.kind.String())
41
+ })
42
+ }
43
+ }
44
+
45
+ func TestFilterUIElems(t *testing.T) {
46
+ var nilText *text
47
+
48
+ simpleText := Text("hello")
49
+
50
+ expectedResult := []UI{
51
+ simpleText,
52
+ }
53
+
54
+ res := FilterUIElems(nil, nilText, simpleText)
55
+ require.Equal(t, expectedResult, res)
56
+ }
57
+
58
+ func TestIsErrReplace(t *testing.T) {
59
+ utests := []struct {
60
+ scenario string
61
+ err error
62
+ isErrReplace bool
63
+ }{
64
+ {
65
+ scenario: "error is a replace error",
66
+ err: errors.New("test").Tag("replace", true),
67
+ isErrReplace: true,
68
+ },
69
+ {
70
+ scenario: "error is not a replace error",
71
+ err: errors.New("test").Tag("test", true),
72
+ isErrReplace: false,
73
+ },
74
+ {
75
+ scenario: "standard error is not a replace error",
76
+ err: fmt.Errorf("test"),
77
+ isErrReplace: false,
78
+ },
79
+ {
80
+ scenario: "nil error is not a replace error",
81
+ err: nil,
82
+ isErrReplace: false,
83
+ },
84
+ }
85
+
86
+ for _, u := range utests {
87
+ t.Run(u.scenario, func(t *testing.T) {
88
+ res := isErrReplace(u.err)
89
+ require.Equal(t, u.isErrReplace, res)
90
+ })
91
+ }
92
+ }
93
+
94
+ type mountTest struct {
95
+ scenario string
96
+ node UI
97
+ }
98
+
99
+ func testMountDismount(t *testing.T, utests []mountTest) {
100
+ for _, u := range utests {
101
+ t.Run(u.scenario, func(t *testing.T) {
102
+ testSkipNonWasm(t)
103
+
104
+ n := u.node
105
+ err := mount(n)
106
+ require.NoError(t, err)
107
+ testMounted(t, n)
108
+
109
+ dismount(u.node)
110
+ testDismounted(t, n)
111
+ })
112
+ }
113
+ }
114
+
115
+ func testMounted(t *testing.T, n UI) {
116
+ require.NotNil(t, n.JSValue())
117
+ require.True(t, n.Mounted())
118
+
119
+ switch n.Kind() {
120
+ case HTML, Component:
121
+ require.NoError(t, n.context().Err())
122
+ require.NotNil(t, n.self())
123
+ }
124
+
125
+ for _, c := range n.children() {
126
+ require.Equal(t, n, c.parent())
127
+ testMounted(t, c)
128
+ }
129
+ }
130
+
131
+ func testDismounted(t *testing.T, n UI) {
132
+ require.Nil(t, n.JSValue())
133
+ require.False(t, n.Mounted())
134
+
135
+ switch n.Kind() {
136
+ case HTML, Component:
137
+ require.Error(t, n.context().Err())
138
+ require.Nil(t, n.self())
139
+ }
140
+
141
+ for _, c := range n.children() {
142
+ testDismounted(t, c)
143
+ }
144
+ }
145
+
146
+ type updateTest struct {
147
+ scenario string
148
+ a UI
149
+ b UI
150
+ matches []TestUIDescriptor
151
+ replaceErr bool
152
+ }
153
+
154
+ func testUpdate(t *testing.T, utests []updateTest) {
155
+ for _, u := range utests {
156
+ t.Run(u.scenario, func(t *testing.T) {
157
+ testSkipNonWasm(t)
158
+
159
+ err := mount(u.a)
160
+ require.NoError(t, err)
161
+ defer dismount(u.a)
162
+
163
+ err = update(u.a, u.b)
164
+ if u.replaceErr {
165
+ require.Error(t, err)
166
+ require.True(t, isErrReplace(err))
167
+ return
168
+ }
169
+
170
+ require.NoError(t, err)
171
+
172
+ for _, d := range u.matches {
173
+ require.NoError(t, TestMatch(u.a, d))
174
+ }
175
+ })
176
+ }
177
+ }
range.go ADDED
@@ -0,0 +1,151 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "net/url"
6
+ "reflect"
7
+ "sort"
8
+
9
+ "github.com/pyros2097/wapp/errors"
10
+ )
11
+
12
+ // RangeLoop represents a control structure that iterates within a slice, an
13
+ // array or a map.
14
+ type RangeLoop interface {
15
+ UI
16
+
17
+ // Slice sets the loop content by repeating the given function for the
18
+ // number of elements in the source.
19
+ //
20
+ // It panics if the range source is not a slice or an array.
21
+ Slice(f func(int) UI) RangeLoop
22
+
23
+ // Map sets the loop content by repeating the given function for the number
24
+ // of elements in the source. Elements are ordered by keys.
25
+ //
26
+ // It panics if the range source is not a map or if map keys are not strings.
27
+ Map(f func(string) UI) RangeLoop
28
+ }
29
+
30
+ // Range returns a range loop that iterates within the given source. Source must
31
+ // be a slice, an array or a map with strings as keys.
32
+ func Range(src interface{}) RangeLoop {
33
+ return rangeLoop{source: src}
34
+ }
35
+
36
+ type rangeLoop struct {
37
+ body []UI
38
+ source interface{}
39
+ }
40
+
41
+ func (r rangeLoop) Slice(f func(int) UI) RangeLoop {
42
+ src := reflect.ValueOf(r.source)
43
+ if src.Kind() != reflect.Slice && src.Kind() != reflect.Array {
44
+ panic(errors.New("range loop source is not a slice or array").
45
+ Tag("src-type", src.Type),
46
+ )
47
+ }
48
+
49
+ body := make([]UI, 0, src.Len())
50
+ for i := 0; i < src.Len(); i++ {
51
+ body = append(body, FilterUIElems(f(i))...)
52
+ }
53
+
54
+ r.body = body
55
+ return r
56
+ }
57
+
58
+ func (r rangeLoop) Map(f func(string) UI) RangeLoop {
59
+ src := reflect.ValueOf(r.source)
60
+ if src.Kind() != reflect.Map {
61
+ panic(errors.New("range loop source is not a map").
62
+ Tag("src-type", src.Type),
63
+ )
64
+ }
65
+
66
+ if keyType := src.Type().Key(); keyType.Kind() != reflect.String {
67
+ panic(errors.New("range loop source keys are not strings").
68
+ Tag("src-type", src.Type).
69
+ Tag("key-type", keyType),
70
+ )
71
+ }
72
+
73
+ body := make([]UI, 0, src.Len())
74
+ keys := make([]string, 0, src.Len())
75
+
76
+ for _, k := range src.MapKeys() {
77
+ keys = append(keys, k.String())
78
+ }
79
+ sort.Strings(keys)
80
+
81
+ for _, k := range keys {
82
+ body = append(body, FilterUIElems(f(k))...)
83
+ }
84
+
85
+ r.body = body
86
+ return r
87
+ }
88
+
89
+ func (r rangeLoop) Kind() Kind {
90
+ return Selector
91
+ }
92
+
93
+ func (r rangeLoop) JSValue() Value {
94
+ return nil
95
+ }
96
+
97
+ func (r rangeLoop) Mounted() bool {
98
+ return false
99
+ }
100
+
101
+ func (r rangeLoop) name() string {
102
+ return "range"
103
+ }
104
+
105
+ func (r rangeLoop) self() UI {
106
+ return r
107
+ }
108
+
109
+ func (r rangeLoop) setSelf(UI) {
110
+ }
111
+
112
+ func (r rangeLoop) context() context.Context {
113
+ return nil
114
+ }
115
+
116
+ func (r rangeLoop) attributes() map[string]string {
117
+ return nil
118
+ }
119
+
120
+ func (r rangeLoop) eventHandlers() map[string]eventHandler {
121
+ return nil
122
+ }
123
+
124
+ func (r rangeLoop) parent() UI {
125
+ return nil
126
+ }
127
+
128
+ func (r rangeLoop) setParent(UI) {
129
+ }
130
+
131
+ func (r rangeLoop) children() []UI {
132
+ return r.body
133
+ }
134
+
135
+ func (r rangeLoop) mount() error {
136
+ return errors.New("range loop is not mountable").
137
+ Tag("name", r.name()).
138
+ Tag("kind", r.Kind())
139
+ }
140
+
141
+ func (r rangeLoop) dismount() {
142
+ }
143
+
144
+ func (r rangeLoop) update(UI) error {
145
+ return errors.New("range loop cannot be updated").
146
+ Tag("name", r.name()).
147
+ Tag("kind", r.Kind())
148
+ }
149
+
150
+ func (r rangeLoop) onNav(*url.URL) {
151
+ }
range_test.go ADDED
@@ -0,0 +1,91 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestRange(t *testing.T) {
6
+ testUpdate(t, []updateTest{
7
+ {
8
+ scenario: "range slice is updated",
9
+ a: Div().Body(
10
+ Range([]string{"hello", "world"}).Slice(func(i int) UI {
11
+ src := []string{"hello", "world"}
12
+ return Text(src[i])
13
+ }),
14
+ ),
15
+ b: Div().Body(
16
+ Range([]string{"hello", "maxoo"}).Slice(func(i int) UI {
17
+ src := []string{"hello", "maxoo"}
18
+ return Text(src[i])
19
+ }),
20
+ ),
21
+ matches: []TestUIDescriptor{
22
+ {
23
+ Path: TestPath(),
24
+ Expected: Div(),
25
+ },
26
+ {
27
+ Path: TestPath(0),
28
+ Expected: Text("hello"),
29
+ },
30
+ {
31
+ Path: TestPath(1),
32
+ Expected: Text("maxoo"),
33
+ },
34
+ },
35
+ },
36
+ {
37
+ scenario: "range slice is updated to be empty",
38
+ a: Div().Body(
39
+ Range([]string{"hello", "world"}).Slice(func(i int) UI {
40
+ src := []string{"hello", "world"}
41
+ return Text(src[i])
42
+ }),
43
+ ),
44
+ b: Div().Body(
45
+ Range([]string{}).Slice(func(i int) UI {
46
+ src := []string{"hello", "maxoo"}
47
+ return Text(src[i])
48
+ }),
49
+ ),
50
+ matches: []TestUIDescriptor{
51
+ {
52
+ Path: TestPath(),
53
+ Expected: Div(),
54
+ },
55
+ {
56
+ Path: TestPath(0),
57
+ Expected: nil,
58
+ },
59
+ {
60
+ Path: TestPath(1),
61
+ Expected: nil,
62
+ },
63
+ },
64
+ },
65
+ {
66
+ scenario: "range map is updated",
67
+ a: Div().Body(
68
+ Range(map[string]string{"key": "value"}).Map(func(k string) UI {
69
+ src := map[string]string{"key": "value"}
70
+ return Text(src[k])
71
+ }),
72
+ ),
73
+ b: Div().Body(
74
+ Range(map[string]string{"key": "value"}).Map(func(k string) UI {
75
+ src := map[string]string{"key": "maxoo"}
76
+ return Text(src[k])
77
+ }),
78
+ ),
79
+ matches: []TestUIDescriptor{
80
+ {
81
+ Path: TestPath(),
82
+ Expected: Div(),
83
+ },
84
+ {
85
+ Path: TestPath(0),
86
+ Expected: Text("maxoo"),
87
+ },
88
+ },
89
+ },
90
+ })
91
+ }
raw.go ADDED
@@ -0,0 +1,173 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ "strings"
7
+
8
+ "github.com/pyros2097/wapp/errors"
9
+ )
10
+
11
+ // Raw returns a ui element from the given raw value. HTML raw value must have a
12
+ // single root.
13
+ //
14
+ // It is not recommended to use this kind of node since there is no check on the
15
+ // raw string content.
16
+ func Raw(v string) UI {
17
+ v = strings.TrimSpace(v)
18
+
19
+ tag := rawRootTagName(v)
20
+ if tag == "" {
21
+ panic(errors.New("creating raw element failed").
22
+ Tag("reason", "opening tag not found"))
23
+ }
24
+
25
+ return &raw{
26
+ value: v,
27
+ tag: tag,
28
+ }
29
+ }
30
+
31
+ type raw struct {
32
+ jsvalue Value
33
+ parentElem UI
34
+ tag string
35
+ value string
36
+ }
37
+
38
+ func (r *raw) Kind() Kind {
39
+ return RawHTML
40
+ }
41
+
42
+ func (r *raw) JSValue() Value {
43
+ return r.jsvalue
44
+ }
45
+
46
+ func (r *raw) Mounted() bool {
47
+ return r.jsvalue != nil
48
+ }
49
+
50
+ func (r *raw) name() string {
51
+ return "raw." + r.tag
52
+ }
53
+
54
+ func (r *raw) self() UI {
55
+ return r
56
+ }
57
+
58
+ func (r *raw) setSelf(UI) {
59
+ }
60
+
61
+ func (r *raw) context() context.Context {
62
+ return nil
63
+ }
64
+
65
+ func (r *raw) attributes() map[string]string {
66
+ return nil
67
+ }
68
+
69
+ func (r *raw) eventHandlers() map[string]eventHandler {
70
+ return nil
71
+ }
72
+
73
+ func (r *raw) parent() UI {
74
+ return r.parentElem
75
+ }
76
+
77
+ func (r *raw) setParent(p UI) {
78
+ r.parentElem = p
79
+ }
80
+
81
+ func (r *raw) children() []UI {
82
+ return nil
83
+ }
84
+
85
+ func (r *raw) mount() error {
86
+ if r.Mounted() {
87
+ return errors.New("mounting raw html element failed").
88
+ Tag("reason", "already mounted").
89
+ Tag("name", r.name()).
90
+ Tag("kind", r.Kind())
91
+ }
92
+
93
+ wrapper := Window().Get("document").Call("createElement", "div")
94
+ wrapper.Set("innerHTML", r.value)
95
+
96
+ value := wrapper.Get("firstChild")
97
+ if !value.Truthy() {
98
+ return errors.New("mounting raw html element failed").
99
+ Tag("reason", "converting raw html to html elements returned nil").
100
+ Tag("name", r.name()).
101
+ Tag("kind", r.Kind()).
102
+ Tag("raw-html", r.value)
103
+ }
104
+
105
+ wrapper.Call("removeChild", value)
106
+ r.jsvalue = value
107
+ return nil
108
+ }
109
+
110
+ func (r *raw) dismount() {
111
+ r.jsvalue = nil
112
+ }
113
+
114
+ func (r *raw) update(n UI) error {
115
+ if !r.Mounted() {
116
+ return nil
117
+ }
118
+
119
+ if n.Kind() != r.Kind() || r.name() != r.name() {
120
+ return errors.New("updating raw html element failed").
121
+ Tag("replace", true).
122
+ Tag("reason", "different element types").
123
+ Tag("current-kind", r.Kind()).
124
+ Tag("current-name", r.name()).
125
+ Tag("updated-kind", n.Kind()).
126
+ Tag("updated-name", n.name())
127
+ }
128
+
129
+ if v := n.(*raw).value; r.value != v {
130
+ return errors.New("updating raw html element failed").
131
+ Tag("replace", true).
132
+ Tag("reason", "different raw values").
133
+ Tag("current-value", r.value).
134
+ Tag("new-value", v)
135
+ }
136
+
137
+ return nil
138
+ }
139
+
140
+ func (r *raw) Html(w io.Writer) {
141
+ r.HtmlWithIndent(w, 0)
142
+ }
143
+
144
+ func (r *raw) HtmlWithIndent(w io.Writer, indent int) {
145
+ writeIndent(w, indent)
146
+ w.Write(stob(r.value))
147
+ w.Write(ln())
148
+ }
149
+
150
+ func rawRootTagName(raw string) string {
151
+ raw = strings.TrimSpace(raw)
152
+
153
+ if strings.HasPrefix(raw, "</") || !strings.HasPrefix(raw, "<") {
154
+ return ""
155
+ }
156
+
157
+ end := -1
158
+ for i := 1; i < len(raw); i++ {
159
+ if raw[i] == ' ' ||
160
+ raw[i] == '\t' ||
161
+ raw[i] == '\n' ||
162
+ raw[i] == '>' {
163
+ end = i
164
+ break
165
+ }
166
+ }
167
+
168
+ if end <= 0 {
169
+ return ""
170
+ }
171
+
172
+ return raw[1:end]
173
+ }
raw_test.go ADDED
@@ -0,0 +1,116 @@
1
+ package app
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/require"
7
+ )
8
+
9
+ func TestRawRootTagName(t *testing.T) {
10
+ tests := []struct {
11
+ scenario string
12
+ raw string
13
+ expected string
14
+ }{
15
+ {
16
+ scenario: "tag set",
17
+ raw: `
18
+ <div>
19
+ <span></span>
20
+ </div>`,
21
+ expected: "div",
22
+ },
23
+ {
24
+ scenario: "tag is empty",
25
+ },
26
+ {
27
+ scenario: "opening tag missing",
28
+ raw: "</div>",
29
+ },
30
+ {
31
+ scenario: "tag is not set",
32
+ raw: "div",
33
+ },
34
+ {
35
+ scenario: "tag is not closing",
36
+ raw: "<div",
37
+ },
38
+ {
39
+ scenario: "tag is not closing",
40
+ raw: "<div",
41
+ },
42
+ {
43
+ scenario: "tag without value",
44
+ raw: "<>",
45
+ },
46
+ }
47
+
48
+ for _, test := range tests {
49
+ t.Run(test.scenario, func(t *testing.T) {
50
+ tag := rawRootTagName(test.raw)
51
+ require.Equal(t, test.expected, tag)
52
+ })
53
+ }
54
+ }
55
+
56
+ func TestRawMountDismount(t *testing.T) {
57
+ testMountDismount(t, []mountTest{
58
+ {
59
+ scenario: "raw html element",
60
+ node: Raw(`<h1>Hello</h1>`),
61
+ },
62
+ {
63
+ scenario: "raw svg element",
64
+ node: Raw(`<svg></svg>`),
65
+ },
66
+ })
67
+ }
68
+
69
+ func TestRawUpdate(t *testing.T) {
70
+ testUpdate(t, []updateTest{
71
+ {
72
+ scenario: "raw html element returns replace error when updated with a non text-element",
73
+ a: Raw("<svg></svg>"),
74
+ b: Div(),
75
+ replaceErr: true,
76
+ },
77
+ {
78
+ scenario: "raw html element is replace by another raw html element",
79
+ a: Div().Body(
80
+ Raw("<div></div>"),
81
+ ),
82
+ b: Div().Body(
83
+ Raw("<svg></svg>"),
84
+ ),
85
+ matches: []TestUIDescriptor{
86
+ {
87
+ Path: TestPath(),
88
+ Expected: Div(),
89
+ },
90
+ {
91
+ Path: TestPath(0),
92
+ Expected: Raw("<svg></svg>"),
93
+ },
94
+ },
95
+ },
96
+ {
97
+ scenario: "raw html element is replace by non-raw html element",
98
+ a: Div().Body(
99
+ Raw("<div></div>"),
100
+ ),
101
+ b: Div().Body(
102
+ Text("hello"),
103
+ ),
104
+ matches: []TestUIDescriptor{
105
+ {
106
+ Path: TestPath(),
107
+ Expected: Div(),
108
+ },
109
+ {
110
+ Path: TestPath(0),
111
+ Expected: Text("hello"),
112
+ },
113
+ },
114
+ },
115
+ })
116
+ }
readme.md ADDED
@@ -0,0 +1,64 @@
1
+ <p align="center">
2
+ <a href="https://goreportcard.com/report/github.com/pyros2097/wapp"><img src="https://goreportcard.com/badge/github.com/pyros2097/wapp" alt="Go Report Card"></a>
3
+ <a href="https://GitHub.com/pyros2097/wapp/releases/"><img src="https://img.shields.io/github/release/pyros2097/wapp.svg" alt="GitHub release"></a>
4
+ <a href="https://pkg.go.dev/github.com/pyros2097/wapp/v7/pkg/app"><img src="https://img.shields.io/badge/dev-reference-007d9c?logo=go&logoColor=white&style=flat" alt="pkg.go.dev docs"></a>
5
+ </p>
6
+
7
+ **wapp** is a package to build [isomorphic web apps](https://developers.google.com/web/progressive-web-apps/) with [Go](https://golang.org) and [WebAssembly](https://webassembly.org).
8
+
9
+ It uses a [declarative syntax](#declarative-syntax) that allows creating and dealing with HTML elements only by using Go, and without writing any HTML markup. The syntax is inspired by react and its awesome hooks and functional component features.
10
+
11
+ This originally started out as of fork of this awesome golang PWA framework [go-app](https://github.com/pyros2097/wapp). All credits goes to Maxence Charriere for majority of the work.
12
+
13
+ ## Install
14
+
15
+ **wapp** requirements:
16
+
17
+ - [Go 1.15](https://golang.org/doc/go1.15)
18
+
19
+ ```sh
20
+ go mod init
21
+ go get -u -v github.com/pyros2097/wapp
22
+ ```
23
+
24
+ ## Declarative syntax
25
+
26
+ **go-app** uses a declarative syntax so you can write component-based UI elements just by using the Go programming language.
27
+
28
+ ```go
29
+ package pages
30
+
31
+ import (
32
+ "github.com/pyros2097/wapp"
33
+ )
34
+
35
+ func Index(c *app.RenderContext) app.UI {
36
+ count, setCount := c.UseInt(0)
37
+ onclick := func(ctx app.Context, e app.Event) {
38
+ setCount(count() + 1)
39
+ }
40
+
41
+ return app.Div().
42
+ Body(
43
+ app.Div().
44
+ Style("cursor", "pointer").
45
+ OnClick(onclick).
46
+ Body(
47
+ app.Text(count()),
48
+ ),
49
+ app.Text("id: "+id.(string)),
50
+ )
51
+ }
52
+ ```
53
+
54
+ The directory should look like as the following:
55
+
56
+ ```sh
57
+ .
58
+ ├── pages
59
+ └── index.go
60
+ └── about.go
61
+ └── static
62
+ └── favicon.png
63
+ └── logo.png
64
+ ```
resource.go ADDED
@@ -0,0 +1,151 @@
1
+ // +build !wasm
2
+
3
+ package app
4
+
5
+ import (
6
+ "net/http"
7
+ "strings"
8
+ )
9
+
10
+ // ResourceProvider is the interface that describes a provider for resources.
11
+ //
12
+ // App resources are the mandatory resources required to run a PWA. They are
13
+ // generated by the Handler and are accessible from the root path. Eg:
14
+ // "/app-worker.js"
15
+ // "/manifest.json"
16
+ // "/wasm_exec.js"
17
+ //
18
+ // Static resources are the resources used by the PWA such as the web assembly
19
+ // binary, styles, scripts, or images. They can be located on a localhost or a
20
+ // remote bucket. In order to avoid confusion with PWA required resources,
21
+ // static resources URL paths are always prefixed by "/web". Eg:
22
+ // "/web/app.wasm"
23
+ // "/web/main.css"
24
+ // "/web/background.jpg"
25
+ //
26
+ // If the resource provider is an http.Handler, the handler is used to serve
27
+ // static resources requests.
28
+ type ResourceProvider interface {
29
+ // The path to the root directory where app resources are accessible.
30
+ AppResources() string
31
+
32
+ // The path or URL where the web directory that contains static resources is
33
+ // located.
34
+ StaticResources() string
35
+
36
+ // The URL of the app.wasm file. This must match the pattern:
37
+ // StaticResources/web/WASM_FILE.
38
+ AppWASM() string
39
+
40
+ // The URL of the robots.txt file. This must match the pattern:
41
+ // StaticResources/web/robots.txt.
42
+ RobotsTxt() string
43
+
44
+ // The URL of the ads.txt file. This must match the pattern:
45
+ // StaticResources/web/ads.txt.
46
+ AdsTxt() string
47
+ }
48
+
49
+ // LocalDir returns a resource provider that serves static resources from a
50
+ // local directory located at the given path.
51
+ func LocalDir(path string) ResourceProvider {
52
+ return localDir{
53
+ Handler: http.StripPrefix("/web/", http.FileServer(http.Dir(path))),
54
+ path: path,
55
+ }
56
+ }
57
+
58
+ type localDir struct {
59
+ http.Handler
60
+ path string
61
+ }
62
+
63
+ func (d localDir) AppResources() string {
64
+ return ""
65
+ }
66
+
67
+ func (d localDir) StaticResources() string {
68
+ return ""
69
+ }
70
+
71
+ func (d localDir) AppWASM() string {
72
+ return "/web/app.wasm"
73
+ }
74
+
75
+ func (d localDir) RobotsTxt() string {
76
+ return "/web/robots.txt"
77
+ }
78
+
79
+ func (d localDir) AdsTxt() string {
80
+ return "/web/ads.txt"
81
+ }
82
+
83
+ // RemoteBucket returns a resource provider that provides resources from a
84
+ // remote bucket such as Amazon S3 or Google Cloud Storage.
85
+ func RemoteBucket(url string) ResourceProvider {
86
+ url = strings.TrimSuffix(url, "/")
87
+ url = strings.TrimSuffix(url, "/web")
88
+
89
+ return remoteBucket{
90
+ url: url,
91
+ }
92
+ }
93
+
94
+ type remoteBucket struct {
95
+ url string
96
+ }
97
+
98
+ func (b remoteBucket) AppResources() string {
99
+ return ""
100
+ }
101
+
102
+ func (b remoteBucket) StaticResources() string {
103
+ return b.url
104
+ }
105
+
106
+ func (b remoteBucket) AppWASM() string {
107
+ return b.StaticResources() + "/web/app.wasm"
108
+ }
109
+
110
+ func (b remoteBucket) RobotsTxt() string {
111
+ return b.StaticResources() + "/web/robots.txt"
112
+ }
113
+
114
+ func (b remoteBucket) AdsTxt() string {
115
+ return b.StaticResources() + "/web/ads.txt"
116
+ }
117
+
118
+ // GitHubPages returns a resource provider that provides resources from GitHub
119
+ // pages. This provider must only be used to generate static websites with the
120
+ // GenerateStaticWebsite function.
121
+ func GitHubPages(repoName string) ResourceProvider {
122
+ if !strings.HasPrefix(repoName, "/") {
123
+ repoName = "/" + repoName
124
+ }
125
+
126
+ return gitHubPages{repo: repoName}
127
+ }
128
+
129
+ type gitHubPages struct {
130
+ repo string
131
+ }
132
+
133
+ func (g gitHubPages) AppResources() string {
134
+ return g.repo
135
+ }
136
+
137
+ func (g gitHubPages) StaticResources() string {
138
+ return g.repo
139
+ }
140
+
141
+ func (g gitHubPages) AppWASM() string {
142
+ return g.StaticResources() + "/web/app.wasm"
143
+ }
144
+
145
+ func (g gitHubPages) RobotsTxt() string {
146
+ return g.StaticResources() + "/web/robots.txt"
147
+ }
148
+
149
+ func (g gitHubPages) AdsTxt() string {
150
+ return g.StaticResources() + "/web/ads.txt"
151
+ }
resource_test.go ADDED
@@ -0,0 +1,85 @@
1
+ // +build !wasm
2
+
3
+ package app
4
+
5
+ import (
6
+ "io/ioutil"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/stretchr/testify/require"
13
+ )
14
+
15
+ func TestLocalDir(t *testing.T) {
16
+ testSkipWasm(t)
17
+
18
+ utests := []struct {
19
+ scenario string
20
+ provider ResourceProvider
21
+ }{
22
+ {
23
+ scenario: "from web directory",
24
+ provider: LocalDir("web"),
25
+ },
26
+ }
27
+
28
+ for _, u := range utests {
29
+ t.Run(u.scenario, func(t *testing.T) {
30
+ h := u.provider.(localDir)
31
+
32
+ require.Empty(t, h.StaticResources())
33
+ require.Equal(t, "/web/app.wasm", h.AppWASM())
34
+ require.Equal(t, "/web/robots.txt", h.RobotsTxt())
35
+ require.Equal(t, "/web/ads.txt", h.AdsTxt())
36
+
37
+ close := testCreateDir(t, "web")
38
+ defer close()
39
+
40
+ resources := []string{
41
+ "/web/test",
42
+ h.AppWASM(),
43
+ h.RobotsTxt(),
44
+ }
45
+
46
+ for _, r := range resources {
47
+ t.Run(r, func(t *testing.T) {
48
+ path := strings.Replace(r, "/web", h.path, 1)
49
+ err := ioutil.WriteFile(path, stob("hello"), 0666)
50
+ require.NoError(t, err)
51
+
52
+ req := httptest.NewRequest(http.MethodGet, r, nil)
53
+ res := httptest.NewRecorder()
54
+ h.ServeHTTP(res, req)
55
+ require.Equal(t, "hello", res.Body.String())
56
+ })
57
+ }
58
+ })
59
+ }
60
+ }
61
+
62
+ func TestRemoteBucket(t *testing.T) {
63
+ utests := []struct {
64
+ scenario string
65
+ provider ResourceProvider
66
+ }{
67
+ {
68
+ scenario: "remote bucket",
69
+ provider: RemoteBucket("https://storage.googleapis.com/test"),
70
+ },
71
+ {
72
+ scenario: "remote bucket with web suffix",
73
+ provider: RemoteBucket("https://storage.googleapis.com/test/web/"),
74
+ },
75
+ }
76
+
77
+ for _, u := range utests {
78
+ t.Run(u.scenario, func(t *testing.T) {
79
+ require.Equal(t, "https://storage.googleapis.com/test", u.provider.StaticResources())
80
+ require.Equal(t, "https://storage.googleapis.com/test/web/app.wasm", u.provider.AppWASM())
81
+ require.Equal(t, "https://storage.googleapis.com/test/web/robots.txt", u.provider.RobotsTxt())
82
+ require.Equal(t, "https://storage.googleapis.com/test/web/ads.txt", u.provider.AdsTxt())
83
+ })
84
+ }
85
+ }
storage.go ADDED
@@ -0,0 +1,29 @@
1
+ package app
2
+
3
+ var (
4
+ // LocalStorage is a storage that uses the browser local storage associated
5
+ // to the document origin. Data stored has no expiration time.
6
+ LocalStorage BrowserStorage
7
+
8
+ // SessionStorage is a storage that uses the browser session storage
9
+ // associated to the document origin. Data stored expire when the page
10
+ // session ends.
11
+ SessionStorage BrowserStorage
12
+ )
13
+
14
+ // BrowserStorage is the interface that describes a web browser storage.
15
+ type BrowserStorage interface {
16
+ // Set sets the value to the given key. The value must be json convertible.
17
+ Set(k string, v interface{}) error
18
+
19
+ // Get gets the item associated to the given key and store it in the given
20
+ // value.
21
+ // It returns an error if v is not a pointer.
22
+ Get(k string, v interface{}) error
23
+
24
+ // Del deletes the item associated with the given key.
25
+ Del(k string)
26
+
27
+ // Clear deletes all items.
28
+ Clear()
29
+ }
storage_nowasm.go ADDED
@@ -0,0 +1,39 @@
1
+ // +build !wasm
2
+
3
+ package app
4
+
5
+ import "encoding/json"
6
+
7
+ func init() {
8
+ LocalStorage = make(memoryStorage)
9
+ SessionStorage = make(memoryStorage)
10
+ }
11
+
12
+ type memoryStorage map[string][]byte
13
+
14
+ func (s memoryStorage) Set(k string, v interface{}) error {
15
+ b, err := json.Marshal(v)
16
+ if err != nil {
17
+ return err
18
+ }
19
+
20
+ s[k] = b
21
+ return nil
22
+ }
23
+
24
+ func (s memoryStorage) Get(k string, v interface{}) error {
25
+ if _, ok := s[k]; !ok {
26
+ return nil
27
+ }
28
+ return json.Unmarshal(s[k], v)
29
+ }
30
+
31
+ func (s memoryStorage) Del(k string) {
32
+ delete(s, k)
33
+ }
34
+
35
+ func (s memoryStorage) Clear() {
36
+ for k := range s {
37
+ delete(s, k)
38
+ }
39
+ }
storage_test.go ADDED
@@ -0,0 +1,154 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "testing"
6
+
7
+ "github.com/stretchr/testify/require"
8
+ )
9
+
10
+ func TestLocalStorage(t *testing.T) {
11
+ testBrowserStorage(t, LocalStorage)
12
+ }
13
+
14
+ func TestLocalStorageFull(t *testing.T) {
15
+ testBrowserStorageFull(t, LocalStorage)
16
+ }
17
+
18
+ func TestSessionStorage(t *testing.T) {
19
+ testBrowserStorage(t, SessionStorage)
20
+ }
21
+
22
+ func TestSessionStorageFull(t *testing.T) {
23
+ testBrowserStorageFull(t, SessionStorage)
24
+ }
25
+
26
+ type obj struct {
27
+ Foo int
28
+ Bar string
29
+ }
30
+
31
+ func testBrowserStorage(t *testing.T, s BrowserStorage) {
32
+ tests := []struct {
33
+ scenario string
34
+ function func(*testing.T, BrowserStorage)
35
+ }{
36
+ {
37
+ scenario: "key does not exists",
38
+ function: testBrowserStorageGetNotExists,
39
+ },
40
+ {
41
+ scenario: "key is set and get",
42
+ function: testBrowserStorageSetGet,
43
+ },
44
+ {
45
+ scenario: "key is deleted",
46
+ function: testBrowserStorageDel,
47
+ },
48
+ {
49
+ scenario: "storage is cleared",
50
+ function: testBrowserStorageClear,
51
+ },
52
+ {
53
+ scenario: "set a non json value returns an error",
54
+ function: testBrowserStorageSetError,
55
+ },
56
+ {
57
+ scenario: "get with non json value receiver returns an error",
58
+ function: testBrowserStorageGetError,
59
+ },
60
+ }
61
+
62
+ for _, test := range tests {
63
+ t.Run(test.scenario, func(t *testing.T) {
64
+ test.function(t, s)
65
+ })
66
+ }
67
+ }
68
+
69
+ func testBrowserStorageGetNotExists(t *testing.T, s BrowserStorage) {
70
+ var o obj
71
+ err := s.Get("/notexists", &o)
72
+ require.NoError(t, err)
73
+ require.Zero(t, o)
74
+ }
75
+
76
+ func testBrowserStorageSetGet(t *testing.T, s BrowserStorage) {
77
+ var o obj
78
+ err := s.Set("/exists", obj{
79
+ Foo: 42,
80
+ Bar: "hello",
81
+ })
82
+ require.NoError(t, err)
83
+
84
+ err = s.Get("/exists", &o)
85
+ require.NoError(t, err)
86
+ require.Equal(t, 42, o.Foo)
87
+ require.Equal(t, "hello", o.Bar)
88
+ }
89
+
90
+ func testBrowserStorageDel(t *testing.T, s BrowserStorage) {
91
+ var o obj
92
+ err := s.Set("/deleted", obj{
93
+ Foo: 42,
94
+ Bar: "bye",
95
+ })
96
+ require.NoError(t, err)
97
+
98
+ s.Del("/deleted")
99
+ err = s.Get("/deleted", &o)
100
+ require.NoError(t, err)
101
+ require.Zero(t, o)
102
+ }
103
+
104
+ func testBrowserStorageClear(t *testing.T, s BrowserStorage) {
105
+ var o obj
106
+ err := s.Set("/cleared", obj{
107
+ Foo: 42,
108
+ Bar: "sayonara",
109
+ })
110
+ require.NoError(t, err)
111
+
112
+ s.Clear()
113
+ err = s.Get("/cleared", &o)
114
+ require.NoError(t, err)
115
+ require.Zero(t, o)
116
+ }
117
+
118
+ func testBrowserStorageSetError(t *testing.T, s BrowserStorage) {
119
+ err := s.Set("/func", func() {})
120
+ require.Error(t, err)
121
+ }
122
+
123
+ func testBrowserStorageGetError(t *testing.T, s BrowserStorage) {
124
+ err := s.Set("/value", obj{
125
+ Foo: 42,
126
+ Bar: "omae",
127
+ })
128
+ require.NoError(t, err)
129
+
130
+ var f func()
131
+ err = s.Get("/value", &f)
132
+ require.Error(t, err)
133
+ }
134
+
135
+ func testBrowserStorageFull(t *testing.T, s BrowserStorage) {
136
+ testSkipNonWasm(t)
137
+
138
+ var err error
139
+ data := make([]byte, 4096)
140
+ i := 0
141
+
142
+ for {
143
+ key := fmt.Sprintf("/key_%d", i)
144
+
145
+ if err = s.Set(key, data); err != nil {
146
+ break
147
+ }
148
+
149
+ i++
150
+ }
151
+
152
+ require.Error(t, err)
153
+ t.Log(err)
154
+ }
storage_wasm.go ADDED
@@ -0,0 +1,72 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "sync"
6
+ "syscall/js"
7
+
8
+ "github.com/pyros2097/wapp/errors"
9
+ )
10
+
11
+ func init() {
12
+ LocalStorage = newJSStorage("localStorage")
13
+ SessionStorage = newJSStorage("sessionStorage")
14
+ }
15
+
16
+ type jsStorage struct {
17
+ name string
18
+ mutex sync.RWMutex
19
+ }
20
+
21
+ func newJSStorage(name string) *jsStorage {
22
+ return &jsStorage{name: name}
23
+ }
24
+
25
+ func (s *jsStorage) Set(k string, v interface{}) (err error) {
26
+ defer func() {
27
+ r := recover()
28
+ if r != nil {
29
+ err = errors.New("setting storage value failed").
30
+ Tag("storage-type", s.name).
31
+ Tag("key", k).
32
+ Wrap(r.(js.Error))
33
+ }
34
+ }()
35
+
36
+ s.mutex.Lock()
37
+ defer s.mutex.Unlock()
38
+
39
+ b, err := json.Marshal(v)
40
+ if err != nil {
41
+ return err
42
+ }
43
+
44
+ Window().Get(s.name).Call("setItem", k, btos(b))
45
+ return nil
46
+ }
47
+
48
+ func (s *jsStorage) Get(k string, v interface{}) error {
49
+ s.mutex.RLock()
50
+ defer s.mutex.RUnlock()
51
+
52
+ item := Window().Get(s.name).Call("getItem", k)
53
+ if !item.Truthy() {
54
+ return nil
55
+ }
56
+
57
+ return json.Unmarshal(stob(item.String()), v)
58
+ }
59
+
60
+ func (s *jsStorage) Del(k string) {
61
+ s.mutex.Lock()
62
+ defer s.mutex.Unlock()
63
+
64
+ Window().Get(s.name).Call("removeItem", k)
65
+ }
66
+
67
+ func (s *jsStorage) Clear() {
68
+ s.mutex.Lock()
69
+ defer s.mutex.Unlock()
70
+
71
+ Window().Get(s.name).Call("clear")
72
+ }
strings.go ADDED
@@ -0,0 +1,45 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "strconv"
7
+ "unsafe"
8
+ )
9
+
10
+ func toString(v interface{}) string {
11
+ switch v := v.(type) {
12
+ case string:
13
+ return v
14
+
15
+ case []byte:
16
+ return btos(v)
17
+
18
+ case int:
19
+ return strconv.Itoa(v)
20
+
21
+ case float64:
22
+ return strconv.FormatFloat(v, 'f', 4, 64)
23
+
24
+ default:
25
+ return fmt.Sprint(v)
26
+ }
27
+ }
28
+
29
+ func writeIndent(w io.Writer, indent int) {
30
+ for i := 0; i < indent*4; i++ {
31
+ w.Write(stob(" "))
32
+ }
33
+ }
34
+
35
+ func ln() []byte {
36
+ return stob("\n")
37
+ }
38
+
39
+ func btos(b []byte) string {
40
+ return *(*string)(unsafe.Pointer(&b))
41
+ }
42
+
43
+ func stob(s string) []byte {
44
+ return *(*[]byte)(unsafe.Pointer(&s))
45
+ }
testing.go ADDED
@@ -0,0 +1,289 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/pyros2097/wapp/errors"
7
+ )
8
+
9
+ // TestUIDescriptor represents a descriptor that describes a UI element and its
10
+ // location from its parents.
11
+ type TestUIDescriptor struct {
12
+ // The location of the node. It is used by the TestMatch to find the
13
+ // element to test.
14
+ //
15
+ // If empty, the expected UI element is compared with the root of the tree.
16
+ //
17
+ // Otherwise, each integer represents the index of the element to traverse,
18
+ // from the root's children to the element to compare
19
+ Path []int
20
+
21
+ // The element to compare with the element targeted by Path. Compare
22
+ // behavior varies depending on the element kind.
23
+ //
24
+ // Simple text elements only have their text value compared.
25
+ //
26
+ // HTML elements have their attribute compared and check if their event
27
+ // handlers are set.
28
+ //
29
+ // Components have their exported field values compared.
30
+ Expected UI
31
+ }
32
+
33
+ // TestPath is a helper function that returns a path to use in a
34
+ // TestUIDescriptor.
35
+ func TestPath(p ...int) []int {
36
+ return p
37
+ }
38
+
39
+ // TestMatch looks for the element targeted by the descriptor in the given tree
40
+ // and reports whether it matches with the expected element.
41
+ //
42
+ // Eg:
43
+ // tree := app.Div().Body(
44
+ // app.H2().Body(
45
+ // app.Text("foo"),
46
+ // ),
47
+ // app.P().Body(
48
+ // app.Text("bar"),
49
+ // ),
50
+ // )
51
+ //
52
+ // // Testing root:
53
+ // err := app.TestMatch(tree, app.TestUIDescriptor{
54
+ // Path: TestPath(),
55
+ // Expected: app.Div(),
56
+ // })
57
+ // // OK => err == nil
58
+ //
59
+ // // Testing h2:
60
+ // err := app.TestMatch(tree, app.TestUIDescriptor{
61
+ // Path: TestPath(0),
62
+ // Expected: app.H3(),
63
+ // })
64
+ // // KO => err != nil because we ask h2 to match with h3
65
+ //
66
+ // // Testing text from p:
67
+ // err = app.TestMatch(tree, app.TestUIDescriptor{
68
+ // Path: TestPath(1, 0),
69
+ // Expected: app.Text("bar"),
70
+ // })
71
+ // // OK => err == nil
72
+ func TestMatch(tree UI, d TestUIDescriptor) error {
73
+ if !tree.Mounted() {
74
+ if err := mount(tree); err != nil {
75
+ return err
76
+ }
77
+ }
78
+
79
+ if d.Expected != nil {
80
+ d.Expected.setSelf(d.Expected)
81
+ }
82
+
83
+ if len(d.Path) != 0 {
84
+ idx := d.Path[0]
85
+
86
+ if idx < 0 || idx >= len(tree.children()) {
87
+ // Check that the element does not exists.
88
+ if d.Expected == nil {
89
+ return nil
90
+ }
91
+
92
+ return errors.New("ui element to match is out of range").
93
+ Tag("name", d.Expected.name()).
94
+ Tag("kind", d.Expected.Kind()).
95
+ Tag("parent-name", tree.name()).
96
+ Tag("parent-kind", tree.Kind()).
97
+ Tag("parent-children-count", len(tree.children())).
98
+ Tag("index", idx)
99
+ }
100
+
101
+ c := tree.children()[idx]
102
+ p := c.parent()
103
+
104
+ if p != tree {
105
+ return errors.New("unexpected ui element parent").
106
+ Tag("name", d.Expected.name()).
107
+ Tag("kind", d.Expected.Kind()).
108
+ Tag("parent-name", p.name()).
109
+ Tag("parent-kind", p.Kind()).
110
+ Tag("parent-addr", fmt.Sprintf("%p", p)).
111
+ Tag("expected-parent-name", tree.name()).
112
+ Tag("expected-parent-kind", tree.Kind()).
113
+ Tag("expected-parent-addr", fmt.Sprintf("%p", tree))
114
+ }
115
+
116
+ d.Path = d.Path[1:]
117
+ return TestMatch(c, d)
118
+ }
119
+
120
+ if d.Expected.name() != tree.name() || d.Expected.Kind() != tree.Kind() {
121
+ return errors.New("the UI element is not matching the descriptor").
122
+ Tag("expected-name", d.Expected.name()).
123
+ Tag("expected-kind", d.Expected.Kind()).
124
+ Tag("current-name", tree.name()).
125
+ Tag("current-kind", tree.Kind())
126
+ }
127
+
128
+ switch d.Expected.Kind() {
129
+ case SimpleText:
130
+ return matchText(tree, d)
131
+
132
+ case HTML:
133
+ if err := matchHTMLElemAttrs(tree, d); err != nil {
134
+ return err
135
+ }
136
+ return matchHTMLElemEventHandlers(tree, d)
137
+
138
+ // case Component:
139
+ // return matchComponent(tree, d)
140
+
141
+ case RawHTML:
142
+ return matchRaw(tree, d)
143
+
144
+ default:
145
+ return errors.New("the UI element is not matching the descriptor").
146
+ Tag("reason", "unavailable matching for the kind").
147
+ Tag("kind", d.Expected.Kind())
148
+ }
149
+ }
150
+
151
+ func matchText(n UI, d TestUIDescriptor) error {
152
+ a := n.(*text)
153
+ b := d.Expected.(*text)
154
+
155
+ if a.value != b.value {
156
+ return errors.New("the text element is not matching the descriptor").
157
+ Tag("name", a.name()).
158
+ Tag("reason", "unexpected text value").
159
+ Tag("expected-value", b.value).
160
+ Tag("current-value", a.value)
161
+ }
162
+ return nil
163
+ }
164
+
165
+ func matchHTMLElemAttrs(n UI, d TestUIDescriptor) error {
166
+ aAttrs := n.attributes()
167
+ bAttrs := d.Expected.attributes()
168
+
169
+ if len(aAttrs) != len(bAttrs) {
170
+ return errors.New("the html element is not matching the descriptor").
171
+ Tag("name", n.name()).
172
+ Tag("reason", "unexpected attributes length").
173
+ Tag("expected-attributes-length", len(bAttrs)).
174
+ Tag("current-attributes-length", len(aAttrs))
175
+ }
176
+
177
+ for k, b := range bAttrs {
178
+ a, exists := aAttrs[k]
179
+ if !exists {
180
+ return errors.New("the html element is not matching the descriptor").
181
+ Tag("name", n.name()).
182
+ Tag("reason", "an attribute is missing").
183
+ Tag("attribute", k)
184
+ }
185
+
186
+ if a != b {
187
+ return errors.New("the html element is not matching the descriptor").
188
+ Tag("name", n.name()).
189
+ Tag("reason", "unexpected attribute value").
190
+ Tag("attribute", k).
191
+ Tag("expected-value", b).
192
+ Tag("current-value", a)
193
+ }
194
+ }
195
+
196
+ for k := range bAttrs {
197
+ _, exists := bAttrs[k]
198
+ if !exists {
199
+ return errors.New("the html element is not matching the descriptor").
200
+ Tag("name", n.name()).
201
+ Tag("reason", "an unexpected attribute is present").
202
+ Tag("attribute", k)
203
+ }
204
+ }
205
+
206
+ return nil
207
+ }
208
+
209
+ func matchHTMLElemEventHandlers(n UI, d TestUIDescriptor) error {
210
+ aevents := n.eventHandlers()
211
+ bevents := d.Expected.eventHandlers()
212
+
213
+ if len(aevents) != len(bevents) {
214
+ return errors.New("the html element is not matching the descriptor").
215
+ Tag("name", n.name()).
216
+ Tag("reason", "unexpected event handlers length").
217
+ Tag("expected-event-handlers-length", len(bevents)).
218
+ Tag("current-event-handlers-length", len(aevents))
219
+ }
220
+
221
+ for k := range bevents {
222
+ _, exists := aevents[k]
223
+ if !exists {
224
+ return errors.New("the html element is not matching the descriptor").
225
+ Tag("name", n.name()).
226
+ Tag("reason", "an event handler is missing").
227
+ Tag("event-handler", k)
228
+ }
229
+ }
230
+
231
+ for k := range bevents {
232
+ _, exists := aevents[k]
233
+ if !exists {
234
+ return errors.New("the html element is not matching the descriptor").
235
+ Tag("name", n.name()).
236
+ Tag("reason", "an unexpected event handler is present").
237
+ Tag("event-handler", k)
238
+ }
239
+ }
240
+
241
+ return nil
242
+
243
+ }
244
+
245
+ // func matchComponent(n UI, d TestUIDescriptor) error {
246
+ // aval := reflect.ValueOf(n).Elem()
247
+ // bval := reflect.ValueOf(d.Expected).Elem()
248
+
249
+ // compotype := reflect.TypeOf(Compo{})
250
+
251
+ // for i := 0; i < bval.NumField(); i++ {
252
+ // a := aval.Field(i)
253
+ // b := bval.Field(i)
254
+
255
+ // if a.Type() == compotype {
256
+ // continue
257
+ // }
258
+
259
+ // if !a.CanSet() {
260
+ // continue
261
+ // }
262
+
263
+ // if !reflect.DeepEqual(a.Interface(), b.Interface()) {
264
+ // return errors.New("the component is not matching with the descriptor").
265
+ // Tag("name", n.name()).
266
+ // Tag("reason", "unexpected field value").
267
+ // Tag("field", bval.Type().Field(i).Name).
268
+ // Tag("expected-value", b.Interface()).
269
+ // Tag("current-value", a.Interface())
270
+ // }
271
+ // }
272
+
273
+ // return nil
274
+ // }
275
+
276
+ func matchRaw(n UI, d TestUIDescriptor) error {
277
+ a := n.(*raw)
278
+ b := d.Expected.(*raw)
279
+
280
+ if a.value != b.value {
281
+ return errors.New("the raw html element is not matching with the descriptor").
282
+ Tag("name", n.name()).
283
+ Tag("reason", "unexpected value").
284
+ Tag("expected-value", b.value).
285
+ Tag("current-value", a.value)
286
+ }
287
+
288
+ return nil
289
+ }
testing_test.go ADDED
@@ -0,0 +1,46 @@
1
+ package app
2
+
3
+ import (
4
+ "io/ioutil"
5
+ "os"
6
+ "runtime"
7
+ "testing"
8
+
9
+ "github.com/stretchr/testify/require"
10
+ )
11
+
12
+ func testSkipNonWasm(t *testing.T) {
13
+ if goarch := runtime.GOARCH; goarch != "wasm" {
14
+ t.Skip("skipping test")
15
+ // t.Skip(logs.New("skipping test").
16
+ // Tag("reason", "unsupported architecture").
17
+ // Tag("required-architecture", "wasm").
18
+ // Tag("current-architecture", goarch),
19
+ // )
20
+ }
21
+ }
22
+
23
+ func testSkipWasm(t *testing.T) {
24
+ if goarch := runtime.GOARCH; goarch == "wasm" {
25
+ t.Skip("skipping test")
26
+ // t.Skip(logs.New("skipping test").
27
+ // Tag("reason", "unsupported architecture").
28
+ // Tag("required-architecture", "!= than wasm").
29
+ // Tag("current-architecture", goarch),
30
+ // )
31
+ }
32
+ }
33
+
34
+ func testCreateDir(t *testing.T, path string) func() {
35
+ err := os.MkdirAll(path, 0755)
36
+ require.NoError(t, err)
37
+
38
+ return func() {
39
+ os.RemoveAll(path)
40
+ }
41
+ }
42
+
43
+ func testCreateFile(t *testing.T, path, content string) {
44
+ err := ioutil.WriteFile(path, stob(content), 0666)
45
+ require.NoError(t, err)
46
+ }
text.go ADDED
@@ -0,0 +1,120 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+
7
+ "github.com/pyros2097/wapp/errors"
8
+ )
9
+
10
+ // Text creates a simple text element.
11
+ func Text(v interface{}) UI {
12
+ return &text{value: toString(v)}
13
+ }
14
+
15
+ type text struct {
16
+ jsvalue Value
17
+ parentElem UI
18
+ value string
19
+ }
20
+
21
+ func (t *text) Kind() Kind {
22
+ return SimpleText
23
+ }
24
+
25
+ func (t *text) JSValue() Value {
26
+ return t.jsvalue
27
+ }
28
+
29
+ func (t *text) Mounted() bool {
30
+ return t.jsvalue != nil
31
+ }
32
+
33
+ func (t *text) name() string {
34
+ return "text"
35
+ }
36
+
37
+ func (t *text) self() UI {
38
+ return t
39
+ }
40
+
41
+ func (t *text) setSelf(n UI) {
42
+ }
43
+
44
+ func (t *text) context() context.Context {
45
+ return context.TODO()
46
+ }
47
+
48
+ func (t *text) attributes() map[string]string {
49
+ return nil
50
+ }
51
+
52
+ func (t *text) eventHandlers() map[string]eventHandler {
53
+ return nil
54
+ }
55
+
56
+ func (t *text) parent() UI {
57
+ return t.parentElem
58
+ }
59
+
60
+ func (t *text) setParent(p UI) {
61
+ t.parentElem = p
62
+ }
63
+
64
+ func (t *text) children() []UI {
65
+ return nil
66
+ }
67
+
68
+ func (t *text) mount() error {
69
+ if t.Mounted() {
70
+ return errors.New("mounting ui element failed").
71
+ Tag("reason", "already mounted").
72
+ Tag("kind", t.Kind()).
73
+ Tag("name", t.name()).
74
+ Tag("value", t.value)
75
+ }
76
+
77
+ t.jsvalue = Window().
78
+ Get("document").
79
+ Call("createTextNode", t.value)
80
+
81
+ return nil
82
+ }
83
+
84
+ func (t *text) dismount() {
85
+ t.jsvalue = nil
86
+ }
87
+
88
+ func (t *text) update(n UI) error {
89
+ if !t.Mounted() {
90
+ return nil
91
+ }
92
+
93
+ o, isText := n.(*text)
94
+ if !isText {
95
+ return errors.New("updating ui element failed").
96
+ Tag("replace", true).
97
+ Tag("reason", "different element types").
98
+ Tag("current-kind", t.Kind()).
99
+ Tag("current-name", t.name()).
100
+ Tag("updated-kind", n.Kind()).
101
+ Tag("updated-name", n.name())
102
+ }
103
+
104
+ if t.value != o.value {
105
+ t.value = o.value
106
+ t.jsvalue.Set("nodeValue", o.value)
107
+ }
108
+
109
+ return nil
110
+ }
111
+
112
+ func (t *text) Html(w io.Writer) {
113
+ t.HtmlWithIndent(w, 0)
114
+ }
115
+
116
+ func (t *text) HtmlWithIndent(w io.Writer, indent int) {
117
+ writeIndent(w, indent)
118
+ // html.EscapeString(
119
+ w.Write(stob(t.value))
120
+ }
text_test.go ADDED
@@ -0,0 +1,103 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestTextMountDismout(t *testing.T) {
6
+ testMountDismount(t, []mountTest{
7
+ {
8
+ scenario: "text",
9
+ node: Text("hello"),
10
+ },
11
+ })
12
+ }
13
+
14
+ func TestTextUpdate(t *testing.T) {
15
+ testUpdate(t, []updateTest{
16
+ {
17
+ scenario: "text element returns replace error when updated with a non text-element",
18
+ a: Text("hello"),
19
+ b: Div(),
20
+ replaceErr: true,
21
+ },
22
+ {
23
+ scenario: "text element is updated",
24
+ a: Text("hello"),
25
+ b: Text("world"),
26
+ matches: []TestUIDescriptor{
27
+ {
28
+ Expected: Text("world"),
29
+ },
30
+ },
31
+ },
32
+
33
+ {
34
+ scenario: "text is replaced by a html elem",
35
+ a: Div().Body(
36
+ Text("hello"),
37
+ ),
38
+ b: Div().Body(
39
+ H2().Text("hello"),
40
+ ),
41
+ matches: []TestUIDescriptor{
42
+ {
43
+ Path: TestPath(),
44
+ Expected: Div(),
45
+ },
46
+ {
47
+ Path: TestPath(0),
48
+ Expected: H2(),
49
+ },
50
+ {
51
+ Path: TestPath(0, 0),
52
+ Expected: Text("hello"),
53
+ },
54
+ },
55
+ },
56
+ {
57
+ scenario: "text is replaced by a component",
58
+ a: Div().Body(
59
+ Text("hello"),
60
+ ),
61
+ // b: Div().Body(
62
+ // &hello{},
63
+ // ),
64
+ matches: []TestUIDescriptor{
65
+ {
66
+ Path: TestPath(),
67
+ Expected: Div(),
68
+ },
69
+ // {
70
+ // Path: TestPath(0),
71
+ // Expected: &hello{},
72
+ // },
73
+ {
74
+ Path: TestPath(0, 0, 0),
75
+ Expected: H1(),
76
+ },
77
+ {
78
+ Path: TestPath(0, 0, 0, 0),
79
+ Expected: Text("hello, "),
80
+ },
81
+ },
82
+ },
83
+ {
84
+ scenario: "text is replaced by a raw html element",
85
+ a: Div().Body(
86
+ Text("hello"),
87
+ ),
88
+ b: Div().Body(
89
+ Raw("<svg></svg>"),
90
+ ),
91
+ matches: []TestUIDescriptor{
92
+ {
93
+ Path: TestPath(),
94
+ Expected: Div(),
95
+ },
96
+ {
97
+ Path: TestPath(0),
98
+ Expected: Raw("<svg></svg>"),
99
+ },
100
+ },
101
+ },
102
+ })
103
+ }