├── pulp_web ├── .gitignore ├── package.json ├── assets.js ├── package-lock.json ├── events.js ├── types.js └── pulp.js ├── .gitignore ├── examples ├── with_npm │ ├── .gitignore │ ├── go.mod │ ├── README.md │ ├── run.sh │ ├── web │ │ ├── index.js │ │ ├── package.json │ │ ├── index.html │ │ ├── package-lock.json │ │ └── bundle.js │ ├── build │ │ └── main.go │ ├── main.go │ └── go.sum └── with_bundle │ ├── web │ ├── index.js │ ├── index.html │ └── pulp.js │ ├── run.sh │ ├── go.mod │ ├── build │ └── main.go │ ├── main.go │ └── go.sum ├── bundle.sh ├── cmd ├── run │ └── main.go └── gen │ ├── main.go │ └── replace.go ├── go.mod ├── LICENSE ├── todo.md ├── socket.go ├── lexer.go ├── gen.go ├── dynamic_test.go ├── parser.go ├── go.sum ├── dynamic.go ├── pulp.go └── README.md /pulp_web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | pulp.bundle.js -------------------------------------------------------------------------------- /examples/with_npm/.gitignore: -------------------------------------------------------------------------------- 1 | web/node_modules/ -------------------------------------------------------------------------------- /bundle.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | browserify pulp_web/pulp.js > pulp.bundle.js -------------------------------------------------------------------------------- /cmd/run/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | // TODO 5 | } 6 | -------------------------------------------------------------------------------- /examples/with_bundle/web/index.js: -------------------------------------------------------------------------------- 1 | const socket = new Pulp.PulpSocket("mount", "/socket", { debug: true }) -------------------------------------------------------------------------------- /examples/with_bundle/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | gen -in main.go -out build/main.go && go run build/main.go 4 | -------------------------------------------------------------------------------- /examples/with_npm/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maltecl/pulp/examples/npm 2 | 3 | go 1.16 4 | 5 | require github.com/maltecl/pulp v0.0.3 6 | -------------------------------------------------------------------------------- /examples/with_bundle/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maltecl/pulp/examples/npm 2 | 3 | go 1.16 4 | 5 | require github.com/maltecl/pulp v0.0.3 6 | -------------------------------------------------------------------------------- /examples/with_npm/README.md: -------------------------------------------------------------------------------- 1 | # With npm 2 | 3 | 4 | Make sure to install the npm dependency before running `run.sh`: 5 | ```sh 6 | cd web && npm i 7 | ``` -------------------------------------------------------------------------------- /examples/with_npm/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/sh 2 | 3 | browserify web/index.js > web/bundle.js && gen -in main.go -out build/main.go && go run build/main.go 4 | -------------------------------------------------------------------------------- /examples/with_npm/web/index.js: -------------------------------------------------------------------------------- 1 | const { PulpSocket, events } = require("pulp_web") 2 | 3 | 4 | const socket = new PulpSocket("mount", "/socket", { debug: false }) -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/maltecl/pulp 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.6 7 | github.com/gorilla/websocket v1.4.2 8 | github.com/kr/pretty v0.2.1 9 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 10 | golang.org/x/tools v0.1.5 11 | ) 12 | -------------------------------------------------------------------------------- /pulp_web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pulp_web", 3 | "version": "0.0.2", 4 | "description": "", 5 | "main": "pulp.js", 6 | "scripts": {}, 7 | "author": "malte.l", 8 | "license": "ISC", 9 | "dependencies": { 10 | "morphdom": "2.6.1" 11 | } 12 | } -------------------------------------------------------------------------------- /examples/with_npm/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with_npm", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "dependencies": { 10 | "pulp_web": "0.0.2" 11 | }, 12 | "author": "malte.l", 13 | "license": "ISC" 14 | } 15 | -------------------------------------------------------------------------------- /pulp_web/assets.js: -------------------------------------------------------------------------------- 1 | class Assets { 2 | 3 | constructor(obj) { 4 | this.cache = obj 5 | } 6 | 7 | 8 | patch(patches) { 9 | let newAssets = {...this.cache } 10 | 11 | for (const key in patches) { 12 | if (patches[key] === null) { // this element should be deleted 13 | delete newAssets[key] 14 | } else { // new or old element. overwrite it 15 | newAssets[key] = patches[key] 16 | } 17 | } 18 | 19 | return new Assets(newAssets) 20 | } 21 | 22 | } 23 | 24 | 25 | module.exports = { Assets } -------------------------------------------------------------------------------- /examples/with_npm/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Document 10 | 11 | 12 | 13 | 14 | 15 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/with_bundle/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | 14 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /pulp_web/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "version": "1.0.0", 9 | "license": "ISC", 10 | "dependencies": { 11 | "morphdom": "^2.6.1" 12 | } 13 | }, 14 | "node_modules/morphdom": { 15 | "version": "2.6.1", 16 | "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz", 17 | "integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==" 18 | } 19 | }, 20 | "dependencies": { 21 | "morphdom": { 22 | "version": "2.6.1", 23 | "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz", 24 | "integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 malte.l 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 | -------------------------------------------------------------------------------- /cmd/gen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | ) 10 | 11 | var ( 12 | inFilename *string = flag.String("in", "", "name of the file you want to run the ast-replacer on") 13 | outFilename *string = flag.String("out", "", "name of the file you want the result to be written to") 14 | verbose *bool = flag.Bool("v", false, "if this value is true, the program is more chatty") 15 | ) 16 | 17 | func init() { 18 | flag.Parse() 19 | 20 | if *inFilename == "" || *outFilename == "" { 21 | flag.Usage() 22 | os.Exit(1) 23 | } 24 | } 25 | 26 | func logic() error { 27 | fileContent, err := os.ReadFile(*inFilename) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | newFileContent, err := replace(*inFilename, string(fileContent)) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if len(newFileContent) == 0 { 38 | return fmt.Errorf("something went wrong. This probably means, that you have an error in %s", *inFilename) 39 | } 40 | 41 | err = ioutil.WriteFile(*outFilename, newFileContent, 0700) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | withImports, err := exec.Command("goimports", *outFilename).Output() // ignore the error, so that when goimports is not installed it's okay 47 | if err == nil { 48 | newFileContent = withImports 49 | } 50 | 51 | return ioutil.WriteFile(*outFilename, newFileContent, 0700) // TODO: should not need to write the file two times 52 | } 53 | 54 | func main() { 55 | if err := logic(); err != nil { 56 | fmt.Fprint(os.Stderr, err) 57 | os.Exit(1) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /examples/with_bundle/build/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/maltecl/pulp" 9 | ) 10 | 11 | type index struct { 12 | msg string 13 | seconds int 14 | counter int 15 | } 16 | 17 | func (c *index) Mount(socket pulp.Socket) { 18 | c.counter = 10 19 | 20 | go func() { 21 | ticker := time.NewTicker(time.Second) 22 | defer ticker.Stop() 23 | for { 24 | select { 25 | case <-ticker.C: 26 | c.seconds++ 27 | socket.Update() 28 | case <-socket.Done(): 29 | return 30 | } 31 | } 32 | }() 33 | } 34 | 35 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 36 | 37 | if _, ok := event.(pulp.RouteChangedEvent); ok { 38 | return 39 | } 40 | 41 | e := event.(pulp.UserEvent) 42 | 43 | switch e.Name { 44 | case "increment": 45 | c.counter++ 46 | socket.Update() 47 | } 48 | 49 | } 50 | 51 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 52 | return func() pulp.StaticDynamic { 53 | x1 := pulp.StaticDynamic{ 54 | Static: []string{"

", "

