diff --git a/Makefile b/Makefile index 64299adc..75298cba 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,13 @@ install: # Examples ## +example.zero: + @ # go run main.go -C example/zero generate2 --embed + @ go run main.go -C example/zero generate2 + +example.zero.watch: + @ watch --clear -- $(MAKE) example.zero + example.basic: @ (cd example/basic && npm link ../../livebud) @ go run main.go -C example/basic run diff --git a/example/zero/_bud/app b/example/zero/_bud/app new file mode 100755 index 00000000..25c0d37b Binary files /dev/null and b/example/zero/_bud/app differ diff --git a/example/zero/_bud/cmd/app/main.go b/example/zero/_bud/cmd/app/main.go new file mode 100644 index 00000000..a6e0f18d --- /dev/null +++ b/example/zero/_bud/cmd/app/main.go @@ -0,0 +1,56 @@ +package main + +import ( + command "github.com/livebud/bud/example/zero/bud/internal/command" + env "github.com/livebud/bud/example/zero/bud/internal/env" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + web1 "github.com/livebud/bud/example/zero/bud/pkg/web" + controller "github.com/livebud/bud/example/zero/bud/pkg/web/controller" + middleware "github.com/livebud/bud/example/zero/bud/pkg/web/middleware" + view "github.com/livebud/bud/example/zero/bud/pkg/web/view" + web2 "github.com/livebud/bud/example/zero/command/web" + posts "github.com/livebud/bud/example/zero/controller/posts" + sessions "github.com/livebud/bud/example/zero/controller/sessions" + users "github.com/livebud/bud/example/zero/controller/users" + app "github.com/livebud/bud/example/zero/generator/app" + csrf "github.com/livebud/bud/example/zero/middleware/csrf" + wraprw "github.com/livebud/bud/example/zero/middleware/wraprw" + mw "github.com/livebud/bud/example/zero/mw" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + web "github.com/livebud/bud/example/zero/web" + gomod "github.com/livebud/bud/package/gomod" + log "github.com/livebud/bud/package/log" +) + +func main() { + app.Main(loadCLI) +} + +func loadCLI(gomodModule *gomod.Module, logLog log.Log) (*command.CLI, error) { + envEnv, err := env.Load() + if err != nil { + return nil, err + } + tailwindTranspiler := &tailwind.Transpiler{Log: logLog} + goldmarkTranspiler := &goldmark.Transpiler{Log: logLog} + transpilerTranspiler := transpiler.Load(tailwindTranspiler, goldmarkTranspiler) + gohtmlViewer := gohtml.New(logLog, transpilerTranspiler) + viewerViewer := viewer.New(transpilerTranspiler, gohtmlViewer) + viewView := view.New(gomodModule, viewerViewer) + postsController := &posts.Controller{} + sessionsController := &sessions.Controller{} + usersController := &users.Controller{Env: envEnv} + controllerController := controller.New(viewView, postsController, sessionsController, usersController) + csrfMiddleware := &csrf.Middleware{Env: envEnv} + wraprwMiddleware := &wraprw.Middleware{} + middlewareMiddleware := middleware.New(csrfMiddleware, wraprwMiddleware) + mwMiddleware := &mw.Middleware{Env: envEnv} + webWeb := &web.Web{Env: envEnv, Controller: controllerController, Middleware: middlewareMiddleware, MW: mwMiddleware, View: viewView} + web1Server := web1.New(webWeb) + web2Command := web2.New(envEnv, logLog, web1Server) + commandCLI := command.New(logLog, web2Command) + return commandCLI, err +} diff --git a/example/zero/_bud/cmd/gen/main.go b/example/zero/_bud/cmd/gen/main.go new file mode 100644 index 00000000..42528736 --- /dev/null +++ b/example/zero/_bud/cmd/gen/main.go @@ -0,0 +1,47 @@ +package main + +import ( + generator "github.com/livebud/bud/example/zero/bud/internal/generator" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + app "github.com/livebud/bud/example/zero/generator/app" + command "github.com/livebud/bud/example/zero/generator/command" + controller "github.com/livebud/bud/example/zero/generator/controller" + env "github.com/livebud/bud/example/zero/generator/env" + middleware "github.com/livebud/bud/example/zero/generator/middleware" + session "github.com/livebud/bud/example/zero/generator/session" + view "github.com/livebud/bud/example/zero/generator/view" + web "github.com/livebud/bud/example/zero/generator/web" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + framework "github.com/livebud/bud/framework" + genfs "github.com/livebud/bud/package/genfs" + gomod "github.com/livebud/bud/package/gomod" + log "github.com/livebud/bud/package/log" + gen "github.com/livebud/bud/runtime/gen" +) + +func main() { + gen.Main(loadGenerator) +} + +func loadGenerator(frameworkFlag *framework.Flag, genfsFileSystem genfs.FileSystem, gomodModule *gomod.Module, logLog log.Log) (*generator.Generator, error) { + genParser := gen.ProvideParser(genfsFileSystem, gomodModule) + genInjector := gen.ProvideInjector(genfsFileSystem, logLog, gomodModule, genParser) + appGenerator := app.New(frameworkFlag, genInjector, gomodModule) + commandGenerator := command.NewGenerator(gomodModule) + webGenerator := web.New(gomodModule) + controllerGenerator := controller.New(genInjector, gomodModule) + tailwindTranspiler := &tailwind.Transpiler{Log: logLog} + goldmarkTranspiler := &goldmark.Transpiler{Log: logLog} + transpilerTranspiler := transpiler.Load(tailwindTranspiler, goldmarkTranspiler) + gohtmlViewer := gohtml.New(logLog, transpilerTranspiler) + viewerViewer := viewer.New(transpilerTranspiler, gohtmlViewer) + viewGenerator := view.New(frameworkFlag, gomodModule, viewerViewer) + envGenerator := env.New(gomodModule) + middlewareGenerator := middleware.New(gomodModule) + sessionGenerator := session.New(gomodModule) + generatorGenerator := generator.NewGenerator(genfsFileSystem, logLog, appGenerator, commandGenerator, webGenerator, controllerGenerator, viewGenerator, envGenerator, middlewareGenerator, sessionGenerator) + return generatorGenerator, nil +} diff --git a/example/zero/_bud/gen b/example/zero/_bud/gen new file mode 100755 index 00000000..21e7f517 Binary files /dev/null and b/example/zero/_bud/gen differ diff --git a/example/zero/_bud/internal/command/command.go b/example/zero/_bud/internal/command/command.go new file mode 100644 index 00000000..c9fc59fb --- /dev/null +++ b/example/zero/_bud/internal/command/command.go @@ -0,0 +1,36 @@ +package command + +import ( + context "context" + web "github.com/livebud/bud/example/zero/command/web" + command "github.com/livebud/bud/example/zero/generator/command" + log "github.com/livebud/bud/package/log" +) + +func New( + log log.Log, + webCmd *web.Command, +) *CLI { + cli := command.New("app") + webIn := new(web.Serve) + cli.Run(func(ctx context.Context) error { + return command.Go(ctx, log, + func(ctx context.Context) error { return webCmd.GoServe(ctx, webIn) }, + ) + }) + + { // web + + { // web:serve + cmd := cli.Command("web:serve", "serve web requests") + in := new(web.Serve) + cmd.Run(func(ctx context.Context) error { + return webCmd.GoServe(ctx, in) + }) + } + } + + return cli +} + +type CLI = command.CLI diff --git a/example/zero/_bud/internal/env/env.go b/example/zero/_bud/internal/env/env.go new file mode 100644 index 00000000..c3c61244 --- /dev/null +++ b/example/zero/_bud/internal/env/env.go @@ -0,0 +1,19 @@ +package env + +// Code generated by bud; DO NOT EDIT. + +import ( + env "github.com/livebud/bud/example/zero/env" + runenv "github.com/livebud/bud/example/zero/generator/env" +) + +func Load() (*Env, error) { + var e Env + // TODO: do this statically instead of using reflect + if err := runenv.Parse(&e); err != nil { + return nil, err + } + return &e, nil +} + +type Env = env.Env diff --git a/example/zero/_bud/internal/generator/generator.go b/example/zero/_bud/internal/generator/generator.go new file mode 100644 index 00000000..d2abf532 --- /dev/null +++ b/example/zero/_bud/internal/generator/generator.go @@ -0,0 +1,42 @@ +package generator + +import ( + app "github.com/livebud/bud/example/zero/generator/app" + command "github.com/livebud/bud/example/zero/generator/command" + controller "github.com/livebud/bud/example/zero/generator/controller" + env "github.com/livebud/bud/example/zero/generator/env" + middleware "github.com/livebud/bud/example/zero/generator/middleware" + session "github.com/livebud/bud/example/zero/generator/session" + view "github.com/livebud/bud/example/zero/generator/view" + web "github.com/livebud/bud/example/zero/generator/web" + log "github.com/livebud/bud/package/log" + generator "github.com/livebud/bud/runtime/generator" +) + +func NewGenerator( + genfs generator.FileSystem, + log log.Log, + app *app.Generator, + command *command.Generator, + web *web.Generator, + controller *controller.Generator, + view *view.Generator, + env *env.Generator, + middleware *middleware.Generator, + session *session.Generator, +) *Generator { + return generator.NewGenerator( + genfs, + log, + app, + command, + web, + controller, + view, + env, + middleware, + session, + ) +} + +type Generator = generator.Generator diff --git a/example/zero/_bud/pkg/sessions/sessions.go b/example/zero/_bud/pkg/sessions/sessions.go new file mode 100644 index 00000000..c694d458 --- /dev/null +++ b/example/zero/_bud/pkg/sessions/sessions.go @@ -0,0 +1,130 @@ +package sessions + +// Code generated by bud; DO NOT EDIT. + +import ( + bytes "bytes" + context "context" + json "encoding/json" + errors "errors" + fmt "fmt" + session "github.com/livebud/bud/example/zero/session" + secretbox "github.com/livebud/bud/package/cipher/secretbox" + cookies "github.com/livebud/bud/package/cookies" + budsession "github.com/livebud/bud/package/session" + http "net/http" + time "time" +) + +func New(secret string) *Middleware { + // cipher := secretbox.New([32]byte{ + // 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + // 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + // 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + // 0x13, 0x89, + // }) + // cs := cookies.Secure(cipher) + return &Middleware{ + Cookies: cookies.Default(), + Store: nil, + } +} + +type Middleware struct { + Cookies cookies.Store + Store budsession.Store // If nil, we use the cookie store +} + +const sessionID = "sid" + +type contextKey string + +var sessionKey = contextKey("session") + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + cipher := secretbox.New([32]byte{ + 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + 0x13, 0x89, + }) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + store := m.Store + if store == nil { + store = budsession.New(cookies.Secure(cipher), w, r) + } + + cookie, err := m.Cookies.Get(r, sessionID) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + fmt.Println("cookie error, showing unauthorized", err) + http.Error(w, "cookie get error", http.StatusUnauthorized) + return + } + cookie = &http.Cookie{ + Name: sessionID, + Value: "a123", + Path: "/", + HttpOnly: true, + MaxAge: 86400, // 1 day + Expires: time.Now().Add(86400 * time.Second), + } + } + + var session Session + if sessionData, err := store.Get(cookie.Value); err != nil { + if !errors.Is(err, budsession.ErrNotFound) { + fmt.Println("session get error, showing unauthorized", err) + http.Error(w, "session get error", http.StatusUnauthorized) + return + } + } else if sessionData != nil { + fmt.Println("GOT SESSION DATA", string(sessionData)) + if err := json.NewDecoder(bytes.NewReader(sessionData)).Decode(&session); err != nil { + fmt.Println("session decode error, showing unauthorized", err) + http.Error(w, "session decode error", http.StatusUnauthorized) + return + } + } + + ctx := context.WithValue(r.Context(), sessionKey, &session) + r = r.WithContext(ctx) + + fmt.Println("mw: session before", session.UserID) + next.ServeHTTP(w, r) + + payload := new(bytes.Buffer) + if err := json.NewEncoder(payload).Encode(session); err != nil { + fmt.Println("session encode error, showing unauthorized", err) + http.Error(w, "session encode error", http.StatusUnauthorized) + return + } + fmt.Println(payload.String()) + + if err := store.Set(cookie.Value, payload.Bytes(), time.Now()); err != nil { + fmt.Println("session set error, showing unauthorized", err) + http.Error(w, "session set error", http.StatusUnauthorized) + return + } + + if err := m.Cookies.Set(w, cookie); err != nil { + fmt.Println("cookie set error, showing unauthorized", err) + http.Error(w, "cookie set error", http.StatusUnauthorized) + return + } + }) +} + +func isDirty(session *Session) bool { + return true +} + +func From(ctx context.Context) (*Session, error) { + session, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil, fmt.Errorf("no session in context") + } + return session, nil +} + +type Session = session.Session diff --git a/example/zero/_bud/pkg/transpiler/transpiler.go b/example/zero/_bud/pkg/transpiler/transpiler.go new file mode 100644 index 00000000..1d15634a --- /dev/null +++ b/example/zero/_bud/pkg/transpiler/transpiler.go @@ -0,0 +1,36 @@ +package transpiler + +import ( + errors "errors" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + transpiler "github.com/livebud/bud/runtime/transpiler2" +) + +// Load the transpiler +func Load( + tailwind *tailwind.Transpiler, + goldmark *goldmark.Transpiler, +) Transpiler { + tr := transpiler.New() + tr.Add(".gohtml", ".gohtml", tailwind.GohtmlToGohtml) + tr.Add(".md", ".gohtml", goldmark.MdToGohtml) + return &proxy{tr} +} + +type Transpiler = transpiler.Interface + +type proxy struct { + Transpiler +} + +func (p *proxy) Transpile(fromExt, toExt string, code []byte) ([]byte, error) { + transpiled, err := p.Transpiler.Transpile(fromExt, toExt, code) + if err != nil { + if !errors.Is(err, transpiler.ErrNoPath) { + return nil, err + } + return code, nil + } + return transpiled, nil +} diff --git a/example/zero/_bud/pkg/viewer/viewer.go b/example/zero/_bud/pkg/viewer/viewer.go new file mode 100644 index 00000000..f5739102 --- /dev/null +++ b/example/zero/_bud/pkg/viewer/viewer.go @@ -0,0 +1,112 @@ +package viewer + +import ( + context "context" + fmt "fmt" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + router "github.com/livebud/bud/package/router" + view "github.com/livebud/bud/runtime/view" + fs "io/fs" +) + +// Load the viewer +// TODO: turn most of this code into a runtime library +func New( + transpiler transpiler.Transpiler, + gohtml *gohtml.Viewer, +) Viewer { + return &viewer{ + transpiler: transpiler, + viewers: map[string]view.Viewer{ + ".gohtml": gohtml, + }, + accepts: []string{ + ".gohtml", + }, + } +} + +type Viewer = view.Viewer + +type viewer struct { + transpiler transpiler.Transpiler + viewers map[string]view.Viewer + accepts []string +} + +var _ Viewer = (*viewer)(nil) + +func (v *viewer) Register(r *router.Router, pages []*view.Page) { + for _, viewer := range v.viewers { + viewer.Register(r, pages) + } +} + +func (v *viewer) Render(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap) ([]byte, error) { + viewer, ok := v.viewers[page.Ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return nil, fmt.Errorf("viewer: unable to render %q. %w", page.Path, err) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + return nil, fmt.Errorf("viewer: unable to find acceptable viewer to render %q", page.Path) +} + +func (v *viewer) RenderError(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap, err error) []byte { + viewer, ok := v.viewers[page.Error.Ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Error.Ext, v.accepts) + if err != nil { + msg := fmt.Sprintf("viewer: unable to find extension to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + msg := fmt.Sprintf("viewer: unable to find acceptable viewer to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) +} + +func (v *viewer) Bundle(ctx context.Context, fsys fs.FS, pages view.Pages, embeds view.Embeds) error { + exts := map[string]map[string]*view.Page{} + for _, page := range pages { + if _, ok := v.viewers[page.Ext]; ok { + if exts[page.Ext] == nil { + exts[page.Ext] = map[string]*view.Page{} + } + exts[page.Ext][page.Path] = page + continue + } + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return fmt.Errorf("viewer: unable find viewer to bundle %q. %w", page.Path, err) + } + if exts[ext] == nil { + exts[ext] = map[string]*view.Page{} + } + exts[ext][page.Path] = page + } + // TODO: consider parallelizing this + for ext, pages := range exts { + viewer, ok := v.viewers[ext] + if !ok { + return fmt.Errorf("viewer: unable find viewer for %q", ext) + } + if err := viewer.Bundle(ctx, fsys, pages, embeds); err != nil { + return fmt.Errorf("viewer: unable to bundle %q. %w", ext, err) + } + } + return nil +} diff --git a/example/zero/_bud/pkg/web/controller/controller.go b/example/zero/_bud/pkg/web/controller/controller.go new file mode 100644 index 00000000..ffa5cf48 --- /dev/null +++ b/example/zero/_bud/pkg/web/controller/controller.go @@ -0,0 +1,147 @@ +package controller + +import ( + context "context" + sessions1 "github.com/livebud/bud/example/zero/bud/pkg/sessions" + view "github.com/livebud/bud/example/zero/bud/pkg/web/view" + posts "github.com/livebud/bud/example/zero/controller/posts" + sessions "github.com/livebud/bud/example/zero/controller/sessions" + users "github.com/livebud/bud/example/zero/controller/users" + router "github.com/livebud/bud/package/router" + http "net/http" +) + +func New( + view *view.View, + posts *posts.Controller, + sessions *sessions.Controller, + users *users.Controller, +) *Controller { + return &Controller{ + &PostsController{ + &PostsIndexAction{posts, view}, + }, + &SessionsController{ + &SessionsNewAction{sessions, view}, + }, + &UsersController{ + &UsersIndexAction{users, view}, + &UsersNewAction{users, view}, + }, + } +} + +type Controller struct { + Posts *PostsController + Sessions *SessionsController + Users *UsersController +} + +// TODO: use a router.Router interface +func (c *Controller) Mount(r *router.Router) error { + c.Posts.Mount(r) + c.Sessions.Mount(r) + c.Users.Mount(r) + return nil +} + +type PostsController struct { + Index *PostsIndexAction +} + +func (c *PostsController) Mount(r *router.Router) error { + r.Get("/posts", c.Index) + return nil +} + +type PostsIndexAction struct { + controller *posts.Controller + view *view.View +} + +func (a *PostsIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + propMap := map[string]interface{}{} + indexContext, err := loadPostsIndexContext(r.Context()) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + res, err := a.controller.Index(indexContext) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + propMap["posts/index"] = res + html, err := a.view.Render(ctx, "posts/index", propMap) + if err != nil { + html = a.view.RenderError(ctx, "posts/index", propMap, err) + } + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) +} + +type SessionsController struct { + New *SessionsNewAction +} + +func (c *SessionsController) Mount(r *router.Router) error { + r.Get("/sessions/new", c.New) + return nil +} + +type SessionsNewAction struct { + controller *sessions.Controller + view *view.View +} + +func (a *SessionsNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +type UsersController struct { + Index *UsersIndexAction + New *UsersNewAction +} + +func (c *UsersController) Mount(r *router.Router) error { + r.Get("/users/new", c.New) + r.Get("/users", c.Index) + return nil +} + +type UsersIndexAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.Index() + w.Write([]byte(res)) +} + +type UsersNewAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +func loadPostsIndexContext(contextContext context.Context) (*posts.IndexContext, error) { + sessions1Session, err := sessions1.From(contextContext) + if err != nil { + return nil, err + } + postsIndexContext := &posts.IndexContext{Session: sessions1Session} + return postsIndexContext, err +} diff --git a/example/zero/_bud/pkg/web/middleware/middleware.go b/example/zero/_bud/pkg/web/middleware/middleware.go new file mode 100644 index 00000000..fc8463f6 --- /dev/null +++ b/example/zero/_bud/pkg/web/middleware/middleware.go @@ -0,0 +1,43 @@ +package middleware + +// Code generated by bud; DO NOT EDIT. + +import ( + csrf "github.com/livebud/bud/example/zero/middleware/csrf" + wraprw "github.com/livebud/bud/example/zero/middleware/wraprw" + middleware "github.com/livebud/bud/package/middleware" + http "net/http" +) + +type Interface = middleware.Middleware +type Stack = middleware.Stack + +func New( + csrf *csrf.Middleware, + // session *session.Middleware, + wraprw *wraprw.Middleware, +) *Middleware { + return &Middleware{ + csrf, + // session, + wraprw, + Stack{ + wraprw, + csrf, + // session, + }, + } +} + +type Middleware struct { + CSRF *csrf.Middleware + // Session *session.Middleware + WrapRW *wraprw.Middleware + stack Stack +} + +var _ Interface = (*Middleware)(nil) + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return m.stack.Middleware(next) +} diff --git a/example/zero/_bud/pkg/web/view/view.go b/example/zero/_bud/pkg/web/view/view.go new file mode 100644 index 00000000..41197e27 --- /dev/null +++ b/example/zero/_bud/pkg/web/view/view.go @@ -0,0 +1,204 @@ +package view + +import ( + context "context" + fmt "fmt" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + gomod "github.com/livebud/bud/package/gomod" + router "github.com/livebud/bud/package/router" + virtual "github.com/livebud/bud/package/virtual" + view "github.com/livebud/bud/runtime/view" + fs "io/fs" + http "net/http" +) + +func New( + module *gomod.Module, + viewer viewer.Viewer, +) *View { + + fsys := module + + // TODO: generate with Pages instead + postsIndexPage := &PostsIndexPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/index", + Path: "posts/index.gohtml", + Ext: ".gohtml", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + postsIntroPage := &PostsIntroPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/intro", + Path: "posts/intro.md", + Ext: ".md", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + return &View{ + map[string]renderer{ + "posts/index": postsIndexPage, + "posts/intro": postsIntroPage, + }, + &PostsView{ + postsIndexPage, + postsIntroPage, + }, + } +} + +var embeddedFS = virtual.List{} + +type renderer interface { + Render(ctx context.Context, propMap view.PropMap) ([]byte, error) + RenderError(ctx context.Context, propMap view.PropMap, err error) []byte +} + +type View struct { + pages map[string]renderer + Posts *PostsView +} + +var _ view.Interface = (*View)(nil) + +// TODO: use a router.Router interface +func (v *View) Mount(r *router.Router) error { + return nil +} + +func (v *View) Render(ctx context.Context, key string, propMap view.PropMap) ([]byte, error) { + page, ok := v.pages[key] + if !ok { + return nil, fmt.Errorf("generator/view: no page for key %s", key) + } + return page.Render(ctx, propMap) +} + +func (v *View) RenderError(ctx context.Context, key string, propMap view.PropMap, err error) []byte { + page, ok := v.pages[key] + if !ok { + return []byte(fmt.Sprintf("no page %q to render error: %s", key, err)) + } + return page.RenderError(ctx, propMap, err) +} + +type PostsView struct { + Index *PostsIndexPage + Intro *PostsIntroPage +} + +func (p *PostsView) Mount(r *router.Router) error { + r.Mount(p.Index) + r.Mount(p.Intro) + return nil +} + +type PostsIndexPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIndexPage) Mount(r *router.Router) error { + r.Get("/posts", p) + return nil +} + +func (p *PostsIndexPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIndexPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIndexPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} + +type PostsIntroPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIntroPage) Mount(r *router.Router) error { + r.Get("/posts/intro", p) + return nil +} + +// TODO: consolidate +func (p *PostsIntroPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIntroPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIntroPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} diff --git a/example/zero/_bud/pkg/web/web.go b/example/zero/_bud/pkg/web/web.go new file mode 100644 index 00000000..40d78912 --- /dev/null +++ b/example/zero/_bud/pkg/web/web.go @@ -0,0 +1,54 @@ +package web + +// Code generated by bud; DO NOT EDIT. + +import ( + context "context" + errors "errors" + web "github.com/livebud/bud/example/zero/web" + middleware "github.com/livebud/bud/package/middleware" + router "github.com/livebud/bud/package/router" + socket "github.com/livebud/bud/package/socket" + errgroup "golang.org/x/sync/errgroup" + net "net" + http "net/http" +) + +func New(web *web.Web) *Server { + middleware := web.Stack(middleware.Stack{}) + router := web.Router(router.New()) + handler := web.Handler(middleware.Middleware(router)) + server := web.Server(&http.Server{ + Handler: handler, + }) + return &Server{server} +} + +type Server struct { + server *http.Server +} + +func (s *Server) Listen(ctx context.Context, addr string) error { + ln, err := socket.Listen(addr) + if err != nil { + return err + } + defer ln.Close() + return s.Serve(ctx, ln) +} + +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + eg := new(errgroup.Group) + eg.Go(func() error { + return s.server.Serve(ln) + }) + eg.Go(func() error { + <-ctx.Done() + // TODO: wait for another ctrl+c to force quit + return s.server.Shutdown(context.Background()) + }) + if err := eg.Wait(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/example/zero/command/web/command.go b/example/zero/command/web/command.go new file mode 100644 index 00000000..0280cb00 --- /dev/null +++ b/example/zero/command/web/command.go @@ -0,0 +1,32 @@ +package web + +import ( + "context" + + "github.com/livebud/bud/example/zero/bud/pkg/web" + "github.com/livebud/bud/example/zero/env" + "github.com/livebud/bud/package/log" +) + +func New(env *env.Env, log log.Log, server *web.Server) *Command { + return &Command{env, log, server} +} + +type Command struct { + env *env.Env + log log.Log + server *web.Server +} + +type Serve struct { + Listen string +} + +func (c *Command) GoServe(ctx context.Context, in *Serve) error { + if in.Listen == "" { + in.Listen = ":3000" + } + c.log.Infof("connecting to database: %s", c.env.Database.URL) + c.log.Infof("starting the web server on: http://localhost%s", in.Listen) + return c.server.Listen(ctx, in.Listen) +} diff --git a/example/zero/controller/posts/controller.go b/example/zero/controller/posts/controller.go new file mode 100644 index 00000000..5aeed575 --- /dev/null +++ b/example/zero/controller/posts/controller.go @@ -0,0 +1,23 @@ +package posts + +import ( + "fmt" + + "github.com/livebud/bud/example/zero/session" +) + +type Controller struct { +} + +type IndexContext struct { + Session *session.Session +} + +func (c *Controller) Index(ctx *IndexContext) (string, error) { + fmt.Println("got session user id", ctx.Session.UserID) + fmt.Println("is logged in?", !ctx.Session.Visitor()) + id := 10 + ctx.Session.UserID = &id + // fmt.Println("got session", ctx.Session) + return "Welcome to my blog!", nil +} diff --git a/example/zero/controller/sessions/controller.go b/example/zero/controller/sessions/controller.go new file mode 100644 index 00000000..33d89fce --- /dev/null +++ b/example/zero/controller/sessions/controller.go @@ -0,0 +1,8 @@ +package sessions + +type Controller struct { +} + +func (c *Controller) New() string { + return "new session" +} diff --git a/example/zero/controller/users/controller.go b/example/zero/controller/users/controller.go new file mode 100644 index 00000000..7c8a6411 --- /dev/null +++ b/example/zero/controller/users/controller.go @@ -0,0 +1,17 @@ +package users + +import ( + "github.com/livebud/bud/example/zero/env" +) + +type Controller struct { + Env *env.Env +} + +func (c *Controller) Index() string { + return "user index" +} + +func (c *Controller) New() string { + return "new user form" +} diff --git a/example/zero/env/env.go b/example/zero/env/env.go new file mode 100644 index 00000000..b3213f41 --- /dev/null +++ b/example/zero/env/env.go @@ -0,0 +1,19 @@ +package env + +type Env struct { + Database Database + CSRF CSRF + Session Session +} + +type Database struct { + URL string `env:"DATABASE_URL" envDefault:"postgres://localhost:5432/zero?sslmode=disable"` +} + +type CSRF struct { + Token string `env:"CSRF_TOKEN"` +} + +type Session struct { + Key string `env:"SESSION_KEY"` +} diff --git a/example/zero/generator/app/generator.go b/example/zero/generator/app/generator.go new file mode 100644 index 00000000..7572c7fa --- /dev/null +++ b/example/zero/generator/app/generator.go @@ -0,0 +1,127 @@ +package app + +import ( + "context" + "errors" + "os" + + "github.com/livebud/bud/example/zero/generator/command" + "github.com/livebud/bud/framework" + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/log/console" + "github.com/livebud/bud/runtime/generator" +) + +func New(flag *framework.Flag, injector *di.Injector, module *gomod.Module) *Generator { + return &Generator{flag, injector, module} +} + +type Generator struct { + flag *framework.Flag + injector *di.Injector + module *gomod.Module +} + +const appTemplate = `package main + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func main() { + app.Main({{ $.Provider.Name }}) +} + +{{ $.Provider.Function }} +` + +var appGen = gotemplate.MustParse("app.gotext", appTemplate) + +func (g *Generator) Extend(gen generator.FileSystem) { + // TODO: should bud/ be implied? I don't think we should sync non-bud/ + // directories, it's too risky. + gen.GenerateFile("bud/cmd/app/main.go", g.generateFile) +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + type State struct { + Imports []*imports.Import + Provider *di.Provider + } + imset := imports.New() + imset.Add(g.module.Import("generator/app")) + aliases := di.Aliases{ + di.ToType(g.module.Import("env"), "*Env"): di.ToType(g.module.Import("bud/internal/env"), "*Env"), + } + if !g.flag.Embed { + aliases[di.ToType(g.module.Import("bud/pkg/web/view"), "FS")] = di.ToType("github.com/livebud/bud/package/gomod", "*Module") + } + + provider, err := g.injector.Wire(&di.Function{ + Name: "loadCLI", + Imports: imset, + Params: []*di.Param{ + &di.Param{ + Import: "github.com/livebud/bud/package/log", + Type: "Log", + }, + &di.Param{ + Import: "github.com/livebud/bud/package/gomod", + Type: "*Module", + }, + }, + Aliases: aliases, + Results: []di.Dependency{ + di.ToType(g.module.Import("bud/internal/command"), "*CLI"), + &di.Error{}, + }, + }) + if err != nil { + return err + } + code, err := appGen.Generate(State{ + Imports: imset.List(), + Provider: provider, + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +///////////////////////////////////////// +// Runtime from the code generated above +///////////////////////////////////////// + +type loadCLI func(module *gomod.Module, log log.Log) (*command.CLI, error) + +func Main(loadCLI loadCLI) { + log := log.New(console.New(os.Stderr)) + if err := run(log, loadCLI); err != nil && !errors.Is(err, context.Canceled) { + log.Error(err) + os.Exit(1) + } +} + +func run(log log.Log, loadCLI loadCLI) error { + module, err := gomod.Find(".") + if err != nil { + return err + } + cli, err := loadCLI(module, log) + if err != nil { + return err + } + ctx := context.Background() + return cli.Parse(ctx, os.Args[1:]...) +} diff --git a/example/zero/generator/command/generator.go b/example/zero/generator/command/generator.go new file mode 100644 index 00000000..8f799b4a --- /dev/null +++ b/example/zero/generator/command/generator.go @@ -0,0 +1,117 @@ +package command + +import ( + "context" + + "github.com/livebud/bud/package/commander" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/runtime/generator" + "golang.org/x/sync/errgroup" +) + +func NewGenerator(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +const template = `package command + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New( + log log.Log, + webCmd *web.Command, +) *CLI { + cli := command.New("app") + webIn := new(web.Serve) + cli.Run(func(ctx context.Context) error { + return command.Go(ctx, log, + func(ctx context.Context) error { return webCmd.GoServe(ctx, webIn) }, + ) + }) + + { // web + + { // web:serve + cmd := cli.Command("web:serve", "serve web requests") + in := new(web.Serve) + cmd.Run(func(ctx context.Context) error { + return webCmd.GoServe(ctx, in) + }) + } + } + + return cli +} + +type CLI = command.CLI +` + +var gen = gotemplate.MustParse("command.gotext", template) + +func (g *Generator) Extend(gen generator.FileSystem) { + // TODO: should bud/ be implied? I don't think we should sync non-bud/ + // directories, it's too risky. + gen.GenerateFile("bud/internal/command/command.go", g.generateFile) +} + +type Routine struct{} + +type State struct { + Imports []*imports.Import + Name string +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("context") + imset.Add("github.com/livebud/bud/package/log") + // imset.Add("golang.org/x/sync/errgroup") + imset.Add(g.module.Import("generator/command")) + // TODO: move to State + imset.Add(g.module.Import("command/web")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + Name: "app", + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +//////////////////////////////////////////////// +// Runtime from the code generated above +//////////////////////////////////////////////// + +func New(name string) *CLI { + return commander.New(name, "zero app") +} + +// Go starts a group of commands in goroutines and waits for them to finish +func Go(ctx context.Context, log log.Log, fns ...func(context.Context) error) error { + eg, ctx := errgroup.WithContext(ctx) + for _, fn := range fns { + fn := fn + eg.Go(func() error { + return fn(ctx) + }) + } + return eg.Wait() +} + +type CLI = commander.CLI diff --git a/example/zero/generator/controller/generator.go b/example/zero/generator/controller/generator.go new file mode 100644 index 00000000..53f93eda --- /dev/null +++ b/example/zero/generator/controller/generator.go @@ -0,0 +1,226 @@ +package controller + +import ( + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(injector *di.Injector, module *gomod.Module) *Generator { + return &Generator{injector, module} +} + +type Generator struct { + injector *di.Injector + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/controller/controller.go", g.generateFile) +} + +const template = `package controller + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{ $import.Name }} "{{ $import.Path }}" + {{- end }} +) +{{- end }} + +func New( + view *view.View, + posts *posts.Controller, + sessions *sessions.Controller, + users *users.Controller, +) *Controller { + return &Controller{ + &PostsController{ + &PostsIndexAction{posts, view}, + }, + &SessionsController{ + &SessionsNewAction{sessions, view}, + }, + &UsersController{ + &UsersIndexAction{users, view}, + &UsersNewAction{users, view}, + }, + } +} + +type Controller struct { + Posts *PostsController + Sessions *SessionsController + Users *UsersController +} + +// TODO: use a router.Router interface +func (c *Controller) Mount(r *router.Router) error { + c.Posts.Mount(r) + c.Sessions.Mount(r) + c.Users.Mount(r) + return nil +} + +type PostsController struct { + Index *PostsIndexAction +} + +func (c *PostsController) Mount(r *router.Router) error { + r.Get("/posts", c.Index) + return nil +} + +type PostsIndexAction struct { + controller *posts.Controller + view *view.View +} + +func (a *PostsIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + propMap := map[string]interface{}{} + indexContext, err := {{ $.PostIndexProvider.Name}}( + {{- if $.PostIndexProvider.Variable "context.Context" }}r.Context(),{{ end }} + {{- if $.PostIndexProvider.Variable "net/http.*Request" }}r,{{ end }} + {{- if $.PostIndexProvider.Variable "net/http.ResponseWriter" }}w,{{ end }} + ) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + res, err := a.controller.Index(indexContext) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + propMap["posts/index"] = res + html, err := a.view.Render(ctx, "posts/index", propMap) + if err != nil { + html = a.view.RenderError(ctx, "posts/index", propMap, err) + } + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) +} + +type SessionsController struct { + New *SessionsNewAction +} + +func (c *SessionsController) Mount(r *router.Router) error { + r.Get("/sessions/new", c.New) + return nil +} + +type SessionsNewAction struct { + controller *sessions.Controller + view *view.View +} + +func (a *SessionsNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +type UsersController struct { + Index *UsersIndexAction + New *UsersNewAction +} + +func (c *UsersController) Mount(r *router.Router) error { + r.Get("/users/new", c.New) + r.Get("/users", c.Index) + return nil +} + +type UsersIndexAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.Index() + w.Write([]byte(res)) +} + +type UsersNewAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +{{ $.PostIndexProvider.Function }} +` + +var gen = gotemplate.MustParse("controller.gotext", template) + +type State struct { + Imports []*imports.Import + PostIndexProvider *di.Provider +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http") + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("posts", g.module.Import("controller/posts")) + imset.AddNamed("users", g.module.Import("controller/users")) + imset.AddNamed("sessions", g.module.Import("controller/sessions")) + imset.AddNamed("view", g.module.Import("bud/pkg/web/view")) + + provider, err := g.injector.Wire(&di.Function{ + Name: "loadPostsIndexContext", + Hoist: true, + Imports: imset, + Target: g.module.Import("bud/pkg/web/controller"), + Params: []*di.Param{ + { + Import: "net/http", + Type: "*Request", + }, + { + Import: "net/http", + Type: "ResponseWriter", + }, + { + Import: "context", + Type: "Context", + }, + }, + Aliases: di.Aliases{ + di.ToType(g.module.Import("env"), "*Env"): di.ToType(g.module.Import("bud/internal/env"), "*Env"), + di.ToType(g.module.Import("session"), "*Session"): di.ToType(g.module.Import("bud/pkg/sessions"), "*Session"), + }, + Results: []di.Dependency{ + di.ToType(g.module.Import("controller/posts"), "*IndexContext"), + &di.Error{}, + }, + }) + if err != nil { + return err + } + // fmt.Println(provider.Function()) + + code, err := gen.Generate(&State{ + Imports: imset.List(), + PostIndexProvider: provider, + }) + if err != nil { + return err + } + file.Data = code + return nil +} diff --git a/example/zero/generator/env/generator.go b/example/zero/generator/env/generator.go new file mode 100644 index 00000000..b6ecc8e6 --- /dev/null +++ b/example/zero/generator/env/generator.go @@ -0,0 +1,74 @@ +package env + +import ( + "github.com/caarlos0/env/v7" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/internal/env/env.go", g.generateFile) +} + +const template = `package env + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func Load() (*Env, error) { + var e Env + // TODO: do this statically instead of using reflect + if err := runenv.Parse(&e); err != nil { + return nil, err + } + return &e, nil +} + +type Env = env.Env +` + +var gen = gotemplate.MustParse("env.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddNamed("env", g.module.Import("env")) + imset.AddNamed("runenv", g.module.Import("generator/env")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// + +func Parse(e interface{}) error { + return env.Parse(e) +} diff --git a/example/zero/generator/middleware/generator.go b/example/zero/generator/middleware/generator.go new file mode 100644 index 00000000..8fcffc20 --- /dev/null +++ b/example/zero/generator/middleware/generator.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/middleware/middleware.go", g.generateFile) +} + +const template = `package middleware + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +type Interface = middleware.Middleware +type Stack = middleware.Stack + +func New( + csrf *csrf.Middleware, + // session *session.Middleware, + wraprw *wraprw.Middleware, +) *Middleware { + return &Middleware{ + csrf, + // session, + wraprw, + Stack{ + wraprw, + csrf, + // session, + }, + } +} + +type Middleware struct { + CSRF *csrf.Middleware + // Session *session.Middleware + WrapRW *wraprw.Middleware + stack Stack +} + +var _ Interface = (*Middleware)(nil) + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return m.stack.Middleware(next) +} +` + +var gen = gotemplate.MustParse("middleware.gotext", template) + +type State struct { + Imports []*imports.Import + Middlewares []*Middleware +} + +type Middleware struct { +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http") + imset.AddNamed("middleware", "github.com/livebud/bud/package/middleware") + // TODO: generate these + imset.AddNamed("wraprw", g.module.Import("middleware/wraprw")) + imset.AddNamed("csrf", g.module.Import("middleware/csrf")) + // imset.AddNamed("session", g.module.Import("middleware/session")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// diff --git a/example/zero/generator/session/generator.go b/example/zero/generator/session/generator.go new file mode 100644 index 00000000..b2e768c6 --- /dev/null +++ b/example/zero/generator/session/generator.go @@ -0,0 +1,182 @@ +package env + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/sessions/sessions.go", g.generateFile) +} + +const template = `package sessions + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New(secret string) *Middleware { + // cipher := secretbox.New([32]byte{ + // 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + // 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + // 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + // 0x13, 0x89, + // }) + // cs := cookies.Secure(cipher) + return &Middleware{ + Cookies: cookies.Default(), + Store: nil, + } +} + +type Middleware struct { + Cookies cookies.Store + Store budsession.Store // If nil, we use the cookie store +} + +const sessionID = "sid" + +type contextKey string + +var sessionKey = contextKey("session") + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + cipher := secretbox.New([32]byte{ + 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + 0x13, 0x89, + }) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + store := m.Store + if store == nil { + store = budsession.New(cookies.Secure(cipher), w, r) + } + + cookie, err := m.Cookies.Get(r, sessionID) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + fmt.Println("cookie error, showing unauthorized", err) + http.Error(w, "cookie get error", http.StatusUnauthorized) + return + } + cookie = &http.Cookie{ + Name: sessionID, + Value: "a123", + Path: "/", + HttpOnly: true, + MaxAge: 86400, // 1 day + Expires: time.Now().Add(86400 * time.Second), + } + } + + var session Session + if sessionData, err := store.Get(cookie.Value); err != nil { + if !errors.Is(err, budsession.ErrNotFound) { + fmt.Println("session get error, showing unauthorized", err) + http.Error(w, "session get error", http.StatusUnauthorized) + return + } + } else if sessionData != nil { + fmt.Println("GOT SESSION DATA", string(sessionData)) + if err := json.NewDecoder(bytes.NewReader(sessionData)).Decode(&session); err != nil { + fmt.Println("session decode error, showing unauthorized", err) + http.Error(w, "session decode error", http.StatusUnauthorized) + return + } + } + + ctx := context.WithValue(r.Context(), sessionKey, &session) + r = r.WithContext(ctx) + + fmt.Println("mw: session before", session.UserID) + next.ServeHTTP(w, r) + + payload := new(bytes.Buffer) + if err := json.NewEncoder(payload).Encode(session); err != nil { + fmt.Println("session encode error, showing unauthorized", err) + http.Error(w, "session encode error", http.StatusUnauthorized) + return + } + fmt.Println(payload.String()) + + if err := store.Set(cookie.Value, payload.Bytes(), time.Now()); err != nil { + fmt.Println("session set error, showing unauthorized", err) + http.Error(w, "session set error", http.StatusUnauthorized) + return + } + + if err := m.Cookies.Set(w, cookie); err != nil { + fmt.Println("cookie set error, showing unauthorized", err) + http.Error(w, "cookie set error", http.StatusUnauthorized) + return + } + }) +} + +func isDirty(session *Session) bool { + return true +} + +func From(ctx context.Context) (*Session, error) { + session, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil, fmt.Errorf("no session in context") + } + return session, nil +} + +type Session = session.Session +` + +var gen = gotemplate.MustParse("session.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http", "context", "time", "errors", "bytes", "encoding/json", "fmt") + // imset.AddStd("fmt") + imset.AddNamed("session", g.module.Import("session")) + imset.AddNamed("cookies", "github.com/livebud/bud/package/cookies") + imset.AddNamed("budsession", "github.com/livebud/bud/package/session") + imset.AddNamed("secretbox", "github.com/livebud/bud/package/cipher/secretbox") + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// + +type Store interface { +} + +// func Parse(e interface{}) error { +// return env.Parse(e) +// } diff --git a/example/zero/generator/view/generator.go b/example/zero/generator/view/generator.go new file mode 100644 index 00000000..a6f23ea9 --- /dev/null +++ b/example/zero/generator/view/generator.go @@ -0,0 +1,287 @@ +package view + +import ( + "context" + "io/fs" + + "github.com/livebud/bud/example/zero/bud/pkg/viewer" + "github.com/livebud/bud/framework" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" + "github.com/livebud/bud/runtime/view" +) + +func New(flag *framework.Flag, module *gomod.Module, viewer viewer.Viewer) *Generator { + return &Generator{flag, module, viewer} +} + +type Generator struct { + flag *framework.Flag + module *gomod.Module + viewer viewer.Viewer +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/view/view.go", g.generateFile) +} + +const template = `package view + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{ $import.Name }} "{{ $import.Path }}" + {{- end }} +) +{{- end }} + +func New( + module *gomod.Module, + viewer viewer.Viewer, +) *View { + {{ if $.Flag.Embed }} + fsys := embeddedFS + {{ else }} + fsys := module + {{ end }} + + // TODO: generate with Pages instead + postsIndexPage := &PostsIndexPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/index", + Path: "posts/index.gohtml", + Ext: ".gohtml", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + postsIntroPage := &PostsIntroPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/intro", + Path: "posts/intro.md", + Ext: ".md", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + return &View{ + map[string]renderer{ + "posts/index": postsIndexPage, + "posts/intro": postsIntroPage, + }, + &PostsView{ + postsIndexPage, + postsIntroPage, + }, + } +} + +var embeddedFS = virtual.List{ + {{- range $embed := $.Embeds }} + &virtual.File{ + Path: "{{ $embed.Path }}", + Data: []byte("{{ $embed.Embed }}"), + }, + {{- end }} +} + +type renderer interface { + Render(ctx context.Context, propMap view.PropMap) ([]byte, error) + RenderError(ctx context.Context, propMap view.PropMap, err error) []byte +} + +type View struct { + pages map[string]renderer + Posts *PostsView +} + +var _ view.Interface = (*View)(nil) + +// TODO: use a router.Router interface +func (v *View) Mount(r *router.Router) error { + return nil +} + +func (v *View) Render(ctx context.Context, key string, propMap view.PropMap) ([]byte, error) { + page, ok := v.pages[key] + if !ok { + return nil, fmt.Errorf("generator/view: no page for key %s", key) + } + return page.Render(ctx, propMap) +} + +func (v *View) RenderError(ctx context.Context, key string, propMap view.PropMap, err error) []byte { + page, ok := v.pages[key] + if !ok { + return []byte(fmt.Sprintf("no page %q to render error: %s", key, err)) + } + return page.RenderError(ctx, propMap, err) +} + +type PostsView struct { + Index *PostsIndexPage + Intro *PostsIntroPage +} + +func (p *PostsView) Mount(r *router.Router) error { + r.Mount(p.Index) + r.Mount(p.Intro) + return nil +} + +type PostsIndexPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIndexPage) Mount(r *router.Router) error { + r.Get("/posts", p) + return nil +} + +func (p *PostsIndexPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIndexPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIndexPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} + +type PostsIntroPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIntroPage) Mount(r *router.Router) error { + r.Get("/posts/intro", p) + return nil +} + +// TODO: consolidate +func (p *PostsIntroPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIntroPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIntroPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} +` + +var gen = gotemplate.MustParse("view.gotext", template) + +type State struct { + Flag *framework.Flag + Imports []*imports.Import + Pages map[string]*view.Page + Embeds view.Embeds +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + ctx := context.TODO() + imset := imports.New() + imset.AddStd("context", "fmt", "net/http", "io/fs") + imset.AddNamed("gomod", "github.com/livebud/bud/package/gomod") + imset.AddNamed("view", "github.com/livebud/bud/runtime/view") + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("viewer", g.module.Import("bud/pkg/viewer")) + imset.AddNamed("virtual", "github.com/livebud/bud/package/virtual") + viewFS, err := fs.Sub(fsys, "view") + if err != nil { + return err + } + pages, err := view.Find(viewFS) + if err != nil { + return err + } + embeds := view.Embeds{} + if g.flag.Embed { + // TODO: decide if we want to scope to the view path or module path + if err := g.viewer.Bundle(ctx, fsys, pages, embeds); err != nil { + return err + } + } + code, err := gen.Generate(&State{ + Flag: g.flag, + Imports: imset.List(), + Pages: pages, + Embeds: embeds, + }) + if err != nil { + return err + } + file.Data = code + return nil +} diff --git a/example/zero/generator/web/generator.go b/example/zero/generator/web/generator.go new file mode 100644 index 00000000..d567c4aa --- /dev/null +++ b/example/zero/generator/web/generator.go @@ -0,0 +1,126 @@ +package web + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/web.go", g.generateFile) +} + +const template = `package web + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New(web *web.Web) *Server { + middleware := web.Stack(middleware.Stack{}) + router := web.Router(router.New()) + handler := web.Handler(middleware.Middleware(router)) + server := web.Server(&http.Server{ + Handler: handler, + }) + return &Server{server} +} + +type Server struct { + server *http.Server +} + +func (s *Server) Listen(ctx context.Context, addr string) error { + ln, err := socket.Listen(addr) + if err != nil { + return err + } + defer ln.Close() + return s.Serve(ctx, ln) +} + +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + eg := new(errgroup.Group) + eg.Go(func() error { + return s.server.Serve(ln) + }) + eg.Go(func() error { + <-ctx.Done() + // TODO: wait for another ctrl+c to force quit + return s.server.Shutdown(context.Background()) + }) + if err := eg.Wait(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} +` + +var gen = gotemplate.MustParse("web.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("context", "net/http", "net", "errors") + imset.AddNamed("errgroup", "golang.org/x/sync/errgroup") + imset.AddNamed("web", g.module.Import("web")) + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("socket", "github.com/livebud/bud/package/socket") + imset.AddNamed("middleware", "github.com/livebud/bud/package/middleware") + + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +///////////////////////////// +// Runtime +///////////////////////////// + +// type Server interface { +// Listen(ctx context.Context, addr string) error +// Serve(ctx context.Context, ln net.Listener) error +// } + +// TODO: move this into *router.Router +// type Router interface { +// http.Handler +// middleware.Middleware +// Set(method string, route string, handler http.Handler) error +// Get(route string, handler http.Handler) error +// Post(route string, handler http.Handler) error +// Put(route string, handler http.Handler) error +// Patch(route string, handler http.Handler) error +// Delete(route string, handler http.Handler) error +// Mount(m Mount) error +// } + +// type Mount interface { +// Mount(router Router) error +// } + +// type Middlewares = middleware.Stack diff --git a/example/zero/go.mod b/example/zero/go.mod new file mode 100644 index 00000000..3f0b6aae --- /dev/null +++ b/example/zero/go.mod @@ -0,0 +1,33 @@ +module github.com/livebud/bud/example/zero + +go 1.19 + +replace github.com/livebud/bud => ../.. + +require ( + github.com/caarlos0/env/v7 v7.1.0 + github.com/felixge/httpsnoop v1.0.3 + github.com/livebud/bud v0.0.0-00010101000000-000000000000 + github.com/matryer/is v1.4.0 + github.com/yuin/goldmark v1.5.4 + golang.org/x/sync v0.1.0 +) + +require ( + github.com/RyanCarrier/dijkstra v1.1.0 // indirect + github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/evanw/esbuild v0.14.11 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/livebud/transpiler v0.0.3 // indirect + github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac // indirect + github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/xlab/treeprint v1.1.0 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect +) diff --git a/example/zero/go.sum b/example/zero/go.sum new file mode 100644 index 00000000..1e098f94 --- /dev/null +++ b/example/zero/go.sum @@ -0,0 +1,83 @@ +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/RyanCarrier/dijkstra v1.1.0 h1:/NDihjfJA3CxFaZz8EdzTwdFKFZDvvB881OVLdakRcI= +github.com/RyanCarrier/dijkstra v1.1.0/go.mod h1:5agGUBNEtUAGIANmbw09fuO3a2htPEkc1jNH01qxCWA= +github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345 h1:fgSpoKViTSqRb4hjDNj10ig5wUvO0CayCzFdLf6fuRM= +github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345/go.mod h1:OK4EvWJ441LQqGzed5NGB6vKBAE34n3z7iayPcEwr30= +github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72 h1:uGeGZl8PxSq8VZGG4QK5njJTFA4/G/x5CYORvQVXtAE= +github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72/go.mod h1:o+JdB7VetTHjLhU0N57x18B9voDBQe0paApdEAEoEfw= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 h1:WWB576BN5zNSZc/M9d/10pqEx5VHNhaQ/yOVAkmj5Yo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/caarlos0/env/v7 v7.1.0 h1:9lzTF5amyQeWHZzuZeKlCb5FWSUxpG1js43mhbY8ozg= +github.com/caarlos0/env/v7 v7.1.0/go.mod h1:LPPWniDUq4JaO6Q41vtlyikhMknqymCLBw0eX4dcH1E= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanw/esbuild v0.14.11 h1:bw50N4v70Dqf/B6Wn+3BM6BVttz4A6tHn8m8Ydj9vxk= +github.com/evanw/esbuild v0.14.11/go.mod h1:GG+zjdi59yh3ehDn4ZWfPcATxjPDUH53iU4ZJbp7dkY= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 h1:Uc+IZ7gYqAf/rSGFplbWBSHaGolEQlNLgMgSE3ccnIQ= +github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813/go.mod h1:P+oSoE9yhSRvsmYyZsshflcR6ePWYLql6UU1amW13IM= +github.com/gitchander/permutation v0.0.0-20201214100618-1f3e7285f953 h1:+rJDfq6waeB1BncyEfuFL1N3U7t3aahrAjPqcKLpMys= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/keegancsmith/rpc v1.3.0 h1:wGWOpjcNrZaY8GDYZJfvyxmlLljm3YQWF+p918DXtDk= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/livebud/transpiler v0.0.3 h1:OFKPsmfTOywBDoOE/ZFMVjAupk/xVXFlXoAgZNRhh5Q= +github.com/livebud/transpiler v0.0.3/go.mod h1:vQYMN//Y2cnM55tw0lOmLGbEETugP7alxTyhQHzNdTI= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c h1:yjGBNrCIE7IghJAwrFcyDzwzwJKf0oRPeOHx60wfkmA= +github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac h1:SjopLdUF96kdJU8ynYmGVHoJmngpwFHRvR5p2plBXG4= +github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac/go.mod h1:0mnotoJNdO4NPxld/GcrLppxwGWuMqgroWyu413oOvw= +github.com/matthewmueller/text v0.0.0-20201215225457-a00346c71bb3/go.mod h1:vtPaEU72VzARd4tSSzHIX7DddCEamoO2X4ozlrdmtNY= +github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8 h1:XTmVlF7P9bpSNkLFlxpNlhig0kaVJ5mO4D3yK2CYjmM= +github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8/go.mod h1:vtPaEU72VzARd4tSSzHIX7DddCEamoO2X4ozlrdmtNY= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237 h1:acuCHBjzG7MFTugvx3buC4m5rLDLaKC9J8C9jtlraRc= +github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237/go.mod h1:UOnLAUmVG5paym8pD3C4B9BQylUDC2vXFJJpT7JrlEA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f h1:OKYpQQVE3DKSc3r3zHVzq46vq5YH7x8xpR3/k9ixmUg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= diff --git a/example/zero/middleware/_session/middleware.go b/example/zero/middleware/_session/middleware.go new file mode 100644 index 00000000..d72ee843 --- /dev/null +++ b/example/zero/middleware/_session/middleware.go @@ -0,0 +1,29 @@ +package session + +import ( + "fmt" + "net/http" + + "github.com/livebud/bud/example/zero/bud/pkg/sessions" + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + Env *env.Env + Sessions *sessions.Store + // Sessions *session.Store + // Store session.Storage +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := m.Sessions.Load(r, "some_id") + if err != nil { + fmt.Println("error loading session", err) + } + fmt.Println("loaded session", session) + next.ServeHTTP(w, r) + // Save the session + fmt.Println("saving session", m.Env.Session.Key) + }) +} diff --git a/example/zero/middleware/csrf/middleware.go b/example/zero/middleware/csrf/middleware.go new file mode 100644 index 00000000..d303d79f --- /dev/null +++ b/example/zero/middleware/csrf/middleware.go @@ -0,0 +1,19 @@ +package csrf + +import ( + "fmt" + "net/http" + + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + Env *env.Env +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Println("checking csrf", m.Env.CSRF.Token) + next.ServeHTTP(w, r) + }) +} diff --git a/example/zero/middleware/wraprw/middleware.go b/example/zero/middleware/wraprw/middleware.go new file mode 100644 index 00000000..75da605a --- /dev/null +++ b/example/zero/middleware/wraprw/middleware.go @@ -0,0 +1,62 @@ +package wraprw + +import ( + "bytes" + "net/http" + + "github.com/felixge/httpsnoop" +) + +type Middleware struct { +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + rw := &responseWriter{ + code: 0, + body: new(bytes.Buffer), + } + return http.HandlerFunc(func(original http.ResponseWriter, r *http.Request) { + w := httpsnoop.Wrap(original, httpsnoop.Hooks{ + WriteHeader: func(_ httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc { + return rw.WriteHeader + }, + Write: func(_ httpsnoop.WriteFunc) httpsnoop.WriteFunc { + return rw.Write + }, + Flush: func(flush httpsnoop.FlushFunc) httpsnoop.FlushFunc { + rw.writeTo(original) + return flush + }, + }) + next.ServeHTTP(w, r) + rw.writeTo(original) + }) +} + +type responseWriter struct { + body *bytes.Buffer + code int + wrote bool +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.code = statusCode +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + return rw.body.Write(b) +} + +func (rw *responseWriter) writeTo(w http.ResponseWriter) { + // Only write status code once to avoid: + // "http: superfluous response.WriteHeader" + // Not concurrency safe. + if !rw.wrote { + if rw.code == 0 { + rw.code = http.StatusOK + } + w.WriteHeader(rw.code) + rw.wrote = true + } + rw.body.WriteTo(w) +} diff --git a/example/zero/middleware/wraprw/middleware_test.go b/example/zero/middleware/wraprw/middleware_test.go new file mode 100644 index 00000000..2c840b15 --- /dev/null +++ b/example/zero/middleware/wraprw/middleware_test.go @@ -0,0 +1,170 @@ +package wraprw_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/livebud/bud/example/zero/middleware/wraprw" + "github.com/matryer/is" +) + +func TestHeadersNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") +} + +func TestHeadersWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "B") +} + +func TestWriteStatusNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestWriteStatusWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "B") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestFlushNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + flush.Flush() + } + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestFlushWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world!")) + w.Header().Add("X-A", "A") + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + w.Write([]byte("yoyo")) + flush.Flush() + } + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!yoyo") +} + +func TestFlushStatusWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world!")) + w.WriteHeader(201) + w.Header().Add("X-A", "A") + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + w.Write([]byte("yoyo")) + flush.Flush() + } + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!yoyo") +} diff --git a/example/zero/mw/mw.go b/example/zero/mw/mw.go new file mode 100644 index 00000000..6aa2ce2d --- /dev/null +++ b/example/zero/mw/mw.go @@ -0,0 +1,30 @@ +package mw + +import ( + "net/http" + + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + *env.Env + // *sessions.Store +} + +// func (m *Middleware) Session(next http.Handler) http.Handler { +// fmt.Println(m.Store) +// // store := sessions.NewCookieStore([]byte(m.Env.Session.Key)) +// // _ = store +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// }) +// } + +func (m *Middleware) CSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} + +func (m *Middleware) WrapRW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} diff --git a/example/zero/session/session.go b/example/zero/session/session.go new file mode 100644 index 00000000..17de74f3 --- /dev/null +++ b/example/zero/session/session.go @@ -0,0 +1,28 @@ +package session + +import "time" + +type Session struct { + UserID *int + ID string `session:"id"` + IP string `session:"ip_address"` + UA string `session:"user_agent"` + LastSeen time.Time `session:"last_seen"` + Expires time.Time `session:"expires"` +} + +func (s *Session) Visitor() bool { + return s.UserID == nil +} + +// func LoadUser(db *sql.Database, session *Session) (*User, error) { +// if session.ID == nil { +// return nil, nil +// } +// // hypothetical database call +// return db.FindUserByID(*session.ID) +// } + +// type User struct { +// *table.User +// } diff --git a/example/zero/session/store.go b/example/zero/session/store.go new file mode 100644 index 00000000..3bc8a6e3 --- /dev/null +++ b/example/zero/session/store.go @@ -0,0 +1,31 @@ +package session + +import ( + "net/http" + "time" +) + +// type Storage interface { +// Find(id string) ([]byte, error) +// } + +func New(w http.ResponseWriter, r *http.Request) *Store { + return &Store{w, r} +} + +type Store struct { + w http.ResponseWriter + r *http.Request +} + +func (s *Store) Create(id string, payload []byte, expires time.Time) error { + return nil +} + +func (s *Store) Find(id string) ([]byte, error) { + return nil, nil +} + +func (s *Store) Delete(id string) error { + return nil +} diff --git a/example/zero/transpiler/goldmark/transpiler.go b/example/zero/transpiler/goldmark/transpiler.go new file mode 100644 index 00000000..31332ca2 --- /dev/null +++ b/example/zero/transpiler/goldmark/transpiler.go @@ -0,0 +1,26 @@ +package goldmark + +import ( + "bytes" + "fmt" + + "github.com/livebud/bud/package/log" + transpiler "github.com/livebud/bud/runtime/transpiler2" + "github.com/yuin/goldmark" +) + +// TODO: finish markdown transpiler + +type Transpiler struct { + Log log.Log +} + +func (t *Transpiler) MdToGohtml(file *transpiler.File) error { + t.Log.Info("transpiling:", file.Path()) + var html bytes.Buffer + if err := goldmark.Convert(file.Data, &html); err != nil { + return fmt.Errorf("goldmark markdown error: %w", err) + } + file.Data = html.Bytes() + return nil +} diff --git a/example/zero/transpiler/tailwind/tailwind.go b/example/zero/transpiler/tailwind/tailwind.go new file mode 100644 index 00000000..98017351 --- /dev/null +++ b/example/zero/transpiler/tailwind/tailwind.go @@ -0,0 +1,20 @@ +package tailwind + +import ( + "bytes" + + "github.com/livebud/bud/package/log" + transpiler "github.com/livebud/bud/runtime/transpiler2" +) + +// TODO: switch to markdown. tailwind is more of a custom web generator + +type Transpiler struct { + Log log.Log +} + +func (t *Transpiler) GohtmlToGohtml(file *transpiler.File) error { + t.Log.Info("transpiling", file.Path()) + file.Data = bytes.ReplaceAll(file.Data, []byte("h1"), []byte("h2")) + return nil +} diff --git a/example/zero/view/error.gohtml b/example/zero/view/error.gohtml new file mode 100644 index 00000000..d04292c8 --- /dev/null +++ b/example/zero/view/error.gohtml @@ -0,0 +1 @@ +

