diff --git a/docs/compiler/browser-compiler.md b/docs/compiler/browser-compiler.md index 5313cea8..9fedf24d 100644 --- a/docs/compiler/browser-compiler.md +++ b/docs/compiler/browser-compiler.md @@ -105,7 +105,8 @@ tries a direct WASM instantiate path and falls back to Go runtime imports when a compiled Go module needs them. Declared browser-side Go packages must produce a browser WASM module and export -the component-scoped ABI entrypoints: +the component-scoped ABI entrypoints with the current `func() uint32` +signature: ```go //go:wasmexport GOWDKMountCounter @@ -118,9 +119,10 @@ func GOWDKHandleCounter() uint32 { return 0 } func GOWDKDestroyCounter() uint32 { return 0 } ``` -The generated loader passes a bootstrap object containing component name, state, -props, emits, refs, and compiler-owned binding metadata. Returned patch lists -may use `setText`, `setAttr`, `removeAttr`, `toggleClass`, `setStyle`, +The generated loader passes a `gowdk-wasm-island-v1` bootstrap object containing +component name, state, props, emits, refs, and compiler-owned binding metadata. +Event and destroy payloads carry the same ABI version. Returned patch lists may +use `setText`, `setAttr`, `removeAttr`, `toggleClass`, `setStyle`, `setHidden`, `replaceList`, and `emit`; unsupported patch operations are rejected with a console error. Missing required exports and startup failures are reported to the browser console instead of silently disabling the island. diff --git a/docs/compiler/build-report.md b/docs/compiler/build-report.md index 8928350d..07971014 100644 --- a/docs/compiler/build-report.md +++ b/docs/compiler/build-report.md @@ -48,6 +48,16 @@ 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. + For `assets/gowdk/islands/wasm_exec.js`, `data.wasmExecGoVersion` records + the Go toolchain version that supplied the runtime file. + ## 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..62a93053 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -112,10 +112,12 @@ Implemented today: exports fails the build. Components without `wasm` keep the minimal placeholder module for the loader-shape slice and do not ship `wasm_exec.js`. The loader discovers matching island roots, builds the ADR-defined bootstrap - object from state, props, emits, refs, and binding metadata, calls - component-scoped WASM exports when present, captures host DOM events, and - applies the supported validated patch commands for text, visibility, - attribute, class, style, and emitted-event updates. + object from ABI version `gowdk-wasm-island-v1`, state, props, emits, refs, + and binding metadata, calls component-scoped WASM exports when present, + captures host DOM events, and applies the supported validated patch commands + for text, visibility, attribute, class, style, and emitted-event updates. The + build report records the Go toolchain version used for generated + `wasm_exec.js` runtime assets. - Generated apps can serve concrete and dynamic SSR pages in the supported generated request-time slice. Dynamic route params are substituted into generated SSR placeholders with request-time HTML escaping. Declared @@ -336,6 +338,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 +350,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/engineering/decisions/0004-production-wasm-island-abi.md b/docs/engineering/decisions/0004-production-wasm-island-abi.md index 9a7e1de1..3be6a4a5 100644 --- a/docs/engineering/decisions/0004-production-wasm-island-abi.md +++ b/docs/engineering/decisions/0004-production-wasm-island-abi.md @@ -31,14 +31,17 @@ Entrypoint naming: - The generated loader looks for exported functions named `GOWDKMount`, `GOWDKHandle`, and `GOWDKDestroy`. +- Each required export must currently have the WASM signature produced by Go + `func() uint32`: no parameters and one `i32` result. - Exported names are component-scoped to avoid a registry in the first slice. - Missing required exports are compile or load diagnostics, not silent no-ops. Bootstrap ABI: - The loader passes one JSON object to `GOWDKMount`. -- The object contains `component`, `state`, `props`, `emits`, `refs`, and - `bindings`. +- The object contains `abiVersion`, `component`, `state`, `props`, `emits`, + `refs`, and `bindings`. +- The current `abiVersion` is `gowdk-wasm-island-v1`. - `state` is the same JSON object used by JS islands. - `props` contains initial prop values and reactive prop expression names. - `bindings` is the compiler-owned table of text, attribute, class, style, @@ -47,7 +50,8 @@ Bootstrap ABI: Event ABI: - DOM events are captured by the JS host. -- The host calls `GOWDKHandle` with `{ event, binding, detail }`. +- The host calls `GOWDKHandle` with + `{ abiVersion, component, event, binding, detail }`. - `event` is the DOM event name or component event name. - `binding` is the compiler-assigned binding ID. - `detail` contains scalar event payload fields. @@ -66,7 +70,7 @@ Lifecycle ABI: - The host calls `GOWDKMount` once per island root. - The host calls `GOWDKDestroy` when the island root is removed or on - pagehide before unload. + pagehide before unload, with `{ abiVersion, component, state }`. - Future effect cleanup uses explicit patch/lifecycle return values rather than ambient goroutines. @@ -74,6 +78,9 @@ Asset strategy: - Component WASM stays at `assets/gowdk/islands/.wasm`. - The loader stays at `assets/gowdk/islands/.wasm.js`. +- Declared Go WASM packages ship `assets/gowdk/islands/wasm_exec.js` from the + Go toolchain used for the build; the build report records that Go version on + the `asset_size` event for the runtime asset. - Multiple component instances share the same WASM module asset but receive separate bootstrap objects. - JS and WASM islands may coexist on the same page. @@ -116,9 +123,11 @@ Asset strategy: ## Implementation - GOWDK builds declared `wasm` packages with `GOOS=js GOARCH=wasm`. +- Generated loader payloads use ABI version `gowdk-wasm-island-v1`. - Built WASM artifacts are rejected unless they export `GOWDKMount`, `GOWDKHandle`, and - `GOWDKDestroy`. + `GOWDKDestroy` with the required no-parameter, `uint32` result + signature. - The generated loader passes the bootstrap object, applies the defined patch operations, rejects unknown patch operations through a console error, and supports JS/WASM island coexistence on the same page. diff --git a/docs/engineering/m8-components-client-language.md b/docs/engineering/m8-components-client-language.md new file mode 100644 index 00000000..4d427c03 --- /dev/null +++ b/docs/engineering/m8-components-client-language.md @@ -0,0 +1,62 @@ +# M8 Components / Client Language Audit + +This audit records the M8 closure criteria for component contracts, the bounded +client language, SPA navigation, and WASM islands. It separates implemented +behavior from explicit deferrals so future work does not depend on issue-body +status. + +## Implemented Slices + +- Component props: scalar literal props, imported Go struct props, scalar + defaults, same-named `{...props}` forwarding, `target:source` prop renaming, + collision diagnostics, and generated render tests cover #17, #93, #94, and + #368. +- Slots: default, named, and scoped slots are the supported reusable-markup + primitive; first-class snippet/render values remain deferred. This covers #16 + and #95. +- Events and exports: typed child-to-parent emits and typed component exports + are generated client contracts with teardown behavior, covering #96 and #369. +- Bindable child state: component `g:bind:={ParentState}` + requires an exported child state field, syncs parent-to-child through reactive + props, syncs child-to-parent through typed exports, avoids prop/export echo + loops, and has generated-output plus browser coverage. This covers #365. +- Client reactivity: component state, computed values, dependency ordering, + cycle diagnostics, lifecycle/effect cleanup, safe refs, form bindings, + `g:if`, `g:for`, keyed list updates, list built-ins, and bounded async helpers + cover #18, #30, #97, #98, #99, #100, and #101. +- Shared state: page-scoped stores, explicit component `use`, local/session + persistence, shape invalidation, and SPA-navigation hydration cover #19. +- SPA navigation: internal-link interception, route shell fetch/swap, prefetch, + scroll/focus restoration, loading/error events, and asset-size reporting cover + #370. +- Generated form validation: direct literal action inputs receive derivable + numeric HTML attributes and partial form POSTs run browser pre-validation + before network submission, covering #174. Server validation remains + authoritative. +- WASM islands: component-level WASM stays opt-in, uses ABI version + `gowdk-wasm-island-v1`, validates required export names and signatures, + rejects browser-unsafe imports, records `wasm_exec.js` Go version in build + reports, and has loader/browser coverage for mount, event, patch, emit, and + cleanup. This covers #29, #64, and #371 for the production ABI slice. + +## Explicit Deferrals + +- Component recursion is rejected, including direct and transitive cycles, to + avoid unbounded build-time rendering. This closes #366 as an intentional + policy. +- Dynamic component selection is rejected; component calls must name a compiler + known component directly or through an explicit `use` alias. This closes #367 + as an intentional policy. + +## Verification Surface + +Run the full repository gates before release: + +```sh +go test ./... +go build ./cmd/gowdk +scripts/test-go-modules.sh +``` + +Focused M8 checks live primarily in `internal/view`, `internal/clientlang`, +`internal/clientrt`, `internal/buildgen`, and `internal/appgen`. diff --git a/docs/engineering/release-plan.md b/docs/engineering/release-plan.md index f8aa2253..4816f9ac 100644 --- a/docs/engineering/release-plan.md +++ b/docs/engineering/release-plan.md @@ -507,7 +507,7 @@ Every 0.x minor release must have: struct prop support as contracts become stable. - [ ] Add prop validation diagnostics. - [ ] Add named slots and scoped slots only when syntax is stable. -- [ ] Add child-to-parent events, typed event payloads, bindable state, mount, +- [x] Add child-to-parent events, typed event payloads, bindable state, mount, update, cleanup, real `g:if`, `g:for`, keyed `g:for`, keyed DOM updates, recursion policy, and dynamic component policy. - [ ] Add component snapshot and browser behavior tests. 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/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 { + } ``` @@ -237,17 +254,30 @@ 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. - -Imported Go structs are the stable typed prop path today. Non-string inline -props are planned, but inline `props {}` blocks currently accept only `string`. -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. +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 for richer contracts. +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. + +Advanced prop forwarding stays inside the typed compiler contract: + +- `{...props}` may be used inside a component that declares props. It forwards + only same-named props that the child component also declares; it does not + expose an arbitrary prop bag or global lookup. +- `target:source` maps a differently named caller prop into a declared child + prop. Without a value, `target:source` forwards `{source}`. With a value, such + as `target:source={Expr}` or `target:source="literal"`, the value is used for + `target` while `source` names the caller-side source for diagnostics. +- Explicit props, spreads, and renames cannot provide the same target prop more + than once. Unknown target props and unsupported spread sources fail before + output is written. 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 @@ -255,9 +285,14 @@ the JSON-compatible initial value into the component island. State is visible to the browser and must not carry secrets, trusted authorization state, database 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. +Bindable child state is supported on component calls with +`g:bind:={ParentState}`. The target must be a child state field, +must be declared in `exports`, and must have a scalar type compatible with the +parent state field. Generated JavaScript sends the parent value down through +reactive props and listens for the child's typed `exports` event to write the +new child value back to parent state. Bound state is still local UI state: it is +not trusted input, server state, auth state, validation, business logic, route +truth, or cache policy. 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 @@ -327,11 +362,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 @@ -339,8 +376,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, @@ -368,17 +406,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: -- 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. -- Full runtime validation for user browser logic in WASM islands, including - required Go/JS entrypoint registration and export checks. +- Supported recursive component rendering and supported dynamic component + selection. +- 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/docs/language/forms.md b/docs/language/forms.md index 03b24ac6..056a38dd 100644 --- a/docs/language/forms.md +++ b/docs/language/forms.md @@ -65,11 +65,15 @@ submits the form with: - `X-GOWDK-Swap: ` Successful enhanced responses swap `innerHTML` or `outerHTML` into the target. -The runtime dispatches `gowdk:before-request`, `gowdk:after-swap`, and -`gowdk:request-error`, toggles `aria-busy`, preserves focus where possible, -and remounts generated islands around replaced DOM. Failed enhanced requests -dispatch `gowdk:request-error` with `detail.status`, `detail.body`, and -`detail.response` when an HTTP response exists. +Before the partial POST, the runtime runs the browser's native constraint +validation (`checkValidity` / `reportValidity`) when available. Invalid +enhanced forms are not posted, `gowdk:validation-blocked` is dispatched on the +form, and the server remains authoritative for every request that reaches the +action handler. The runtime dispatches `gowdk:before-request`, +`gowdk:after-swap`, and `gowdk:request-error`, toggles `aria-busy`, preserves +focus where possible, and remounts generated islands around replaced DOM. +Failed enhanced requests dispatch `gowdk:request-error` with `detail.status`, +`detail.body`, and `detail.response` when an HTTP response exists. Enhanced redirects are not a stable contract today. For enhanced requests, return a fragment response for the target. Use normal full-page POST redirects @@ -105,6 +109,15 @@ The generated first slice infers direct `input`, `textarea`, `select`, and named submit controls with literal `name` attributes. It does not infer fields hidden inside component calls. +Author-written form controls keep their literal browser validation attributes +such as `required`, `type`, `inputmode`, `min`, and `max`. When a `g:post` +action has bound Go input metadata, the renderer can add missing numeric browser +attributes to direct literal `` controls: `type="number"`, +`inputmode="numeric"`, `min="0"` for unsigned integers, and exact `min`/`max` +bounds for sized integer types. It does not synthesize `required` because +requiredness is enforced from literal form constraints, not from Go field type +metadata. + File uploads are intentionally user-owned. Direct `input type="file"` controls and multipart generated action forms are rejected. Use a normal Go API/server handler when uploads need explicit body limits, storage, validation, cleanup, diff --git a/docs/language/markup.md b/docs/language/markup.md index 045d38ba..5147ab3c 100644 --- a/docs/language/markup.md +++ b/docs/language/markup.md @@ -133,6 +133,9 @@ Implemented today: - Local form bindings can be used inside normal `g:post` action forms. Binding listeners do not add submit interception; the action form still posts through its lowered `method` and `action`. +- Component-call bindings use the component contract described in + [components.md](components.md): `g:bind:={ParentState}` binds + parent UI state to an exported child state field. - Reactive expression attributes on safe non-URL attributes inside stateful components, such as `disabled={Open}` and `aria-expanded={Open}`. Boolean HTML attributes are toggled as attributes; scalar and ARIA attributes are @@ -284,7 +287,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/partials.md b/docs/language/partials.md index c84205a3..98fb501c 100644 --- a/docs/language/partials.md +++ b/docs/language/partials.md @@ -68,12 +68,14 @@ Current support: - `internal/clientrt` emits a small `gowdk.js` runtime that enhances `form[data-gowdk-target]` submissions, sends `X-GOWDK-Partial`, `X-GOWDK-Target`, and `X-GOWDK-Swap`, applies `innerHTML` or `outerHTML` - swaps, dispatches `gowdk:before-request`, `gowdk:after-swap`, and - `gowdk:request-error`, and toggles `aria-busy` on the form while the request - is pending. Failed enhanced requests include `status`, `body`, and `response` - in the `gowdk:request-error` detail when an HTTP response exists. It restores - focus by matching the active element's `id` or `name` after the swap when - possible. Before a swap, it calls the generated island + swaps, dispatches `gowdk:before-request`, `gowdk:validation-blocked`, + `gowdk:after-swap`, and `gowdk:request-error`, and toggles `aria-busy` on the + form while the request is pending. Browser constraint validation blocks + invalid enhanced submissions before the partial request is sent. Failed + enhanced requests include `status`, `body`, and `response` in the + `gowdk:request-error` detail when an HTTP response exists. It restores focus + by matching the active element's `id` or `name` after the swap when possible. + Before a swap, it calls the generated island destroy hook when present for islands being replaced; after the swap, it calls the generated island mount hook so newly inserted JavaScript islands can attach. diff --git a/docs/language/semantics.md b/docs/language/semantics.md index 4aaae104..b714a892 100644 --- a/docs/language/semantics.md +++ b/docs/language/semantics.md @@ -11,7 +11,11 @@ Action endpoints on those pages inherit the generated concrete paths. - SPA navigation enhancement is optional runtime behavior over literal internal links. Route existence, route output, auth, and server behavior remain owned - by generated files and generated Go. + by generated files and generated Go. The runtime can prefetch same-origin + internal page HTML on hover, focus, or touch, fetch it with + `X-GOWDK-Navigate` during navigation, mark ``, + dispatch `gowdk:navigate-start` / `gowdk:navigate-end`, and fall back to a + normal browser navigation on unsupported or failed responses. - `load {}` runs at request time. - SPA pages may declare `act` blocks without SSR. diff --git a/docs/language/spec.md b/docs/language/spec.md index e932f39a..b712e5eb 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. +- Broad lifecycle behavior, child-to-parent events, and a full reactive graph + are planned. ## Scoped JavaScript diff --git a/docs/language/stability.md b/docs/language/stability.md index 14e6e9e2..54375793 100644 --- a/docs/language/stability.md +++ b/docs/language/stability.md @@ -91,6 +91,9 @@ Supported exact-name directives (the closed set in | `g:ref` | Partial | Client reference. | | `g:slot` | Partial | Named/scoped slot. | +Component calls also accept `g:bind:` for exported child state +fields. HTML elements remain limited to `g:bind:value` and `g:bind:checked`. + Planned directives are rejected. They currently surface as the generic `parse_error` rather than the intended `unsupported_markup_directive` code; that code lands when markup rejections carry their own code (see diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 1ebc2635..02bbab0a 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -60,10 +60,10 @@ implemented. | Area | Requirement Direction | Status | | --- | --- | --- | | 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 | -| 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 | +| Snippets and slots | Keep slots as the stable reusable markup primitive; defer first-class snippet/render values. | Partial — default, named, and scoped slots are implemented and documented as the reusable-markup contract; first-class snippet/render values remain deferred. | +| Component props | Keep imported Go structs as the primary typed prop path; inline scalar `string`/`int`/`float`/`bool` props, scalar defaults, same-named `{...props}` forwarding, `target:source` prop renaming, and component `g:bind:` for exported child state are supported; recursive rendering and dynamic component selection remain explicit rejections. | 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. | Partial — component-local state, computed values, dependency ordering and cycle diagnostics, handlers, events, effects, lifecycle cleanup, refs, bindings, conditionals, lists, typed exports, and bounded async helpers are implemented; broader browser-owned app behavior remains out of scope. | +| Shared state | Keep stores page/island scoped until cross-package or app-global stores have explicit ownership, serialization, subscription, and teardown contracts. | Partial — page-scoped stores, explicit component `use`, optional local/session persistence, shape invalidation, and SPA-navigation hydration are implemented; app-global stores remain deferred. | | 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. | | Hybrid | Keep hybrid route metadata internal until the source contract is stable; defer streaming, data refresh, and non-HTTP revalidation. | Partial | | Hooks | Compose app-wide hooks as `net/http` middleware plus explicit generated registration points; defer route rewriting and fetch interception. | Planned | @@ -83,9 +83,9 @@ implemented. | Testing and scaffolding | Add optional Go handler tests, generated app smoke tests, template/addon selection, and editable generated examples. | Partial | | Deployment and operations | Prefer docs and optional generators for static hosts, Docker, systemd, reverse proxies, CDN policy, health checks, metrics, logging, binary deploy, rollback, and CSRF secret rotation. | Planned | | Full-page hydration | Keep full-page hydration out of the repository core; use static pages, progressive enhancement, server fragments, and explicit islands. | Intentionally out of scope | -| Island ergonomics | Improve compiler-owned island syntax, lifecycle cleanup, focus helpers, local batching, and diagnostics without exposing arbitrary JavaScript as the app contract. | Planned | -| Client builtins | Add deterministic formatting, collection, async-safe UI, focus, and selection helpers only with generated-output tests. | Planned | -| WASM islands | Keep browser-side Go explicit and separate from backend handlers; improve ABI docs, validation, and examples. | Planned | +| Island ergonomics | Improve compiler-owned island syntax, lifecycle cleanup, focus helpers, local batching, and diagnostics without exposing arbitrary JavaScript as the app contract. | Partial — generated JS islands support idempotent mount/remount, cleanup, lifecycle/effect blocks, bounded refs such as focus/blur/scroll, local batching, and diagnostics; broader HMR-style ergonomics remain deferred. | +| Client builtins | Add deterministic formatting, collection, async-safe UI, focus, and selection helpers only with generated-output tests. | Partial — scalar expression helpers, list mutation built-ins, `fetchJSON`, and safe DOM ref methods are implemented in the bounded client language; broader formatting, selection, and date/time helpers remain deferred. | +| WASM islands | Keep browser-side Go explicit and separate from backend handlers; improve ABI docs, validation, and examples. | Partial — component-level WASM islands have a versioned `gowdk-wasm-island-v1` ABI, required export and signature validation, browser-unsafe import diagnostics, loader/browser tests, and `wasm_exec.js` size/version reporting; richer user-code runtime validation and examples remain deferred. | | PWA/offline | Keep service workers and PWA behavior optional and documentation-first; no hidden offline/cache defaults. | Planned | | Images | Document image optimization patterns first; optional integrations may emit assets or metadata without turning core into an image pipeline. | Planned | | Addon discovery | Start with repository/website docs or registry metadata; add CLI discovery only after addon versioning, trust, and compatibility rules exist. | Planned | diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index fe8a7f0e..f0b4c6c0 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,14 @@ 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), + PropDefaults: irPropDefaults(component.Props), + Exports: irExportTypes(component.Exports), + Body: component.Blocks.ViewBody, } addFragmentComponent(out, compiled) } @@ -229,6 +233,41 @@ 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 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 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/action_input_fields.go b/internal/buildgen/action_input_fields.go new file mode 100644 index 00000000..d9731fb3 --- /dev/null +++ b/internal/buildgen/action_input_fields.go @@ -0,0 +1,27 @@ +package buildgen + +import ( + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/view" +) + +func pageActionInputFields(ir gwdkir.Program) map[string]map[string][]view.ActionInputField { + out := map[string]map[string][]view.ActionInputField{} + for _, endpoint := range ir.Endpoints { + if endpoint.Kind != gwdkir.EndpointAction || endpoint.PageID == "" || len(endpoint.Binding.InputFields) == 0 { + continue + } + if out[endpoint.PageID] == nil { + out[endpoint.PageID] = map[string][]view.ActionInputField{} + } + fields := make([]view.ActionInputField, 0, len(endpoint.Binding.InputFields)) + for _, field := range endpoint.Binding.InputFields { + fields = append(fields, view.ActionInputField{ + FormName: field.FormName, + Type: field.Type, + }) + } + out[endpoint.PageID][endpoint.Symbol] = fields + } + return out +} diff --git a/internal/buildgen/actions_partials_test.go b/internal/buildgen/actions_partials_test.go index 318d6c38..5993db16 100644 --- a/internal/buildgen/actions_partials_test.go +++ b/internal/buildgen/actions_partials_test.go @@ -9,6 +9,7 @@ import ( "github.com/cssbruno/gowdk" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" ) func TestBuildLowersGPostDirectiveForActionPage(t *testing.T) { @@ -41,6 +42,49 @@ func TestBuildLowersGPostDirectiveForActionPage(t *testing.T) { } } +func TestBuildSynthesizesActionInputAttrsFromBindingFields(t *testing.T) { + outputDir := t.TempDir() + ir := gwdkir.Program{Version: gwdkir.Version, Pages: []gwdkir.Page{{ + ID: "signup", + Route: "/signup", + Render: gowdk.Action, + Blocks: gwdkir.Blocks{ + View: true, + ViewBody: `
`, + Actions: []gwdkir.Action{{Name: "Submit"}}, + }, + }}, Endpoints: []gwdkir.Endpoint{{ + Kind: gwdkir.EndpointAction, + Source: gwdkir.EndpointSourceGOWDK, + PageID: "signup", + Symbol: "Submit", + Method: "POST", + Path: "/signup", + Binding: gwdkir.Binding{InputFields: []source.BackendInputField{ + {FieldName: "Age", FormName: "age", Type: "uint8"}, + {FieldName: "Score", FormName: "score", Type: "int16"}, + }}, + }}} + + _, err := BuildFromIR(gowdk.Config{}, ir, outputDir) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(filepath.Join(outputDir, "signup", "index.html")) + if err != nil { + t.Fatal(err) + } + output := string(payload) + for _, want := range []string{ + ``, + ``, + } { + if !strings.Contains(output, want) { + t.Fatalf("expected %q in output:\n%s", want, output) + } + } +} + func TestBuildProductionRequiresBoundBackendHandlers(t *testing.T) { outputDir := t.TempDir() app := gwdkanalysis.Sources{Pages: []gwdkir.Page{{ diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index a28859e0..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" @@ -85,8 +86,7 @@ func buildFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings []sourc } recordWriteStat(&result, wrote) reporter.debug("write", "css_written", "CSS artifact written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) - artifact.CSSArtifact.Hash = contentHash(artifact.contents) - artifact.CSSArtifact.CachePolicy = immutableAssetCachePolicy + finalizeCSSArtifact(&artifact) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) } for _, artifact := range planned.assets { @@ -96,10 +96,7 @@ func buildFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings []sourc } recordWriteStat(&result, wrote) reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) - artifact.AssetArtifact.Hash = contentHash(artifact.contents) - if artifact.AssetArtifact.CachePolicy == "" { - artifact.AssetArtifact.CachePolicy = noCacheAssetCachePolicy - } + finalizeAssetArtifact(&artifact) result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) } for _, artifact := range planned.pages { @@ -128,6 +125,7 @@ func buildFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings []sourc result.AssetManifestPath = assetManifestPath reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, assetManifestPath)}) reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) + reportAssetSizes(reporter, outputDir, result.AssetArtifacts) openAPIPath, err := writeOpenAPI(outputDir, ir) if err != nil { return Result{}, reporter.fail("report", err) @@ -158,6 +156,22 @@ func recordWriteStat(result *Result, wrote bool) { result.WriteStats.IdenticalWritesSkipped++ } +func finalizeCSSArtifact(artifact *plannedCSSArtifact) { + artifact.CSSArtifact.Hash = contentHash(artifact.contents) + artifact.CSSArtifact.CachePolicy = immutableAssetCachePolicy + artifact.CSSArtifact.SizeBytes = int64(len(artifact.contents)) +} + +func finalizeAssetArtifact(artifact *plannedAssetArtifact) { + if artifact.AssetArtifact.Hash == "" { + artifact.AssetArtifact.Hash = contentHash(artifact.contents) + } + if artifact.AssetArtifact.CachePolicy == "" { + artifact.AssetArtifact.CachePolicy = noCacheAssetCachePolicy + } + artifact.AssetArtifact.SizeBytes = int64(len(artifact.contents)) +} + func BuildMemory(config gowdk.Config, sources gwdkanalysis.Sources, outputDir string) (MemoryResult, error) { ir := gwdkanalysis.BuildProgram(config, sources) return buildMemoryFromIR(config, ir, compiler.BackendBindingsFromIR(ir), outputDir) @@ -221,8 +235,7 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ if err != nil { return MemoryResult{}, reporter.fail("memory", err) } - artifact.CSSArtifact.Hash = contentHash(artifact.contents) - artifact.CSSArtifact.CachePolicy = immutableAssetCachePolicy + finalizeCSSArtifact(&artifact) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) result.Files[rel] = append([]byte(nil), artifact.contents...) reporter.debug("memory", "css_collected", "CSS artifact collected", BuildEvent{Path: rel}) @@ -232,10 +245,7 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ if err != nil { return MemoryResult{}, reporter.fail("memory", err) } - artifact.AssetArtifact.Hash = contentHash(artifact.contents) - if artifact.AssetArtifact.CachePolicy == "" { - artifact.AssetArtifact.CachePolicy = noCacheAssetCachePolicy - } + finalizeAssetArtifact(&artifact) result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) result.Files[rel] = append([]byte(nil), artifact.contents...) reporter.debug("memory", "asset_collected", "runtime asset collected", BuildEvent{Path: rel}) @@ -267,6 +277,7 @@ func buildMemoryFromIR(config gowdk.Config, ir gwdkir.Program, backendBindings [ result.Files[assetManifestFile] = assetManifest reporter.info("manifest", "asset_manifest_collected", "asset manifest collected", BuildEvent{Path: assetManifestFile}) reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) + reportAssetSizes(reporter, outputDir, result.AssetArtifacts) openAPI, err := openAPIPayload(ir) if err != nil { return MemoryResult{}, reporter.fail("report", err) @@ -428,6 +439,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st scopedJS, scopedJSFailures := planScopedJSAssets(ir.Assets, outputDir) baseStylesheets := append([]gowdk.Stylesheet{}, config.Build.Stylesheets...) baseStylesheets = append(baseStylesheets, css.stylesheets...) + actionFields := pageActionInputFields(ir) var failures []string failures = append(failures, componentFailures...) @@ -470,6 +482,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st } recordWriteStat(&result, wrote) reporter.debug("write", "css_written", "CSS artifact written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) + finalizeCSSArtifact(&artifact) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) } for _, artifact := range runtime { @@ -479,12 +492,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st } recordWriteStat(&result, wrote) reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) - if artifact.AssetArtifact.Hash == "" { - artifact.AssetArtifact.Hash = contentHash(artifact.contents) - } - if artifact.AssetArtifact.CachePolicy == "" { - artifact.AssetArtifact.CachePolicy = noCacheAssetCachePolicy - } + finalizeAssetArtifact(&artifact) result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) } @@ -518,7 +526,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st changedPageIDs[page.ID] = true stylesheets := append([]gowdk.Stylesheet{}, baseStylesheets...) stylesheets = append(stylesheets, css.pageStylesheets[page.ID]...) - pageArtifacts, err := pageOutputArtifacts(config, outputDir, page, components, layouts, stylesheets) + pageArtifacts, err := pageOutputArtifacts(config, outputDir, page, components, layouts, stylesheets, actionFields[page.ID]) if err != nil { failures = append(failures, err.Error()) continue @@ -560,6 +568,7 @@ func buildIncrementalFromIR(config gowdk.Config, ir gwdkir.Program, outputDir st result.AssetManifestPath = assetManifestPath reporter.info("manifest", "asset_manifest_written", "asset manifest written", BuildEvent{Path: eventPath(outputDir, assetManifestPath)}) reportCachePolicies(reporter, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) + reportAssetSizes(reporter, outputDir, result.AssetArtifacts) openAPIPath, err := writeOpenAPI(outputDir, ir) if err != nil { return Result{}, reporter.fail("report", err) @@ -625,6 +634,48 @@ func reportCachePolicies(reporter *buildReporter, pages []Artifact, css []CSSArt reporter.info("report", "cache_policy", "cache policies summarized", BuildEvent{Data: data}) } +func reportAssetSizes(reporter *buildReporter, outputDir string, assets []AssetArtifact) { + for _, artifact := range assets { + rel := eventPath(outputDir, artifact.Path) + logical := artifactLogicalPath(artifact.LogicalPath, rel) + data := map[string]string{ + "bytes": fmt.Sprint(artifact.SizeBytes), + "kind": assetReportKind(logical), + } + if logical != "" && logical != rel { + data["logicalPath"] = logical + } + if artifact.Hash != "" { + data["hash"] = artifact.Hash + } + 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, + }) + } +} + +func assetReportKind(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".js": + return "javascript" + case ".wasm": + return "wasm" + case ".map": + return "sourcemap" + case ".css": + return "css" + default: + return "asset" + } +} + func pageCachePolicies(artifacts []Artifact) []string { policies := make([]string, 0, len(artifacts)) for _, artifact := range artifacts { @@ -693,6 +744,7 @@ func planFromIR(config gowdk.Config, ir gwdkir.Program, outputDir string) (build scopedJS, scopedJSFailures := planScopedJSAssets(ir.Assets, outputDir) baseStylesheets := append([]gowdk.Stylesheet{}, config.Build.Stylesheets...) baseStylesheets = append(baseStylesheets, css.stylesheets...) + actionFields := pageActionInputFields(ir) var planned []plannedArtifact var failures []string seenOutputPaths := map[string]string{} @@ -707,7 +759,7 @@ func planFromIR(config gowdk.Config, ir gwdkir.Program, outputDir string) (build } stylesheets := append([]gowdk.Stylesheet{}, baseStylesheets...) stylesheets = append(stylesheets, css.pageStylesheets[page.ID]...) - pageArtifacts, err := pageOutputArtifacts(config, outputDir, page, components, layouts, stylesheets) + pageArtifacts, err := pageOutputArtifacts(config, outputDir, page, components, layouts, stylesheets, actionFields[page.ID]) if err != nil { failures = append(failures, err.Error()) continue diff --git a/internal/buildgen/build_report_test.go b/internal/buildgen/build_report_test.go index 0715fb87..cd9ab1df 100644 --- a/internal/buildgen/build_report_test.go +++ b/internal/buildgen/build_report_test.go @@ -461,6 +461,34 @@ func TestBuildReportIncludesCachePolicySummary(t *testing.T) { t.Fatalf("expected cache policy data %s=%q, got %#v", key, expected, event.Data) } } + if len(result.AssetArtifacts) != 1 { + t.Fatalf("expected one generated asset, got %#v", result.AssetArtifacts) + } + runtimeAsset := result.AssetArtifacts[0] + if runtimeAsset.SizeBytes <= 0 { + t.Fatalf("expected generated runtime asset size, got %#v", runtimeAsset) + } + sizeEvent := findBuildReportEvent(result.Report, "report", "asset_size") + if sizeEvent == nil { + t.Fatalf("missing asset_size event in %#v", result.Report.Events) + } + if sizeEvent.Path != clientRuntimeAssetPath { + t.Fatalf("expected runtime asset size path %q, got %#v", clientRuntimeAssetPath, sizeEvent) + } + if sizeEvent.Data["kind"] != "javascript" || sizeEvent.Data["bytes"] == "" || sizeEvent.Data["hash"] == "" { + t.Fatalf("unexpected asset_size data: %#v", sizeEvent.Data) + } + payload, err := os.ReadFile(result.AssetManifestPath) + if err != nil { + t.Fatal(err) + } + var manifest runtimeasset.Manifest + if err := json.Unmarshal(payload, &manifest); err != nil { + t.Fatal(err) + } + if got := manifest.SizeBytes(clientRuntimeAssetPath); got != runtimeAsset.SizeBytes { + t.Fatalf("expected manifest runtime asset size %d, got %d", runtimeAsset.SizeBytes, got) + } } func TestBuildReportIncludesBackendBindingEndpointMetadata(t *testing.T) { diff --git a/internal/buildgen/components.go b/internal/buildgen/components.go index 99a6f785..9a46afc7 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, propDefaults, propFailures := componentProps(component) for _, failure := range propFailures { failures = append(failures, failure) valid = false @@ -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 } @@ -79,6 +84,8 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, ScopeIDs: componentScopeIDs(component), DefaultIsland: componentDefaultIsland(component), Props: props, + PropTypes: propTypes, + PropDefaults: propDefaults, State: state, StateJSON: stateJSON, Handlers: handlers, @@ -86,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, } @@ -168,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 } @@ -186,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() @@ -194,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...), @@ -231,19 +240,82 @@ func componentEmits(component gwdkir.Component) map[string]clientlang.Emit { return out } -func componentPropNames(component gwdkir.Component) ([]string, []string) { +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) if err != nil { - return 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 { + propTypes[field.Name] = clientlang.NormalizeType(field.Type) + } + for field, typ := range resolved.FieldTypes { + propTypes[field] = clientlang.NormalizeType(typ) } - return resolved.FieldNames(), 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 { - 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 +325,15 @@ func componentPropNames(component gwdkir.Component) ([]string, []string) { } 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, 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 238a545e..59a3da9f 100644 --- a/internal/buildgen/components_layouts_test.go +++ b/internal/buildgen/components_layouts_test.go @@ -50,6 +50,109 @@ 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 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 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/buildgen/island_js_source.go b/internal/buildgen/island_js_source.go index 9f5eb39d..57ca6a78 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; @@ -1095,7 +1116,14 @@ func islandJSSource(componentName string, includeSourceMap bool) string { }); }; root.addEventListener("gowdk:props", async (event) => { - Object.assign(state, event.detail || {}); + const nextProps = event.detail || {}; + let changed = false; + Object.keys(nextProps).forEach((name) => { + if (Object.is(state[name], nextProps[name])) return; + state[name] = nextProps[name]; + changed = true; + }); + if (!changed) return; recomputeComputed(state, computeds, helpers); await settleEffects(); recomputeComputed(state, computeds, helpers); @@ -1108,6 +1136,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..066ea564 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,200 @@ 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 = ``, 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 TestRenderWithComponentsExpandsSpreadProps(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Parent": {Name: "Parent", Props: []string{"title"}, Body: ``}, + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-component="Hero"`, + `data-gowdk-props="{"title":"title"}"`, + `

GOWDK

`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in spread prop output:\n%s", want, got) + } + } +} + +func TestRenderWithComponentsRejectsSpreadPropsOutsideComponentPropScope(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err == nil { + t.Fatal("expected spread scope error") + } + if !strings.Contains(err.Error(), "spread source props is not available") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderWithComponentsExpandsPropRenaming(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

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

