From 56bd6638db63851d881fa1f3fc4e69a578c544d3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 16:42:41 +0000 Subject: [PATCH] feat(codegen): add TypeScript declarations Generate .d.ts output for Web Component bundles and add a CLI -dts mode.\n\nCo-Authored-By: Virgil --- cmd/codegen/main.go | 31 +++++++++++++++++- cmd/codegen/main_test.go | 14 ++++++++ codegen/codegen.go | 69 ++++++++++++++++++++++++++++++++++------ codegen/codegen_test.go | 16 ++++++++++ docs/history.md | 4 +-- docs/index.md | 4 +-- 6 files changed, 124 insertions(+), 14 deletions(-) diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index b72def3..5b107a2 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -4,10 +4,12 @@ // Usage: // // echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ > components.js +// echo '{"H":"nav-bar","C":"main-content"}' | go run ./cmd/codegen/ -dts > components.d.ts package main import ( "encoding/json" + "flag" goio "io" "os" @@ -15,7 +17,17 @@ import ( log "dappco.re/go/core/log" ) +var emitTypeDefinitions = flag.Bool("dts", false, "emit TypeScript declarations instead of JavaScript") + func run(r goio.Reader, w goio.Writer) error { + return runWithMode(r, w, false) +} + +func runTypeDefinitions(r goio.Reader, w goio.Writer) error { + return runWithMode(r, w, true) +} + +func runWithMode(r goio.Reader, w goio.Writer, emitTypes bool) error { data, err := goio.ReadAll(r) if err != nil { return log.E("codegen", "reading stdin", err) @@ -26,6 +38,15 @@ func run(r goio.Reader, w goio.Writer) error { return log.E("codegen", "invalid JSON", err) } + if emitTypes { + dts, err := codegen.GenerateTypeDefinitions(slots) + if err != nil { + return err + } + _, err = goio.WriteString(w, dts) + return err + } + js, err := codegen.GenerateBundle(slots) if err != nil { return err @@ -36,7 +57,15 @@ func run(r goio.Reader, w goio.Writer) error { } func main() { - if err := run(os.Stdin, os.Stdout); err != nil { + flag.Parse() + + var err error + if *emitTypeDefinitions { + err = runTypeDefinitions(os.Stdin, os.Stdout) + } else { + err = run(os.Stdin, os.Stdout) + } + if err != nil { log.Error("codegen failed", "err", err) os.Exit(1) } diff --git a/cmd/codegen/main_test.go b/cmd/codegen/main_test.go index edacf19..c7ae45f 100644 --- a/cmd/codegen/main_test.go +++ b/cmd/codegen/main_test.go @@ -23,6 +23,20 @@ func TestRun_Good(t *testing.T) { assert.Equal(t, 2, strings.Count(js, "extends HTMLElement")) } +func TestRunTypeDefinitions_Good(t *testing.T) { + input := strings.NewReader(`{"H":"nav-bar","C":"main-content"}`) + var output bytes.Buffer + + err := runTypeDefinitions(input, &output) + require.NoError(t, err) + + dts := output.String() + assert.Contains(t, dts, "declare global") + assert.Contains(t, dts, `"nav-bar": NavBar;`) + assert.Contains(t, dts, `"main-content": MainContent;`) + assert.Contains(t, dts, "export {};") +} + func TestRun_Bad_InvalidJSON(t *testing.T) { input := strings.NewReader(`not json`) var output bytes.Buffer diff --git a/codegen/codegen.go b/codegen/codegen.go index 8692b0b..f679823 100644 --- a/codegen/codegen.go +++ b/codegen/codegen.go @@ -2,6 +2,8 @@ package codegen import ( "fmt" + "maps" + "slices" "strings" "text/template" @@ -69,23 +71,72 @@ func TagToClassName(tag string) string { // GenerateBundle produces all WC class definitions and registrations // for a set of HLCRF slot assignments. func GenerateBundle(slots map[string]string) (string, error) { - seen := make(map[string]bool) var b strings.Builder - for slot, tag := range slots { - if seen[tag] { - continue - } - seen[tag] = true - - cls, err := GenerateClass(tag, slot) + for _, entry := range sortedSlotEntries(slots) { + cls, err := GenerateClass(entry.Tag, entry.Slot) if err != nil { return "", err } b.WriteString(cls) b.WriteByte('\n') - b.WriteString(GenerateRegistration(tag, TagToClassName(tag))) + b.WriteString(GenerateRegistration(entry.Tag, TagToClassName(entry.Tag))) b.WriteByte('\n') } return b.String(), nil } + +// GenerateTypeDefinitions produces a TypeScript declaration file for the +// generated custom elements. +func GenerateTypeDefinitions(slots map[string]string) (string, error) { + entries := sortedSlotEntries(slots) + var b strings.Builder + + for _, entry := range entries { + className := TagToClassName(entry.Tag) + b.WriteString("declare class ") + b.WriteString(className) + b.WriteString(" extends HTMLElement {\n") + b.WriteString(" connectedCallback(): void;\n") + b.WriteString(" render(html: string): void;\n") + b.WriteString("}\n") + } + + b.WriteString("\ndeclare global {\n") + b.WriteString(" interface HTMLElementTagNameMap {\n") + for _, entry := range entries { + className := TagToClassName(entry.Tag) + b.WriteString(` "`) + b.WriteString(entry.Tag) + b.WriteString(`": `) + b.WriteString(className) + b.WriteString(";\n") + } + b.WriteString(" }\n") + b.WriteString("}\n") + b.WriteString("\nexport {};\n") + + return b.String(), nil +} + +type slotEntry struct { + Slot string + Tag string +} + +func sortedSlotEntries(slots map[string]string) []slotEntry { + keys := slices.Collect(maps.Keys(slots)) + slices.Sort(keys) + + seen := make(map[string]bool, len(keys)) + entries := make([]slotEntry, 0, len(keys)) + for _, slot := range keys { + tag := slots[slot] + if seen[tag] { + continue + } + seen[tag] = true + entries = append(entries, slotEntry{Slot: slot, Tag: tag}) + } + return entries +} diff --git a/codegen/codegen_test.go b/codegen/codegen_test.go index 28a6aa2..fadbc28 100644 --- a/codegen/codegen_test.go +++ b/codegen/codegen_test.go @@ -52,3 +52,19 @@ func TestGenerateBundle_Good(t *testing.T) { assert.Contains(t, js, "MainContent") assert.Equal(t, 2, strings.Count(js, "extends HTMLElement")) } + +func TestGenerateTypeDefinitions_Good(t *testing.T) { + slots := map[string]string{ + "H": "nav-bar", + "C": "main-content", + } + + dts, err := GenerateTypeDefinitions(slots) + require.NoError(t, err) + assert.Contains(t, dts, "declare global") + assert.Contains(t, dts, "class NavBar extends HTMLElement") + assert.Contains(t, dts, "class MainContent extends HTMLElement") + assert.Contains(t, dts, `"nav-bar": NavBar;`) + assert.Contains(t, dts, `"main-content": MainContent;`) + assert.Contains(t, dts, "export {};") +} diff --git a/docs/history.md b/docs/history.md index bf835fe..61e194e 100644 --- a/docs/history.md +++ b/docs/history.md @@ -103,7 +103,7 @@ These are not regressions; they are design choices or deferred work recorded for 4. **Context.service is private.** The i18n service cannot be set after construction or swapped. This is a conservative choice; relaxing it requires deciding whether mutation should be safe for concurrent use. -5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components. +5. **TypeScript definitions are generated.** `codegen.GenerateTypeDefinitions()` produces a `.d.ts` companion for the generated Web Components. 6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred. @@ -113,7 +113,7 @@ These are not regressions; they are design choices or deferred work recorded for These items were captured during the WASM size reduction work and expert review sessions. They are not committed work items. -- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. +- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers. Implemented via `GenerateTypeDefinitions()`. - **Accessibility helpers** — `aria-label`, `alt`, `aria-hidden`, and `tabindex` helpers. The layout has semantic HTML and ARIA roles, and the node layer now exposes common accessibility attribute shortcuts beyond `Attr()`. - **Layout variant validation** — `ValidateLayoutVariant()` and `Layout.VariantError()` report unrecognised slot characters while preserving silent render-time skipping. - **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows. diff --git a/docs/index.md b/docs/index.md index c7b12c4..706a2e0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,8 +46,8 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`