Got an error: {{ $.Message }}

\ No newline at end of file diff --git a/example/zero/view/layout.gohtml b/example/zero/view/layout.gohtml new file mode 100644 index 00000000..696d5743 --- /dev/null +++ b/example/zero/view/layout.gohtml @@ -0,0 +1,12 @@ + + + + + + + Weblog + + + {{ . }} + + diff --git a/example/zero/view/posts/frame.gohtml b/example/zero/view/posts/frame.gohtml new file mode 100644 index 00000000..e667abf9 --- /dev/null +++ b/example/zero/view/posts/frame.gohtml @@ -0,0 +1 @@ +
{{ . }}
\ No newline at end of file diff --git a/example/zero/view/posts/index.gohtml b/example/zero/view/posts/index.gohtml new file mode 100644 index 00000000..0181a3be --- /dev/null +++ b/example/zero/view/posts/index.gohtml @@ -0,0 +1,3 @@ +

{{ . }}

+ +
diff --git a/example/zero/view/posts/intro.md b/example/zero/view/posts/intro.md new file mode 100644 index 00000000..e10b99d0 --- /dev/null +++ b/example/zero/view/posts/intro.md @@ -0,0 +1 @@ +# Introduction diff --git a/example/zero/viewer/gohtml/viewer.go b/example/zero/viewer/gohtml/viewer.go new file mode 100644 index 00000000..838d91d0 --- /dev/null +++ b/example/zero/viewer/gohtml/viewer.go @@ -0,0 +1,185 @@ +package gohtml + +import ( + "bytes" + "context" + "fmt" + "html/template" + "io/fs" + "path" + + "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/router" + "github.com/livebud/bud/runtime/view" +) + +// TODO: may want a view.FS that is a wrapper around a fs.Sub(module, "view") +func New(log log.Log, tr transpiler.Transpiler) *Viewer { + return &Viewer{log, tr} +} + +type Viewer struct { + log log.Log + tr transpiler.Transpiler +} + +var _ view.Viewer = (*Viewer)(nil) + +func (v *Viewer) Register(r *router.Router, pages []*view.Page) { + +} + +func (v *Viewer) Mount(r *router.Router) error { + return nil +} + +func (v *Viewer) parseTemplate(fsys fs.FS, templatePath string) (*template.Template, error) { + // TODO: decide if we want to scope to the view path or module path + viewPath := path.Join("view", templatePath) + code, err := fs.ReadFile(fsys, viewPath) + if err != nil { + return nil, fmt.Errorf("unable to parse template %q. %w", templatePath, err) + } + // TODO: don't transpile when embedded + code, err = v.tr.Transpile(viewPath, ".gohtml", code) + if err != nil { + return nil, fmt.Errorf("gohtml unable to transpile %s: %w", viewPath, err) + } + tpl, err := template.New(templatePath).Parse(string(code)) + if err != nil { + return nil, err + } + return tpl, nil +} + +func (v *Viewer) render(ctx context.Context, fsys fs.FS, templatePath string, props interface{}) ([]byte, error) { + tpl, err := v.parseTemplate(fsys, templatePath) + if err != nil { + return nil, err + } + return render(ctx, tpl, props) +} + +func render(ctx context.Context, tpl *template.Template, props interface{}) ([]byte, error) { + out := new(bytes.Buffer) + // TODO: pass context through + if err := tpl.Execute(out, props); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func (v *Viewer) Render(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap) ([]byte, error) { + v.log.Info("rendering gohtml", page.Path) + html, err := v.render(ctx, fsys, page.Path, propMap[page.Key]) + if err != nil { + return nil, err + } + for _, frame := range page.Frames { + // TODO: support other props + html, err = v.render(ctx, fsys, frame.Path, template.HTML(html)) + if err != nil { + return nil, err + } + } + // TODO: support other props + return v.render(ctx, fsys, page.Layout.Path, template.HTML(html)) +} + +func (v *Viewer) RenderError(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap, originalError error) []byte { + v.log.Info("rendering gohtml", page.Error.Path) + errorEntry, err := v.parseTemplate(fsys, page.Error.Path) + if err != nil { + return []byte(fmt.Sprintf("unable to read error template %q to render error %s. %s", page.Error.Path, err, originalError)) + } + layout, err := v.parseTemplate(fsys, page.Layout.Path) + if err != nil { + return []byte(fmt.Sprintf("unable to parse layout template %q to render error %s. %s", page.Error.Path, err, originalError)) + } + state := errorState{ + Message: originalError.Error(), + } + html, err := render(ctx, errorEntry, state) + if err != nil { + return []byte(fmt.Sprintf("unable to render error template %q to render error %s. %s", page.Error.Path, err, originalError)) + } + html, err = render(ctx, layout, template.HTML(html)) + if err != nil { + return []byte(fmt.Sprintf("unable to render layout template %q to render error %s. %s", page.Error.Path, err, originalError)) + } + return html +} + +type errorState struct { + Message string +} + +func (v *Viewer) Bundle(ctx context.Context, fsys fs.FS, pages view.Pages, embeds view.Embeds) (err error) { + for _, page := range pages { + // TODO: decide if we want to scope to the view path or module path + pagePath := path.Join("view", page.Path) + // Embed the page + pageEmbed, err := v.embedView(fsys, pagePath) + if err != nil { + return err + } + embeds[pagePath] = pageEmbed + + // Embed the layout + if page.Layout != nil { + layoutPath := path.Join("view", page.Layout.Path) + if _, ok := embeds[layoutPath]; ok { + continue + } + layoutEmbed, err := v.embedView(fsys, layoutPath) + if err != nil { + return err + } + embeds[layoutPath] = layoutEmbed + } + + // Embed the frames + for _, frame := range page.Frames { + framePath := path.Join("view", frame.Path) + if _, ok := embeds[framePath]; ok { + continue + } + frameEmbed, err := v.embedView(fsys, framePath) + if err != nil { + return err + } + embeds[framePath] = frameEmbed + } + + // Embed the error + if page.Error != nil { + errorPath := path.Join("view", page.Error.Path) + if _, ok := embeds[errorPath]; ok { + continue + } + errorEmbed, err := v.embedView(fsys, errorPath) + if err != nil { + return err + } + embeds[errorPath] = errorEmbed + } + } + return nil +} + +func (v *Viewer) embedView(fsys fs.FS, viewPath string) (*view.Embed, error) { + code, err := fs.ReadFile(fsys, viewPath) + if err != nil { + return nil, err + } + // TODO: bring back pre-transpilation + // Sanity check that the transpiled code is valid + if _, err := template.New(viewPath).Parse(string(code)); err != nil { + return nil, fmt.Errorf("gohtml: unable to parse transpiled template %q. %w", viewPath, err) + } + return &view.Embed{ + Path: viewPath, + Data: code, + }, nil +} diff --git a/example/zero/web/web.go b/example/zero/web/web.go new file mode 100644 index 00000000..8b1fe32a --- /dev/null +++ b/example/zero/web/web.go @@ -0,0 +1,51 @@ +package web + +import ( + "net/http" + + "github.com/livebud/bud/example/zero/bud/pkg/sessions" + "github.com/livebud/bud/example/zero/bud/pkg/web/controller" + "github.com/livebud/bud/example/zero/bud/pkg/web/middleware" + "github.com/livebud/bud/example/zero/bud/pkg/web/view" + "github.com/livebud/bud/example/zero/env" + "github.com/livebud/bud/example/zero/mw" + "github.com/livebud/bud/package/router" +) + +type Web struct { + Env *env.Env + Controller *controller.Controller + Middleware *middleware.Middleware + MW *mw.Middleware + View *view.View +} + +// type Stack []func(next http.Handler) http.Handler + +// func (w *Web) Stack(s middleware.Stack) Stack { +// return Stack{ +// w.MW.WrapRW, +// w.MW.Session, +// w.MW.CSRF, +// } +// } + +func (w *Web) Stack(s middleware.Stack) middleware.Interface { + return append(s, w.Middleware, sessions.New(w.Env.Session.Key)) +} + +// TODO: use a router interface +func (w *Web) Router(r *router.Router) *router.Router { + // r.Get("/posts", w.Controller.Index) + r.Mount(w.Controller) + r.Mount(w.View.Posts.Intro) + return r +} + +func (w *Web) Handler(h http.Handler) http.Handler { + return h +} + +func (w *Web) Server(s *http.Server) *http.Server { + return s +} diff --git a/go.mod b/go.mod index c8096120..c8057fff 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/livebud/bud-test-plugin v0.0.9 github.com/livebud/js v0.0.0-20230416184227-ef633a94787b github.com/livebud/transpiler v0.0.3 + github.com/matryer/is v1.4.0 github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8 @@ -32,6 +33,7 @@ require ( github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 github.com/xlab/treeprint v1.1.0 go.kuoruan.net/v8go-polyfills v0.5.1-0.20220727011656-c74c5b408ebd + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 golang.org/x/sync v0.1.0 golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f diff --git a/go.sum b/go.sum index b31c3100..c6e616b6 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,7 @@ github.com/livebud/js v0.0.0-20230416184227-ef633a94787b/go.mod h1:Xkuu9hOX5C6DV github.com/livebud/transpiler v0.0.3 h1:OFKPsmfTOywBDoOE/ZFMVjAupk/xVXFlXoAgZNRhh5Q= github.com/livebud/transpiler v0.0.3/go.mod h1:vQYMN//Y2cnM55tw0lOmLGbEETugP7alxTyhQHzNdTI= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c h1:yjGBNrCIE7IghJAwrFcyDzwzwJKf0oRPeOHx60wfkmA= github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c/go.mod h1:xIxeV0n2W+WNB3ik/j9lUkcrQAk71VrvT9ch8u4pcvM= github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac h1:SjopLdUF96kdJU8ynYmGVHoJmngpwFHRvR5p2plBXG4= @@ -140,6 +141,8 @@ go.kuoruan.net/v8go-polyfills v0.5.1-0.20220727011656-c74c5b408ebd/go.mod h1:egH golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 37c52c2f..d53551c6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -22,7 +22,6 @@ import ( "github.com/livebud/bud/internal/dag" "github.com/livebud/bud/internal/extrafile" "github.com/livebud/bud/internal/prompter" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/internal/versions" "github.com/livebud/bud/package/budhttp/budsvr" "github.com/livebud/bud/package/commander" @@ -34,6 +33,7 @@ import ( "github.com/livebud/bud/package/log/console" "github.com/livebud/bud/package/log/levelfilter" "github.com/livebud/bud/package/parser" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/remotefs" "github.com/livebud/bud/package/socket" "github.com/livebud/bud/package/virtual" @@ -149,6 +149,17 @@ func (c *CLI) Parse(ctx context.Context, args ...string) error { cli.Run(func(ctx context.Context) error { return c.Generate(ctx, in) }) } + { // $ bud generate2 [packages...] + in := &Generate2{Flag: &framework.Flag{}} + cli := cli.Command("generate2", "generate bud packages").Hidden() + cli.Flag("listen-dev", "dev server address").String(&in.ListenDev).Default(":35729") + cli.Flag("embed", "embed assets").Bool(&in.Flag.Embed).Default(false) + cli.Flag("hot", "hot reloading").Bool(&in.Flag.Hot).Default(true) + cli.Flag("minify", "minify assets").Bool(&in.Flag.Minify).Default(false) + cli.Args("packages").Strings(&in.Packages) + cli.Run(func(ctx context.Context) error { return c.Generate2(ctx, in) }) + } + { // $ bud run in := &Run{Flag: &framework.Flag{}} cli := cli.Command("run", "run the app in development mode") diff --git a/internal/cli/generate2.go b/internal/cli/generate2.go new file mode 100644 index 00000000..8161fa60 --- /dev/null +++ b/internal/cli/generate2.go @@ -0,0 +1,579 @@ +package cli + +import ( + "context" + + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/parser" + + "github.com/livebud/bud/package/gotemplate" + + "github.com/livebud/bud/package/virtual" + + "github.com/livebud/bud/framework" + "github.com/livebud/bud/internal/dag" + "github.com/livebud/bud/package/genfs" +) + +type Generate2 struct { + Flag *framework.Flag + ListenDev string + Packages []string +} + +func (c *CLI) Generate2(ctx context.Context, in *Generate2) (err error) { + // Load the logger if not already provided + log, err := c.loadLog() + if err != nil { + return err + } + log = log.Field("method", "Generate2").Field("package", "cli") + + // Find the module if not already provided + module, err := c.findModule() + if err != nil { + return err + } + // cache, err := dag.Load(log, module.Directory("bud", "bud.db")) + // if err != nil { + // return err + // } + // defer cache.Close() + gen := genfs.New(dag.Discard, module, log) + parser := parser.New(gen, module) + injector := di.New(gen, log, module, parser) + gen.FileGenerator("bud/cmd/gen/main.go", &mainGenerator{injector, log, module}) + gen.FileGenerator("bud/internal/generator/generator.go", &generatorGenerator{log, module}) + gen.FileGenerator("bud/pkg/transpiler/transpiler.go", &transpilerGenerator{log, module}) + gen.FileGenerator("bud/pkg/viewer/viewer.go", &viewerGenerator{log, module}) + if err := virtual.Sync(log, gen, module, "bud"); err != nil { + return err + } + + // Build bud/gen + cmd := c.command(module.Directory(), "go", "build", "-mod=mod", "-o=bud/gen", "./bud/cmd/gen") + if err := cmd.Run(); err != nil { + return err + } + + // Run bud/gen + cmd = c.command(module.Directory(), "./bud/gen", in.Flag.Flags()...) + if err := cmd.Run(); err != nil { + return err + } + + // Run bud/app + // TODO: this should be moved into `bud run` + cmd = c.command(module.Directory(), "./bud/app") + if err := cmd.Run(); err != nil { + return err + } + + return nil +} + +type mainGenerator struct { + injector *di.Injector + log log.Log + module *gomod.Module +} + +const mainTemplate = `package main + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func main() { + gen.Main({{ $.Provider.Name }}) +} + +{{ $.Provider.Function }} +` + +var mainGen = gotemplate.MustParse("bud/cmd/gen/main.go", mainTemplate) + +func (g *mainGenerator) GenerateFile(fsys genfs.FS, file *genfs.File) error { + g.log.Info("generating file", file.Path()) + type State struct { + Imports []*imports.Import + Provider *di.Provider + } + imset := imports.New() + imset.AddNamed("gen", "github.com/livebud/bud/runtime/gen") + provider, err := g.injector.Wire(&di.Function{ + Name: "loadGenerator", + Imports: imset, + Params: []*di.Param{ + &di.Param{ + Import: "github.com/livebud/bud/framework", + Type: "*Flag", + }, + &di.Param{ + Import: "github.com/livebud/bud/package/gomod", + Type: "*Module", + }, + &di.Param{ + Import: "github.com/livebud/bud/package/log", + Type: "Log", + }, + &di.Param{ + Import: "github.com/livebud/bud/package/genfs", + Type: "FileSystem", + }, + }, + Aliases: di.Aliases{ + di.ToType("github.com/livebud/bud/package/parser", "*Parser"): di.ToType("github.com/livebud/bud/runtime/gen", "*Parser"), + di.ToType("github.com/livebud/bud/package/di", "*Injector"): di.ToType("github.com/livebud/bud/runtime/gen", "*Injector"), + }, + Results: []di.Dependency{ + di.ToType(g.module.Import("bud/internal/generator"), "*Generator"), + &di.Error{}, + }, + }) + if err != nil { + return err + } + code, err := mainGen.Generate(State{ + Imports: imset.List(), + Provider: provider, + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +type generatorGenerator struct { + log log.Log + module *gomod.Module +} + +const generatorTemplate = `package generator + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func NewGenerator( + genfs generator.FileSystem, + log log.Log, + {{- range $generator := $.Generators }} + {{ $generator.Camel }} *{{ $generator.Import.Name }}.{{ $generator.Type }}, + {{- end }} +) *Generator { + return generator.NewGenerator( + genfs, + log, + {{- range $generator := $.Generators }} + {{ $generator.Camel }}, + {{- end }} + ) +} + +type Generator = generator.Generator +` + +var generatorGen = gotemplate.MustParse("generator.go", generatorTemplate) + +func (g *generatorGenerator) GenerateFile(fsys genfs.FS, file *genfs.File) error { + g.log.Info("generating file", file.Path()) + type Generator struct { + Import *imports.Import + Path string // Path that triggers the generator (e.g. "bud/cmd/app/main.go") + Camel string + Type string + } + type State struct { + Imports []*imports.Import + Generators []*Generator + } + imset := imports.New() + // imset.AddStd("fmt") + imset.AddNamed("generator", "github.com/livebud/bud/runtime/generator") + // imset.AddNamed("gomod", "github.com/livebud/bud/package/gomod") + imset.AddNamed("log", "github.com/livebud/bud/package/log") + appImportPath := g.module.Import("generator/app") + commandImportPath := g.module.Import("generator/command") + webImportPath := g.module.Import("generator/web") + controllerImportPath := g.module.Import("generator/controller") + viewImportPath := g.module.Import("generator/view") + envImportPath := g.module.Import("generator/env") + middlewareImportPath := g.module.Import("generator/middleware") + sessionImportPath := g.module.Import("generator/session") + generators := []*Generator{ + { + Import: &imports.Import{ + Name: imset.Add(appImportPath), + Path: appImportPath, + }, + Path: "bud", + Camel: "app", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(commandImportPath), + Path: commandImportPath, + }, + Path: "bud", + Camel: "command", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(webImportPath), + Path: webImportPath, + }, + Path: "bud", + Camel: "web", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(controllerImportPath), + Path: controllerImportPath, + }, + Path: "bud", + Camel: "controller", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(viewImportPath), + Path: viewImportPath, + }, + Path: "bud", + Camel: "view", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(envImportPath), + Path: envImportPath, + }, + Path: "bud", + Camel: "env", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(middlewareImportPath), + Path: middlewareImportPath, + }, + Path: "bud", + Camel: "middleware", + Type: "Generator", + }, + { + Import: &imports.Import{ + Name: imset.Add(sessionImportPath), + Path: sessionImportPath, + }, + Path: "bud", + Camel: "session", + Type: "Generator", + }, + } + + code, err := generatorGen.Generate(State{ + Imports: imset.List(), + Generators: generators, + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +type transpilerGenerator struct { + log log.Log + module *gomod.Module +} + +const transpilerTemplate = `package transpiler + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +// Load the transpiler +func Load( + {{- range $transpiler := $.Transpilers }} + {{ $transpiler.Camel }} *{{ $transpiler.Import.Name }}.Transpiler, + {{- end }} +) Transpiler { + tr := transpiler.New() + {{- range $transpiler := $.Transpilers }} + {{- range $method := $transpiler.Methods }} + tr.Add("{{ $method.From }}", "{{ $method.To }}", {{ $transpiler.Camel }}.{{ $method.Pascal }}) + {{- end }} + {{- end }} + return &proxy{tr} +} + +type Transpiler = transpiler.Interface + +type proxy struct { + Transpiler +} + +func (p *proxy) Transpile(fromExt, toExt string, code []byte) ([]byte, error) { + transpiled, err := p.Transpiler.Transpile(fromExt, toExt, code) + if err != nil { + if !errors.Is(err, transpiler.ErrNoPath) { + return nil, err + } + return code, nil + } + return transpiled, nil +} +` + +var transpilerGen = gotemplate.MustParse("transpiler.go", transpilerTemplate) + +func (g *transpilerGenerator) GenerateFile(fsys genfs.FS, file *genfs.File) error { + g.log.Info("generating file", file.Path()) + type Method struct { + Pascal string // Method name in pascal + From string // From extension + To string // To extension + } + type Transpiler struct { + Import *imports.Import + Camel string + Methods []*Method + } + type State struct { + Imports []*imports.Import + Transpilers []*Transpiler + } + imset := imports.New() + imset.AddStd("errors") + imset.AddNamed("transpiler", "github.com/livebud/bud/runtime/transpiler2") + tailwindImport := g.module.Import("transpiler/tailwind") + goldmarkImport := g.module.Import("transpiler/goldmark") + code, err := transpilerGen.Generate(State{ + Transpilers: []*Transpiler{ + { + Import: &imports.Import{ + Name: imset.Add(tailwindImport), + Path: tailwindImport, + }, + Camel: "tailwind", + Methods: []*Method{ + { + Pascal: "GohtmlToGohtml", + From: ".gohtml", + To: ".gohtml", + }, + }, + }, + { + Import: &imports.Import{ + Name: imset.Add(goldmarkImport), + Path: goldmarkImport, + }, + Camel: "goldmark", + Methods: []*Method{ + { + Pascal: "MdToGohtml", + From: ".md", + To: ".gohtml", + }, + }, + }, + }, + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +type viewerGenerator struct { + log log.Log + module *gomod.Module +} + +const viewerTemplate = `package viewer + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +// Load the viewer +// TODO: turn most of this code into a runtime library +func New( + transpiler transpiler.Transpiler, + {{- range $viewer := $.Viewers }} + {{ $viewer.Camel }} *{{ $viewer.Import.Name }}.Viewer, + {{- end }} +) Viewer { + return &viewer{ + transpiler: transpiler, + viewers: map[string]view.Viewer{ + {{- range $viewer := $.Viewers }} + "{{ $viewer.Ext }}": {{ $viewer.Camel }}, + {{- end }} + }, + accepts: []string{ + {{- range $viewer := $.Viewers }} + "{{ $viewer.Ext }}", + {{- end }} + }, + } +} + +type Viewer = view.Viewer + +type viewer struct { + transpiler transpiler.Transpiler + viewers map[string]view.Viewer + accepts []string +} + +var _ Viewer = (*viewer)(nil) + +func (v *viewer) Register(r *router.Router, pages []*view.Page) { + for _, viewer := range v.viewers { + viewer.Register(r, pages) + } +} + +func (v *viewer) Render(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap) ([]byte, error) { + viewer, ok := v.viewers[page.Ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return nil, fmt.Errorf("viewer: unable to render %q. %w", page.Path, err) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + return nil, fmt.Errorf("viewer: unable to find acceptable viewer to render %q", page.Path) +} + +func (v *viewer) RenderError(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap, err error) []byte { + viewer, ok := v.viewers[page.Error.Ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Error.Ext, v.accepts) + if err != nil { + msg := fmt.Sprintf("viewer: unable to find extension to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + msg := fmt.Sprintf("viewer: unable to find acceptable viewer to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) +} + +func (v *viewer) Bundle(ctx context.Context, fsys fs.FS, pages view.Pages, embeds view.Embeds) error { + exts := map[string]map[string]*view.Page{} + for _, page := range pages { + if _, ok := v.viewers[page.Ext]; ok { + if exts[page.Ext] == nil { + exts[page.Ext] = map[string]*view.Page{} + } + exts[page.Ext][page.Path] = page + continue + } + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return fmt.Errorf("viewer: unable find viewer to bundle %q. %w", page.Path, err) + } + if exts[ext] == nil { + exts[ext] = map[string]*view.Page{} + } + exts[ext][page.Path] = page + } + // TODO: consider parallelizing this + for ext, pages := range exts { + viewer, ok := v.viewers[ext] + if !ok { + return fmt.Errorf("viewer: unable find viewer for %q", ext) + } + if err := viewer.Bundle(ctx, fsys, pages, embeds); err != nil { + return fmt.Errorf("viewer: unable to bundle %q. %w", ext, err) + } + } + return nil +} +` + +var viewerGen = gotemplate.MustParse("viewer.go", viewerTemplate) + +func (g *viewerGenerator) GenerateFile(fsys genfs.FS, file *genfs.File) error { + g.log.Info("generating viewer", file.Path()) + type Viewer struct { + Import *imports.Import + Ext string + Camel string + Pascal string + } + type State struct { + Imports []*imports.Import + Viewers []*Viewer + } + imset := imports.New() + imset.AddStd("context", "fmt", "io/fs") + imset.Add("github.com/livebud/bud/runtime/view") + imset.Add("github.com/livebud/bud/package/router") + imset.Add(g.module.Import("bud/pkg/transpiler")) + gohtmlPath := g.module.Import("viewer/gohtml") + code, err := viewerGen.Generate(State{ + Viewers: []*Viewer{ + { + Import: &imports.Import{ + Name: imset.Add(gohtmlPath), + Path: gohtmlPath, + }, + Ext: ".gohtml", + Camel: "gohtml", + Pascal: "Gohtml", + }, + }, + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} diff --git a/internal/testcli/testcli.go b/internal/testcli/testcli.go index 50ca2c9e..9d0bc6cc 100644 --- a/internal/testcli/testcli.go +++ b/internal/testcli/testcli.go @@ -18,7 +18,7 @@ import ( "github.com/lithammer/dedent" "github.com/livebud/bud/internal/errs" "github.com/livebud/bud/internal/once" - "github.com/livebud/bud/internal/pubsub" + "github.com/livebud/bud/package/pubsub" "github.com/matthewmueller/diff" "github.com/livebud/bud/package/hot" diff --git a/internal/versions/align.go b/internal/versions/align.go index 17333d1f..b7a87b80 100644 --- a/internal/versions/align.go +++ b/internal/versions/align.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" + "github.com/livebud/bud/internal/current" "github.com/livebud/bud/package/gomod" ) @@ -19,7 +20,7 @@ func AlignRuntime(ctx context.Context, module *gomod.Module, budVersion string) return nil } // Best effort attempt to replace bud with the latest version. - budModule, err := gomod.FindBudModule() + budModule, err := findBudModule() if err != nil { return nil } @@ -57,3 +58,11 @@ func AlignRuntime(ctx context.Context, module *gomod.Module, budVersion string) } return nil } + +func findBudModule() (*gomod.Module, error) { + dirname, err := current.Directory() + if err != nil { + return nil, err + } + return gomod.Find(dirname) +} diff --git a/package/budhttp/budsvr/handler.go b/package/budhttp/budsvr/handler.go index e100349d..28f3e911 100644 --- a/package/budhttp/budsvr/handler.go +++ b/package/budhttp/budsvr/handler.go @@ -15,7 +15,7 @@ import ( "github.com/livebud/bud/package/log" "github.com/livebud/bud/package/virtual" - "github.com/livebud/bud/internal/pubsub" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/js" "github.com/livebud/bud/package/router" diff --git a/package/budhttp/budsvr/server.go b/package/budhttp/budsvr/server.go index 3e505fee..5e7b2748 100644 --- a/package/budhttp/budsvr/server.go +++ b/package/budhttp/budsvr/server.go @@ -8,9 +8,9 @@ import ( "net/http" "github.com/livebud/bud/framework" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/js" "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/pubsub" "golang.org/x/sync/errgroup" ) diff --git a/package/budhttp/budsvr/server_test.go b/package/budhttp/budsvr/server_test.go index 181e6c1a..b2c7582e 100644 --- a/package/budhttp/budsvr/server_test.go +++ b/package/budhttp/budsvr/server_test.go @@ -7,10 +7,10 @@ import ( "github.com/livebud/bud/framework" "github.com/livebud/bud/internal/is" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/budhttp/budsvr" v8 "github.com/livebud/bud/package/js/v8" "github.com/livebud/bud/package/log/testlog" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/socket" "github.com/livebud/bud/package/virtual" "golang.org/x/sync/errgroup" diff --git a/package/budhttp/client_test.go b/package/budhttp/client_test.go index 7eb79063..91cb2685 100644 --- a/package/budhttp/client_test.go +++ b/package/budhttp/client_test.go @@ -17,11 +17,11 @@ import ( "github.com/livebud/bud/framework/view/nodemodules" "github.com/livebud/bud/framework/view/ssr" "github.com/livebud/bud/internal/is" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/budhttp" "github.com/livebud/bud/package/budhttp/budsvr" "github.com/livebud/bud/package/gomod" v8 "github.com/livebud/bud/package/js/v8" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/svelte" "github.com/livebud/bud/package/testdir" ) diff --git a/package/cipher/cipher.go b/package/cipher/cipher.go new file mode 100644 index 00000000..841f9e04 --- /dev/null +++ b/package/cipher/cipher.go @@ -0,0 +1,49 @@ +package cipher + +import ( + "crypto/rand" + "errors" + "io" +) + +var ErrDecrypting = errors.New("unable to decrypt") + +type Cipher interface { + Encrypt(plaintext []byte) ([]byte, error) + Decrypt(ciphertext []byte) ([]byte, error) +} + +func Random(b []byte) error { + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return err + } + return nil +} + +func Rotator(latest Cipher, priors ...Cipher) Cipher { + return &rotator{append([]Cipher{latest}, priors...)} +} + +type rotator struct { + ciphers []Cipher +} + +// Decrypt with any past or present cipher +func (r *rotator) Decrypt(ciphertext []byte) ([]byte, error) { + for _, c := range r.ciphers { + plaintext, err := c.Decrypt(ciphertext) + if err != nil { + if !errors.Is(err, ErrDecrypting) { + return nil, err + } + continue + } + return plaintext, nil + } + return nil, ErrDecrypting +} + +// Encrypt with the latest cipher +func (r *rotator) Encrypt(plaintext []byte) ([]byte, error) { + return r.ciphers[0].Encrypt(plaintext) +} diff --git a/package/cipher/cipher_test.go b/package/cipher/cipher_test.go new file mode 100644 index 00000000..e23db7e0 --- /dev/null +++ b/package/cipher/cipher_test.go @@ -0,0 +1,15 @@ +package cipher_test + +import "testing" + +func TestRotatorLatest(t *testing.T) { + +} + +func TestRotatorPast(t *testing.T) { + +} + +func TestRotatorNotOk(t *testing.T) { + +} diff --git a/package/cipher/secretbox/secretbox.go b/package/cipher/secretbox/secretbox.go new file mode 100644 index 00000000..e5e1fbdc --- /dev/null +++ b/package/cipher/secretbox/secretbox.go @@ -0,0 +1,47 @@ +package secretbox + +import ( + "fmt" + + "github.com/livebud/bud/package/cipher" + "golang.org/x/crypto/nacl/secretbox" +) + +func New(secret [32]byte) *Cipher { + return &Cipher{ + secret: &secret, + Random: cipher.Random, + } +} + +type Cipher struct { + secret *[32]byte + Random func([]byte) error +} + +var _ cipher.Cipher = (*Cipher)(nil) + +// Encrypt the plaintext using the secret key. +func (c *Cipher) Encrypt(plaintext []byte) (ciphertext []byte, err error) { + // You must use a different nonce for each message you encrypt with the + // same key. Since the nonce here is 192 bits long, a random value + // provides a sufficiently small probability of repeats. + var nonce [24]byte + if err := c.Random(nonce[:]); err != nil { + return nil, err + } + // This encrypts "hello world" and appends the result to the nonce. + ciphertext = secretbox.Seal(nonce[:], plaintext, &nonce, c.secret) + return ciphertext, nil +} + +// Decrypt the ciphertext using the secret key. +func (c *Cipher) Decrypt(ciphertext []byte) (plaintext []byte, err error) { + var nonce [24]byte + copy(nonce[:], ciphertext[:24]) + plaintext, ok := secretbox.Open(nil, ciphertext[24:], &nonce, c.secret) + if !ok { + return nil, fmt.Errorf("secretbox: %w", cipher.ErrDecrypting) + } + return plaintext, nil +} diff --git a/package/cipher/secretbox/secretbox_test.go b/package/cipher/secretbox/secretbox_test.go new file mode 100644 index 00000000..f519599f --- /dev/null +++ b/package/cipher/secretbox/secretbox_test.go @@ -0,0 +1,78 @@ +package secretbox_test + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "io" + "testing" + + "github.com/livebud/bud/internal/is" + "github.com/livebud/bud/package/cipher" + "github.com/livebud/bud/package/cipher/secretbox" +) + +func random(nonce []byte) error { + copy(nonce[:], "2uVCNEZYf83uovM8aAVmNDgUdP294Ape") + return nil +} + +func generate() (secret [32]byte, err error) { + if _, err := io.ReadFull(rand.Reader, secret[:]); err != nil { + return [32]byte{}, err + } + return secret, nil +} + +func TestOk(t *testing.T) { + is := is.New(t) + secret, err := generate() + is.NoErr(err) + sb := secretbox.New(secret) + sb.Random = random + input := []byte("10") + ciphertext, err := sb.Encrypt(input) + is.NoErr(err) + plaintext, err := sb.Decrypt(ciphertext) + is.NoErr(err) + is.Equal(string(plaintext), string(input)) +} + +var secretKey = [32]byte{ + 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + 0x13, 0x89, +} + +func TestCipherText(t *testing.T) { + is := is.New(t) + sb := secretbox.New(secretKey) + sb.Random = random + input := []byte("10") + ciphertext, err := sb.Encrypt(input) + is.NoErr(err) + b64 := base64.StdEncoding.EncodeToString(ciphertext) + is.Equal("MnVWQ05FWllmODN1b3ZNOGFBVm1ORGdVB4vQmn+Qj9A8ldDGJhIIH5s3", b64) + plaintext, err := sb.Decrypt(ciphertext) + is.NoErr(err) + is.Equal(string(plaintext), string(input)) +} + +func TestNotOk(t *testing.T) { + is := is.New(t) + secret, err := generate() + is.NoErr(err) + sb := secretbox.New(secret) + sb.Random = random + input := []byte("10") + ciphertext, err := sb.Encrypt(input) + is.NoErr(err) + secret2, err := generate() + is.NoErr(err) + sb2 := secretbox.New(secret2) + plaintext, err := sb2.Decrypt(ciphertext) + is.True(err != nil) + is.True(errors.Is(err, cipher.ErrDecrypting)) + is.Equal(plaintext, nil) +} diff --git a/package/cookies/cookies.go b/package/cookies/cookies.go new file mode 100644 index 00000000..4d82bace --- /dev/null +++ b/package/cookies/cookies.go @@ -0,0 +1,73 @@ +package cookies + +import ( + "encoding/base64" + "fmt" + "net/http" + + "github.com/livebud/bud/package/cipher" +) + +type Store interface { + Get(r *http.Request, name string) (*http.Cookie, error) + Set(w http.ResponseWriter, cookie *http.Cookie) error +} + +func Default() Store { + return &defaultStore{} +} + +type defaultStore struct{} + +func (defaultStore) Get(r *http.Request, name string) (*http.Cookie, error) { + return r.Cookie(name) +} + +func (defaultStore) Set(w http.ResponseWriter, cookie *http.Cookie) error { + http.SetCookie(w, cookie) + return nil +} + +// Secure cookie store +func Secure(c cipher.Cipher) Store { + return &secure{c} +} + +// secure cookie store +type secure struct { + c cipher.Cipher +} + +func (s *secure) Get(r *http.Request, name string) (*http.Cookie, error) { + cookie, err := r.Cookie(name) + if err != nil { + fmt.Println("cookie session: error getting cookie", name, err) + return nil, err + } + ciphertext, err := base64.RawURLEncoding.DecodeString(cookie.Value) + if err != nil { + fmt.Println("cookie session: error decoding cookie", name, err) + return nil, err + } + plaintext, err := s.c.Decrypt(ciphertext) + if err != nil { + return nil, err + } + cookie.Value = string(plaintext) + return cookie, err +} + +func (s *secure) Set(w http.ResponseWriter, cookie *http.Cookie) error { + // plaintext, err := base64.RawURLEncoding.DecodeString(cookie.Value) + // if err != nil { + // fmt.Println("cookie session: error decoding cookie", err) + // return err + // } + ciphertext, err := s.c.Encrypt([]byte(cookie.Value)) + if err != nil { + return err + } + cookie.Value = base64.RawURLEncoding.EncodeToString(ciphertext) + http.SetCookie(w, cookie) + return nil +} diff --git a/package/cookies/secure.go b/package/cookies/secure.go new file mode 100644 index 00000000..770818d0 --- /dev/null +++ b/package/cookies/secure.go @@ -0,0 +1,15 @@ +package cookies + +// Secure cookie store +// func Secure(c cipher.Cipher) Jar { +// // return &secure{c} +// } + +// func Secure(cipher Cipher, store Store) Store { +// return &SecureCookie{ +// cipher: cipher, +// store: store, +// } +// } + +// // type SecureCookie diff --git a/package/genfs/dir.go b/package/genfs/dir.go index ecff9e23..0f8427f2 100644 --- a/package/genfs/dir.go +++ b/package/genfs/dir.go @@ -16,7 +16,7 @@ type Dir struct { tree *tree } -var _ Generators = (*Dir)(nil) +var _ Directory = (*Dir)(nil) func (d *Dir) Target() string { return d.target diff --git a/package/genfs/genfs.go b/package/genfs/genfs.go index 21460970..2c9a4a59 100644 --- a/package/genfs/genfs.go +++ b/package/genfs/genfs.go @@ -10,13 +10,8 @@ import ( "github.com/livebud/bud/package/virtual" ) -type FileSystem interface { - fs.FS - fs.ReadDirFS - Generators -} - -type Generators interface { +// Directory is the interface for adding file generators and directories +type Directory interface { GenerateFile(path string, fn func(fsys FS, file *File) error) FileGenerator(path string, generator FileGenerator) GenerateDir(path string, fn func(fsys FS, dir *Dir) error) @@ -27,12 +22,34 @@ type Generators interface { ExternalGenerator(path string, generator ExternalGenerator) } +// // Extension allows you to extend the filesystem with more generators +// type Extension interface { +// Extend(g Directory) +// } + +// FileSystem allows you to create and use a generator filesystem +type FileSystem interface { + fs.FS + fs.ReadDirFS + Directory + // Sub(dir string) FileSystem + // Extend(extensions ...Extension) FileSystem + // Sync(to virtual.FS, subdirs ...string) error + // Copy(to virtual.FS, subdirs ...string) error +} + type Cache interface { Get(path string) (*virtual.File, error) Set(path string, file *virtual.File) error Link(from string, toPatterns ...string) error } +type discardCache struct{} + +func (discardCache) Get(path string) (*virtual.File, error) { return nil, errors.New("not found") } +func (discardCache) Set(path string, file *virtual.File) error { return nil } +func (discardCache) Link(from string, toPatterns ...string) error { return nil } + type FS interface { fs.FS fs.ReadDirFS @@ -45,6 +62,9 @@ type generator interface { } func New(cache Cache, fsys fs.FS, log log.Log) FileSystem { + if cache == nil { + cache = discardCache{} + } return &fileSystem{cache, fsys, log, newTree()} } @@ -57,6 +77,14 @@ type fileSystem struct { var _ FileSystem = (*fileSystem)(nil) +// // Extend the filesystem with more generators +// func (f *fileSystem) Extend(extensions ...Extension) FileSystem { +// for _, extension := range extensions { +// extension.Extend(f) +// } +// return f +// } + func (f *fileSystem) GenerateFile(path string, fn func(fsys FS, file *File) error) { fileg := &fileGenerator{f.cache, fn, f, path} f.tree.Insert(path, modeGen, fileg) @@ -167,6 +195,82 @@ func (f *fileSystem) ReadDir(target string) ([]fs.DirEntry, error) { return deset.List(), nil } +// func (f *fileSystem) Sync(to virtual.FS, subdirs ...string) error { +// return virtual.Sync(f.log, f, to, subdirs...) +// } + +// func (f *fileSystem) Copy(to virtual.FS, subdirs ...string) error { +// return virtual.Copy(f.log, f, to, subdirs...) +// } + +// func (f *fileSystem) Sub(dir string) FileSystem { +// return &subSystem{dir, f, f.log} +// } + +// type subSystem struct { +// dir string +// fsys FileSystem +// log log.Log +// } + +// var _ FileSystem = (*subSystem)(nil) + +// func (s *subSystem) Open(name string) (fs.File, error) { +// return s.fsys.Open(path.Join(s.dir, name)) +// } + +// func (s *subSystem) ReadDir(name string) ([]fs.DirEntry, error) { +// return s.fsys.ReadDir(path.Join(s.dir, name)) +// } + +// func (s *subSystem) GenerateFile(subpath string, fn func(fsys FS, file *File) error) { +// s.fsys.GenerateFile(path.Join(s.dir, subpath), fn) +// } + +// func (s *subSystem) FileGenerator(subpath string, generator FileGenerator) { +// s.fsys.FileGenerator(path.Join(s.dir, subpath), generator) +// } + +// func (s *subSystem) GenerateDir(subdir string, fn func(fsys FS, dir *Dir) error) { +// s.fsys.GenerateDir(path.Join(s.dir, subdir), fn) +// } + +// func (s *subSystem) DirGenerator(subdir string, generator DirGenerator) { +// s.fsys.DirGenerator(path.Join(s.dir, subdir), generator) +// } + +// func (s *subSystem) ServeFile(subdir string, fn func(fsys FS, file *File) error) { +// s.fsys.ServeFile(path.Join(s.dir, subdir), fn) +// } + +// func (s *subSystem) FileServer(subdir string, server FileServer) { +// s.fsys.FileServer(path.Join(s.dir, subdir), server) +// } + +// func (s *subSystem) GenerateExternal(subpath string, fn func(fsys FS, file *External) error) { +// s.fsys.GenerateExternal(path.Join(s.dir, subpath), fn) +// } + +// func (s *subSystem) ExternalGenerator(subpath string, generator ExternalGenerator) { +// s.fsys.ExternalGenerator(path.Join(s.dir, subpath), generator) +// } + +// func (s *subSystem) Sub(dir string) FileSystem { +// return &subSystem{path.Join(s.dir, dir), s.fsys, s.log} +// } + +// func (s *subSystem) Extend(extensions ...Extension) FileSystem { +// return s.fsys.Extend(extensions...) +// } + +// func (s *subSystem) Sync(to virtual.FS, subdirs ...string) error { +// return virtual.Sync(s.log, s, to, subdirs...) +// } + +// func (s *subSystem) Copy(to virtual.FS, subdirs ...string) error { +// return virtual.Copy(s.log, s, to, subdirs...) +// } + func relativePath(base, target string) string { rel := strings.TrimPrefix(target, base) if rel == "" { diff --git a/package/gomod/mod.go b/package/gomod/mod.go index 83a60b6e..355b1724 100644 --- a/package/gomod/mod.go +++ b/package/gomod/mod.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" - "github.com/livebud/bud/internal/current" "github.com/livebud/bud/package/modcache" "github.com/livebud/bud/package/virtual" "golang.org/x/mod/modfile" @@ -139,15 +138,6 @@ func Absolute(dir string) (abs string, err error) { return filepath.Abs(dir) } -// FindBudModule finds the go.mod for bud itself -func FindBudModule() (*Module, error) { - dirname, err := current.Directory() - if err != nil { - return nil, err - } - return Find(dirname) -} - func absolute(dir string) (abs string, err error) { path := filepath.Join(dir, "go.mod") // Check if this path exists, otherwise recursively traverse towards root diff --git a/package/hot/hot_test.go b/package/hot/hot_test.go index 2c8d0921..8d3b8336 100644 --- a/package/hot/hot_test.go +++ b/package/hot/hot_test.go @@ -11,9 +11,9 @@ import ( "golang.org/x/sync/errgroup" "github.com/livebud/bud/internal/is" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/hot" "github.com/livebud/bud/package/log/testlog" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/socket" ) diff --git a/package/hot/server.go b/package/hot/server.go index 054db2b6..e6456389 100644 --- a/package/hot/server.go +++ b/package/hot/server.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/pubsub" ) // New server-sent event (SSE) server diff --git a/package/middleware/middleware.go b/package/middleware/middleware.go index beb4332a..6106a250 100644 --- a/package/middleware/middleware.go +++ b/package/middleware/middleware.go @@ -19,6 +19,8 @@ func (fn Function) Middleware(next http.Handler) http.Handler { // Stack of middleware type Stack []Middleware +var _ Middleware = Stack{} + // Middleware fn func (stack Stack) Middleware(next http.Handler) http.Handler { return Compose(stack...).Middleware(next) diff --git a/internal/pubsub/discard.go b/package/pubsub/discard.go similarity index 100% rename from internal/pubsub/discard.go rename to package/pubsub/discard.go diff --git a/internal/pubsub/pubsub.go b/package/pubsub/pubsub.go similarity index 100% rename from internal/pubsub/pubsub.go rename to package/pubsub/pubsub.go diff --git a/internal/pubsub/pubsub_test.go b/package/pubsub/pubsub_test.go similarity index 95% rename from internal/pubsub/pubsub_test.go rename to package/pubsub/pubsub_test.go index d97b4039..b4bc7b89 100644 --- a/internal/pubsub/pubsub_test.go +++ b/package/pubsub/pubsub_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/livebud/bud/internal/is" - "github.com/livebud/bud/internal/pubsub" + "github.com/livebud/bud/package/pubsub" "golang.org/x/sync/errgroup" ) diff --git a/package/router/router.go b/package/router/router.go index 82bfb119..0bf60a46 100644 --- a/package/router/router.go +++ b/package/router/router.go @@ -22,15 +22,15 @@ type Router struct { var _ http.Handler = (*Router)(nil) -// Add a handler to a route -func (rt *Router) Add(method, route string, handler http.Handler) error { +// Set a handler manually +func (rt *Router) Set(method, route string, handler http.Handler) error { if !isMethod(method) { return fmt.Errorf("router: %q is not a valid HTTP method", method) } - return rt.add(method, route, handler) + return rt.set(method, route, handler) } -func (rt *Router) add(method, route string, handler http.Handler) error { +func (rt *Router) set(method, route string, handler http.Handler) error { if route == "/" { return rt.insert(method, route, handler) } @@ -49,27 +49,27 @@ func (rt *Router) insert(method, route string, handler http.Handler) error { // Get route func (rt *Router) Get(route string, handler http.Handler) error { - return rt.add(http.MethodGet, route, handler) + return rt.set(http.MethodGet, route, handler) } // Post route func (rt *Router) Post(route string, handler http.Handler) error { - return rt.add(http.MethodPost, route, handler) + return rt.set(http.MethodPost, route, handler) } // Put route func (rt *Router) Put(route string, handler http.Handler) error { - return rt.add(http.MethodPut, route, handler) + return rt.set(http.MethodPut, route, handler) } // Patch route func (rt *Router) Patch(route string, handler http.Handler) error { - return rt.add(http.MethodPatch, route, handler) + return rt.set(http.MethodPatch, route, handler) } // Delete route func (rt *Router) Delete(route string, handler http.Handler) error { - return rt.add(http.MethodDelete, route, handler) + return rt.set(http.MethodDelete, route, handler) } func (rt *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -106,6 +106,14 @@ func (rt *Router) Middleware(next http.Handler) http.Handler { }) } +type Mount interface { + Mount(router *Router) error +} + +func (rt *Router) Mount(m Mount) error { + return m.Mount(rt) +} + func trimTrailingSlash(path string) string { if path == "/" { return path diff --git a/package/router/router_test.go b/package/router/router_test.go index 1443af43..4ee38609 100644 --- a/package/router/router_test.go +++ b/package/router/router_test.go @@ -60,7 +60,7 @@ func ok(t testing.TB, test *test) { case http.MethodDelete: err = router.Delete(route.route, handler(route.route)) default: - err = router.Add(route.method, route.route, handler(route.route)) + err = router.Set(route.method, route.route, handler(route.route)) } if err != nil { is.Equal(route.err, err.Error()) @@ -222,10 +222,10 @@ func TestPut(t *testing.T) { is.Equal("id=10", string(body)) } -func TestAdd(t *testing.T) { +func TestSet(t *testing.T) { is := is.New(t) router := router.New() - is.NoErr(router.Add(http.MethodHead, "/:id", handler("/:id"))) + is.NoErr(router.Set(http.MethodHead, "/:id", handler("/:id"))) req := httptest.NewRequest(http.MethodHead, "/10", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) diff --git a/package/session/session.go b/package/session/session.go new file mode 100644 index 00000000..eac119b5 --- /dev/null +++ b/package/session/session.go @@ -0,0 +1,59 @@ +package session + +import ( + "errors" + "net/http" + "time" + + "github.com/livebud/bud/package/cookies" +) + +var ErrNotFound = errors.New("session not found") + +type Store interface { + Get(id string) ([]byte, error) + Set(id string, payload []byte, expires time.Time) error + Delete(id string) error +} + +// New cookie store +func New(cs cookies.Store, w http.ResponseWriter, r *http.Request) *CookieStore { + return &CookieStore{cs, r, w} +} + +// CookieStore is a cookie store +type CookieStore struct { + cs cookies.Store + r *http.Request + w http.ResponseWriter +} + +var _ Store = (*CookieStore)(nil) + +func (c *CookieStore) Get(id string) ([]byte, error) { + cookie, err := c.cs.Get(c.r, id) + if err != nil { + if errors.Is(err, http.ErrNoCookie) { + return nil, ErrNotFound + } + return nil, err + } + return []byte(cookie.Value), nil +} + +func (c *CookieStore) Set(id string, payload []byte, expires time.Time) error { + cookie := &http.Cookie{ + Name: id, + Value: string(payload), + Expires: time.Now().Add(24 * time.Hour), + } + return c.cs.Set(c.w, cookie) +} + +func (c *CookieStore) Delete(id string) error { + cookie := &http.Cookie{ + Name: id, + MaxAge: -1, + } + return c.cs.Set(c.w, cookie) +} diff --git a/package/transpiler/transpiler.go b/package/transpiler/transpiler.go index e4bb29d1..622c914c 100644 --- a/package/transpiler/transpiler.go +++ b/package/transpiler/transpiler.go @@ -111,13 +111,6 @@ func (t *Transpiler) Best(fromExt string, accepts []string) (toExt string, err e return t.best(fromExt, accepts) } -// Candidate is a candidate extension we can transpile to with the number of -// hops it would take to get there. -// type candidate struct { -// Ext string -// Hops int -// } - func (t *Transpiler) best(fromExt string, accepts []string) (string, error) { fromID, ok := t.ids[fromExt] if !ok { diff --git a/package/virtual/sync.go b/package/virtual/sync.go index 171ff1ba..45a3e768 100644 --- a/package/virtual/sync.go +++ b/package/virtual/sync.go @@ -13,6 +13,7 @@ import ( ) // Sync files from one filesystem to another at subpath +// TODO switch to distinct subpaths (e.g. cmd/gen & internal/gen) func Sync(log log.Log, from fs.FS, to FS, subpaths ...string) error { log.Debug("virtual: syncing") target := path.Join(subpaths...) @@ -20,8 +21,7 @@ func Sync(log log.Log, from fs.FS, to FS, subpaths ...string) error { target = "." } now := time.Now() - - ops, err := diff(log, from, to, target) + ops, err := diff(log, from, to, ".") if err != nil { return err } diff --git a/package/virtual/sync_test.go b/package/virtual/sync_test.go index 01687960..1164197b 100644 --- a/package/virtual/sync_test.go +++ b/package/virtual/sync_test.go @@ -364,3 +364,28 @@ func TestSyncDeleteNotExist(t *testing.T) { is.True(errors.Is(err, fs.ErrNotExist)) is.Equal(data, nil) } + +func TestDeleteFile(t *testing.T) { + is := is.New(t) + log := testlog.New() + // starting points + to := virtual.Tree{ + "bud/cmd/app/main.go": &virtual.File{Data: []byte("package main")}, + "bud/cmd/app/random.txt": &virtual.File{Data: []byte("random")}, + } + excluded := virtual.Exclude(to, func(path string) bool { + return strings.HasPrefix(path, "bud") + }) + gen := genfs.New(dag.Discard, excluded, log) + gen.FileGenerator("bud/cmd/app/main.go", &genfs.Embed{ + Data: []byte("package main"), + }) + err := virtual.Sync(log, gen, to, "bud") + is.NoErr(err) + data, err := fs.ReadFile(to, "bud/cmd/app/main.go") + is.NoErr(err) + is.Equal(string(data), "package main") + data, err = fs.ReadFile(to, "bud/cmd/app/random.txt") + is.True(errors.Is(err, fs.ErrNotExist)) + is.Equal(data, nil) +} diff --git a/runtime/gen/gen.go b/runtime/gen/gen.go new file mode 100644 index 00000000..7bc4510c --- /dev/null +++ b/runtime/gen/gen.go @@ -0,0 +1,126 @@ +package gen + +import ( + "context" + "os" + "os/exec" + "path" + "strings" + + "github.com/livebud/bud/package/log/console" + "github.com/livebud/bud/package/log/levelfilter" + + "github.com/livebud/bud/framework" + "github.com/livebud/bud/package/commander" + "github.com/livebud/bud/package/virtual" + + "github.com/livebud/bud/internal/dag" + + "github.com/livebud/bud/package/genfs" + + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/parser" + "github.com/livebud/bud/runtime/generator" +) + +type loadFn = func(*framework.Flag, genfs.FileSystem, *gomod.Module, log.Log) (*generator.Generator, error) + +func ProvideParser(genfs genfs.FileSystem, module *gomod.Module) *Parser { + return parser.New(genfs, module) +} + +type Parser = parser.Parser + +func ProvideInjector(genfs genfs.FileSystem, log log.Log, module *gomod.Module, parser *Parser) *Injector { + return di.New(genfs, log, module, parser) +} + +type Injector = di.Injector + +func Main(load loadFn) { + ctx := context.Background() + if err := run(ctx, load, os.Args[1:]); err != nil { + console.Error(err) + os.Exit(1) + } +} + +func run(ctx context.Context, load loadFn, args []string) error { + cmd := &Generate{new(framework.Flag), "info", load} + cli := commander.New("gen", "generator") + cli.Flag("embed", "embed assets").Bool(&cmd.flag.Embed).Default(false) + cli.Flag("hot", "hot reloading").Bool(&cmd.flag.Hot).Default(true) + cli.Flag("minify", "minify assets").Bool(&cmd.flag.Minify).Default(false) + cli.Flag("log", "filter logs with this pattern").Short('L').String(&cmd.lvl).Default("info") + cli.Run(cmd.Run) + return cli.Parse(ctx, args...) +} + +// Generate command +type Generate struct { + flag *framework.Flag + lvl string + load loadFn +} + +// Run the generator +func (g *Generate) Run(ctx context.Context) error { + lvl, err := log.ParseLevel(g.lvl) + if err != nil { + return err + } + log := log.New(levelfilter.New(console.New(os.Stderr), lvl)) + module, err := gomod.Find(".") + if err != nil { + return err + } + fsys := virtual.Exclude(module, exclude) + gen := genfs.New(dag.Discard, fsys, log) + generator, err := g.load(g.flag, gen, module, log) + if err != nil { + return err + } + // Generate the application packages like bud/cmd/app/main.go + if err := generator.Generate(module, "bud"); err != nil { + return err + } + // Build bud/cmd/app + cmd := exec.Command("go", "build", "-mod=mod", "-o=bud/app", "./bud/cmd/app") + cmd.Dir = module.Directory() + cmd.Env = os.Environ() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + return nil +} + +// Avoid deleting files that were synced or built earlier +func exclude(path string) bool { + if isGenPath(path) || isBudChild(path) { + return false + } else if isBudPath(path) { + return true + } + return false +} + +// Exclude everything in bud/ +func isBudPath(p string) bool { + return strings.HasPrefix(p, "bud/") +} + +func isBudChild(p string) bool { + return path.Dir(p) == "bud" +} + +func isGenPath(p string) bool { + return strings.HasPrefix(p, "bud/cmd/gen") || + strings.HasPrefix(p, "bud/internal/generator") || + strings.HasPrefix(p, "bud/pkg/transpiler") || + strings.HasPrefix(p, "bud/pkg/viewer") +} diff --git a/runtime/generator/generator.go b/runtime/generator/generator.go new file mode 100644 index 00000000..715302ba --- /dev/null +++ b/runtime/generator/generator.go @@ -0,0 +1,94 @@ +package generator + +import ( + "github.com/livebud/bud/package/genfs" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/virtual" +) + +type FS = genfs.FS +type File = genfs.File +type Dir = genfs.Dir +type GenerateFile = genfs.GenerateFile +type GenerateDir = genfs.GenerateDir +type FileSystem = genfs.FileSystem + +type Extension interface { + Extend(genfs FileSystem) +} + +func NewGenerator(genfs genfs.FileSystem, log log.Log, extensions ...Extension) *Generator { + for _, extension := range extensions { + extension.Extend(genfs) + } + return &Generator{genfs, log} +} + +type Generator struct { + from genfs.FileSystem + log log.Log +} + +// Generate files to the bud/ directory +func (g *Generator) Generate(to virtual.FS, subdirs ...string) error { + return virtual.Sync(g.log, g.from, to, subdirs...) +} + +// type Generator struct { +// from genfs.FileSystem +// } + +// // type Schema struct { +// // GenerateFiles map[string]GenerateFile +// // GenerateDirs map[string]GenerateDir +// // } + +// // func New(genfs, extensions ...Extension) genfs.FileSystem { +// // // Genfs combines both the generators and the real filesystem files into one +// // // virtual filesystem. We don't want to the virtual filesystem to know about +// // // the bud/ files because otherwise it will never cleanup bud/ files that +// // // were once generated but are no longer in the generators. +// // // +// // // There's one subtly though. We want to keep the gen files, like +// // // bud/cmd/gen/main.go, so we don't exclude those. +// // dirfs := virtual.Exclude(module, func(path string) bool { +// // return exclude(path) +// // }) +// // gen := genfs.New(dag.Discard, dirfs, log) +// // gen.Extend(extensions...) +// // return gen +// // } + +// func exclude(path string) bool { +// if isGenPath(path) || isBudChild(path) { +// return false +// } else if isBudPath(path) { +// return true +// } +// return false +// } + +// // Exclude everything in bud/ +// func isBudPath(p string) bool { +// return strings.HasPrefix(p, "bud/") +// } + +// func isBudChild(p string) bool { +// return path.Dir(p) == "bud" +// } + +// func isGenPath(p string) bool { +// return strings.HasPrefix(p, "bud/cmd/gen") || +// strings.HasPrefix(p, "bud/internal/gen") || +// strings.HasPrefix(p, "bud/pkg/gen") +// } + +// // type FileSystem struct { +// // gen genfs.FileSystem +// // log log.Log +// // module *gomod.Module +// // } + +// // func (g *FileSystem) Sync() error { +// // return virtual.Sync(g.log, g.gen, g.module, "bud") +// // } diff --git a/runtime/transpiler/transpiler.go b/runtime/transpiler/transpiler.go index 51071a1d..1eff0223 100644 --- a/runtime/transpiler/transpiler.go +++ b/runtime/transpiler/transpiler.go @@ -15,6 +15,8 @@ import ( type File = transpiler.File type Interface = transpiler.Interface +var ErrNoPath = transpiler.ErrNoPath + func New() *Transpiler { return transpiler.New() } diff --git a/runtime/transpiler2/transpiler.go b/runtime/transpiler2/transpiler.go new file mode 100644 index 00000000..b0397954 --- /dev/null +++ b/runtime/transpiler2/transpiler.go @@ -0,0 +1,222 @@ +package transpiler + +import ( + "errors" + "fmt" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/RyanCarrier/dijkstra" +) + +// ErrNoPath is returned when there is no path between two extensions. +var ErrNoPath = dijkstra.ErrNoPath + +type File struct { + base string + ext string + Data []byte +} + +// Path returns the current file path that's being transpiled. +func (f *File) Path() string { + return f.base + f.ext +} + +// Interface for transpiling and testing if you can transpile from one extension +// to another. This interface is read-only. If you'd like to add extensions, use +// the Transpiler struct. +type Interface interface { + Best(fromExt string, accepts []string) (string, error) + Transpile(fromPath, toExt string, code []byte) ([]byte, error) +} + +func New() *Transpiler { + return &Transpiler{ + ids: map[string]int{}, + exts: map[int]string{}, + fns: map[string][]func(file *File) error{}, + graph: dijkstra.NewGraph(), + } +} + +// Transpiler is a generic multi-step tool for transpiling code from one +// language to another. +type Transpiler struct { + ids map[string]int // ext -> id + exts map[int]string // id -> ext + fns map[string][]func(file *File) error // map["ext>ext"][]fns + + mu sync.RWMutex + graph *dijkstra.Graph +} + +var _ Interface = (*Transpiler)(nil) + +// edgekey returns a key for the edge between two extensions. +// (e.g. edgeKey("svelte", "html") => "svelte>html") +func edgeKey(fromExt, toExt string) string { + return fromExt + ">" + toExt +} + +// Add a tranpile function to go from one extension to another. +func (t *Transpiler) Add(fromExt, toExt string, transpile func(file *File) error) { + t.mu.Lock() + defer t.mu.Unlock() + t.add(fromExt, toExt, transpile) +} + +func (t *Transpiler) add(fromExt, toExt string, transpile func(file *File) error) { + // Add the "from" extension to the graph + if _, ok := t.ids[fromExt]; !ok { + id := len(t.ids) + t.ids[fromExt] = id + t.exts[id] = fromExt + t.graph.AddVertex(id) + } + edge := edgeKey(fromExt, toExt) + // If the "from" and "to" extensions are the same, add the function and return + if fromExt == toExt { + t.fns[edge] = append(t.fns[edge], transpile) + return + } + // Add the "to" extension to the graph + if _, ok := t.ids[toExt]; !ok { + id := len(t.ids) + t.ids[toExt] = id + t.exts[id] = toExt + t.graph.AddVertex(id) + } + // Add the edge with a cost of 1 + t.graph.AddArc(t.ids[fromExt], t.ids[toExt], 1) + // Add the function to a list of transpilers + t.fns[edge] = append(t.fns[edge], transpile) +} + +func (t *Transpiler) Path(fromExt, toExt string) (hops []string, err error) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.path(fromExt, toExt) +} + +// Best returns the best extension to transpile to from the given extension +func (t *Transpiler) Best(fromExt string, accepts []string) (toExt string, err error) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.best(fromExt, accepts) +} + +// Candidate is a candidate extension we can transpile to with the number of +// hops it would take to get there. +// type candidate struct { +// Ext string +// Hops int +// } + +func (t *Transpiler) best(fromExt string, accepts []string) (string, error) { + fromID, ok := t.ids[fromExt] + if !ok { + return "", fmt.Errorf("transpiler: %w for %q", ErrNoPath, fromExt) + } + buckets := map[int][]string{} + for _, id := range t.ids { + if id == fromID { + continue + } + best, err := t.graph.Shortest(fromID, id) + if err != nil { + if errors.Is(err, ErrNoPath) { + continue + } + return "", fmt.Errorf("transpiler: unable to get shorted path for %q. %w", fromExt, err) + } + distance := int(best.Distance) + buckets[distance] = append(buckets[distance], t.exts[id]) + } + distances := []int{} + for distance := range buckets { + distances = append(distances, distance) + } + sort.Ints(distances) + hops := [][]string{} + for _, distance := range distances { + hops = append(hops, buckets[distance]) + } + // Within each hop bucket, look for the most acceptable extension + for _, exts := range hops { + for _, accept := range accepts { + for _, ext := range exts { + if ext == accept { + return ext, nil + } + } + } + } + return "", fmt.Errorf("transpiler: no acceptable path for %q. %w", fromExt, ErrNoPath) +} + +// Path to go from one extension to another. +func (t *Transpiler) path(fromExt, toExt string) (hops []string, err error) { + if fromExt == toExt { + return []string{fromExt}, nil + } + if _, ok := t.ids[fromExt]; !ok { + return nil, fmt.Errorf("transpiler: %w from %q to %q", ErrNoPath, fromExt, toExt) + } + if _, ok := t.ids[toExt]; !ok { + return nil, fmt.Errorf("transpiler: %w from %q to %q", ErrNoPath, fromExt, toExt) + } + best, err := t.graph.Shortest(t.ids[fromExt], t.ids[toExt]) + if err != nil { + return nil, fmt.Errorf("transpiler: %w", err) + } + for _, id := range best.Path { + hops = append(hops, t.exts[id]) + } + return hops, nil +} + +func (t *Transpiler) Transpile(fromPath, toExt string, code []byte) ([]byte, error) { + t.mu.Lock() + defer t.mu.Unlock() + return t.transpile(fromPath, toExt, code) +} + +// Transpile the code from one extension to another. +func (t *Transpiler) transpile(fromPath, toExt string, code []byte) ([]byte, error) { + fromExt := filepath.Ext(fromPath) + // Find the shortest path + hops, err := t.path(fromExt, toExt) + if err != nil { + return nil, err + } + // Create the file + file := &File{ + base: strings.TrimSuffix(fromPath, filepath.Ext(fromPath)), + ext: fromExt, + Data: code, + } + // For each hop run the functions + for i, ext := range hops { + // Call the transition functions (e.g. svelte => html) + if i > 0 { + prevExt := hops[i-1] + edge := edgeKey(prevExt, ext) + for _, fn := range t.fns[edge] { + if err := fn(file); err != nil { + return nil, err + } + } + } + file.ext = ext + // Call the loops (e.g. svelte => svelte) + for _, fn := range t.fns[edgeKey(ext, ext)] { + if err := fn(file); err != nil { + return nil, err + } + } + } + return file.Data, nil +} diff --git a/runtime/transpiler2/transpiler_test.go b/runtime/transpiler2/transpiler_test.go new file mode 100644 index 00000000..60c8d914 --- /dev/null +++ b/runtime/transpiler2/transpiler_test.go @@ -0,0 +1,252 @@ +package transpiler_test + +import ( + "bytes" + "errors" + "strings" + "testing" + + transpiler "github.com/livebud/bud/runtime/transpiler2" + "github.com/matryer/is" +) + +func TestTranspileSvelteToJSX(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + paths := []string{} + trace := []string{} + tr.Add(".svelte", ".jsx", func(file *transpiler.File) error { + paths = append(paths, file.Path()) + trace = append(trace, "svelte->jsx") + file.Data = []byte(`export default function() { return ` + string(file.Data) + ` }`) + return nil + }) + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + paths = append(paths, file.Path()) + trace = append(trace, "svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + result, err := tr.Transpile("hello.svelte", ".jsx", []byte("