", " seconds passed
you have pressed the button ", " times "}, 55 | Dynamic: pulp.Dynamics{c.msg, c.seconds, c.counter}, 56 | } 57 | 58 | return x1 59 | }(), nil 60 | } 61 | 62 | func (c index) Unmount() { 63 | log.Println("heading out.") 64 | } 65 | 66 | func main() { 67 | 68 | http.Handle("/", http.FileServer(http.Dir("./web"))) 69 | 70 | http.HandleFunc("/socket", pulp.LiveSocket(func() pulp.LiveComponent { 71 | return &index{msg: "hello world"} 72 | })) 73 | http.ListenAndServe(":4000", nil) 74 | } 75 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | ## templating language 2 | - make it so, that dynamic values can reference other dynamic values 3 | - example ```{ 4 | "0": "hello world", 5 | "1": "0"}``` 6 | - this way sending the same value multiple times within one patch will be prevented 7 | - this dependency should only be sent **once**! with the big payload upon mount. In patches value `"1"` can be derived from `"0"` 8 | 9 | 10 | 11 | ### Json Path 12 | - use json path for slimmer patches 13 | 14 | 15 | ### run go import on the output file 16 | 17 | ### make it so, that multiple payloads can be sent, for the same event 18 | ```handlebars 19 | 20 | ``` 21 | 22 | 23 | ### last token after goSource seems to be missing 24 | ```handlebars 25 | 30 | ``` 31 | ## If 32 | - make it so that when the condition is `True` the dynamic values for the `False` `StaticDynamic` are not sent. 33 | - When the condition later flips, those dynamic values are sent 34 | - assignments in if's are not working 35 | ```handlebars 36 | {{ if page := ; page == "chat" }} 37 | {{ page}} 38 | {{ chatPage }} 39 | {{ end }} 40 | ``` 41 | 42 | 43 | ## SD flattening 44 | perform some sort of flattening on nested static dynamics, such that 45 | ```handlebars 46 | {{ for username := range c.users :key username }} 47 | {{ c.userTile(username) }} 48 | {{ end }} 49 | ``` 50 | will perform just as well as 51 | 52 | ```handlebars 53 | {{ for username := range c.users :key username }} 54 |
  • 55 | {{ username }} 56 |
  • 57 | {{ end }} 58 | ``` 59 | 60 | -------------------------------------------------------------------------------- /examples/with_npm/web/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with_npm", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "with_npm", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "pulp_web": "0.0.2" 13 | } 14 | }, 15 | "node_modules/morphdom": { 16 | "version": "2.6.1", 17 | "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz", 18 | "integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==" 19 | }, 20 | "node_modules/pulp_web": { 21 | "version": "0.0.2", 22 | "resolved": "https://registry.npmjs.org/pulp_web/-/pulp_web-0.0.2.tgz", 23 | "integrity": "sha512-DaDcWciGuBw/2DDn98vycpePO5HOO1NL3JHrOu2/nAWx8O/zIJQNX2P31Oqn4jiIPD9bJACkgoUCWuUrRvDtMQ==", 24 | "dependencies": { 25 | "morphdom": "2.6.1" 26 | } 27 | } 28 | }, 29 | "dependencies": { 30 | "morphdom": { 31 | "version": "2.6.1", 32 | "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz", 33 | "integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==" 34 | }, 35 | "pulp_web": { 36 | "version": "0.0.2", 37 | "resolved": "https://registry.npmjs.org/pulp_web/-/pulp_web-0.0.2.tgz", 38 | "integrity": "sha512-DaDcWciGuBw/2DDn98vycpePO5HOO1NL3JHrOu2/nAWx8O/zIJQNX2P31Oqn4jiIPD9bJACkgoUCWuUrRvDtMQ==", 39 | "requires": { 40 | "morphdom": "2.6.1" 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/with_bundle/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/maltecl/pulp" 10 | ) 11 | 12 | type index struct { 13 | msg string 14 | seconds int 15 | counter int 16 | } 17 | 18 | func (c *index) Mount(socket pulp.Socket) { 19 | c.counter = 10 20 | 21 | // If you keep reference to the socket in another go-routine, make sure to shutdown that go-routine once the socket is done. 22 | // Otherwise this will leak the socket and end up crashing your app 23 | go func() { 24 | ticker := time.NewTicker(time.Second) 25 | defer ticker.Stop() 26 | for { 27 | select { 28 | case <-ticker.C: 29 | c.seconds++ 30 | socket.Update() 31 | case <-socket.Done(): 32 | return 33 | } 34 | } 35 | }() 36 | } 37 | 38 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 39 | 40 | if _, ok := event.(pulp.RouteChangedEvent); ok { 41 | return 42 | } 43 | 44 | e := event.(pulp.UserEvent) 45 | 46 | switch e.Name { 47 | case "increment": 48 | c.counter++ 49 | socket.Update() 50 | } 51 | 52 | } 53 | 54 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 55 | return pulp.L(` 56 |

    {{ c.msg }}

    57 | {{ c.seconds }} seconds passed
    58 | you have pressed the button {{ c.counter }} times 59 | `), nil 60 | } 61 | 62 | func (c index) Unmount() { 63 | log.Println("heading out.") 64 | } 65 | 66 | func main() { 67 | // serve your html however you like 68 | http.Handle("/", http.FileServer(http.Dir("./web"))) 69 | 70 | http.HandleFunc("/socket", pulp.LiveSocket(func() pulp.LiveComponent { 71 | return &index{msg: "hello world"} 72 | })) 73 | fmt.Println("listening on localhost:4000") 74 | http.ListenAndServe(":4001", nil) 75 | } 76 | -------------------------------------------------------------------------------- /pulp_web/events.js: -------------------------------------------------------------------------------- 1 | // pulp events are the events pulp will pick up on and send via the wire 2 | // the field "description" is not actually used yet 3 | 4 | 5 | 6 | const inputEvent = { 7 | description: "in a text-input, whenever _any_ text is entered, fire off an event, including the standard HTML-value attributes value", 8 | applyWhen(node) { 9 | return ["HTMLInputElement", "HTMLTextAreaElement"].includes(node.constructor.name) 10 | }, 11 | on: "input", 12 | event: "input", 13 | handler(e, name) { 14 | return { name, value: e.target.value } 15 | }, 16 | } 17 | 18 | 19 | const clickEvent = { 20 | description: "on a button or anchor tag, when clicked, fire off an event", 21 | applyWhen(node) { 22 | return ["HTMLButtonElement"].includes(node.constructor.name) 23 | }, 24 | on: "click", 25 | event: "click", 26 | handler(e, name) { 27 | return { name } 28 | }, 29 | } 30 | 31 | const keySubmitEvent = { 32 | description: "in a text-input, when enter is entered, fire off an event", 33 | applyWhen(node) { 34 | return ["HTMLInputElement", "HTMLTextAreaElement"].includes(node.constructor.name) 35 | }, 36 | on: "keydown", // uses the "keydonw" HTML Event 37 | event: "key-submit", // is tagged with "key-submit". in the source code it looks like this: ":key-submit=" 38 | handler(e, name) { 39 | console.log("MARKER2") 40 | const enterKeyCode = 13 41 | if (e.keyCode !== enterKeyCode) { 42 | return null // reject the event. Payload is not sent 43 | } 44 | e.preventDefault() 45 | return { name } 46 | }, 47 | } 48 | 49 | module.exports = { 50 | defaultEvents: { 51 | inputTag: inputEvent, 52 | clickTag: clickEvent, 53 | }, 54 | keySubmitTag: keySubmitEvent, 55 | } -------------------------------------------------------------------------------- /examples/with_npm/build/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/maltecl/pulp" 9 | ) 10 | 11 | type index struct { 12 | msg string 13 | seconds int 14 | counter int 15 | } 16 | 17 | func (c *index) Mount(socket pulp.Socket) { 18 | c.counter = 10 19 | 20 | go func() { 21 | ticker := time.NewTicker(time.Second) 22 | defer ticker.Stop() 23 | for { 24 | select { 25 | case <-ticker.C: 26 | c.seconds++ 27 | socket.Update() 28 | case <-socket.Done(): 29 | return 30 | } 31 | } 32 | }() 33 | } 34 | 35 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 36 | 37 | if _, ok := event.(pulp.RouteChangedEvent); ok { 38 | return 39 | } 40 | 41 | e := event.(pulp.UserEvent) 42 | 43 | switch e.Name { 44 | case "increment": 45 | c.counter++ 46 | socket.Update() 47 | } 48 | 49 | } 50 | 51 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 52 | return func() pulp.StaticDynamic { 53 | x1 := pulp.StaticDynamic{ 54 | Static: []string{"

    ", "

    ", " seconds passed
    you have pressed the button ", " times "}, 55 | Dynamic: pulp.Dynamics{c.msg, c.seconds, c.counter}, 56 | } 57 | 58 | return x1 59 | }(), nil 60 | } 61 | 62 | func (c index) Unmount() { 63 | log.Println("heading out.") 64 | } 65 | 66 | func main() { 67 | 68 | http.HandleFunc("/socket", pulp.LiveSocket(func() pulp.LiveComponent { 69 | return &index{msg: "hello world"} 70 | })) 71 | 72 | http.HandleFunc("/bundle.js", func(rw http.ResponseWriter, r *http.Request) { 73 | http.ServeFile(rw, r, "web/bundle.js") 74 | }) 75 | 76 | http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { 77 | http.ServeFile(rw, r, "web/index.html") 78 | }) 79 | 80 | http.ListenAndServe(":4000", nil) 81 | } 82 | -------------------------------------------------------------------------------- /socket.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Socket struct { 8 | ID uint32 9 | 10 | updates chan socketUpdate 11 | component LiveComponent 12 | Err error 13 | context.Context 14 | events chan<- Event 15 | 16 | Route string 17 | } 18 | 19 | type socketUpdate struct { 20 | err *error 21 | route *string 22 | } 23 | 24 | type Assets map[string]interface{} 25 | 26 | func (a Assets) mergeAndOverwrite(other Assets) Assets { 27 | if a == nil { 28 | return other 29 | } 30 | 31 | for key, val := range other { 32 | a[key] = val 33 | } 34 | return a 35 | } 36 | 37 | // TODO: make sure this works fine 38 | // type M map[string]interface{} 39 | 40 | // func (s *Socket) Dispatch(event string, data M) { 41 | // select { 42 | // case <-s.Done(): 43 | // case s.events <- UserEvent{Name: event, Data: data}: 44 | // } 45 | // } 46 | 47 | // func (s *Socket) Errorf(format string, values ...interface{}) { 48 | // err := fmt.Errorf(format, values...) 49 | // s.sendUpdate(socketUpdate{err: &err}) 50 | // } 51 | 52 | // TODO: add flash messages that will be sent as assets 53 | // func (s *Socket) FlashError(route string) { 54 | // } 55 | 56 | // func (s *Socket) FlashInfo(route string) { 57 | // } 58 | 59 | // func (s *Socket) FlashWarning(route string) { 60 | // } 61 | 62 | func (s Socket) assets() Assets { 63 | return Assets{ 64 | "route": s.Route, 65 | } 66 | } 67 | 68 | func (s *Socket) sendUpdate(update socketUpdate) { 69 | select { 70 | case <-s.Done(): 71 | case s.updates <- update: 72 | } 73 | } 74 | 75 | func (s *Socket) Update() { 76 | s.sendUpdate(socketUpdate{}) 77 | } 78 | 79 | func (s *Socket) Redirect(route string) { 80 | s.sendUpdate(socketUpdate{route: &route}) 81 | } 82 | 83 | func (u socketUpdate) apply(socket *Socket) { 84 | if u.route != nil { 85 | socket.Route = *u.route 86 | } 87 | 88 | if u.err != nil { 89 | socket.Err = *u.err 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /examples/with_npm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/maltecl/pulp" 10 | ) 11 | 12 | type index struct { 13 | msg string 14 | seconds int 15 | counter int 16 | } 17 | 18 | func (c *index) Mount(socket pulp.Socket) { 19 | c.counter = 10 20 | 21 | // If you keep reference to the socket in another go-routine, make sure to shutdown that go-routine once the socket is done. 22 | // Otherwise this will leak the socket and end up crashing your app 23 | go func() { 24 | ticker := time.NewTicker(time.Second) 25 | defer ticker.Stop() 26 | for { 27 | select { 28 | case <-ticker.C: 29 | c.seconds++ 30 | socket.Update() 31 | case <-socket.Done(): 32 | return 33 | } 34 | } 35 | }() 36 | } 37 | 38 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 39 | 40 | if _, ok := event.(pulp.RouteChangedEvent); ok { 41 | return 42 | } 43 | 44 | e := event.(pulp.UserEvent) 45 | 46 | switch e.Name { 47 | case "increment": 48 | c.counter++ 49 | socket.Update() 50 | } 51 | 52 | } 53 | 54 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 55 | return pulp.L(` 56 |

    {{ c.msg }}

    57 | {{ c.seconds }} seconds passed
    58 | you have pressed the button {{ c.counter }} times 59 | `), nil 60 | } 61 | 62 | func (c index) Unmount() { 63 | log.Println("heading out.") 64 | } 65 | 66 | func main() { 67 | 68 | http.HandleFunc("/socket", pulp.LiveSocket(func() pulp.LiveComponent { 69 | return &index{msg: "hello world"} 70 | })) 71 | // serve your html however you like 72 | http.HandleFunc("/bundle.js", func(rw http.ResponseWriter, r *http.Request) { 73 | http.ServeFile(rw, r, "web/bundle.js") 74 | }) 75 | 76 | http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { 77 | http.ServeFile(rw, r, "web/index.html") 78 | }) 79 | 80 | fmt.Println("listening on localhost:4000") 81 | http.ListenAndServe(":4000", nil) 82 | } 83 | -------------------------------------------------------------------------------- /cmd/gen/replace.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "go/ast" 7 | "go/format" 8 | "go/parser" 9 | "go/token" 10 | "os" 11 | 12 | "github.com/kr/pretty" 13 | "github.com/maltecl/pulp" 14 | "golang.org/x/tools/go/ast/astutil" 15 | ) 16 | 17 | func vPrintf(format string, args ...interface{}) { 18 | if !*verbose { 19 | return 20 | } 21 | fmt.Printf(format, args...) 22 | } 23 | 24 | func replace(sourceName, source string) ([]byte, error) { 25 | fset := token.NewFileSet() 26 | expr, err := parser.ParseFile(fset, sourceName, source, parser.AllErrors) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | shouldReturnOuter := false 32 | var outerErr error 33 | 34 | result := astutil.Apply(expr, func(cr *astutil.Cursor) bool { 35 | if source := detect(cr.Node()); source != nil { 36 | *source = (*source)[1 : len(*source)-1] // removes the backticks or the " from the string literal 37 | 38 | g := pulp.NewGenerator() 39 | parser := pulp.NewParser(*source) 40 | tree, err := parser.Parse() 41 | if err != nil { 42 | shouldReturnOuter = true 43 | outerErr = err 44 | return false 45 | } 46 | vPrintf("ast: %v\n", pretty.Sprint(tree)) 47 | if parser.Error != nil { 48 | fmt.Fprint(os.Stderr, parser.Error) 49 | os.Exit(-1) 50 | } 51 | tree.Gen(g) 52 | vPrintf("gen: %v\n", g.Out()) 53 | cr.Replace(&ast.BasicLit{Value: g.Out()}) 54 | return false 55 | } 56 | return true 57 | }, nil) 58 | 59 | if shouldReturnOuter { 60 | return nil, fmt.Errorf("parser error: %v", outerErr) 61 | } 62 | 63 | retBuf := &bytes.Buffer{} 64 | if err := format.Node(retBuf, fset, result); err != nil { 65 | return nil, err 66 | } 67 | 68 | return retBuf.Bytes(), nil 69 | } 70 | 71 | func detect(node ast.Node) *string { 72 | if r, ok := node.(*ast.CallExpr); ok { 73 | if t, ok1 := r.Fun.(*ast.SelectorExpr); ok1 { 74 | if t.Sel.Name == "L" { 75 | if sourceLit, ok2 := r.Args[0].(*ast.BasicLit); ok2 { 76 | return &sourceLit.Value 77 | } 78 | } 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /lexer.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "strings" 5 | "unicode/utf8" 6 | ) 7 | 8 | type tokenTyp uint8 9 | 10 | const ( 11 | tokEof tokenTyp = iota 12 | tokGoSource 13 | tokOtherSource 14 | ) 15 | 16 | type token struct { 17 | typ tokenTyp 18 | value string 19 | } 20 | 21 | type lexer struct { 22 | input string 23 | pos, start int 24 | width int 25 | tokens chan *token 26 | state lexerFunc 27 | } 28 | 29 | const ( 30 | eof = rune(-1) 31 | ) 32 | 33 | type lexerFunc func(*lexer) lexerFunc 34 | 35 | func (l *lexer) next() (r rune) { 36 | if l.pos >= len(l.input) { 37 | // l.width = 0 38 | l.emit(tokEof) 39 | return eof 40 | } 41 | 42 | r, l.width = utf8.DecodeRuneInString(l.input[l.pos:]) 43 | l.pos += l.width 44 | return r 45 | } 46 | 47 | func lexUntilLBrace(l *lexer) lexerFunc { 48 | return lexUntil([2]rune{'{', '{'}, tokOtherSource, lexUntilRBrace) 49 | } 50 | 51 | func lexUntilRBrace(l *lexer) lexerFunc { 52 | return lexUntil([2]rune{'}', '}'}, tokGoSource, lexUntilLBrace) 53 | } 54 | 55 | func lexUntil(pattern [2]rune, tokenTyp tokenTyp, continueWith lexerFunc) lexerFunc { 56 | return func(l *lexer) lexerFunc { 57 | for { 58 | next := l.next() 59 | if next == eof { 60 | return nil 61 | } 62 | 63 | if next == pattern[0] && l.next() == pattern[1] { 64 | break 65 | } 66 | } 67 | 68 | // backup the two runes of the pattern 69 | l.backup() 70 | l.backup() 71 | 72 | l.emit(tokenTyp) 73 | 74 | // skip the two runes of the patten 75 | l.next() 76 | l.next() 77 | l.ignore() 78 | 79 | return continueWith 80 | } 81 | } 82 | 83 | func (l *lexer) ignore() { 84 | l.start = l.pos 85 | } 86 | 87 | func (l *lexer) backup() { 88 | l.pos -= l.width 89 | } 90 | 91 | func (l *lexer) emit(t tokenTyp) { 92 | val := l.input[l.start:l.pos] 93 | tok := &token{t, strings.ReplaceAll(strings.ReplaceAll(val, "\n", ""), "\t", "")} 94 | l.tokens <- tok 95 | l.start = l.pos 96 | } 97 | 98 | func (l *lexer) run() { 99 | for state := l.state; state != nil; { 100 | state = state(l) 101 | } 102 | close(l.tokens) 103 | } 104 | -------------------------------------------------------------------------------- /examples/with_bundle/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 2 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 3 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 4 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 6 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 7 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 8 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 9 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 10 | github.com/maltecl/pulp v0.0.3 h1:X3IrCr9LyW946gMC/s7nBX8e6+LrKQaJ0tT9DlfFOhQ= 11 | github.com/maltecl/pulp v0.0.3/go.mod h1:mGYGk2VxX5cUsIOTLNbEBCL7ClPyrQlPA5cVr74MkAM= 12 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 15 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 16 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 18 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 19 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 21 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 30 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 31 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 32 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 33 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 34 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 35 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 37 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 38 | -------------------------------------------------------------------------------- /examples/with_npm/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 2 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 3 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 4 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 5 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 6 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 7 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 8 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 9 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 10 | github.com/maltecl/pulp v0.0.3 h1:X3IrCr9LyW946gMC/s7nBX8e6+LrKQaJ0tT9DlfFOhQ= 11 | github.com/maltecl/pulp v0.0.3/go.mod h1:mGYGk2VxX5cUsIOTLNbEBCL7ClPyrQlPA5cVr74MkAM= 12 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 15 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 16 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 18 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 19 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 20 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 21 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 30 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 31 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 32 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 33 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 34 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 35 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 37 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 38 | -------------------------------------------------------------------------------- /pulp_web/types.js: -------------------------------------------------------------------------------- 1 | const set = x => x !== undefined 2 | 3 | 4 | function classify(it) { 5 | 6 | 7 | if (SD.detect(it)) { 8 | return new SD(it) 9 | } 10 | 11 | if (IF.detect(it)) { 12 | return new IF(it) 13 | } 14 | 15 | if (FOR.detect(it)) { 16 | return new FOR(it) 17 | } 18 | 19 | 20 | return it 21 | 22 | } 23 | class SD { 24 | static detect = (it) => set(it.s) && set(it.d) 25 | 26 | static render({ s, d }) { 27 | let out = "" 28 | 29 | for (let i = 0; i < s.length; i++) { 30 | out += s[i] 31 | 32 | // if (!d) { 33 | // continue 34 | // } 35 | 36 | if (i < d.length) { 37 | out += set(d[i].render) ? d[i].render() : d[i] 38 | } 39 | } 40 | 41 | return out 42 | } 43 | 44 | static patchListOfDynamics(list, patches) { 45 | let copy = [...list] 46 | 47 | Object.keys(patches).forEach(key => { 48 | if (copy[key] !== null && copy[key] !== undefined) { 49 | if (set(copy[key].patch)) { 50 | copy[key] = copy[key].patch(patches[key]) 51 | return 52 | } 53 | } 54 | 55 | 56 | copy[key] = patches[key] 57 | }) 58 | 59 | return copy 60 | } 61 | 62 | constructor({ s, d }) { 63 | this.s = s 64 | this.d = d.map(classify) 65 | } 66 | 67 | render() { 68 | return SD.render(this) 69 | } 70 | 71 | patch(patches) { 72 | return new SD({ s: this.s, d: SD.patchListOfDynamics(this.d, patches) }) 73 | } 74 | } 75 | 76 | 77 | class IF { 78 | static detect = (it) => set(it.c) || set(it.f) || set(it.t) 79 | 80 | type_ = "IF" 81 | 82 | constructor({ c, t, f }) { 83 | this.c = c 84 | this.t = new SD(t) 85 | this.f = new SD(f) 86 | } 87 | 88 | render() { return SD.render(this.c ? this.t : this.f) } 89 | 90 | patch(patches) { 91 | return new IF({ 92 | c: set(patches.c) ? patches.c : this.c, 93 | t: set(patches.t) ? new SD(this.t).patch(patches.t) : this.t, 94 | f: set(patches.f) ? new SD(this.f).patch(patches.f) : this.f, 95 | }) 96 | } 97 | } 98 | 99 | 100 | class FOR { 101 | // static strategy = { 102 | // append: 0, 103 | // prepend: 1, 104 | // } 105 | 106 | static detect = (it) => set(it.ds) /* holds true for both the initial server push and the patches*/ 107 | 108 | constructor({ /*strategy,*/ ds, s }) { 109 | // this.strategy = strategy 110 | this.s = s 111 | this.ds = Object.keys(ds).reduce((acc, key) => ({...acc, [key]: ds[key].map(classify) }), {}) 112 | } 113 | 114 | render() { 115 | let out = "" 116 | 117 | Object.values(this.ds).forEach(dynamic => { 118 | out += SD.render({ s: this.s, d: dynamic }) 119 | }) 120 | 121 | return out 122 | } 123 | 124 | patch(patches) { 125 | let newDS = {...this.ds } 126 | 127 | for (const key in patches.ds) { 128 | if (patches.ds[key] === null) { // this element should be deleted 129 | delete newDS[key] 130 | } else if (set(this.ds[key])) { // old elemenent. patch it! 131 | newDS[key] = SD.patchListOfDynamics(this.ds[key], patches.ds[key]) 132 | } else { // new element 133 | newDS[key] = patches.ds[key].map(classify) 134 | } 135 | } 136 | 137 | return new FOR({...this, ds: newDS }) 138 | } 139 | 140 | } 141 | 142 | 143 | 144 | 145 | 146 | module.exports = { SD, FOR, IF } -------------------------------------------------------------------------------- /gen.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/kr/pretty" 8 | ) 9 | 10 | type Generator struct { 11 | idCounter int 12 | scopes *scopeStack 13 | } 14 | 15 | func NewGenerator() *Generator { 16 | g := Generator{} 17 | g.pushScope() 18 | return &g 19 | } 20 | 21 | type scopeStack struct { 22 | prev *scopeStack 23 | scope 24 | } 25 | 26 | type scope struct { 27 | strings.Builder 28 | } 29 | 30 | func (g *Generator) pushScope() { 31 | newScopeEntry := scopeStack{prev: g.scopes} 32 | g.scopes = &newScopeEntry 33 | } 34 | 35 | func (g *Generator) popScope() string { 36 | if g.scopes == nil { 37 | return "" 38 | } 39 | ret := g.scopes.String() 40 | g.scopes = g.scopes.prev 41 | return ret 42 | } 43 | 44 | func (g *Generator) WriteNamed(format string, args ...interface{}) id { 45 | ident := g.nextID() 46 | g.scopes.WriteString(string(ident) + " := " + fmt.Sprintf(format, args...)) 47 | return ident 48 | } 49 | 50 | func (g *Generator) WriteNamedWithID(source func(id) string) id { 51 | ident := g.nextID() 52 | g.scopes.WriteString(string(ident) + " := " + source(ident)) 53 | return ident 54 | } 55 | 56 | func (g Generator) Out() string { 57 | return fmt.Sprintf(`func() pulp.StaticDynamic { 58 | %s 59 | return %s 60 | }()`, g.popScope(), string(g.lastID())) 61 | } 62 | 63 | func (g *Generator) nextID() id { 64 | g.idCounter++ 65 | return id("x" + fmt.Sprint(g.idCounter)) 66 | } 67 | 68 | func (g *Generator) lastID() id { 69 | return id("x" + fmt.Sprint(g.idCounter)) 70 | } 71 | 72 | func (r staticDynamicExpr) Gen(g *Generator) id { 73 | return g.WriteNamed(`pulp.StaticDynamic{ 74 | Static: %s, 75 | Dynamic: pulp.Dynamics%s, 76 | } 77 | `, pretty.Sprint(r.static), sprintDynamic(r.dynamic, g)) 78 | } 79 | 80 | func (i *ifExpr) Gen(g *Generator) id { 81 | return g.WriteNamed( 82 | `pulp.If{ 83 | Condition: %s, 84 | True: pulp.StaticDynamic{ 85 | Static: %s, 86 | Dynamic: pulp.Dynamics%s, 87 | }, 88 | False: pulp.StaticDynamic{ 89 | Static: %s, 90 | Dynamic: pulp.Dynamics%s, 91 | }, 92 | } 93 | `, 94 | i.condStr, 95 | pretty.Sprint(i.True.static), 96 | sprintDynamic(i.True.dynamic, g), 97 | pretty.Sprint(i.False.static), 98 | sprintDynamic(i.False.dynamic, g), 99 | ) 100 | } 101 | 102 | func (e rawStringExpr) Gen(g *Generator) id { 103 | return g.WriteNamed(string(e) + "\n") 104 | } 105 | 106 | func (e forExpr) Gen(g *Generator) id { 107 | return g.WriteNamedWithID(func(currentID id) string { 108 | ret := fmt.Sprintf(`pulp.For{ 109 | Statics: %s, 110 | ManyDynamics: make(map[string]pulp.Dynamics), 111 | } 112 | 113 | for %s { 114 | `, pretty.Sprint(e.sd.static), e.rangeStr) 115 | 116 | // this is pretty ugly 117 | 118 | g.pushScope() 119 | idStr := string(currentID) 120 | ids := sprintDynamic(e.sd.dynamic, g) 121 | scopeStr := g.popScope() 122 | 123 | ret += fmt.Sprintf(`%s 124 | %s.ManyDynamics[%s] = pulp.Dynamics%s 125 | } 126 | `, scopeStr, idStr, e.keyStr, ids) 127 | 128 | return ret 129 | }) 130 | } 131 | 132 | type keyedSectionExpr struct { 133 | keyString string 134 | sd staticDynamicExpr 135 | } 136 | 137 | func (e keyedSectionExpr) Gen(g *Generator) id { 138 | return g.WriteNamed(`pulp.KeyedSection{ 139 | Key: %s, 140 | StaticDynamic: %s, 141 | } 142 | `, e.keyString, e.sd.Gen(g)) 143 | } 144 | 145 | func sprintDynamic(dynamics []expr, g *Generator) string { 146 | ret := &strings.Builder{} 147 | 148 | for _, e := range dynamics { 149 | if ee, ok := e.(rawStringExpr); ok { 150 | ret.WriteString(string(ee)) 151 | } else { 152 | ret.WriteString(string(e.Gen(g))) 153 | } 154 | ret.WriteString(", ") 155 | } 156 | 157 | retStr := ret.String() 158 | 159 | if len(dynamics) > 1 { 160 | retStr = retStr[:len(retStr)-1] 161 | } 162 | return "{" + retStr + "}" 163 | } 164 | -------------------------------------------------------------------------------- /dynamic_test.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | // func equal(a, b []int) bool { 4 | // if len(a) != len(b) { 5 | // return false 6 | // } 7 | // for i, v := range a { 8 | // if v != b[i] { 9 | // return false 10 | // } 11 | // } 12 | // return true 13 | // } 14 | 15 | // func TestDiff(t *testing.T) { 16 | 17 | // cases := []struct { 18 | // from, to Diffable 19 | // patches *Patches 20 | // exptectErr bool 21 | // }{ 22 | // { 23 | // from: Dynamics{"hello"}, 24 | // to: Dynamics{"hello"}, 25 | // }, 26 | // { 27 | // from: Dynamics{""}, 28 | // to: Dynamics{"hello"}, 29 | // patches: &Patches{"0": "hello"}, 30 | // }, 31 | // { 32 | // from: Dynamics{""}, 33 | // to: Dynamics{"hello", ""}, 34 | // exptectErr: true, 35 | // }, 36 | // // if 37 | // { 38 | // from: If{True: StaticDynamic{Dynamic: Dynamics{"hello"}}}, 39 | // to: If{True: StaticDynamic{Dynamic: Dynamics{"hello"}}}, 40 | // }, 41 | // { 42 | // from: If{True: StaticDynamic{Dynamic: Dynamics{"hello"}}}, 43 | // to: If{Condition: true, True: StaticDynamic{Dynamic: Dynamics{"hello"}}}, 44 | // patches: &Patches{"c": true}, 45 | // }, 46 | // { 47 | // from: If{True: StaticDynamic{Dynamic: Dynamics{"hello"}}}, 48 | // to: If{Condition: true, True: StaticDynamic{Dynamic: Dynamics{""}}}, 49 | // patches: &Patches{"c": true, "t": &Patches{"0": ""}}, 50 | // }, 51 | // } 52 | 53 | // for i, tc := range cases { 54 | 55 | // var ( 56 | // want = tc.patches 57 | // got *Patches 58 | // err error 59 | // ) 60 | 61 | // errCatcher := func() { 62 | // if mErr, isErr := recover().(error); isErr && mErr != nil { 63 | // err = mErr 64 | // } 65 | // } 66 | 67 | // func() { 68 | // defer errCatcher() 69 | // got = tc.from.Diff(tc.to) 70 | // }() 71 | 72 | // errMatches := tc.exptectErr == (err != nil) 73 | // eq := cmp.Equal(got, want) 74 | 75 | // if eq && errMatches { 76 | // continue 77 | // } 78 | 79 | // errStr := &strings.Builder{} 80 | // fmt.Fprintf(errStr, "test %v: ", i) 81 | 82 | // if !eq { 83 | // fmt.Fprintf(errStr, "\n%v (expected) != %v,\ndiff: %s", want, got, cmp.Diff(got, want)) 84 | // } 85 | 86 | // if err != nil { 87 | // fmt.Fprintf(errStr, "\ngot unexpected error: %v", err) 88 | // } 89 | 90 | // t.Error(errStr.String()) 91 | // } 92 | 93 | // } 94 | 95 | // func TestNewStaticDynamic(t *testing.T) { 96 | 97 | // cases := []struct { 98 | // static string 99 | // dynamic []interface{} 100 | 101 | // expectedSD StaticDynamic 102 | // expectedString string 103 | // }{ 104 | // { 105 | // static: "hello {}", 106 | // dynamic: []interface{}{0}, 107 | // expectedSD: StaticDynamic{Static: []string{"hello ", ""}, Dynamic: []interface{}{0}}, 108 | // expectedString: "hello 0", 109 | // }, 110 | // } 111 | 112 | // for i, tc := range cases { 113 | // got := NewStaticDynamic(tc.static, tc.dynamic...) 114 | // eq := Comparable(got, tc.expectedSD) && cmp.Equal(got, tc.expectedSD) 115 | // eqString := cmp.Equal(tc.expectedString, got.Render()) 116 | 117 | // if eq && eqString { 118 | // continue 119 | // } 120 | 121 | // errStr := &strings.Builder{} 122 | 123 | // fmt.Fprintf(errStr, "test %v: ", i) 124 | 125 | // if !eq { 126 | // fmt.Fprintf(errStr, "!eq: ") 127 | // fmt.Fprintf(errStr, "%v (expected) != %v", pretty.Sprint(tc.expectedSD), pretty.Sprint(got)) 128 | // fmt.Fprintf(errStr, "\ndiff:%v", cmp.Diff(tc.expectedSD, got)) 129 | // } 130 | 131 | // if !eqString { 132 | // fmt.Fprintf(errStr, "!eqString: ") 133 | // fmt.Fprintf(errStr, "%q (expected) != %q", tc.expectedString, got.Render()) 134 | // fmt.Fprintf(errStr, "\ndiff:%v", cmp.Diff(tc.expectedString, got.Render())) 135 | // } 136 | 137 | // t.Error(errStr.String()) 138 | 139 | // } 140 | 141 | // } 142 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type parser struct { 9 | tokens <-chan *token 10 | runLexer func() 11 | done <-chan struct{} 12 | last *token 13 | lastTrimmed *token 14 | 15 | Error error 16 | } 17 | 18 | func (p *parser) assertf(cond bool, format string, args ...interface{}) { 19 | if p.Error != nil || cond { 20 | return 21 | } 22 | panic(fmt.Errorf(format, args...)) 23 | } 24 | 25 | func NewParser(input string) *parser { 26 | tokens := make(chan *token) 27 | 28 | l := &lexer{tokens: tokens, input: input, state: lexUntilLBrace} 29 | 30 | return &parser{ 31 | tokens: tokens, 32 | runLexer: l.run, 33 | } 34 | } 35 | 36 | func (p *parser) next() *token { 37 | select { 38 | case <-p.done: 39 | return nil 40 | case p.last = <-p.tokens: 41 | // if p.last.typ == tokEof { 42 | // return nil 43 | // } 44 | 45 | p.lastTrimmed = &token{typ: p.last.typ, value: strings.TrimSpace(p.last.value)} 46 | return p.last 47 | } 48 | } 49 | 50 | func (p *parser) Parse() (result *staticDynamicExpr, err error) { 51 | sd := staticDynamicExpr{} 52 | 53 | go p.runLexer() 54 | 55 | defer func() { 56 | if rec, ok := recover().(error); ok { 57 | err = rec 58 | } 59 | }() 60 | 61 | ret, _ := parseAllUntil(p, []string{}) 62 | sd.dynamic = ret.dynamic 63 | sd.static = ret.static 64 | 65 | result = &sd 66 | return 67 | } 68 | 69 | func parseAllUntil(p *parser, delimiters []string) (ret staticDynamicExpr, endedWith string) { 70 | shouldBreak := false 71 | for !shouldBreak { 72 | next := p.next() 73 | 74 | shouldBreak = next.typ == tokEof // the tokEof is not empty, so don't break here 75 | 76 | for _, delimiter := range delimiters { 77 | if p.lastTrimmed.value == delimiter { 78 | endedWith = delimiter 79 | return 80 | } 81 | } 82 | 83 | if next.typ == tokGoSource { 84 | keyWord := strings.Split(p.lastTrimmed.value, " ")[0] 85 | parser, foundMatchingParser := parserMap[keyWord] 86 | 87 | if !foundMatchingParser { 88 | parser = parseRawString 89 | } 90 | 91 | ret.dynamic = append(ret.dynamic, parser(p)) 92 | } else if next.typ == tokOtherSource || next.typ == tokEof { 93 | ret.static = append(ret.static, next.value) 94 | } else { 95 | notreached() 96 | } 97 | } 98 | 99 | return 100 | } 101 | 102 | type id string 103 | 104 | type expr interface { 105 | Gen(*Generator) id 106 | } 107 | 108 | type parserFunc func(p *parser) expr 109 | 110 | var parserMap map[string]parserFunc 111 | 112 | func init() { 113 | parserMap = map[string]parserFunc{ 114 | "for": parseFor, 115 | "if": parseIf, 116 | // "key": parseKeyedSection, 117 | } 118 | } 119 | 120 | type rawStringExpr string 121 | 122 | func parseRawString(p *parser) expr { 123 | return rawStringExpr(p.lastTrimmed.value) 124 | } 125 | 126 | type staticDynamicExpr struct { 127 | static []string 128 | dynamic []expr 129 | } 130 | 131 | type ifExpr struct { 132 | condStr string 133 | True staticDynamicExpr 134 | False staticDynamicExpr 135 | } 136 | 137 | func parseIf(p *parser) expr { 138 | ret := ifExpr{} 139 | ret.condStr = p.last.value[len("if "):] 140 | 141 | var endedWith string 142 | ret.True, endedWith = parseAllUntil(p, []string{"else", "end"}) 143 | 144 | gotElseBranch := endedWith == "else" 145 | 146 | if gotElseBranch { 147 | ret.False, endedWith = parseAllUntil(p, []string{"end"}) 148 | } else { 149 | ret.False = staticDynamicExpr{static: []string{}, dynamic: []expr{}} 150 | } 151 | p.assertf(endedWith == "end", "expected \"end\", got: %q", endedWith) 152 | 153 | return &ret 154 | } 155 | 156 | type forExpr struct { 157 | rangeStr string 158 | keyStr string 159 | sd staticDynamicExpr 160 | } 161 | 162 | func parseFor(p *parser) expr { 163 | ret := forExpr{} 164 | 165 | headerParts := strings.Split(p.last.value[len("for "):], ":key") 166 | ret.rangeStr = headerParts[0] 167 | if hasExplicitKey := len(headerParts) > 1; hasExplicitKey { 168 | ret.keyStr = headerParts[1] 169 | } 170 | 171 | var endedWith string 172 | ret.sd, endedWith = parseAllUntil(p, []string{"end"}) 173 | p.assertf(endedWith == "end", `expected "end", got: %q`, endedWith) 174 | 175 | return ret 176 | } 177 | 178 | // func parseKeyedSection(p *parser) expr { 179 | // ret := keyedSectionExpr{keyString: p.last.value[len("key "):]} 180 | 181 | // var endedWith string 182 | // ret.sd, endedWith = parseAllUntil(p, []string{"end"}) 183 | // p.assertf(endedWith == "end", `expected "end", got: %q`, endedWith) 184 | 185 | // return ret 186 | // } 187 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 2 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 6 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 8 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 9 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 10 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 11 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 12 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 13 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 14 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 15 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= 18 | github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 21 | github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w= 22 | github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 23 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 25 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 26 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 27 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 28 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 29 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 30 | golang.org/x/net v0.0.0-20210716203947-853a461950ff h1:j2EK/QoxYNBsXI4R7fQkkRUk8y6wnOBI+6hgPdP/6Ds= 31 | golang.org/x/net v0.0.0-20210716203947-853a461950ff/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 32 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 33 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 34 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 35 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 36 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 40 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 42 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 43 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 44 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 45 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 46 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 47 | golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA= 48 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 49 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 50 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 52 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 56 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | -------------------------------------------------------------------------------- /pulp_web/pulp.js: -------------------------------------------------------------------------------- 1 | const morphdom = require("morphdom") 2 | const { defaultEvents, ...otherEvents } = require("./events") 3 | const { SD, FOR } = require("./types") 4 | 5 | const { Assets } = require("./assets") 6 | 7 | 8 | 9 | const morphdomHooks = (socket, handlers, userHooks) => ({ 10 | getNodeKey: function (node) { 11 | return node.id; 12 | }, 13 | onBeforeNodeAdded: function (node) { 14 | return node; 15 | }, 16 | onNodeAdded: function (node) { 17 | 18 | userHooks.onNodeAdded && userHooks.onNodeAdded(node) 19 | 20 | for (const { applyWhen, on, event, handler } 21 | of handlers) { 22 | 23 | if (!applyWhen(node)) { 24 | continue 25 | } 26 | 27 | if (!node.hasAttribute(event) && !node.hasAttribute(":" + event)) { 28 | continue 29 | } 30 | 31 | 32 | let eventName = node.getAttribute(event) 33 | if (eventName === null) { 34 | eventName = node.getAttribute(":" + event) 35 | } 36 | 37 | node.addEventListener(on, (event) => { 38 | let payload = handler(event, eventName) 39 | if (payload === null) { 40 | return 41 | } 42 | 43 | 44 | for (const attribute of node.attributes) { 45 | if (attribute.name.startsWith(":value-")) { 46 | const key = attribute.name.slice(":value-".length) 47 | payload = { ...payload, [key]: attribute.value.trim() } 48 | } 49 | } 50 | 51 | socket.ws.send(JSON.stringify(payload, null, 0)) 52 | }) 53 | } 54 | 55 | }, 56 | onBeforeElUpdated: function (fromEl, toEl) { 57 | return true; 58 | }, 59 | onElUpdated: function (el) { 60 | 61 | }, 62 | onBeforeNodeDiscarded: function (node) { 63 | return true; 64 | }, 65 | onNodeDiscarded: function (node) { 66 | // note: all event-listeners should be removed automatically, as no one holds reference of the node 67 | // see: https://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory 68 | }, 69 | onBeforeElChildrenUpdated: function (fromEl, toEl) { 70 | return true; 71 | }, 72 | childrenOnly: false 73 | }) 74 | 75 | class PulpSocket { 76 | 77 | constructor(mountID, wsPath, config) { 78 | const events = config.events || [] 79 | const debug = config.debug || false 80 | const hooks = config.hooks || {} 81 | 82 | this.lastRoute = null 83 | let cachedSD = null; 84 | let cachedAssets = null 85 | 86 | const mount = document.getElementById(mountID) 87 | 88 | if (!wsPath.startsWith("/")) { 89 | wsPath = "/" + wsPath 90 | } 91 | 92 | this.ws = new WebSocket(new URL(wsPath, "ws://" + document.location.host).href) 93 | 94 | Object.assign(globalThis, { PulpSocket: this }) 95 | 96 | const mHooks = morphdomHooks({ ws: this.ws }, [...Object.values(defaultEvents), ...events], hooks) 97 | 98 | 99 | this.ws.onopen = (it) => { 100 | debug && console.log(`socket for ${mountID} connected: `, it) 101 | } 102 | 103 | this.ws.onmessage = ({ data }) => { 104 | data.text() 105 | .then(x => [JSON.parse(x), x]) 106 | .then(([messageJSON, raw]) => { 107 | 108 | debug && console.log("got patch: ", raw, messageJSON) 109 | 110 | if (messageJSON.assets !== undefined) { 111 | const { assets } = messageJSON 112 | debug && console.log(assets) 113 | if (cachedAssets == null) { 114 | cachedAssets = new Assets(assets) 115 | } else { 116 | cachedAssets = cachedAssets.patch(assets) 117 | } 118 | 119 | Object.assign(globalThis, { cachedAssets }) 120 | 121 | const { route } = assets 122 | history.pushState({}, null, route) 123 | this.lastRoute = route 124 | 125 | 126 | if (this.onassets !== undefined) { 127 | this.onassets(cachedAssets.cache) 128 | } 129 | } 130 | 131 | 132 | if (messageJSON.html !== undefined) { 133 | if (cachedSD === null) { // has not mounted yet => no patching 134 | cachedSD = new SD(messageJSON.html) 135 | } else { 136 | const patches = messageJSON.html 137 | cachedSD = cachedSD.patch(patches) 138 | } 139 | } 140 | 141 | const temp = document.createElement("div") 142 | temp.id = mountID 143 | temp.innerHTML = cachedSD.render() 144 | morphdom(mount, temp, mHooks) 145 | 146 | }).catch(console.error) 147 | } 148 | 149 | 150 | const self = this 151 | window.addEventListener("popstate", (e) => { 152 | self.ws.send(JSON.stringify({ from: this.lastRoute === null ? "" : this.lastRoute, to: new URL(document.location.href).pathname }, null, 0)) 153 | }) 154 | } 155 | } 156 | 157 | 158 | Object.assign(globalThis, { Pulp: { PulpSocket, events: { ...defaultEvents, ...otherEvents } } }) 159 | 160 | module.exports = { PulpSocket, events: { ...defaultEvents, ...otherEvents } } -------------------------------------------------------------------------------- /dynamic.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | ) 9 | 10 | type rootNode struct { 11 | DynHTML StaticDynamic `json:"html"` 12 | UserAssets Assets `json:"assets"` 13 | } 14 | 15 | func (r rootNode) Diff(new_ interface{}) *Patches { 16 | new := new_.(rootNode) 17 | 18 | assetsPatches := r.UserAssets.Diff(new.UserAssets) 19 | htmlPatches := r.DynHTML.Diff(new.DynHTML) 20 | if assetsPatches == nil && htmlPatches == nil { 21 | return nil 22 | } 23 | 24 | patches := Patches{} 25 | if assetsPatches != nil { 26 | patches["assets"] = assetsPatches 27 | } 28 | 29 | if htmlPatches != nil { 30 | patches["html"] = htmlPatches 31 | } 32 | 33 | return &patches 34 | } 35 | 36 | func (old Assets) Diff(new_ interface{}) *Patches { 37 | new := new_.(Assets) 38 | 39 | patches := Patches{} 40 | 41 | for key, val := range new { 42 | if oldValue, isOld := old[key]; isOld { 43 | if oldValue != val { 44 | patches[key] = val 45 | } 46 | } else { 47 | patches[key] = val // new value, push the whole state 48 | } 49 | } 50 | 51 | for key := range old { 52 | if _, ok := new[key]; !ok { 53 | patches[key] = nil // deleted value, push nil 54 | } 55 | } 56 | 57 | if patches.IsEmpty() { 58 | return nil 59 | } 60 | 61 | return &patches 62 | } 63 | 64 | type StaticDynamic struct { 65 | Static []string `json:"s"` 66 | Dynamic Dynamics `json:"d"` 67 | } 68 | 69 | func NewStaticDynamic(format string, dynamics ...interface{}) StaticDynamic { 70 | static := strings.Split(format, "{}") 71 | 72 | if dynamics == nil { 73 | dynamics = []interface{}{} 74 | } 75 | 76 | return StaticDynamic{static, dynamics} 77 | } 78 | 79 | func Comparable(sd1, sd2 StaticDynamic) bool { 80 | return len(sd1.Dynamic) == len(sd2.Dynamic) && len(sd1.Static) == len(sd2.Static) 81 | } 82 | 83 | func notreached() { 84 | panic("should not be reached") 85 | } 86 | 87 | // Patches can point to actual value itself or another layer of Patches 88 | type Patches map[string]interface{} 89 | 90 | func (p Patches) IsEmpty() bool { 91 | return len(map[string]interface{}(p)) == 0 92 | } 93 | 94 | type Diffable interface { 95 | Diff(new_ interface{}) *Patches 96 | } 97 | 98 | // TODO: not quite working yet 99 | func (sd StaticDynamic) Diff(new_ interface{}) *Patches { 100 | new := new_.(StaticDynamic) 101 | 102 | return sd.Dynamic.Diff(new.Dynamic) 103 | } 104 | 105 | // Dynamics can be filled by actual values or itself by other Diffables 106 | type Dynamics []interface{} 107 | 108 | func (d Dynamics) Diff(new_ interface{}) *Patches { 109 | new := new_.(Dynamics) 110 | 111 | if len(d) != len(new) { 112 | panic(fmt.Errorf("expected equal length in Dynamics")) 113 | } 114 | 115 | ret := Patches{} 116 | 117 | for i := 0; i < len(d); i++ { 118 | 119 | var key string 120 | if keyed, ok := d[i].(KeyedSection); ok { 121 | key = fmt.Sprint(keyed.Key) 122 | } else { 123 | key = fmt.Sprint(i) 124 | } 125 | 126 | if d1Diffable, isDiffable := d[i].(Diffable); isDiffable { 127 | if diff := d1Diffable.Diff(new[i]); diff != nil { 128 | ret[key] = diff 129 | } 130 | } else { 131 | if !cmp.Equal(d[i], new[i]) { 132 | ret[key] = new[i] 133 | } 134 | } 135 | } 136 | 137 | if ret.IsEmpty() { // does this yield the length of keys in the map? 138 | return nil 139 | } 140 | 141 | return &ret 142 | } 143 | 144 | var _ Diffable = If{} 145 | 146 | type If struct { 147 | Condition bool `json:"c"` 148 | True StaticDynamic `json:"t"` 149 | False StaticDynamic `json:"f"` 150 | } 151 | 152 | func (old If) Diff(new_ interface{}) *Patches { 153 | new := new_.(If) 154 | 155 | patches := Patches{} 156 | 157 | if old.Condition != new.Condition { 158 | patches["c"] = new.Condition 159 | } 160 | 161 | // if new.Condition { 162 | if trueDiff := old.True.Dynamic.Diff(new.True.Dynamic); trueDiff != nil { 163 | patches["t"] = trueDiff 164 | } 165 | // } else { 166 | if falseDiff := old.False.Dynamic.Diff(new.False.Dynamic); falseDiff != nil { 167 | patches["f"] = falseDiff 168 | } 169 | // } 170 | 171 | if patches.IsEmpty() { 172 | return nil 173 | } 174 | 175 | return &patches 176 | } 177 | 178 | type For struct { 179 | // keyOrder []string 180 | 181 | Statics []string `json:"s"` 182 | ManyDynamics map[string]Dynamics `json:"ds"` 183 | // DiffStrategy `json:"strategy"` 184 | } 185 | 186 | // for some reason the elements are already rendered in the right order... so this is not needed, it seems 187 | // DiffStrategy is the strategy used for when a new node is pushed (i.e. the key of that node was unknown to the client) 188 | // The problem this (for now) solves is, that when a new node is pushed, the client does not know to which position in the 189 | // array this node belongs. 190 | // DiffStrategy allows for assumptions about that. 191 | // type DiffStrategy uint8 192 | 193 | // // the order here is reflected in types.js 194 | // const ( 195 | // // When a new node is pushed, display it after all other nodes (as the last element) 196 | // Append DiffStrategy = iota 197 | 198 | // // ... display it before all other nodes (as the first element) 199 | // Prepend 200 | 201 | // // ... also diff&patch the keyOrder, making sure everything is displayed in the proper order (as it was pushed into ManyDynamics) 202 | // Intuitiv 203 | // ) 204 | 205 | func (old For) Diff(new_ interface{}) *Patches { 206 | new := new_.(For) 207 | 208 | patches := Patches{} 209 | 210 | for key, val := range new.ManyDynamics { 211 | if oldVal, ok := old.ManyDynamics[key]; ok { 212 | if diff := oldVal.Diff(val); diff != nil { 213 | patches[key] = diff // old value, push the diff 214 | } 215 | } else { 216 | patches[key] = val // new value, push the whole state 217 | } 218 | } 219 | 220 | for key := range old.ManyDynamics { 221 | if _, ok := new.ManyDynamics[key]; !ok { 222 | patches[key] = nil // deleted value, push nil 223 | } 224 | } 225 | 226 | if patches.IsEmpty() { 227 | return nil 228 | } 229 | 230 | return &Patches{ 231 | "ds": patches, 232 | } 233 | } 234 | 235 | type KeyedSection struct { 236 | Key interface{} `json:"key"` 237 | StaticDynamic 238 | } 239 | -------------------------------------------------------------------------------- /pulp.go: -------------------------------------------------------------------------------- 1 | package pulp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "sync/atomic" 11 | 12 | "github.com/gorilla/websocket" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | type LiveComponent interface { 17 | Mount(Socket) 18 | Render(Socket) (HTML, Assets) // HTML guranteed to be StaticDynamic after code generation 19 | HandleEvent(Event, Socket) 20 | } 21 | 22 | type Unmountable interface { 23 | Unmount() 24 | } 25 | 26 | type Event interface { 27 | event() 28 | } 29 | type UserEvent struct { 30 | Name string 31 | Data map[string]interface{} 32 | } 33 | 34 | type RouteChangedEvent struct { 35 | From, To string 36 | } 37 | 38 | func (UserEvent) event() {} 39 | func (RouteChangedEvent) event() {} 40 | 41 | var socketID = uint32(0) 42 | 43 | func newPatchesStream(ctx context.Context, component LiveComponent, events chan Event, route string) (rootNode, <-chan Patches, <-chan error) { 44 | 45 | // TODO: @router get route from initial HTTP request 46 | socket := Socket{ 47 | Context: ctx, 48 | updates: make(chan socketUpdate, 10), 49 | events: events, 50 | ID: socketID, 51 | Route: route, 52 | component: component, 53 | } 54 | 55 | atomic.AddUint32(&socketID, 1) 56 | 57 | errors := make(chan error) 58 | patchesStream := make(chan Patches) 59 | 60 | socket.component.Mount(socket) 61 | 62 | initalTemplate, initialUserAssets := socket.component.Render(socket) 63 | lastTemplate, ok := initalTemplate.(StaticDynamic) 64 | if !ok { 65 | fmt.Println("the first return value of the call to the Render() method is not of type StaticDynamic, this means that you probably did not generate your code first") 66 | os.Exit(1) 67 | } 68 | 69 | lastRender := rootNode{DynHTML: lastTemplate, UserAssets: initialUserAssets.mergeAndOverwrite(socket.assets())} 70 | // onMount is closed 71 | 72 | go func() { 73 | defer func() { 74 | close(errors) 75 | close(patchesStream) 76 | close(socket.updates) 77 | }() 78 | 79 | outer: 80 | for { 81 | select { 82 | case <-ctx.Done(): 83 | return 84 | case event := <-events: 85 | if userEvent, ok := event.(UserEvent); ok { 86 | socket.component.HandleEvent(userEvent, socket) 87 | continue outer 88 | } 89 | 90 | if routeEvent, ok := event.(RouteChangedEvent); ok { 91 | socket.component.HandleEvent(routeEvent, socket) 92 | socket.Redirect(routeEvent.To) 93 | } 94 | case update, ok := <-socket.updates: 95 | if !ok { 96 | return 97 | } 98 | update.apply(&socket) 99 | if socket.Err != nil { 100 | errors <- socket.Err 101 | return 102 | } 103 | } 104 | 105 | newTemplate, newAssets := socket.component.Render(socket) 106 | newRender := rootNode{DynHTML: newTemplate.(StaticDynamic), UserAssets: newAssets.mergeAndOverwrite(socket.assets())} 107 | patches := lastRender.Diff(newRender) 108 | if patches == nil { 109 | continue 110 | } 111 | 112 | lastRender = newRender 113 | 114 | select { 115 | case <-ctx.Done(): 116 | return 117 | case patchesStream <- *patches: 118 | } 119 | } 120 | }() 121 | 122 | return lastRender, patchesStream, errors 123 | } 124 | 125 | type HTML interface{ html() } 126 | 127 | type L string 128 | 129 | func (L) html() {} 130 | 131 | func (StaticDynamic) html() {} 132 | 133 | func LiveSocket(newComponent func() LiveComponent) http.HandlerFunc { 134 | return func(rw http.ResponseWriter, r *http.Request) { 135 | 136 | upgrader := websocket.Upgrader{} 137 | 138 | conn, err := upgrader.Upgrade(rw, r, nil) 139 | if err != nil { 140 | log.Println(err) 141 | rw.WriteHeader(http.StatusBadRequest) 142 | return 143 | } 144 | 145 | events := make(chan Event, 1024) 146 | 147 | ctx, canc := context.WithCancel(r.Context()) 148 | errGroup, ctx := errgroup.WithContext(ctx) 149 | 150 | component := newComponent() 151 | route := r.URL.RawFragment 152 | initialRender, patchesStream, _ := newPatchesStream(ctx, component, events, route) 153 | 154 | // send mount message 155 | { 156 | payload, err := json.Marshal(initialRender) 157 | if err != nil { 158 | rw.WriteHeader(http.StatusBadRequest) 159 | canc() 160 | return 161 | } 162 | 163 | if err = conn.WriteMessage(websocket.BinaryMessage, payload); err != nil { 164 | rw.WriteHeader(http.StatusBadRequest) 165 | canc() 166 | return 167 | } 168 | } 169 | 170 | // errGroup.Go(func() error { 171 | // select { 172 | // case <-ctx.Done(): 173 | // return ctx.Err() 174 | // case err := <-componentErrors: 175 | // canc() 176 | // log.Println(err) 177 | // return err 178 | // } 179 | // }) 180 | 181 | errGroup.Go(func() error { 182 | for { 183 | select { 184 | case <-ctx.Done(): 185 | return ctx.Err() 186 | case patches := <-patchesStream: 187 | 188 | payload, err := json.Marshal(patches) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | err = conn.WriteMessage(websocket.BinaryMessage, payload) 194 | if err != nil { 195 | return err 196 | } 197 | } 198 | } 199 | }) 200 | 201 | errGroup.Go(func() error { 202 | for { 203 | var msg = map[string]interface{}{} 204 | 205 | err := conn.ReadJSON(&msg) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | var e Event 211 | 212 | if _, ok := msg["to"]; ok { // got redirect event 213 | e = RouteChangedEvent{ 214 | From: msg["from"].(string), 215 | To: msg["to"].(string), 216 | } 217 | } else { 218 | t, ok := msg["name"].(string) 219 | if !ok { 220 | continue 221 | } 222 | delete(msg, "name") 223 | e = UserEvent{Name: t, Data: msg} 224 | } 225 | 226 | select { 227 | case <-ctx.Done(): 228 | return ctx.Err() 229 | case events <- e: 230 | } 231 | } 232 | }) 233 | 234 | if err := errGroup.Wait(); err != nil && !websocket.IsUnexpectedCloseError(err) { 235 | log.Println("errGroup.Error: ", err) 236 | } 237 | canc() 238 | 239 | if unmountable, ok := component.(Unmountable); ok { 240 | unmountable.Unmount() 241 | } 242 | close(events) 243 | conn.Close() 244 | 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pulp 2 | 3 | Pulp allows you to write dynamic web-applications entirely in go, by reacting to events on the server-side. 4 | 5 | 6 | ```go 7 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 8 | return pulp.L(` 9 | {{ if c.showMessage }} 10 |

    {{ c.message }}

    11 | {{ end }} 12 | 13 | 14 | you have pressed the button {{ c.counter }} times 15 | 16 | 17 | 18 | {{ for _, user := range users :key user.id}} 19 | ... 20 | {{ end }} 21 | `), nil 22 | } 23 | 24 | 25 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 26 | switch event.(pulp.UserEvent).Name { 27 | case "increment": 28 | c.counter++ 29 | socket.Update() 30 | } 31 | } 32 | ``` 33 | 34 | ## Getting Started 35 | The best way to start, is to copy one of the examples. Right now there are only two examples: one for when you want to use npm for including the client library, one for when you just want to include a bundled js file. When you don't use the already bundled file, you will need some tool for bundling the library and your js files. The example uses [browserify](https://browserify.org/). Install (globally) with `npm i -g browserify`. Run `GOBIN= go install github.com/maltecl/pulp/cmd/gen@latest` to install the tool that will generate go code from your templates. Make sure `GOBIN` is in your PATH. 36 | 37 | 38 | Now, run the `run.sh` script and open the url in your browser. 39 | 40 | Pulp is built so, that you can integrate it in your existing app. 41 | There are 4 steps: 42 | - You need a struct that implements `LiveComponent` 43 | - expose it's websocket via `LiveSocket(newComponent func() LiveComponent) http.HandlerFunc`. You can use any router for that. 44 | - have an HTML Element with an ID 45 | - use `new PulpSocket("", "/")` to connect to the live-socket and mount it at that ID 46 | 47 | 48 | 49 | ## Lifecycle 50 | Pulp roughly uses the same methods as Phoenix LiveView, I do __not__ claim to have invented the mechanism. 51 | 52 | Upon mount, the template you wrote will be rendered and sent to the client. From now on, the client uses the same websocket connection to send all the events, that should be reacted to, to the server. The server will then re-render the template, compare the old render with the new render and create patches from that. Those are then sent across the wire back to the client and will be (efficiently) patched into the dom. 53 | 54 | 55 | In code, the lifecycle of the app is represented with the methods of the LiveComponent interface: 56 | ```go 57 | type LiveComponent interface { 58 | Mount(Socket) 59 | Render(socket Socket) (HTML, Assets) 60 | HandleEvent(Event, Socket) 61 | } 62 | ``` 63 | `Mount` is called on mount. 64 | 65 | `Render` is called, once, immediately after Mount was called and after that, whenever the `socket.Update()` or `socket.Redirect()` methods are called. 66 | 67 | `HandleEvent` is called, whenever the client sent a pulp-event. Call `socket.Update()` from inside `HandleEvent()` when you are done handling the event to reflect the changes in the client. 68 | 69 | Optionally, the component can have a `Unmount()` method, that is called, when the connection is closed for whatever reason. 70 | 71 | ```go 72 | type Unmountable interface { 73 | Unmount() 74 | } 75 | ``` 76 | 77 | 78 | ## Pulp Events 79 | Pulp events are those things that start with ":". Because of a lack of time, only three (`:click`, `:input`, `:key-submit`) of those are so far implemented and pulled in by default. See [pulp_web/events.js](https://github.com/maltecl/pulp/blob/master/pulp_web/events.js) for how you would go and implement your own. You can tell pulp to use your own events in addition like this: 80 | ```js 81 | const socket = new PulpSocket("mount", "/ws", { 82 | events: [ 83 | ... your events ... 84 | ] 85 | }) 86 | ``` 87 | Template code: 88 | ```handlebars 89 | 90 | ``` 91 | 92 | where `event name` is the name, that is then passed to `HandleEvent`. Along with that are passed all values of the attributes that start with `:value-`: 93 | ```handlebars 94 | 95 | ``` 96 | `:input` will also send the standard HTML `value`-attribute. 97 | `HandleEvent` could handle the `:input`-event like so: 98 | 99 | ```go 100 | func (c *index) HandleEvent(event pulp.Event, socket pulp.Socket) { 101 | e := event.(pulp.UserEvent) 102 | 103 | switch e.Name { 104 | case "": 105 | fmt.Println(e.Data["some-value"].(string)) 106 | c.message = e.Data["value"].(string) 107 | socket.Update() 108 | } 109 | } 110 | ``` 111 | 112 | ## Assets 113 | You can use the same websocket connection, that is used for sending the events/patches back and forth for sending values, which should not appear in the markup. Those values are returned from `Render` with the second return value: 114 | 115 | 116 | ```go 117 | func (c index) Render(pulp.Socket) (pulp.HTML, pulp.Assets) { 118 | return pulp.L(`markup...`), pulp.Assets{ 119 | "intVal": 10, 120 | "stringVal": "hello world", 121 | } 122 | } 123 | ``` 124 | Pulp will also only upon mount send all of those, after that it will just send the ones that have changed. 125 | In the client you receive the values like so: 126 | ```js 127 | const socket = new PulpSocket("mount", "/ws", {}) 128 | socket.onassets(({ intVal, stringVal }) => { 129 | console.log(intVal, stringVal) 130 | }) 131 | ``` 132 | 133 | ## Template Language 134 | 135 | For fast renders and diffs, pulp uses it's own template language. This can be off-putting at first, but because the language is compiled to go code, it can directly reference surounding go code, variable values do not need to be passed in some context, like with other template languages. 136 | 137 | The template language can be used __anywhere__ in your go code, not just in the `Render` method, as long as it is inside a `string` wrapped in `pulp.L`: 138 | ```go 139 | func f(value int) pulp.HTML { 140 | return pulp.L("value: {{ value }}") 141 | } 142 | ``` 143 | 144 | Dynamic values are passed in between two curly braces: 145 | 146 | ```handlebars 147 | {{ dynamicValue }} 148 | ``` 149 | 150 | If expressions look like this: 151 | ```handlebars 152 | {{ if dynamicValue > 10 }} 153 | {{ dynamicValue }} 154 | {{ else }} 155 |

    too bad

    156 | {{ end }} 157 | ``` 158 | The `else`-case is optional. Note, that the `dynamicValue > 10` is just standard go code and will be copied as is into the compiled source. This expression can be as complicated as any go expression with one exception: binding variables like in `if err := ...; err != nil` is not yet possible. 159 | 160 | 161 | For loops on the other hand can do this: 162 | ```handlebars 163 | {{ for i, user := range users :key user.id}} 164 |
  • {{ i }} - {{ renderUser(user) }}
  • 165 | {{ end }} 166 | ``` 167 | 168 | The code before `:key` is copied into the compiled source, just like with the `if`-expression. The expression after `:key` is used as a key for the body of the `for`-loop. The key must be of type `string` and __must__ be specified. The mechanism used here is similar to the one react uses and makes for much smaller patches and more efficient patching. As in react (? not sure about the current state) using the index , of an element as the key, may result in weird behaviour. 169 | 170 | 171 | There is no extra syntax for declaring a variable inside the template. In the future this might be a good idea, but for now just declare that variable in the scope outside of the template, you can access it from inside the template. 172 | 173 | Also, for embedding one template inside another, just use the normal "{{ }}": 174 | ```handlebars 175 | {{ navbar }} 176 | {{ body }} 177 | {{ footer}} 178 | ``` 179 | 180 | The templating language is really small, because you can do things like creating functions/declaring variables in the scope outside of the template and then call/access it from inside the template without any more trouble. 181 | 182 | 183 | ## Further tips 184 | If you want to use this with [nats.io](https://pkg.go.dev/github.com/nats-io/nats.go), make sure to not use the callback way of subscribing to topics, if you use the socket in that callback. This will cause problems, because the socket is not garbage-collected, even if it umounted. Use channels instead: 185 | 186 | 187 | ```go 188 | func (c *index) Mount(socket pulp.Socket) { 189 | socket.Redirect("/login") 190 | c.users = map[string]struct{}{} 191 | socket.Update() 192 | 193 | newMessage := make(chan Message) 194 | pubSub.BindRecvChan("new-message", newMessage) 195 | userJoined := make(chan string) 196 | pubSub.BindRecvChan("user-joined", userJoined) 197 | 198 | go func() { 199 | defer func() { 200 | close(newMessage) 201 | close(userJoined) 202 | }() 203 | 204 | for { 205 | select { 206 | case <-socket.Done(): 207 | return 208 | case msg := <-newMessage: 209 | c.chatHistory = append(c.chatHistory, msg) 210 | case username := <-userJoined: 211 | c.users[username] = struct{}{} 212 | } 213 | socket.Update() 214 | } 215 | }() 216 | } 217 | ``` 218 | 219 | ## Why does this exist? 220 | As far as I am aware of, there are currently three other projects, that do the LiveView for go thing: 221 | 222 | | library | order by page rank (lower is better) | number of github stars | 223 | |---|---|---| 224 | | [golive](https://www.grank.io/pkg/github.com/brendonmatos/golive.html) | 3421 | [178](https://github.com/brendonmatos/golive) | 225 | | [jfyne/live](https://www.grank.io/pkg/github.com/jfyne/live.html) | 7742 | [299](https://github.com/jfyne/live) | 226 | | [hlive](https://www.grank.io/pkg/github.com/SamHennessy/hlive%20(github.com/samhennessy/hlive).html) | 7742 | [23](https://github.com/SamHennessy/hlive) | 227 | 228 | I wrote my own version, because I wanted it to be as simple as writing LiveView components and also because I wanted to learn the details. 229 | I wrote the template language, because of technical details, but also, because I wanted it to feel more like react, where you can refer to your code directly. This makes for faster developement and, because the template code is compiled to go code, you also get type safety. 230 | 231 | 232 | ## What's planned? 233 | I wrote this, (partly) because I needed the end result. There are many ways to improve/optimise this project. I will adress those probably sometime, when I feel like I really need them and have a decent solution in mind. 234 | 235 | Things I would really like to add: 236 | - components -> right now, one component cannot render another component directly 237 | - a more complete router 238 | - ? smaller patches using [json-path](https://jsonpath.com/) (for now it's okay though) 239 | 240 | If you have anything you want to add or a question in general, let me know. 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | -------------------------------------------------------------------------------- /examples/with_npm/web/bundle.js: -------------------------------------------------------------------------------- 1 | (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= 0; i--) { 26 | attr = toNodeAttrs[i]; 27 | attrName = attr.name; 28 | attrNamespaceURI = attr.namespaceURI; 29 | attrValue = attr.value; 30 | 31 | if (attrNamespaceURI) { 32 | attrName = attr.localName || attrName; 33 | fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); 34 | 35 | if (fromValue !== attrValue) { 36 | if (attr.prefix === 'xmlns'){ 37 | attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix 38 | } 39 | fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); 40 | } 41 | } else { 42 | fromValue = fromNode.getAttribute(attrName); 43 | 44 | if (fromValue !== attrValue) { 45 | fromNode.setAttribute(attrName, attrValue); 46 | } 47 | } 48 | } 49 | 50 | // Remove any extra attributes found on the original DOM element that 51 | // weren't found on the target element. 52 | var fromNodeAttrs = fromNode.attributes; 53 | 54 | for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { 55 | attr = fromNodeAttrs[d]; 56 | attrName = attr.name; 57 | attrNamespaceURI = attr.namespaceURI; 58 | 59 | if (attrNamespaceURI) { 60 | attrName = attr.localName || attrName; 61 | 62 | if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { 63 | fromNode.removeAttributeNS(attrNamespaceURI, attrName); 64 | } 65 | } else { 66 | if (!toNode.hasAttribute(attrName)) { 67 | fromNode.removeAttribute(attrName); 68 | } 69 | } 70 | } 71 | } 72 | 73 | var range; // Create a range object for efficently rendering strings to elements. 74 | var NS_XHTML = 'http://www.w3.org/1999/xhtml'; 75 | 76 | var doc = typeof document === 'undefined' ? undefined : document; 77 | var HAS_TEMPLATE_SUPPORT = !!doc && 'content' in doc.createElement('template'); 78 | var HAS_RANGE_SUPPORT = !!doc && doc.createRange && 'createContextualFragment' in doc.createRange(); 79 | 80 | function createFragmentFromTemplate(str) { 81 | var template = doc.createElement('template'); 82 | template.innerHTML = str; 83 | return template.content.childNodes[0]; 84 | } 85 | 86 | function createFragmentFromRange(str) { 87 | if (!range) { 88 | range = doc.createRange(); 89 | range.selectNode(doc.body); 90 | } 91 | 92 | var fragment = range.createContextualFragment(str); 93 | return fragment.childNodes[0]; 94 | } 95 | 96 | function createFragmentFromWrap(str) { 97 | var fragment = doc.createElement('body'); 98 | fragment.innerHTML = str; 99 | return fragment.childNodes[0]; 100 | } 101 | 102 | /** 103 | * This is about the same 104 | * var html = new DOMParser().parseFromString(str, 'text/html'); 105 | * return html.body.firstChild; 106 | * 107 | * @method toElement 108 | * @param {String} str 109 | */ 110 | function toElement(str) { 111 | str = str.trim(); 112 | if (HAS_TEMPLATE_SUPPORT) { 113 | // avoid restrictions on content for things like `Hi` which 114 | // createContextualFragment doesn't support 115 | //