├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── cmd └── vgrgen │ └── vgrgen-main.go ├── go.mod ├── go.sum ├── mpath.go ├── mpath_test.go ├── navigator.go ├── rgen ├── rgen.go └── rgen_test.go ├── rjs.go ├── router.go └── router_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .vscode 15 | 16 | *.gobak 17 | 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.14.x 4 | 5 | os: 6 | - linux 7 | 8 | dist: trusty 9 | sudo: false 10 | 11 | install: true 12 | 13 | env: 14 | - GO111MODULE=on 15 | 16 | script: 17 | - go test ./... 18 | 19 | notifications: 20 | email: true 21 | 22 | before_install: 23 | - go get golang.org/x/tools/cmd/goimports && go install golang.org/x/tools/cmd/goimports 24 | 25 | # - docker pull vugu/wasm-test-suite:latest 26 | # - docker run -d -t -p 9222:9222 -p 8846:8846 --name wasm-test-suite vugu/wasm-test-suite:latest 27 | 28 | #services: 29 | # - docker 30 | 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vugu 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vgrouter 2 | 3 | A URL router for Vugu. As of April 2020 this is functional. See test cases https://github.com/vugu/vgrouter/blob/master/rgen/rgen_test.go and https://github.com/vugu/vugu/tree/master/wasm-test-suite/test-012-router as examples. 4 | 5 | [](https://travis-ci.org/vugu/vgrouter) 6 | 7 | More documention will follow. 8 | 9 | 31 | -------------------------------------------------------------------------------- /cmd/vgrgen/vgrgen-main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "path/filepath" 7 | 8 | "github.com/vugu/vgrouter/rgen" 9 | ) 10 | 11 | func main() { 12 | 13 | packageName := flag.String("p", "", "The full package name to use. If unspecified auto-detection will be attempted using go.mod") 14 | recursive := flag.Bool("r", false, "Specify to recursively process subdirectories") 15 | q := flag.Bool("q", false, "Only print information upon error (quiet mode)") 16 | 17 | flag.Parse() 18 | 19 | args := flag.Args() 20 | if len(args) == 0 { 21 | args = []string{"."} // default to current dir 22 | } 23 | 24 | if *packageName != "" && len(args) > 1 { 25 | log.Fatalf("-p is only valid with a single directory, either don't use -p or only specify one dir") 26 | } 27 | 28 | for _, arg := range args { 29 | 30 | dir, err := filepath.Abs(arg) 31 | if err != nil { 32 | log.Fatalf("Error converting %q to absolute path: %v", arg, err) 33 | } 34 | 35 | if !*q { 36 | log.Printf("Processing routes for dir: %s", arg) 37 | } 38 | 39 | err = rgen.New(). 40 | SetDir(dir). 41 | SetPackageName(*packageName). 42 | SetRecursive(*recursive). 43 | Generate() 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vugu/vgrouter 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/tdewolff/minify/v2 v2.7.3 // indirect 7 | github.com/vugu/vugu v0.3.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= 2 | github.com/chromedp/cdproto v0.0.0-20191009033829-c22f49c9ff0a/go.mod h1:PfAWWKJqjlGFYJEidUM6aVIWPr0EpobeyVWEEmplX7g= 3 | github.com/chromedp/chromedp v0.5.1/go.mod h1:3NMfuKTrKNr8PWEvHzdzZ57PK4jm9zW1C5nKiaWdxcM= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 7 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 8 | github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= 9 | github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 10 | github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= 11 | github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0= 12 | github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= 13 | github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 18 | github.com/tdewolff/minify/v2 v2.7.3/go.mod h1:BkDSm8aMMT0ALGmpt7j3Ra7nLUgZL0qhyrAHXwxcy5w= 19 | github.com/tdewolff/parse/v2 v2.4.2/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= 20 | github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 21 | github.com/vugu/html v0.0.0-20190914200101-c62dc20b8289 h1:w3hfLuU5tKlcf+hhfmx6UZ5IC1h5M69dL9/6uugfLj8= 22 | github.com/vugu/html v0.0.0-20190914200101-c62dc20b8289/go.mod h1:Y3pLGz8dZUSrB9SARXqFmtW8RNs4HIGAr0+JaWL31Vg= 23 | github.com/vugu/vjson v0.0.0-20191111004939-722507e863cb/go.mod h1:z7mAqSUjRDMQ09NIO18jG2llXMHLnUHlZ3/8MEMyBPA= 24 | github.com/vugu/vugu v0.1.1-0.20200331000654-0d4b64362b04 h1:IxkA/3CjtvrpQBC/yuuqz++EbhFHw2/YHOFlqoXWXPk= 25 | github.com/vugu/vugu v0.1.1-0.20200331000654-0d4b64362b04/go.mod h1:ZKTep3ABw5XlGBkT+RIw00ds49bsWPvbTaZiLUe07Ys= 26 | github.com/vugu/vugu v0.1.1-0.20200406224150-50acda24c5ef h1:D/a/mnpFhm08XZ4OYakRYrigpMNUEwV/eUrppRUXFr0= 27 | github.com/vugu/vugu v0.1.1-0.20200406224150-50acda24c5ef/go.mod h1:kXK4tbS92o3x29D/W5kpOkysOm5IMd7OUu+pcqyIz0M= 28 | github.com/vugu/vugu v0.3.0 h1:/02oeyFqSU9+OZyG2A0NiF5WeRmiyXaD9WNdhit97BY= 29 | github.com/vugu/vugu v0.3.0/go.mod h1:RFwOrlJHEkdZvrFcde4d6c0/7SqlRA8E4l2yz1Rs8xM= 30 | github.com/vugu/xxhash v0.0.0-20191111030615-ed24d0179019 h1:8NGiD5gWbVGObr+lnqcbM2rcOQBO6mr+m19BIblCdho= 31 | github.com/vugu/xxhash v0.0.0-20191111030615-ed24d0179019/go.mod h1:PrBK6+LJXwb+3EnJTHo43Uh4FhjFFwvN4jKk4Zc5zZ8= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/net v0.0.0-20190912160710-24e19bdeb0f2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 34 | golang.org/x/sys v0.0.0-20181031143558-9b800f95dbbc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 35 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 36 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 39 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 40 | -------------------------------------------------------------------------------- /mpath.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "net/url" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | // parseMpath will split p into appropriate parts for an mpath. 12 | // After parsing each element of mpath will start with a slash, 13 | // and if it's a parameter it will be followed by a colon. 14 | func parseMpath(p string) (mpath, error) { 15 | ret := make(mpath, 0, 2) 16 | p = path.Clean("/" + p) 17 | 18 | startIdx := 0 19 | for i, c := range p { 20 | if c == '/' { 21 | str := p[startIdx:i] 22 | if len(str) > 0 { 23 | ret = append(ret, str) 24 | } 25 | startIdx = i 26 | } 27 | } 28 | 29 | str := p[startIdx:] 30 | if len(str) > 0 { 31 | ret = append(ret, str) 32 | } 33 | 34 | // lastWasSlash := false 35 | // inParam := false 36 | // startIdx := 0 37 | 38 | // for i := range p { 39 | 40 | // c := p[i] 41 | 42 | // if c == '/' { 43 | // if inParam { 44 | // ret = append(ret, p[startIdx:i]) 45 | // inParam = false 46 | // startIdx = i 47 | // continue 48 | // } 49 | // lastWasSlash = true 50 | // continue 51 | // } 52 | 53 | // if lastWasSlash && c == ':' { 54 | // ret = append(ret, p[startIdx:i]) 55 | // inParam = true 56 | // startIdx = i 57 | // continue 58 | // } 59 | 60 | // } 61 | 62 | // // append last part if needed 63 | // if startIdx < len(p) { 64 | // ret = append(ret, p[startIdx:len(p)]) 65 | // } 66 | 67 | return ret, nil 68 | } 69 | 70 | // mpath is a matchable-path. 71 | // It's split so each element starts with a slash. 72 | type mpath []string 73 | 74 | // TODO: we'll need to know the static prefix when we get into using trie stuff 75 | // func (mp mpath) prefix() string { 76 | // if len(mp) > 0 { 77 | // return mp[0] 78 | // } 79 | // return "" 80 | // } 81 | 82 | // paramNames will return the parameter names 83 | // without the preceding colon, i.e. the path "/somewhere/:p1/:p2" 84 | // will return []string{"p1","p2"} 85 | func (mp mpath) paramNames() []string { 86 | var ret []string 87 | for _, p := range mp { 88 | if strings.HasPrefix(p, "/:") { 89 | ret = append(ret, p[2:]) 90 | } 91 | } 92 | return ret 93 | } 94 | 95 | // String returns the re-assembled path pattern 96 | func (mp mpath) String() string { 97 | return strings.Join(mp, "") 98 | } 99 | 100 | var errMissingParam = errors.New("missing param") 101 | 102 | // merge will use any values provided for the appropriate path params 103 | // and return the constructed path. A missing param value will cause 104 | // errMissingParam to be returned but will still return the path with 105 | // the missing param(s) replaced with "_". The otherValues will 106 | // be populated with all values not merged into the output path. 107 | func (mp mpath) merge(v url.Values) (outPath string, otherValues url.Values, reterr error) { 108 | 109 | if len(v) > 0 { 110 | otherValues = make(url.Values, len(v)) 111 | for k, val := range v { 112 | otherValues[k] = val 113 | } 114 | } 115 | 116 | var buf bytes.Buffer 117 | buf.Grow(64) 118 | 119 | for _, p := range mp { 120 | // log.Printf("p = %q", p) 121 | if strings.HasPrefix(p, "/:") { 122 | pname := p[2:] 123 | vlist := v[pname] 124 | buf.WriteString("/") 125 | if len(vlist) == 0 { // it's only an error if no value provided, we want "?param=" to not error 126 | // log.Printf("errMissingParam pname=%q, vlist=%#v, v=%#v", pname, vlist, v) 127 | reterr = errMissingParam 128 | buf.WriteString("_") 129 | continue 130 | } 131 | buf.WriteString(vlist[0]) 132 | otherValues.Del(pname) 133 | continue 134 | } 135 | buf.WriteString(p) 136 | continue 137 | } 138 | 139 | if len(otherValues) == 0 { 140 | otherValues = nil 141 | } 142 | 143 | return buf.String(), otherValues, reterr 144 | } 145 | 146 | // match compares our mpath to the path provided and returns the parameter 147 | // values plus ok true if match. If !exact it means the path matched but there is more after 148 | func (mp mpath) match(p string) (paramValues url.Values, exact, ok bool) { 149 | 150 | p = path.Clean("/" + p) 151 | 152 | pparts := strings.Split(p, "/")[1:] // remove first empty element 153 | 154 | // mp=["/"] is a special case and matches everything 155 | if len(mp) == 1 && mp[0] == "/" { 156 | // log.Printf("matched root") 157 | return nil, p == "/", true 158 | } 159 | 160 | for i := range mp { 161 | 162 | mpart := mp[i][1:] // mpart with slash removed 163 | 164 | // log.Printf("i=%d mpart = %#v", i, mpart) 165 | 166 | // if input path is shorter (fewer parts) than pattern then definitely not a match 167 | if len(pparts) <= i { 168 | // log.Printf("pparts too short") 169 | return paramValues, false, false 170 | } 171 | 172 | ppart := pparts[i] // already has slash removed 173 | 174 | // parameter 175 | if strings.HasPrefix(mpart, ":") { 176 | 177 | pname := mpart[1:] 178 | if paramValues == nil { 179 | paramValues = make(url.Values, 2) 180 | } 181 | paramValues.Set(pname, ppart) 182 | 183 | continue 184 | 185 | } 186 | 187 | // exact match 188 | if mpart == ppart { 189 | continue 190 | } 191 | 192 | // no match 193 | return paramValues, false, false 194 | } 195 | 196 | return paramValues, len(pparts) == len(mp), true 197 | 198 | // prest := path.Clean("/" + p) 199 | 200 | // readParam := func(pin string) (pr, pv string) { 201 | // // log.Printf("readParam called with %q", pin) 202 | // for i := range pin { 203 | // if pin[i] == '/' { 204 | // return pin[i:], pin[:i] 205 | // } 206 | // } 207 | // // no slash means the entire input is the param value 208 | // return "", pin 209 | // } 210 | 211 | // for _, mpart := range mp { 212 | 213 | // // log.Printf("mp=%#v, mpart=%v, prest=%v", mp, mpart, prest) 214 | 215 | // // log.Printf("mpart=%q", mpart) 216 | // // read param 217 | // if strings.HasPrefix(mpart, ":") { 218 | // pname := mpart[1:] 219 | // var pval string 220 | // prest, pval = readParam(prest) 221 | // if paramValues == nil { 222 | // paramValues = make(url.Values, 2) 223 | // } 224 | // paramValues.Set(pname, pval) 225 | // // log.Printf("GOT TO Set %q=%q", pname, pval) 226 | // continue 227 | // } 228 | // // // check for exact match 229 | // // if !strings.HasPrefix(prest, mpart) { 230 | // // return 231 | // // } 232 | // // move past this part 233 | // prest = prest[len(mpart):] 234 | // } 235 | 236 | // exact = prest == "" 237 | 238 | // ok = true 239 | // return 240 | } 241 | -------------------------------------------------------------------------------- /mpath_test.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestMPathParamNames(t *testing.T) { 10 | 11 | var mp mpath 12 | 13 | mp, _ = parseMpath("/") 14 | if !reflect.DeepEqual(mp.paramNames(), []string(nil)) { 15 | t.Error() 16 | } 17 | 18 | mp, _ = parseMpath("/:id") 19 | if !reflect.DeepEqual(mp.paramNames(), []string{"id"}) { 20 | t.Error() 21 | } 22 | 23 | mp, _ = parseMpath("/a/:id") 24 | if !reflect.DeepEqual(mp.paramNames(), []string{"id"}) { 25 | t.Error() 26 | } 27 | 28 | mp, _ = parseMpath("/a/:id/:id2") 29 | if !reflect.DeepEqual(mp.paramNames(), []string{"id", "id2"}) { 30 | t.Error() 31 | } 32 | 33 | } 34 | 35 | func TestMPathParse(t *testing.T) { 36 | 37 | var tlist = []struct { 38 | in string 39 | out mpath 40 | }{ 41 | {"/", mpath{"/"}}, 42 | {"/:p1", mpath{"/:p1"}}, 43 | {"/:p1/", mpath{"/:p1"}}, 44 | {"/:p1/test", mpath{"/:p1", "/test"}}, 45 | {"/:p1/test/:p2", mpath{"/:p1", "/test", "/:p2"}}, 46 | {"/:p1/:p2", mpath{"/:p1", "/:p2"}}, 47 | {"/a/b", mpath{"/a", "/b"}}, 48 | } 49 | 50 | for _, ti := range tlist { 51 | t.Run(ti.in, func(t *testing.T) { 52 | mp, err := parseMpath(ti.in) 53 | if err != nil { 54 | t.Error(err) 55 | } 56 | if !reflect.DeepEqual(ti.out, mp) { 57 | t.Errorf("expected %#v, got %#v", ti.out, mp) 58 | } 59 | }) 60 | } 61 | 62 | } 63 | 64 | func TestMPathMergeMatch(t *testing.T) { 65 | 66 | var tlist = []struct { 67 | inpath string 68 | mpath mpath 69 | pvals url.Values 70 | }{ 71 | {"/", mpath{"/"}, nil}, 72 | {"/somewhere", mpath{"/:id"}, url.Values{"id": []string{"somewhere"}}}, 73 | {"/blah/somewhere", mpath{"/blah", "/:id"}, url.Values{"id": []string{"somewhere"}}}, 74 | {"/blah/somewhere/something", mpath{"/blah", "/:id", "/:id2"}, url.Values{"id": []string{"somewhere"}, "id2": []string{"something"}}}, 75 | } 76 | 77 | for _, ti := range tlist { 78 | t.Run(ti.inpath, func(t *testing.T) { 79 | pv, _, ok := ti.mpath.match(ti.inpath) 80 | if !ok { 81 | t.Errorf("got ok false") 82 | } 83 | if !reflect.DeepEqual(ti.pvals, pv) { 84 | t.Errorf("expected params %#v, got %#v", ti.pvals, pv) 85 | } 86 | p2, _, err := ti.mpath.merge(pv) 87 | if err != nil { 88 | t.Errorf("merge error: %v", err) 89 | } 90 | if p2 != ti.inpath { 91 | t.Errorf("expected p2 %#v, got %#v", ti.inpath, p2) 92 | } 93 | }) 94 | } 95 | 96 | } 97 | 98 | func TestMPathMatchExact(t *testing.T) { 99 | 100 | var tlist = []struct { 101 | inpath string 102 | mpath mpath 103 | exact bool 104 | ok bool 105 | }{ 106 | {"/", mpath{"/"}, true, true}, 107 | {"/somewhere", mpath{"/"}, false, true}, 108 | {"/somewhere/here", mpath{"/somewhere"}, false, true}, 109 | {"/somewhere", mpath{"/somewhere"}, true, true}, 110 | {"/somewhere/1", mpath{"/somewhere", "/:id"}, true, true}, 111 | {"/somewhere/1/2", mpath{"/somewhere", "/:id"}, false, true}, 112 | {"/a/v1", mpath{"/a"}, false, true}, 113 | } 114 | 115 | for _, ti := range tlist { 116 | t.Run(ti.inpath, func(t *testing.T) { 117 | _, exact, ok := ti.mpath.match(ti.inpath) 118 | if !(ok == ti.ok) { 119 | t.Errorf("expected ok %#v, got %#v", ti.ok, ok) 120 | } 121 | if !(exact == ti.exact) { 122 | t.Errorf("expected exact %#v, got %#v", ti.exact, exact) 123 | } 124 | }) 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /navigator.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import "net/url" 4 | 5 | // Navigator interface has the methods commonly needed throughout the application 6 | // to go to different pages and deal with routing. 7 | type Navigator interface { 8 | 9 | // MustNavigate is like Navigate but panics upon error. 10 | MustNavigate(path string, query url.Values, opts ...NavigatorOpt) 11 | 12 | // Navigate will go the specified path and query. 13 | Navigate(path string, query url.Values, opts ...NavigatorOpt) error 14 | 15 | // Push will take any bound parameters and put them into the URL in the appropriate place. 16 | // Only works in wasm environment otherwise has no effect. 17 | Push(opts ...NavigatorOpt) error 18 | } 19 | 20 | // NavigatorRef embeds a reference to a Navigator and provides a NavigatorSet method. 21 | type NavigatorRef struct { 22 | Navigator 23 | } 24 | 25 | // NavigatorSet implements NavigatorSetter interface. 26 | func (nr *NavigatorRef) NavigatorSet(v Navigator) { 27 | nr.Navigator = v 28 | } 29 | 30 | // NavigatorSetter is implemented by things that can accept a Navigator. 31 | type NavigatorSetter interface { 32 | NavigatorSet(v Navigator) 33 | } 34 | 35 | // NavigatorOpt is a marker interface to ensure that options to Navigator are passed intentionally. 36 | type NavigatorOpt interface { 37 | IsNavigatorOpt() 38 | } 39 | 40 | type intNavigatorOpt int 41 | 42 | // IsNavigatorOpt implements NavigatorOpt. 43 | func (i intNavigatorOpt) IsNavigatorOpt() {} 44 | 45 | var ( 46 | // NavReplace will cause this navigation to replace the 47 | // current history entry rather than pushing to the stack. 48 | // Implemented using window.history.replaceState() 49 | NavReplace NavigatorOpt = intNavigatorOpt(1) 50 | 51 | // NavSkipRender will cause this navigation to not re-render 52 | // the current component state. It can be used when a component 53 | // has already accounted for the render in some other way and 54 | // just wants to inform the Navigator of the current logical path and query. 55 | NavSkipRender NavigatorOpt = intNavigatorOpt(2) 56 | ) 57 | 58 | type navOpts []NavigatorOpt 59 | 60 | func (no navOpts) has(o NavigatorOpt) bool { 61 | for _, o2 := range no { 62 | if o == o2 { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | -------------------------------------------------------------------------------- /rgen/rgen.go: -------------------------------------------------------------------------------- 1 | package rgen 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "path/filepath" 14 | "strconv" 15 | "strings" 16 | "text/template" 17 | ) 18 | 19 | // New returns a new Generator instance. 20 | func New() *Generator { 21 | return &Generator{} 22 | } 23 | 24 | // Generator performs route generation on a given directory (and optionally sub-directories) 25 | type Generator struct { 26 | dir string // starting directory 27 | recursive bool // if true we will descend into directories 28 | packageName string // fully qualified package name corresponding to dir 29 | pathFunc func(fileName string) string // function derive path from file or struct name 30 | includeFunc func(path, fileName string) bool // function to determine if a file should be included 31 | } 32 | 33 | // SetDir assigns the directory to start generating in. 34 | func (g *Generator) SetDir(dir string) *Generator { 35 | g.dir = dir 36 | return g 37 | } 38 | 39 | // SetRecursive if passed true will enable the generator recursing 40 | // into sub-directories. 41 | func (g *Generator) SetRecursive(recursive bool) *Generator { 42 | g.recursive = recursive 43 | return g 44 | } 45 | 46 | // SetPackageName sets the fully qualified package name that corresponds 47 | // with the directory set with SetDir. 48 | func (g *Generator) SetPackageName(packageName string) *Generator { 49 | g.packageName = packageName 50 | return g 51 | } 52 | 53 | // SetPathFunc sets a function which transforms. 54 | // If not set, DefaultPathFunc will be used. 55 | func (g *Generator) SetPathFunc(f func(fileName string) string) *Generator { 56 | g.pathFunc = f 57 | return g 58 | } 59 | 60 | // SetIncludeFunc sets the function which determines which files are included in the route map. 61 | // The include function will be passed the path relative to the dir set by SetDir (and will be empty 62 | // for files in that directory) and fileName will contain the base file name. E.g. given SetDir("/a") 63 | // "/a/b.vugu" will result in a call with ("", "b.vugu"), and "/a/b/c.vugu" will result in a call 64 | // with ("b", "c.vugu"), "/a/b/c/d.vugu" with ("b/c", "d.vugu") and so on. 65 | func (g *Generator) SetIncludeFunc(f func(path, fileName string) bool) *Generator { 66 | g.includeFunc = f 67 | return g 68 | } 69 | 70 | // DefaultPathFunc will return the fileName with any suffix removed and a slash prepended. 71 | // E.g. file name "example.vugu" will return "/example". The special case of index.vugu 72 | // will return "/". 73 | func DefaultPathFunc(fileName string) string { 74 | if fileName == "index.vugu" { 75 | return "/" 76 | } 77 | return "/" + strings.TrimSuffix(fileName, path.Ext(fileName)) 78 | } 79 | 80 | // DefaultIncludeFunc will return true for any file which ends with .vugu. 81 | func DefaultIncludeFunc(path, fileName string) bool { 82 | return strings.HasSuffix(fileName, ".vugu") 83 | } 84 | 85 | // Generate does the route generation. 86 | func (g *Generator) Generate() error { 87 | 88 | // to keep our sanity we need to guarantee that g.dir is absolute 89 | dir, err := filepath.Abs(g.dir) 90 | if err != nil { 91 | return err 92 | } 93 | g.dir = dir 94 | 95 | // auto-detect g.packageName as needed 96 | if g.packageName == "" { 97 | g.packageName, err = guessImportPath(dir) 98 | // cmd := exec.Command("go", "list", "-json") 99 | // cmd.Dir = g.dir 100 | // b, err := cmd.CombinedOutput() 101 | // if err != nil { 102 | // return fmt.Errorf("error running `go list -json` to detect import path: %w; full output:\n%s", err, b) 103 | // } 104 | // var listData struct { 105 | // ImportPath string `json:"ImportPath"` 106 | // } 107 | // err = json.Unmarshal(b, &listData) 108 | // if err != nil { 109 | // return fmt.Errorf("error unmarshaling `go list -json` output: %w", err) 110 | // } 111 | // g.packageName = listData.ImportPath 112 | } 113 | 114 | df, err := g.readDirf(g.dir) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // TODO: prune branches that have nothing to be generated underneath them 120 | 121 | err = g.writeRoutes(df) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | return nil 127 | } 128 | 129 | func (g *Generator) readDirf(dirPath string) (*dirf, error) { 130 | 131 | includeFunc := g.includeFunc 132 | if includeFunc == nil { 133 | includeFunc = DefaultIncludeFunc 134 | } 135 | 136 | f, err := os.Open(dirPath) 137 | if err != nil { 138 | return nil, err 139 | } 140 | defer f.Close() 141 | 142 | fis, err := f.Readdir(-1) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | rel, err := filepath.Rel(g.dir, dirPath) 148 | if err != nil { 149 | return nil, fmt.Errorf("relative path conversion failed: %w", err) 150 | } 151 | rel = strings.TrimPrefix(path.Clean("/"+filepath.ToSlash(rel)), "/") 152 | 153 | ret := &dirf{ 154 | path: rel, 155 | } 156 | 157 | for _, fi := range fis { 158 | 159 | if fi.IsDir() { 160 | if !g.recursive { 161 | continue 162 | } 163 | subdirf, err := g.readDirf(filepath.Join(dirPath, fi.Name())) 164 | if err != nil { 165 | return nil, err 166 | } 167 | if ret.subdirs == nil { 168 | ret.subdirs = make(map[string]*dirf) 169 | } 170 | ret.subdirs[fi.Name()] = subdirf 171 | continue 172 | } 173 | 174 | if includeFunc(rel, fi.Name()) { 175 | ret.fileNames = append(ret.fileNames, fi.Name()) 176 | } 177 | } 178 | 179 | return ret, nil 180 | 181 | } 182 | 183 | type dirf struct { 184 | path string // path relative to g.dir 185 | fileNames []string // list of included files 186 | subdirs map[string]*dirf // children 187 | } 188 | 189 | func (df *dirf) Path() string { return df.path } 190 | 191 | func (g *Generator) writeRoutes(df *dirf) error { 192 | 193 | // TODO: this should be smarter about detecting what local package name is currently in use and using that 194 | _, localPackage := path.Split(df.path) 195 | if localPackage == "" { 196 | _, localPackage = filepath.Split(g.dir) 197 | } 198 | localPackage = strings.ReplaceAll(localPackage, "-", "_") // dashes not allowed, replace them with underscore for now 199 | 200 | cm := map[string]interface{}{ 201 | "LocalPackage": localPackage, 202 | "PackageName": g.packageName, 203 | "FileNameList": df.fileNames, 204 | "Subdirs": df.subdirs, 205 | "Recursive": g.recursive, 206 | "G": g, 207 | } 208 | 209 | fm := template.FuncMap{ 210 | "PathName": func(s string) string { 211 | pf := g.pathFunc 212 | if pf == nil { 213 | pf = DefaultPathFunc 214 | } 215 | return pf(s) 216 | }, 217 | "StructName": func(s string) string { 218 | return structName(s) 219 | }, 220 | "HashIdent": func(s string) string { 221 | return fmt.Sprintf("ident%x", md5.Sum([]byte(s))) 222 | }, 223 | "PathBase": path.Base, 224 | } 225 | 226 | t := template.New("0_routes_vgen.go") 227 | t.Funcs(fm) 228 | t, err := t.Parse(`package {{.LocalPackage}} 229 | 230 | // WARNING: This file was generated by vgrouter/rgen. Do not modify. 231 | 232 | import "path" 233 | 234 | {{if .Recursive}}{{range $k, $subdir := .Subdirs}}import {{HashIdent (printf "%s%s" $.PackageName $subdir.Path)}} "{{$.PackageName}}/{{$subdir.Path}}" 235 | {{end}}{{end}} 236 | 237 | // routeMap is the generated route mappings for this package. 238 | // The key is the path and the value is an instance of the component 239 | // that should be used for it. 240 | var vgRouteMap = map[string]interface{}{ 241 | {{range $k, $v := .FileNameList}} "{{PathName $v}}": &{{StructName $v}}{}, 242 | {{end}} 243 | } 244 | 245 | type vgroutes struct { 246 | prefix string 247 | recursive bool 248 | clean bool 249 | } 250 | 251 | func (r vgroutes) WithRecursive(v bool) vgroutes { 252 | r.recursive = v 253 | return r 254 | } 255 | 256 | func (r vgroutes) WithPrefix(v string) vgroutes { 257 | r.prefix = v 258 | return r 259 | } 260 | 261 | func (r vgroutes) WithClean(v bool) vgroutes { 262 | r.clean = v 263 | return r 264 | } 265 | 266 | func (r vgroutes) Map() map[string]interface{} { 267 | ret := make(map[string]interface{}, len(vgRouteMap)) 268 | for k, v := range vgRouteMap { 269 | key := r.prefix+k 270 | if r.clean { 271 | key = path.Clean(key) 272 | } 273 | ret[key] = v 274 | } 275 | 276 | {{if .Recursive}} 277 | if r.recursive { 278 | {{range $k, $subdir := .Subdirs}} 279 | for k, v := range {{HashIdent (printf "%s%s" $.PackageName $subdir.Path)}}. 280 | MakeRoutes(). 281 | WithClean(r.clean). 282 | WithRecursive(true). 283 | WithPrefix(r.prefix+"/{{PathBase $subdir.Path}}"). 284 | Map() { 285 | if r.clean { 286 | k = path.Clean(k) 287 | } 288 | ret[k] = v 289 | } 290 | {{end}} 291 | } 292 | {{end}} 293 | 294 | return ret 295 | } 296 | 297 | // MakeRoutes returns the routes for this package and an sub-packages as applicable. 298 | func MakeRoutes() vgroutes { 299 | return vgroutes{} 300 | } 301 | `) 302 | if err != nil { 303 | return err 304 | } 305 | 306 | var buf bytes.Buffer 307 | err = t.Execute(&buf, cm) 308 | if err != nil { 309 | return err 310 | } 311 | 312 | fullRouteMapPath := filepath.Join(g.dir, df.path, "0_routes_vgen.go") 313 | 314 | err = ioutil.WriteFile(fullRouteMapPath, buf.Bytes(), 0644) 315 | if err != nil { 316 | return err 317 | } 318 | 319 | b, err := exec.Command("go", "fmt", fullRouteMapPath).CombinedOutput() 320 | if err != nil { 321 | return fmt.Errorf("error running go fmt on %q: %w; full output: %s", fullRouteMapPath, err, b) 322 | } 323 | 324 | if g.recursive { 325 | // recurse into subdirs 326 | for _, subdf := range df.subdirs { 327 | err := g.writeRoutes(subdf) 328 | if err != nil { 329 | return fmt.Errorf("error in writeRoutes for %q: %w", subdf.path, err) 330 | } 331 | } 332 | } 333 | 334 | return nil 335 | } 336 | 337 | func structName(s string) string { 338 | 339 | // // trim file extension 340 | // s = strings.TrimSuffix(s, path.Ext(s)) 341 | 342 | // // if any upper case letters we use the file name as-is 343 | // for _, c := range s { 344 | // if unicode.IsUpper(c) { 345 | // return s 346 | // } 347 | // } 348 | 349 | // otherwise we transform it the same way vugu does 350 | return fnameToGoTypeName(s) 351 | 352 | } 353 | 354 | func fnameToGoTypeName(s string) string { 355 | s = strings.Split(s, ".")[0] // remove file extension if present 356 | parts := strings.Split(s, "-") 357 | for i := range parts { 358 | p := parts[i] 359 | if len(p) > 0 { 360 | p = strings.ToUpper(p[:1]) + p[1:] 361 | } 362 | parts[i] = p 363 | } 364 | return strings.Join(parts, "") 365 | } 366 | 367 | func guessImportPath(dir string) (string, error) { 368 | 369 | after := "" 370 | lastDir := dir 371 | 372 | for { 373 | f, err := os.Open(filepath.Join(dir, "go.mod")) 374 | if err == nil { 375 | defer f.Close() 376 | ret, err := readModuleEntry(f) 377 | return ret + after, err 378 | } 379 | 380 | after = "/" + filepath.Base(dir) + after 381 | 382 | dir, err = filepath.Abs(filepath.Join(dir, "..")) 383 | if err != nil { 384 | return "", err 385 | } 386 | 387 | if dir == lastDir { // we hit the root dir 388 | return "", fmt.Errorf("no go.mod file found, cannot guess import path") 389 | } 390 | } 391 | 392 | } 393 | 394 | func readModuleEntry(r io.Reader) (string, error) { 395 | 396 | b, err := ioutil.ReadAll(r) 397 | if err != nil { 398 | return "", err 399 | } 400 | 401 | ret := modulePath(b) 402 | if ret == "" { 403 | return "", errors.New("unable to determine module path from go.mod") 404 | } 405 | 406 | return ret, nil 407 | } 408 | 409 | // shamelessly stolen from: https://github.com/golang/vgo/blob/master/vendor/cmd/go/internal/modfile/read.go#L837 410 | // ModulePath returns the module path from the gomod file text. 411 | // If it cannot find a module path, it returns an empty string. 412 | // It is tolerant of unrelated problems in the go.mod file. 413 | func modulePath(mod []byte) string { 414 | for len(mod) > 0 { 415 | line := mod 416 | mod = nil 417 | if i := bytes.IndexByte(line, '\n'); i >= 0 { 418 | line, mod = line[:i], line[i+1:] 419 | } 420 | if i := bytes.Index(line, slashSlash); i >= 0 { 421 | line = line[:i] 422 | } 423 | line = bytes.TrimSpace(line) 424 | if !bytes.HasPrefix(line, moduleStr) { 425 | continue 426 | } 427 | line = line[len(moduleStr):] 428 | n := len(line) 429 | line = bytes.TrimSpace(line) 430 | if len(line) == n || len(line) == 0 { 431 | continue 432 | } 433 | 434 | if line[0] == '"' || line[0] == '`' { 435 | p, err := strconv.Unquote(string(line)) 436 | if err != nil { 437 | return "" // malformed quoted string or multiline module path 438 | } 439 | return p 440 | } 441 | 442 | return string(line) 443 | } 444 | return "" // missing module path 445 | } 446 | 447 | var ( 448 | slashSlash = []byte("//") 449 | moduleStr = []byte("module") 450 | ) 451 | -------------------------------------------------------------------------------- /rgen/rgen_test.go: -------------------------------------------------------------------------------- 1 | package rgen 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/vugu/vugu/gen" 14 | ) 15 | 16 | func TestFull(t *testing.T) { 17 | 18 | tmpDir, err := ioutil.TempDir("", "rgen") 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | defer os.RemoveAll(tmpDir) 23 | log.Printf("tmpDir: %s", tmpDir) 24 | 25 | must(ioutil.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(`module rgentestfull 26 | 27 | require github.com/vugu/vugu master 28 | `), 0644)) 29 | must(ioutil.WriteFile(filepath.Join(tmpDir, "index.vugu"), []byte("
"), 0644)) 30 | must(ioutil.WriteFile(filepath.Join(tmpDir, "page1.vugu"), []byte(""), 0644)) 31 | must(os.MkdirAll(filepath.Join(tmpDir, "section1"), 0755)) 32 | must(ioutil.WriteFile(filepath.Join(tmpDir, "section1", "index.vugu"), []byte(""), 0644)) 33 | must(ioutil.WriteFile(filepath.Join(tmpDir, "section1", "page-a.vugu"), []byte(""), 0644)) 34 | must(ioutil.WriteFile(filepath.Join(tmpDir, "section1", "page-b.vugu"), []byte(""), 0644)) 35 | must(os.MkdirAll(filepath.Join(tmpDir, "section1/subsection1"), 0755)) 36 | must(ioutil.WriteFile(filepath.Join(tmpDir, "section1/subsection1", "index.vugu"), []byte(""), 0644)) 37 | must(ioutil.WriteFile(filepath.Join(tmpDir, "section1/subsection1", "page-c.vugu"), []byte(""), 0644)) 38 | 39 | err = New().SetDir(tmpDir).SetRecursive(true).Generate() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | // run vugugen stuff so the expected Go structs exist 45 | parser := gen.NewParserGoPkg(tmpDir, nil) 46 | err = parser.Run() 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | parser = gen.NewParserGoPkg(filepath.Join(tmpDir, "section1"), nil) 51 | err = parser.Run() 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | parser = gen.NewParserGoPkg(filepath.Join(tmpDir, "section1/subsection1"), nil) 56 | err = parser.Run() 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | // write a go test file that we can use as our entry point 62 | must(ioutil.WriteFile(filepath.Join(tmpDir, "run_test.go"), []byte(`package `+filepath.Base(tmpDir)+` 63 | 64 | import ( 65 | "fmt" 66 | "sort" 67 | "testing" 68 | ) 69 | 70 | func TestOutput(t *testing.T) { 71 | 72 | m := MakeRoutes().WithRecursive(true).WithClean(true).Map() 73 | plist := make([]string, 0, len(m)) 74 | for p := range m { 75 | plist = append(plist, p) 76 | } 77 | sort.Strings(plist) 78 | for _, p := range plist { 79 | fmt.Printf("ROUTE: %s -> %T\n", p, m[p]) 80 | } 81 | 82 | } 83 | 84 | `), 0644)) 85 | 86 | // run it and get it's output (ensures both compilation and expected result) 87 | cmd := exec.Command("go", "test", "-v") 88 | cmd.Dir = tmpDir 89 | b, err := cmd.CombinedOutput() 90 | if err != nil { 91 | t.Logf("Error executing go test, OUTPUT:\n%s", b) 92 | t.Fatal(err) 93 | } 94 | 95 | // make sure it's what we expect 96 | t.Logf("OUTPUT:\n%s", b) 97 | 98 | lines := strings.Split(strings.TrimSpace(string(b)), "\n") 99 | routeLines := make([]string, 0, len(lines)) 100 | for _, line := range lines { 101 | if strings.HasPrefix(line, "ROUTE:") { 102 | routeLines = append(routeLines, line) 103 | } 104 | } 105 | 106 | if !regexp.MustCompile(`ROUTE: / -> \*.*\.Index`).MatchString(routeLines[0]) { 107 | t.Errorf("match failure") 108 | } 109 | if !regexp.MustCompile(`ROUTE: /page1 -> \*.*\.Page1`).MatchString(routeLines[1]) { 110 | t.Errorf("match failure") 111 | } 112 | // if !regexp.MustCompile(`ROUTE: /section1/ -> \*section1\.Index`).MatchString(routeLines[2]) { 113 | if !regexp.MustCompile(`ROUTE: /section1 -> \*section1\.Index`).MatchString(routeLines[2]) { 114 | t.Errorf("match failure") 115 | } 116 | if !regexp.MustCompile(`ROUTE: /section1/page-a -> \*section1\.PageA`).MatchString(routeLines[3]) { 117 | t.Errorf("match failure") 118 | } 119 | if !regexp.MustCompile(`ROUTE: /section1/page-b -> \*section1\.PageB`).MatchString(routeLines[4]) { 120 | t.Errorf("match failure") 121 | } 122 | if !regexp.MustCompile(`ROUTE: /section1/subsection1 -> \*subsection1\.Index`).MatchString(routeLines[5]) { 123 | t.Errorf("match failure") 124 | } 125 | if !regexp.MustCompile(`ROUTE: /section1/subsection1/page-c -> \*subsection1\.PageC`).MatchString(routeLines[6]) { 126 | t.Errorf("match failure") 127 | } 128 | 129 | } 130 | 131 | func must(err error) { 132 | if err != nil { 133 | panic(err) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /rjs.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/vugu/vugu/js" 9 | ) 10 | 11 | func (r *Router) pushPathAndQuery(pathAndQuery string) { 12 | 13 | g := js.Global() 14 | if g.Truthy() { 15 | pqv := pathAndQuery 16 | if r.useFragment { 17 | pqv = "#" + pathAndQuery 18 | } 19 | g.Get("window").Get("history").Call("pushState", nil, "", pqv) 20 | } 21 | 22 | } 23 | 24 | func (r *Router) replacePathAndQuery(pathAndQuery string) { 25 | 26 | g := js.Global() 27 | if g.Truthy() { 28 | pqv := pathAndQuery 29 | if r.useFragment { 30 | pqv = "#" + pathAndQuery 31 | } 32 | g.Get("window").Get("history").Call("replaceState", nil, "", pqv) 33 | } 34 | 35 | } 36 | 37 | func (r *Router) readBrowserURL() (*url.URL, error) { 38 | 39 | g := js.Global() 40 | if !g.Truthy() { 41 | return nil, errors.New("not in browser (js) environment") 42 | } 43 | 44 | var locstr string 45 | if r.useFragment { 46 | locstr = strings.TrimPrefix(js.Global().Get("window").Get("location").Get("hash").String(), "#") 47 | } else { 48 | locstr = js.Global().Get("window").Get("location").Call("toString").String() 49 | } 50 | 51 | u, err := url.Parse(locstr) 52 | if err != nil { 53 | return u, err 54 | } 55 | 56 | return u, nil 57 | 58 | } 59 | 60 | func (r *Router) removePopStateListener() error { 61 | 62 | g := js.Global() 63 | if !g.Truthy() { 64 | return errors.New("not in browser (js) environment") 65 | } 66 | 67 | if r.popStateFunc.IsUndefined() { 68 | return errors.New("popstate listener not set") 69 | } 70 | 71 | g.Get("window").Call("removeEventListener", "popstate", r.popStateFunc) 72 | 73 | r.popStateFunc.Release() 74 | r.popStateFunc = js.Func{} 75 | 76 | return nil 77 | } 78 | 79 | func (r *Router) addPopStateListener(f func(this js.Value, args []js.Value) interface{}) error { 80 | 81 | g := js.Global() 82 | if !g.Truthy() { 83 | return errors.New("not in browser (js) environment") 84 | } 85 | 86 | if !r.popStateFunc.IsUndefined() { 87 | return errors.New("popstate listener already set") 88 | } 89 | 90 | jf := js.FuncOf(f) 91 | 92 | g.Get("window").Call("addEventListener", "popstate", jf) 93 | 94 | r.popStateFunc = jf 95 | 96 | return nil 97 | 98 | } 99 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/vugu/vugu/js" 11 | ) 12 | 13 | // TODO: 14 | // * more tests DONE 15 | // * need path prefix support - wasm test suite needs it plus other 16 | // * need a method to just say "process this" and a variation of that which accepts an http.Request and sets it on the RouteMatch 17 | // * implement js stuff and fragment 18 | // * do tests in wasm test suite 19 | // - one test can cover with and without fragement by detecting "#" upon page load 20 | // * do the change to generate to _gen.go, allow MixedCase.vugu, and put in a banner 21 | // at the top of the generated file and detect before clobbering it 22 | // * make codegen directory router 23 | // also make it output a list of files, so static generator can use it 24 | // need index functionanlity plus see what we do about parameters if we can support 25 | 26 | // EventEnv is our view of a Vugu EventEnv 27 | type EventEnv interface { 28 | Lock() // acquire write lock 29 | UnlockOnly() // release write lock 30 | UnlockRender() // release write lock and request re-render 31 | 32 | // RLock() // acquire read lock 33 | // RUnlock() // release read lock 34 | } 35 | 36 | // New returns a new Router. 37 | func New(eventEnv EventEnv) *Router { 38 | // FIXME: how do we account for NavSkipRender? Is it even needed? The render loop is controlled outside of 39 | // this so maybe just leave rendering outside of the router altogether. 40 | 41 | // TODO: WE NEED TO THINK ABOUT SYNCHRONIZATION FOR ALL THIS STUFF - WHAT HAPPENS IF A 42 | // GOROUTINE TRIES TO NAVIGATE WHILE SOMETHING ELSE IS HAPPENING? 43 | 44 | return &Router{ 45 | eventEnv: eventEnv, 46 | bindParamMap: make(map[string]BindParam), 47 | } 48 | } 49 | 50 | // Router handles URL routing. 51 | type Router struct { 52 | useFragment bool 53 | pathPrefix string 54 | 55 | popStateFunc js.Func 56 | 57 | eventEnv EventEnv 58 | 59 | rlist []routeEntry 60 | notFoundHandler RouteHandler 61 | 62 | // bindRoutePath string // the route (with :param stuff in it) that matches the bind params, so we can reconstruct it 63 | bindRouteMPath mpath 64 | bindParamMap map[string]BindParam 65 | } 66 | 67 | type routeEntry struct { 68 | mpath mpath 69 | rh RouteHandler 70 | } 71 | 72 | // SetUseFragment sets the fragment flag which if set means the fragment part of the URL (after the "#") 73 | // is used as the path and query string. This can be useful for compatibility in applications which are 74 | // served statically and do not have the ability to handle URL routing on the server side. 75 | // This option is disabled by default. If used it should be set immediately after creation. Changing it 76 | // after navigation may have undefined results. 77 | func (r *Router) SetUseFragment(v bool) { 78 | r.useFragment = v 79 | } 80 | 81 | // SetPathPrefix sets the path prefix to use prepend or stripe when iteracting with the browser or external requests. 82 | // Internally paths do not use this prefix. 83 | // For example, calling `r.SetPrefix("/pfx"); r.Navigate("/a", nil)` will result in /pfx/a in the browser, but the 84 | // path will be treated as just /a during processing. The prefix is stripped from the URL when calling Pull() 85 | // and also when http.Requests are processed server-side. 86 | func (r *Router) SetPathPrefix(pfx string) { 87 | r.pathPrefix = pfx 88 | } 89 | 90 | // ListenForPopState registers an event listener so the user navigating with 91 | // forward/back/history or fragment changes will be detected and handled by this router. 92 | // Any call to SetUseFragment or SetPathPrefix should occur before calling 93 | // ListenForPopState. 94 | // 95 | // Only works in wasm environment and if called outside it will have no effect and return error. 96 | func (r *Router) ListenForPopState() error { 97 | return r.addPopStateListener(func(this js.Value, args []js.Value) interface{} { 98 | 99 | // TODO: see if we need something better for error handling 100 | 101 | // log.Printf("addPopStateListener callack") 102 | 103 | u, err := r.readBrowserURL() 104 | // log.Printf("addPopStateListener callack: u=%#v, err=%v", u, err) 105 | if err != nil { 106 | log.Printf("ListenForPopState: error from readBrowserURL: %v", err) 107 | return nil 108 | } 109 | 110 | p := u.Path 111 | if !strings.HasPrefix(p, r.pathPrefix) { 112 | log.Printf("ListenForPopState: prefix error: %v", 113 | ErrMissingPrefix{Path: p, Message: fmt.Sprintf("path %q does not begin with prefix %q", p, r.pathPrefix)}) 114 | return nil 115 | } 116 | 117 | tp := strings.TrimPrefix(p, r.pathPrefix) 118 | q := u.Query() 119 | 120 | // log.Printf("addPopStateListener calling process: tp=%q, q=%#v", tp, q) 121 | 122 | r.eventEnv.Lock() 123 | defer r.eventEnv.UnlockRender() 124 | r.process(tp, q) 125 | 126 | return nil 127 | 128 | }) 129 | } 130 | 131 | // UnlistenForPopState removes the listener created by ListenForPopState. 132 | func (r *Router) UnlistenForPopState() error { 133 | return r.removePopStateListener() 134 | } 135 | 136 | // MustNavigate is like Navigate but panics upon error. 137 | func (r *Router) MustNavigate(path string, query url.Values, opts ...NavigatorOpt) { 138 | err := r.Navigate(path, query, opts...) 139 | if err != nil { 140 | panic(err) 141 | } 142 | } 143 | 144 | // Navigate will go the specified path and query. 145 | func (r *Router) Navigate(path string, query url.Values, opts ...NavigatorOpt) error { 146 | 147 | r.process(path, query) 148 | 149 | pq := r.pathPrefix + path 150 | q := query.Encode() 151 | if len(q) > 0 { 152 | pq = pq + "?" + q 153 | } 154 | 155 | if navOpts(opts).has(NavReplace) { 156 | r.replacePathAndQuery(pq) 157 | } else { 158 | r.pushPathAndQuery(pq) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | // BrowserAvail returns true if in browser mode. 165 | func (r *Router) BrowserAvail() bool { 166 | // this is really just so otehr packages don't have to import `js` just to figure out if they should do extra browser setup 167 | return js.Global().Truthy() 168 | } 169 | 170 | // ErrMissingPrefix is returned when a prefix was expected but not found. 171 | type ErrMissingPrefix struct { 172 | Message string // error message 173 | Path string // path which is missing the prefix 174 | } 175 | 176 | // Error implements error. 177 | func (e ErrMissingPrefix) Error() string { return e.Message } 178 | 179 | // Pull will read the current browser URL and navigate to it. This is generally called 180 | // once at application startup. 181 | // Only works in wasm environment otherwise has no effect and will return error. 182 | // If a path prefix has been set and the path read does not start with prefix 183 | // then an error of type *ErrMissingPrefix will be returned. 184 | func (r *Router) Pull() error { 185 | 186 | u, err := r.readBrowserURL() 187 | if err != nil { 188 | return err 189 | } 190 | 191 | p := u.Path 192 | if !strings.HasPrefix(p, r.pathPrefix) { 193 | return ErrMissingPrefix{Path: p, Message: fmt.Sprintf("path %q does not begin with prefix %q", p, r.pathPrefix)} 194 | } 195 | 196 | r.process(strings.TrimPrefix(p, r.pathPrefix), u.Query()) 197 | 198 | return nil 199 | } 200 | 201 | // Push will take any bound parameters and put them into the URL in the appropriate place. 202 | // Only works in wasm environment otherwise has no effect. 203 | func (r *Router) Push(opts ...NavigatorOpt) error { 204 | 205 | params := make(url.Values, len(r.bindParamMap)) 206 | for k, v := range r.bindParamMap { 207 | params[k] = v.BindParamRead() 208 | } 209 | 210 | outPath, outParams, err := r.bindRouteMPath.merge(params) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | q := outParams.Encode() 216 | pq := r.pathPrefix + outPath 217 | if len(q) > 0 { 218 | pq = pq + "?" + q 219 | } 220 | 221 | if navOpts(opts).has(NavReplace) { 222 | r.replacePathAndQuery(pq) 223 | } else { 224 | r.pushPathAndQuery(pq) 225 | } 226 | 227 | return nil 228 | } 229 | 230 | // UnbindParams will remove any previous parameter bindings. 231 | // Note that this is called implicitly when navigiation occurs since that involves re-binding newly based on the 232 | // path being navigated to. 233 | func (r *Router) UnbindParams() { 234 | for k := range r.bindParamMap { 235 | delete(r.bindParamMap, k) 236 | } 237 | } 238 | 239 | // MustAddRouteExact is like AddRouteExact but panic's upon error. 240 | func (r *Router) MustAddRouteExact(path string, rh RouteHandler) { 241 | err := r.AddRouteExact(path, rh) 242 | if err != nil { 243 | panic(err) 244 | } 245 | } 246 | 247 | // AddRouteExact adds a route but only calls the handler if the path 248 | // provided matches exactly. E.g. an exact route for "/a" will not fire 249 | // when "/a/b" is navigated to (whereas AddRoute would do this). 250 | func (r *Router) AddRouteExact(path string, rh RouteHandler) error { 251 | return r.AddRoute(path, RouteHandlerFunc(func(rm *RouteMatch) { 252 | if rm.Exact { 253 | rh.RouteHandle(rm) 254 | } 255 | })) 256 | } 257 | 258 | // MustAddRoute is like AddRoute but panics upon error. 259 | func (r *Router) MustAddRoute(path string, rh RouteHandler) { 260 | err := r.AddRoute(path, rh) 261 | if err != nil { 262 | panic(err) 263 | } 264 | } 265 | 266 | // AddRoute adds a route to the list. 267 | func (r *Router) AddRoute(path string, rh RouteHandler) error { 268 | 269 | mp, err := parseMpath(path) 270 | if err != nil { 271 | return err 272 | } 273 | 274 | r.rlist = append(r.rlist, routeEntry{ 275 | mpath: mp, 276 | rh: rh, 277 | }) 278 | 279 | return nil 280 | } 281 | 282 | // SetNotFound assigns the handler for the case of no exact match reoute. 283 | func (r *Router) SetNotFound(rh RouteHandler) { 284 | r.notFoundHandler = rh 285 | } 286 | 287 | // GetNotFound returns what was set by SetNotFound. Provided to facilitate code that needs 288 | // to wrap an existing not found behavior with another one. 289 | func (r *Router) GetNotFound() RouteHandler { 290 | return r.notFoundHandler 291 | } 292 | 293 | // ProcessRequest processes the route contained in request. This is meant for server-side use with static rendering. 294 | func (r *Router) ProcessRequest(req *http.Request) { 295 | 296 | p := req.URL.Path 297 | q := req.URL.Query() 298 | 299 | r.process2(p, q, req) 300 | 301 | } 302 | 303 | // process is used interally to run through the routes and call appropriate handlers. 304 | // It will set bindRouteMPath and unbind the params and allow them to be reset. 305 | func (r *Router) process(path string, query url.Values) { 306 | r.process2(path, query, nil) 307 | } 308 | 309 | func (r *Router) process2(path string, query url.Values, req *http.Request) { 310 | 311 | // TODO: ideally we would improve the performance here with some fancy trie stuff, but for the moment 312 | // I'm much more concerned with getting things functional. 313 | 314 | for k := range r.bindParamMap { 315 | delete(r.bindParamMap, k) 316 | } 317 | r.bindRouteMPath = nil 318 | foundExact := false 319 | 320 | for _, re := range r.rlist { 321 | 322 | pvals, exact, ok := re.mpath.match(path) 323 | if !ok { 324 | continue 325 | } 326 | 327 | if !foundExact && exact { 328 | foundExact = true 329 | r.bindRouteMPath = re.mpath 330 | } 331 | 332 | // merge any other values from query into pvals 333 | if pvals == nil { 334 | pvals = make(url.Values) 335 | } 336 | for k, v := range query { 337 | if pvals[k] == nil { 338 | pvals[k] = v 339 | } 340 | } 341 | 342 | req := &RouteMatch{ 343 | router: r, 344 | Path: path, 345 | RoutePath: re.mpath.String(), 346 | Params: pvals, 347 | Exact: exact, 348 | Request: req, 349 | } 350 | 351 | re.rh.RouteHandle(req) 352 | 353 | } 354 | 355 | if !foundExact && r.notFoundHandler != nil { 356 | r.notFoundHandler.RouteHandle(&RouteMatch{ 357 | router: r, 358 | Path: path, 359 | Request: req, 360 | }) 361 | } 362 | 363 | } 364 | 365 | // RouteHandler implementations are called in response to a route matching (being navigated to). 366 | type RouteHandler interface { 367 | RouteHandle(rm *RouteMatch) 368 | } 369 | 370 | // RouteHandlerFunc implements RouteHandler as a function. 371 | type RouteHandlerFunc func(rm *RouteMatch) 372 | 373 | // RouteHandle implements the RouteHandler interface. 374 | func (f RouteHandlerFunc) RouteHandle(rm *RouteMatch) { f(rm) } 375 | 376 | // RouteMatch describes a request to navigate to a route. 377 | type RouteMatch struct { 378 | Path string // path input (with any params interpolated) 379 | RoutePath string // route path pattern with params as :param 380 | Params url.Values // parameters (combined query and route params) 381 | Exact bool // true if the path is an exact match or false if just the prefix 382 | 383 | Request *http.Request // if ProcessRequest is used, this will be set to Request instance passed to it; server-side only 384 | 385 | router *Router 386 | } 387 | 388 | // Bind adds a BindParam to the list of bound parameters. 389 | // Later calls to Bind with the same name will replace the bind 390 | // from earlier calls. 391 | func (r *RouteMatch) Bind(name string, param BindParam) { 392 | if r.router.bindParamMap == nil { 393 | r.router.bindParamMap = make(map[string]BindParam) 394 | } 395 | r.router.bindParamMap[name] = param 396 | } 397 | 398 | // BindParam is implemented by something that can be read and written as a URL param. 399 | type BindParam interface { 400 | BindParamRead() []string 401 | BindParamWrite(v []string) 402 | } 403 | 404 | // StringParam implements BindParam on a string. 405 | type StringParam string 406 | 407 | // BindParamRead implements BindParam. 408 | func (s *StringParam) BindParamRead() []string { return []string{string(*s)} } 409 | 410 | // BindParamWrite implements BindParam. 411 | func (s *StringParam) BindParamWrite(v []string) { 412 | if len(*s) == 0 { 413 | *s = "" 414 | return 415 | } 416 | *s = StringParam(v[0]) 417 | } 418 | -------------------------------------------------------------------------------- /router_test.go: -------------------------------------------------------------------------------- 1 | package vgrouter 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "testing" 8 | ) 9 | 10 | func TestRouter(t *testing.T) { 11 | 12 | type appRouter struct { 13 | *Router 14 | out map[string]RouteMatch 15 | } 16 | 17 | type tcase struct { 18 | path string // the path to request 19 | params string 20 | rp []string // route paths for which we AddRoute 21 | check func(ar *appRouter) bool 22 | } 23 | 24 | tclist := []tcase{ 25 | 26 | { 27 | "/", 28 | "", 29 | []string{"/"}, 30 | func(ar *appRouter) bool { return ar.out["/"].Path == "/" }, 31 | }, 32 | 33 | { 34 | "/nothing", 35 | "", 36 | []string{"/"}, 37 | func(ar *appRouter) bool { return ar.out["_not_found"].Path == "/nothing" }, 38 | }, 39 | 40 | { 41 | "/a", 42 | "", 43 | []string{"/a"}, 44 | func(ar *appRouter) bool { 45 | return ar.out["/a"].Path == "/a" && 46 | ar.out["/a"].Exact 47 | }, 48 | }, 49 | 50 | { 51 | "/a", 52 | "", 53 | []string{"/", "/a"}, 54 | func(ar *appRouter) bool { 55 | return ar.out["/"].Path == "/a" && 56 | !ar.out["/"].Exact && 57 | ar.out["/a"].Path == "/a" && 58 | ar.out["/a"].Exact 59 | }, 60 | }, 61 | 62 | { 63 | "/a/v1", 64 | "", 65 | []string{"/", "/a", "/a/:id"}, 66 | func(ar *appRouter) bool { 67 | // log.Printf("len(ar.out): %#v", len(ar.out)) 68 | // log.Printf("ar.out: %#v", ar.out) 69 | return ar.out["/a"].Path == "/a/v1" && 70 | !ar.out["/a"].Exact && 71 | ar.out["/a/:id"].Exact && 72 | ar.out["/a/:id"].Path == "/a/v1" 73 | }, 74 | }, 75 | 76 | { 77 | "/", 78 | "p=1", 79 | []string{"/"}, 80 | func(ar *appRouter) bool { 81 | return ar.out["/"].Path == "/" && 82 | ar.out["/"].Exact && 83 | ar.out["/"].Params.Get("p") == "1" 84 | }, 85 | }, 86 | 87 | { 88 | "/", 89 | "p=1&q=2", 90 | []string{"/"}, 91 | func(ar *appRouter) bool { 92 | return ar.out["/"].Path == "/" && 93 | ar.out["/"].Exact && 94 | ar.out["/"].Params.Get("p") == "1" && 95 | ar.out["/"].Params.Get("q") == "2" 96 | }, 97 | }, 98 | 99 | { 100 | "/a", 101 | "p=1", 102 | []string{"/", "/a"}, 103 | func(ar *appRouter) bool { 104 | return ar.out["/"].Path == "/a" && 105 | !ar.out["/"].Exact && 106 | ar.out["/a"].Params.Get("p") == "1" && 107 | ar.out["/a"].Path == "/a" && 108 | ar.out["/a"].Exact && 109 | ar.out["/a"].Params.Get("p") == "1" 110 | }, 111 | }, 112 | 113 | { 114 | "/a/v1", 115 | "p=1", 116 | []string{"/", "/a", "/a/:id"}, 117 | func(ar *appRouter) bool { 118 | return ar.out["/"].Path == "/a/v1" && 119 | !ar.out["/"].Exact && 120 | ar.out["/a"].Params.Get("p") == "1" && 121 | ar.out["/a"].Path == "/a/v1" && 122 | !ar.out["/a"].Exact && 123 | ar.out["/a"].Params.Get("p") == "1" && 124 | ar.out["/a/:id"].Path == "/a/v1" && 125 | ar.out["/a/:id"].Exact && 126 | ar.out["/a/:id"].Params.Get("p") == "1" && 127 | ar.out["/a/:id"].Params.Get("id") == "v1" 128 | }, 129 | }, 130 | } 131 | 132 | for i, tc := range tclist { 133 | t.Run(fmt.Sprint(i), func(t *testing.T) { 134 | 135 | ar := appRouter{Router: New(nil), out: make(map[string]RouteMatch)} 136 | for _, p := range tc.rp { 137 | p := p 138 | // log.Printf("adding route for %q", p) 139 | ar.MustAddRoute(p, RouteHandlerFunc(func(rm *RouteMatch) { 140 | // log.Printf("got route handle for %q", p) 141 | ar.out[p] = *rm 142 | })) 143 | } 144 | ar.SetNotFound(RouteHandlerFunc(func(rm *RouteMatch) { 145 | ar.out["_not_found"] = *rm 146 | })) 147 | 148 | params, err := url.ParseQuery(tc.params) 149 | if err != nil { 150 | log.Printf("Error parsing supplied test case parameters - %v\n", err) 151 | t.Fail() 152 | } 153 | 154 | ar.process(tc.path, params) 155 | 156 | if !tc.check(&ar) { 157 | t.Fail() 158 | } 159 | 160 | }) 161 | } 162 | 163 | } 164 | --------------------------------------------------------------------------------