hi world

")) + is.NoErr(err) + is.Equal(string(result), `export default function() { return

hi world

}`) + is.Equal(strings.Join(trace, " "), "svelte->svelte svelte->jsx") + is.Equal(strings.Join(paths, " "), "hello.svelte hello.svelte") + hops, err := tr.Path(".svelte", ".jsx") + is.NoErr(err) + is.Equal(len(hops), 2) + is.Equal(hops[0], ".svelte") + is.Equal(hops[1], ".jsx") +} + +func TestSvelteSvelte(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + trace := []string{} + paths := []string{} + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + paths = append(paths, file.Path()) + trace = append(trace, "svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + result, err := tr.Transpile("hello.svelte", ".svelte", []byte("

hi world

")) + is.NoErr(err) + is.Equal(string(result), `

hi world

`) + is.Equal(strings.Join(trace, " "), "svelte->svelte") + is.Equal(strings.Join(paths, " "), "hello.svelte") + hops, err := tr.Path(".svelte", ".svelte") + is.NoErr(err) + is.Equal(len(hops), 1) + is.Equal(hops[0], ".svelte") +} + +func TestNoExt(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + result, err := tr.Transpile("hello.svelte", ".jsx", []byte("

hi world

")) + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(result, nil) + hops, err := tr.Path(".svelte", ".jsx") + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(hops, nil) +} + +func TestNoPath(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + tr.Add(".jsx", ".jsx", func(file *transpiler.File) error { + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + result, err := tr.Transpile("hello.svelte", ".jsx", []byte("

hi world

")) + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(result, nil) + hops, err := tr.Path(".svelte", ".jsx") + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(hops, nil) +} + +func TestMultiStep(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + trace := []string{} + path := []string{} + tr.Add(".jsx", ".jsx", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "jsx->jsx") + file.Data = []byte("/* some prelude */ " + string(file.Data)) + return nil + }) + tr.Add(".svelte", ".jsx", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "svelte->jsx") + file.Data = []byte(`export default function() { return ` + string(file.Data) + ` }`) + return nil + }) + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "1:svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "2:svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + tr.Add(".md", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "md->svelte") + file.Data = bytes.TrimPrefix(file.Data, []byte("# ")) + file.Data = []byte("

" + string(file.Data) + "

") + return nil + }) + result, err := tr.Transpile("hello.md", ".jsx", []byte("# hi world")) + is.NoErr(err) + is.Equal(strings.Join(trace, " "), "md->svelte 1:svelte->svelte 2:svelte->svelte svelte->jsx jsx->jsx") + is.Equal(strings.Join(path, " "), "hello.md hello.svelte hello.svelte hello.svelte hello.jsx") + is.Equal(string(result), `/* some prelude */ export default function() { return

hi world

}`) + hops, err := tr.Path(".md", ".jsx") + is.NoErr(err) + is.Equal(len(hops), 3) + is.Equal(hops[0], ".md") + is.Equal(hops[1], ".svelte") + is.Equal(hops[2], ".jsx") +} + +func TestTranspileSSRJS(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + tr.Add(".svelte", ".ssr.js", func(file *transpiler.File) error { + file.Data = []byte(`module.exports = "` + string(file.Data) + `"`) + return nil + }) + code, err := tr.Transpile("hello.svelte", ".ssr.js", []byte("

hello

")) + is.NoErr(err) + is.Equal(string(code), `module.exports = "

