Files
kjol/go/cmd/wasmgen/main.go

215 lines
6.0 KiB
Go

// 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 <path> [static] [layout=<name>]
// a page factory `func(Deps) func() *vdom.VNode`.
// `static` => the server SSRs that route;
// `layout=` => wrap it in a named //gowasm:layout.
// //gowasm:layout <name> 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}}`