// Command genevents reads Go event constants from events.go using go/ast // and generates the corresponding TypeScript constants file. // // Usage: // // go run ./cmd/genevents -source events.go -output ../../frontend/src/events.ts package main import ( "flag" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "strings" ) func main() { source := flag.String("source", "events.go", "path to Go events source file") output := flag.String("output", "", "path to TypeScript output file (stdout if empty)") flag.Parse() consts, err := parseEvents(*source) if err != nil { fmt.Fprintf(os.Stderr, "genevents: %v\n", err) os.Exit(1) } ts := generateTypeScript(consts) if *output == "" || *output == "/dev/stdout" { fmt.Print(ts) return } if err := writeAtomic(*output, ts); err != nil { fmt.Fprintf(os.Stderr, "genevents: write %s: %v\n", *output, err) os.Exit(1) } } // constGroup holds a block of related constants with its doc comment. type constGroup struct { Comment string // doc comment text (empty if none) Consts []constEntry } // constEntry holds one constant name and its string value. type constEntry struct { Name string Value string } // parseEvents parses the Go source file and extracts typed string constant // groups in declaration order. func parseEvents(path string) ([]constGroup, error) { fset := token.NewFileSet() f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) if err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } var groups []constGroup for _, decl := range f.Decls { gd, ok := decl.(*ast.GenDecl) if !ok || gd.Tok != token.CONST { continue } var g constGroup // Extract doc comment from the const block. if gd.Doc != nil { g.Comment = cleanComment(gd.Doc.Text()) } for _, spec := range gd.Specs { vs, ok := spec.(*ast.ValueSpec) if !ok { continue } for i, name := range vs.Names { if i >= len(vs.Values) { continue } bl, ok := vs.Values[i].(*ast.BasicLit) if !ok || bl.Kind != token.STRING { continue } // Strip quotes from the string literal value. val := strings.Trim(bl.Value, `"`) g.Consts = append(g.Consts, constEntry{Name: name.Name, Value: val}) } } if len(g.Consts) > 0 { groups = append(groups, g) } } return groups, nil } // cleanComment trims whitespace and strips trailing periods from Go doc // comment text (Go convention uses periods; TypeScript comments typically // do not). func cleanComment(s string) string { s = strings.TrimSpace(s) s = strings.TrimSuffix(s, ".") return s } // commentLines renders a doc comment as indented TypeScript line // comments, one per source line. func commentLines(comment string) []string { if comment == "" { return nil } var out []string for _, line := range strings.Split(comment, "\n") { if line == "" { out = append(out, " //") continue } out = append(out, " // "+line) } return out } // generateTypeScript produces the full TypeScript source from the parsed // constant groups. func generateTypeScript(groups []constGroup) string { var b strings.Builder b.WriteString("// Code generated by genevents from backend/events/events.go. DO NOT EDIT.\n") b.WriteString("\n") b.WriteString("export const Events = {\n") for i, g := range groups { // Every line, not just the first: a doc comment that runs to a // second paragraph used to emit its remainder as bare prose // inside the object literal, so `make generate` — a pre-commit // hook — produced TypeScript that does not parse. for _, line := range commentLines(g.Comment) { b.WriteString(line + "\n") } for _, c := range g.Consts { fmt.Fprintf(&b, " %s: %q,\n", c.Name, c.Value) } // Blank line between groups, but not after the last one. if i < len(groups)-1 { b.WriteString("\n") } } b.WriteString("} as const;\n") b.WriteString("\n") b.WriteString("export type EventName = (typeof Events)[keyof typeof Events];\n") return b.String() } // writeAtomic writes data to a temporary file in the same directory as path, // then renames it into place for atomic replacement. func writeAtomic(path, data string) error { dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".genevents-*.tmp") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.WriteString(data); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) return err } return os.Rename(tmpName, path) }