Files
yellowjacket/backend/events/cmd/genevents/main.go
logan fcf2fe509e fix(events): keep every line of a doc comment inside a comment
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.
2026-08-12 01:17:41 -04:00

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)
}