hello

"`) + code, err = tr.Transpile("hello.svelte", ".ssr.js", []byte("

world

")) + is.NoErr(err) + is.Equal(string(code), `module.exports = "

world

"`) +} + +func TestTranspileNoPath(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + code, err := tr.Transpile("hello.jsx", ".ssr.js", []byte("

hello

")) + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(code, nil) +} + +func TestTranspileBest(t *testing.T) { + is := is.New(t) + tr := transpiler.New() + trace := []string{} + path := []string{} + tr.Add(".jsx", ".js", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "jsx->js") + file.Data = []byte("// " + string(file.Data)) + return nil + }) + tr.Add(".jsx", ".jsx", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "jsx->jsx") + file.Data = []byte("/* some prelude */ " + string(file.Data)) + return nil + }) + tr.Add(".svelte", ".jsx", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "svelte->jsx") + file.Data = []byte(`export default function() { return ` + string(file.Data) + ` }`) + return nil + }) + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "1:svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + tr.Add(".svelte", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "2:svelte->svelte") + file.Data = []byte("
" + string(file.Data) + "
") + return nil + }) + tr.Add(".md", ".svelte", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "md->svelte") + file.Data = bytes.TrimPrefix(file.Data, []byte("# ")) + file.Data = []byte("

" + string(file.Data) + "

