From b248142c263d0984daf7f1f7426dd73aef9afec2 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 17:58:57 -0300 Subject: [PATCH 01/11] feat: support scalar component props --- docs/language/README.md | 8 +- docs/language/components.md | 16 ++-- docs/language/markup.md | 1 - docs/language/spec.md | 6 +- docs/product/requirements.md | 2 +- internal/appgen/ir.go | 23 +++-- internal/buildgen/components.go | 23 +++-- internal/buildgen/components_layouts_test.go | 41 +++++++++ .../compiler/validate_component_contracts.go | 2 +- internal/parser/page_test.go | 7 +- internal/parser/syntax.go | 2 +- internal/view/api.go | 15 ++++ internal/view/component.go | 87 ++++++++++++++++++- internal/view/island_helpers.go | 2 +- internal/view/view_test.go | 46 ++++++++++ 15 files changed, 245 insertions(+), 36 deletions(-) diff --git a/docs/language/README.md b/docs/language/README.md index 4aca5e4f..c422f6bf 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -22,10 +22,10 @@ interpolation in views, Go-typed component props/state contracts, first-slice generated JavaScript islands for stateful components, component-level `wasm` island asset emission, formatting, diagnostics, manifest output, build output for simple SPA pages/components, generated partial fragment responses for -embedded apps, and LSP/editor integration. It does not yet parse non-string -inline props, full typed action semantics, API request/response bodies, broad -local client-side reactivity, or full semantic/type analysis outside the -component contract and inline package-go-block slices. +embedded apps, and LSP/editor integration. It does not yet implement full typed +action semantics, API request/response +bodies, broad local client-side reactivity, or full semantic/type analysis +outside the component contract and inline package-go-block slices. ## Current Files diff --git a/docs/language/components.md b/docs/language/components.md index 826f115c..249b2fe3 100644 --- a/docs/language/components.md +++ b/docs/language/components.md @@ -6,6 +6,7 @@ Implemented today: - Explicit or discovered `.cmp.gwdk` build inputs with `component Name`. - Optional `props { name string }` declarations. +- Inline scalar props with `string`, `int`, `float`, and `bool` types. - Component-local Go imports using normal module import paths, such as `import ui "github.com/acme/app/ui"`. - Typed props contracts that reference imported Go structs, such as @@ -237,14 +238,14 @@ files import normal Go packages for typed contracts and build-time helpers. GOWDK `use` declarations import discovered `.gwdk` source packages; today that contract is implemented for qualified component calls. -Props are caller-provided inputs. Inline `props {}` declarations are string-only -in the current slice, while imported Go struct contracts can provide typed -props metadata. Parent calls can pass literal strings and the implemented -build-data interpolation subset. Props are read-only to `client {}` code; mutable -browser state belongs in `state` or in an explicit page store. +Props are caller-provided inputs. Inline `props {}` declarations support scalar +`string`, `int`, `float`, and `bool` types. Parent calls pass quoted string +props, scalar literal expressions for numbers and booleans, or expression +values from the implemented build-data subset. Props are read-only to +`client {}` code; mutable browser state belongs in `state` or in an explicit +page store. -Imported Go structs are the stable typed prop path today. Non-string inline -props are planned, but inline `props {}` blocks currently accept only `string`. +Imported Go structs are the stable typed prop path for richer contracts. Defaults should be expressed in normal Go init/build data or by rendering a fallback in the component `view {}`. There is no rest/spread prop syntax, prop renaming syntax, or implicit global prop lookup in the current contract. @@ -373,7 +374,6 @@ replacement for backend handlers. Not implemented yet: -- Non-string props in inline `props {}` blocks. - Stable parent consumption of typed `exports {}` values. - Rest/spread props, prop renaming, recursive component rendering, dynamic component selection, and bindable child state. diff --git a/docs/language/markup.md b/docs/language/markup.md index 045d38ba..0e446667 100644 --- a/docs/language/markup.md +++ b/docs/language/markup.md @@ -284,7 +284,6 @@ server-rendered HTML via `innerHTML`/`outerHTML`, so raw HTML rendered with Not implemented yet: -- Non-string component props in inline `props {}` blocks. - Raw HTML escape hatches beyond the `g:html` element directive, including attribute-position or text-position raw output. - Snippet/render block syntax as a first-class reusable markup value. diff --git a/docs/language/spec.md b/docs/language/spec.md index e932f39a..79322c6d 100644 --- a/docs/language/spec.md +++ b/docs/language/spec.md @@ -247,11 +247,11 @@ components. Current component support is partial: -- String-like props and first typed Go prop/state contracts are supported. +- Scalar inline props and first typed Go prop/state contracts are supported. - Component CSS and assets can be scoped and emitted. - Component-level `wasm` can emit browser WASM island assets. -- Rich non-string props, broad lifecycle behavior, child-to-parent events, and - a full reactive graph are planned. +- Prop defaults, broad lifecycle behavior, child-to-parent events, and a full + reactive graph are planned. ## Scoped JavaScript diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 1ebc2635..dc278610 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -61,7 +61,7 @@ implemented. | --- | --- | --- | | Markup language | Expand `view {}` only through GOWDK-owned AST nodes and directives; defer async placeholders, transitions, DOM/document targets, and DOM actions until separate contracts exist. | Partial — the directive contract is closed: unknown `g:*` directives and deferred families (transitions, DOM/document/window targets, async placeholders, DOM actions) fail at parse time with family-specific guidance under `unsupported_markup_directive`/`unsupported_markup_syntax`, and raw HTML has its explicit contract via `g:html={Expr}` (PRD-018). | | Snippets and slots | Keep slots as the stable reusable markup primitive; defer first-class snippet/render values. | Planned | -| Component props | Keep imported Go structs as the primary typed prop path; add non-string literal props and defaults before considering rest/spread, renaming, recursion, dynamic components, or bindable child state. | Planned | +| Component props | Keep imported Go structs as the primary typed prop path; inline scalar `string`/`int`/`float`/`bool` props are supported; add defaults before considering rest/spread, renaming, recursion, dynamic components, or bindable child state. | Partial | | Client reactivity | Keep bounded compiler-owned `client {}`; generated JS must not own routing, auth, business rules, database access, server validation, action behavior, global app state, or page loading policy. | Planned | | Shared state | Keep stores page/island scoped until cross-package or app-global stores have explicit ownership, serialization, subscription, and teardown contracts. | Planned | | Load/data lifecycle | Keep `build {}` build-time, `load {}` request-time, and actions/APIs/fragments as endpoint lanes; defer universal/browser-owned load policy. | Partial | `load {}` runs on request-time page routes, actions do not invalidate load data implicitly, generated/client behavior supports explicit redirects, fragments, JSON, and `response.ReloadPage()` reload outcomes, and browser-owned universal load policy remains out of scope. | diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index fe8a7f0e..aa0d175c 100644 --- a/internal/appgen/ir.go +++ b/internal/appgen/ir.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/cssbruno/gowdk/internal/clientlang" "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/source" "github.com/cssbruno/gowdk/internal/view" @@ -161,11 +162,12 @@ func fragmentComponentsFromIR(components []gwdkir.Component) map[string]view.Com out := map[string]view.Component{} for _, component := range components { compiled := view.Component{ - Name: component.Name, - Package: component.Package, - Uses: irUsesMap(component.Uses), - Props: irPropNames(component.Props), - Body: component.Blocks.ViewBody, + Name: component.Name, + Package: component.Package, + Uses: irUsesMap(component.Uses), + Props: irPropNames(component.Props), + PropTypes: irPropTypes(component.Props), + Body: component.Blocks.ViewBody, } addFragmentComponent(out, compiled) } @@ -229,6 +231,17 @@ func irPropNames(props []gwdkir.Prop) []string { return out } +func irPropTypes(props []gwdkir.Prop) map[string]clientlang.ValueType { + if len(props) == 0 { + return nil + } + out := map[string]clientlang.ValueType{} + for _, prop := range props { + out[prop.Name] = clientlang.NormalizeType(prop.Type) + } + return out +} + func irBindingsByEndpoint(endpoints []gwdkir.Endpoint) map[string]source.BackendBinding { out := map[string]source.BackendBinding{} for _, endpoint := range endpoints { diff --git a/internal/buildgen/components.go b/internal/buildgen/components.go index 99a6f785..c7eba46f 100644 --- a/internal/buildgen/components.go +++ b/internal/buildgen/components.go @@ -41,7 +41,7 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, continue } - props, propFailures := componentPropNames(component) + props, propTypes, propFailures := componentProps(component) for _, failure := range propFailures { failures = append(failures, failure) valid = false @@ -79,6 +79,7 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, ScopeIDs: componentScopeIDs(component), DefaultIsland: componentDefaultIsland(component), Props: props, + PropTypes: propTypes, State: state, StateJSON: stateJSON, Handlers: handlers, @@ -231,19 +232,28 @@ func componentEmits(component gwdkir.Component) map[string]clientlang.Emit { return out } -func componentPropNames(component gwdkir.Component) ([]string, []string) { +func componentProps(component gwdkir.Component) ([]string, map[string]clientlang.ValueType, []string) { if component.PropsType.Name != "" { resolved, err := gotypes.ResolveStruct(component.Imports, component.PropsType) if err != nil { - return nil, []string{fmt.Sprintf("component %s props: %v", component.Name, err)} + return nil, nil, []string{fmt.Sprintf("component %s props: %v", component.Name, err)} } - return resolved.FieldNames(), nil + propTypes := map[string]clientlang.ValueType{} + for _, field := range resolved.Fields { + propTypes[field.Name] = clientlang.NormalizeType(field.Type) + } + for field, typ := range resolved.FieldTypes { + propTypes[field] = clientlang.NormalizeType(typ) + } + return resolved.FieldNames(), propTypes, nil } props := make([]string, 0, len(component.Props)) + propTypes := map[string]clientlang.ValueType{} seen := map[string]bool{} var failures []string for _, prop := range component.Props { - if prop.Type != "string" { + propType := clientlang.NormalizeType(prop.Type) + if propType == clientlang.TypeUnknown || propType == clientlang.TypeArray || propType == clientlang.TypeObject { failures = append(failures, fmt.Sprintf("component %s prop %s uses unsupported type %q", component.Name, prop.Name, prop.Type)) continue } @@ -253,8 +263,9 @@ func componentPropNames(component gwdkir.Component) ([]string, []string) { } seen[prop.Name] = true props = append(props, prop.Name) + propTypes[prop.Name] = propType } - return props, failures + return props, propTypes, failures } func componentInitialState(component gwdkir.Component) (map[string]string, map[string]clientlang.ValueType, string, error) { diff --git a/internal/buildgen/components_layouts_test.go b/internal/buildgen/components_layouts_test.go index 238a545e..cefb523d 100644 --- a/internal/buildgen/components_layouts_test.go +++ b/internal/buildgen/components_layouts_test.go @@ -50,6 +50,47 @@ func TestBuildExpandsExplicitComponents(t *testing.T) { } } +func TestBuildExpandsTypedLiteralComponentProps(t *testing.T) { + outputDir := t.TempDir() + app := gwdkanalysis.Sources{ + Pages: []gwdkir.Page{{ + ID: "home", + Route: "/", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + }, + }}, + Components: []gwdkir.Component{{ + Name: "Stats", + Props: []gwdkir.Prop{ + {Name: "count", Type: "int"}, + {Name: "ratio", Type: "float"}, + {Name: "active", Type: "bool"}, + }, + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
{count}:{ratio}
`, + }, + }}, + } + + _, err := Build(gowdk.Config{}, app, outputDir) + if err != nil { + t.Fatal(err) + } + output := readFile(t, filepath.Join(outputDir, "index.html")) + for _, expected := range []string{ + `data-gowdk-state="{"active":true,"count":3,"ratio":1.5}"`, + `data-active="true"`, + `>3:1.5`, + } { + if !strings.Contains(output, expected) { + t.Fatalf("expected %q in typed component prop output:\n%s", expected, output) + } + } +} + func TestBuildExpandsImportedGOWDKPackageComponent(t *testing.T) { outputDir := t.TempDir() app := gwdkanalysis.Sources{ diff --git a/internal/compiler/validate_component_contracts.go b/internal/compiler/validate_component_contracts.go index fb40c12b..917d3405 100644 --- a/internal/compiler/validate_component_contracts.go +++ b/internal/compiler/validate_component_contracts.go @@ -82,7 +82,7 @@ func resolveComponentContracts(component gwdkir.Component) (componentContracts, } for _, prop := range component.Props { contracts.Props[prop.Name] = true - contracts.PropTypes[prop.Name] = clientlang.TypeString + contracts.PropTypes[prop.Name] = clientlang.NormalizeType(prop.Type) } var diagnostics []ValidationError diff --git a/internal/parser/page_test.go b/internal/parser/page_test.go index 8905c83e..959cc70a 100644 --- a/internal/parser/page_test.go +++ b/internal/parser/page_test.go @@ -1024,6 +1024,9 @@ component Hero props { title string tagline string + count int + ratio float + active bool } view { @@ -1038,7 +1041,7 @@ view { if component.Name != "Hero" { t.Fatalf("expected Hero, got %q", component.Name) } - if len(component.Props) != 2 || component.Props[0].Name != "title" || component.Props[1].Type != "string" { + if len(component.Props) != 5 || component.Props[0].Name != "title" || component.Props[2].Type != "int" || component.Props[3].Type != "float" || component.Props[4].Type != "bool" { t.Fatalf("unexpected props: %#v", component.Props) } if component.Blocks.ViewBody != "
\n

{title}

\n
" { @@ -1264,7 +1267,7 @@ func TestParseComponentRejectsUnsupportedPropType(t *testing.T) { component Hero props { - count int + items []string } view { diff --git a/internal/parser/syntax.go b/internal/parser/syntax.go index a9a71061..3c846e08 100644 --- a/internal/parser/syntax.go +++ b/internal/parser/syntax.go @@ -663,7 +663,7 @@ func parseSyntaxProps(body []syntaxBodyLine) ([]Prop, error) { } func supportedSyntaxPropType(value string) bool { - return value == "string" + return supportedScalarType(value) } func parseSyntaxExports(body []syntaxBodyLine) ([]Export, error) { diff --git a/internal/view/api.go b/internal/view/api.go index 9b9000b2..fb3ba448 100644 --- a/internal/view/api.go +++ b/internal/view/api.go @@ -26,6 +26,7 @@ type Component struct { ScopeIDs []string DefaultIsland string Props []string + PropTypes map[string]clientlang.ValueType State map[string]string StateJSON string Handlers map[string]clientlang.Handler @@ -54,6 +55,20 @@ func (component Component) HasProp(name string) bool { return false } +// PropType returns the declared scalar type for a prop. Components constructed +// by older tests only populate Props; those props remain string-typed. +func (component Component) PropType(name string) clientlang.ValueType { + if component.PropTypes != nil { + if typ, ok := component.PropTypes[name]; ok && typ != clientlang.TypeUnknown { + return typ + } + } + if component.HasProp(name) { + return clientlang.TypeString + } + return clientlang.TypeUnknown +} + // Parse parses a view markup fragment. func Parse(source string) ([]Node, error) { parser := parser{source: []rune(source)} diff --git a/internal/view/component.go b/internal/view/component.go index dac7650f..cfb79cb0 100644 --- a/internal/view/component.go +++ b/internal/view/component.go @@ -87,6 +87,7 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { mode = component.DefaultIsland } props := map[string]string{} + propValues := map[string]any{} propExpressions := map[string]string{} taintedValues := map[string]bool{} var parentListeners []parentComponentListener @@ -108,14 +109,24 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { } return fmt.Errorf("component %s uses unsupported directive attribute %q", node.Name, attr.Name) } + if !component.HasProp(attr.Name) { + return fmt.Errorf("component %s does not declare prop %q", node.Name, attr.Name) + } + propType := component.PropType(attr.Name) if attr.Boolean { - return fmt.Errorf("component %s prop %q requires a string value", node.Name, attr.Name) + if propType != clientlang.TypeBool { + return fmt.Errorf("component %s prop %q requires a value", node.Name, attr.Name) + } + props[attr.Name] = "true" + propValues[attr.Name] = true + continue } - value, tainted, err := interpolateValue(ctx, attr.Value) + value, typedValue, tainted, err := componentPropValue(ctx, attr, propType) if err != nil { return err } props[attr.Name] = value + propValues[attr.Name] = typedValue if attr.Expression { propExpressions[attr.Name] = expressionAttrSource(attr.Value) } @@ -192,6 +203,7 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { Mode: mode, Body: body, Props: props, + PropValues: propValues, PropExpressions: propExpressions, ComputedValues: computedValues, ParentListeners: parentListeners, @@ -207,6 +219,7 @@ type componentIslandRender struct { Mode string Body string Props map[string]string + PropValues map[string]any PropExpressions map[string]string ComputedValues map[string]any ParentListeners []parentComponentListener @@ -217,7 +230,7 @@ func renderComponentIsland(out *renderOutput, island componentIslandRender) erro if err != nil { return err } - stateJSON, err := componentStateJSON(island.Component.StateJSON, island.Props, island.ComputedValues) + stateJSON, err := componentStateJSON(island.Component.StateJSON, island.PropValues, island.ComputedValues) if err != nil { return err } @@ -252,6 +265,74 @@ func componentPropExpressionsJSON(propExpressions map[string]string) (string, er return string(payload), nil } +func componentPropValue(ctx *renderContext, attr Attr, propType clientlang.ValueType) (string, any, bool, error) { + if propType == clientlang.TypeUnknown { + propType = clientlang.TypeString + } + if propType == clientlang.TypeString { + value, tainted, err := interpolateValue(ctx, attr.Value) + if err != nil { + return "", nil, false, err + } + return value, value, tainted, nil + } + if !attr.Expression && strings.Contains(attr.Value, "{") { + return "", nil, false, fmt.Errorf("prop %q with type %s requires a scalar literal or expression", attr.Name, propType) + } + source := strings.TrimSpace(attr.Value) + if attr.Expression { + source = expressionAttrSource(source) + } + if source == "" { + return "", nil, false, fmt.Errorf("prop %q with type %s requires a scalar literal or expression", attr.Name, propType) + } + value, err := clientlang.EvalValue(source, ctx.values) + if err != nil { + return "", nil, false, fmt.Errorf("prop %q: %w", attr.Name, err) + } + typed, err := coerceComponentPropValue(attr.Name, value, propType) + if err != nil { + return "", nil, false, err + } + scalar, ok := scalarString(typed) + if !ok { + return "", nil, false, fmt.Errorf("prop %q with type %s must resolve to a scalar value", attr.Name, propType) + } + return scalar, typed, false, nil +} + +func coerceComponentPropValue(name string, value any, propType clientlang.ValueType) (any, error) { + switch propType { + case clientlang.TypeBool: + typed, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("prop %q expects bool, got %T", name, value) + } + return typed, nil + case clientlang.TypeInt: + switch typed := value.(type) { + case int: + return typed, nil + case float64: + asInt := int(typed) + if float64(asInt) == typed { + return asInt, nil + } + } + return nil, fmt.Errorf("prop %q expects int, got %T", name, value) + case clientlang.TypeFloat: + switch typed := value.(type) { + case int: + return float64(typed), nil + case float64: + return typed, nil + } + return nil, fmt.Errorf("prop %q expects float, got %T", name, value) + default: + return nil, fmt.Errorf("prop %q uses unsupported type %s", name, propType) + } +} + func writeParentComponentListenerAttrs(out *renderOutput, listener parentComponentListener) { out.write(gowhtml.Attr("data-gowdk-parent-on-"+listener.Event, listener.Expression)) out.write(gowhtml.Attr("data-gowdk-parent-event-"+listener.Event, listener.Modifiers)) diff --git a/internal/view/island_helpers.go b/internal/view/island_helpers.go index af182ba4..4db23573 100644 --- a/internal/view/island_helpers.go +++ b/internal/view/island_helpers.go @@ -172,7 +172,7 @@ func evalComputedValues(computeds []clientlang.Computed, values map[string]strin return stringsOut, valuesOut, nil } -func componentStateJSON(stateJSON string, props map[string]string, computed map[string]any) (string, error) { +func componentStateJSON(stateJSON string, props map[string]any, computed map[string]any) (string, error) { if stateJSON == "" && len(props) == 0 && len(computed) == 0 { return "", nil } diff --git a/internal/view/view_test.go b/internal/view/view_test.go index b8938007..736ded9e 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -119,6 +119,52 @@ func TestRenderWithComponentsExpandsSPAStringProps(t *testing.T) { } } +func TestRenderWithComponentsExpandsTypedLiteralProps(t *testing.T) { + got, err := RenderWithComponents(`
`, map[string]Component{ + "Stats": { + Name: "Stats", + Props: []string{"count", "ratio", "active", "label"}, + PropTypes: map[string]clientlang.ValueType{ + "count": clientlang.TypeInt, + "ratio": clientlang.TypeFloat, + "active": clientlang.TypeBool, + "label": clientlang.TypeString, + }, + Body: `