GOWDK

` { + t.Fatalf("unexpected renamed prop output: %s", got) + } +} + +func TestRenderWithComponentsExpandsPropRenamingShorthand(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Parent": {Name: "Parent", Props: []string{"heading"}, Body: ``}, + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-component="Hero"`, + `data-gowdk-props="{"title":"heading"}"`, + `

GOWDK

`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in renamed prop output:\n%s", want, got) + } + } +} + +func TestRenderWithComponentsRejectsPropCollision(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Parent": {Name: "Parent", Props: []string{"title"}, Body: ``}, + "Hero": {Name: "Hero", Props: []string{"title"}, Body: `

{title}

`}, + }) + if err == nil { + t.Fatal("expected prop collision error") + } + if !strings.Contains(err.Error(), `prop "title" is provided more than once`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRenderWithComponentsWiresBindableChildState(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Parent": { + Name: "Parent", + State: map[string]string{"SelectedID": "first"}, + StateJSON: `{"SelectedID":"first"}`, + StateTypes: map[string]clientlang.ValueType{"SelectedID": clientlang.TypeString}, + Body: ``, + }, + "Child": { + Name: "Child", + State: map[string]string{"selected": ""}, + StateJSON: `{"selected":""}`, + StateTypes: map[string]clientlang.ValueType{"selected": clientlang.TypeString}, + Exports: map[string]clientlang.ValueType{"selected": clientlang.TypeString}, + HandlersJSON: `{"exports":["selected"]}`, + Body: `

{selected}

`, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-component="Child"`, + `data-gowdk-state="{"selected":"first"}"`, + `data-gowdk-props="{"selected":"SelectedID"}"`, + `data-gowdk-parent-on-exports="SelectedID = event.selected"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in bind output:\n%s", want, got) + } + } +} + +func TestRenderWithComponentsRejectsBindableChildStateWithoutExport(t *testing.T) { + _, err := RenderWithComponents(``, map[string]Component{ + "Parent": { + Name: "Parent", + State: map[string]string{"SelectedID": "first"}, + StateJSON: `{"SelectedID":"first"}`, + StateTypes: map[string]clientlang.ValueType{"SelectedID": clientlang.TypeString}, + Body: ``, + }, + "Child": { + Name: "Child", + State: map[string]string{"selected": ""}, + StateJSON: `{"selected":""}`, + StateTypes: map[string]clientlang.ValueType{"selected": clientlang.TypeString}, + Body: `

{selected}

`, + }, + }) + if err == nil { + t.Fatal("expected missing bind export error") + } + if !strings.Contains(err.Error(), `bind target "selected" must be declared in exports`) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestParseRejectsUnsupportedTemplateSyntaxWithGOWDKAlternatives(t *testing.T) { tests := []struct { name string @@ -119,6 +276,104 @@ 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 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": { @@ -371,6 +626,39 @@ func TestRenderWithComponentsWiresParentComponentEventListener(t *testing.T) { } } +func TestRenderWithComponentsWiresTypedExportListener(t *testing.T) { + got, err := RenderWithComponents(``, map[string]Component{ + "Parent": { + Name: "Parent", + State: map[string]string{"SelectedID": ""}, + StateJSON: `{"SelectedID":""}`, + StateTypes: map[string]clientlang.ValueType{"SelectedID": clientlang.TypeString}, + Body: ``, + }, + "Child": { + Name: "Child", + State: map[string]string{"ID": "first"}, + StateJSON: `{"ID":"first"}`, + StateTypes: map[string]clientlang.ValueType{"ID": clientlang.TypeString}, + Exports: map[string]clientlang.ValueType{"ID": clientlang.TypeString}, + HandlersJSON: `{"exports":["ID"]}`, + Body: `

{ID}

`, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `data-gowdk-parent-on-exports="SelectedID = event.ID"`, + `data-gowdk-client="{"exports":["ID"]}"`, + `

first

`, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in typed export output:\n%s", want, got) + } + } +} + func TestRenderWithComponentsMarksReactivePropExpressions(t *testing.T) { got, err := RenderWithComponents(``, map[string]Component{ "Parent": { @@ -1260,6 +1548,47 @@ func TestRenderWithOptionsLowersGPostDirective(t *testing.T) { } } +func TestRenderWithOptionsSynthesizesActionInputAttrs(t *testing.T) { + got, err := RenderWithOptions(`
`, nil, nil, Options{ + Actions: map[string]string{"save": "/profile"}, + ActionInputFields: map[string][]ActionInputField{ + "save": { + {FormName: "age", Type: "uint8"}, + {FormName: "score", Type: "int16"}, + {FormName: "email", Type: "string"}, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + ``, + ``, + ``, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected %q in output:\n%s", want, got) + } + } +} + +func TestRenderWithOptionsKeepsExplicitActionInputAttrs(t *testing.T) { + got, err := RenderWithOptions(`
`, nil, nil, Options{ + Actions: map[string]string{"save": "/profile"}, + ActionInputFields: map[string][]ActionInputField{ + "save": {{FormName: "age", Type: "uint8"}}, + }, + }) + if err != nil { + t.Fatal(err) + } + want := `
` + if got != want { + t.Fatalf("unexpected output:\n%s", got) + } +} + func TestRenderWithOptionsMarksGCommandForm(t *testing.T) { got, err := RenderWithOptions(`
`, nil, nil, Options{}) if err != nil { @@ -1519,6 +1848,19 @@ func TestComponentCallUsagesMarksReactiveProps(t *testing.T) { } } +func TestComponentCallUsagesMarksSpreadPropsReactive(t *testing.T) { + usages, err := ComponentCallUsages(``) + if err != nil { + t.Fatal(err) + } + if len(usages) != 2 { + t.Fatalf("unexpected component usages: %#v", usages) + } + if usages[1].Component != "Child" || !usages[1].ReactiveProps { + t.Fatalf("expected spread props to be reactive, got %#v", usages[1]) + } +} + func TestViewDependenciesIncludesClassShorthand(t *testing.T) { dependencies, err := ViewDependencies(`
`) if err != nil { diff --git a/runtime/asset/asset.go b/runtime/asset/asset.go index 9a22269f..ad4d4793 100644 --- a/runtime/asset/asset.go +++ b/runtime/asset/asset.go @@ -8,6 +8,7 @@ type Manifest struct { Files map[string]string `json:"files"` Hashes map[string]string `json:"hashes,omitempty"` Cache map[string]string `json:"cache,omitempty"` + Sizes map[string]int64 `json:"sizes,omitempty"` } // Resolve returns the emitted path for a logical asset name. @@ -34,6 +35,15 @@ func (manifest Manifest) CachePolicy(name string) string { return manifest.Cache[name] } +// SizeBytes returns the generated asset byte size recorded for a logical asset +// name, or zero when the manifest has no size metadata for that asset. +func (manifest Manifest) SizeBytes(name string) int64 { + if manifest.Sizes == nil { + return 0 + } + return manifest.Sizes[name] +} + // URL returns the browser-facing URL for a logical asset name. func (manifest Manifest) URL(name string) string { resolved := manifest.Resolve(name) diff --git a/runtime/asset/asset_test.go b/runtime/asset/asset_test.go index bc57e755..df9b5bcc 100644 --- a/runtime/asset/asset_test.go +++ b/runtime/asset/asset_test.go @@ -14,6 +14,9 @@ func TestManifestResolve(t *testing.T) { Cache: map[string]string{ "assets/app.css": "public, max-age=31536000, immutable", }, + Sizes: map[string]int64{ + "assets/app.css": 42, + }, } if got := manifest.Resolve("assets/app.css"); got != "assets/app.css" { @@ -28,6 +31,12 @@ func TestManifestResolve(t *testing.T) { if got := manifest.CachePolicy("assets/app.css"); got != "public, max-age=31536000, immutable" { t.Fatalf("expected asset cache policy, got %q", got) } + if got := manifest.SizeBytes("assets/app.css"); got != 42 { + t.Fatalf("expected asset size, got %d", got) + } + if got := manifest.SizeBytes("missing.css"); got != 0 { + t.Fatalf("expected missing asset size to be zero, got %d", got) + } if got := (Manifest{}).Resolve("assets/app.css"); got != "" { t.Fatalf("expected nil manifest to resolve empty, got %q", got) }