") + return nil + }) + tr.Add(".md", ".jsx", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "md->jsx") + file.Data = bytes.TrimPrefix(file.Data, []byte("# ")) + file.Data = []byte("

" + string(file.Data) + "

") + return nil + }) + tr.Add(".png", ".jpg", func(file *transpiler.File) error { + path = append(path, file.Path()) + trace = append(trace, "png->jpg") + file.Data = []byte("jpg") + return nil + }) + // Prefer jsx because same hops as svelte + ext, err := tr.Best(".md", []string{".jsx", ".svelte", ".js"}) + is.NoErr(err) + is.Equal(ext, ".jsx") + // Prefer svelte because same hops as jsx + ext, err = tr.Best(".md", []string{".svelte", ".jsx", ".js"}) + is.NoErr(err) + is.Equal(ext, ".svelte") + // Svelte has less hops than JS + ext, err = tr.Best(".md", []string{".js", ".svelte", ".jsx"}) + is.NoErr(err) + is.Equal(ext, ".svelte") + + ext, err = tr.Best(".scss", []string{".css"}) + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(ext, "") + + ext, err = tr.Best(".jpg", []string{".min.jpg"}) + is.True(err != nil) + is.True(errors.Is(err, transpiler.ErrNoPath)) + is.Equal(ext, "") +} diff --git a/runtime/view/find.go b/runtime/view/find.go new file mode 100644 index 00000000..33e61380 --- /dev/null +++ b/runtime/view/find.go @@ -0,0 +1,102 @@ +package view + +import ( + "io/fs" + "path" + "path/filepath" +) + +// Find pages +func Find(fsys fs.FS) (pages map[Key]*Page, err error) { + pages = make(map[Key]*Page) + inherited := &inherited{ + Layout: make(map[ext]*View), + Frames: make(map[ext][]*View), + Error: make(map[ext]*View), + } + if err := find(fsys, pages, inherited, "."); err != nil { + return nil, err + } + return pages, nil +} + +type ext = string + +type inherited struct { + Layout map[ext]*View + Frames map[ext][]*View + Error map[ext]*View +} + +func find(fsys fs.FS, pages map[Key]*Page, inherited *inherited, dir string) error { + des, err := fs.ReadDir(fsys, dir) + if err != nil { + return err + } + + // First pass: look for layouts, frames and errors + for _, de := range des { + if de.IsDir() { + continue + } + ext := filepath.Ext(de.Name()) + extless := de.Name()[:len(de.Name())-len(ext)] + switch extless { + case "layout": + inherited.Layout[ext] = &View{ + Path: "./" + path.Join(dir, de.Name()), + Key: path.Join(dir, extless), + Ext: ext, + } + case "frame": + inherited.Frames[ext] = append(inherited.Frames[ext], &View{ + Path: "./" + path.Join(dir, de.Name()), + Key: path.Join(dir, extless), + Ext: ext, + }) + case "error": + inherited.Error[ext] = &View{ + Path: "./" + path.Join(dir, de.Name()), + Key: path.Join(dir, extless), + Ext: ext, + } + } + } + + // Second pass: go through pages + for _, de := range des { + if de.IsDir() { + continue + } + ext := filepath.Ext(de.Name()) + extless := de.Name()[:len(de.Name())-len(ext)] + switch extless { + case "layout", "frame", "error": + continue + default: + key := path.Join(dir, extless) + pages[key] = &Page{ + View: &View{ + Path: "./" + path.Join(dir, de.Name()), + Key: key, + Ext: ext, + }, + Layout: inherited.Layout[ext], + Frames: inherited.Frames[ext], + Error: inherited.Error[ext], + } + } + } + + // Third pass: go through directories + for _, de := range des { + if !de.IsDir() { + continue + } + if err := find(fsys, pages, inherited, de.Name()); err != nil { + return err + } + } + + return nil +} diff --git a/runtime/view/find_test.go b/runtime/view/find_test.go new file mode 100644 index 00000000..7dd04b02 --- /dev/null +++ b/runtime/view/find_test.go @@ -0,0 +1,55 @@ +package view_test + +import ( + "testing" + "testing/fstest" + + "github.com/livebud/bud/internal/is" + "github.com/livebud/bud/runtime/view" +) + +func TestIndex(t *testing.T) { + is := is.New(t) + fsys := fstest.MapFS{ + "index.gohtml": &fstest.MapFile{Data: []byte("Hello {{ .Planet }}!")}, + } + // Find the pages + pages, err := view.Find(fsys) + is.NoErr(err) + is.Equal(len(pages), 1) + is.True(pages["index"] != nil) + is.Equal(pages["index"].Path, "./index.gohtml") + is.Equal(len(pages["index"].Frames), 0) + is.Equal(pages["index"].Layout, nil) + is.Equal(pages["index"].Error, nil) +} + +func TestNested(t *testing.T) { + is := is.New(t) + fsys := fstest.MapFS{ + "layout.svelte": &fstest.MapFile{Data: []byte(``)}, + "frame.svelte": &fstest.MapFile{Data: []byte(``)}, + "posts/frame.svelte": &fstest.MapFile{Data: []byte(``)}, + "posts/index.svelte": &fstest.MapFile{Data: []byte(`

