gromer
git clone https://git.pyrossh.dev/gromer
gromer is a framework and cli to build multipage web apps in golang using htmx and alpinejs.
4970ae1
— pyros2097
2020-11-13T19:22:46+05:30
initial commit
- .gitattributes +2 -0
- .gitignore +21 -0
- LICENSE +21 -0
- app.go +55 -0
- app_nowasm.go +32 -0
- app_wasm.go +100 -0
- cmd/wapp/main.go +310 -0
- component.go +380 -0
- component_test.go +277 -0
- concurrency.go +15 -0
- condition.go +121 -0
- condition_test.go +175 -0
- context.go +20 -0
- element.go +456 -0
- element_test.go +367 -0
- errors/README.md +17 -0
- errors/errors.go +208 -0
- errors/errors_test.go +153 -0
- go.mod +12 -0
- go.sum +31 -0
- html.go +65 -0
- js.go +224 -0
- js_nowasm.go +150 -0
- js_wasm.go +247 -0
- log.go +48 -0
- makefile +44 -0
- node.go +203 -0
- node_test.go +177 -0
- range.go +151 -0
- range_test.go +91 -0
- raw.go +173 -0
- raw_test.go +116 -0
- readme.md +64 -0
- resource.go +151 -0
- resource_test.go +85 -0
- storage.go +29 -0
- storage_nowasm.go +39 -0
- storage_test.go +154 -0
- storage_wasm.go +72 -0
- strings.go +45 -0
- testing.go +289 -0
- testing_test.go +46 -0
- text.go +120 -0
- 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
|
+
}
|