feat(codegen): add TypeScript declarations
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Generate .d.ts output for Web Component bundles and add a CLI -dts mode.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 16:42:41 +00:00
parent 8bf49c8935
commit 56bd6638db
6 changed files with 124 additions and 14 deletions

View file

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

View file

@ -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

View file

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

View file

@ -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 {};")
}

View file

@ -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.

View file

@ -46,8 +46,8 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| `render.go` | `Render()` convenience function |
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
| `pipeline.go` | `StripTags`, `Imprint`, `CompareVariants` (server-side only, `!js` build tag) |
| `codegen/codegen.go` | Web Component class generation (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout |
| `codegen/codegen.go` | Web Component class generation and TypeScript declarations (closed Shadow DOM) |
| `cmd/codegen/main.go` | Build-time CLI: JSON slot map on stdin, JS bundle on stdout, `-dts` for `.d.ts` output |
| `cmd/wasm/main.go` | WASM entry point exporting `renderToString()` to JavaScript |
## Key Concepts