Hello {planet}!

`)}, + } + // Find the pages + pages, err := view.Find(fsys) + is.NoErr(err) + is.Equal(len(pages), 1) + is.True(pages["posts/index"] != nil) + is.Equal(pages["posts/index"].Path, "./posts/index.svelte") + + // Frames + is.Equal(len(pages["posts/index"].Frames), 2) + is.Equal(pages["posts/index"].Frames[0].Key, "frame") + is.Equal(pages["posts/index"].Frames[0].Path, "./frame.svelte") + is.Equal(pages["posts/index"].Frames[1].Key, "posts/frame") + is.Equal(pages["posts/index"].Frames[1].Path, "./posts/frame.svelte") + + is.Equal(pages["posts/index"].Error, nil) + + // Layout + is.True(pages["posts/index"].Layout != nil) + is.Equal(pages["posts/index"].Layout.Key, "layout") + is.Equal(pages["posts/index"].Layout.Path, "./layout.svelte") +} diff --git a/runtime/view/view.go b/runtime/view/view.go new file mode 100644 index 00000000..862496ad --- /dev/null +++ b/runtime/view/view.go @@ -0,0 +1,65 @@ +package view + +import ( + "context" + "errors" + "io/fs" + "path" + + "github.com/livebud/bud/package/router" + "github.com/livebud/bud/package/virtual" +) + +var ErrViewerNotFound = errors.New("viewer not found") +var ErrPageNotFound = errors.New("page not found") + +type Key = string +type Ext = string +type PropMap = map[Key]interface{} +type ReadFS = fs.FS + +// Interface for bud/internal/web/view +type Interface interface { + Mount(r *router.Router) error + Render(ctx context.Context, key string, propMap PropMap) ([]byte, error) + RenderError(ctx context.Context, key string, propMap PropMap, err error) []byte +} + +type View struct { + Key Key + Path string + Ext string +} + +// Client is the standard route for specific views. This is typically used for +// hot reloading individual views. +func (v *View) Client() string { + return "/bud/" + path.Clean(v.Path) + ".js" +} + +type Page struct { + *View // Entry + Frames []*View + Layout *View + Error *View +} + +// Client is the standard entry route for pages that need a client +func (p *Page) Client() string { + return "/bud/" + path.Clean(p.View.Path) + ".entry.js" +} + +type Embed = virtual.File +type Embeds = map[string]*Embed + +type Viewer interface { + Register(r *router.Router, pages []*Page) + Render(ctx context.Context, fsys fs.FS, page *Page, propMap PropMap) ([]byte, error) + RenderError(ctx context.Context, fsys fs.FS, page *Page, propMap PropMap, err error) []byte + Bundle(ctx context.Context, fsys fs.FS, pages Pages, embed Embeds) error +} + +type Viewers map[Ext]Viewer +type Pages map[Key]*Page +type ViewerPages map[Ext][]*Page +type PageViewer map[Key]Ext diff --git a/scripts/svelte/main.go b/scripts/svelte/main.go index 4b6104c6..5372a471 100644 --- a/scripts/svelte/main.go +++ b/scripts/svelte/main.go @@ -8,12 +8,12 @@ import ( "github.com/livebud/bud/framework" "github.com/livebud/bud/internal/current" - "github.com/livebud/bud/internal/pubsub" "github.com/livebud/bud/package/es" "github.com/livebud/bud/package/gomod" "github.com/livebud/bud/package/hot" "github.com/livebud/bud/package/log" "github.com/livebud/bud/package/log/console" + "github.com/livebud/bud/package/pubsub" "github.com/livebud/bud/package/router" "github.com/livebud/bud/package/transpiler" "github.com/livebud/bud/package/viewer"