{label}: {count} / {ratio}

`, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-state="{"active":true,"count":3,"label":"GOWDK","ratio":1.5}"`, + `data-gowdk-props="{"count":"3","ratio":"1.5"}"`, + `data-active="true"`, + `

GOWDK: 3 / 1.5

`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in typed prop output:\n%s", want, got) + } + } +} + +func TestRenderWithComponentsRejectsTypedPropMismatch(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Stats": { + Name: "Stats", + Props: []string{"count"}, + PropTypes: map[string]clientlang.ValueType{"count": clientlang.TypeInt}, + Body: `

{count}

`, + }, + }) + if err == nil { + t.Fatal("expected typed prop mismatch error") + } + if !strings.Contains(err.Error(), `prop "count" expects int`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestRenderWithComponentsExpandsQualifiedComponentCall(t *testing.T) { got, err := RenderWithOptions(`
`, map[string]Component{ "ui.Hero": { From 8579faef4d2bfff01a39daa25e7f17c8f7860aca Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 18:04:01 -0300 Subject: [PATCH 02/11] feat: support component prop defaults --- docs/language/components.md | 12 +++-- docs/language/spec.md | 4 +- docs/product/requirements.md | 2 +- internal/appgen/ir.go | 26 +++++++--- internal/buildgen/components.go | 18 +++++-- internal/buildgen/components_layouts_test.go | 36 +++++++++++++ .../validate_component_fingerprint.go | 6 ++- internal/gwdkast/ast.go | 8 +-- internal/gwdkir/ir.go | 8 +-- internal/lang/manifest_json.go | 8 +-- internal/lang/tools_test.go | 4 +- internal/parser/component_lower.go | 2 +- internal/parser/page_test.go | 30 +++++++++-- internal/parser/patterns.go | 12 ++++- internal/parser/syntax.go | 41 ++++++++++++++- internal/view/api.go | 1 + internal/view/component.go | 23 +++++++- internal/view/view_test.go | 52 +++++++++++++++++++ 18 files changed, 253 insertions(+), 40 deletions(-) diff --git a/docs/language/components.md b/docs/language/components.md index 249b2fe3..9c79cec3 100644 --- a/docs/language/components.md +++ b/docs/language/components.md @@ -5,7 +5,8 @@ The first component slice is implemented for SPA build output. Implemented today: - Explicit or discovered `.cmp.gwdk` build inputs with `component Name`. -- Optional `props { name string }` declarations. +- Optional `props { name string }` declarations, including scalar default + literals such as `props { count int = 0 }`. - Inline scalar props with `string`, `int`, `float`, and `bool` types. - Component-local Go imports using normal module import paths, such as `import ui "github.com/acme/app/ui"`. @@ -142,6 +143,8 @@ component Row props { label string + count int = 0 + active bool = false } view { @@ -246,9 +249,10 @@ values from the implemented build-data subset. Props are read-only to page store. Imported Go structs are the stable typed prop path for richer contracts. -Defaults should be expressed in normal Go init/build data or by rendering a -fallback in the component `view {}`. There is no rest/spread prop syntax, prop -renaming syntax, or implicit global prop lookup in the current contract. +Inline props can declare static scalar defaults with `name type = literal`. +Defaults are used when a caller omits the prop and are overridden by explicit +caller values. There is no rest/spread prop syntax, prop renaming syntax, or +implicit global prop lookup in the current contract. State is component-local UI state. A `state Type = Init()` declaration runs the no-argument Go init function at build time for SPA/static output and serializes diff --git a/docs/language/spec.md b/docs/language/spec.md index 79322c6d..b712e5eb 100644 --- a/docs/language/spec.md +++ b/docs/language/spec.md @@ -250,8 +250,8 @@ Current component support is partial: - Scalar inline props and first typed Go prop/state contracts are supported. - Component CSS and assets can be scoped and emitted. - Component-level `wasm` can emit browser WASM island assets. -- Prop defaults, broad lifecycle behavior, child-to-parent events, and a full - reactive graph are planned. +- Broad lifecycle behavior, child-to-parent events, and a full reactive graph + are planned. ## Scoped JavaScript diff --git a/docs/product/requirements.md b/docs/product/requirements.md index dc278610..badd52bb 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -61,7 +61,7 @@ implemented. | --- | --- | --- | | Markup language | Expand `view {}` only through GOWDK-owned AST nodes and directives; defer async placeholders, transitions, DOM/document targets, and DOM actions until separate contracts exist. | Partial — the directive contract is closed: unknown `g:*` directives and deferred families (transitions, DOM/document/window targets, async placeholders, DOM actions) fail at parse time with family-specific guidance under `unsupported_markup_directive`/`unsupported_markup_syntax`, and raw HTML has its explicit contract via `g:html={Expr}` (PRD-018). | | Snippets and slots | Keep slots as the stable reusable markup primitive; defer first-class snippet/render values. | Planned | -| Component props | Keep imported Go structs as the primary typed prop path; inline scalar `string`/`int`/`float`/`bool` props are supported; add defaults before considering rest/spread, renaming, recursion, dynamic components, or bindable child state. | Partial | +| Component props | Keep imported Go structs as the primary typed prop path; inline scalar `string`/`int`/`float`/`bool` props and scalar defaults are supported; defer rest/spread, renaming, recursion, dynamic components, and bindable child state. | Partial | | Client reactivity | Keep bounded compiler-owned `client {}`; generated JS must not own routing, auth, business rules, database access, server validation, action behavior, global app state, or page loading policy. | Planned | | Shared state | Keep stores page/island scoped until cross-package or app-global stores have explicit ownership, serialization, subscription, and teardown contracts. | Planned | | Load/data lifecycle | Keep `build {}` build-time, `load {}` request-time, and actions/APIs/fragments as endpoint lanes; defer universal/browser-owned load policy. | Partial | `load {}` runs on request-time page routes, actions do not invalidate load data implicitly, generated/client behavior supports explicit redirects, fragments, JSON, and `response.ReloadPage()` reload outcomes, and browser-owned universal load policy remains out of scope. | diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index aa0d175c..8d3f9b9d 100644 --- a/internal/appgen/ir.go +++ b/internal/appgen/ir.go @@ -162,12 +162,13 @@ func fragmentComponentsFromIR(components []gwdkir.Component) map[string]view.Com out := map[string]view.Component{} for _, component := range components { compiled := view.Component{ - Name: component.Name, - Package: component.Package, - Uses: irUsesMap(component.Uses), - Props: irPropNames(component.Props), - PropTypes: irPropTypes(component.Props), - Body: component.Blocks.ViewBody, + Name: component.Name, + Package: component.Package, + Uses: irUsesMap(component.Uses), + Props: irPropNames(component.Props), + PropTypes: irPropTypes(component.Props), + PropDefaults: irPropDefaults(component.Props), + Body: component.Blocks.ViewBody, } addFragmentComponent(out, compiled) } @@ -242,6 +243,19 @@ func irPropTypes(props []gwdkir.Prop) map[string]clientlang.ValueType { return out } +func irPropDefaults(props []gwdkir.Prop) map[string]string { + out := map[string]string{} + for _, prop := range props { + if prop.DefaultSet { + out[prop.Name] = prop.Default + } + } + if len(out) == 0 { + return nil + } + return out +} + func irBindingsByEndpoint(endpoints []gwdkir.Endpoint) map[string]source.BackendBinding { out := map[string]source.BackendBinding{} for _, endpoint := range endpoints { diff --git a/internal/buildgen/components.go b/internal/buildgen/components.go index c7eba46f..e14f44ae 100644 --- a/internal/buildgen/components.go +++ b/internal/buildgen/components.go @@ -41,7 +41,7 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, continue } - props, propTypes, propFailures := componentProps(component) + props, propTypes, propDefaults, propFailures := componentProps(component) for _, failure := range propFailures { failures = append(failures, failure) valid = false @@ -80,6 +80,7 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, DefaultIsland: componentDefaultIsland(component), Props: props, PropTypes: propTypes, + PropDefaults: propDefaults, State: state, StateJSON: stateJSON, Handlers: handlers, @@ -232,11 +233,11 @@ func componentEmits(component gwdkir.Component) map[string]clientlang.Emit { return out } -func componentProps(component gwdkir.Component) ([]string, map[string]clientlang.ValueType, []string) { +func componentProps(component gwdkir.Component) ([]string, map[string]clientlang.ValueType, map[string]string, []string) { if component.PropsType.Name != "" { resolved, err := gotypes.ResolveStruct(component.Imports, component.PropsType) if err != nil { - return nil, nil, []string{fmt.Sprintf("component %s props: %v", component.Name, err)} + return nil, nil, nil, []string{fmt.Sprintf("component %s props: %v", component.Name, err)} } propTypes := map[string]clientlang.ValueType{} for _, field := range resolved.Fields { @@ -245,10 +246,11 @@ func componentProps(component gwdkir.Component) ([]string, map[string]clientlang for field, typ := range resolved.FieldTypes { propTypes[field] = clientlang.NormalizeType(typ) } - return resolved.FieldNames(), propTypes, nil + return resolved.FieldNames(), propTypes, nil, nil } props := make([]string, 0, len(component.Props)) propTypes := map[string]clientlang.ValueType{} + propDefaults := map[string]string{} seen := map[string]bool{} var failures []string for _, prop := range component.Props { @@ -264,8 +266,14 @@ func componentProps(component gwdkir.Component) ([]string, map[string]clientlang seen[prop.Name] = true props = append(props, prop.Name) propTypes[prop.Name] = propType + if prop.DefaultSet { + propDefaults[prop.Name] = prop.Default + } + } + if len(propDefaults) == 0 { + propDefaults = nil } - return props, propTypes, failures + return props, propTypes, propDefaults, failures } func componentInitialState(component gwdkir.Component) (map[string]string, map[string]clientlang.ValueType, string, error) { diff --git a/internal/buildgen/components_layouts_test.go b/internal/buildgen/components_layouts_test.go index cefb523d..8ce439c2 100644 --- a/internal/buildgen/components_layouts_test.go +++ b/internal/buildgen/components_layouts_test.go @@ -91,6 +91,42 @@ func TestBuildExpandsTypedLiteralComponentProps(t *testing.T) { } } +func TestBuildUsesComponentPropDefaults(t *testing.T) { + outputDir := t.TempDir() + app := gwdkanalysis.Sources{ + Pages: []gwdkir.Page{{ + ID: "home", + Route: "/", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + }, + }}, + Components: []gwdkir.Component{{ + Name: "Stats", + Props: []gwdkir.Prop{ + {Name: "label", Type: "string", Default: "Default", DefaultSet: true}, + {Name: "count", Type: "int", Default: "2", DefaultSet: true}, + {Name: "active", Type: "bool", Default: "true", DefaultSet: true}, + }, + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
{label}:{count}
`, + }, + }}, + } + + _, err := Build(gowdk.Config{}, app, outputDir) + if err != nil { + t.Fatal(err) + } + output := readFile(t, filepath.Join(outputDir, "index.html")) + expected := `
Default:2
` + if !strings.Contains(output, expected) { + t.Fatalf("expected default prop output %q in:\n%s", expected, output) + } +} + func TestBuildExpandsImportedGOWDKPackageComponent(t *testing.T) { outputDir := t.TempDir() app := gwdkanalysis.Sources{ diff --git a/internal/compiler/validate_component_fingerprint.go b/internal/compiler/validate_component_fingerprint.go index 5433bad0..b43401ad 100644 --- a/internal/compiler/validate_component_fingerprint.go +++ b/internal/compiler/validate_component_fingerprint.go @@ -64,7 +64,11 @@ func componentPropsFingerprint(component gwdkir.Component) string { } props := make([]string, 0, len(component.Props)) for _, prop := range component.Props { - props = append(props, prop.Name+":"+prop.Type) + defaultValue := "" + if prop.DefaultSet { + defaultValue = "=" + prop.Default + } + props = append(props, prop.Name+":"+prop.Type+defaultValue) } sort.Strings(props) return "inline:" + strings.Join(props, ",") diff --git a/internal/gwdkast/ast.go b/internal/gwdkast/ast.go index 335d6b17..4414cfa0 100644 --- a/internal/gwdkast/ast.go +++ b/internal/gwdkast/ast.go @@ -243,9 +243,11 @@ type BuildCall struct { // Prop is one scalar prop declaration inside props {}. type Prop struct { - Name string - Type string - Span source.SourceSpan + Name string + Type string + Default string + DefaultSet bool + Span source.SourceSpan } // Export is one typed public component export inside exports {}. diff --git a/internal/gwdkir/ir.go b/internal/gwdkir/ir.go index 1adf9165..851de57e 100644 --- a/internal/gwdkir/ir.go +++ b/internal/gwdkir/ir.go @@ -276,9 +276,11 @@ type WASMContract struct { } type Prop struct { - Name string - Type string - Span source.SourceSpan + Name string + Type string + Default string + DefaultSet bool + Span source.SourceSpan } type Export struct { diff --git a/internal/lang/manifest_json.go b/internal/lang/manifest_json.go index 6685c093..8d0acba3 100644 --- a/internal/lang/manifest_json.go +++ b/internal/lang/manifest_json.go @@ -92,8 +92,10 @@ type routeParamJSON struct { } type propJSON struct { - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + DefaultSet bool `json:"defaultSet,omitempty"` } type emitJSON struct { @@ -523,7 +525,7 @@ func propsJSON(props []gwdkir.Prop) []propJSON { } out := make([]propJSON, 0, len(props)) for _, prop := range props { - out = append(out, propJSON{Name: prop.Name, Type: prop.Type}) + out = append(out, propJSON{Name: prop.Name, Type: prop.Type, Default: prop.Default, DefaultSet: prop.DefaultSet}) } return out } diff --git a/internal/lang/tools_test.go b/internal/lang/tools_test.go index b150ffdb..a04c187c 100644 --- a/internal/lang/tools_test.go +++ b/internal/lang/tools_test.go @@ -735,7 +735,7 @@ func TestParseComponentSourceReportsTypedParserDiagnostic(t *testing.T) { component Badge props { - Count int + Count time } view { @@ -753,7 +753,7 @@ view { } if diagnostic.Range == nil || diagnostic.Range.Start.Line != 5 || diagnostic.Range.Start.Column != 3 || - diagnostic.Range.End.Line != 5 || diagnostic.Range.End.Column != 12 { + diagnostic.Range.End.Line != 5 || diagnostic.Range.End.Column != 13 { t.Fatalf("unexpected component diagnostic range: %#v", diagnostic.Range) } } diff --git a/internal/parser/component_lower.go b/internal/parser/component_lower.go index b1c5bdf3..285584c4 100644 --- a/internal/parser/component_lower.go +++ b/internal/parser/component_lower.go @@ -141,7 +141,7 @@ func lowerSyntaxGoFuncRef(ref gwdkast.GoFuncRef) gwdkir.GoRef { func lowerSyntaxProps(in []gwdkast.Prop) []gwdkir.Prop { out := make([]gwdkir.Prop, 0, len(in)) for _, item := range in { - out = append(out, gwdkir.Prop{Name: item.Name, Type: item.Type, Span: item.Span}) + out = append(out, gwdkir.Prop{Name: item.Name, Type: item.Type, Default: item.Default, DefaultSet: item.DefaultSet, Span: item.Span}) } return out } diff --git a/internal/parser/page_test.go b/internal/parser/page_test.go index 959cc70a..4e8eae35 100644 --- a/internal/parser/page_test.go +++ b/internal/parser/page_test.go @@ -1023,10 +1023,10 @@ component Hero props { title string - tagline string - count int - ratio float - active bool + tagline string = "Portable" + count int = 2 + ratio float = 1.5 + active bool = true } view { @@ -1041,7 +1041,7 @@ view { if component.Name != "Hero" { t.Fatalf("expected Hero, got %q", component.Name) } - if len(component.Props) != 5 || component.Props[0].Name != "title" || component.Props[2].Type != "int" || component.Props[3].Type != "float" || component.Props[4].Type != "bool" { + if len(component.Props) != 5 || component.Props[0].Name != "title" || component.Props[1].Default != "Portable" || !component.Props[1].DefaultSet || component.Props[2].Type != "int" || component.Props[2].Default != "2" || component.Props[3].Type != "float" || component.Props[3].Default != "1.5" || component.Props[4].Type != "bool" || component.Props[4].Default != "true" { t.Fatalf("unexpected props: %#v", component.Props) } if component.Blocks.ViewBody != "
\n

{title}

\n
" { @@ -1279,6 +1279,26 @@ view { } } +func TestParseComponentRejectsInvalidPropDefault(t *testing.T) { + _, err := ParseComponent([]byte(` +component Hero + +props { + count int = true +} + +view { +
Count
+} +`)) + if err == nil { + t.Fatal("expected invalid prop default error") + } + if !strings.Contains(err.Error(), "default must be an int literal") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestParseComponentRejectsUnknownMetadata(t *testing.T) { _, err := ParseComponent([]byte(` component Hero diff --git a/internal/parser/patterns.go b/internal/parser/patterns.go index af21e673..941f1736 100644 --- a/internal/parser/patterns.go +++ b/internal/parser/patterns.go @@ -288,14 +288,22 @@ func parseAPIBlockLine(line string) []string { } func parsePropLine(line string) []string { - tokens := syntaxTokens(line) + raw := strings.TrimSpace(line) + left, defaultValue, hasDefault := strings.Cut(raw, "=") + if hasDefault { + defaultValue = strings.TrimSpace(defaultValue) + if defaultValue == "" { + return nil + } + } + tokens := syntaxTokens(left) if len(tokens) != 2 || !isIdentifierToken(tokens[0]) || !isIdentifierToken(tokens[1]) { return nil } if !isStrictIdent(tokens[0].Lexeme) || !isStrictIdent(tokens[1].Lexeme) { return nil } - return []string{line, tokens[0].Lexeme, tokens[1].Lexeme} + return []string{line, tokens[0].Lexeme, tokens[1].Lexeme, defaultValue} } func parseEmitLine(line string) []string { diff --git a/internal/parser/syntax.go b/internal/parser/syntax.go index 3c846e08..100da274 100644 --- a/internal/parser/syntax.go +++ b/internal/parser/syntax.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "fmt" + "strconv" "strings" "github.com/cssbruno/gowdk/internal/cssscope" @@ -657,7 +658,16 @@ func parseSyntaxProps(body []syntaxBodyLine) ([]Prop, error) { if !supportedSyntaxPropType(match[2]) { return nil, lineDiagnosticError(DiagnosticUnsupportedComponentPropType, raw.Line, raw.Text, "prop %s uses unsupported type %q", match[1], match[2]) } - props = append(props, Prop{Name: match[1], Type: match[2], Span: sourceLineSpan(raw.Line, raw.Text)}) + prop := Prop{Name: match[1], Type: match[2], Span: sourceLineSpan(raw.Line, raw.Text)} + if len(match) > 3 && strings.TrimSpace(match[3]) != "" { + value, err := validateSyntaxPropDefault(match[1], match[2], match[3]) + if err != nil { + return nil, lineDiagnosticError(DiagnosticInvalidComponentProp, raw.Line, raw.Text, "%v", err) + } + prop.Default = value + prop.DefaultSet = true + } + props = append(props, prop) } return props, nil } @@ -666,6 +676,35 @@ func supportedSyntaxPropType(value string) bool { return supportedScalarType(value) } +func validateSyntaxPropDefault(name string, typ string, value string) (string, error) { + value = strings.TrimSpace(value) + switch typ { + case "string": + decoded, err := strconv.Unquote(value) + if err != nil { + return "", fmt.Errorf("prop %s default for string must be a quoted string literal", name) + } + return decoded, nil + case "int": + if _, err := strconv.Atoi(value); err != nil { + return "", fmt.Errorf("prop %s default must be an int literal", name) + } + return value, nil + case "float": + if _, err := strconv.ParseFloat(value, 64); err != nil { + return "", fmt.Errorf("prop %s default must be a float literal", name) + } + return value, nil + case "bool": + if value != "true" && value != "false" { + return "", fmt.Errorf("prop %s default must be true or false", name) + } + return value, nil + default: + return "", fmt.Errorf("prop %s uses unsupported type %q", name, typ) + } +} + func parseSyntaxExports(body []syntaxBodyLine) ([]Export, error) { var exports []Export for _, raw := range body { diff --git a/internal/view/api.go b/internal/view/api.go index fb3ba448..92c6b8a0 100644 --- a/internal/view/api.go +++ b/internal/view/api.go @@ -27,6 +27,7 @@ type Component struct { DefaultIsland string Props []string PropTypes map[string]clientlang.ValueType + PropDefaults map[string]string State map[string]string StateJSON string Handlers map[string]clientlang.Handler diff --git a/internal/view/component.go b/internal/view/component.go index cfb79cb0..fa603cc2 100644 --- a/internal/view/component.go +++ b/internal/view/component.go @@ -86,8 +86,15 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { if mode == "" { mode = component.DefaultIsland } - props := map[string]string{} + props := cloneValues(component.PropDefaults) propValues := map[string]any{} + for prop, value := range props { + typed, err := typedComponentPropString(prop, value, component.PropType(prop)) + if err != nil { + return err + } + propValues[prop] = typed + } propExpressions := map[string]string{} taintedValues := map[string]bool{} var parentListeners []parentComponentListener @@ -333,6 +340,20 @@ func coerceComponentPropValue(name string, value any, propType clientlang.ValueT } } +func typedComponentPropString(name string, value string, propType clientlang.ValueType) (any, error) { + if propType == clientlang.TypeUnknown { + propType = clientlang.TypeString + } + if propType == clientlang.TypeString { + return value, nil + } + typed, err := clientlang.EvalValue(value, nil) + if err != nil { + return nil, fmt.Errorf("prop %q default: %w", name, err) + } + return coerceComponentPropValue(name, typed, propType) +} + func writeParentComponentListenerAttrs(out *renderOutput, listener parentComponentListener) { out.write(gowhtml.Attr("data-gowdk-parent-on-"+listener.Event, listener.Expression)) out.write(gowhtml.Attr("data-gowdk-parent-event-"+listener.Event, listener.Modifiers)) diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 736ded9e..01a0fabd 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -165,6 +165,58 @@ func TestRenderWithComponentsRejectsTypedPropMismatch(t *testing.T) { } } +func TestRenderWithComponentsUsesPropDefaults(t *testing.T) { + got, err := RenderWithComponents(`
`, map[string]Component{ + "Stats": { + Name: "Stats", + Props: []string{"label", "count", "ratio", "active"}, + PropTypes: map[string]clientlang.ValueType{ + "label": clientlang.TypeString, + "count": clientlang.TypeInt, + "ratio": clientlang.TypeFloat, + "active": clientlang.TypeBool, + }, + PropDefaults: map[string]string{ + "label": "Default", + "count": "2", + "ratio": "1.5", + "active": "true", + }, + Body: `

{label}: {count} / {ratio}

`, + }, + }) + if err != nil { + t.Fatal(err) + } + want := `

Default: 2 / 1.5

` + if got != want { + t.Fatalf("unexpected HTML:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderWithComponentsLetsPropsOverrideDefaults(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Stats": { + Name: "Stats", + Props: []string{"count"}, + PropTypes: map[string]clientlang.ValueType{"count": clientlang.TypeInt}, + PropDefaults: map[string]string{"count": "2"}, + Body: `

{count}

`, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-state="{"count":4}"`, + `4`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in override output:\n%s", want, got) + } + } +} + func TestRenderWithComponentsExpandsQualifiedComponentCall(t *testing.T) { got, err := RenderWithOptions(`
`, map[string]Component{ "ui.Hero": { From 2099ae6a82f28e9526023b92a458471c88857ac6 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 18:05:21 -0300 Subject: [PATCH 03/11] test: pin component recursion policies --- docs/language/components.md | 9 ++++--- internal/buildgen/components_layouts_test.go | 26 ++++++++++++++++++++ internal/view/parser.go | 3 +++ internal/view/view_test.go | 23 +++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/language/components.md b/docs/language/components.md index 9c79cec3..cba10c8c 100644 --- a/docs/language/components.md +++ b/docs/language/components.md @@ -344,8 +344,9 @@ slot props plus caller-side `let:` bindings. GOWDK does not currently have a separate snippet/render value model. Recursive component rendering is rejected to prevent unbounded build-time -rendering. Dynamic component selection is deferred; component calls must name a -known component directly or through an explicit `use` alias. +rendering; direct and transitive cycles fail before output is written. Dynamic +component selection is rejected; component calls must name a known component +directly or through an explicit `use` alias. `client {}` is a compiler-owned UI language, not arbitrary JavaScript. The supported handlers, helpers, lifecycle blocks, effects, refs, list built-ins, @@ -379,8 +380,8 @@ replacement for backend handlers. Not implemented yet: - Stable parent consumption of typed `exports {}` values. -- Rest/spread props, prop renaming, recursive component rendering, dynamic - component selection, and bindable child state. +- Rest/spread props, prop renaming, supported recursive component rendering, + supported dynamic component selection, and bindable child state. - Full runtime validation for user browser logic in WASM islands, including required Go/JS entrypoint registration and export checks. - Wiring generated Go component packages into the generated app layout. diff --git a/internal/buildgen/components_layouts_test.go b/internal/buildgen/components_layouts_test.go index 8ce439c2..59a3da9f 100644 --- a/internal/buildgen/components_layouts_test.go +++ b/internal/buildgen/components_layouts_test.go @@ -127,6 +127,32 @@ func TestBuildUsesComponentPropDefaults(t *testing.T) { } } +func TestBuildRejectsRecursiveComponentCycle(t *testing.T) { + outputDir := t.TempDir() + app := gwdkanalysis.Sources{ + Pages: []gwdkir.Page{{ + ID: "home", + Route: "/", + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + }, + }}, + Components: []gwdkir.Component{ + {Name: "A", Blocks: gwdkir.Blocks{View: true, ViewBody: ``}}, + {Name: "B", Blocks: gwdkir.Blocks{View: true, ViewBody: ``}}, + }, + } + + _, err := Build(gowdk.Config{}, app, outputDir) + if err == nil { + t.Fatal("expected recursive component cycle error") + } + if !strings.Contains(err.Error(), `recursive component "A"`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestBuildExpandsImportedGOWDKPackageComponent(t *testing.T) { outputDir := t.TempDir() app := gwdkanalysis.Sources{ diff --git a/internal/view/parser.go b/internal/view/parser.go index f6c6fb13..2bbd60f0 100644 --- a/internal/view/parser.go +++ b/internal/view/parser.go @@ -88,6 +88,9 @@ func (parser *parser) element() (Node, error) { if !parser.consume("<") { return nil, parser.errorf("expected element") } + if parser.startsWith("{") { + return nil, parser.errorf("dynamic component selection is not supported; component calls must name a known component directly") + } name, err := parser.name() if err != nil { return nil, err diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 01a0fabd..6251424e 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -62,6 +62,29 @@ func TestRenderSPARejectsMissingComponent(t *testing.T) { } } +func TestRenderSPARejectsDynamicComponentSyntax(t *testing.T) { + _, err := RenderSPA(`
<{Current} />
`) + if err == nil { + t.Fatal("expected dynamic component error") + } + if !strings.Contains(err.Error(), "dynamic component selection is not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderWithComponentsRejectsRecursiveComponentCycle(t *testing.T) { + _, err := RenderWithComponents(`
`, map[string]Component{ + "A": {Name: "A", Body: ``}, + "B": {Name: "B", Body: ``}, + }) + if err == nil { + t.Fatal("expected recursive component error") + } + if !strings.Contains(err.Error(), `recursive component "A"`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestParseRejectsUnsupportedTemplateSyntaxWithGOWDKAlternatives(t *testing.T) { tests := []struct { name string From 2c46b9714c0e40e88cfde4e9f02c81897af68baf Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 18:06:39 -0300 Subject: [PATCH 04/11] test: reject advanced component prop syntax --- docs/language/components.md | 3 +++ internal/view/component.go | 3 +++ internal/view/parser.go | 3 +++ internal/view/view_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/docs/language/components.md b/docs/language/components.md index cba10c8c..bcefa578 100644 --- a/docs/language/components.md +++ b/docs/language/components.md @@ -253,6 +253,9 @@ Inline props can declare static scalar defaults with `name type = literal`. Defaults are used when a caller omits the prop and are overridden by explicit caller values. There is no rest/spread prop syntax, prop renaming syntax, or implicit global prop lookup in the current contract. +Rest/spread-looking component calls such as `` and +renaming-looking props such as `title:heading="..."` are rejected; pass each +declared prop explicitly. State is component-local UI state. A `state Type = Init()` declaration runs the no-argument Go init function at build time for SPA/static output and serializes diff --git a/internal/view/component.go b/internal/view/component.go index fa603cc2..de31a79f 100644 --- a/internal/view/component.go +++ b/internal/view/component.go @@ -116,6 +116,9 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { } return fmt.Errorf("component %s uses unsupported directive attribute %q", node.Name, attr.Name) } + if strings.Contains(attr.Name, ":") { + return fmt.Errorf("component %s prop renaming is not supported; pass declared prop %q directly", node.Name, strings.Split(attr.Name, ":")[0]) + } if !component.HasProp(attr.Name) { return fmt.Errorf("component %s does not declare prop %q", node.Name, attr.Name) } diff --git a/internal/view/parser.go b/internal/view/parser.go index 2bbd60f0..7e13b0fb 100644 --- a/internal/view/parser.go +++ b/internal/view/parser.go @@ -165,6 +165,9 @@ func (parser *parser) componentCall(name string, start int) (ComponentCall, erro case parser.done(): return ComponentCall{}, parser.errorf("unterminated <%s> component tag", name) default: + if parser.startsWith("{...") || parser.startsWith("...") { + return ComponentCall{}, parser.errorf("component rest/spread props are not supported; pass declared props explicitly") + } attr, err := parser.attr() if err != nil { return ComponentCall{}, err diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 6251424e..47d0ed72 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -85,6 +85,30 @@ func TestRenderWithComponentsRejectsRecursiveComponentCycle(t *testing.T) { } } +func TestRenderWithComponentsRejectsRestSpreadProps(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err == nil { + t.Fatal("expected rest/spread prop error") + } + if !strings.Contains(err.Error(), "rest/spread props are not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderWithComponentsRejectsPropRenaming(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err == nil { + t.Fatal("expected prop renaming error") + } + if !strings.Contains(err.Error(), "prop renaming is not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestParseRejectsUnsupportedTemplateSyntaxWithGOWDKAlternatives(t *testing.T) { tests := []struct { name string From 19835be09a7db42be2cad51c0751fae88d707e21 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 18:07:08 -0300 Subject: [PATCH 05/11] test: reject component child state binding --- docs/language/components.md | 2 +- internal/view/component.go | 3 +++ internal/view/view_test.go | 12 ++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/language/components.md b/docs/language/components.md index bcefa578..676c9f1e 100644 --- a/docs/language/components.md +++ b/docs/language/components.md @@ -265,7 +265,7 @@ state, or server validation results that the server still needs to enforce. Bindable child state is not stable as a parent/child contract. Parent-child coordination should use typed emits plus parent-owned state, or server actions -for trusted behavior. +for trusted behavior. Component-call `g:bind:*` is rejected with that guidance. Computed values are read-only derived state. They can depend on props, state, and other computed values. The compiler builds a dependency graph for declared diff --git a/internal/view/component.go b/internal/view/component.go index de31a79f..a05d2114 100644 --- a/internal/view/component.go +++ b/internal/view/component.go @@ -114,6 +114,9 @@ func (node ComponentCall) render(ctx *renderContext, out *renderOutput) error { if attr.Name == "g:event" { return fmt.Errorf("component %s must not declare g:event; domain and integration events are backend-owned facts", node.Name) } + if strings.HasPrefix(attr.Name, "g:bind:") || attr.Name == "g:bind" { + return fmt.Errorf("component %s bindable child state is not supported; use typed emits plus parent-owned state", node.Name) + } return fmt.Errorf("component %s uses unsupported directive attribute %q", node.Name, attr.Name) } if strings.Contains(attr.Name, ":") { diff --git a/internal/view/view_test.go b/internal/view/view_test.go index 47d0ed72..a58efdfd 100644 --- a/internal/view/view_test.go +++ b/internal/view/view_test.go @@ -109,6 +109,18 @@ func TestRenderWithComponentsRejectsPropRenaming(t *testing.T) { } } +func TestRenderWithComponentsRejectsBindableChildState(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Child": {Name: "Child", Props: []string{"value"}, Body: `

{value}

`}, + }) + if err == nil { + t.Fatal("expected bindable child state error") + } + if !strings.Contains(err.Error(), "bindable child state is not supported") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestParseRejectsUnsupportedTemplateSyntaxWithGOWDKAlternatives(t *testing.T) { tests := []struct { name string From 944118d8f4e757ec8747eb393d66970fd31000a5 Mon Sep 17 00:00:00 2001 From: cssbruno Date: Sat, 13 Jun 2026 18:26:18 -0300 Subject: [PATCH 06/11] feat: harden generated client runtime --- docs/compiler/build-report.md | 8 + docs/compiler/generated-output.md | 14 +- docs/compiler/manifest.md | 14 +- docs/language/actions.md | 5 + docs/language/forms.md | 23 ++- docs/language/partials.md | 14 +- docs/language/semantics.md | 6 +- internal/buildgen/action_input_fields.go | 27 ++++ internal/buildgen/actions_partials_test.go | 44 ++++++ internal/buildgen/build.go | 88 ++++++++--- internal/buildgen/build_report_test.go | 28 ++++ internal/buildgen/manifests.go | 12 ++ internal/buildgen/render.go | 7 +- internal/buildgen/routes.go | 4 +- internal/buildgen/ssr.go | 7 +- internal/buildgen/types.go | 2 + internal/clientrt/runtime.go | 168 ++++++++++++++++----- internal/clientrt/runtime_dom_test.go | 31 ++++ internal/clientrt/runtime_test.go | 15 +- internal/view/action_input_attrs.go | 117 ++++++++++++++ internal/view/api.go | 15 +- internal/view/component.go | 1 + internal/view/element.go | 6 + internal/view/island_helpers.go | 8 + internal/view/render_context.go | 2 + internal/view/view_test.go | 41 +++++ runtime/asset/asset.go | 10 ++ runtime/asset/asset_test.go | 9 ++ 28 files changed, 633 insertions(+), 93 deletions(-) create mode 100644 internal/buildgen/action_input_fields.go create mode 100644 internal/view/action_input_attrs.go diff --git a/docs/compiler/build-report.md b/docs/compiler/build-report.md index 8928350d..f94bfad8 100644 --- a/docs/compiler/build-report.md +++ b/docs/compiler/build-report.md @@ -48,6 +48,14 @@ Current stages are: - `complete`: successful build summary. - `report`: build report serialization or write failure. +Current report events include: + +- `cache_policy`: summarizes generated page, CSS, asset, and request-time cache + policies. +- `asset_size`: one event per generated runtime asset, including JavaScript, + source maps, WASM modules, and loaders. `data.kind` is `javascript`, `wasm`, + `sourcemap`, `css`, or `asset`; `data.bytes` is the generated byte count. + ## CLI Debug Output `gowdk build --debug` prints a readable version of this report to stderr while diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index a51f5268..a5680afa 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -336,6 +336,9 @@ as `/blog/hello-gowdk`. "hashes": { "assets/app.css": "sha256:..." }, + "sizes": { + "assets/app.css": 1204 + }, "cache": { "assets/app.css": "public, max-age=31536000, immutable", "index.html": "public, max-age=120" @@ -345,11 +348,12 @@ as `/blog/hello-gowdk`. The `files` map resolves logical asset names to slash-separated paths relative to the selected output directory. `hashes` records SHA-256 content hashes for -generated assets, and `cache` records the HTTP cache policy generated binaries -should apply when serving generated assets or route HTML files. The current -implementation records CSS files emitted by CSS processors, generated page CSS -files, partial runtime assets, generated island runtime assets, generated island -source maps, and page-level `cache` policies for generated SPA HTML. It does +generated assets, `sizes` records generated asset byte counts, and `cache` +records the HTTP cache policy generated binaries should apply when serving +generated assets or route HTML files. The current implementation records CSS +files emitted by CSS processors, generated page CSS files, partial runtime +assets, generated island runtime assets, generated island source maps, WASM +island assets, and page-level `cache` policies for generated SPA HTML. It does not record configured stylesheet URLs that were not written by the build. ## Current Build Report diff --git a/docs/compiler/manifest.md b/docs/compiler/manifest.md index cb6a62b3..fb1fc1a1 100644 --- a/docs/compiler/manifest.md +++ b/docs/compiler/manifest.md @@ -147,16 +147,22 @@ page-level cache policies: "files": { "assets/app.css": "assets/app.7ada5a1234b1.css", "assets/gowdk/islands/Counter.js": "assets/gowdk/islands/Counter.js" + }, + "sizes": { + "assets/app.css": 1204, + "assets/gowdk/islands/Counter.js": 4096 } } ``` Keys are stable logical asset names and values are emitted slash-separated paths relative to the selected output directory. Generated CSS values include a -content hash in the filename after minification. The `cache` map may also -include route HTML paths such as `index.html`; those route entries do not need -to appear in `files`. Configured stylesheet links are not included unless GOWDK -emits the referenced file. +content hash in the filename after minification. The optional `hashes`, +`cache`, and `sizes` maps record content hashes, generated cache policy, and +byte size for emitted assets. The `cache` map may also include route HTML paths +such as `index.html`; those route entries do not need to appear in `files`. +Configured stylesheet links are not included unless GOWDK emits the referenced +file. ## Planned Manifest Work diff --git a/docs/language/actions.md b/docs/language/actions.md index 806b6785..2a9b41ab 100644 --- a/docs/language/actions.md +++ b/docs/language/actions.md @@ -177,6 +177,11 @@ Current form behavior is intentionally narrow and literal-analysis driven: route. - Field inference reads direct `input`, `textarea`, `select`, and named submit controls with literal `name` attributes. +- When bound Go action input metadata is available, direct literal numeric + `` controls can receive missing browser attributes derived + from integer field types: `type="number"`, `inputmode="numeric"`, unsigned + `min="0"`, and sized integer `min`/`max` bounds. Existing author attributes + are preserved. - Named submit controls such as ` +} +``` + +Parent components can listen with `g:on:exports`: + +```gwdk +view { + } ``` @@ -335,11 +348,13 @@ component that reads a persisted store still declares a matching `state` shape. Invalid scopes are reported but not auto-fixed, because choosing `local` vs `session` is a deliberate decision. -Exports are typed component metadata today. They document values a component -intends to expose, but parent pages/components do not yet have a stable runtime -API for consuming exported component values. Until that contract is generated -and documented, use props, typed emits, stores, actions, or build/load data for -actual data flow. +Exports must reference a declared prop, state field, or computed value and the +declared type must match that local symbol. Generated JavaScript islands emit +an `exports` event with `event.active == true` after mount and updates, plus a +`gowdk:exports` DOM event for direct integrations. Before unmount, the runtime +emits the same events with `event.active == false` and exported values set to +`null`, so parent code can clear local handles. Exports are local UI handles; +they are not server state, trusted input, or a replacement for backend actions. Slots are the reusable-markup primitive. A default slot uses ``, named slots use ``, and scoped slots pass scalar values through @@ -377,16 +392,16 @@ to that component use the WASM island runtime by default. The referenced package is browser-side Go compiled for `GOOS=js GOARCH=wasm` with server and process packages rejected. GOWDK validates the required component-scoped ABI entrypoints, ships Go's browser `wasm_exec.js` runtime asset for declared Go WASM packages, -and keeps DOM mutation in the generated host loader. WASM islands are not a -replacement for backend handlers. +passes `gowdk-wasm-island-v1` payloads to component WASM exports, and keeps DOM +mutation in the generated host loader. WASM islands are not a replacement for +backend handlers. Not implemented yet: -- Stable parent consumption of typed `exports {}` values. - Rest/spread props, prop renaming, supported recursive component rendering, supported dynamic component selection, and bindable child state. -- Full runtime validation for user browser logic in WASM islands, including - required Go/JS entrypoint registration and export checks. +- Full runtime validation for user browser logic in WASM islands beyond + required export, browser import, and patch-operation checks. - Wiring generated Go component packages into the generated app layout. - Cross-package store and asset use syntax. - Emitting and rewriting component-scoped CSS and component-level assets from diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index 8d3f9b9d..f0b4c6c0 100644 --- a/internal/appgen/ir.go +++ b/internal/appgen/ir.go @@ -168,6 +168,7 @@ func fragmentComponentsFromIR(components []gwdkir.Component) map[string]view.Com Props: irPropNames(component.Props), PropTypes: irPropTypes(component.Props), PropDefaults: irPropDefaults(component.Props), + Exports: irExportTypes(component.Exports), Body: component.Blocks.ViewBody, } addFragmentComponent(out, compiled) @@ -256,6 +257,17 @@ func irPropDefaults(props []gwdkir.Prop) map[string]string { return out } +func irExportTypes(exports []gwdkir.Export) map[string]clientlang.ValueType { + if len(exports) == 0 { + return nil + } + out := map[string]clientlang.ValueType{} + for _, export := range exports { + out[export.Name] = clientlang.NormalizeType(export.Type) + } + return out +} + func irBindingsByEndpoint(endpoints []gwdkir.Endpoint) map[string]source.BackendBinding { out := map[string]source.BackendBinding{} for _, endpoint := range endpoints { diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 8c373f56..a0572d8c 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + goruntime "runtime" "sort" "strings" @@ -650,6 +651,9 @@ func reportAssetSizes(reporter *buildReporter, outputDir string, assets []AssetA if artifact.CachePolicy != "" { data["cache"] = artifact.CachePolicy } + if logical == islandWASMExecAssetPath() { + data["wasmExecGoVersion"] = goruntime.Version() + } reporter.info("report", "asset_size", "generated asset size recorded", BuildEvent{ Path: rel, Data: data, diff --git a/internal/buildgen/components.go b/internal/buildgen/components.go index e14f44ae..9a46afc7 100644 --- a/internal/buildgen/components.go +++ b/internal/buildgen/components.go @@ -51,7 +51,18 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, failures = append(failures, fmt.Sprintf("component %s state: %v", component.Name, err)) valid = false } - handlers, handlersJSON, err := componentClientHandlers(component) + emits := componentEmits(component) + computeds, computedFailures := componentClientComputeds(component) + for _, failure := range computedFailures { + failures = append(failures, failure) + valid = false + } + exports, exportNames, exportFailures := componentExports(component, propTypes, stateTypes, computeds) + for _, failure := range exportFailures { + failures = append(failures, failure) + valid = false + } + handlers, handlersJSON, err := componentClientHandlers(component, exportNames) if err != nil { failures = append(failures, fmt.Sprintf("component %s client: %v", component.Name, err)) valid = false @@ -61,12 +72,6 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, failures = append(failures, failure) valid = false } - emits := componentEmits(component) - computeds, computedFailures := componentClientComputeds(component) - for _, failure := range computedFailures { - failures = append(failures, failure) - valid = false - } if !valid { continue } @@ -88,6 +93,7 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, StateTypes: stateTypes, Refs: refs, Emits: emits, + Exports: exports, Computed: computeds, Body: component.Blocks.ViewBody, } @@ -170,13 +176,13 @@ func componentClientRefs(component gwdkir.Component) (map[string]clientlang.Ref, return program.RefMap(), nil } -func componentClientHandlers(component gwdkir.Component) (map[string]clientlang.Handler, string, error) { +func componentClientHandlers(component gwdkir.Component, exports []string) (map[string]clientlang.Handler, string, error) { emits := componentEmits(component) - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" && len(emits) == 0 { + if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" && len(emits) == 0 && len(exports) == 0 { return nil, "", nil } if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { - payload, err := json.Marshal(clientlang.Bootstrap{Emits: emits}) + payload, err := json.Marshal(clientlang.Bootstrap{Emits: emits, Exports: exports}) if err != nil { return nil, "", err } @@ -188,7 +194,7 @@ func componentClientHandlers(component gwdkir.Component) (map[string]clientlang. } handlers := program.HandlerMap() helpers := program.HelperMap() - if len(handlers) == 0 && len(helpers) == 0 && !program.NeedsBootstrap() && len(emits) == 0 { + if len(handlers) == 0 && len(helpers) == 0 && !program.NeedsBootstrap() && len(emits) == 0 && len(exports) == 0 { return nil, "", nil } computeds, err := program.OrderedComputed() @@ -196,11 +202,12 @@ func componentClientHandlers(component gwdkir.Component) (map[string]clientlang. return nil, "", err } var payload []byte - if program.NeedsBootstrap() || len(emits) > 0 { + if program.NeedsBootstrap() || len(emits) > 0 || len(exports) > 0 { payload, err = json.Marshal(clientlang.Bootstrap{ Handlers: handlers, Helpers: helpers, Emits: emits, + Exports: exports, Stores: program.StoreNames(), Mount: append([]string(nil), program.Mount...), Destroy: append([]string(nil), program.Destroy...), @@ -233,6 +240,59 @@ func componentEmits(component gwdkir.Component) map[string]clientlang.Emit { return out } +func componentExports(component gwdkir.Component, propTypes map[string]clientlang.ValueType, stateTypes map[string]clientlang.ValueType, computeds []clientlang.Computed) (map[string]clientlang.ValueType, []string, []string) { + if len(component.Exports) == 0 { + return nil, nil, nil + } + computedTypes := map[string]clientlang.ValueType{} + for _, computed := range computeds { + computedTypes[computed.Name] = clientlang.NormalizeType(computed.Type) + } + out := map[string]clientlang.ValueType{} + names := make([]string, 0, len(component.Exports)) + seen := map[string]bool{} + var failures []string + for _, export := range component.Exports { + if seen[export.Name] { + failures = append(failures, fmt.Sprintf("component %s declares duplicate export %q", component.Name, export.Name)) + continue + } + seen[export.Name] = true + expected := clientlang.NormalizeType(export.Type) + if expected == clientlang.TypeUnknown || expected == clientlang.TypeArray || expected == clientlang.TypeObject { + failures = append(failures, fmt.Sprintf("component %s export %s uses unsupported type %q", component.Name, export.Name, export.Type)) + continue + } + actual, ok := propTypes[export.Name] + if !ok { + actual, ok = stateTypes[export.Name] + } + if !ok { + actual, ok = computedTypes[export.Name] + } + if !ok { + failures = append(failures, fmt.Sprintf("component %s export %q must reference a declared prop, state field, or computed value", component.Name, export.Name)) + continue + } + if actual != clientlang.TypeUnknown && actual != expected && !compatibleClientNumericTypes(actual, expected) { + failures = append(failures, fmt.Sprintf("component %s export %q declares %s but local symbol is %s", component.Name, export.Name, expected, actual)) + continue + } + out[export.Name] = expected + names = append(names, export.Name) + } + if len(out) == 0 { + out = nil + names = nil + } + return out, names, failures +} + +func compatibleClientNumericTypes(actual clientlang.ValueType, expected clientlang.ValueType) bool { + return (actual == clientlang.TypeInt || actual == clientlang.TypeFloat) && + (expected == clientlang.TypeInt || expected == clientlang.TypeFloat) +} + func componentProps(component gwdkir.Component) ([]string, map[string]clientlang.ValueType, map[string]string, []string) { if component.PropsType.Name != "" { resolved, err := gotypes.ResolveStruct(component.Imports, component.PropsType) diff --git a/internal/buildgen/island_js_source.go b/internal/buildgen/island_js_source.go index 9f5eb39d..3ed39635 100644 --- a/internal/buildgen/island_js_source.go +++ b/internal/buildgen/island_js_source.go @@ -809,6 +809,18 @@ func islandJSSource(componentName string, includeSourceMap bool) string { }); } + function dispatchComponentExports(root, exportNames, state, active) { + if (!Array.isArray(exportNames) || exportNames.length === 0) return; + const payload = Object.create(null); + payload.active = Boolean(active); + exportNames.forEach((name) => { + payload[name] = active ? state[name] : null; + }); + root.__gowdkExports = payload; + root.dispatchEvent(new CustomEvent("exports", { detail: payload, bubbles: true })); + root.dispatchEvent(new CustomEvent("gowdk:exports", { detail: payload, bubbles: true })); + } + function updateTextBindings(bindings, state) { bindings.text.forEach(({ node, field }) => { node.textContent = state[field] == null ? "" : String(state[field]); @@ -886,10 +898,11 @@ func islandJSSource(componentName string, includeSourceMap bool) string { root.setAttribute("data-gowdk-mounted", "js"); const state = JSON.parse(root.getAttribute("data-gowdk-state") || "{}"); const client = JSON.parse(root.getAttribute("data-gowdk-client") || "{}"); - const hasEnvelope = Boolean(client.handlers || client.helpers || client.emits || client.stores || client.mount || client.destroy || client.effects || client.computed); + const hasEnvelope = Boolean(client.handlers || client.helpers || client.emits || client.exports || client.stores || client.mount || client.destroy || client.effects || client.computed); const handlers = hasEnvelope ? (client.handlers || {}) : client; const helpers = client.helpers || {}; const emitEvents = client.emits || {}; + const exportNames = client.exports || []; const storeNames = Array.isArray(client.stores) ? client.stores : []; const storeRegistry = window.__gowdkStores; const mountStatements = client.mount || []; @@ -947,6 +960,7 @@ func islandJSSource(componentName string, includeSourceMap bool) string { bindings = render(root, state, helpers, bindings); bindInteractiveNodes(); syncChildProps(root, state, helpers); + dispatchComponentExports(root, exportNames, state, true); publishStores(); }; let renderScheduled = false; @@ -1052,6 +1066,13 @@ func islandJSSource(componentName string, includeSourceMap bool) string { invoke(customEvent); }; node.addEventListener(event, listener, { once: modifiers.once, capture: modifiers.capture }); + if (event === "exports" && node.__gowdkExports) { + listener({ + detail: node.__gowdkExports, + preventDefault() {}, + stopPropagation() {} + }); + } return; } if (!owned) return; @@ -1108,6 +1129,7 @@ func islandJSSource(componentName string, includeSourceMap bool) string { if (root.getAttribute("data-gowdk-mounted") !== "js") return; root.removeAttribute("data-gowdk-mounted"); registry.roots.delete(root); + dispatchComponentExports(root, exportNames, state, false); storeUnsubscribers.forEach((unsubscribe) => unsubscribe()); if (destroyStatements.length > 0) { await runAllEffectCleanups(); diff --git a/internal/buildgen/islands_test.go b/internal/buildgen/islands_test.go index 9944ccc8..8c8ba02f 100644 --- a/internal/buildgen/islands_test.go +++ b/internal/buildgen/islands_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + goruntime "runtime" "strings" "testing" "time" @@ -650,6 +651,88 @@ func TestBuildEmitsComponentEventRuntimeForJSIsland(t *testing.T) { } } +func TestBuildEmitsTypedExportRuntimeForJSIsland(t *testing.T) { + outputDir := t.TempDir() + parent := textComponent() + parent.Name = "Parent" + parent.Source = "components/parent.cmp.gwdk" + parent.Blocks.ViewBody = `