// Command wasmgen preprocesses component directives into glue code so that // server-component calls look identical to client-component calls, and routes // can opt into SSR with a tag. // // Directives (as doc comments on functions in the target package): // // //gowasm:page [static] [layout=] // a page factory `func(Deps) func() *vdom.VNode`. // `static` => the server SSRs that route; // `layout=` => wrap it in a named //gowasm:layout. // //gowasm:layout a layout `func(Deps, *vdom.VNode) *vdom.VNode`. // //gowasm:server a server component `func() func() *vdom.VNode`. // // It emits three files in the package: // - routes.gen.go (neutral) Routes(Deps) + StaticPaths + RouteLayout/LayoutFor // - server.gen.go (native) init() registering each server component // - client.gen.go (wasm) a client stub per server component that // mounts it over /rsc package main import ( "fmt" "go/ast" "go/format" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" "text/template" ) type page struct { Func string Path string Static bool Layout string // name of the //gowasm:layout wrapper (empty => default) } type layoutDecl struct { Name string Func string } type genData struct { Pages []page Servers []string Layouts []layoutDecl DefaultLayout string // func name used when a page declares no layout } func main() { dir := "app" if len(os.Args) > 1 { dir = os.Args[1] } fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { return !strings.HasSuffix(fi.Name(), ".gen.go") }, parser.ParseComments) if err != nil { fmt.Fprintln(os.Stderr, "wasmgen: parse:", err) os.Exit(1) } var data genData for _, pkg := range pkgs { for _, file := range pkg.Files { for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Doc == nil { continue } for _, c := range fn.Doc.List { line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) switch { case strings.HasPrefix(line, "gowasm:page "): fields := strings.Fields(line[len("gowasm:page "):]) if len(fields) == 0 { continue } p := page{Func: fn.Name.Name, Path: fields[0]} for _, f := range fields[1:] { switch { case f == "static": p.Static = true case strings.HasPrefix(f, "layout="): p.Layout = strings.TrimPrefix(f, "layout=") } } data.Pages = append(data.Pages, p) case strings.HasPrefix(line, "gowasm:layout "): fields := strings.Fields(line[len("gowasm:layout "):]) if len(fields) == 0 { continue } data.Layouts = append(data.Layouts, layoutDecl{Name: fields[0], Func: fn.Name.Name}) case line == "gowasm:server": data.Servers = append(data.Servers, fn.Name.Name) } } } } } sort.Slice(data.Pages, func(i, j int) bool { return data.Pages[i].Path < data.Pages[j].Path }) sort.Strings(data.Servers) sort.Slice(data.Layouts, func(i, j int) bool { return data.Layouts[i].Name < data.Layouts[j].Name }) // Default layout: prefer one literally named "app", else the first declared. var defaultName string if len(data.Layouts) > 0 { data.DefaultLayout, defaultName = data.Layouts[0].Func, data.Layouts[0].Name for _, l := range data.Layouts { if l.Name == "app" { data.DefaultLayout, defaultName = l.Func, l.Name break } } } for i := range data.Pages { if data.Pages[i].Layout == "" { data.Pages[i].Layout = defaultName } } write(filepath.Join(dir, "routes.gen.go"), routesTmpl, data) write(filepath.Join(dir, "server.gen.go"), serverTmpl, data) write(filepath.Join(dir, "client.gen.go"), clientTmpl, data) fmt.Printf("wasmgen: %d page(s), %d server component(s)\n", len(data.Pages), len(data.Servers)) } func write(path, tmpl string, data genData) { var b strings.Builder if err := template.Must(template.New("g").Parse(tmpl)).Execute(&b, data); err != nil { panic(err) } src, err := format.Source([]byte(b.String())) if err != nil { fmt.Fprintf(os.Stderr, "wasmgen: format %s: %v\n---\n%s\n", path, err, b.String()) os.Exit(1) } // Only write when changed, so re-running codegen doesn't churn mtimes (which // would make the dev server's file watcher loop). if existing, err := os.ReadFile(path); err == nil && string(existing) == string(src) { return } if err := os.WriteFile(path, src, 0o644); err != nil { panic(err) } } const routesTmpl = `// Code generated by wasmgen. DO NOT EDIT. package app import "kjol/wasmruntime/vdom" // Routes maps each //gowasm:page path to its instantiated render function. func Routes(d Deps) map[string]func() *vdom.VNode { return map[string]func() *vdom.VNode{ {{range .Pages}} "{{.Path}}": {{.Func}}(d), {{end}} } } // StaticPaths are the routes the server pre-renders (SSR); others render client-side. var StaticPaths = map[string]bool{ {{range .Pages}}{{if .Static}} "{{.Path}}": true, {{end}}{{end}}} // RouteLayout maps each route to the name of the layout that wraps it. var RouteLayout = map[string]string{ {{range .Pages}} "{{.Path}}": "{{.Layout}}", {{end}}} // LayoutFor wraps a page's content in the layout declared for its route. func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode { {{if .Layouts}} switch RouteLayout[path] { {{range .Layouts}} case "{{.Name}}": return {{.Func}}(d, content) {{end}} } return {{.DefaultLayout}}(d, content) {{else}} return content {{end}}} ` const serverTmpl = `// Code generated by wasmgen. DO NOT EDIT. //go:build !(js && wasm) package app import "kjol/wasmruntime/rsc" func init() { {{range .Servers}} rsc.Register("{{.}}", {{.}}) {{end}}} ` const clientTmpl = `// Code generated by wasmgen. DO NOT EDIT. //go:build js && wasm package app import ( "kjol/wasmruntime/rsc" "kjol/wasmruntime/vdom" ) {{range .Servers}}// {{.}} is a generated client stub for the server component of the same name. func {{.}}() func() *vdom.VNode { return rsc.Mount("{{.}}") } {{end}}`