genevents prefixed only the *first* line of a const block's doc comment with `//`, so a comment that ran to a second paragraph emitted bare prose into the TypeScript object literal — a generated file that does not parse. Nothing had noticed because nobody had run the generator since the comments were written, and `make generate` is a pre-commit hook: the failure was waiting for whoever next touched a .sql, a .templ or an event constant. A generator is only verified by running it.
206 lines
4.5 KiB
Go
206 lines
4.5 KiB
Go
// 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)
|
|
}
|