From bad0ec6436899d9db61d0bc1ed79ccb943a46a8d Mon Sep 17 00:00:00 2001 From: cssbruno Date: Thu, 20 Aug 2026 19:11:49 -0300 Subject: [PATCH 1/3] feat(compiler): harden v1 execution contracts --- addons/actions/actions.go | 2 +- addons/api/api.go | 2 +- addons/contracts/contracts.go | 2 +- addons/css/css.go | 2 +- addons/db/db.go | 2 +- addons/embed/embed.go | 2 +- addons/observability/observability.go | 2 +- addons/partial/partial.go | 2 +- addons/ratelimit/ratelimit.go | 2 +- addons/realtime/realtime.go | 2 +- addons/spa/spa.go | 2 +- addons/ssr/ssr.go | 2 +- addons/static/static.go | 2 +- config.go | 45 ++ config_build.go | 108 +++ config_css.go | 14 + config_interop.go | 173 ++++ config_interop_test.go | 46 ++ config_validation.go | 152 ++++ config_validation_test.go | 45 ++ examples/auth-guard/gowdk.config.go | 4 + .../flagship/apphooks/flagship_hooks.go.txt | 8 - examples/flagship/gowdk.config.go | 5 + examples/flagship/src/app/app.go | 5 + examples/i18n/gowdk.config.go | 1 + examples/i18n/messages.go | 16 + gowdk.go | 459 ++++++----- internal/appgen/appgen.go | 130 ++- internal/appgen/appgen_test.go | 152 ++-- internal/appgen/auto_routes.go | 8 + internal/appgen/build.go | 314 +++++-- internal/appgen/build_test.go | 174 ++++ internal/appgen/files.go | 69 +- internal/appgen/ir.go | 15 +- internal/appgen/ir_test.go | 11 +- internal/appgen/scripts.go | 47 +- internal/appgen/source.go | 7 +- internal/appgen/source_actions.go | 43 +- internal/appgen/source_api.go | 2 +- internal/appgen/source_auth.go | 9 + internal/appgen/source_backend_app.go | 7 + internal/appgen/source_guards.go | 10 +- internal/appgen/source_i18n_errors.go | 73 ++ .../generated_go_golden/app.go.golden | 36 +- internal/appgen/types.go | 6 +- internal/buildgen/build.go | 384 ++++----- internal/buildgen/build_data_routes_test.go | 6 +- internal/buildgen/components.go | 43 +- internal/buildgen/css.go | 17 +- internal/buildgen/data.go | 4 +- internal/buildgen/data_util.go | 21 - internal/buildgen/incremental_test.go | 6 +- internal/buildgen/interop_test.go | 17 + internal/buildgen/ir_test.go | 37 + internal/buildgen/islands_test.go | 3 +- internal/buildgen/manifests.go | 141 ---- internal/buildgen/openapi.go | 13 - internal/buildgen/render.go | 23 +- internal/buildgen/report.go | 12 - internal/buildgen/routes.go | 16 - internal/buildgen/runtime_islands.go | 36 +- internal/buildgen/security_manifest.go | 27 - internal/buildgen/seo.go | 27 +- internal/buildgen/ssr_list_test.go | 2 +- internal/buildgen/ssr_test.go | 16 +- internal/buildgen/store_use_fields_test.go | 2 + internal/buildgen/test_helpers_test.go | 62 ++ internal/buildgen/types.go | 25 +- internal/compiler/assemble.go | 2 +- .../compiler/backend_binding_diagnostics.go | 6 + internal/compiler/backend_binding_policy.go | 2 +- internal/compiler/backend_bindings.go | 42 +- internal/compiler/interop_bindings_test.go | 66 ++ internal/compiler/validate.go | 2 + .../compiler/validate_component_client.go | 18 +- .../compiler/validate_component_contracts.go | 9 +- .../validate_component_fingerprint.go | 15 +- internal/compiler/validate_component_lists.go | 6 +- internal/compiler/validate_component_view.go | 8 - .../validate_component_view_contract.go | 17 +- internal/compiler/validate_directive_lanes.go | 81 ++ .../compiler/validate_directive_lanes_test.go | 39 + internal/compiler/validate_identity.go | 36 +- internal/compiler/validate_interop.go | 74 ++ internal/compiler/validate_page_lists.go | 12 +- internal/compiler/validate_scripts.go | 27 +- internal/compiler/validate_source_uses.go | 26 +- internal/compiler/validate_stores.go | 8 +- internal/diagnostics/explain.go | 30 + internal/diagnostics/registry.go | 10 + internal/gowdkcmd/audit.go | 6 +- internal/gowdkcmd/audit_test.go | 17 +- internal/gowdkcmd/build.go | 174 ++-- internal/gowdkcmd/build_audit.go | 3 + internal/gowdkcmd/command_schema.go | 232 ++++++ internal/gowdkcmd/command_schema_test.go | 53 ++ internal/gowdkcmd/config_helper.go | 34 +- internal/gowdkcmd/dev.go | 35 +- internal/gowdkcmd/dev_loop.go | 141 ++-- internal/gowdkcmd/env.go | 3 +- internal/gowdkcmd/go_bindings_report.go | 85 +- internal/gowdkcmd/go_bindings_report_test.go | 26 + internal/gowdkcmd/main.go | 134 +-- internal/gowdkcmd/main_test.go | 200 ++++- internal/gowdkcmd/operation_error.go | 19 + internal/gowdkcmd/project_inputs.go | 24 +- internal/gowdkcmd/serve.go | 71 +- internal/gowdkcmd/test.go | 14 +- internal/gwdkanalysis/ir_builder.go | 114 +++ internal/gwdkir/directive_lanes.go | 51 ++ internal/gwdkir/invariants.go | 42 +- internal/gwdkir/ir.go | 64 +- internal/lang/accessibility.go | 20 +- internal/lang/completion.go | 5 +- internal/lang/conformance_coverage_test.go | 1 + internal/lang/format_test.go | 4 +- internal/lang/manifest_json.go | 22 +- internal/lang/sitemap_test.go | 9 +- internal/lang/stability.go | 63 +- internal/lang/stability_doc_test.go | 2 +- .../format_parser_backed/expected.gwdk | 4 +- .../testdata/format_parser_backed/input.gwdk | 4 +- internal/lang/tools.go | 83 +- internal/lang/tools_test.go | 8 +- internal/lsp/completion_fields.go | 98 ++- internal/lsp/completion_hover.go | 2 +- internal/lsp/components.go | 3 +- internal/lsp/limits_test.go | 166 ++++ internal/lsp/notifications.go | 66 +- internal/lsp/rpc.go | 114 ++- internal/lsp/server.go | 61 +- internal/parser/component_lower.go | 1 + internal/parser/diagnostic.go | 2 + internal/parser/directive_lanes.go | 67 ++ internal/parser/directive_lanes_test.go | 65 ++ internal/parser/layout.go | 1 + internal/parser/page_lower.go | 1 + internal/parser/patterns.go | 3 + internal/parser/syntax.go | 3 + internal/parser/syntax_test.go | 13 + internal/playground/sandbox_linux_test.go | 8 +- internal/project/config.go | 387 ++++++++- internal/project/config_exec.go | 765 +++++++++++++++--- internal/project/config_test.go | 258 +++++- internal/project/config_validation.go | 33 +- internal/projectcompile/compile.go | 160 ++++ internal/projectcompile/compile_test.go | 49 ++ internal/publicapi/gowdk_test.go | 45 ++ internal/publish/publish.go | 247 ++++++ internal/publish/publish_test.go | 123 +++ internal/securitymanifest/manifest.go | 30 +- internal/securitymanifest/manifest_test.go | 19 +- internal/source/source.go | 3 + internal/viewanalysis/canonical.go | 15 +- internal/viewanalysis/contract_references.go | 6 + internal/viewparse/directives.go | 9 +- internal/viewrender/bindings.go | 6 + internal/viewrender/component.go | 3 + internal/viewrender/directives.go | 1 + internal/viewrender/server_list.go | 53 +- runtime/api/response.go | 11 + runtime/api/response_test.go | 16 + runtime/envfile/envfile.go | 136 +++- runtime/envfile/envfile_test.go | 51 +- runtime/guard/response.go | 13 +- runtime/i18n/error_bundle_test.go | 16 + runtime/i18n/i18n.go | 43 + runtime/response/response.go | 141 +++- runtime/response/response_test.go | 4 +- runtime/response/user_errors_test.go | 56 ++ runtime/validation/validation.go | 23 +- testfixture/interop/hooks.go | 26 + 172 files changed, 6797 insertions(+), 2088 deletions(-) create mode 100644 config.go create mode 100644 config_build.go create mode 100644 config_css.go create mode 100644 config_interop.go create mode 100644 config_interop_test.go create mode 100644 config_validation.go create mode 100644 config_validation_test.go create mode 100644 internal/appgen/build_test.go create mode 100644 internal/appgen/source_i18n_errors.go create mode 100644 internal/buildgen/interop_test.go create mode 100644 internal/compiler/interop_bindings_test.go create mode 100644 internal/compiler/validate_directive_lanes.go create mode 100644 internal/compiler/validate_directive_lanes_test.go create mode 100644 internal/compiler/validate_interop.go create mode 100644 internal/gowdkcmd/command_schema.go create mode 100644 internal/gowdkcmd/command_schema_test.go create mode 100644 internal/gowdkcmd/go_bindings_report_test.go create mode 100644 internal/gwdkir/directive_lanes.go create mode 100644 internal/lsp/limits_test.go create mode 100644 internal/parser/directive_lanes.go create mode 100644 internal/parser/directive_lanes_test.go create mode 100644 internal/projectcompile/compile.go create mode 100644 internal/projectcompile/compile_test.go create mode 100644 internal/publish/publish.go create mode 100644 internal/publish/publish_test.go create mode 100644 runtime/i18n/error_bundle_test.go create mode 100644 runtime/response/user_errors_test.go create mode 100644 testfixture/interop/hooks.go diff --git a/addons/actions/actions.go b/addons/actions/actions.go index 21d82314..ffc90522 100644 --- a/addons/actions/actions.go +++ b/addons/actions/actions.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/actions" // Addon enables typed backend actions and form handling. func Addon() gowdk.Addon { - return gowdk.NewAddon("actions", gowdk.FeatureActions) + return gowdk.NewBuiltinAddon("actions", gowdk.FeatureActions) } diff --git a/addons/api/api.go b/addons/api/api.go index c72ed67e..5820599b 100644 --- a/addons/api/api.go +++ b/addons/api/api.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/api" // Addon enables generated API handlers. func Addon() gowdk.Addon { - return gowdk.NewAddon("api", gowdk.FeatureAPI) + return gowdk.NewBuiltinAddon("api", gowdk.FeatureAPI) } diff --git a/addons/contracts/contracts.go b/addons/contracts/contracts.go index 02174ac8..b10aee7c 100644 --- a/addons/contracts/contracts.go +++ b/addons/contracts/contracts.go @@ -9,5 +9,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/contracts" // Addon enables contract-driven runtime metadata and generated adapters once // the compiler integration lands. func Addon() gowdk.Addon { - return gowdk.NewAddon("contracts", gowdk.FeatureContracts) + return gowdk.NewBuiltinAddon("contracts", gowdk.FeatureContracts) } diff --git a/addons/css/css.go b/addons/css/css.go index c0f2342d..c5b3d8d5 100644 --- a/addons/css/css.go +++ b/addons/css/css.go @@ -8,7 +8,7 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/css" // Addon enables compile-time CSS processing. func Addon() gowdk.Addon { - return gowdk.NewAddon("css", gowdk.FeatureCSS) + return gowdk.NewBuiltinAddon("css", gowdk.FeatureCSS) } // Processor is the compile-time CSS plugin contract. diff --git a/addons/db/db.go b/addons/db/db.go index 25f0def7..f5ce8a1c 100644 --- a/addons/db/db.go +++ b/addons/db/db.go @@ -25,7 +25,7 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/db" // Addon enables the database plumbing feature. func Addon() gowdk.Addon { - return gowdk.NewAddon("db", gowdk.FeatureDB) + return gowdk.NewBuiltinAddon("db", gowdk.FeatureDB) } // Options tunes the connection pool. The zero value applies sensible defaults. diff --git a/addons/embed/embed.go b/addons/embed/embed.go index a8b572d8..c076dfd8 100644 --- a/addons/embed/embed.go +++ b/addons/embed/embed.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/embed" // Addon enables one-binary embedded asset serving. func Addon() gowdk.Addon { - return gowdk.NewAddon("embed", gowdk.FeatureEmbed) + return gowdk.NewBuiltinAddon("embed", gowdk.FeatureEmbed) } diff --git a/addons/observability/observability.go b/addons/observability/observability.go index f2d3c223..1fb1c5de 100644 --- a/addons/observability/observability.go +++ b/addons/observability/observability.go @@ -20,7 +20,7 @@ type Collector = gowdktrace.Collector // Addon enables generated app trace wiring. func Addon() gowdk.Addon { - return gowdk.NewAddon("observability", gowdk.FeatureObservability) + return gowdk.NewBuiltinAddon("observability", gowdk.FeatureObservability) } // CollectorOption configures a Collector. diff --git a/addons/partial/partial.go b/addons/partial/partial.go index ce0e130e..429923fa 100644 --- a/addons/partial/partial.go +++ b/addons/partial/partial.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/partial" // Addon enables server fragments and partial swaps. func Addon() gowdk.Addon { - return gowdk.NewAddon("partial", gowdk.FeaturePartial) + return gowdk.NewBuiltinAddon("partial", gowdk.FeaturePartial) } diff --git a/addons/ratelimit/ratelimit.go b/addons/ratelimit/ratelimit.go index 8c75d0e8..cd7207cc 100644 --- a/addons/ratelimit/ratelimit.go +++ b/addons/ratelimit/ratelimit.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/ratelimit" // Addon enables request-time rate limiting support. func Addon() gowdk.Addon { - return gowdk.NewAddon("ratelimit", gowdk.FeatureRateLimit) + return gowdk.NewBuiltinAddon("ratelimit", gowdk.FeatureRateLimit) } diff --git a/addons/realtime/realtime.go b/addons/realtime/realtime.go index 68b991e6..ea2030d4 100644 --- a/addons/realtime/realtime.go +++ b/addons/realtime/realtime.go @@ -27,7 +27,7 @@ type SSEStats = runtimerealtime.SSEStats // Addon enables realtime presentation-event fanout support. func Addon() gowdk.Addon { - return gowdk.NewAddon("realtime", gowdk.FeatureRealtime) + return gowdk.NewBuiltinAddon("realtime", gowdk.FeatureRealtime) } // NewSSE creates a dependency-free server-sent events presentation fanout hub. diff --git a/addons/spa/spa.go b/addons/spa/spa.go index 08f87a5a..4909b6a8 100644 --- a/addons/spa/spa.go +++ b/addons/spa/spa.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/spa" // Addon enables build-time prerendering. func Addon() gowdk.Addon { - return gowdk.NewAddon("spa", gowdk.FeatureSPA) + return gowdk.NewBuiltinAddon("spa", gowdk.FeatureSPA) } diff --git a/addons/ssr/ssr.go b/addons/ssr/ssr.go index f1e9ca68..12be8268 100644 --- a/addons/ssr/ssr.go +++ b/addons/ssr/ssr.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/ssr" // Addon enables request-time full-page rendering. func Addon() gowdk.Addon { - return gowdk.NewAddon("ssr", gowdk.FeatureSSR) + return gowdk.NewBuiltinAddon("ssr", gowdk.FeatureSSR) } diff --git a/addons/static/static.go b/addons/static/static.go index 8b643fe9..a317b815 100644 --- a/addons/static/static.go +++ b/addons/static/static.go @@ -7,5 +7,5 @@ const ImportPath = "github.com/cssbruno/gowdk/addons/static" // Addon enables build-time static page output. func Addon() gowdk.Addon { - return gowdk.NewAddon("static", gowdk.FeatureSPA) + return gowdk.NewBuiltinAddon("static", gowdk.FeatureSPA) } diff --git a/config.go b/config.go new file mode 100644 index 00000000..365874dc --- /dev/null +++ b/config.go @@ -0,0 +1,45 @@ +package gowdk + +// Config is the root application configuration. Each field delegates to a +// focused config type; ValidateStructural rejects invalid cross-field states. +type Config struct { + AppName string + Source SourceConfig + Modules []ModuleConfig + Render RenderConfig + I18N I18NConfig + Env EnvConfig + Lifecycle LifecycleConfig + Interop InteropConfig + Build BuildConfig + CSS CSSConfig + Features FeatureConfig + Extensions []Extension + // Addons is the deprecated 0.x compatibility lane. New config should use + // Features for built-ins and Extensions for build-time behavior. + Addons []Addon +} + +// SourceConfig selects portable .gwdk files for discovery. +type SourceConfig struct { + Include []string + Exclude []string +} + +// ModuleConfig names a source group inside a GOWDK app. +type ModuleConfig struct { + Name string + Type string + Source SourceConfig +} + +// RenderConfig controls default render behavior. SPA is the default. +type RenderConfig struct { + Default RenderMode +} + +// BuildParams carries compile-time route values into Go build helpers. +type BuildParams struct { + Route map[string]string `json:"route,omitempty"` + Locale string `json:"locale,omitempty"` +} diff --git a/config_build.go b/config_build.go new file mode 100644 index 00000000..cbbadcd8 --- /dev/null +++ b/config_build.go @@ -0,0 +1,108 @@ +package gowdk + +// BuildConfig controls output artifacts and frontend asset packaging. +type BuildConfig struct { + Output string + Mode BuildMode + Assets AssetMode + ObfuscateAssets bool + Head HeadConfig + CSRF CSRFConfig + CORS CORSConfig + SecurityHeaders SecurityHeadersConfig + BodyLimits BodyLimitsConfig + AllowMissingBackend bool + Stylesheets []Stylesheet + Scripts []Script + Worker ContractWorkerConfig + Cron ContractCronConfig + Targets []BuildTargetConfig +} + +type HeadConfig struct { + SiteName string + Favicon string + Image string + TwitterCard string +} + +type SecurityHeadersConfig struct { + Enabled bool + Headers map[string]string +} + +type CORSConfig struct { + Enabled bool + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + ExposedHeaders []string + AllowCredentials bool + MaxAgeSeconds int +} + +// CSRFConfig is enabled by default. Disabled is its single explicit opt-out. +type CSRFConfig struct { + Disabled bool + SecretEnv string + VerificationSecretEnvs []string + CookieName string + FieldName string + HeaderName string + Insecure bool +} + +type BodyLimitsConfig struct { + ActionBytes int64 + APIBytes int64 +} + +// BuildTargetConfig declares one independently publishable artifact set. +type BuildTargetConfig struct { + Name string + Modules []string + Output string + App string + Binary string + WASM string + BackendApp string + BackendBinary string + WorkerApp string + WorkerBinary string + Worker ContractWorkerConfig + CronApp string + CronBinary string + Cron ContractCronConfig + DeployRecipes []string +} + +type ContractWorkerConfig struct { + EventSource ServiceRef + SeenStore ServiceRef + Backoff ServiceRef +} + +type ContractCronConfig struct { + Jobs []ContractCronJobConfig +} + +type ContractCronJobConfig struct { + Type string + Schedule string + OverlapPolicy string + MissedRunPolicy string +} + +type AssetMode string + +const ( + AssetExternal AssetMode = "external" + Embed AssetMode = "embed" +) + +type BuildMode string + +const ( + Development BuildMode = "development" + Production BuildMode = "production" +) diff --git a/config_css.go b/config_css.go new file mode 100644 index 00000000..070e6bcc --- /dev/null +++ b/config_css.go @@ -0,0 +1,14 @@ +package gowdk + +// CSSConfig controls discovered CSS inputs and page CSS output. +type CSSConfig struct { + Include []string + Exclude []string + Default []string + Output CSSOutputConfig +} + +type CSSOutputConfig struct { + Dir string + HrefPrefix string +} diff --git a/config_interop.go b/config_interop.go new file mode 100644 index 00000000..85681b68 --- /dev/null +++ b/config_interop.go @@ -0,0 +1,173 @@ +package gowdk + +import ( + "fmt" + "reflect" + "runtime" + "strings" + + gowdkauth "github.com/cssbruno/gowdk/runtime/auth" + gowdkguard "github.com/cssbruno/gowdk/runtime/guard" +) + +// InteropConfig explicitly connects generated runtime integration points to +// ordinary application Go packages. Registration constructors accept real Go +// functions so rename and find-references tooling can follow each binding. +type InteropConfig struct { + Loads []LoadRegistration + Guards GuardRegistration + AuthProvider AuthProviderRegistration +} + +// GoHookRef is compiler metadata derived from a typed Go function value. +// Applications should create refs through the Register* constructors. +type GoHookRef struct { + ImportPath string + Function string + SourceFile string +} + +// LoadRegistration binds one page ID to its request-time load function. +type LoadRegistration struct { + Page string + Hook GoHookRef + err string +} + +// GuardRegistration binds generated route startup to a guard registry provider. +type GuardRegistration struct { + Hook GoHookRef + err string +} + +// AuthProviderRegistration binds native RBAC guards to an auth provider factory. +type AuthProviderRegistration struct { + Hook GoHookRef + err string +} + +// RegisterLoad explicitly binds a page server block to an exported Go load +// function. The compiler validates the supported ssr.LoadContext signature. +func RegisterLoad(page string, load any) LoadRegistration { + ref, err := hookRef(load) + registration := LoadRegistration{Page: strings.TrimSpace(page), Hook: ref} + if err != nil { + registration.err = err.Error() + } + return registration +} + +// RegisterGuards explicitly binds custom guards to a typed registry provider. +func RegisterGuards(provider func() gowdkguard.Registry) GuardRegistration { + ref, err := hookRef(provider) + registration := GuardRegistration{Hook: ref} + if err != nil { + registration.err = err.Error() + } + return registration +} + +// RegisterAuthProvider explicitly binds native RBAC to a typed provider factory. +func RegisterAuthProvider(provider func() gowdkauth.Provider) AuthProviderRegistration { + ref, err := hookRef(provider) + registration := AuthProviderRegistration{Hook: ref} + if err != nil { + registration.err = err.Error() + } + return registration +} + +func hookRef(function any) (GoHookRef, error) { + value := reflect.ValueOf(function) + if !value.IsValid() || value.Kind() != reflect.Func || value.IsNil() { + return GoHookRef{}, fmt.Errorf("registration requires a non-nil package-level function") + } + resolved := runtime.FuncForPC(value.Pointer()) + if resolved == nil { + return GoHookRef{}, fmt.Errorf("registration function could not be resolved") + } + fullName := resolved.Name() + dot := strings.LastIndex(fullName, ".") + if dot <= strings.LastIndex(fullName, "/") || dot == len(fullName)-1 { + return GoHookRef{}, fmt.Errorf("registration requires an exported package-level function") + } + functionName := fullName[dot+1:] + if !exportedIdentifier(functionName) || strings.ContainsAny(functionName, ".[]") { + return GoHookRef{}, fmt.Errorf("registration function %q must be an exported, non-generic package-level function", fullName) + } + file, _ := resolved.FileLine(value.Pointer()) + return GoHookRef{ImportPath: fullName[:dot], Function: functionName, SourceFile: file}, nil +} + +func exportedIdentifier(value string) bool { + if value == "" { + return false + } + first := rune(value[0]) + return first >= 'A' && first <= 'Z' +} + +// Validate rejects incomplete, duplicate, and constructor-invalid bindings. +func (config InteropConfig) Validate() error { + seenPages := map[string]bool{} + for index, registration := range config.Loads { + if registration.err != "" { + return fmt.Errorf("Interop.Loads[%d]: %s", index, registration.err) + } + page := strings.TrimSpace(registration.Page) + if page == "" { + return fmt.Errorf("Interop.Loads[%d].Page is required", index) + } + if seenPages[page] { + return fmt.Errorf("Interop.Loads[%d].Page %q is registered more than once", index, page) + } + seenPages[page] = true + if err := registration.Hook.validate(fmt.Sprintf("Interop.Loads[%d].Hook", index)); err != nil { + return err + } + } + if config.Guards.err != "" { + return fmt.Errorf("Interop.Guards: %s", config.Guards.err) + } + if !config.Guards.Hook.empty() { + if err := config.Guards.Hook.validate("Interop.Guards.Hook"); err != nil { + return err + } + } + if config.AuthProvider.err != "" { + return fmt.Errorf("Interop.AuthProvider: %s", config.AuthProvider.err) + } + if !config.AuthProvider.Hook.empty() { + if err := config.AuthProvider.Hook.validate("Interop.AuthProvider.Hook"); err != nil { + return err + } + } + return nil +} + +func (ref GoHookRef) empty() bool { + return strings.TrimSpace(ref.ImportPath) == "" && strings.TrimSpace(ref.Function) == "" && strings.TrimSpace(ref.SourceFile) == "" +} + +func (ref GoHookRef) validate(path string) error { + if strings.TrimSpace(ref.ImportPath) == "" || strings.TrimSpace(ref.Function) == "" || strings.TrimSpace(ref.SourceFile) == "" { + return fmt.Errorf("%s must be created by a typed Register* constructor", path) + } + return nil +} + +// LoadForPage returns the explicit request-time load binding for page, if any. +func (config InteropConfig) LoadForPage(page string) (LoadRegistration, bool) { + for _, registration := range config.Loads { + if registration.Page == page { + return registration, true + } + } + return LoadRegistration{}, false +} + +// Configured reports whether a guard provider was explicitly registered. +func (registration GuardRegistration) Configured() bool { return !registration.Hook.empty() } + +// Configured reports whether an auth provider was explicitly registered. +func (registration AuthProviderRegistration) Configured() bool { return !registration.Hook.empty() } diff --git a/config_interop_test.go b/config_interop_test.go new file mode 100644 index 00000000..749b8fa4 --- /dev/null +++ b/config_interop_test.go @@ -0,0 +1,46 @@ +package gowdk + +import ( + "net/http" + "strings" + "testing" + + gowdkauth "github.com/cssbruno/gowdk/runtime/auth" + gowdkguard "github.com/cssbruno/gowdk/runtime/guard" + "github.com/cssbruno/gowdk/runtime/ssr" +) + +func InteropTestLoad(ssr.LoadContext) map[string]any { return nil } +func InteropTestGuards() gowdkguard.Registry { return nil } +func InteropTestAuth() gowdkauth.Provider { + return gowdkauth.ProviderFunc(func(*http.Request) (*gowdkauth.Principal, error) { return nil, nil }) +} + +func TestTypedInteropRegistrationsCaptureNavigableGoSymbols(t *testing.T) { + config := Config{Interop: InteropConfig{ + Loads: []LoadRegistration{RegisterLoad("dashboard", InteropTestLoad)}, + Guards: RegisterGuards(InteropTestGuards), + AuthProvider: RegisterAuthProvider(InteropTestAuth), + }} + if err := config.ValidateStructural(); err != nil { + t.Fatal(err) + } + load, ok := config.Interop.LoadForPage("dashboard") + if !ok || load.Hook.Function != "InteropTestLoad" || load.Hook.ImportPath != "github.com/cssbruno/gowdk" || load.Hook.SourceFile == "" { + t.Fatalf("unexpected load registration: %#v", load) + } +} + +func TestInteropRegistrationsRejectDuplicatesAndNonFunctions(t *testing.T) { + config := Config{Interop: InteropConfig{Loads: []LoadRegistration{ + RegisterLoad("dashboard", InteropTestLoad), + RegisterLoad("dashboard", InteropTestLoad), + }}} + if err := config.ValidateStructural(); err == nil || !strings.Contains(err.Error(), "registered more than once") { + t.Fatalf("expected duplicate page registration error, got %v", err) + } + config = Config{Interop: InteropConfig{Loads: []LoadRegistration{RegisterLoad("dashboard", "not a function")}}} + if err := config.ValidateStructural(); err == nil || !strings.Contains(err.Error(), "non-nil package-level function") { + t.Fatalf("expected typed function error, got %v", err) + } +} diff --git a/config_validation.go b/config_validation.go new file mode 100644 index 00000000..e57dc06e --- /dev/null +++ b/config_validation.go @@ -0,0 +1,152 @@ +package gowdk + +import ( + "fmt" + "strings" +) + +// ValidateStructural rejects invalid configuration states before compiler, +// generator, or runtime planning begins. It never reads process environment. +func (config Config) ValidateStructural() error { + if err := config.Source.Validate("Source"); err != nil { + return err + } + seenModules := map[string]bool{} + for index, module := range config.Modules { + name := strings.TrimSpace(module.Name) + if name == "" { + return fmt.Errorf("Modules[%d].Name is required", index) + } + if seenModules[name] { + return fmt.Errorf("Modules[%d].Name %q is declared more than once", index, name) + } + seenModules[name] = true + if err := module.Source.Validate(fmt.Sprintf("Modules[%d].Source", index)); err != nil { + return err + } + } + if err := config.Render.Validate(); err != nil { + return err + } + if err := config.Env.Validate(nil); err != nil { + return fmt.Errorf("env: %w", err) + } + if err := config.Lifecycle.Validate(); err != nil { + return err + } + if err := config.Interop.Validate(); err != nil { + return err + } + if err := config.I18N.Validate(); err != nil { + return err + } + if err := config.Build.Validate(); err != nil { + return err + } + if err := ValidateAddons(config.Addons); err != nil { + return fmt.Errorf("addons: %w", err) + } + if err := ValidateExtensions(config.Extensions); err != nil { + return fmt.Errorf("extensions: %w", err) + } + return nil +} + +// Validate checks source include/exclude entries for silent empty values. +func (config SourceConfig) Validate(path string) error { + for index, value := range config.Include { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s.Include[%d] must not be empty", path, index) + } + } + for index, value := range config.Exclude { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s.Exclude[%d] must not be empty", path, index) + } + } + return nil +} + +// Validate checks the configured default rendering lane. +func (config RenderConfig) Validate() error { + switch config.Default { + case "", SPA, Hybrid, SSR: + return nil + default: + return fmt.Errorf("Render.Default has unknown mode %q", config.Default) + } +} + +// Validate rejects build fields that would otherwise be ignored or normalized +// differently by separate command paths. +func (config BuildConfig) Validate() error { + switch config.Mode { + case "", Development, Production: + default: + return fmt.Errorf("Build.Mode has unknown value %q", config.Mode) + } + switch config.Assets { + case "", AssetExternal, Embed: + default: + return fmt.Errorf("Build.Assets has unknown value %q", config.Assets) + } + if config.ObfuscateAssets && config.Mode != Production { + return fmt.Errorf("Build.ObfuscateAssets requires Build.Mode = gowdk.Production") + } + if err := config.CORS.Validate(); err != nil { + return err + } + if err := config.CSRF.Validate(); err != nil { + return err + } + if !config.SecurityHeaders.Enabled && len(config.SecurityHeaders.Headers) > 0 { + return fmt.Errorf("Build.SecurityHeaders.Headers requires Enabled = true") + } + if config.BodyLimits.ActionBytes < 0 { + return fmt.Errorf("Build.BodyLimits.ActionBytes must not be negative") + } + if config.BodyLimits.APIBytes < 0 { + return fmt.Errorf("Build.BodyLimits.APIBytes must not be negative") + } + seenTargets := map[string]bool{} + for index, target := range config.Targets { + if err := target.Validate(index, seenTargets); err != nil { + return err + } + } + return nil +} + +// Validate checks one configured artifact target before command selection. +func (target BuildTargetConfig) Validate(index int, seen map[string]bool) error { + name := strings.TrimSpace(target.Name) + if name == "" { + return fmt.Errorf("Build.Targets[%d].Name is required", index) + } + if seen[name] { + return fmt.Errorf("Build.Targets[%d].Name %q is declared more than once", index, name) + } + seen[name] = true + dependencies := []struct { + artifact string + owner string + label string + }{ + {target.Binary, target.App, "Binary requires App"}, + {target.WASM, target.App, "WASM requires App"}, + {target.BackendBinary, target.BackendApp, "BackendBinary requires BackendApp"}, + {target.WorkerBinary, target.WorkerApp, "WorkerBinary requires WorkerApp"}, + {target.CronBinary, target.CronApp, "CronBinary requires CronApp"}, + } + for _, dependency := range dependencies { + if strings.TrimSpace(dependency.artifact) != "" && strings.TrimSpace(dependency.owner) == "" { + return fmt.Errorf("Build.Targets[%d].%s", index, dependency.label) + } + } + for recipeIndex, recipe := range target.DeployRecipes { + if strings.TrimSpace(recipe) == "" { + return fmt.Errorf("Build.Targets[%d].DeployRecipes[%d] must not be empty", index, recipeIndex) + } + } + return nil +} diff --git a/config_validation_test.go b/config_validation_test.go new file mode 100644 index 00000000..d2dd8d32 --- /dev/null +++ b/config_validation_test.go @@ -0,0 +1,45 @@ +package gowdk + +import ( + "strings" + "testing" +) + +func TestConfigValidateStructuralRejectsInvalidStates(t *testing.T) { + tests := []struct { + name string + config Config + want string + }{ + {name: "render mode", config: Config{Render: RenderConfig{Default: "magic"}}, want: "Render.Default"}, + {name: "build mode", config: Config{Build: BuildConfig{Mode: "fast"}}, want: "Build.Mode"}, + {name: "asset mode", config: Config{Build: BuildConfig{Assets: "inline"}}, want: "Build.Assets"}, + {name: "obfuscation mode", config: Config{Build: BuildConfig{ObfuscateAssets: true}}, want: "requires Build.Mode"}, + {name: "disabled cors fields", config: Config{Build: BuildConfig{CORS: CORSConfig{AllowedOrigins: []string{"https://example.com"}}}}, want: "require Enabled"}, + {name: "disabled csrf fields", config: Config{Build: BuildConfig{CSRF: CSRFConfig{Disabled: true, SecretEnv: "SECRET"}}}, want: "cannot be combined"}, + {name: "security header state", config: Config{Build: BuildConfig{SecurityHeaders: SecurityHeadersConfig{Headers: map[string]string{"X-Test": "yes"}}}}, want: "requires Enabled"}, + {name: "negative body limit", config: Config{Build: BuildConfig{BodyLimits: BodyLimitsConfig{APIBytes: -1}}}, want: "APIBytes"}, + {name: "duplicate module", config: Config{Modules: []ModuleConfig{{Name: "site"}, {Name: "site"}}}, want: "declared more than once"}, + {name: "binary without app", config: Config{Build: BuildConfig{Targets: []BuildTargetConfig{{Name: "site", Binary: "bin/site"}}}}, want: "Binary requires App"}, + {name: "duplicate target", config: Config{Build: BuildConfig{Targets: []BuildTargetConfig{{Name: "site"}, {Name: "site"}}}}, want: "declared more than once"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.config.ValidateStructural() + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ValidateStructural() error = %v, want %q", err, test.want) + } + }) + } +} + +func TestConfigValidateStructuralAcceptsDefaultAndExplicitBuild(t *testing.T) { + for _, config := range []Config{ + {}, + {Render: RenderConfig{Default: SPA}, Build: BuildConfig{Mode: Production, Assets: Embed, ObfuscateAssets: true, Targets: []BuildTargetConfig{{Name: "site", App: ".gowdk/site", Binary: "bin/site"}}}}, + } { + if err := config.ValidateStructural(); err != nil { + t.Fatalf("ValidateStructural() = %v", err) + } + } +} diff --git a/examples/auth-guard/gowdk.config.go b/examples/auth-guard/gowdk.config.go index d364b186..b2197fcf 100644 --- a/examples/auth-guard/gowdk.config.go +++ b/examples/auth-guard/gowdk.config.go @@ -6,6 +6,7 @@ import ( "github.com/cssbruno/gowdk" authaddon "github.com/cssbruno/gowdk/addons/auth" "github.com/cssbruno/gowdk/addons/ssr" + authguard "github.com/cssbruno/gowdk/examples/auth-guard/src/authguard" ) var Config = gowdk.Config{ @@ -13,6 +14,9 @@ var Config = gowdk.Config{ Source: gowdk.SourceConfig{ Include: []string{"src/**/*.gwdk"}, }, + Interop: gowdk.InteropConfig{Loads: []gowdk.LoadRegistration{ + gowdk.RegisterLoad("dashboard", authguard.LoadDashboard), + }}, Env: gowdk.EnvConfig{ Secrets: []gowdk.SecretEnv{ {Name: "GOWDK_AUTH_SESSION_SECRET", Required: true, MinBytes: 32}, diff --git a/examples/flagship/apphooks/flagship_hooks.go.txt b/examples/flagship/apphooks/flagship_hooks.go.txt index 75b44c1d..ed5a6889 100644 --- a/examples/flagship/apphooks/flagship_hooks.go.txt +++ b/examples/flagship/apphooks/flagship_hooks.go.txt @@ -3,8 +3,6 @@ package gowdkapp import ( "time" - flagship "github.com/cssbruno/gowdk/examples/flagship/src/app" - gowdkguard "github.com/cssbruno/gowdk/runtime/guard" gowdkratelimit "github.com/cssbruno/gowdk/runtime/ratelimit" ) @@ -20,9 +18,3 @@ func init() { } RegisterRateLimiter(limiter) } - -func GOWDKGuardRegistry() gowdkguard.Registry { - return gowdkguard.Registry{ - "auth.required": flagship.RequireSession, - } -} diff --git a/examples/flagship/gowdk.config.go b/examples/flagship/gowdk.config.go index d7ea0627..9081fe28 100644 --- a/examples/flagship/gowdk.config.go +++ b/examples/flagship/gowdk.config.go @@ -6,6 +6,7 @@ import ( "github.com/cssbruno/gowdk/addons/partial" "github.com/cssbruno/gowdk/addons/ratelimit" "github.com/cssbruno/gowdk/addons/ssr" + flagship "github.com/cssbruno/gowdk/examples/flagship/src/app" ) var Config = gowdk.Config{ @@ -13,6 +14,10 @@ var Config = gowdk.Config{ Source: gowdk.SourceConfig{ Include: []string{"src/**/*.gwdk"}, }, + Interop: gowdk.InteropConfig{ + Loads: []gowdk.LoadRegistration{gowdk.RegisterLoad("dashboard", flagship.LoadDashboard)}, + Guards: gowdk.RegisterGuards(flagship.Guards), + }, Build: gowdk.BuildConfig{ Output: "dist", Targets: []gowdk.BuildTargetConfig{ diff --git a/examples/flagship/src/app/app.go b/examples/flagship/src/app/app.go index 66b985e3..472b7777 100644 --- a/examples/flagship/src/app/app.go +++ b/examples/flagship/src/app/app.go @@ -103,6 +103,11 @@ func RequireSession(ctx guard.Context) error { return guard.RedirectTo("/?login=required") } +// Guards is the explicit typed runtime registration used by gowdk.config.go. +func Guards() guard.Registry { + return guard.Registry{"auth.required": RequireSession} +} + func LoadDashboard(ctx ssr.LoadContext) (map[string]any, error) { current, ok := currentSession(ctx.Request) if !ok { diff --git a/examples/i18n/gowdk.config.go b/examples/i18n/gowdk.config.go index 32d1b6a4..474d60c3 100644 --- a/examples/i18n/gowdk.config.go +++ b/examples/i18n/gowdk.config.go @@ -17,6 +17,7 @@ var Config = gowdk.Config{ }, I18N: gowdk.I18NConfig{ DefaultLocale: localeEnglish, + Errors: runtimeErrorMessages, Locales: []gowdk.LocaleConfig{ {Code: localeEnglish, Name: "English"}, {Code: localePortuguese, Name: "Portuguese"}, diff --git a/examples/i18n/messages.go b/examples/i18n/messages.go index e0f8cc61..ae065abf 100644 --- a/examples/i18n/messages.go +++ b/examples/i18n/messages.go @@ -33,6 +33,22 @@ var homeMessages = gowdki18n.NewBundle("en", map[string]gowdki18n.Catalog[messag }), }) +const ( + ErrorInvalidForm gowdki18n.ErrorCode = "invalid_form" + ErrorValidationRequired gowdki18n.ErrorCode = "validation_required" +) + +var runtimeErrorMessages = gowdki18n.NewErrorBundle("en", map[string]gowdki18n.Catalog[gowdki18n.ErrorCode]{ + "en": gowdki18n.NewCatalog("en", map[gowdki18n.ErrorCode]string{ + ErrorInvalidForm: "The submitted form is invalid.", + ErrorValidationRequired: "{field} is required.", + }), + "pt": gowdki18n.NewCatalog("pt", map[gowdki18n.ErrorCode]string{ + ErrorInvalidForm: "O formulário enviado é inválido.", + ErrorValidationRequired: "{field} é obrigatório.", + }), +}) + func HomeCopyForBuild(params gowdk.BuildParams) HomeCopy { locale := params.LocaleCode() if locale == "" { diff --git a/gowdk.go b/gowdk.go index ae6d7dfc..1cea2c33 100644 --- a/gowdk.go +++ b/gowdk.go @@ -1,6 +1,7 @@ package gowdk import ( + "context" "fmt" "reflect" "strings" @@ -8,51 +9,10 @@ import ( "unicode" "github.com/cssbruno/gowdk/runtime/corsorigin" + gowdki18n "github.com/cssbruno/gowdk/runtime/i18n" runtimeseo "github.com/cssbruno/gowdk/runtime/seo" ) -// Config describes how a GOWDK application should be discovered, compiled, -// and packaged. -type Config struct { - AppName string - Source SourceConfig - Modules []ModuleConfig - Render RenderConfig - I18N I18NConfig - Env EnvConfig - Lifecycle LifecycleConfig - Build BuildConfig - CSS CSSConfig - Addons []Addon -} - -// SourceConfig selects portable .gwdk files for discovery. -type SourceConfig struct { - Include []string - Exclude []string -} - -// ModuleConfig names a source group inside a GOWDK app. Build discovery uses -// selected module sources to decide what gets compiled into output, generated -// apps, and generated binaries. Type is user-defined metadata. -type ModuleConfig struct { - Name string - Type string - Source SourceConfig -} - -// RenderConfig controls default render behavior. SPA is the default when -// omitted. -type RenderConfig struct { - Default RenderMode -} - -// BuildParams carries compile-time route values into Go build helpers. -type BuildParams struct { - Route map[string]string `json:"route,omitempty"` - Locale string `json:"locale,omitempty"` -} - // Param returns a declared dynamic route param by name. func (params BuildParams) Param(name string) (string, bool) { name = strings.TrimSpace(name) @@ -95,6 +55,9 @@ type I18NConfig struct { Locales []LocaleConfig DefaultLocale string OmitDefaultPrefix bool + // Errors localizes stable runtime error codes. Missing entries use each + // error's safe default message. + Errors gowdki18n.ErrorBundle } // LocaleConfig declares one locale available to build-time and request-time @@ -453,55 +416,6 @@ func (config LifecycleConfig) Validate() error { return nil } -// BuildConfig controls output artifacts and frontend asset packaging. -type BuildConfig struct { - Output string - Mode BuildMode - Assets AssetMode - ObfuscateAssets bool - Head HeadConfig - CSRF CSRFConfig - CORS CORSConfig - SecurityHeaders SecurityHeadersConfig - BodyLimits BodyLimitsConfig - AllowMissingBackend bool - Stylesheets []Stylesheet - Scripts []Script - Worker ContractWorkerConfig - Cron ContractCronConfig - Targets []BuildTargetConfig -} - -// HeadConfig controls app-level document head tags emitted around page -// metadata. -type HeadConfig struct { - SiteName string - Favicon string - Image string - TwitterCard string -} - -// SecurityHeadersConfig declares generated runtime response headers. Audit -// policy can require these headers statically, and generated audit tests can -// verify that the handler emits them. -type SecurityHeadersConfig struct { - Enabled bool - Headers map[string]string -} - -// CORSConfig controls generated CORS headers and preflight handling for API -// and web contract endpoints. It is disabled by default, so generated -// endpoints remain same-origin unless a policy is declared. -type CORSConfig struct { - Enabled bool - AllowedOrigins []string - AllowedMethods []string - AllowedHeaders []string - ExposedHeaders []string - AllowCredentials bool - MaxAgeSeconds int -} - // EnabledForGeneratedAPIs reports whether generated API/contract routes should // install CORS handling. func (config CORSConfig) EnabledForGeneratedAPIs() bool { @@ -511,6 +425,9 @@ func (config CORSConfig) EnabledForGeneratedAPIs() bool { // Validate checks structural safety rules for the generated CORS policy. func (config CORSConfig) Validate() error { if !config.Enabled { + if len(config.AllowedOrigins) > 0 || len(config.AllowedMethods) > 0 || len(config.AllowedHeaders) > 0 || len(config.ExposedHeaders) > 0 || config.AllowCredentials || config.MaxAgeSeconds != 0 { + return fmt.Errorf("Build.CORS policy fields require Enabled = true") + } return nil } if config.MaxAgeSeconds < 0 { @@ -576,23 +493,9 @@ func isHTTPToken(value string) bool { const DefaultCSRFSecretEnv = "GOWDK_CSRF_SECRET" -// CSRFConfig controls generated CSRF token wiring for browser-reachable -// state-changing endpoints. -type CSRFConfig struct { - Enabled bool - Disabled bool - SecretEnv string - VerificationSecretEnvs []string - CookieName string - FieldName string - HeaderName string - Insecure bool -} - // EnabledForGeneratedEndpoints reports whether generated state-changing // endpoints should emit CSRF token injection and validation. CSRF is on by -// default; Disabled is the explicit opt-out. Enabled is retained for older -// configs that already set it. +// default; Disabled is the single explicit opt-out. func (config CSRFConfig) EnabledForGeneratedEndpoints() bool { return !config.Disabled } @@ -625,6 +528,9 @@ func (config CSRFConfig) SecretEnvNames() []string { // Validate checks the structural CSRF configuration without reading secret // values from the runtime environment. func (config CSRFConfig) Validate() error { + if config.Disabled && (strings.TrimSpace(config.SecretEnv) != "" || len(config.VerificationSecretEnvs) > 0 || strings.TrimSpace(config.CookieName) != "" || strings.TrimSpace(config.FieldName) != "" || strings.TrimSpace(config.HeaderName) != "" || config.Insecure) { + return fmt.Errorf("Build.CSRF.Disabled cannot be combined with CSRF secret, token-name, or Insecure fields") + } seen := map[string]bool{config.SecretEnvName(): true} for index, name := range config.VerificationSecretEnvNames() { if name == "" { @@ -642,13 +548,6 @@ func (config CSRFConfig) Validate() error { // action and API endpoints. const DefaultRequestBodyLimitBytes int64 = 1 << 20 -// BodyLimitsConfig controls generated request body caps. Omitted or non-positive -// values use the default 1 MiB cap. -type BodyLimitsConfig struct { - ActionBytes int64 - APIBytes int64 -} - // ActionLimitBytes returns the configured action body cap or the default cap. func (config BodyLimitsConfig) ActionLimitBytes() int64 { if config.ActionBytes > 0 { @@ -665,84 +564,6 @@ func (config BodyLimitsConfig) APILimitBytes() int64 { return DefaultRequestBodyLimitBytes } -// BuildTargetConfig declares one configured build target. Modules selects the -// configured source modules compiled into Output, App, Binary, WASM, BackendApp, -// BackendBinary, and optional deployment recipes. -type BuildTargetConfig struct { - Name string - Modules []string - Output string - App string - Binary string - WASM string - BackendApp string - BackendBinary string - WorkerApp string - WorkerBinary string - Worker ContractWorkerConfig - CronApp string - CronBinary string - Cron ContractCronConfig - DeployRecipes []string -} - -// ContractWorkerConfig controls generated standalone contract worker targets. -// EventSource is required and must name a function returning -// (contracts.EventSource, error). SeenStore and Backoff are optional provider -// hooks returning (contracts.SeenStore, error) and -// (contracts.EventWorkerBackoff, error). -type ContractWorkerConfig struct { - EventSource ServiceRef - SeenStore ServiceRef - Backoff ServiceRef -} - -// ContractCronConfig controls generated standalone scheduled job targets. -type ContractCronConfig struct { - Jobs []ContractCronJobConfig -} - -// ContractCronJobConfig declares one generated cron role job. Type accepts the -// scanned job type name, package-qualified name, or full import-path-qualified -// name. Schedule currently supports @once and @every . -type ContractCronJobConfig struct { - Type string - Schedule string - OverlapPolicy string - MissedRunPolicy string -} - -// CSSConfig controls discovered CSS inputs and page CSS output. -type CSSConfig struct { - Include []string - Exclude []string - Default []string - Output CSSOutputConfig -} - -// CSSOutputConfig controls generated page stylesheet locations. -type CSSOutputConfig struct { - Dir string - HrefPrefix string -} - -// AssetMode controls how frontend artifacts are shipped. -type AssetMode string - -const ( - AssetExternal AssetMode = "external" - Embed AssetMode = "embed" -) - -// BuildMode controls whether generated frontend artifacts include development -// metadata such as source maps. Development is the default when omitted. -type BuildMode string - -const ( - Development BuildMode = "development" - Production BuildMode = "production" -) - // DebugAssets reports whether generated frontend artifacts should include // debugging metadata. func (config BuildConfig) DebugAssets() bool { @@ -790,7 +611,8 @@ func (mode RenderMode) IsBuildTime() bool { return mode == SPA } -// Feature names a compiler or generator capability selected from Config.Addons. +// Feature names a compiler or generator capability selected from Config.Features +// or the deprecated Config.Addons compatibility lane. // A feature flag enables GOWDK-owned behavior; it does not by itself mean the // addon object runs request-time application code. type Feature string @@ -812,6 +634,99 @@ const ( FeatureObservability Feature = "observability" ) +// FeatureConfig selects built-in compiler behavior through typed config. +type FeatureConfig struct { + SPA bool + Actions bool + Partial bool + SSR bool + API bool + Embed bool + CSS bool + RateLimit bool + Contracts bool + Realtime bool + Auth AuthFeatureConfig + DB bool + SEO SEOFeatureConfig + Observability bool +} + +// AuthFeatureConfig owns built-in auth feature selection and its typed session +// configuration. +type AuthFeatureConfig struct { + Enabled bool + Session AuthSessionOptions +} + +// SEOFeatureConfig owns built-in SEO feature selection and options. +type SEOFeatureConfig struct { + Enabled bool + Options SEOOptions +} + +func (config FeatureConfig) enabled() FeatureSet { + features := FeatureSet{} + for feature, enabled := range map[Feature]bool{ + FeatureSPA: config.SPA, FeatureActions: config.Actions, + FeaturePartial: config.Partial, FeatureSSR: config.SSR, + FeatureAPI: config.API, FeatureEmbed: config.Embed, + FeatureCSS: config.CSS, FeatureRateLimit: config.RateLimit, + FeatureContracts: config.Contracts, FeatureRealtime: config.Realtime, + FeatureAuth: config.Auth.Enabled, FeatureDB: config.DB, + FeatureSEO: config.SEO.Enabled, FeatureObservability: config.Observability, + } { + if enabled { + features[feature] = true + } + } + return features +} + +// ExtensionProtocolVersion is the executable build-time extension contract +// supported by this GOWDK release. +const ExtensionProtocolVersion = 1 + +const ( + ExtensionCapabilityCSSProcessor = "gowdk.css-processor" + ExtensionCapabilityGoBlockConsumer = "gowdk.go-block-consumer" + ExtensionCapabilitySEOProvider = "gowdk.seo-provider" + ExtensionCapabilityAuthSession = "gowdk.auth-session-provider" +) + +// ExtensionPhase names an explicit compiler phase an extension participates in. +type ExtensionPhase string + +const ( + ExtensionPhaseValidate ExtensionPhase = "validate" + ExtensionPhasePlan ExtensionPhase = "plan" + ExtensionPhaseGeneratedGo ExtensionPhase = "generated-go" + ExtensionPhaseCSS ExtensionPhase = "css" + ExtensionPhaseBuildMetadata ExtensionPhase = "build-metadata" +) + +// ExtensionCapabilityDescriptor declares one versioned host capability. +type ExtensionCapabilityDescriptor struct { + Name string + Version int + Required bool +} + +// ExtensionDescriptor identifies a behaviorful build-time extension. +type ExtensionDescriptor struct { + Name string + ProtocolVersion int + Phases []ExtensionPhase + Capabilities []ExtensionCapabilityDescriptor +} + +// Extension is distinct from built-in feature selection. Optional compiler +// capabilities are exposed through ExtensionCapabilityProvider. +type Extension interface { + Name() string + Descriptor() ExtensionDescriptor +} + // Addon is a config declaration for a named feature set. Some addons also // implement build-time extension interfaces such as CSSProcessor, SEOProvider, // or GoBlockConsumer; request-time services remain wired by generated app hooks @@ -879,6 +794,13 @@ type GoBlockConsumer interface { GeneratedGo(target GoBlockTarget, context GoBlockContext) ([]GoBlockFile, error) } +// GoBlockConsumerContext is the cancellable form used by executable hosts. +type GoBlockConsumerContext interface { + GoBlockConsumer + ValidateGoBlockContext(context.Context, GoBlockTarget, GoBlockContext) []GoBlockDiagnostic + GeneratedGoContext(context.Context, GoBlockTarget, GoBlockContext) ([]GoBlockFile, error) +} + // GoBlockTarget describes one parsed go block passed to an addon. type GoBlockTarget struct { Target string @@ -928,9 +850,20 @@ type addon struct { features []Feature } -// NewAddon creates a simple config marker for feature checks. +type builtinAddon struct{ addon } + +// NewAddon creates the legacy built-in feature marker. New configuration should +// prefer Config.Features; the constructor remains trusted for 0.x compatibility. func NewAddon(name string, features ...Feature) Addon { - return addon{name: name, features: append([]Feature(nil), features...)} + return NewBuiltinAddon(name, features...) +} + +// NewBuiltinAddon creates the deprecated constructor adapter used by bundled +// feature packages. The concrete marker is sealed so ordinary custom Addon +// implementations cannot claim compiler-owned features without implementing a +// matching executable capability. +func NewBuiltinAddon(name string, features ...Feature) Addon { + return builtinAddon{addon{name: name, features: append([]Feature(nil), features...)}} } func (a addon) Name() string { @@ -963,6 +896,9 @@ func ValidateAddons(addons []Addon) error { if len(addonFeatures) == 0 { return fmt.Errorf("addons[%d] %q must declare at least one feature", index, name) } + if err := validateAddonFeatureContracts(index, name, addon, addonFeatures); err != nil { + return err + } for featureIndex, feature := range addonFeatures { if strings.TrimSpace(string(feature)) == "" { return fmt.Errorf("addons[%d] %q declares empty feature at index %d", index, name, featureIndex) @@ -973,14 +909,45 @@ func ValidateAddons(addons []Addon) error { if _, ok := features[feature]; !ok { features[feature] = index } - } - if err := validateAddonFeatureContracts(index, name, addon, addonFeatures); err != nil { - return err + if isCoreFeature(feature) && !isBuiltinAddon(addon) && !legacyExecutableFeature(addon, feature) { + return fmt.Errorf("addons[%d] %q cannot claim built-in feature %q; use Config.Features or migrate executable behavior to Config.Extensions", index, name, feature) + } } } return nil } +func isBuiltinAddon(value Addon) bool { + _, ok := value.(builtinAddon) + return ok +} + +func isCoreFeature(feature Feature) bool { + switch feature { + case FeatureSPA, FeatureActions, FeaturePartial, FeatureSSR, FeatureAPI, + FeatureEmbed, FeatureCSS, FeatureRateLimit, FeatureContracts, + FeatureRealtime, FeatureAuth, FeatureDB, FeatureSEO, + FeatureObservability: + return true + default: + return false + } +} + +func legacyExecutableFeature(addon Addon, feature Feature) bool { + capabilities := ResolveAddonCapabilities(addon) + switch feature { + case FeatureCSS: + return capabilities.CSSProcessor != nil + case FeatureSEO: + return capabilities.SEOProvider != nil + case FeatureAuth: + return capabilities.AuthSessionProvider != nil + default: + return false + } +} + func validateAddonFeatureContracts(index int, name string, addon Addon, features []Feature) error { capabilities := ResolveAddonCapabilities(addon) for _, feature := range features { @@ -1018,9 +985,10 @@ func duplicateFeatureAllowed(feature Feature) bool { // FeatureSet is a lookup table of enabled compiler/generator capabilities. type FeatureSet map[Feature]bool -// EnabledFeatures returns the feature flags declared by Config.Addons. +// EnabledFeatures returns built-in feature selection plus deprecated addon +// compatibility markers. Executable extensions cannot claim built-in features. func EnabledFeatures(config Config) FeatureSet { - features := FeatureSet{} + features := config.Features.enabled() for _, addon := range config.Addons { if addonIsNil(addon) { continue @@ -1037,7 +1005,7 @@ func (features FeatureSet) Has(feature Feature) bool { return features[feature] } -// HasFeature reports whether Config.Addons declares a feature flag. +// HasFeature reports whether typed or compatibility config selects a feature. func (config Config) HasFeature(feature Feature) bool { return EnabledFeatures(config).Has(feature) } @@ -1093,6 +1061,12 @@ type CSSProcessor interface { ProcessCSS(CSSContext) (CSSResult, error) } +// CSSProcessorContext is the cancellable form used by executable hosts. +type CSSProcessorContext interface { + CSSProcessor + ProcessCSSContext(context.Context, CSSContext) (CSSResult, error) +} + // AddonCapabilities describes the optional compiler and generated-output // capabilities exposed by an addon. Executable config bridges use this // descriptor because Go interface method sets cannot be reconstructed @@ -1111,6 +1085,91 @@ type AddonCapabilityProvider interface { AddonCapabilities() AddonCapabilities } +// ExtensionCapabilityProvider exposes the executable capabilities of an +// Extension independently from built-in feature selection. +type ExtensionCapabilityProvider interface { + ExtensionCapabilities() AddonCapabilities +} + +// ResolveExtensionCapabilities returns the explicit capabilities of an +// executable extension. +func ResolveExtensionCapabilities(extension Extension) AddonCapabilities { + if extension == nil { + return AddonCapabilities{} + } + provider, ok := extension.(ExtensionCapabilityProvider) + if !ok { + return AddonCapabilities{} + } + return provider.ExtensionCapabilities() +} + +// ValidateExtensions checks protocol, identity, phases, and capability +// descriptors without executing extension operations. +func ValidateExtensions(extensions []Extension) error { + names := map[string]int{} + for index, extension := range extensions { + if extension == nil { + return fmt.Errorf("extensions[%d] is nil", index) + } + descriptor := extension.Descriptor() + name := strings.TrimSpace(extension.Name()) + if name == "" || strings.TrimSpace(descriptor.Name) != name { + return fmt.Errorf("extensions[%d] has inconsistent name %q", index, name) + } + if previous, ok := names[name]; ok { + return fmt.Errorf("extensions[%d] %q duplicates extensions[%d]", index, name, previous) + } + names[name] = index + if descriptor.ProtocolVersion != ExtensionProtocolVersion { + return fmt.Errorf("extensions[%d] %q requires protocol %d; GOWDK supports %d", index, name, descriptor.ProtocolVersion, ExtensionProtocolVersion) + } + phases := map[ExtensionPhase]bool{} + for phaseIndex, phase := range descriptor.Phases { + switch phase { + case ExtensionPhaseValidate, ExtensionPhasePlan, ExtensionPhaseGeneratedGo, ExtensionPhaseCSS, ExtensionPhaseBuildMetadata: + default: + return fmt.Errorf("extensions[%d] %q has unknown phase %q at index %d", index, name, phase, phaseIndex) + } + if phases[phase] { + return fmt.Errorf("extensions[%d] %q repeats phase %q", index, name, phase) + } + phases[phase] = true + } + resolved := ResolveExtensionCapabilities(extension) + capabilityNames := map[string]bool{} + for capabilityIndex, capability := range descriptor.Capabilities { + if strings.TrimSpace(capability.Name) == "" || capability.Version <= 0 { + return fmt.Errorf("extensions[%d] %q has invalid capability at index %d", index, name, capabilityIndex) + } + if capabilityNames[capability.Name] { + return fmt.Errorf("extensions[%d] %q repeats capability %q", index, name, capability.Name) + } + capabilityNames[capability.Name] = true + if capability.Version != 1 && capability.Required { + return fmt.Errorf("extensions[%d] %q requires unsupported capability %q version %d", index, name, capability.Name, capability.Version) + } + available := true + switch capability.Name { + case ExtensionCapabilityCSSProcessor: + available = resolved.CSSProcessor != nil + case ExtensionCapabilityGoBlockConsumer: + available = resolved.GoBlockConsumer != nil + case ExtensionCapabilitySEOProvider: + available = resolved.SEOProvider != nil + case ExtensionCapabilityAuthSession: + available = resolved.AuthSessionProvider != nil + default: + available = false + } + if capability.Required && !available { + return fmt.Errorf("extensions[%d] %q requires unavailable capability %q", index, name, capability.Name) + } + } + } + return nil +} + // ResolveAddonCapabilities returns explicit addon capabilities when available, // otherwise it derives them from the optional interfaces implemented directly // by the addon. diff --git a/internal/appgen/appgen.go b/internal/appgen/appgen.go index bf7c073c..a79da9a0 100644 --- a/internal/appgen/appgen.go +++ b/internal/appgen/appgen.go @@ -4,9 +4,10 @@ package appgen import ( "fmt" "go/format" - "os" "path/filepath" "strings" + + "github.com/cssbruno/gowdk/internal/publish" ) const ( @@ -136,7 +137,6 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re return Result{}, err } plannedFiles := append([]plannedFile(nil), outputFiles...) - var removeAfterPublish []string modulePath := filepath.Join(absApp, modFileName) if moduleContext.Nested { modulePayload, err := moduleSource(options) @@ -145,7 +145,6 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re } plannedFiles = append(plannedFiles, plannedFile{path: modulePath, contents: []byte(modulePayload)}) } else { - removeAfterPublish = append(removeAfterPublish, modulePath) modulePath = "" } packageSource, err := appPackageSource(options) @@ -165,7 +164,6 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re path := filepath.Join(absApp, name) source, ok := lifecycleSources[name] if !ok { - removeAfterPublish = append(removeAfterPublish, path) continue } formatted, err := formatGeneratedGo(name, source) @@ -181,8 +179,6 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re auditTestPath := filepath.Join(absApp, auditTestFileName) if len(auditTestSource) > 0 { plannedFiles = append(plannedFiles, plannedFile{path: auditTestPath, contents: auditTestSource}) - } else { - removeAfterPublish = append(removeAfterPublish, auditTestPath) } scriptFiles, scriptPlannedFiles, err := collectInlineGoBlockFiles(absApp, options) if err != nil { @@ -201,25 +197,24 @@ func GenerateWithPlan(outputDir, appDir string, plan ApplicationPlan) (result Re return Result{}, err } plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, mainFileName), contents: []byte(mainSource)}) - if err := os.MkdirAll(absApp, 0o755); err != nil { - return Result{}, err - } - if err := os.MkdirAll(targetOutput, 0o755); err != nil { + var publication publish.Transaction + stageApp, err := publication.StageDirectory(absApp) + if err != nil { return Result{}, err } + defer publication.Abort() for _, file := range plannedFiles { - if err := writeFileIfChanged(file.path, file.contents); err != nil { + rel, err := filepath.Rel(absApp, file.path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Result{}, fmt.Errorf("planned generated app file %q escapes app directory %q", file.path, absApp) + } + if err := stageGeneratedFile(filepath.Join(stageApp, rel), file.path, file.contents); err != nil { return Result{}, err } } - if err := removeStaleOutputFiles(targetOutput, files); err != nil { + if err := publication.Commit(); err != nil { return Result{}, err } - for _, path := range removeAfterPublish { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return Result{}, err - } - } return Result{ AppDir: absApp, @@ -260,13 +255,16 @@ func GenerateBackendWithPlan(appDir string, plan ApplicationPlan) (result Result if !plan.backendOnly { return Result{}, fmt.Errorf("embedded application plan cannot be used for backend app generation") } - if err := os.MkdirAll(absApp, 0o755); err != nil { - return Result{}, err - } moduleContext := resolveGeneratedModuleContext(absApp) - modulePath, err := writeGeneratedModuleFile(absApp, moduleContext, options) - if err != nil { - return Result{}, err + var plannedFiles []plannedFile + modulePath := "" + if moduleContext.Nested { + modulePayload, err := moduleSource(options) + if err != nil { + return Result{}, err + } + modulePath = filepath.Join(absApp, modFileName) + plannedFiles = append(plannedFiles, plannedFile{path: modulePath, contents: []byte(modulePayload)}) } packageSource, err := backendAppPackageSource(options) if err != nil { @@ -276,23 +274,53 @@ func GenerateBackendWithPlan(appDir string, plan ApplicationPlan) (result Result if err != nil { return Result{}, err } - if err := writeFileIfChanged(filepath.Join(absApp, appFileName), appSource); err != nil { + plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, appFileName), contents: appSource}) + lifecycleSources, err := lifecycleServiceFileSources(options) + if err != nil { return Result{}, err } - if err := writeLifecycleServiceFiles(absApp, options); err != nil { - return Result{}, err + for _, name := range []string{lifecycleFileName, lifecycleJSName} { + source, ok := lifecycleSources[name] + if !ok { + continue + } + formatted, err := formatGeneratedGo(name, source) + if err != nil { + return Result{}, err + } + plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, name), contents: formatted}) } - if _, err := writeInlineGoBlockFiles(absApp, options); err != nil { + inlineFiles, inlinePlanned, err := collectInlineGoBlockFiles(absApp, options) + if err != nil { return Result{}, err } - if _, err := writeAddonGoBlockFiles(absApp, options); err != nil { + addonFiles, addonPlanned, err := collectAddonGoBlockFiles(absApp, options) + if err != nil { return Result{}, err } + plannedFiles = append(plannedFiles, inlinePlanned...) + plannedFiles = append(plannedFiles, addonPlanned...) mainSource, err := serverMainSource(moduleContext.ImportBase + "/" + appPackageDirName) if err != nil { return Result{}, err } - if err := writeFileIfChanged(filepath.Join(absApp, mainFileName), []byte(mainSource)); err != nil { + plannedFiles = append(plannedFiles, plannedFile{path: filepath.Join(absApp, mainFileName), contents: []byte(mainSource)}) + var publication publish.Transaction + stageApp, err := publication.StageDirectory(absApp) + if err != nil { + return Result{}, err + } + defer publication.Abort() + for _, file := range plannedFiles { + rel, err := filepath.Rel(absApp, file.path) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return Result{}, fmt.Errorf("planned generated backend file %q escapes app directory %q", file.path, absApp) + } + if err := stageGeneratedFile(filepath.Join(stageApp, rel), file.path, file.contents); err != nil { + return Result{}, err + } + } + if err := publication.Commit(); err != nil { return Result{}, err } return Result{ @@ -300,52 +328,10 @@ func GenerateBackendWithPlan(appDir string, plan ApplicationPlan) (result Result MainPath: filepath.Join(absApp, mainFileName), PackagePath: filepath.Join(absApp, appFileName), ModulePath: modulePath, + Files: append(inlineFiles, addonFiles...), }, nil } -func writeGeneratedModuleFile(absApp string, context generatedModuleContext, options Options) (string, error) { - nestedPath := filepath.Join(absApp, modFileName) - if !context.Nested { - if err := os.Remove(nestedPath); err != nil && !os.IsNotExist(err) { - return "", err - } - return "", nil - } - modulePayload, err := moduleSource(options) - if err != nil { - return "", err - } - if err := writeFileIfChanged(nestedPath, []byte(modulePayload)); err != nil { - return "", err - } - return nestedPath, nil -} - -func writeLifecycleServiceFiles(absApp string, options Options) error { - sources, err := lifecycleServiceFileSources(options) - if err != nil { - return err - } - for _, name := range []string{lifecycleFileName, lifecycleJSName} { - path := filepath.Join(absApp, name) - source, ok := sources[name] - if !ok { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - continue - } - formatted, err := formatGeneratedGo(name, source) - if err != nil { - return err - } - if err := writeFileIfChanged(path, formatted); err != nil { - return err - } - } - return nil -} - func formatGeneratedGo(name string, source []byte) ([]byte, error) { formatted, err := format.Source(source) if err != nil { diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index d22eea19..091ee817 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -28,6 +28,8 @@ import ( "github.com/cssbruno/gowdk/internal/securitymanifest" "github.com/cssbruno/gowdk/internal/source" gowdkactions "github.com/cssbruno/gowdk/runtime/actions" + gowdki18n "github.com/cssbruno/gowdk/runtime/i18n" + interopfixture "github.com/cssbruno/gowdk/testfixture/interop" ) var updateGolden = flag.Bool("update", false, "update appgen golden files") @@ -36,6 +38,47 @@ func csrfDisabledConfig() gowdk.Config { return gowdk.Config{Build: gowdk.BuildConfig{CSRF: gowdk.CSRFConfig{Disabled: true}}} } +func typedGuardConfig() gowdk.Config { + config := csrfDisabledConfig() + config.Interop.Guards = gowdk.RegisterGuards(interopfixture.Guards) + return config +} + +func TestGenerateEmbedsRuntimeErrorCatalogAndStableCodes(t *testing.T) { + root := t.TempDir() + outputDir := filepath.Join(root, "dist") + appDir := filepath.Join(root, "generated-app") + writeTestFile(t, filepath.Join(outputDir, "index.html"), "
Home
") + config := csrfDisabledConfig() + config.I18N = gowdk.I18NConfig{ + DefaultLocale: "en", + Locales: []gowdk.LocaleConfig{{Code: "en"}, {Code: "pt"}}, + Errors: gowdki18n.NewErrorBundleStrings("en", map[string]map[string]string{ + "pt": {"invalid_form": "Formulário inválido"}, + }), + } + result, err := GenerateWithOptions(outputDir, appDir, Options{Config: config, APIs: []APIEndpoint{{ + PageID: "form", APIName: "Submit", Method: "POST", Route: "/api/form", Guards: []string{"public"}, + Binding: source.BackendBinding{Status: source.BackendBindingBound, ImportPath: "example.com/app/forms", PackageName: "forms", FunctionName: "Submit", Signature: source.BackendSignatureAPIInput, InputType: "Input"}, + }}}) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(result.PackagePath) + if err != nil { + t.Fatal(err) + } + source := string(payload) + for _, expected := range []string{ + `gowdki18n.NewErrorBundleStrings("en", map[string]map[string]string{"pt": map[string]string{"invalid_form": "Formulário inválido"}})`, + `gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context()))`, + } { + if !strings.Contains(source, expected) { + t.Fatalf("expected generated localized error contract %q:\n%s", expected, source) + } + } +} + func withCSRFDisabled(config gowdk.Config) gowdk.Config { config.Build.CSRF.Disabled = true return config @@ -968,11 +1011,11 @@ func TestGenerateWritesActionRedirectHandler(t *testing.T) { `if err := request.ParseForm(); err != nil`, `if gowdkresponse.IsRequestBodyTooLarge(err)`, `http.StatusRequestEntityTooLarge`, - `gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form")`, - `gowdkresponse.WriteNoStoreError(response, http.StatusRequestEntityTooLarge, "request body too large")`, - `gowdkresponse.WriteNoStoreError(response, http.StatusUnprocessableEntity, "validation failed")`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_form"`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusRequestEntityTooLarge, "request_body_too_large"`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusUnprocessableEntity, "validation_failed"`, `validationTarget := strings.TrimSpace(request.Header.Get("X-GOWDK-Target"))`, - `gowdkresponse.WriteNoStoreHTTP(response, gowdkresponse.ValidationFragment(validationTarget, validation))`, + `gowdkresponse.WriteNoStoreHTTP(response, gowdkresponse.LocalizedValidationFragment(validationTarget, validation`, `requestPath := actionRequestPath(request.URL.Path)`, `func actionRequestPath(value string) string`, `type SubscribeInput struct`, @@ -980,11 +1023,11 @@ func TestGenerateWritesActionRedirectHandler(t *testing.T) { `gowdkform.DecodeExpected(values, gowdkform.Schema{Fields: []gowdkform.Field{{Name: "email"}}})`, `validation := gowdkvalidation.Result{}`, `values.HasSubmitted("email")`, - `validation.Add("email", "Email is required")`, + `validation.AddCode("email", "validation_required", "Email is required", nil)`, `utf8.RuneCountInString(value) < 5`, `utf8.RuneCountInString(value) > 80`, `gowdkvalidation.MatchPattern("[a-z]+@[a-z]+[.][a-z]{2,4}", value)`, - `validation.Add("email", "Use a real email address")`, + `validation.AddCode("email", "validation_pattern", "Use a real email address", nil)`, `http.StatusUnprocessableEntity`, `gowdkresponse.WriteNoStoreHTTP(response, gowdkresponse.RedirectTo("/newsletter?ok=1"))`, } { @@ -1097,7 +1140,7 @@ func TestGenerateWritesBoundContractBackendRoutes(t *testing.T) { `func decodeContractPatientsGetPatientPageInput(values gowdkform.Values) (patients.GetPatientPage, error)`, `input.Filter = field0`, `gowdkresponse.JSONValue(http.StatusOK, result)`, - `gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError)`, + `gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError`, } { if !strings.Contains(source, expected) { t.Fatalf("expected generated contract app source to contain %q:\n%s", expected, source) @@ -1613,7 +1656,7 @@ func TestGenerateGuardsRealtimeStreamForSubscribedPages(t *testing.T) { }}, } - result, err := GenerateWithOptions(outputDir, appDir, Options{Config: csrfDisabledConfig(), IR: program}) + result, err := GenerateWithOptions(outputDir, appDir, Options{Config: typedGuardConfig(), IR: program}) if err != nil { t.Fatal(err) } @@ -1637,7 +1680,7 @@ func TestGenerateGuardsRealtimeStreamForSubscribedPages(t *testing.T) { `return []string{"gowdk.route.0"}`, `return []string{"auth.required"}`, `if !runGuards(response, request, realtimeStreamGuards(request))`, - `RegisterGuards(GOWDKGuardRegistry())`, + `RegisterGuards(interop.Guards())`, } { if !strings.Contains(source, expected) { t.Fatalf("expected generated guarded realtime source to contain %q:\n%s", expected, source) @@ -2376,7 +2419,7 @@ func TestGenerateWiresCSRFByDefault(t *testing.T) { `Insecure: true`, `if csrfValidator != nil {`, `err := csrfValidator.Validate(request)`, - `gowdkresponse.WriteNoStoreError(response, http.StatusForbidden, "invalid csrf token")`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusForbidden, "invalid_csrf_token"`, } { if !strings.Contains(source, expected) { t.Fatalf("expected generated app source to contain %q:\n%s", expected, source) @@ -2429,7 +2472,7 @@ func TestGenerateWiresCSRFForStateChangingAPIs(t *testing.T) { `case request.Method == "POST" && requestPath == "/api/status":`, `if csrfValidator != nil {`, `err := csrfValidator.Validate(request)`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "invalid csrf token")`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusForbidden, "invalid_csrf_token"`, `request.Body = http.MaxBytesReader(response, request.Body, maxAPIBodyBytes)`, `result, err := status.Update(ctx, request)`, } { @@ -2573,11 +2616,11 @@ func TestGenerateWiresCSRFForCommandContracts(t *testing.T) { `request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes)`, `if err := request.ParseForm(); err != nil`, `if gowdkresponse.IsRequestBodyTooLarge(err)`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusRequestEntityTooLarge, "request body too large")`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form")`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusRequestEntityTooLarge, "request_body_too_large"`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusBadRequest, "invalid_form"`, `if csrfValidator != nil {`, `err := csrfValidator.Validate(request)`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "invalid csrf token")`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusForbidden, "invalid_csrf_token"`, `input := patients.CreatePatient{}`, `gowdkcontracts.CaptureCommandEventsForRole[patients.CreatePatient, patients.CreatePatientResult]`, `gowdkcontracts.DispatchCommandEvents(ctx, currentContractEventSink(), contractRegistry, gowdkcontracts.RoleWeb, events)`, @@ -3464,7 +3507,7 @@ func TestGenerateWritesBoundAPIHandler(t *testing.T) { `ctx := gowdkruntime.WithEndpoint(gowdkruntime.WithRequest(request.Context(), request), gowdkruntime.EndpointMetadata{Kind: "api", PageID: "status", Name: "Health", Method: "GET", Path: "/api/health"})`, `request.Body = http.MaxBytesReader(response, request.Body, maxAPIBodyBytes)`, `result, err := status.Health(ctx, request)`, - `gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError)`, + `gowdkresponse.WriteNoStoreLocalizedHandlerError(response, err, http.StatusInternalServerError`, `gowdkresponse.WriteNoStoreHTTP(response, result)`, } { if !strings.Contains(source, expected) { @@ -3477,7 +3520,7 @@ func TestGenerateWritesBoundAPIHandler(t *testing.T) { for _, unexpected := range []string{ `func newCSRF() (*gowdkactions.CSRF, error)`, `err := csrfValidator.Validate(request)`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "invalid csrf token")`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusForbidden, "invalid_csrf_token"`, } { if strings.Contains(source, unexpected) { t.Fatalf("safe API output should not emit CSRF validation %q:\n%s", unexpected, source) @@ -3550,7 +3593,7 @@ func TestGenerateWritesTypedBoundAPIHandlers(t *testing.T) { `decoder, err := gowdkapi.NewJSONFieldDecoder(request)`, `var maxBytesErr *http.MaxBytesError`, `if errors.As(err, &maxBytesErr)`, - `gowdkresponse.WriteNoStoreJSONError(response, http.StatusRequestEntityTooLarge, "request body too large")`, + `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusRequestEntityTooLarge, "request_body_too_large"`, `case "name":`, `field0, err := decoder.String("name")`, `input.Name = field0`, @@ -3571,7 +3614,7 @@ func TestGenerateWritesTypedBoundAPIHandlers(t *testing.T) { `result, err := status.List(ctx, &input)`, `status := gowdkapi.ResultStatus(result, http.StatusOK)`, `httpResult, err := gowdkresponse.JSONValue(status, result)`, - `gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError)`, + `gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError`, `gowdkresponse.WriteNoStoreHTTP(response, httpResult)`, } { if !strings.Contains(source, expected) { @@ -3789,7 +3832,7 @@ func TestGenerateWritesActionFragmentHandler(t *testing.T) { `fragment := gowdkpartial.Fragment("#patients", "

Updated patients

")`, `gowdkpartial.Swap(fragment.Target, gowdkpartial.SwapMode(swap), fragment.Body)`, `gowdkresponse.WriteNoStoreHTTP(response, fragment)`, - `gowdkresponse.WriteNoStoreError(response, http.StatusNotFound, "partial fragment not found")`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusNotFound, "fragment_not_found"`, `gowdkresponse.WriteNoStoreHTTP(response, gowdkresponse.RedirectTo("/patients"))`, } { if !strings.Contains(source, expected) { @@ -3978,7 +4021,7 @@ func TestGenerateWritesSSRLoadHandler(t *testing.T) { `redirectURL, redirectStatus, ok := gowdkssr.RedirectTarget(err)`, `gowdkresponse.WriteNoStoreHTTP(response, gowdkresponse.Response{Kind: gowdkresponse.Redirect, Status: redirectStatus, URL: redirectURL})`, `errorStatus := gowdkresponse.HandlerStatus(err, http.StatusInternalServerError)`, - `gowdkruntime.WriteErrorPage(response, request, errorStatus, gowdkresponse.HandlerErrorMessage(err, errorStatus))`, + `gowdkruntime.WriteErrorPage(response, request, errorStatus, gowdkresponse.LocalizedHandlerErrorMessage(err, errorStatus`, `loadValue0, loadOK0 := gowdkssr.LoadPath(loadData, "user.name")`, `gowdkruntime.WriteErrorPage(response, request, http.StatusInternalServerError, "missing load field user.name")`, `strings.ReplaceAll(html, "__USER__", gowdkhtml.Escape(fmt.Sprint(loadValue0)))`, @@ -4319,8 +4362,8 @@ func TestGenerateWritesTypedSSRRouteParamBindings(t *testing.T) { `RouteParams: []gowdkruntime.RouteParamMetadata{gowdkruntime.RouteParamMetadata{Name: "id", Type: "int"}}`, `typedParams := map[string]any{}`, `paramValue0, paramOK0, paramErr0 := gowdkroute.Int(params, "id")`, - `gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid route parameter id")`, - `gowdkresponse.WriteNoStoreError(response, http.StatusNotFound, "missing route parameter id")`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_route_parameter"`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusNotFound, "invalid_route_parameter"`, `typedParams["id"] = paramValue0`, `ctx = gowdkruntime.WithTypedParams(ctx, typedParams)`, } { @@ -4359,7 +4402,7 @@ func TestGenerateWritesDynamicFragmentRouteParamBindings(t *testing.T) { `if params, ok := gowdkroute.Match("/patients/{id:int}/vitals", request.URL.Path); request.Method == "GET" && ok {`, `ctx = gowdkruntime.WithParams(ctx, params)`, `paramValue0, paramOK0, paramErr0 := gowdkroute.Int(params, "id")`, - `gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid route parameter id")`, + `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_route_parameter"`, `typedParams["id"] = paramValue0`, `ctx = gowdkruntime.WithTypedParams(ctx, typedParams)`, `gowdkpartial.Fragment("#vitals", "
Vitals
")`, @@ -4407,7 +4450,7 @@ func TestGenerateAutoDetectsActionAndSSRRoutes(t *testing.T) { ID: "dashboard", Route: "/dashboard", Render: gowdk.SSR, - Guards: []string{"auth.required"}, + Guards: []string{"public"}, Blocks: gwdkir.Blocks{ View: true, ViewBody: `

Dashboard

`, @@ -4439,7 +4482,7 @@ func TestGenerateAutoDetectsActionAndSSRRoutes(t *testing.T) { `case request.Method == "GET" && requestPath == "/newsletter/list":`, `gowdkpartial.Fragment("#newsletter", "
Newsletter list
")`, `case "/dashboard":`, - `gowdkruntime.RouteMetadata{Kind: "ssr", PageID: "dashboard", Method: "GET", Path: "/dashboard", Render: "ssr", Guards: []string{"auth.required"}}`, + `gowdkruntime.RouteMetadata{Kind: "ssr", PageID: "dashboard", Method: "GET", Path: "/dashboard", Render: "ssr", Guards: []string{"public"}}`, `

Dashboard

`, } { if !strings.Contains(source, expected) { @@ -4553,6 +4596,7 @@ func TestGenerateWritesGuardRegistryAndGuardChecks(t *testing.T) { writeTestFile(t, filepath.Join(outputDir, "index.html"), "
Home
") result, err := GenerateWithOptions(outputDir, appDir, Options{ + Config: typedGuardConfig(), Actions: []ActionEndpoint{{ PageID: "newsletter", ActionName: "Subscribe", @@ -4591,9 +4635,9 @@ func TestGenerateWritesGuardRegistryAndGuardChecks(t *testing.T) { `var authProvider gowdkauth.Provider`, `func RegisterAuthProvider(provider gowdkauth.Provider)`, `func init()`, - `RegisterGuards(GOWDKGuardRegistry())`, + `RegisterGuards(interop.Guards())`, `gowdkguard.RunGuardsWithAuth(guardContext, guards, guardRegistry, authProvider)`, - `gowdkguard.WriteNoStoreFailure(response, err)`, + `gowdkguard.WriteNoStoreLocalizedFailure(response, err, userErrorCatalog, gowdkruntime.Locale(request.Context()))`, `if !runGuards(response, request, []string{"auth.required"})`, } { if !strings.Contains(source, expected) { @@ -7027,7 +7071,7 @@ func Session(ctx context.Context, request *http.Request) (gowdkresponse.Response } } -func TestGeneratedBinarySSRGuardRequiresBackingCode(t *testing.T) { +func TestGeneratedBinarySSRGuardHasNoMagicCompileDependency(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") appDir := filepath.Join(root, "generated-app") @@ -7042,8 +7086,8 @@ func TestGeneratedBinarySSRGuardRequiresBackingCode(t *testing.T) { }}}); err != nil { t.Fatal(err) } - if _, err := BuildBinary(appDir, binaryPath); err == nil || !strings.Contains(err.Error(), "GOWDKGuardRegistry") { - t.Fatalf("expected missing GOWDKGuardRegistry compile error, got %v", err) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatalf("generated app should not depend on a magic GOWDKGuardRegistry symbol: %v", err) } } @@ -7072,7 +7116,7 @@ func TestGeneratedBinaryAuthAddonSuppliesGuardBackingCode(t *testing.T) { } } -func TestGeneratedBinaryBackendGuardsRequireBackingCode(t *testing.T) { +func TestGeneratedBinaryBackendGuardsHaveNoMagicCompileDependency(t *testing.T) { root := t.TempDir() outputDir := filepath.Join(root, "dist") appDir := filepath.Join(root, "generated-app") @@ -7098,8 +7142,8 @@ func TestGeneratedBinaryBackendGuardsRequireBackingCode(t *testing.T) { }); err != nil { t.Fatal(err) } - if _, err := BuildBinary(appDir, binaryPath); err == nil || !strings.Contains(err.Error(), "GOWDKGuardRegistry") { - t.Fatalf("expected missing GOWDKGuardRegistry compile error, got %v", err) + if _, err := BuildBinary(appDir, binaryPath); err != nil { + t.Fatalf("generated app should not depend on a magic GOWDKGuardRegistry symbol: %v", err) } } @@ -7155,7 +7199,7 @@ func TestGeneratedBinaryContractFallbacksAreExplicitNoStore(t *testing.T) { if contentType := response.Header.Get("Content-Type"); contentType != "application/json; charset=utf-8" { t.Fatalf("expected JSON missing contract response, got content type %q: %s", contentType, payload) } - if strings.TrimSpace(string(payload)) != `{"error":"command patients.CreatePatient is not registered"}` { + if strings.TrimSpace(string(payload)) != `{"ok":false,"error":{"code":"handler_not_implemented","message":"command patients.CreatePatient is not registered"}}` { t.Fatalf("expected explicit JSON missing contract response, got %s", payload) } if cacheControl := response.Header.Get("Cache-Control"); cacheControl != "no-store" { @@ -7960,12 +8004,12 @@ import ( gowdkguard "github.com/cssbruno/gowdk/runtime/guard" ) -func GOWDKGuardRegistry() gowdkguard.Registry { - return gowdkguard.Registry{ +func init() { + RegisterGuards(gowdkguard.Registry{ "auth.required": func(ctx gowdkguard.Context) error { return errors.New("denied") }, - } + }) } `) if _, err := BuildBinary(appDir, binaryPath); err != nil { @@ -8131,7 +8175,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if commandResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected command JSON error content type, got %q", commandResponse.Header.Get("Content-Type")) } - if strings.TrimSpace(string(commandPayload)) != `{"error":"Internal Server Error"}` { + if strings.TrimSpace(string(commandPayload)) != `{"ok":false,"error":{"code":"request_failed","message":"Internal Server Error"}}` { t.Fatalf("unexpected command JSON error payload: %s", commandPayload) } if strings.Contains(string(commandPayload), "secret") { @@ -8153,7 +8197,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if commandParseResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected command parse JSON error content type, got %q", commandParseResponse.Header.Get("Content-Type")) } - if strings.TrimSpace(string(commandParsePayload)) != `{"error":"invalid form"}` { + if strings.TrimSpace(string(commandParsePayload)) != `{"ok":false,"error":{"code":"invalid_form","message":"invalid form"}}` { t.Fatalf("unexpected command parse JSON error payload: %s", commandParsePayload) } @@ -8172,7 +8216,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if commandDecodeResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected command decode JSON error content type, got %q", commandDecodeResponse.Header.Get("Content-Type")) } - if strings.TrimSpace(string(commandDecodePayload)) != `{"error":"invalid form"}` { + if strings.TrimSpace(string(commandDecodePayload)) != `{"ok":false,"error":{"code":"invalid_form","message":"invalid form"}}` { t.Fatalf("unexpected command decode JSON error payload: %s", commandDecodePayload) } @@ -8193,7 +8237,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if queryResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected query JSON error content type, got %q", queryResponse.Header.Get("Content-Type")) } - if strings.TrimSpace(string(queryPayload)) != `{"error":"invalid filter"}` { + if strings.TrimSpace(string(queryPayload)) != `{"ok":false,"error":{"code":"handler_error","message":"invalid filter"}}` { t.Fatalf("unexpected query JSON error payload: %s", queryPayload) } @@ -8214,7 +8258,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData if queryDecodeResponse.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected query decode JSON error content type, got %q", queryDecodeResponse.Header.Get("Content-Type")) } - if strings.TrimSpace(string(queryDecodePayload)) != `{"error":"invalid form"}` { + if strings.TrimSpace(string(queryDecodePayload)) != `{"ok":false,"error":{"code":"invalid_form","message":"invalid form"}}` { t.Fatalf("unexpected query decode JSON error payload: %s", queryDecodePayload) } } @@ -8306,7 +8350,7 @@ func HandleCreatePatient(ctx context.Context, command CreatePatient) (CreatePati if response.Header.Get("Content-Type") != "application/json; charset=utf-8" { t.Fatalf("expected csrf JSON error content type, got %q", response.Header.Get("Content-Type")) } - if strings.TrimSpace(string(payload)) != `{"error":"invalid csrf token"}` { + if strings.TrimSpace(string(payload)) != `{"ok":false,"error":{"code":"invalid_csrf_token","message":"invalid csrf token"}}` { t.Fatalf("unexpected csrf JSON error payload: %s", payload) } if cache := response.Header.Get("Cache-Control"); cache != "no-store" { @@ -8364,12 +8408,12 @@ func TestGeneratedBinaryRegisteredGuardsAllowRequestTimeRoutes(t *testing.T) { import gowdkssr "github.com/cssbruno/gowdk/runtime/ssr" -func GOWDKGuardRegistry() gowdkssr.GuardRegistry { - return gowdkssr.GuardRegistry{ +func init() { + RegisterGuards(gowdkssr.GuardRegistry{ "auth.required": func(ctx gowdkssr.LoadContext) error { return nil }, - } + }) } `) writeTestFile(t, filepath.Join(appDir, "backend", "backend.go"), `package backend @@ -8456,12 +8500,12 @@ func TestGeneratedBinaryGuardCanRedirectRequestTimeRoute(t *testing.T) { import gowdkguard "github.com/cssbruno/gowdk/runtime/guard" -func GOWDKGuardRegistry() gowdkguard.Registry { - return gowdkguard.Registry{ +func init() { + RegisterGuards(gowdkguard.Registry{ "auth.required": func(ctx gowdkguard.Context) error { return gowdkguard.RedirectTo("/login") }, - } + }) } `) if _, err := BuildBinary(appDir, binaryPath); err != nil { @@ -8514,14 +8558,14 @@ import ( gowdkauth "github.com/cssbruno/gowdk/runtime/auth" ) -func GOWDKAuthProvider() gowdkauth.Provider { - return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { +func init() { + RegisterAuthProvider(gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { return &gowdkauth.Principal{ ID: "user-1", Roles: []string{"admin"}, Permissions: []string{"admin.read"}, }, nil - }) + })) } `) if _, err := BuildBinary(appDir, binaryPath); err != nil { @@ -9860,8 +9904,8 @@ func TestDeniedPageRoutePatternsSelectsGuardlessDynamicPages(t *testing.T) { } func TestGuardlessActionAndAPIAreDeniedByOmission(t *testing.T) { - const deny = `gowdkresponse.WriteNoStoreError(response, http.StatusForbidden, "403 forbidden")` - const denyJSON = `gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "403 forbidden")` + const deny = `gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusForbidden, "forbidden"` + const denyJSON = `gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusForbidden, "forbidden"` actionSrc, err := actionHandlerSource([]ActionEndpoint{{PageID: "p", ActionName: "Sub", Route: "/sub"}}) if err != nil { diff --git a/internal/appgen/auto_routes.go b/internal/appgen/auto_routes.go index a21a2b92..1ba94ecf 100644 --- a/internal/appgen/auto_routes.go +++ b/internal/appgen/auto_routes.go @@ -245,6 +245,12 @@ func assignBackendAliases(options *Options) { paths[route.LoadBinding.ImportPath] = route.LoadBinding.PackageName } } + if ref := options.Config.Interop.Guards.Hook; ref.ImportPath != "" { + paths[ref.ImportPath] = path.Base(ref.ImportPath) + } + if ref := options.Config.Interop.AuthProvider.Hook; ref.ImportPath != "" { + paths[ref.ImportPath] = path.Base(ref.ImportPath) + } if len(paths) == 0 { return } @@ -277,6 +283,8 @@ func assignBackendAliases(options *Options) { for index := range options.SSR { options.SSR[index].LoadBackendAlias = aliases[options.SSR[index].LoadBinding.ImportPath] } + options.guardHookAlias = aliases[options.Config.Interop.Guards.Hook.ImportPath] + options.authHookAlias = aliases[options.Config.Interop.AuthProvider.Hook.ImportPath] } func generatedImportAliasUseCounts() map[string]int { diff --git a/internal/appgen/build.go b/internal/appgen/build.go index 76ac8392..89f0b78a 100644 --- a/internal/appgen/build.go +++ b/internal/appgen/build.go @@ -1,128 +1,318 @@ package appgen import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" + "io" "os" "os/exec" "path/filepath" + "runtime" + "sort" "strings" ) +// PackagingOptions contains every caller-controlled input to a generated Go +// artifact build. Environment is copied to the go command instead of being +// read or changed through process-global state. +type PackagingOptions struct { + Environment []string + Tags []string +} + +// PackagingMetadata is the reproducibility envelope recorded for a generated +// binary or WASM artifact. +type PackagingMetadata struct { + GoVersion string + GOOS string + GOARCH string + CGOEnabled string + Trimpath bool + BuildVCS bool + ModuleMode string + Tags []string + ArtifactSHA256 string +} + +// Data returns stable string fields suitable for a build-report event. +func (metadata PackagingMetadata) Data() map[string]string { + return map[string]string{ + "artifactSHA256": metadata.ArtifactSHA256, + "buildVCS": "false", + "cgoEnabled": metadata.CGOEnabled, + "goarch": metadata.GOARCH, + "goos": metadata.GOOS, + "goVersion": metadata.GoVersion, + "moduleMode": metadata.ModuleMode, + "tags": strings.Join(metadata.Tags, ","), + "trimpath": "true", + } +} + +// PackagingResult describes an atomically published generated artifact. +type PackagingResult struct { + Path string + Metadata PackagingMetadata +} + // BuildBinary compiles the generated app into binaryPath. func BuildBinary(appDir, binaryPath string) (string, error) { - return buildGeneratedCommand(appDir, binaryPath, "./cmd/server", "generated app", "binary output path is required") + result, err := BuildBinaryWithOptions(appDir, binaryPath, PackagingOptions{}) + return result.Path, err +} + +// BuildBinaryWithOptions compiles and atomically publishes the generated app. +func BuildBinaryWithOptions(appDir, binaryPath string, options PackagingOptions) (PackagingResult, error) { + return buildGeneratedCommand(appDir, binaryPath, "./cmd/server", "generated app", "binary output path is required", options, "", "") } // BuildWorkerBinary compiles the generated contract worker app. func BuildWorkerBinary(appDir, binaryPath string) (string, error) { - return buildGeneratedCommand(appDir, binaryPath, "./cmd/worker", "generated worker app", "worker binary output path is required") + result, err := BuildWorkerBinaryWithOptions(appDir, binaryPath, PackagingOptions{}) + return result.Path, err +} + +// BuildWorkerBinaryWithOptions compiles and atomically publishes a worker. +func BuildWorkerBinaryWithOptions(appDir, binaryPath string, options PackagingOptions) (PackagingResult, error) { + return buildGeneratedCommand(appDir, binaryPath, "./cmd/worker", "generated worker app", "worker binary output path is required", options, "", "") } // BuildCronBinary compiles the generated contract cron app. func BuildCronBinary(appDir, binaryPath string) (string, error) { - return buildGeneratedCommand(appDir, binaryPath, "./cmd/cron", "generated cron app", "cron binary output path is required") + result, err := BuildCronBinaryWithOptions(appDir, binaryPath, PackagingOptions{}) + return result.Path, err +} + +// BuildCronBinaryWithOptions compiles and atomically publishes a cron role. +func BuildCronBinaryWithOptions(appDir, binaryPath string, options PackagingOptions) (PackagingResult, error) { + return buildGeneratedCommand(appDir, binaryPath, "./cmd/cron", "generated cron app", "cron binary output path is required", options, "", "") } -func buildGeneratedCommand(appDir, binaryPath string, packagePath string, label string, emptyBinaryMessage string) (string, error) { +func buildGeneratedCommand(appDir, artifactPath, packagePath, label, emptyArtifactMessage string, options PackagingOptions, goos, goarch string) (PackagingResult, error) { if strings.TrimSpace(appDir) == "" { - return "", fmt.Errorf("%s directory is required", label) + return PackagingResult{}, fmt.Errorf("%s directory is required", label) } - if strings.TrimSpace(binaryPath) == "" { - return "", fmt.Errorf("%s", emptyBinaryMessage) + if strings.TrimSpace(artifactPath) == "" { + return PackagingResult{}, fmt.Errorf("%s", emptyArtifactMessage) } absApp, err := filepath.Abs(appDir) if err != nil { - return "", err + return PackagingResult{}, err } - absBinary, err := filepath.Abs(binaryPath) + absArtifact, err := filepath.Abs(artifactPath) if err != nil { - return "", err + return PackagingResult{}, err } - if err := os.MkdirAll(filepath.Dir(absBinary), 0o755); err != nil { - return "", err + if err := os.MkdirAll(filepath.Dir(absArtifact), 0o755); err != nil { + return PackagingResult{}, err } + context := resolveGeneratedModuleContext(absApp) if packagePath != "./cmd/server" { - context = generatedModuleContext{ - Nested: true, - ImportBase: legacyGeneratedAppModulePath, - BuildDir: absApp, - } + context = generatedModuleContext{Nested: true, ImportBase: legacyGeneratedAppModulePath, BuildDir: absApp} + } + baseEnvironment := options.Environment + if baseEnvironment == nil { + baseEnvironment = os.Environ() + } + goEnvironment := generatedAppGoEnv(buildEnvWithout(baseEnvironment, "GOFLAGS", "GOOS", "GOARCH"), context.Nested) + if goos != "" { + goEnvironment = append(goEnvironment, "GOOS="+goos) + } + if goarch != "" { + goEnvironment = append(goEnvironment, "GOARCH="+goarch) } - goEnv := generatedAppGoEnv(nil, context.Nested) buildDir := context.BuildDir buildPackage := packagePath - if context.Nested { - if err := tidyGeneratedApp(absApp, goEnv); err != nil { - return "", err - } - } else { + if !context.Nested { buildPackage = "./" + pathJoinSlash(context.AppRel, strings.TrimPrefix(packagePath, "./")) } + moduleMode := "readonly" + if info, err := os.Stat(filepath.Join(buildDir, "vendor")); err == nil && info.IsDir() { + moduleMode = "vendor" + } + tags := cleanPackagingTags(options.Tags) - command := exec.Command("go", "build", "-buildvcs=false", "-o", absBinary, packagePath) - command.Args[len(command.Args)-1] = buildPackage + temporary, err := os.CreateTemp(filepath.Dir(absArtifact), ".gowdk-artifact-*") + if err != nil { + return PackagingResult{}, err + } + temporaryPath := temporary.Name() + if err := temporary.Close(); err != nil { + _ = os.Remove(temporaryPath) + return PackagingResult{}, err + } + defer os.Remove(temporaryPath) + + args := []string{"build", "-trimpath", "-buildvcs=false", "-mod=" + moduleMode} + if len(tags) > 0 { + args = append(args, "-tags="+strings.Join(tags, ",")) + } + args = append(args, "-o", temporaryPath, buildPackage) + command, err := commandWithEnvironment("go", goEnvironment, args...) + if err != nil { + return PackagingResult{}, err + } command.Dir = buildDir - command.Env = goEnv + command.Env = goEnvironment output, err := command.CombinedOutput() if err != nil { - return "", fmt.Errorf("go build %s failed: %w\n%s", label, err, strings.TrimSpace(string(output))) + return PackagingResult{}, fmt.Errorf("go build %s failed: %w\n%s", label, err, strings.TrimSpace(string(output))) + } + digest, err := artifactDigest(temporaryPath) + if err != nil { + return PackagingResult{}, err + } + metadata, err := packagingMetadata(buildDir, goEnvironment) + if err != nil { + return PackagingResult{}, err + } + metadata.Trimpath = true + metadata.BuildVCS = false + metadata.ModuleMode = moduleMode + metadata.Tags = tags + metadata.ArtifactSHA256 = digest + if err := publishArtifact(temporaryPath, absArtifact); err != nil { + return PackagingResult{}, err } - return absBinary, nil + return PackagingResult{Path: absArtifact, Metadata: metadata}, nil } // BuildWASM compiles the generated app into a Go js/wasm artifact. func BuildWASM(appDir, wasmPath string) (string, error) { - if strings.TrimSpace(appDir) == "" { - return "", fmt.Errorf("generated app directory is required") - } - if strings.TrimSpace(wasmPath) == "" { - return "", fmt.Errorf("wasm output path is required") + result, err := BuildWASMWithOptions(appDir, wasmPath, PackagingOptions{}) + return result.Path, err +} + +// BuildWASMWithOptions compiles and atomically publishes a Go js/wasm artifact. +func BuildWASMWithOptions(appDir, wasmPath string, options PackagingOptions) (PackagingResult, error) { + return buildGeneratedCommand(appDir, wasmPath, "./cmd/server", "generated wasm", "wasm output path is required", options, "js", "wasm") +} + +func packagingMetadata(buildDir string, environment []string) (PackagingMetadata, error) { + command, err := commandWithEnvironment("go", environment, "env", "-json", "GOVERSION", "GOOS", "GOARCH", "CGO_ENABLED") + if err != nil { + return PackagingMetadata{}, err } - absApp, err := filepath.Abs(appDir) + command.Dir = buildDir + command.Env = environment + output, err := command.CombinedOutput() if err != nil { - return "", err + return PackagingMetadata{}, fmt.Errorf("read Go packaging environment: %w\n%s", err, strings.TrimSpace(string(output))) + } + var values struct { + GoVersion string `json:"GOVERSION"` + GOOS string `json:"GOOS"` + GOARCH string `json:"GOARCH"` + CGOEnabled string `json:"CGO_ENABLED"` + } + if err := json.Unmarshal(output, &values); err != nil { + return PackagingMetadata{}, fmt.Errorf("parse Go packaging environment: %w", err) } - absWASM, err := filepath.Abs(wasmPath) + return PackagingMetadata{GoVersion: values.GoVersion, GOOS: values.GOOS, GOARCH: values.GOARCH, CGOEnabled: values.CGOEnabled}, nil +} + +func artifactDigest(path string) (string, error) { + file, err := os.Open(path) if err != nil { return "", err } - if err := os.MkdirAll(filepath.Dir(absWASM), 0o755); err != nil { + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { return "", err } - context := resolveGeneratedModuleContext(absApp) - wasmEnv := append(generatedAppGoEnv(buildEnvWithout(os.Environ(), "GOOS", "GOARCH"), context.Nested), "GOOS=js", "GOARCH=wasm") - buildDir := context.BuildDir - buildPackage := "./cmd/server" - if context.Nested { - if err := tidyGeneratedApp(absApp, wasmEnv); err != nil { - return "", err + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func publishArtifact(temporaryPath, artifactPath string) error { + if runtime.GOOS != "windows" { + return os.Rename(temporaryPath, artifactPath) + } + backupPath := artifactPath + ".gowdk-previous" + _ = os.Remove(backupPath) + hadPrevious := false + if _, err := os.Stat(artifactPath); err == nil { + if err := os.Rename(artifactPath, backupPath); err != nil { + return fmt.Errorf("preserve previous artifact before publish: %w", err) } - } else { - buildPackage = "./" + pathJoinSlash(context.AppRel, "cmd/server") + hadPrevious = true + } + if err := os.Rename(temporaryPath, artifactPath); err != nil { + if hadPrevious { + _ = os.Rename(backupPath, artifactPath) + } + return fmt.Errorf("publish generated artifact: %w", err) + } + if hadPrevious { + _ = os.Remove(backupPath) } + return nil +} - command := exec.Command("go", "build", "-buildvcs=false", "-o", absWASM, buildPackage) - command.Dir = buildDir - command.Env = wasmEnv - output, err := command.CombinedOutput() - if err != nil { - return "", fmt.Errorf("go build generated wasm failed: %w\n%s", err, strings.TrimSpace(string(output))) +func cleanPackagingTags(tags []string) []string { + seen := map[string]bool{} + var cleaned []string + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" || seen[tag] { + continue + } + seen[tag] = true + cleaned = append(cleaned, tag) } - return absWASM, nil + sort.Strings(cleaned) + return cleaned } -func tidyGeneratedApp(appDir string, env []string) error { - command := exec.Command("go", "mod", "tidy") - command.Dir = appDir - if env != nil { - command.Env = env +func commandWithEnvironment(name string, environment []string, args ...string) (*exec.Cmd, error) { + pathValue := "" + pathExtensions := "" + for _, entry := range environment { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + switch strings.ToUpper(key) { + case "PATH": + pathValue = value + case "PATHEXT": + pathExtensions = value + } } - output, err := command.CombinedOutput() - if err != nil { - return fmt.Errorf("go mod tidy generated app failed: %w\n%s", err, strings.TrimSpace(string(output))) + if pathValue == "" { + resolved, err := exec.LookPath(name) + if err != nil { + return nil, err + } + command := exec.Command(resolved, args...) + command.Env = environment + return command, nil } - return nil + extensions := []string{""} + if runtime.GOOS == "windows" { + extensions = filepath.SplitList(pathExtensions) + if len(extensions) == 0 { + extensions = []string{".com", ".exe", ".bat", ".cmd"} + } + } + for _, directory := range filepath.SplitList(pathValue) { + if directory == "" { + directory = "." + } + for _, extension := range extensions { + candidate := filepath.Join(directory, name+extension) + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() && (runtime.GOOS == "windows" || info.Mode()&0o111 != 0) { + command := exec.Command(candidate, args...) + command.Env = environment + return command, nil + } + } + } + return nil, fmt.Errorf("executable %q not found in packaging PATH", name) } func generatedAppGoEnv(env []string, disableWorkspace bool) []string { diff --git a/internal/appgen/build_test.go b/internal/appgen/build_test.go new file mode 100644 index 00000000..486507ad --- /dev/null +++ b/internal/appgen/build_test.go @@ -0,0 +1,174 @@ +package appgen + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestPackagingIsExplicitNonMutatingAndAtomic(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper is a POSIX shell script") + } + root := t.TempDir() + fakeBin := filepath.Join(root, "bin") + if err := os.MkdirAll(fakeBin, 0o755); err != nil { + t.Fatal(err) + } + goPath := filepath.Join(fakeBin, "go") + script := `#!/bin/sh +if [ "$1" = "env" ]; then + printf '{"GOVERSION":"go-test","GOOS":"linux","GOARCH":"amd64","CGO_ENABLED":"0"}\n' + exit 0 +fi +printf '%s\n' "$@" > "$FAKE_GO_ARGS" +if [ "$FAKE_GO_FAIL" = "1" ]; then + printf 'intentional build failure\n' >&2 + exit 1 +fi +previous="" +for argument in "$@"; do + if [ "$previous" = "-o" ]; then + printf 'stable artifact bytes\n' > "$argument" + exit 0 + fi + previous="$argument" +done +exit 2 +` + if err := os.WriteFile(goPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + appDir := filepath.Join(root, "generated") + if err := os.MkdirAll(filepath.Join(appDir, "cmd", "server"), 0o755); err != nil { + t.Fatal(err) + } + goModPath := filepath.Join(appDir, "go.mod") + goSumPath := filepath.Join(appDir, "go.sum") + goMod := []byte("module example.com/generated\n\ngo 1.26.4\n") + goSum := []byte("sentinel sum\n") + if err := os.WriteFile(goModPath, goMod, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goSumPath, goSum, 0o644); err != nil { + t.Fatal(err) + } + argsPath := filepath.Join(root, "args.txt") + artifactPath := filepath.Join(root, "site") + environment := []string{ + "PATH=" + fakeBin, + "FAKE_GO_ARGS=" + argsPath, + "GOFLAGS=-tags=ambient -ldflags=-X=unstable", + } + result, err := BuildBinaryWithOptions(appDir, artifactPath, PackagingOptions{ + Environment: environment, + Tags: []string{"sqlite", "enterprise", "sqlite"}, + }) + if err != nil { + t.Fatal(err) + } + if result.Path != artifactPath { + t.Fatalf("path = %q, want %q", result.Path, artifactPath) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{"-trimpath", "-buildvcs=false", "-mod=readonly", "-tags=enterprise,sqlite"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("go build args missing %q:\n%s", expected, args) + } + } + if strings.Contains(string(args), "ambient") || strings.Contains(string(args), "unstable") { + t.Fatalf("ambient GOFLAGS leaked into explicit build args:\n%s", args) + } + payload, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(payload) + if result.Metadata.ArtifactSHA256 != hex.EncodeToString(digest[:]) { + t.Fatalf("artifact hash = %q", result.Metadata.ArtifactSHA256) + } + if result.Metadata.GoVersion != "go-test" || result.Metadata.ModuleMode != "readonly" || !result.Metadata.Trimpath || result.Metadata.BuildVCS { + t.Fatalf("unexpected packaging metadata: %#v", result.Metadata) + } + assertFileBytes(t, goModPath, goMod) + assertFileBytes(t, goSumPath, goSum) + + previous := []byte("previous release\n") + if err := os.WriteFile(artifactPath, previous, 0o755); err != nil { + t.Fatal(err) + } + failingEnvironment := append(append([]string(nil), environment...), "FAKE_GO_FAIL=1") + if _, err := BuildBinaryWithOptions(appDir, artifactPath, PackagingOptions{Environment: failingEnvironment}); err == nil || !strings.Contains(err.Error(), "intentional build failure") { + t.Fatalf("expected intentional failure, got %v", err) + } + assertFileBytes(t, artifactPath, previous) + assertFileBytes(t, goModPath, goMod) + assertFileBytes(t, goSumPath, goSum) +} + +func TestPackagingHashIsIndependentOfProjectPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test helper is a POSIX shell script") + } + root := t.TempDir() + fakeBin := filepath.Join(root, "bin") + if err := os.MkdirAll(fakeBin, 0o755); err != nil { + t.Fatal(err) + } + goPath := filepath.Join(fakeBin, "go") + script := `#!/bin/sh +if [ "$1" = "env" ]; then + printf '{"GOVERSION":"go-test","GOOS":"linux","GOARCH":"amd64","CGO_ENABLED":"0"}\n' + exit 0 +fi +previous="" +for argument in "$@"; do + if [ "$previous" = "-o" ]; then + printf 'same content for every root\n' > "$argument" + exit 0 + fi + previous="$argument" +done +exit 2 +` + if err := os.WriteFile(goPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + environment := []string{"PATH=" + fakeBin} + var hashes []string + for _, name := range []string{"first/deep/root", "second/root"} { + appDir := filepath.Join(root, name, "app") + if err := os.MkdirAll(filepath.Join(appDir, "cmd", "server"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(appDir, "go.mod"), []byte("module example.com/generated\n\ngo 1.26.4\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := BuildBinaryWithOptions(appDir, filepath.Join(root, name, "site"), PackagingOptions{Environment: environment}) + if err != nil { + t.Fatal(err) + } + hashes = append(hashes, result.Metadata.ArtifactSHA256) + } + if hashes[0] != hashes[1] { + t.Fatalf("path-independent hashes differ: %q != %q", hashes[0], hashes[1]) + } +} + +func assertFileBytes(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("%s changed:\ngot: %q\nwant: %q", path, got, want) + } +} diff --git a/internal/appgen/files.go b/internal/appgen/files.go index 97f150c3..daac9675 100644 --- a/internal/appgen/files.go +++ b/internal/appgen/files.go @@ -42,19 +42,6 @@ func isSameOrWithin(parent, child string) bool { return rel == "." || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") } -func copyOutputFiles(sourceRoot, targetRoot string) ([]string, error) { - files, planned, err := collectOutputFiles(sourceRoot, targetRoot) - if err != nil { - return nil, err - } - for _, file := range planned { - if err := writeFileIfChanged(file.path, file.contents); err != nil { - return nil, err - } - } - return files, nil -} - func collectOutputFiles(sourceRoot, targetRoot string) ([]string, []plannedFile, error) { var files []string var planned []plannedFile @@ -103,38 +90,6 @@ func unsafeEmbeddedDirectory(rel string) bool { return safeasset.UnsafeEmbeddedDirectory(rel) } -func copyFile(sourcePath, targetPath string) error { - payload, err := os.ReadFile(sourcePath) - if err != nil { - return err - } - return writeFileIfChanged(targetPath, payload) -} - -func removeStaleOutputFiles(targetRoot string, files []string) error { - keep := map[string]bool{} - for _, file := range files { - keep[file] = true - } - return filepath.WalkDir(targetRoot, func(filePath string, entry os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - rel, err := filepath.Rel(targetRoot, filePath) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - if keep[rel] { - return nil - } - return os.Remove(filePath) - }) -} - func writeFileIfChanged(filePath string, contents []byte) error { current, err := os.ReadFile(filePath) if err == nil && bytes.Equal(current, contents) { @@ -174,3 +129,27 @@ func writeFileIfChanged(filePath string, contents []byte) error { cleanup = false return nil } + +func stageGeneratedFile(stagedPath, currentPath string, contents []byte) error { + current, err := os.ReadFile(currentPath) + if err != nil && !os.IsNotExist(err) { + return err + } + if err != nil || !bytes.Equal(current, contents) { + return writeFileIfChanged(stagedPath, contents) + } + if err := os.MkdirAll(filepath.Dir(stagedPath), 0o755); err != nil { + return err + } + if err := os.Link(currentPath, stagedPath); err == nil { + return nil + } + if err := os.WriteFile(stagedPath, contents, 0o644); err != nil { + return err + } + if info, err := os.Stat(currentPath); err == nil { + _ = os.Chmod(stagedPath, info.Mode().Perm()) + _ = os.Chtimes(stagedPath, info.ModTime(), info.ModTime()) + } + return nil +} diff --git a/internal/appgen/ir.go b/internal/appgen/ir.go index f809896e..8b7e48ec 100644 --- a/internal/appgen/ir.go +++ b/internal/appgen/ir.go @@ -97,10 +97,10 @@ func actionEndpointRoutes(config gowdk.I18NConfig, pageRoute string, route strin func actionFormSchemaFromBlocks(blocks gwdkir.Blocks) (map[string][]view.ActionFormField, error) { if len(blocks.ViewNodes) == 0 { - if strings.TrimSpace(blocks.ViewBody) == "" { + if !blocks.View { return map[string][]view.ActionFormField{}, nil } - return nil, fmt.Errorf("view {} has source body but no parsed nodes") + return nil, fmt.Errorf("view {} has no parsed nodes") } return view.ActionFormSchemaFromNodes(blocks.ViewNodes) } @@ -172,7 +172,7 @@ func fragmentEndpointsFromIR(ir gwdkir.Program) ([]FragmentEndpoint, error) { for _, page := range ir.Pages { for _, fragment := range page.Blocks.Fragments { uses := irUsesMap(page.Uses) - html, err := renderFragmentHTML(fragment.Body, page.Package, uses, components) + html, err := renderFragmentHTML(fragment.Nodes, page.Package, uses, components) if err != nil { return nil, fmt.Errorf("%s.%s: fragment %s: %w", page.ID, fragment.Name, fragment.Target, err) } @@ -206,8 +206,8 @@ func fragmentEndpointsFromIR(ir gwdkir.Program) ([]FragmentEndpoint, error) { return endpoints, nil } -func renderFragmentHTML(body string, packageName string, uses map[string]string, components map[string]view.Component) (string, error) { - return view.RenderWithOptions(body, componentRegistryForFragment(packageName, uses, components), nil, view.Options{ +func renderFragmentHTML(nodes []view.Node, packageName string, uses map[string]string, components map[string]view.Component) (string, error) { + return view.RenderNodesWithOptions(nodes, componentRegistryForFragment(packageName, uses, components), nil, view.Options{ Package: packageName, Uses: uses, }) @@ -224,7 +224,6 @@ func fragmentComponentsFromIR(components []gwdkir.Component) map[string]view.Com PropTypes: irPropTypes(component.Props), PropDefaults: irPropDefaults(component.Props), Exports: irExportTypes(component.Exports), - Body: component.Blocks.ViewBody, Nodes: append([]view.Node(nil), component.Blocks.ViewNodes...), } addFragmentComponent(out, compiled) @@ -233,7 +232,7 @@ func fragmentComponentsFromIR(components []gwdkir.Component) map[string]view.Com } func addFragmentComponent(registry map[string]view.Component, component view.Component) { - if component.Name == "" || component.Body == "" { + if component.Name == "" || len(component.Nodes) == 0 { return } registry[fragmentComponentKey(component.Package, component.Name)] = component @@ -383,7 +382,7 @@ func actionFragmentsFromIR(action gwdkir.Action) ([]ActionFragment, error) { } fragments := make([]ActionFragment, 0, len(action.Fragments)) for _, fragment := range action.Fragments { - html, err := view.RenderSPA(fragment.Body) + html, err := view.RenderNodesWithOptions(fragment.Nodes, nil, nil, view.Options{}) if err != nil { return nil, fmt.Errorf("fragment %s: %w", fragment.Target, err) } diff --git a/internal/appgen/ir_test.go b/internal/appgen/ir_test.go index f8b190c8..f789c8d5 100644 --- a/internal/appgen/ir_test.go +++ b/internal/appgen/ir_test.go @@ -138,12 +138,20 @@ func TestAPIEndpointsFromIR(t *testing.T) { } func TestFragmentEndpointsFromIR(t *testing.T) { + componentNodes, err := view.Parse(`
{name}
`) + if err != nil { + t.Fatal(err) + } + fragmentNodes, err := view.Parse(`
`) + if err != nil { + t.Fatal(err) + } endpoints, err := fragmentEndpointsFromIR(gwdkir.Program{ Components: []gwdkir.Component{{ Name: "PatientCard", Package: "components", Props: []gwdkir.Prop{{Name: "name", Type: "string"}}, - Blocks: gwdkir.Blocks{View: true, ViewBody: `
{name}
`}, + Blocks: gwdkir.Blocks{View: true, ViewBody: `
{name}
`, ViewNodes: componentNodes}, }}, Pages: []gwdkir.Page{{ ID: "patients", @@ -158,6 +166,7 @@ func TestFragmentEndpointsFromIR(t *testing.T) { Route: "/patients/list", Target: "#patients", Body: `
`, + Nodes: fragmentNodes, }}, }, }}, diff --git a/internal/appgen/scripts.go b/internal/appgen/scripts.go index 75a79c43..9050566e 100644 --- a/internal/appgen/scripts.go +++ b/internal/appgen/scripts.go @@ -1,6 +1,7 @@ package appgen import ( + "context" "fmt" "go/format" "path/filepath" @@ -22,19 +23,6 @@ type addonGoBlockTarget struct { render gowdk.RenderMode } -func writeInlineGoBlockFiles(appDir string, options Options) ([]string, error) { - files, planned, err := collectInlineGoBlockFiles(appDir, options) - if err != nil { - return nil, err - } - for _, file := range planned { - if err := writeFileIfChanged(file.path, file.contents); err != nil { - return nil, err - } - } - return files, nil -} - func collectInlineGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) { if options.IR == nil { return nil, nil, nil @@ -128,19 +116,6 @@ func mergeGoBlockImports(left []gwdkir.Import, right []gwdkir.Import) []gwdkir.I return out } -func writeAddonGoBlockFiles(appDir string, options Options) ([]string, error) { - files, planned, err := collectAddonGoBlockFiles(appDir, options) - if err != nil { - return nil, err - } - for _, file := range planned { - if err := writeFileIfChanged(file.path, file.contents); err != nil { - return nil, err - } - } - return files, nil -} - func collectAddonGoBlockFiles(appDir string, options Options) ([]string, []plannedFile, error) { if options.IR == nil { return nil, nil, nil @@ -152,7 +127,11 @@ func collectAddonGoBlockFiles(appDir string, options Options) ([]string, []plann if !ok { return nil, nil, fmt.Errorf("go block target %s requires an enabled addon implementing gowdk.GoBlockConsumer", target.target.Target) } - generated, err := consumer.GeneratedGo(target.target, gowdk.GoBlockContext{Render: target.render}) + blockContext := gowdk.GoBlockContext{Render: target.render} + generated, err := consumer.GeneratedGo(target.target, blockContext) + if cancellable, ok := consumer.(gowdk.GoBlockConsumerContext); ok { + generated, err = cancellable.GeneratedGoContext(context.Background(), target.target, blockContext) + } if err != nil { return nil, nil, fmt.Errorf("generate addon go block target %s: %w", target.target.Target, err) } @@ -195,6 +174,20 @@ func addonGoBlockConsumer(config gowdk.Config, target string) (gowdk.GoBlockCons } } } + for _, extension := range config.Extensions { + if extension.Name() != name { + continue + } + consumer := gowdk.ResolveExtensionCapabilities(extension).GoBlockConsumer + if consumer == nil { + return nil, false + } + for _, supported := range consumer.GoBlockTargets() { + if supported == target { + return consumer, true + } + } + } return nil, false } diff --git a/internal/appgen/source.go b/internal/appgen/source.go index c9cf7dbe..b2627230 100644 --- a/internal/appgen/source.go +++ b/internal/appgen/source.go @@ -29,6 +29,7 @@ func appPackageSource(options Options) (source string, err error) { imports["embed"] = "embed" imports["fs"] = "io/fs" imports["http"] = "net/http" + imports["gowdki18n"] = "github.com/cssbruno/gowdk/runtime/i18n" return printGoFile("gowdkapp", imports, append(appShellDecls(options), appGeneratedDecls(direct, options)...)) } @@ -311,7 +312,8 @@ func appGeneratedDecls(direct Options, full Options) []ast.Decl { if full.ProxyBackend { csrfOptions = full } - decls := actionHandlerDecls(adapter.Actions, csrfEnabled(direct), generatedUsesRateLimit(direct)) + decls := []ast.Decl{errorCatalogDecl(full)} + decls = append(decls, actionHandlerDecls(adapter.Actions, csrfEnabled(direct), generatedUsesRateLimit(direct))...) decls = append(decls, apiHandlerDecls(adapter.APIs, csrfEnabled(direct), generatedUsesRateLimit(direct))...) decls = append(decls, fragmentFuncDecl(adapter.Fragments, generatedUsesRateLimit(direct))) decls = append(decls, contractHandlerDecls(adapter.ContractExposures, csrfEnabled(direct), generatedUsesRateLimit(direct), generatedRealtimeQueryInvalidationsEnabled(direct), commandPatchRenderingEnabled(direct))...) @@ -351,7 +353,8 @@ func appGeneratedDecls(direct Options, full Options) []ast.Decl { func backendGeneratedDecls(options Options) []ast.Decl { adapter := backendAdapterIR(options) - decls := actionHandlerDecls(adapter.Actions, csrfEnabled(options), generatedUsesRateLimit(options)) + decls := []ast.Decl{errorCatalogDecl(options)} + decls = append(decls, actionHandlerDecls(adapter.Actions, csrfEnabled(options), generatedUsesRateLimit(options))...) decls = append(decls, apiHandlerDecls(adapter.APIs, csrfEnabled(options), generatedUsesRateLimit(options))...) decls = append(decls, fragmentFuncDecl(adapter.Fragments, generatedUsesRateLimit(options))) decls = append(decls, contractHandlerDecls(adapter.ContractExposures, csrfEnabled(options), generatedUsesRateLimit(options), generatedRealtimeQueryInvalidationsEnabled(options), false)...) diff --git a/internal/appgen/source_actions.go b/internal/appgen/source_actions.go index 8705bd71..bcb3e7c5 100644 --- a/internal/appgen/source_actions.go +++ b/internal/appgen/source_actions.go @@ -89,15 +89,6 @@ func actionUsesPartialAddon(action BackendActionAdapter) bool { return action.Binding.Status == "" && len(action.Fragments) > 0 } -func actionsParseForm(actions []BackendActionAdapter) bool { - for _, action := range actions { - if action.Binding.Status != source.BackendBindingMissing && action.Binding.Status != source.BackendBindingUnsupportedSignature { - return true - } - } - return false -} - func actionsUseStringHelpers(actions []BackendActionAdapter) bool { for _, action := range actions { if endpointDeniedByOmission(action.Guards) { @@ -418,7 +409,7 @@ func actionRequiredValidationStmts(action BackendActionAdapter) []ast.Stmt { } stmts = append(stmts, &ast.IfStmt{ Cond: &ast.UnaryExpr{Op: token.NOT, X: call(selExpr(submission, "HasSubmitted"), stringLit(field))}, - Body: block(exprStmt(call(selExpr(id("validation"), "Add"), stringLit(field), stringLit(actionValidationMessage(action.RequiredMessages[field], "required"))))), + Body: block(exprStmt(call(selExpr(id("validation"), "AddCode"), stringLit(field), stringLit("validation_required"), stringLit(actionValidationMessage(action.RequiredMessages[field], "required")), id("nil")))), }) } for _, rule := range action.ValidationRules { @@ -453,7 +444,7 @@ func actionRequiredValidationStmts(action BackendActionAdapter) []ast.Stmt { }, }, Body: block( - writeNoStoreHTTPStmt(call(sel("gowdkresponse", "ValidationFragment"), id("validationTarget"), id("validation"))), + writeNoStoreHTTPStmt(call(sel("gowdkresponse", "LocalizedValidationFragment"), id("validationTarget"), id("validation"), id("userErrorCatalog"), requestLocaleExpr())), returnBool(true), ), }, @@ -474,7 +465,7 @@ func actionValidationRuleStmt(rule ActionValidationRule) ast.Stmt { Op: token.LSS, Y: intLit(rule.MinLength), }, - Body: block(exprStmt(call(selExpr(id("validation"), "Add"), stringLit(rule.Field), stringLit(actionValidationMessage(rule.MinLengthMessage, "minlength"))))), + Body: block(exprStmt(call(selExpr(id("validation"), "AddCode"), stringLit(rule.Field), stringLit("validation_min_length"), stringLit(actionValidationMessage(rule.MinLengthMessage, "minlength")), id("nil")))), }) } if rule.MaxLength > 0 { @@ -484,7 +475,7 @@ func actionValidationRuleStmt(rule ActionValidationRule) ast.Stmt { Op: token.GTR, Y: intLit(rule.MaxLength), }, - Body: block(exprStmt(call(selExpr(id("validation"), "Add"), stringLit(rule.Field), stringLit(actionValidationMessage(rule.MaxLengthMessage, "maxlength"))))), + Body: block(exprStmt(call(selExpr(id("validation"), "AddCode"), stringLit(rule.Field), stringLit("validation_max_length"), stringLit(actionValidationMessage(rule.MaxLengthMessage, "maxlength")), id("nil")))), }) } if rule.Pattern != "" { @@ -495,7 +486,7 @@ func actionValidationRuleStmt(rule ActionValidationRule) ast.Stmt { Op: token.LOR, Y: &ast.UnaryExpr{Op: token.NOT, X: id("matched")}, }, - Body: block(exprStmt(call(selExpr(id("validation"), "Add"), stringLit(rule.Field), stringLit(actionValidationMessage(rule.PatternMessage, "pattern"))))), + Body: block(exprStmt(call(selExpr(id("validation"), "AddCode"), stringLit(rule.Field), stringLit("validation_pattern"), stringLit(actionValidationMessage(rule.PatternMessage, "pattern")), id("nil")))), }) } return block( @@ -884,31 +875,39 @@ func backendNotImplementedStmts(binding source.BackendBinding, kind string) []as if message == "" { message = "GOWDK " + kind + " handler is not implemented" } - return []ast.Stmt{writeNoStoreErrorStmt(sel("http", "StatusNotImplemented"), message)} + return []ast.Stmt{writeNoStoreErrorExprStmt(sel("http", "StatusNotImplemented"), stringLit("handler_not_implemented"), stringLit(message))} +} + +func backendNotImplementedJSONStmts(binding source.BackendBinding, kind string) []ast.Stmt { + message := strings.TrimSpace(binding.Message) + if message == "" { + message = "GOWDK " + kind + " handler is not implemented" + } + return []ast.Stmt{exprStmt(call(sel("gowdkresponse", "WriteNoStoreLocalizedJSONUserError"), id("response"), sel("http", "StatusNotImplemented"), stringLit("handler_not_implemented"), stringLit(message), id("nil"), id("userErrorCatalog"), requestLocaleExpr()))} } func writeNoStoreErrorStmt(status ast.Expr, message string) ast.Stmt { - return writeNoStoreErrorExprStmt(status, stringLit(message)) + return writeNoStoreErrorExprStmt(status, stringLit(generatedErrorCode(message)), stringLit(message)) } -func writeNoStoreErrorExprStmt(status ast.Expr, message ast.Expr) ast.Stmt { - return exprStmt(call(sel("gowdkresponse", "WriteNoStoreError"), id("response"), status, message)) +func writeNoStoreErrorExprStmt(status ast.Expr, code ast.Expr, message ast.Expr) ast.Stmt { + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreLocalizedUserError"), id("response"), status, code, message, id("nil"), id("userErrorCatalog"), requestLocaleExpr())) } func writeNoStoreHandlerErrorExprStmt(err ast.Expr, fallbackStatus ast.Expr) ast.Stmt { - return exprStmt(call(sel("gowdkresponse", "WriteNoStoreHandlerError"), id("response"), err, fallbackStatus)) + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreLocalizedHandlerError"), id("response"), err, fallbackStatus, id("userErrorCatalog"), requestLocaleExpr())) } func writeNoStoreJSONErrorStmt(status ast.Expr, message string) ast.Stmt { - return exprStmt(call(sel("gowdkresponse", "WriteNoStoreJSONError"), id("response"), status, stringLit(message))) + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreLocalizedJSONUserError"), id("response"), status, stringLit(generatedErrorCode(message)), stringLit(message), id("nil"), id("userErrorCatalog"), requestLocaleExpr())) } func writeNoStoreHandlerJSONErrorExprStmt(err ast.Expr, fallbackStatus ast.Expr) ast.Stmt { - return exprStmt(call(sel("gowdkresponse", "WriteNoStoreHandlerJSONError"), id("response"), err, fallbackStatus)) + return exprStmt(call(sel("gowdkresponse", "WriteNoStoreLocalizedHandlerJSONError"), id("response"), err, fallbackStatus, id("userErrorCatalog"), requestLocaleExpr())) } func handlerErrorMessageExpr(err ast.Expr, fallbackStatus ast.Expr) ast.Expr { - return call(sel("gowdkresponse", "HandlerErrorMessage"), err, fallbackStatus) + return call(sel("gowdkresponse", "LocalizedHandlerErrorMessage"), err, fallbackStatus, id("userErrorCatalog"), requestLocaleExpr()) } func writeNoStoreHTTPStmt(result ast.Expr) ast.Stmt { diff --git a/internal/appgen/source_api.go b/internal/appgen/source_api.go index eb42a3c7..2d79e167 100644 --- a/internal/appgen/source_api.go +++ b/internal/appgen/source_api.go @@ -73,7 +73,7 @@ func apiCaseStmts(api BackendAPIAdapter, csrf bool, rateLimit bool) []ast.Stmt { stmts = append(stmts, rateLimitStmts(rateLimit)...) stmts = append(stmts, guardStmts(api.Guards)...) if api.Binding.Status != source.BackendBindingBound { - stmts = append(stmts, backendNotImplementedStmts(api.Binding, "API")...) + stmts = append(stmts, backendNotImplementedJSONStmts(api.Binding, "API")...) stmts = append(stmts, returnBool(true)) return stmts } diff --git a/internal/appgen/source_auth.go b/internal/appgen/source_auth.go index cf2fa072..9297891a 100644 --- a/internal/appgen/source_auth.go +++ b/internal/appgen/source_auth.go @@ -77,6 +77,15 @@ func authSetupStmts(options Options) []ast.Stmt { } func authSessionOptions(config gowdk.Config) gowdk.AuthSessionOptions { + if config.Features.Auth.Enabled { + return config.Features.Auth.Session + } + for _, extension := range config.Extensions { + provider := gowdk.ResolveExtensionCapabilities(extension).AuthSessionProvider + if provider != nil { + return provider.AuthSessionOptions() + } + } for _, addon := range config.Addons { provider := gowdk.ResolveAddonCapabilities(addon).AuthSessionProvider if provider != nil { diff --git a/internal/appgen/source_backend_app.go b/internal/appgen/source_backend_app.go index 73299061..96e1de82 100644 --- a/internal/appgen/source_backend_app.go +++ b/internal/appgen/source_backend_app.go @@ -5,6 +5,7 @@ func backendAppPackageSource(options Options) (source string, err error) { imports := backendRuntimeImportMap(options) imports["http"] = "net/http" + imports["gowdki18n"] = "github.com/cssbruno/gowdk/runtime/i18n" return printGoFile("gowdkapp", imports, append(backendShellDecls(options), backendGeneratedDecls(options)...)) } @@ -87,6 +88,12 @@ func backendRuntimeImportMap(options Options) map[string]string { imports["gowdkauth"] = "github.com/cssbruno/gowdk/runtime/auth" imports["gowdkguard"] = "github.com/cssbruno/gowdk/runtime/guard" } + if generatedRequiresAppGuardRegistry(options) && options.guardHookAlias != "" { + imports[options.guardHookAlias] = options.Config.Interop.Guards.Hook.ImportPath + } + if generatedUsesNativeRBACGuards(options) && !generatedUsesAuthAddon(options) && options.authHookAlias != "" && options.Config.Interop.AuthProvider.Hook.ImportPath != options.Config.Interop.Guards.Hook.ImportPath { + imports[options.authHookAlias] = options.Config.Interop.AuthProvider.Hook.ImportPath + } if csrfEnabled(options) { imports["errors"] = "errors" imports["gowdkactions"] = "github.com/cssbruno/gowdk/runtime/actions" diff --git a/internal/appgen/source_guards.go b/internal/appgen/source_guards.go index f2740c85..f9953fae 100644 --- a/internal/appgen/source_guards.go +++ b/internal/appgen/source_guards.go @@ -89,11 +89,11 @@ func registerAuthProviderDecl() ast.Decl { func requiredGuardBackingInitDecl(options Options) ast.Decl { var stmts []ast.Stmt - if generatedRequiresAppGuardRegistry(options) { - stmts = append(stmts, exprStmt(call(sel("RegisterGuards"), call(id("GOWDKGuardRegistry"))))) + if generatedRequiresAppGuardRegistry(options) && options.guardHookAlias != "" { + stmts = append(stmts, exprStmt(call(sel("RegisterGuards"), call(sel(options.guardHookAlias, options.Config.Interop.Guards.Hook.Function))))) } - if generatedUsesNativeRBACGuards(options) && !generatedUsesAuthAddon(options) { - stmts = append(stmts, exprStmt(call(sel("RegisterAuthProvider"), call(id("GOWDKAuthProvider"))))) + if generatedUsesNativeRBACGuards(options) && !generatedUsesAuthAddon(options) && options.authHookAlias != "" { + stmts = append(stmts, exprStmt(call(sel("RegisterAuthProvider"), call(sel(options.authHookAlias, options.Config.Interop.AuthProvider.Hook.Function))))) } if len(stmts) == 0 { return nil @@ -116,7 +116,7 @@ func runGuardsDecl() ast.Decl { Init: define([]ast.Expr{id("err")}, call(sel("gowdkguard", "RunGuardsWithAuth"), id("guardContext"), id("guards"), id("guardRegistry"), id("authProvider"))), Cond: notNil("err"), Body: block( - exprStmt(call(sel("gowdkguard", "WriteNoStoreFailure"), id("response"), id("err"))), + exprStmt(call(sel("gowdkguard", "WriteNoStoreLocalizedFailure"), id("response"), id("err"), id("userErrorCatalog"), requestLocaleExpr())), returnBool(false), ), }, diff --git a/internal/appgen/source_i18n_errors.go b/internal/appgen/source_i18n_errors.go new file mode 100644 index 00000000..ffa8bc9f --- /dev/null +++ b/internal/appgen/source_i18n_errors.go @@ -0,0 +1,73 @@ +package appgen + +import ( + "go/ast" + "go/token" + "sort" + "strings" + + "github.com/cssbruno/gowdk/runtime/i18n" +) + +func errorCatalogDecl(options Options) ast.Decl { + defaultLocale := strings.TrimSpace(options.Config.I18N.Errors.DefaultLocale) + if defaultLocale == "" { + defaultLocale = options.Config.I18N.DefaultLocaleCode() + } + return &ast.GenDecl{Tok: token.VAR, Specs: []ast.Spec{&ast.ValueSpec{ + Names: []*ast.Ident{id("userErrorCatalog")}, + Values: []ast.Expr{call(sel("gowdki18n", "NewErrorBundleStrings"), stringLit(defaultLocale), errorCatalogMapExpr(options.Config.I18N.Errors))}, + }}} +} + +func errorCatalogMapExpr(bundle i18n.ErrorBundle) ast.Expr { + outer := &ast.CompositeLit{Type: &ast.MapType{Key: id("string"), Value: &ast.MapType{Key: id("string"), Value: id("string")}}} + locales := make([]string, 0, len(bundle.Catalogs)) + for locale := range bundle.Catalogs { + locales = append(locales, locale) + } + sort.Strings(locales) + for _, locale := range locales { + catalog := bundle.Catalogs[locale] + inner := &ast.CompositeLit{Type: &ast.MapType{Key: id("string"), Value: id("string")}} + codes := make([]string, 0, len(catalog.Messages)) + for code := range catalog.Messages { + codes = append(codes, string(code)) + } + sort.Strings(codes) + for _, code := range codes { + inner.Elts = append(inner.Elts, &ast.KeyValueExpr{Key: stringLit(code), Value: stringLit(catalog.Messages[i18n.ErrorCode(code)])}) + } + outer.Elts = append(outer.Elts, &ast.KeyValueExpr{Key: stringLit(locale), Value: inner}) + } + return outer +} + +func requestLocaleExpr() ast.Expr { + return call(sel("gowdkruntime", "Locale"), call(selExpr(id("request"), "Context"))) +} + +func generatedErrorCode(message string) string { + switch { + case strings.Contains(message, "csrf"): + return "invalid_csrf_token" + case strings.Contains(message, "request body too large"): + return "request_body_too_large" + case strings.Contains(message, "invalid form"): + return "invalid_form" + case strings.Contains(message, "validation failed"): + return "validation_failed" + case strings.Contains(message, "partial fragment not found"): + return "fragment_not_found" + case strings.Contains(message, "route parameter"): + return "invalid_route_parameter" + case strings.Contains(message, "method not allowed"): + return "method_not_allowed" + case strings.Contains(message, "not implemented"), strings.Contains(message, "not registered"): + return "handler_not_implemented" + case strings.Contains(message, "403 forbidden"): + return "forbidden" + default: + return "request_failed" + } +} diff --git a/internal/appgen/testdata/generated_go_golden/app.go.golden b/internal/appgen/testdata/generated_go_golden/app.go.golden index e37dbeee..71fadac8 100644 --- a/internal/appgen/testdata/generated_go_golden/app.go.golden +++ b/internal/appgen/testdata/generated_go_golden/app.go.golden @@ -11,6 +11,7 @@ import ( gowdkcontracts "github.com/cssbruno/gowdk/runtime/contracts" gowdkenvfile "github.com/cssbruno/gowdk/runtime/envfile" gowdkform "github.com/cssbruno/gowdk/runtime/form" + gowdki18n "github.com/cssbruno/gowdk/runtime/i18n" gowdkresponse "github.com/cssbruno/gowdk/runtime/response" "io/fs" "net/http" @@ -108,6 +109,9 @@ func loadEnvFile() error { _, err = gowdkenvfile.LoadIntoEnv(path, explicit != "") return err } + +var userErrorCatalog = gowdki18n.NewErrorBundleStrings("", map[string]map[string]string{}) + func action(response http.ResponseWriter, request *http.Request) bool { requestPath := actionRequestPath(request.URL.Path) switch requestPath { @@ -117,33 +121,33 @@ func action(response http.ResponseWriter, request *http.Request) bool { request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes) if err := request.ParseForm(); err != nil { if gowdkresponse.IsRequestBodyTooLarge(err) { - gowdkresponse.WriteNoStoreError(response, http.StatusRequestEntityTooLarge, "request body too large") + gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusRequestEntityTooLarge, "request_body_too_large", "request body too large", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } if csrfValidator != nil { if err := csrfValidator.Validate(request); err != nil { - gowdkresponse.WriteNoStoreError(response, http.StatusForbidden, "invalid csrf token") + gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusForbidden, "invalid_csrf_token", "invalid csrf token", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } } values := gowdkform.FromURLValues(request.PostForm) if decodedValues, err := gowdkform.DecodeExpected(values, gowdkform.Schema{Fields: []gowdkform.Field{{Name: "email"}, {Name: "tag"}, {Name: "age"}, {Name: "remember"}}}); err != nil { - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } else { values = decodedValues } input, err := decodeNewsletterSubscribeBoundInput(values) if err != nil { - gowdkresponse.WriteNoStoreError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } result, err := newsletter.Subscribe(ctx, input) if err != nil { - gowdkresponse.WriteNoStoreHandlerError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } _ = gowdkresponse.WriteNoStoreHTTP(response, result) @@ -197,36 +201,36 @@ func commandPatientsCreatePatientPOSTPatients(contractRegistry *gowdkcontracts.R request.Body = http.MaxBytesReader(response, request.Body, maxActionBodyBytes) if err := request.ParseForm(); err != nil { if gowdkresponse.IsRequestBodyTooLarge(err) { - gowdkresponse.WriteNoStoreJSONError(response, http.StatusRequestEntityTooLarge, "request body too large") + gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusRequestEntityTooLarge, "request_body_too_large", "request body too large", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } - gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } if csrfValidator != nil { if err := csrfValidator.Validate(request); err != nil { - gowdkresponse.WriteNoStoreJSONError(response, http.StatusForbidden, "invalid csrf token") + gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusForbidden, "invalid_csrf_token", "invalid csrf token", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } } values := gowdkform.FromURLValues(request.PostForm) input, err := decodeContractPatientsCreatePatientInput(values) if err != nil { - gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } result, events, err := gowdkcontracts.CaptureCommandEventsForRole[patients.CreatePatient, patients.CreatePatientResult](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) if err != nil { - gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } if dispatchErr := gowdkcontracts.DispatchCommandEvents(ctx, currentContractEventSink(), contractRegistry, gowdkcontracts.RoleWeb, events); dispatchErr != nil { - gowdkresponse.WriteNoStoreHandlerJSONError(response, dispatchErr, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, dispatchErr, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } httpResult, err := gowdkresponse.JSONValue(http.StatusOK, result) if err != nil { - gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } _ = gowdkresponse.WriteNoStoreHTTP(response, httpResult) @@ -240,17 +244,17 @@ func queryPatientsGetPatientPageGETPatients(contractRegistry *gowdkcontracts.Reg values := gowdkform.FromURLValues(request.URL.Query()) input, err := decodeContractPatientsGetPatientPageInput(values) if err != nil { - gowdkresponse.WriteNoStoreJSONError(response, http.StatusBadRequest, "invalid form") + gowdkresponse.WriteNoStoreLocalizedJSONUserError(response, http.StatusBadRequest, "invalid_form", "invalid form", nil, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } result, err := gowdkcontracts.ExecuteQueryForRole[patients.GetPatientPage, patients.PatientPageData](ctx, contractRegistry, gowdkcontracts.RoleWeb, input) if err != nil { - gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } httpResult, err := gowdkresponse.JSONValue(http.StatusOK, result) if err != nil { - gowdkresponse.WriteNoStoreHandlerJSONError(response, err, http.StatusInternalServerError) + gowdkresponse.WriteNoStoreLocalizedHandlerJSONError(response, err, http.StatusInternalServerError, userErrorCatalog, gowdkruntime.Locale(request.Context())) return true } _ = gowdkresponse.WriteNoStoreHTTP(response, httpResult) diff --git a/internal/appgen/types.go b/internal/appgen/types.go index 9ca3ed58..0febf175 100644 --- a/internal/appgen/types.go +++ b/internal/appgen/types.go @@ -37,8 +37,10 @@ type Options struct { Program *compiler.ValidatedProgram // IR is the legacy raw-IR option path. Production auto-routing should pass // Program so generation receives a compiler-validated phase token. - IR *gwdkir.Program - Sitemap buildgen.RuntimeSitemapPlan + IR *gwdkir.Program + Sitemap buildgen.RuntimeSitemapPlan + guardHookAlias string + authHookAlias string } // ApplicationPlan is the normalized generated-application plan consumed by diff --git a/internal/buildgen/build.go b/internal/buildgen/build.go index 1c7c6948..7096a688 100644 --- a/internal/buildgen/build.go +++ b/internal/buildgen/build.go @@ -1,9 +1,11 @@ package buildgen import ( + "bytes" "encoding/json" "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -12,6 +14,7 @@ import ( "github.com/cssbruno/gowdk/internal/compiler" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/publish" "github.com/cssbruno/gowdk/internal/source" ) @@ -20,12 +23,42 @@ import ( // defaults, localized page outputs, CSS/assets, fragments, schemas, and report // metadata are finalized before files are written. type BuildPlan struct { - reporter *buildReporter - planned buildPlan - config gowdk.Config - ir gwdkir.Program - outputDir string - valid bool + reporter *buildReporter + planned buildPlan + config gowdk.Config + ir gwdkir.Program + outputDir string + extraFiles []plannedPublishedFile + prePublish func(Result) error + valid bool +} + +// AddOutputFile adds a compiler-owned report or artifact to the same output +// generation transaction. relativePath must stay within the build directory. +func (plan *BuildPlan) AddOutputFile(relativePath string, contents []byte) error { + if plan == nil || !plan.valid { + return fmt.Errorf("build plan was not constructed by buildgen planning") + } + relativePath = filepath.Clean(strings.TrimSpace(relativePath)) + if relativePath == "." || filepath.IsAbs(relativePath) || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { + return fmt.Errorf("additional output path %q must stay within the build directory", relativePath) + } + absolute := filepath.Join(plan.outputDir, relativePath) + for _, file := range plan.extraFiles { + if filepath.Clean(file.path) == filepath.Clean(absolute) { + return fmt.Errorf("additional output path %q is already planned", relativePath) + } + } + plan.extraFiles = append(plan.extraFiles, plannedPublishedFile{path: absolute, contents: append([]byte(nil), contents...)}) + return nil +} + +// SetPrePublishValidation installs a read-only audit over staged artifact +// paths. A failure aborts publication and leaves the previous generation live. +func (plan *BuildPlan) SetPrePublishValidation(validate func(Result) error) { + if plan != nil { + plan.prePublish = validate + } } func Build(config gowdk.Config, sources gwdkanalysis.Sources, outputDir string) (Result, error) { @@ -100,7 +133,11 @@ func BuildFromPlan(plan BuildPlan) (Result, error) { CSSArtifacts: make([]CSSArtifact, 0, len(planned.css)), AssetArtifacts: make([]AssetArtifact, 0, len(planned.assets)), } + for _, file := range plan.extraFiles { + result.AdditionalOutputPaths = append(result.AdditionalOutputPaths, file.path) + } files := make([]plannedPublishedFile, 0, len(planned.css)+len(planned.assets)+len(planned.pages)+6) + files = append(files, plan.extraFiles...) for _, artifact := range planned.css { finalizeCSSArtifact(&artifact) result.CSSArtifacts = append(result.CSSArtifacts, artifact.CSSArtifact) @@ -189,8 +226,27 @@ func BuildFromPlan(plan BuildPlan) (Result, error) { } result.BuildReportPath = buildReportPath(outputDir) files = append(files, plannedPublishedFile{path: result.BuildReportPath, contents: buildReport}) + var publication publish.Transaction + stageOutput, err := publication.StageDirectory(outputDir) + if err != nil { + return Result{}, reporter.fail("write", err) + } + defer publication.Abort() + stageSecurity, err := publication.StageFile(result.SecurityManifestPath) + if err != nil { + return Result{}, reporter.fail("write", err) + } for _, file := range files { - wrote, err := writeFileIfChangedStatus(file.path, file.contents) + stagedPath := stageSecurity + if filepath.Clean(file.path) != filepath.Clean(result.SecurityManifestPath) { + rel, relErr := relativeOutputPath(outputDir, file.path) + if relErr != nil { + return Result{}, reporter.fail("write", relErr) + } + stagedPath = filepath.Join(stageOutput, filepath.FromSlash(rel)) + } + wrote := !samePublishedContents(file.path, file.contents) + err := stagePublishedFile(stagedPath, file.path, file.contents, !wrote) if err != nil { return Result{}, reporter.fail("write", err) } @@ -198,12 +254,90 @@ func BuildFromPlan(plan BuildPlan) (Result, error) { recordWriteStat(&result, wrote) } } - if err := removeServedSecurityManifest(outputDir); err != nil { - return Result{}, reporter.fail("cleanup", err) + if plan.prePublish != nil { + stagedResult := stagedBuildResult(result, outputDir, stageOutput, result.SecurityManifestPath, stageSecurity) + if err := plan.prePublish(stagedResult); err != nil { + return Result{}, reporter.fail("pre_publish_validation", err) + } + } + if err := publication.Commit(); err != nil { + return Result{}, reporter.fail("publish", err) } return result, nil } +func stagePublishedFile(stagedPath, currentPath string, contents []byte, unchanged bool) error { + if !unchanged { + _, err := writeFileIfChangedStatus(stagedPath, contents) + return err + } + if err := os.MkdirAll(filepath.Dir(stagedPath), 0o755); err != nil { + return err + } + if err := os.Remove(stagedPath); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.Link(currentPath, stagedPath); err == nil { + return nil + } + if err := os.WriteFile(stagedPath, contents, 0o644); err != nil { + return err + } + if info, err := os.Stat(currentPath); err == nil { + _ = os.Chmod(stagedPath, info.Mode().Perm()) + _ = os.Chtimes(stagedPath, info.ModTime(), info.ModTime()) + } + return nil +} + +func stagedBuildResult(result Result, outputDir, stageOutput, securityPath, stageSecurity string) Result { + // Result is returned to callers after validation. Clone every slice before + // rewriting staged paths so the validator cannot mutate the published result + // through a shared backing array. + result.Artifacts = append([]Artifact(nil), result.Artifacts...) + result.CSSArtifacts = append([]CSSArtifact(nil), result.CSSArtifacts...) + result.AssetArtifacts = append([]AssetArtifact(nil), result.AssetArtifacts...) + result.AdditionalOutputPaths = append([]string(nil), result.AdditionalOutputPaths...) + mapPath := func(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + if filepath.Clean(path) == filepath.Clean(securityPath) { + return stageSecurity + } + rel, err := relativeOutputPath(outputDir, path) + if err != nil { + return path + } + return filepath.Join(stageOutput, filepath.FromSlash(rel)) + } + for index := range result.Artifacts { + result.Artifacts[index].Path = mapPath(result.Artifacts[index].Path) + } + for index := range result.CSSArtifacts { + result.CSSArtifacts[index].Path = mapPath(result.CSSArtifacts[index].Path) + } + for index := range result.AssetArtifacts { + result.AssetArtifacts[index].Path = mapPath(result.AssetArtifacts[index].Path) + } + result.RouteManifestPath = mapPath(result.RouteManifestPath) + result.AssetManifestPath = mapPath(result.AssetManifestPath) + result.SitemapPath = mapPath(result.SitemapPath) + result.RobotsPath = mapPath(result.RobotsPath) + result.OpenAPIPath = mapPath(result.OpenAPIPath) + result.SecurityManifestPath = mapPath(result.SecurityManifestPath) + result.BuildReportPath = mapPath(result.BuildReportPath) + for index := range result.AdditionalOutputPaths { + result.AdditionalOutputPaths[index] = mapPath(result.AdditionalOutputPaths[index]) + } + return result +} + +func samePublishedContents(path string, contents []byte) bool { + current, err := os.ReadFile(path) + return err == nil && bytes.Equal(current, contents) +} + func recordWriteStat(result *Result, wrote bool) { if wrote { result.WriteStats.FilesWritten++ @@ -709,234 +843,10 @@ func BuildIncrementalFromAnalyzedProgram(config gowdk.Config, analyzed compiler. // BuildIncrementalFromValidatedProgram incrementally renders changed SPA pages // from compiler-validated IR. func BuildIncrementalFromValidatedProgram(config gowdk.Config, validated compiler.ValidatedProgram, outputDir string, changedPageSources []string) (Result, error) { - ir := validated.Program() - backendBindings := validated.BackendBindings() - reporter := newBuildReporter("incremental", outputDir) - reporter.info("start", "build_started", "incremental SPA build started", BuildEvent{ - Data: map[string]string{ - "pages": fmt.Sprint(len(ir.Pages)), - "changedSources": fmt.Sprint(len(changedPageSources)), - }, - }) - if strings.TrimSpace(outputDir) == "" { - return Result{}, reporter.fail("validate", fmt.Errorf("build output directory is required")) - } - if !validated.Valid() { - return Result{}, reporter.fail("validate", fmt.Errorf("validated program was not constructed by compiler validation")) - } - reporter.info("validate", "ir_valid", "compiler IR validation completed", BuildEvent{}) - reportBackendBindings(reporter, backendBindings) - reportContractReferences(reporter, ir.ContractRefs) - reportRealtimeSubscriptions(reporter, ir.RealtimeSubscriptions) - reportStructuredData(reporter, ir) - if err := compiler.ValidateBackendBindingPolicyIR(config, ir); err != nil { - return Result{}, reporter.fail("bind", err) - } - - changedPages := sourcePathSet(changedPageSources) - components, componentFailures := buildComponents(ir.Components) - layouts, layoutFailures := buildLayouts(ir.Layouts) - css, cssFailures := planCSS(config, ir, outputDir, components, layouts) - componentAssets, componentAssetFailures := planComponentFileAssets(ir.Assets, outputDir) - scopedJS, scopedJSFailures := planScopedJSAssets(ir.Assets, outputDir) - baseStylesheets := append([]gowdk.Stylesheet{}, config.Build.Stylesheets...) - baseStylesheets = append(baseStylesheets, css.stylesheets...) - actionFields := pageActionInputFields(ir) - realtimeEventTypeNames := realtimeSubscriptionEventTypeNames(ir.RealtimeSubscriptions) - queryTypeNames := queryInvalidationTypeNames(ir.QueryInvalidations) - - var failures []string - failures = append(failures, componentFailures...) - failures = append(failures, layoutFailures...) - failures = append(failures, cssFailures...) - failures = append(failures, componentAssetFailures...) - failures = append(failures, scopedJSFailures...) - if len(failures) > 0 { - return Result{}, reporter.fail("plan", errors.New(strings.Join(failures, "\n"))) - } - runtime, err := runtimeArtifacts(config, ir, outputDir, layouts, components) - if err != nil { - return Result{}, reporter.fail("plan", err) - } - runtime = append(scopedJS, runtime...) - runtime = append(componentAssets, runtime...) - var obfuscations []assetObfuscationRecord - runtime, obfuscations, err = applyAssetObfuscation(config, outputDir, runtime) - if err != nil { - return Result{}, reporter.fail("plan", err) - } - reporter.info("plan", "artifacts_planned", "incremental artifacts planned", BuildEvent{ - Data: map[string]string{ - "css": fmt.Sprint(len(css.assets)), - "assets": fmt.Sprint(len(runtime)), - }, - }) - reportAssetObfuscation(reporter, config.Build.ObfuscateAssets, obfuscations) - - result := Result{ - Artifacts: make([]Artifact, 0, len(ir.Pages)), - CSSArtifacts: make([]CSSArtifact, 0, len(css.assets)), - AssetArtifacts: make([]AssetArtifact, 0, 1), - } - previousRoutes, err := readRouteManifestIfExists(outputDir) - if err != nil { - return Result{}, reporter.fail("manifest", err) - } - previousAssets, err := readAssetManifestIfExists(outputDir) - if err != nil { - return Result{}, reporter.fail("manifest", err) - } - reporter.debug("manifest", "previous_route_manifest_read", "previous route manifest read", BuildEvent{ - Data: map[string]string{"routes": fmt.Sprint(len(previousRoutes.Routes))}, - }) - changedPageIDs := map[string]bool{} - for _, artifact := range css.assets { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - 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 { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - recordWriteStat(&result, wrote) - reporter.debug("write", "asset_written", "runtime asset written", BuildEvent{Path: eventPath(outputDir, artifact.Path)}) - finalizeAssetArtifact(&artifact) - result.AssetArtifacts = append(result.AssetArtifacts, artifact.AssetArtifact) - } - - seenOutputPaths := map[string]string{} - for _, page := range ir.Pages { - if isRequestTimePage(config, page) { - continue - } - routeArtifacts, err := pageRouteArtifacts(config, outputDir, page) - if err != nil { - failures = append(failures, err.Error()) - continue - } - for _, artifact := range routeArtifacts { - if _, err := relativeOutputPath(outputDir, artifact.Path); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", page.ID, err)) - continue - } - if previousPage, ok := seenOutputPaths[artifact.Path]; ok { - failures = append(failures, pageOutputCollisionError(page, artifact.Route, previousPage)) - continue - } - seenOutputPaths[artifact.Path] = page.ID - result.Artifacts = append(result.Artifacts, artifact) - } - - if !sourcePathChanged(changedPages, page.Source) { - continue - } - 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, actionFields[page.ID], realtimeEventTypeNames, queryTypeNames) - if err != nil { - failures = append(failures, err.Error()) - continue - } - for _, artifact := range pageArtifacts { - wrote, err := writeFileIfChangedStatus(artifact.Path, artifact.contents) - if err != nil { - return Result{}, reporter.fail("write", err) - } - recordWriteStat(&result, wrote) - reporter.debug("write", "page_written", "page artifact written", BuildEvent{ - PageID: artifact.PageID, - Route: artifact.Route, - Path: eventPath(outputDir, artifact.Path), - }) - } - } - if len(failures) > 0 { - return Result{}, reporter.fail("plan", errors.New(strings.Join(failures, "\n"))) - } - reportSkippedPrerenderPages(reporter, config, ir) - if err := removeStaleChangedPageArtifacts(outputDir, previousRoutes, result.Artifacts, changedPageIDs); err != nil { - return Result{}, reporter.fail("cleanup", err) - } - reporter.info("cleanup", "stale_artifacts_removed", "stale changed-page artifacts removed", BuildEvent{ - Data: map[string]string{"changedPages": fmt.Sprint(len(changedPageIDs))}, - }) - - endpoints := compiler.BuildRouteMetadataFromIR(config, ir).Endpoints - manifestPath, err := writeRouteManifest(outputDir, result.Artifacts, endpoints) - if err != nil { - return Result{}, reporter.fail("manifest", err) - } - result.RouteManifestPath = manifestPath - reporter.info("manifest", "route_manifest_written", "route manifest written", BuildEvent{Path: eventPath(outputDir, manifestPath)}) - seoPlan, err := planSEOArtifacts(config, ir, result.Artifacts) - if err != nil { - return Result{}, reporter.fail("seo", err) - } - reportSEOExclusions(reporter, seoPlan.Exclusions) - sitemapPath, robotsPath, sitemapWrote, robotsWrote, err := writeSEOArtifacts(outputDir, seoPlan) - if err != nil { - return Result{}, reporter.fail("seo", err) - } - if sitemapPath != "" { - recordWriteStat(&result, sitemapWrote) - result.SitemapPath = sitemapPath - reporter.info("seo", "sitemap_written", "sitemap written", BuildEvent{ - Path: eventPath(outputDir, sitemapPath), - Data: map[string]string{"urls": fmt.Sprint(len(seoPlan.URLs))}, - }) - } - if robotsPath != "" { - recordWriteStat(&result, robotsWrote) - result.RobotsPath = robotsPath - reporter.info("seo", "robots_written", "robots.txt written", BuildEvent{Path: eventPath(outputDir, robotsPath)}) - } - if err := removeStaleAssetManifestFiles(outputDir, previousAssets, result.CSSArtifacts, result.AssetArtifacts); err != nil { - return Result{}, reporter.fail("cleanup", err) - } - reporter.info("cleanup", "stale_assets_removed", "stale generated assets removed", BuildEvent{}) - assetManifestPath, err := writeAssetManifest(outputDir, result.Artifacts, result.CSSArtifacts, result.AssetArtifacts) - if err != nil { - return Result{}, reporter.fail("manifest", err) - } - 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, config, ir) - if err != nil { - return Result{}, reporter.fail("report", err) - } - result.OpenAPIPath = openAPIPath - reporter.info("report", "openapi_written", "OpenAPI report written", BuildEvent{Path: eventPath(outputDir, openAPIPath)}) - securityManifestPath, err := writeSecurityManifest(outputDir, config, ir) - if err != nil { - return Result{}, reporter.fail("manifest", err) - } - result.SecurityManifestPath = securityManifestPath - reporter.info("manifest", "security_manifest_written", "security manifest written", BuildEvent{Path: eventPath(outputDir, securityManifestPath)}) - reporter.info("complete", "build_complete", "incremental SPA build completed", BuildEvent{ - Data: map[string]string{ - "pages": fmt.Sprint(len(result.Artifacts)), - "changedPages": fmt.Sprint(len(changedPageIDs)), - "css": fmt.Sprint(len(result.CSSArtifacts)), - "assets": fmt.Sprint(len(result.AssetArtifacts)), - }, - }) - result.Report = reporter.result() - buildReportPath, err := writeBuildReport(outputDir, result.Report) - if err != nil { - return Result{}, reporter.fail("report", err) - } - result.BuildReportPath = buildReportPath - return result, nil + // Publication is generation-transactional. Planning the complete generation + // also guarantees that stale routes and assets disappear in the same commit. + // changedPageSources remains in the API for dev dependency accounting. + return BuildFromValidatedProgram(config, validated, outputDir) } func plan(config gowdk.Config, sources gwdkanalysis.Sources, outputDir string) (buildPlan, error) { diff --git a/internal/buildgen/build_data_routes_test.go b/internal/buildgen/build_data_routes_test.go index 4a7ebd9d..c405064e 100644 --- a/internal/buildgen/build_data_routes_test.go +++ b/internal/buildgen/build_data_routes_test.go @@ -208,7 +208,7 @@ func TestBuildRejectsInvalidBuildDataBeforeWriting(t *testing.T) { { name: "malformed", body: `title: "Home"`, - wantError: `build line 1 must use`, + wantError: `unsupported literal record syntax`, }, { name: "duplicate field across declarations", @@ -219,7 +219,7 @@ func TestBuildRejectsInvalidBuildDataBeforeWriting(t *testing.T) { { name: "duplicate field", body: `=> { title: "Home", title: "Again" }`, - wantError: `duplicate build field "title"`, + wantError: `duplicate literal record field "title"`, }, { name: "invalid expression", @@ -1098,7 +1098,7 @@ func TestBuildRejectsInvalidDynamicPathsBeforeWriting(t *testing.T) { { name: "malformed", body: `slug: "hello-gowdk"`, - wantError: `paths line 1 must use`, + wantError: `unsupported literal record syntax`, }, { name: "missing param", diff --git a/internal/buildgen/components.go b/internal/buildgen/components.go index b6b5cb4b..9c5ae21f 100644 --- a/internal/buildgen/components.go +++ b/internal/buildgen/components.go @@ -38,8 +38,8 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, failures = append(failures, fmt.Sprintf("component %s missing view {}", component.Name)) continue } - if strings.TrimSpace(component.Blocks.ViewBody) == "" { - failures = append(failures, fmt.Sprintf("component %s view {} is empty", component.Name)) + if len(component.Blocks.ViewNodes) == 0 { + failures = append(failures, fmt.Sprintf("component %s view {} has no parsed nodes", component.Name)) continue } @@ -98,7 +98,6 @@ func buildComponents(components []gwdkir.Component) (map[string]view.Component, Emits: emits, Exports: exports, Computed: computeds, - Body: component.Blocks.ViewBody, Nodes: append([]view.Node(nil), component.Blocks.ViewNodes...), } registry[key] = compiled @@ -175,13 +174,13 @@ func componentUses(uses []gwdkir.Use) map[string]string { } func componentClientComputeds(component gwdkir.Component) ([]clientlang.Computed, []string) { - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { + if !component.Blocks.Client { return nil, nil } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { - return nil, []string{fmt.Sprintf("component %s client: %v", component.Name, err)} + if component.Blocks.ClientProgram == nil { + return nil, []string{fmt.Sprintf("component %s client: parsed program is missing", component.Name)} } + program := *component.Blocks.ClientProgram computeds, err := program.OrderedComputed() if err != nil { return nil, []string{fmt.Sprintf("component %s computed dependency graph: %v", component.Name, err)} @@ -190,32 +189,33 @@ func componentClientComputeds(component gwdkir.Component) ([]clientlang.Computed } func componentClientRefs(component gwdkir.Component) (map[string]clientlang.Ref, []string) { - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { + if !component.Blocks.Client { return nil, nil } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { - return nil, []string{fmt.Sprintf("component %s client: %v", component.Name, err)} + if component.Blocks.ClientProgram == nil { + return nil, []string{fmt.Sprintf("component %s client: parsed program is missing", component.Name)} } + program := *component.Blocks.ClientProgram return program.RefMap(), nil } 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 && len(exports) == 0 { + if !component.Blocks.Client && len(emits) == 0 && len(exports) == 0 { return nil, "", nil } - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { + if !component.Blocks.Client { payload, err := json.Marshal(clientlang.Bootstrap{Emits: emits, Exports: exports}) if err != nil { return nil, "", err } return nil, string(payload), nil } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { - return nil, "", err + if component.Blocks.ClientProgram == nil { + return nil, "", fmt.Errorf("parsed client program is missing") } + program := *component.Blocks.ClientProgram + var err error handlers := program.HandlerMap() helpers := program.HelperMap() if len(handlers) == 0 && len(helpers) == 0 && !program.NeedsBootstrap() && len(emits) == 0 && len(exports) == 0 { @@ -425,13 +425,10 @@ func componentInitialState(component gwdkir.Component) (map[string]string, map[s // re-marshal the seed JSON. Type-resolution failures are reported by contract // validation, so they are swallowed here. func mergeComponentStoreSeed(component gwdkir.Component, state map[string]string, stateTypes map[string]clientlang.ValueType, raw map[string]any) bool { - if strings.TrimSpace(component.Blocks.ClientBody) == "" { - return false - } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { + if !component.Blocks.Client || component.Blocks.ClientProgram == nil { return false } + program := *component.Blocks.ClientProgram added := false for _, use := range program.Uses { if use.Type == "" { @@ -517,8 +514,8 @@ func buildLayouts(layouts []gwdkir.Layout) (map[string]gwdkir.Layout, []string) failures = append(failures, fmt.Sprintf("layout %s missing view {}", layout.ID)) continue } - if strings.TrimSpace(layout.Blocks.ViewBody) == "" { - failures = append(failures, fmt.Sprintf("layout %s view {} is empty", layout.ID)) + if len(layout.Blocks.ViewNodes) == 0 { + failures = append(failures, fmt.Sprintf("layout %s view {} has no parsed nodes", layout.ID)) continue } registry[key] = layout diff --git a/internal/buildgen/css.go b/internal/buildgen/css.go index 0560fa45..c47fea3b 100644 --- a/internal/buildgen/css.go +++ b/internal/buildgen/css.go @@ -1,6 +1,7 @@ package buildgen import ( + contextpkg "context" "fmt" "os" "path" @@ -47,12 +48,24 @@ func planCSS(config gowdk.Config, ir gwdkir.Program, outputDir string, component Build: config.Build, CSS: config.CSS, } + processors := make([]gowdk.CSSProcessor, 0, len(config.Addons)+len(config.Extensions)) for _, addon := range config.Addons { - processor := gowdk.ResolveAddonCapabilities(addon).CSSProcessor + processors = append(processors, gowdk.ResolveAddonCapabilities(addon).CSSProcessor) + } + for _, extension := range config.Extensions { + processors = append(processors, gowdk.ResolveExtensionCapabilities(extension).CSSProcessor) + } + for _, processor := range processors { if processor == nil { continue } - result, err := processor.ProcessCSS(context) + var result gowdk.CSSResult + var err error + if cancellable, ok := processor.(gowdk.CSSProcessorContext); ok { + result, err = cancellable.ProcessCSSContext(contextpkg.Background(), context) + } else { + result, err = processor.ProcessCSS(context) + } if err != nil { failures = append(failures, fmt.Sprintf("css processor %s failed: %v", processor.Name(), err)) continue diff --git a/internal/buildgen/data.go b/internal/buildgen/data.go index 048d1f70..190593ed 100644 --- a/internal/buildgen/data.go +++ b/internal/buildgen/data.go @@ -15,7 +15,7 @@ func parsePathDeclarations(body string) ([]map[string]string, error) { func parsePathDeclarationsFromBlocks(blocks gwdkir.Blocks) ([]map[string]string, error) { if len(blocks.PathsRecords) == 0 { - return parsePathDeclarations(blocks.PathsBody) + return nil, nil } declarations := make([]map[string]string, 0, len(blocks.PathsRecords)) for index, record := range blocks.PathsRecords { @@ -131,7 +131,7 @@ func parseBuildDataFromBlocks(blocks gwdkir.Blocks, routeParams map[string]strin return runBuildDataCallRef(buildCallRef{Alias: blocks.BuildCall.Alias, Function: blocks.BuildCall.Function}, imports, blocks.GoBlocks, source, routeParams, locale) } if len(blocks.BuildRecords) == 0 { - return parseBuildData(blocks.BuildBody, routeParams, locale, imports, blocks.GoBlocks, source) + return map[string]string{}, nil } data := map[string]buildValue{} env := newBuildEnv(routeParams, data) diff --git a/internal/buildgen/data_util.go b/internal/buildgen/data_util.go index 67edbd6f..19174797 100644 --- a/internal/buildgen/data_util.go +++ b/internal/buildgen/data_util.go @@ -2,7 +2,6 @@ package buildgen import ( "fmt" - "path/filepath" ) func mergeBuildData(buildData, routeData map[string]string) (map[string]string, error) { @@ -23,23 +22,3 @@ func cloneStringMap(input map[string]string) map[string]string { } return output } - -func sourcePathSet(paths []string) map[string]bool { - set := map[string]bool{} - for _, sourcePath := range paths { - abs, err := filepath.Abs(sourcePath) - if err != nil { - continue - } - set[filepath.Clean(abs)] = true - } - return set -} - -func sourcePathChanged(set map[string]bool, sourcePath string) bool { - abs, err := filepath.Abs(sourcePath) - if err != nil { - return false - } - return set[filepath.Clean(abs)] -} diff --git a/internal/buildgen/incremental_test.go b/internal/buildgen/incremental_test.go index effe92bb..6dfdbc3e 100644 --- a/internal/buildgen/incremental_test.go +++ b/internal/buildgen/incremental_test.go @@ -110,10 +110,8 @@ func TestBuildIncrementalRendersOnlyChangedPageSources(t *testing.T) { ID: "about", Route: "/about", Blocks: gwdkir.Blocks{ - Build: true, - BuildBody: `=> missing.BuildData()`, - View: true, - ViewBody: `
About stable
`, + View: true, + ViewBody: `
About stable
`, }, }, }} diff --git a/internal/buildgen/interop_test.go b/internal/buildgen/interop_test.go new file mode 100644 index 00000000..f536d8ad --- /dev/null +++ b/internal/buildgen/interop_test.go @@ -0,0 +1,17 @@ +package buildgen + +import ( + "github.com/cssbruno/gowdk" + fixture "github.com/cssbruno/gowdk/testfixture/interop" +) + +func ssrTestConfig(loadPages ...string) gowdk.Config { + config := gowdk.Config{ + Features: gowdk.FeatureConfig{Auth: gowdk.AuthFeatureConfig{Enabled: true}}, + Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}, + } + for _, page := range loadPages { + config.Interop.Loads = append(config.Interop.Loads, gowdk.RegisterLoad(page, fixture.LoadDashboard)) + } + return config +} diff --git a/internal/buildgen/ir_test.go b/internal/buildgen/ir_test.go index 6b83dd33..74fcc6ff 100644 --- a/internal/buildgen/ir_test.go +++ b/internal/buildgen/ir_test.go @@ -152,6 +152,43 @@ func TestBuildFromPlanDoesNotPublishBeforeManifestPlanningSucceeds(t *testing.T) } } +func TestBuildFromPlanKeepsPriorGenerationWhenPrePublishValidationFails(t *testing.T) { + outputDir := filepath.Join(t.TempDir(), "dist") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + oldPath := filepath.Join(outputDir, "old.txt") + if err := os.WriteFile(oldPath, []byte("committed"), 0o644); err != nil { + t.Fatal(err) + } + analyzed, err := compiler.AnalyzeProgram(gowdk.Config{}, gwdkanalysis.Sources{Pages: []gwdkir.Page{{ + Source: "home.page.gwdk", Package: "app", ID: "home", Route: "/", + Blocks: gwdkir.Blocks{View: true, ViewBody: `
new
`}, + }}}) + if err != nil { + t.Fatal(err) + } + validated, err := compiler.ValidateAnalyzedProgram(gowdk.Config{}, analyzed) + if err != nil { + t.Fatal(err) + } + plan, err := PlanBuildFromValidatedProgram(gowdk.Config{}, validated, outputDir) + if err != nil { + t.Fatal(err) + } + plan.SetPrePublishValidation(func(Result) error { return errors.New("audit rejected stage") }) + if _, err := BuildFromPlan(plan); err == nil || !strings.Contains(err.Error(), "audit rejected stage") { + t.Fatalf("pre-publish failure = %v", err) + } + payload, err := os.ReadFile(oldPath) + if err != nil || string(payload) != "committed" { + t.Fatalf("prior generation changed: %q, %v", payload, err) + } + if _, err := os.Stat(filepath.Join(outputDir, "index.html")); !os.IsNotExist(err) { + t.Fatalf("staged page was published: %v", err) + } +} + func TestBuildMemoryFromIRCollectsArtifacts(t *testing.T) { config := gowdk.Config{} app := gwdkanalysis.Sources{Pages: []gwdkir.Page{{ diff --git a/internal/buildgen/islands_test.go b/internal/buildgen/islands_test.go index 4276307b..94123dc1 100644 --- a/internal/buildgen/islands_test.go +++ b/internal/buildgen/islands_test.go @@ -195,7 +195,8 @@ fn Add() { } html := readFile(t, filepath.Join(outputDir, "counter", "index.html")) for _, expected := range []string{ - ``, + ``, ``, ``, `data-gowdk-client="{"handlers":{"Add":{"statements":["Count++"]}},"stores":["cart"]}"`, diff --git a/internal/buildgen/manifests.go b/internal/buildgen/manifests.go index 585bfb2f..4fef4c89 100644 --- a/internal/buildgen/manifests.go +++ b/internal/buildgen/manifests.go @@ -47,19 +47,6 @@ type routeManifestParam struct { Type string `json:"type,omitempty"` } -func writeRouteManifest(outputDir string, artifacts []Artifact, endpoints []compiler.EndpointBinding) (string, error) { - payload, err := routeManifestPayload(outputDir, artifacts, endpoints) - if err != nil { - return "", err - } - - manifestPath := filepath.Join(outputDir, routeManifestFile) - if err := writeFileIfChanged(manifestPath, payload); err != nil { - return "", err - } - return manifestPath, nil -} - func routeManifestPayload(outputDir string, artifacts []Artifact, endpoints []compiler.EndpointBinding) ([]byte, error) { routes := make([]routeManifestEntry, 0, len(artifacts)) for _, artifact := range artifacts { @@ -150,129 +137,6 @@ func routeManifestParams(params []source.RouteParam) []routeManifestParam { return out } -func readRouteManifestIfExists(outputDir string) (routeManifest, error) { - manifestPath := filepath.Join(outputDir, routeManifestFile) - payload, err := os.ReadFile(manifestPath) - if os.IsNotExist(err) { - return routeManifest{}, nil - } - if err != nil { - return routeManifest{}, err - } - var manifest routeManifest - if err := json.Unmarshal(payload, &manifest); err != nil { - return routeManifest{}, fmt.Errorf("read existing route manifest: %w", err) - } - return manifest, nil -} - -func readAssetManifestIfExists(outputDir string) (runtimeasset.Manifest, error) { - manifestPath := filepath.Join(outputDir, assetManifestFile) - payload, err := os.ReadFile(manifestPath) - if os.IsNotExist(err) { - return runtimeasset.Manifest{}, nil - } - if err != nil { - return runtimeasset.Manifest{}, err - } - var manifest runtimeasset.Manifest - if err := json.Unmarshal(payload, &manifest); err != nil { - return runtimeasset.Manifest{}, fmt.Errorf("read existing asset manifest: %w", err) - } - return manifest, nil -} - -func removeStaleChangedPageArtifacts(outputDir string, previous routeManifest, current []Artifact, changedPageIDs map[string]bool) error { - if len(previous.Routes) == 0 || len(changedPageIDs) == 0 { - return nil - } - keep := map[string]bool{} - for _, artifact := range current { - if !changedPageIDs[artifact.PageID] { - continue - } - rel, err := relativeOutputPath(outputDir, artifact.Path) - if err != nil { - return err - } - keep[rel] = true - } - for _, route := range previous.Routes { - if !changedPageIDs[route.PageID] || keep[route.Path] { - continue - } - filePath, err := outputFilePath(outputDir, route.Path) - if err != nil { - return err - } - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return err - } - } - return nil -} - -func removeStaleAssetManifestFiles(outputDir string, previous runtimeasset.Manifest, cssArtifacts []CSSArtifact, assetArtifacts []AssetArtifact) error { - if len(previous.Files) == 0 { - return nil - } - keep := map[string]bool{} - for _, artifact := range cssArtifacts { - rel, err := relativeOutputPath(outputDir, artifact.Path) - if err != nil { - return err - } - keep[rel] = true - } - for _, artifact := range assetArtifacts { - rel, err := relativeOutputPath(outputDir, artifact.Path) - if err != nil { - return err - } - keep[rel] = true - } - for _, rel := range previous.Files { - if keep[rel] { - continue - } - filePath, err := outputFilePath(outputDir, rel) - if err != nil { - return err - } - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return err - } - } - return nil -} - -func outputFilePath(outputDir, rel string) (string, error) { - if strings.TrimSpace(rel) == "" { - return "", fmt.Errorf("route manifest path is required") - } - if filepath.IsAbs(rel) { - return "", fmt.Errorf("route manifest path %q must be relative", rel) - } - clean := filepath.Clean(filepath.FromSlash(rel)) - if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("route manifest path %q must stay inside output directory", rel) - } - return filepath.Join(outputDir, clean), nil -} - -func writeAssetManifest(outputDir string, pageArtifacts []Artifact, cssArtifacts []CSSArtifact, assetArtifacts []AssetArtifact) (string, error) { - payload, err := assetManifestPayload(outputDir, pageArtifacts, cssArtifacts, assetArtifacts) - if err != nil { - return "", err - } - - manifestPath := filepath.Join(outputDir, assetManifestFile) - if err := writeFileIfChanged(manifestPath, payload); err != nil { - return "", err - } - return manifestPath, nil -} - func assetManifestPayload(outputDir string, pageArtifacts []Artifact, cssArtifacts []CSSArtifact, assetArtifacts []AssetArtifact) ([]byte, error) { files := make(map[string]string, len(cssArtifacts)+len(assetArtifacts)) hashes := make(map[string]string, len(cssArtifacts)+len(assetArtifacts)) @@ -361,11 +225,6 @@ func artifactLogicalPath(logicalPath string, fallback string) string { return logical } -func writeFileIfChanged(filePath string, contents []byte) error { - _, err := writeFileIfChangedStatus(filePath, contents) - return err -} - func writeFileIfChangedStatus(filePath string, contents []byte) (bool, error) { current, err := os.ReadFile(filePath) if err == nil && bytes.Equal(current, contents) { diff --git a/internal/buildgen/openapi.go b/internal/buildgen/openapi.go index e06f0370..c93c4963 100644 --- a/internal/buildgen/openapi.go +++ b/internal/buildgen/openapi.go @@ -2,7 +2,6 @@ package buildgen import ( "encoding/json" - "path/filepath" "sort" "strconv" "strings" @@ -96,18 +95,6 @@ type openAPIGOWDKExtension struct { Roles []string `json:"roles,omitempty"` } -func writeOpenAPI(outputDir string, config gowdk.Config, ir gwdkir.Program) (string, error) { - payload, err := openAPIPayload(config, ir) - if err != nil { - return "", err - } - path := filepath.Join(outputDir, openAPIFile) - if err := writeFileIfChanged(path, payload); err != nil { - return "", err - } - return path, nil -} - func openAPIPayload(config gowdk.Config, ir gwdkir.Program) ([]byte, error) { spec := buildOpenAPISpec(config, ir) payload, err := json.MarshalIndent(spec, "", " ") diff --git a/internal/buildgen/render.go b/internal/buildgen/render.go index 7c957f9e..f071b465 100644 --- a/internal/buildgen/render.go +++ b/internal/buildgen/render.go @@ -36,8 +36,8 @@ func renderPage(config gowdk.Config, page gwdkir.Page, route string, components if !page.Blocks.View { return "", ssrRegions{}, fmt.Errorf("%s: missing view {}", page.ID) } - if strings.TrimSpace(page.Blocks.ViewBody) == "" { - return "", ssrRegions{}, fmt.Errorf("%s: view {} is empty", page.ID) + if len(page.Blocks.ViewNodes) == 0 { + return "", ssrRegions{}, fmt.Errorf("%s: view {} has no parsed nodes", page.ID) } viewNodes, err := composePageViewNodes(page, layouts) if err != nil { @@ -171,9 +171,6 @@ func requestTimeTaintedFields(page gwdkir.Page, policy renderModePolicy) map[str func composePageViewNodes(page gwdkir.Page, layouts map[string]gwdkir.Layout) ([]view.Node, error) { nodes := cloneViewNodes(page.Blocks.ViewNodes) - if len(nodes) == 0 && strings.TrimSpace(page.Blocks.ViewBody) != "" { - return nil, fmt.Errorf("view {} has source body but no parsed nodes") - } if len(layouts) == 0 { return nodes, nil } @@ -253,8 +250,8 @@ func resolvePageLayout(page gwdkir.Page, layouts map[string]gwdkir.Layout, layou } func composeLayoutNodes(layout gwdkir.Layout, child []view.Node) ([]view.Node, error) { - if len(layout.Blocks.ViewNodes) == 0 && strings.TrimSpace(layout.Blocks.ViewBody) != "" { - return nil, fmt.Errorf("layout %s has source body but no parsed nodes", layout.ID) + if len(layout.Blocks.ViewNodes) == 0 { + return nil, fmt.Errorf("layout %s has no parsed view nodes", layout.ID) } nodes, slots := replaceLayoutSlotNodes(layout.Blocks.ViewNodes, child) if slots != 1 { @@ -568,6 +565,7 @@ func isInternalNavigationHref(value string) bool { type pageStoreSeed struct { Name string JSON string + Shape string Persist *storePersistSeed } @@ -594,12 +592,12 @@ func pageStoreSeeds(page gwdkir.Page) ([]pageStoreSeed, error) { if err != nil { return nil, fmt.Errorf("store %s init: %w", store.Name, err) } - seed := pageStoreSeed{Name: store.Name, JSON: string(payload)} + resolved, err := gotypes.ResolveStruct(page.Imports, store.Type) + if err != nil { + return nil, fmt.Errorf("store %s shape: %w", store.Name, err) + } + seed := pageStoreSeed{Name: store.Name, JSON: string(payload), Shape: storeSchemaHash(resolved, string(payload))} if store.Persist == "local" || store.Persist == "session" { - resolved, err := gotypes.ResolveStruct(page.Imports, store.Type) - if err != nil { - return nil, fmt.Errorf("store %s persist: %w", store.Name, err) - } seed.Persist = &storePersistSeed{ Scope: store.Persist, Key: "gowdk:store:" + store.Name, @@ -722,6 +720,7 @@ func document(config gowdk.Config, page gwdkir.Page, route string, body string, continue } attrs := gowhtml.Attr("data-gowdk-store", seed.Name) + attrs += gowhtml.Attr("data-gowdk-store-shape", seed.Shape) if seed.Persist != nil { attrs += gowhtml.Attr("data-gowdk-persist", seed.Persist.Scope) attrs += gowhtml.Attr("data-gowdk-persist-key", seed.Persist.Key) diff --git a/internal/buildgen/report.go b/internal/buildgen/report.go index 1171b138..352f4325 100644 --- a/internal/buildgen/report.go +++ b/internal/buildgen/report.go @@ -129,18 +129,6 @@ func (reporter *buildReporter) result() BuildReport { return report } -func writeBuildReport(outputDir string, report BuildReport) (string, error) { - reportPath := filepath.Join(outputDir, buildReportFile) - payload, err := buildReportPayload(report) - if err != nil { - return "", err - } - if err := writeFileIfChanged(reportPath, payload); err != nil { - return "", err - } - return reportPath, nil -} - func buildReportPayload(report BuildReport) ([]byte, error) { payload, err := json.MarshalIndent(report, "", " ") if err != nil { diff --git a/internal/buildgen/routes.go b/internal/buildgen/routes.go index 0404f8bd..afbfe4cb 100644 --- a/internal/buildgen/routes.go +++ b/internal/buildgen/routes.go @@ -17,22 +17,6 @@ type pageOutput struct { locale string } -func pageRouteArtifacts(config gowdk.Config, outputDir string, page gwdkir.Page) ([]Artifact, error) { - outputs, err := pageOutputs(config, page) - if err != nil { - return nil, fmt.Errorf("%s: %w", page.ID, err) - } - artifacts := make([]Artifact, 0, len(outputs)) - for _, output := range outputs { - outputPath, err := outputPath(outputDir, output.route) - if err != nil { - return nil, fmt.Errorf("%s: %w", page.ID, err) - } - artifacts = append(artifacts, Artifact{PageID: page.ID, Route: output.route, Path: outputPath, CachePolicy: page.CachePolicy(), Locale: output.locale}) - } - return artifacts, nil -} - func pageOutputArtifacts(config gowdk.Config, outputDir string, page gwdkir.Page, components map[string]view.Component, layouts map[string]gwdkir.Layout, stylesheets []gowdk.Stylesheet, actionFields map[string][]view.ActionInputField, realtimeEventTypeNames map[string]string, queryTypeNames map[string]string) ([]plannedArtifact, error) { outputs, err := pageOutputs(config, page) if err != nil { diff --git a/internal/buildgen/runtime_islands.go b/internal/buildgen/runtime_islands.go index feda59a8..f014e353 100644 --- a/internal/buildgen/runtime_islands.go +++ b/internal/buildgen/runtime_islands.go @@ -86,7 +86,7 @@ func islandScriptHrefsForView(source string, nodes []view.Node, components map[s case "wasm": href = "/" + islandWASMLoaderAssetPath(component.Package, component.Name) case "": - if component.StateJSON != "" || component.HandlersJSON != "" || len(component.Emits) > 0 || len(component.Exports) > 0 || usage.call.ReactiveProps || componentViewHasAwait(component.Body, component.Nodes) { + if component.StateJSON != "" || component.HandlersJSON != "" || len(component.Emits) > 0 || len(component.Exports) > 0 || usage.call.ReactiveProps || nodesHaveAwait(component.Nodes) { needsSharedRuntime = true href = "/" + islandJSAssetPath(component.Package, component.Name) } @@ -159,19 +159,9 @@ func recursiveComponentCallUsagesForView[T any](source string, nodes []view.Node visiting := map[string]bool{} var walk func(string, []view.Node, string, map[string]string) error walk = func(source string, nodes []view.Node, ownerPackage string, uses map[string]string) error { - var direct []viewanalysis.ComponentCallUsage - if len(nodes) > 0 { - var err error - direct, err = viewanalysis.ComponentCallUsagesFromNodes(nodes) - if err != nil { - return err - } - } else { - var err error - direct, err = viewanalysis.ComponentCallUsages(source) - if err != nil { - return err - } + direct, err := viewanalysis.ComponentCallUsagesFromNodes(nodes) + if err != nil { + return err } for _, usage := range direct { component, ok := lookupComponent(components, usage.Component, ownerPackage, uses) @@ -184,7 +174,11 @@ func recursiveComponentCallUsagesForView[T any](source string, nodes []view.Node continue } visiting[identity] = true - if err := walk(resolver.Body(component), resolver.Nodes(component), resolver.Package(component), resolver.Uses(component)); err != nil { + componentNodes := resolver.Nodes(component) + if len(componentNodes) == 0 && strings.TrimSpace(resolver.Body(component)) != "" { + return fmt.Errorf("component %q has raw view source but no typed view nodes", identity) + } + if err := walk("", componentNodes, resolver.Package(component), resolver.Uses(component)); err != nil { return err } delete(visiting, identity) @@ -220,17 +214,7 @@ func lookupComponent[T any](components map[string]T, name string, ownerPackage s } func componentNeedsJSIsland(component gwdkir.Component) bool { - return component.State.Type.Name != "" || component.Blocks.Client || len(component.Emits) > 0 || componentViewHasAwait(component.Blocks.ViewBody, component.Blocks.ViewNodes) -} - -func componentViewHasAwait(source string, nodes []view.Node) bool { - if len(nodes) == 0 && strings.TrimSpace(source) != "" { - parsed, err := view.Parse(source) - if err == nil { - nodes = parsed - } - } - return nodesHaveAwait(nodes) + return component.State.Type.Name != "" || component.Blocks.Client || len(component.Emits) > 0 || nodesHaveAwait(component.Blocks.ViewNodes) } func nodesHaveAwait(nodes []view.Node) bool { diff --git a/internal/buildgen/security_manifest.go b/internal/buildgen/security_manifest.go index 8408be09..9d4626bf 100644 --- a/internal/buildgen/security_manifest.go +++ b/internal/buildgen/security_manifest.go @@ -2,7 +2,6 @@ package buildgen import ( "encoding/json" - "os" "path/filepath" "github.com/cssbruno/gowdk" @@ -19,24 +18,6 @@ func securityManifestPayload(config gowdk.Config, ir gwdkir.Program) ([]byte, er return json.MarshalIndent(manifest, "", " ") } -func writeSecurityManifest(outputDir string, config gowdk.Config, ir gwdkir.Program) (string, error) { - payload, err := securityManifestPayload(config, ir) - if err != nil { - return "", err - } - manifestPath, err := securityManifestPath(outputDir) - if err != nil { - return "", err - } - if err := writeFileIfChanged(manifestPath, payload); err != nil { - return "", err - } - if err := removeServedSecurityManifest(outputDir); err != nil { - return "", err - } - return manifestPath, nil -} - func securityManifestPath(outputDir string) (string, error) { absOutput, err := filepath.Abs(outputDir) if err != nil { @@ -61,11 +42,3 @@ func memorySecurityManifestPath(outputBase string, diskOutputPath bool) (string, } return filepath.Join(filepath.Dir(cleanOutput), ".gowdk", "reports", outputName, securityManifestFile), nil } - -func removeServedSecurityManifest(outputDir string) error { - servedPath := filepath.Join(outputDir, securityManifestFile) - if err := os.Remove(servedPath); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} diff --git a/internal/buildgen/seo.go b/internal/buildgen/seo.go index a0ca2cc6..53dde5c1 100644 --- a/internal/buildgen/seo.go +++ b/internal/buildgen/seo.go @@ -6,7 +6,6 @@ import ( "go/ast" "go/token" "net/url" - "path/filepath" "sort" "strings" @@ -164,6 +163,15 @@ func validateDynamicSitemap(options gowdk.SEODynamicSitemap) error { func seoOptionsFromConfig(config gowdk.Config) (gowdk.SEOOptions, bool, error) { var found bool var options gowdk.SEOOptions + if config.Features.SEO.Enabled { + return config.Features.SEO.Options, true, nil + } + for _, extension := range config.Extensions { + provider := gowdk.ResolveExtensionCapabilities(extension).SEOProvider + if provider != nil { + return provider.SEOOptions(), true, nil + } + } for _, addon := range config.Addons { if !addonHasFeature(addon, gowdk.FeatureSEO) { continue @@ -425,23 +433,6 @@ func seoExclusions(config gowdk.Config, ir gwdkir.Program, artifacts []Artifact, return excluded } -func writeSEOArtifacts(outputDir string, plan seoPlan) (string, string, bool, bool, error) { - if !plan.Enabled { - return "", "", false, false, nil - } - sitemapPath := filepath.Join(outputDir, sitemapFile) - sitemapWrote, err := writeFileIfChangedStatus(sitemapPath, plan.Sitemap) - if err != nil { - return "", "", false, false, err - } - robotsPath := filepath.Join(outputDir, robotsFile) - robotsWrote, err := writeFileIfChangedStatus(robotsPath, plan.Robots) - if err != nil { - return "", "", false, false, err - } - return sitemapPath, robotsPath, sitemapWrote, robotsWrote, nil -} - func reportSEOExclusions(reporter *buildReporter, exclusions []seoExclusion) { for _, exclusion := range exclusions { data := map[string]string{"reason": exclusion.Reason} diff --git a/internal/buildgen/ssr_list_test.go b/internal/buildgen/ssr_list_test.go index e48f161f..c16a2947 100644 --- a/internal/buildgen/ssr_list_test.go +++ b/internal/buildgen/ssr_list_test.go @@ -71,7 +71,7 @@ func buildSSRRegionArtifact(t *testing.T, loadBody, view string) SSRArtifact { ViewBody: view, }, }}} - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, t.TempDir()) + artifacts, err := SSRArtifacts(ssrTestConfig("board"), app, t.TempDir()) if err != nil { t.Fatalf("build SSR artifacts: %v", err) } diff --git a/internal/buildgen/ssr_test.go b/internal/buildgen/ssr_test.go index 58606ae3..e1fb219b 100644 --- a/internal/buildgen/ssr_test.go +++ b/internal/buildgen/ssr_test.go @@ -138,7 +138,7 @@ func TestSSRArtifactsIncludeScopedJSScripts(t *testing.T) { }}, } - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig(), app, outputDir) if err != nil { t.Fatal(err) } @@ -372,7 +372,7 @@ func TestSSRArtifactsRenderDynamicSSRPageWithPlaceholders(t *testing.T) { }}, } - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig(), app, outputDir) if err != nil { t.Fatal(err) } @@ -440,6 +440,7 @@ func TestSSRArtifactsRenderLoadPlaceholders(t *testing.T) { ID: "dashboard", Route: "/dashboard", Render: gowdk.SSR, + Guards: []string{"public"}, Blocks: gwdkir.Blocks{ Server: true, ServerBody: `=> { user.name, account.plan }`, @@ -448,7 +449,7 @@ func TestSSRArtifactsRenderLoadPlaceholders(t *testing.T) { }, }}} - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig("dashboard"), app, outputDir) if err != nil { t.Fatal(err) } @@ -534,6 +535,7 @@ func TestSSRArtifactsMarkLoadURLPlaceholders(t *testing.T) { ID: "profile", Route: "/profile", Render: gowdk.SSR, + Guards: []string{"public"}, Blocks: gwdkir.Blocks{ Server: true, ServerBody: `=> { user.slug, user.avatar }`, @@ -542,7 +544,7 @@ func TestSSRArtifactsMarkLoadURLPlaceholders(t *testing.T) { }, }}} - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig("profile"), app, outputDir) if err != nil { t.Fatal(err) } @@ -585,6 +587,7 @@ func TestSSRArtifactsComposePageLoadThroughLayouts(t *testing.T) { ID: "dashboard", Route: "/dashboard", Render: gowdk.SSR, + Guards: []string{"public"}, Layouts: []string{"shell"}, Blocks: gwdkir.Blocks{ Server: true, @@ -602,7 +605,7 @@ func TestSSRArtifactsComposePageLoadThroughLayouts(t *testing.T) { }}, } - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig("dashboard"), app, outputDir) if err != nil { t.Fatal(err) } @@ -632,6 +635,7 @@ func TestSSRArtifactsIncludeLayoutErrorPageBoundaries(t *testing.T) { ID: "dashboard", Route: "/dashboard", Render: gowdk.SSR, + Guards: []string{"public"}, Layouts: []string{"section"}, Blocks: gwdkir.Blocks{ Server: true, @@ -661,7 +665,7 @@ func TestSSRArtifactsIncludeLayoutErrorPageBoundaries(t *testing.T) { }, } - artifacts, err := SSRArtifacts(gowdk.Config{Addons: []gowdk.Addon{gowdk.NewAddon("ssr", gowdk.FeatureSSR)}}, app, outputDir) + artifacts, err := SSRArtifacts(ssrTestConfig("dashboard"), app, outputDir) if err != nil { t.Fatal(err) } diff --git a/internal/buildgen/store_use_fields_test.go b/internal/buildgen/store_use_fields_test.go index 22cfe2fa..b1e360b4 100644 --- a/internal/buildgen/store_use_fields_test.go +++ b/internal/buildgen/store_use_fields_test.go @@ -24,6 +24,7 @@ func TestComponentInitialStateSeedsTypedUseStoreFields(t *testing.T) { ViewBody: "{Count}", }, } + component = irComponent(component) state, stateTypes, stateJSON, err := componentInitialState(component) if err != nil { @@ -56,6 +57,7 @@ func TestComponentInitialStateUntypedUseAddsNoFields(t *testing.T) { ViewBody: "x", }, } + component = irComponent(component) state, _, stateJSON, err := componentInitialState(component) if err != nil { diff --git a/internal/buildgen/test_helpers_test.go b/internal/buildgen/test_helpers_test.go index a4710458..1fe727ed 100644 --- a/internal/buildgen/test_helpers_test.go +++ b/internal/buildgen/test_helpers_test.go @@ -2,8 +2,10 @@ package buildgen import ( "encoding/json" + "fmt" "os" "path/filepath" + "strconv" "strings" "testing" @@ -26,6 +28,66 @@ func irComponent(component gwdkir.Component) gwdkir.Component { func analyzedIRFixture(t *testing.T, program gwdkir.Program) gwdkir.Program { t.Helper() + // Lower every source fixture through the production analyzer. Tests in this + // package historically constructed raw Blocks bodies directly; generators + // now require the typed records produced before validation. + lowered := gwdkanalysis.BuildProgram(gowdk.Config{}, gwdkanalysis.Sources{ + Pages: program.Pages, + Components: program.Components, + Layouts: program.Layouts, + AuditSpecs: program.AuditSpecs, + }) + program.Pages = lowered.Pages + program.Components = lowered.Components + program.Layouts = lowered.Layouts + program.Diagnostics = append(program.Diagnostics, lowered.Diagnostics...) + for index := range program.Pages { + blocks := &program.Pages[index].Blocks + if blocks.Paths && len(blocks.PathsRecords) == 0 && strings.TrimSpace(blocks.PathsBody) != "" { + declarations, err := parsePathDeclarations(blocks.PathsBody) + if err != nil { + program.Diagnostics = append(program.Diagnostics, gwdkir.Diagnostic{Code: "test_fixture_paths_error", Source: program.Pages[index].Source, Message: err.Error()}) + } else { + for _, declaration := range declarations { + record := gwdkir.LiteralRecord{Fields: map[string]string{}, Expressions: map[string]string{}} + for name, value := range declaration { + record.FieldOrder = append(record.FieldOrder, name) + record.Fields[name] = value + record.Expressions[name] = strconv.Quote(value) + } + blocks.PathsRecords = append(blocks.PathsRecords, record) + } + } + } + if blocks.Build && blocks.BuildCall == nil && len(blocks.BuildRecords) == 0 && strings.TrimSpace(blocks.BuildBody) != "" { + lines := significantBuildLines(blocks.BuildBody) + if len(lines) == 1 { + if call, ok, err := parseBuildDataCallLine(lines[0]); err != nil { + program.Diagnostics = append(program.Diagnostics, gwdkir.Diagnostic{Code: "test_fixture_build_error", Source: program.Pages[index].Source, Message: err.Error()}) + } else if ok { + blocks.BuildCall = &gwdkir.BuildCall{Alias: call.Alias, Function: call.Function} + } + } + if blocks.BuildCall == nil { + for lineIndex, line := range lines { + fields, ok, err := buildLiteralRecordFields(line) + if err != nil || !ok { + if err == nil { + err = fmt.Errorf("build line %d must use `=> { name: value }` or `=> BuildData()`", lineIndex+1) + } + program.Diagnostics = append(program.Diagnostics, gwdkir.Diagnostic{Code: "test_fixture_build_error", Source: program.Pages[index].Source, Message: err.Error()}) + break + } + record := gwdkir.LiteralRecord{Expressions: map[string]string{}} + for _, field := range fields { + record.FieldOrder = append(record.FieldOrder, field.name) + record.Expressions[field.name] = field.expr + } + blocks.BuildRecords = append(blocks.BuildRecords, record) + } + } + } + } parseBlocks := func(blocks *gwdkir.Blocks) { t.Helper() if strings.TrimSpace(blocks.ViewBody) == "" { diff --git a/internal/buildgen/types.go b/internal/buildgen/types.go index 7249c443..71b0f472 100644 --- a/internal/buildgen/types.go +++ b/internal/buildgen/types.go @@ -27,18 +27,19 @@ type AssetArtifact struct { } type Result struct { - Artifacts []Artifact - CSSArtifacts []CSSArtifact - AssetArtifacts []AssetArtifact - RouteManifestPath string - AssetManifestPath string - SitemapPath string - RobotsPath string - OpenAPIPath string - SecurityManifestPath string - BuildReportPath string - Report BuildReport - WriteStats WriteStats + Artifacts []Artifact + CSSArtifacts []CSSArtifact + AssetArtifacts []AssetArtifact + RouteManifestPath string + AssetManifestPath string + SitemapPath string + RobotsPath string + OpenAPIPath string + SecurityManifestPath string + BuildReportPath string + AdditionalOutputPaths []string + Report BuildReport + WriteStats WriteStats } type WriteStats struct { diff --git a/internal/compiler/assemble.go b/internal/compiler/assemble.go index 73b5b2a8..ad769c48 100644 --- a/internal/compiler/assemble.go +++ b/internal/compiler/assemble.go @@ -25,7 +25,7 @@ func EnrichProgram(config gowdk.Config, program *gwdkir.Program) ([]source.Backe if err := DiscoverGoEndpoints(config, program); err != nil { return nil, err } - return BindBackendHandlers(program), nil + return BindBackendHandlersWithConfig(config, program), nil } // AssembleProgram builds the canonical compiler IR from parsed sources and runs diff --git a/internal/compiler/backend_binding_diagnostics.go b/internal/compiler/backend_binding_diagnostics.go index 6dc1e324..1dde953f 100644 --- a/internal/compiler/backend_binding_diagnostics.go +++ b/internal/compiler/backend_binding_diagnostics.go @@ -28,6 +28,12 @@ func BackendBindingDiagnostics(bindings []source.BackendBinding) []ValidationErr var diagnostics []ValidationError for _, binding := range bindings { switch { + case binding.ExplicitRegistrationRequired: + diagnostics = append(diagnostics, ValidationError{ + Code: "missing_load_registration", PageID: binding.PageID, + Source: binding.Source, Span: binding.Span, Message: binding.Message, + Severity: SeverityError, + }) case binding.Ambiguous: diagnostics = append(diagnostics, backendBindingDiagnostic("ambiguous_backend_handler", binding)) case binding.Status == source.BackendBindingUnsupportedSignature: diff --git a/internal/compiler/backend_binding_policy.go b/internal/compiler/backend_binding_policy.go index 4c4b373d..6e5a482b 100644 --- a/internal/compiler/backend_binding_policy.go +++ b/internal/compiler/backend_binding_policy.go @@ -19,7 +19,7 @@ func ValidateBackendBindingPolicyIR(config gowdk.Config, ir gwdkir.Program) erro } bindings := BackendBindingsFromIR(ir) if len(bindings) == 0 && programDeclaresBackendEndpoints(ir) { - bindings = computeBackendBindings(ir) + bindings = computeBackendBindingsWithConfig(ir, &config) } var diagnostics []ValidationError diff --git a/internal/compiler/backend_bindings.go b/internal/compiler/backend_bindings.go index d67b9988..14b6ec27 100644 --- a/internal/compiler/backend_bindings.go +++ b/internal/compiler/backend_bindings.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/cssbruno/gowdk" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/source" @@ -24,10 +25,23 @@ func BindBackendHandlers(ir *gwdkir.Program) []source.BackendBinding { return bindings } +// BindBackendHandlersWithConfig is the canonical project binding path. Server +// loads must be explicitly registered in Config.Interop; the config-free +// helper remains for low-level IR tests and embedders migrating old programs. +func BindBackendHandlersWithConfig(config gowdk.Config, ir *gwdkir.Program) []source.BackendBinding { + bindings := computeBackendBindingsWithConfig(*ir, &config) + gwdkanalysis.AttachBackendBindings(ir, bindings) + return bindings +} + // computeBackendBindings derives the binding records without mutating the // program, for callers that only need the records (e.g. the production binding // policy check on an unbound program). func computeBackendBindings(ir gwdkir.Program) []source.BackendBinding { + return computeBackendBindingsWithConfig(ir, nil) +} + +func computeBackendBindingsWithConfig(ir gwdkir.Program, config *gowdk.Config) []source.BackendBinding { var bindings []source.BackendBinding cache := map[string]featurePackage{} for _, page := range ir.Pages { @@ -50,7 +64,11 @@ func computeBackendBindings(ir gwdkir.Program) []source.BackendBinding { return inlinePkg } if page.Blocks.Server { - bindings = append(bindings, bindLoad(page, pkg)) + if config == nil { + bindings = append(bindings, bindLoad(page, pkg)) + } else { + bindings = append(bindings, bindRegisteredLoad(page, *config, pkg)) + } } for _, action := range page.Blocks.Actions { inlinePkg := defaultInlinePkg() @@ -103,6 +121,28 @@ func computeBackendBindings(ir gwdkir.Program) []source.BackendBinding { return bindings } +func bindRegisteredLoad(page gwdkir.Page, config gowdk.Config, legacyPackage featurePackage) source.BackendBinding { + registration, ok := config.Interop.LoadForPage(page.ID) + if !ok { + functionName := loadFunctionName(page.ID) + binding := baseBackendBinding(page, loadHandlerKind, functionName, "GET", page.Route, page.Blocks.Spans.Server, legacyPackage) + binding.Status = source.BackendBindingMissing + binding.ExplicitRegistrationRequired = true + binding.Message = fmt.Sprintf("GOWDK SSR load for page %s is not explicitly registered; add gowdk.RegisterLoad(%q, package.%s) to Config.Interop.Loads", page.ID, page.ID, functionName) + return binding + } + pkg := inspectFeaturePackage(filepath.Dir(registration.Hook.SourceFile)) + functionName := registration.Hook.Function + binding := bindLoadFromPackage(page, functionName, pkg) + if binding.ImportPath == "" { + binding.ImportPath = registration.Hook.ImportPath + } + if binding.Status == source.BackendBindingMissing { + binding.Message = fmt.Sprintf("registered GOWDK SSR load %s.%s for page %s could not be inspected: %s", registration.Hook.ImportPath, functionName, page.ID, binding.Message) + } + return binding +} + func bindLoad(page gwdkir.Page, pkg featurePackage) source.BackendBinding { functionName := loadFunctionName(page.ID) // A broken same-package Go package cannot be inspected: surface that instead diff --git a/internal/compiler/interop_bindings_test.go b/internal/compiler/interop_bindings_test.go new file mode 100644 index 00000000..4fde2859 --- /dev/null +++ b/internal/compiler/interop_bindings_test.go @@ -0,0 +1,66 @@ +package compiler + +import ( + "testing" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" + fixture "github.com/cssbruno/gowdk/testfixture/interop" +) + +func TestBindBackendHandlersWithConfigUsesExplicitLoadRegistration(t *testing.T) { + ir := gwdkir.Program{Pages: []gwdkir.Page{{ + ID: "dashboard", Package: "app", Source: "dashboard.page.gwdk", + Route: "/dashboard", Render: gowdk.SSR, + Blocks: gwdkir.Blocks{Server: true}, + }}} + config := gowdk.Config{Interop: gowdk.InteropConfig{Loads: []gowdk.LoadRegistration{ + gowdk.RegisterLoad("dashboard", fixture.LoadDashboard), + }}} + bindings := BindBackendHandlersWithConfig(config, &ir) + if len(bindings) != 1 || bindings[0].Status != source.BackendBindingBound || bindings[0].FunctionName != "LoadDashboard" || bindings[0].ImportPath != "github.com/cssbruno/gowdk/testfixture/interop" { + t.Fatalf("unexpected explicit load binding: %#v", bindings) + } +} + +func TestBindBackendHandlersWithConfigRejectsMagicLoadName(t *testing.T) { + ir := gwdkir.Program{Pages: []gwdkir.Page{{ + ID: "dashboard", Source: "dashboard.page.gwdk", Route: "/dashboard", + Render: gowdk.SSR, Blocks: gwdkir.Blocks{Server: true}, + }}} + bindings := BindBackendHandlersWithConfig(gowdk.Config{}, &ir) + diagnostics := BackendBindingDiagnostics(bindings) + if len(diagnostics) != 1 || diagnostics[0].Code != "missing_load_registration" || diagnostics[0].Severity != SeverityError { + t.Fatalf("expected early explicit-registration diagnostic, got %#v", diagnostics) + } +} + +func TestValidateInteropRegistrationsRequiresGuardAndAuthProviders(t *testing.T) { + ir := gwdkir.Program{Pages: []gwdkir.Page{{ + ID: "dashboard", Source: "dashboard.page.gwdk", Route: "/dashboard", Render: gowdk.SSR, + Guards: []string{"session", "role:admin"}, Blocks: gwdkir.Blocks{Server: true, View: true, ViewBody: "
"}, + }}} + diagnostics := validateInteropRegistrations(gowdk.Config{}, ir) + if !hasDiagnosticCode(diagnostics, "missing_guard_registration") || !hasDiagnosticCode(diagnostics, "missing_auth_registration") { + t.Fatalf("expected guard and auth registration diagnostics, got %#v", diagnostics) + } + config := gowdk.Config{Interop: gowdk.InteropConfig{ + Guards: gowdk.RegisterGuards(fixture.Guards), + AuthProvider: gowdk.RegisterAuthProvider(fixture.AuthProvider), + }} + if diagnostics := validateInteropRegistrations(config, ir); len(diagnostics) != 0 { + t.Fatalf("expected typed providers to satisfy validation, got %#v", diagnostics) + } +} + +func TestValidateInteropRegistrationsCoversRealtimeGuards(t *testing.T) { + ir := gwdkir.Program{RealtimeSubscriptions: []gwdkir.RealtimeSubscription{{ + OwnerID: "dashboard", Source: "dashboard.page.gwdk", + Guards: []string{"session", "permission:events.read"}, + }}} + diagnostics := validateInteropRegistrations(gowdk.Config{}, ir) + if !hasDiagnosticCode(diagnostics, "missing_guard_registration") || !hasDiagnosticCode(diagnostics, "missing_auth_registration") { + t.Fatalf("expected realtime guard and auth registration diagnostics, got %#v", diagnostics) + } +} diff --git a/internal/compiler/validate.go b/internal/compiler/validate.go index 0b37d231..1f07dd75 100644 --- a/internal/compiler/validate.go +++ b/internal/compiler/validate.go @@ -103,6 +103,8 @@ func validateProgram(config gowdk.Config, ir gwdkir.Program, crossFile bool) Val diagnostics = append(diagnostics, validateLayoutReferences(ir.Layouts)...) diagnostics = append(diagnostics, validatePageLayoutReferences(ir.Pages, ir.Layouts)...) diagnostics = append(diagnostics, validateGoBlocks(config, ir)...) + diagnostics = append(diagnostics, validateDirectiveLanes(ir)...) + diagnostics = append(diagnostics, validateInteropRegistrations(config, ir)...) diagnostics = append(diagnostics, validateUniquePageRoutes(config, ir.Pages)...) diagnostics = append(diagnostics, validateAmbiguousDynamicPageRoutes(config, ir.Pages, ir.Endpoints, ir.SourceMap, ir.ContractRefs)...) diagnostics = append(diagnostics, validateRouteMethodConflicts(config, ir.Pages, ir.Endpoints, ir.SourceMap, ir.ContractRefs)...) diff --git a/internal/compiler/validate_component_client.go b/internal/compiler/validate_component_client.go index 3a44a969..a2c82c46 100644 --- a/internal/compiler/validate_component_client.go +++ b/internal/compiler/validate_component_client.go @@ -11,19 +11,19 @@ import ( ) func validateComponentClient(component gwdkir.Component, stateTypes map[string]clientlang.ValueType, symbolTypes map[string]clientlang.ValueType) (map[string]clientlang.Handler, map[string]clientlang.Helper, map[string]clientlang.Ref, map[string]source.SourceSpan, map[string]clientlang.ValueType, []ValidationError) { - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { + if !component.Blocks.Client { return nil, nil, nil, nil, nil, nil } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { + if component.Blocks.ClientProgram == nil { return nil, nil, nil, nil, nil, []ValidationError{{ Code: "component_client_error", ComponentName: component.Name, Source: component.Source, - Span: clientParseErrorSpan(component, err), - Message: fmt.Sprintf("component %s client block is invalid: %v", component.Name, err), + Span: firstSpan(component.Blocks.Spans.Client, component.Span), + Message: fmt.Sprintf("component %s client block has no parsed program", component.Name), }} } + program := *component.Blocks.ClientProgram handlers := program.HandlerMap() helpers := program.HelperMap() helperFuncs := helperExprFunctions(helpers) @@ -321,14 +321,6 @@ func clientStatementErrorSpan(component gwdkir.Component, statements []string, s return firstSpan(component.Blocks.Spans.Client, component.Span) } -func clientParseErrorSpan(component gwdkir.Component, err error) source.SourceSpan { - var parseErr *clientlang.ParseError - if errors.As(err, &parseErr) && parseErr.Line > 0 { - return clientSpan(component, clientlang.Span{StartLine: parseErr.Line, EndLine: parseErr.Line}) - } - return firstSpan(component.Blocks.Spans.Client, component.Span) -} - func clientExpressionErrorSpan(component gwdkir.Component, statement string, span clientlang.Span, err error) source.SourceSpan { var exprErr clientlang.ExprValidationError if !errors.As(err, &exprErr) || exprErr.Span.StartColumn <= 0 { diff --git a/internal/compiler/validate_component_contracts.go b/internal/compiler/validate_component_contracts.go index ac4d9038..05cb3292 100644 --- a/internal/compiler/validate_component_contracts.go +++ b/internal/compiler/validate_component_contracts.go @@ -143,15 +143,10 @@ func resolveComponentContracts(component gwdkir.Component) (componentContracts, // has a different shape is the author's responsibility, exactly as a mismatched // local state declaration was. func resolveComponentStoreFields(component gwdkir.Component, contracts *componentContracts) []ValidationError { - if strings.TrimSpace(component.Blocks.ClientBody) == "" { - return nil - } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { - // A malformed client block is reported with a precise span by - // validateComponentClient; skip store-field binding here. + if !component.Blocks.Client || component.Blocks.ClientProgram == nil { return nil } + program := *component.Blocks.ClientProgram var diagnostics []ValidationError for _, use := range program.Uses { if use.Type == "" { diff --git a/internal/compiler/validate_component_fingerprint.go b/internal/compiler/validate_component_fingerprint.go index 99a7c517..698c50a7 100644 --- a/internal/compiler/validate_component_fingerprint.go +++ b/internal/compiler/validate_component_fingerprint.go @@ -5,7 +5,6 @@ import ( "sort" "strings" - "github.com/cssbruno/gowdk/internal/clientlang" "github.com/cssbruno/gowdk/internal/gotypes" "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/viewanalysis" @@ -84,22 +83,14 @@ func componentStateFingerprint(component gwdkir.Component) string { } func componentViewFingerprint(component gwdkir.Component) string { - canonical, err := viewanalysis.Canonical(component.Blocks.ViewBody) - if err == nil { - return canonical - } - return strings.Join(strings.Fields(component.Blocks.ViewBody), " ") + return viewanalysis.CanonicalNodes(component.Blocks.ViewNodes) } func componentClientFingerprint(component gwdkir.Component) string { - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { + if !component.Blocks.Client || component.Blocks.ClientProgram == nil { return "" } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err == nil { - return program.Canonical() - } - return strings.Join(strings.Fields(component.Blocks.ClientBody), " ") + return component.Blocks.ClientProgram.Canonical() } func canonicalGoType(imports []gwdkir.Import, ref gwdkir.GoRef) string { diff --git a/internal/compiler/validate_component_lists.go b/internal/compiler/validate_component_lists.go index 2b9b469c..c2840fe6 100644 --- a/internal/compiler/validate_component_lists.go +++ b/internal/compiler/validate_component_lists.go @@ -15,11 +15,7 @@ import ( func validateComponentListDirectives(component gwdkir.Component, symbols map[string]clientlang.ValueType, stateTypes map[string]clientlang.ValueType, handlers map[string]clientlang.Handler, helpers map[string]clientlang.ExprFunction) []ValidationError { nodes := component.Blocks.ViewNodes if len(nodes) == 0 { - var err error - nodes, err = viewparse.Parse(component.Blocks.ViewBody) - if err != nil { - return nil - } + return nil } var messages []spannedMessage validateListNodes(nodes, component, symbols, stateTypes, handlers, helpers, &messages) diff --git a/internal/compiler/validate_component_view.go b/internal/compiler/validate_component_view.go index b12c657d..a69b8621 100644 --- a/internal/compiler/validate_component_view.go +++ b/internal/compiler/validate_component_view.go @@ -72,14 +72,6 @@ type componentViewRefs struct { RefBinds []fieldRef } -func componentViewReferences(source string) (componentViewRefs, error) { - nodes, err := viewparse.Parse(source) - if err != nil { - return componentViewRefs{}, err - } - return componentViewReferencesFromNodes(source, nodes), nil -} - func componentViewReferencesFromNodes(source string, nodes []viewmodel.Node) componentViewRefs { refs := componentViewRefs{Fields: map[string]bool{}} collectComponentViewReferences(source, nodes, &refs) diff --git a/internal/compiler/validate_component_view_contract.go b/internal/compiler/validate_component_view_contract.go index 08c326ca..5ba9bb38 100644 --- a/internal/compiler/validate_component_view_contract.go +++ b/internal/compiler/validate_component_view_contract.go @@ -13,22 +13,7 @@ import ( ) func validateComponentViewContract(component gwdkir.Component, ctx componentValidationContext) []ValidationError { - var viewRefs componentViewRefs - if len(component.Blocks.ViewNodes) > 0 { - viewRefs = componentViewReferencesFromNodes(component.Blocks.ViewBody, component.Blocks.ViewNodes) - } else { - var err error - viewRefs, err = componentViewReferences(component.Blocks.ViewBody) - if err != nil { - return []ValidationError{{ - Code: "component_field_error", - ComponentName: component.Name, - Source: component.Source, - Span: firstSpan(component.Blocks.Spans.View, component.Span), - Message: fmt.Sprintf("component %s view is invalid: %v", component.Name, err), - }} - } - } + viewRefs := componentViewReferencesFromNodes(component.Blocks.ViewBody, component.Blocks.ViewNodes) helperFuncs := helperExprFunctions(ctx.Helpers) emits := componentEmitMap(component) diff --git a/internal/compiler/validate_directive_lanes.go b/internal/compiler/validate_directive_lanes.go new file mode 100644 index 00000000..7737584b --- /dev/null +++ b/internal/compiler/validate_directive_lanes.go @@ -0,0 +1,81 @@ +package compiler + +import ( + "fmt" + "strings" + + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/viewparse" +) + +func validateDirectiveLanes(program gwdkir.Program) []ValidationError { + var diagnostics []ValidationError + for _, page := range program.Pages { + serverRoots := map[string]bool{} + for _, field := range page.Blocks.ServerFields { + serverRoots[exprRoot(field)] = true + } + serverPaths := map[string]bool{} + for _, directive := range page.Blocks.DirectiveLanes { + if directive.Lane == "" { + continue + } + resolved := "client" + if parentDirectiveLaneIsServer(directive.Path, serverPaths) || serverRoots[directiveExpressionRoot(directive)] { + resolved = "server" + serverPaths[directive.Path] = true + } + if directive.Lane != resolved { + diagnostics = append(diagnostics, ValidationError{ + Code: "directive_lane_mismatch", + PageID: page.ID, + Source: page.Source, + Span: page.Blocks.Spans.View, + Message: fmt.Sprintf("%s declares %s g:lane=%q, but expression %q resolves to the %s lane", page.ID, directive.Directive, directive.Lane, directive.Expression, resolved), + }) + } + } + } + for _, component := range program.Components { + for _, directive := range component.Blocks.DirectiveLanes { + if directive.Lane == "" { + continue + } + if directive.Lane != "client" { + diagnostics = append(diagnostics, ValidationError{Code: "directive_lane_mismatch", ComponentName: component.Name, Source: component.Source, Span: component.Blocks.Spans.View, Message: fmt.Sprintf("component %s %s must use g:lane=\"client\"; components do not own request-time server data", component.Name, directive.Directive)}) + } + } + } + for _, layout := range program.Layouts { + for _, directive := range layout.Blocks.DirectiveLanes { + if directive.Lane == "" { + continue + } + if directive.Lane != "client" { + diagnostics = append(diagnostics, ValidationError{Code: "directive_lane_mismatch", Source: layout.Source, Span: layout.Blocks.Spans.View, Message: fmt.Sprintf("layout %s %s must use g:lane=\"client\"; layouts do not own request-time server data", layout.ID, directive.Directive)}) + } + } + } + return diagnostics +} + +func directiveExpressionRoot(directive gwdkir.DirectiveLane) string { + expression := strings.TrimSpace(directive.Expression) + if directive.Directive == "g:for" { + if parsed, err := viewparse.ParseForDirective(expression); err == nil { + expression = parsed.Collection + } + } + expression = strings.TrimSpace(strings.TrimPrefix(expression, "!")) + return exprRoot(expression) +} + +func parentDirectiveLaneIsServer(path string, serverPaths map[string]bool) bool { + for parent := path; strings.Contains(parent, "."); { + parent = parent[:strings.LastIndex(parent, ".")] + if serverPaths[parent] { + return true + } + } + return false +} diff --git a/internal/compiler/validate_directive_lanes_test.go b/internal/compiler/validate_directive_lanes_test.go new file mode 100644 index 00000000..45958c42 --- /dev/null +++ b/internal/compiler/validate_directive_lanes_test.go @@ -0,0 +1,39 @@ +package compiler + +import ( + "strings" + "testing" + + "github.com/cssbruno/gowdk/internal/gwdkir" +) + +func TestValidateDirectiveLanesRejectsDataOwnershipMismatch(t *testing.T) { + program := gwdkir.Program{Pages: []gwdkir.Page{{ + ID: "issues", + Blocks: gwdkir.Blocks{ + Server: true, + ServerFields: []string{"issues", "visible"}, + DirectiveLanes: []gwdkir.DirectiveLane{ + {Path: "0", Directive: "g:if", Lane: "client", Expression: "visible"}, + {Path: "1", Directive: "g:for", Lane: "server", Expression: "item in localItems"}, + }, + }, + }}} + diagnostics := validateDirectiveLanes(program) + if len(diagnostics) != 2 { + t.Fatalf("diagnostics = %#v", diagnostics) + } + for _, diagnostic := range diagnostics { + if diagnostic.Code != "directive_lane_mismatch" || !strings.Contains(diagnostic.Message, "resolves to") { + t.Fatalf("unexpected diagnostic: %#v", diagnostic) + } + } +} + +func TestValidateDirectiveLanesRejectsServerLaneInComponent(t *testing.T) { + program := gwdkir.Program{Components: []gwdkir.Component{{Name: "List", Blocks: gwdkir.Blocks{DirectiveLanes: []gwdkir.DirectiveLane{{Path: "0", Directive: "g:for", Lane: "server", Expression: "item in Items"}}}}}} + diagnostics := validateDirectiveLanes(program) + if len(diagnostics) != 1 || diagnostics[0].Code != "directive_lane_mismatch" || !strings.Contains(diagnostics[0].Message, `g:lane="client"`) { + t.Fatalf("diagnostics = %#v", diagnostics) + } +} diff --git a/internal/compiler/validate_identity.go b/internal/compiler/validate_identity.go index d579dfc8..526a5e87 100644 --- a/internal/compiler/validate_identity.go +++ b/internal/compiler/validate_identity.go @@ -6,6 +6,7 @@ import ( "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/source" + "github.com/cssbruno/gowdk/internal/viewmodel" ) func validateUniquePages(pages []gwdkir.Page) []ValidationError { @@ -272,7 +273,7 @@ func detectLayoutCycles(layouts []gwdkir.Layout, edges map[string][]string) []Va func validateLayoutSlots(layouts []gwdkir.Layout) []ValidationError { var diagnostics []ValidationError for _, layout := range layouts { - count := countLayoutSlots(layout.Blocks.ViewBody) + count := countLayoutSlots(layout.Blocks.ViewNodes) if count == 1 { continue } @@ -296,26 +297,21 @@ func validateLayoutSlots(layouts []gwdkir.Layout) []ValidationError { // countLayoutSlots counts self-closing `` placeholders, mirroring the // app-shell composition slot scan (whitespace tolerated, named slots ignored). -func countLayoutSlots(body string) int { - isSpace := func(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } +func countLayoutSlots(nodes []viewmodel.Node) int { count := 0 - for index := 0; index < len(body); index++ { - if body[index] != '<' || !strings.HasPrefix(body[index:], "= len(body) || body[cursor] != '/' { - continue - } - cursor++ - for cursor < len(body) && isSpace(body[cursor]) { - cursor++ - } - if cursor < len(body) && body[cursor] == '>' { - count++ + for _, node := range nodes { + switch typed := node.(type) { + case viewmodel.Element: + if typed.Name == "slot" && len(typed.Attrs) == 0 && len(typed.Children) == 0 { + count++ + } + count += countLayoutSlots(typed.Children) + case viewmodel.ComponentCall: + count += countLayoutSlots(typed.Children) + case viewmodel.AwaitBlock: + count += countLayoutSlots(typed.Pending) + count += countLayoutSlots(typed.Then) + count += countLayoutSlots(typed.Catch) } } return count diff --git a/internal/compiler/validate_interop.go b/internal/compiler/validate_interop.go new file mode 100644 index 00000000..90a0f428 --- /dev/null +++ b/internal/compiler/validate_interop.go @@ -0,0 +1,74 @@ +package compiler + +import ( + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + "github.com/cssbruno/gowdk/internal/source" + "github.com/cssbruno/gowdk/runtime/auth" +) + +func validateInteropRegistrations(config gowdk.Config, ir gwdkir.Program) []ValidationError { + type owner struct { + id string + source string + span source.SourceSpan + } + var customOwner, nativeOwner *owner + observe := func(guards []string, candidate owner) { + for _, name := range guards { + switch { + case auth.IsPublicGuard(name): + case name == "auth.required" && config.HasFeature(gowdk.FeatureAuth): + case auth.IsNativeGuard(name): + if nativeOwner == nil { + copy := candidate + nativeOwner = © + } + default: + if customOwner == nil { + copy := candidate + customOwner = © + } + } + } + } + for index := range ir.Pages { + page := &ir.Pages[index] + observe(page.Guards, owner{id: page.ID, source: page.Source, span: firstSpan(page.Blocks.Spans.Server, page.Blocks.Spans.View)}) + } + for _, endpoint := range ir.Endpoints { + candidate := owner{id: endpoint.PageID, source: endpoint.SourceFile, span: endpoint.Span} + if page := pageByID(ir.Pages, endpoint.PageID); page != nil { + candidate = owner{id: page.ID, source: page.Source, span: firstSpan(endpoint.Span, page.Blocks.Spans.Server, page.Blocks.Spans.View)} + } + observe(endpoint.Guards, candidate) + } + for _, subscription := range ir.RealtimeSubscriptions { + observe(subscription.Guards, owner{id: subscription.OwnerID, source: subscription.Source, span: subscription.Span}) + } + var diagnostics []ValidationError + if customOwner != nil && !config.Interop.Guards.Configured() { + diagnostics = append(diagnostics, ValidationError{ + Code: "missing_guard_registration", PageID: customOwner.id, + Source: customOwner.source, Span: customOwner.span, + Message: "custom guards require Config.Interop.Guards = gowdk.RegisterGuards(package.Guards)", + }) + } + if nativeOwner != nil && !config.HasFeature(gowdk.FeatureAuth) && !config.Interop.AuthProvider.Configured() { + diagnostics = append(diagnostics, ValidationError{ + Code: "missing_auth_registration", PageID: nativeOwner.id, + Source: nativeOwner.source, Span: nativeOwner.span, + Message: "native role:/permission: guards require Config.Interop.AuthProvider = gowdk.RegisterAuthProvider(package.AuthProvider) when the auth addon is disabled", + }) + } + return diagnostics +} + +func pageByID(pages []gwdkir.Page, id string) *gwdkir.Page { + for index := range pages { + if pages[index].ID == id { + return &pages[index] + } + } + return nil +} diff --git a/internal/compiler/validate_page_lists.go b/internal/compiler/validate_page_lists.go index 288833f0..82f9291a 100644 --- a/internal/compiler/validate_page_lists.go +++ b/internal/compiler/validate_page_lists.go @@ -31,17 +31,7 @@ func validatePageServerLists(page gwdkir.Page) []ValidationError { } func pageViewNodes(page gwdkir.Page) []viewmodel.Node { - if len(page.Blocks.ViewNodes) > 0 { - return page.Blocks.ViewNodes - } - if strings.TrimSpace(page.Blocks.ViewBody) == "" { - return nil - } - nodes, err := viewparse.Parse(page.Blocks.ViewBody) - if err != nil { - return nil - } - return nodes + return page.Blocks.ViewNodes } // pageLoads describes a page's declared server {} fields. fields holds the exact diff --git a/internal/compiler/validate_scripts.go b/internal/compiler/validate_scripts.go index 04b572be..becfabf1 100644 --- a/internal/compiler/validate_scripts.go +++ b/internal/compiler/validate_scripts.go @@ -1,6 +1,7 @@ package compiler import ( + contextpkg "context" "fmt" "go/parser" "go/token" @@ -13,7 +14,7 @@ import ( func validateGoBlocks(config gowdk.Config, app gwdkir.Program) []ValidationError { var diagnostics []ValidationError - enabledAddons := addonsByName(config) + enabledAddons := goBlockConsumersByName(config) for _, page := range app.Pages { mode := page.RenderMode(config.Render.DefaultMode()) for _, block := range page.Blocks.GoBlocks { @@ -57,7 +58,7 @@ func validateGoBlockSyntax(packageName string, sourcePath string, pageID string, }} } -func validateGoBlockTarget(enabledAddons map[string]gowdk.Addon, pageID string, componentName string, sourcePath string, packageName string, mode gowdk.RenderMode, block gwdkir.GoBlock) []ValidationError { +func validateGoBlockTarget(enabledAddons map[string]gowdk.GoBlockConsumer, pageID string, componentName string, sourcePath string, packageName string, mode gowdk.RenderMode, block gwdkir.GoBlock) []ValidationError { target := strings.TrimSpace(block.Target) switch { case target == "" || target == "client": @@ -106,7 +107,7 @@ func renamedGoServerTarget(pageID, componentName, sourcePath string, block gwdki } } -func validateNonPageGoBlockTarget(config gowdk.Config, enabledAddons map[string]gowdk.Addon, pageID string, componentName string, sourcePath string, packageName string, block gwdkir.GoBlock) []ValidationError { +func validateNonPageGoBlockTarget(config gowdk.Config, enabledAddons map[string]gowdk.GoBlockConsumer, pageID string, componentName string, sourcePath string, packageName string, block gwdkir.GoBlock) []ValidationError { target := strings.TrimSpace(block.Target) switch { case target == "": @@ -150,11 +151,10 @@ func validateNonPageGoBlockTarget(config gowdk.Config, enabledAddons map[string] } } -func validateAddonGoBlockTarget(enabledAddons map[string]gowdk.Addon, pageID string, componentName string, sourcePath string, packageName string, render gowdk.RenderMode, block gwdkir.GoBlock) []ValidationError { +func validateAddonGoBlockTarget(enabledAddons map[string]gowdk.GoBlockConsumer, pageID string, componentName string, sourcePath string, packageName string, render gowdk.RenderMode, block gwdkir.GoBlock) []ValidationError { name := strings.TrimPrefix(strings.TrimSpace(block.Target), "addon.") - addon, ok := enabledAddons[name] + consumer, ok := enabledAddons[name] if name != "" && ok { - consumer := gowdk.ResolveAddonCapabilities(addon).GoBlockConsumer if consumer == nil { return []ValidationError{{ Code: "unsupported_addon_go_block_target", @@ -200,7 +200,11 @@ func addonGoBlockDiagnostics(consumer gowdk.GoBlockConsumer, pageID string, comp target := gowdkGoBlockTarget(pageID, componentName, sourcePath, packageName, block) context := gowdk.GoBlockContext{Render: render} var diagnostics []ValidationError - for _, diagnostic := range consumer.ValidateGoBlock(target, context) { + pluginDiagnostics := consumer.ValidateGoBlock(target, context) + if cancellable, ok := consumer.(gowdk.GoBlockConsumerContext); ok { + pluginDiagnostics = cancellable.ValidateGoBlockContext(contextpkg.Background(), target, context) + } + for _, diagnostic := range pluginDiagnostics { span := block.Span if diagnostic.Span.Start.Line != 0 || diagnostic.Span.End.Line != 0 { span = manifestSpan(diagnostic.Span) @@ -221,10 +225,13 @@ func addonGoBlockDiagnostics(consumer gowdk.GoBlockConsumer, pageID string, comp return diagnostics } -func addonsByName(config gowdk.Config) map[string]gowdk.Addon { - names := map[string]gowdk.Addon{} +func goBlockConsumersByName(config gowdk.Config) map[string]gowdk.GoBlockConsumer { + names := map[string]gowdk.GoBlockConsumer{} for _, addon := range config.Addons { - names[addon.Name()] = addon + names[addon.Name()] = gowdk.ResolveAddonCapabilities(addon).GoBlockConsumer + } + for _, extension := range config.Extensions { + names[extension.Name()] = gowdk.ResolveExtensionCapabilities(extension).GoBlockConsumer } return names } diff --git a/internal/compiler/validate_source_uses.go b/internal/compiler/validate_source_uses.go index 29672b35..4713a34e 100644 --- a/internal/compiler/validate_source_uses.go +++ b/internal/compiler/validate_source_uses.go @@ -134,19 +134,10 @@ func validateComponentUses(component gwdkir.Component, usesByAlias map[string]gw } func validatePageQualifiedComponentRefs(page gwdkir.Page, usesByAlias map[string]gwdkir.Use, componentPackages map[string]bool, componentByPackageName map[string]bool, sourcePackages map[string]bool, crossFile bool) []ValidationError { - if !page.Blocks.View || strings.TrimSpace(page.Blocks.ViewBody) == "" { + if !page.Blocks.View || len(page.Blocks.ViewNodes) == 0 { return nil } - refs, err := viewanalysis.ComponentReferenceSpans(page.Blocks.ViewBody) - if err != nil { - return []ValidationError{{ - Code: "view_parse_error", - PageID: page.ID, - Source: page.Source, - Span: firstSpan(page.Blocks.Spans.View, page.Spans.Page), - Message: fmt.Sprintf("%s view cannot be parsed: %v", page.ID, err), - }} - } + refs := viewanalysis.ComponentReferenceSpansFromNodes(page.Blocks.ViewNodes) var diagnostics []ValidationError for _, ref := range refs { alias, name, ok := strings.Cut(ref.Name, ".") @@ -212,19 +203,10 @@ func validatePageQualifiedComponentRefs(page gwdkir.Page, usesByAlias map[string } func validateComponentQualifiedComponentRefs(component gwdkir.Component, usesByAlias map[string]gwdkir.Use, componentPackages map[string]bool, componentByPackageName map[string]bool, sourcePackages map[string]bool, crossFile bool) []ValidationError { - if !component.Blocks.View || strings.TrimSpace(component.Blocks.ViewBody) == "" { + if !component.Blocks.View || len(component.Blocks.ViewNodes) == 0 { return nil } - refs, err := viewanalysis.ComponentReferenceSpans(component.Blocks.ViewBody) - if err != nil { - return []ValidationError{{ - Code: "view_parse_error", - ComponentName: component.Name, - Source: component.Source, - Span: firstSpan(component.Blocks.Spans.View, component.Span), - Message: fmt.Sprintf("component %s view cannot be parsed: %v", component.Name, err), - }} - } + refs := viewanalysis.ComponentReferenceSpansFromNodes(component.Blocks.ViewNodes) var diagnostics []ValidationError for _, ref := range refs { alias, name, ok := strings.Cut(ref.Name, ".") diff --git a/internal/compiler/validate_stores.go b/internal/compiler/validate_stores.go index 4c45aa0a..0b41164a 100644 --- a/internal/compiler/validate_stores.go +++ b/internal/compiler/validate_stores.go @@ -5,7 +5,6 @@ import ( "sort" "strings" - "github.com/cssbruno/gowdk/internal/clientlang" "github.com/cssbruno/gowdk/internal/gotypes" "github.com/cssbruno/gowdk/internal/gwdkir" ) @@ -125,13 +124,10 @@ func declaredStoreNamesByPackage(pages []gwdkir.Page) map[string]map[string]bool func validateStoreUsesAgainst(declared map[string]map[string]bool, components []gwdkir.Component) []ValidationError { var diagnostics []ValidationError for _, component := range components { - if !component.Blocks.Client && strings.TrimSpace(component.Blocks.ClientBody) == "" { - continue - } - program, err := clientlang.Parse(component.Blocks.ClientBody) - if err != nil { + if !component.Blocks.Client || component.Blocks.ClientProgram == nil { continue } + program := *component.Blocks.ClientProgram usesByAlias := componentUsesByAlias(component) for _, use := range program.Uses { if use.PackageAlias != "" { diff --git a/internal/diagnostics/explain.go b/internal/diagnostics/explain.go index fc3cb6c5..b7efd741 100644 --- a/internal/diagnostics/explain.go +++ b/internal/diagnostics/explain.go @@ -27,6 +27,36 @@ type explanationDetail struct { } var explanationDetails = map[string]explanationDetail{ + "directive_lane_required": { + Details: "Every g:for and g:if must declare whether it executes against server-rendered or browser-owned data. The explicit lane prevents a later refactor from silently moving data across the request/client boundary.", + NextSteps: []string{"Add g:lane=\"server\" for request/build data.", "Add g:lane=\"client\" for stores and other browser-owned state."}, + Invalid: `
  • {item}
  • `, + Fixed: `
  • {item}
  • `, + }, + "directive_lane_invalid": { + Details: "g:lane accepts only server or client and is meaningful only beside g:for or g:if.", + NextSteps: []string{"Use g:lane=\"server\" or g:lane=\"client\".", "Remove g:lane when the element has no structural directive."}, + Invalid: `

    Hello

    `, + Fixed: `

    Hello

    `, + }, + "directive_lane_mismatch": { + Details: "The declared structural-directive lane conflicts with the expression's data owner. Server data cannot be evaluated only in the browser, and browser stores do not exist during request/build rendering.", + NextSteps: []string{"Change g:lane to match the referenced data.", "Move the expression to data owned by the intended lane."}, + Invalid: `
  • {item}
  • `, + Fixed: `
  • {item}
  • `, + }, + "missing_load_registration": { + Details: "A server {} page delegates loading to application Go, but no typed load registration identifies the function. GOWDK no longer discovers loads from magic function names.", + NextSteps: []string{"Add gowdk.RegisterLoad(pageID, package.LoadFunction) to Config.Interop.Loads.", "Use go server {} when the implementation is intentionally inline."}, + }, + "missing_guard_registration": { + Details: "The source names a custom guard, but generated startup has no typed guard-registry provider.", + NextSteps: []string{"Add Config.Interop.Guards = gowdk.RegisterGuards(package.Guards).", "Use public or a built-in auth guard when no custom registry is needed."}, + }, + "missing_auth_registration": { + Details: "A role: or permission: guard needs an auth provider, but neither the auth feature nor a typed provider registration supplies one.", + NextSteps: []string{"Enable the auth feature.", "Or add Config.Interop.AuthProvider = gowdk.RegisterAuthProvider(package.AuthProvider)."}, + }, "page_store_persist_key_conflict": { Details: "Two pages declare a persisted store with the same name but different struct shapes. Persistence is keyed by store name (gowdk:store:), so both pages read and write the same browser storage slot. Because their embedded schema hashes differ, navigating from one page to the other discards the saved value every time. Either rename one store so each owns its own key, or give them the same shape so sharing is intentional.", NextSteps: []string{ diff --git a/internal/diagnostics/registry.go b/internal/diagnostics/registry.go index 5b276f7e..56466151 100644 --- a/internal/diagnostics/registry.go +++ b/internal/diagnostics/registry.go @@ -110,6 +110,7 @@ var Registry = []Code{ {Code: "client_go_block_wasm_export_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM exports are missing or invalid"}, {Code: "client_go_block_wasm_import_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client WASM imports are invalid"}, {Code: "client_go_block_wasm_source_error", Area: "wasm", Stability: StabilityExperimental, Severity: SeverityError, Summary: "page go client source materialization failed"}, + {Code: "compile_time_block_parse_error", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "compile-time config block could not be parsed"}, {Code: "component_client_error", Area: "components", Stability: StabilityStable, Severity: SeverityError, Summary: "component client block or local island behavior is invalid"}, {Code: "component_composition_cycle", Area: "components", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "component composition graph contains a cycle"}, {Code: "component_contract_error", Area: "components", Stability: StabilityStable, Severity: SeverityError, Summary: "component Go props or state contract is invalid"}, @@ -128,6 +129,10 @@ var Registry = []Code{ {Code: "contract_route_invalid", Area: "contracts", Stability: StabilityExperimental, Severity: SeverityError, Summary: "contract reference route method or path is invalid"}, {Code: "contract_type_invalid", Area: "contracts", Stability: StabilityExperimental, Severity: SeverityError, Summary: "contract type is invalid"}, {Code: "cyclic_layout_reference", Area: "layouts", Stability: StabilityStable, Severity: SeverityError, Summary: "layout layout inheritance forms a cycle"}, + {Code: "decode_request", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "executable config helper could not decode a bridge request"}, + {Code: "directive_lane_invalid", Area: "parser", Stability: StabilityStable, Severity: SeverityError, Summary: "g:lane is malformed or appears without a structural directive"}, + {Code: "directive_lane_mismatch", Area: "compiler", Stability: StabilityStable, Severity: SeverityError, Summary: "declared directive lane disagrees with its data source"}, + {Code: "directive_lane_required", Area: "parser", Stability: StabilityStable, Severity: SeverityError, Summary: "g:for or g:if is missing an explicit server/client lane"}, {Code: "duplicate_command_owner", Area: "contracts", Stability: StabilityExperimental, Severity: SeverityError, Summary: "command has more than one owner registration"}, {Code: "duplicate_component_emit", Area: "components", Stability: StabilityExperimental, Severity: SeverityError, Summary: "component declares the same emitted event more than once"}, {Code: "duplicate_component_name", Area: "components", Stability: StabilityStable, Severity: SeverityError, Summary: "component name is declared more than once"}, @@ -177,10 +182,13 @@ var Registry = []Code{ {Code: "malformed_package_declaration", Area: "packages", Stability: StabilityStable, Severity: SeverityError, Summary: "GOWDK package declaration is malformed"}, {Code: "malformed_route", Area: "routing", Stability: StabilityStable, Severity: SeverityError, Summary: "route path violates GOWDK route syntax"}, {Code: "missing_accessible_name", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "interactive control has no accessible name"}, + {Code: "missing_auth_registration", Area: "interop", Stability: StabilityStable, Severity: SeverityError, Summary: "native RBAC guards have no explicit typed provider"}, {Code: "missing_button_type", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "button element is missing an explicit type"}, {Code: "missing_form_label", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "form control is missing an accessible label"}, + {Code: "missing_guard_registration", Area: "interop", Stability: StabilityStable, Severity: SeverityError, Summary: "custom route guards have no explicit typed provider"}, {Code: "missing_img_alt", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "image element is missing explicit alt text"}, {Code: "missing_landmark_name", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "explicit landmark has no accessible name"}, + {Code: "missing_load_registration", Area: "interop", Stability: StabilityStable, Severity: SeverityError, Summary: "request-time page load is not explicitly registered"}, {Code: "missing_package_declaration", Area: "packages", Stability: StabilityStable, Severity: SeverityError, Summary: "GOWDK source is missing a package declaration"}, {Code: "missing_page_guard", Area: "pages", Stability: StabilityStable, Severity: SeverityWarning, Summary: "page declares no guard; warning (route denied 403) or error when it defines act/api/fragment endpoints"}, {Code: "missing_realtime_addon", Area: "realtime", Stability: StabilityExperimental, Severity: SeverityError, Summary: "realtime subscriptions require the realtime addon"}, @@ -208,6 +216,7 @@ var Registry = []Code{ {Code: "policy_unknown_extends", Area: "audit", Stability: StabilityExperimental, Severity: SeverityError, Summary: "audit policy extends a policy that is not defined"}, {Code: "policy_unknown_selector", Area: "audit", Stability: StabilityExperimental, Severity: SeverityWarning, Summary: "audit policy uses an unrecognized selector form"}, {Code: "positive_tabindex", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "positive tabindex changes natural focus order"}, + {Code: "protocol_mismatch", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "executable config helper protocol versions do not match"}, {Code: "public_guard_exclusive", Area: "pages", Stability: StabilityStable, Severity: SeverityError, Summary: "guard public must be the only guard on an intentionally public page"}, {Code: "query_invalidation_invalid", Area: "realtime", Stability: StabilityExperimental, Severity: SeverityError, Summary: "query invalidation targets invalid contract metadata"}, {Code: "query_invalidation_missing", Area: "realtime", Stability: StabilityExperimental, Severity: SeverityError, Summary: "query invalidation has no matching contract metadata"}, @@ -243,6 +252,7 @@ var Registry = []Code{ {Code: "unknown_gowdk_use_alias", Area: "source-imports", Stability: StabilityStable, Severity: SeverityError, Fix: missingUseFix, Summary: "source references a GOWDK use alias that is not declared"}, {Code: "unknown_gowdk_use_package", Area: "source-imports", Stability: StabilityStable, Severity: SeverityError, Summary: "use declaration references a GOWDK package that was not discovered"}, {Code: "unknown_layout_id", Area: "layouts", Stability: StabilityStable, Severity: SeverityError, Summary: "page references a layout that does not exist"}, + {Code: "unknown_method", Area: "config", Stability: StabilityStable, Severity: SeverityError, Summary: "executable config helper received an unsupported bridge method"}, {Code: "unresolved_accessibility_reference", Area: "accessibility", Stability: StabilityStable, Severity: SeverityWarning, Summary: "literal ARIA or label reference does not resolve to a literal id"}, {Code: "unsupported_action_method", Area: "backend", Stability: StabilityStable, Severity: SeverityError, Summary: "action endpoint uses a method other than POST"}, {Code: "unsupported_addon_go_block_target", Area: "go-block", Stability: StabilityExperimental, Severity: SeverityError, Summary: "enabled addon does not consume the requested go block target"}, diff --git a/internal/gowdkcmd/audit.go b/internal/gowdkcmd/audit.go index e1f34d39..14133e5c 100644 --- a/internal/gowdkcmd/audit.go +++ b/internal/gowdkcmd/audit.go @@ -579,8 +579,8 @@ func writeGeneratedAppAuditRunHooks(appDir string, ir gwdkir.Program) error { builder.WriteString(")\n") builder.WriteString(` -func GOWDKAuthProvider() gowdkauth.Provider { - return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { +func init() { + RegisterAuthProvider(gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { actor := strings.TrimSpace(request.Header.Get("X-GOWDK-Audit-Actor")) switch { case actor == "" || actor == "anonymous": @@ -592,7 +592,7 @@ func GOWDKAuthProvider() gowdkauth.Provider { default: return &gowdkauth.Principal{ID: "audit", Roles: []string{actor}}, nil } - }) + })) } `) return os.WriteFile(hookPath, []byte(builder.String()), 0o644) diff --git a/internal/gowdkcmd/audit_test.go b/internal/gowdkcmd/audit_test.go index 04bb87f4..954bc294 100644 --- a/internal/gowdkcmd/audit_test.go +++ b/internal/gowdkcmd/audit_test.go @@ -379,6 +379,7 @@ view { func TestAuditCommandRunSupportsActorExpectationsAgainstGeneratedApp(t *testing.T) { root := t.TempDir() + t.Setenv("GOWDK_AUTH_SESSION_SECRET", strings.Repeat("s", 32)) config := writeAuditCLIConfigWithSSR(t, root) writeCLITestModule(t, root, "example.com/gowdk-audit-run-actor") pagePath := filepath.Join(root, "admin.page.gwdk") @@ -415,7 +416,7 @@ test admin { } } -func TestAuditCommandRunReportsMissingCustomGuardFixtures(t *testing.T) { +func TestAuditCommandRejectsMissingCustomGuardRegistration(t *testing.T) { root := t.TempDir() config := writeAuditCLIConfigWithSSR(t, root) writeCLITestModule(t, root, "example.com/gowdk-audit-run-custom-guard") @@ -424,7 +425,7 @@ func TestAuditCommandRunReportsMissingCustomGuardFixtures(t *testing.T) { page admin route "/admin" -guard auth.required +guard session go server { } @@ -437,15 +438,12 @@ view { stdout, stderr, err := captureCLIOutput(t, func() error { return run([]string{"audit", "--config", config, "--run", pagePath}) }) - if err != nil { - t.Fatalf("expected missing custom guard fixtures to report as a finding, got error: %v\nstderr:\n%s", err, stderr) + if err == nil { + t.Fatalf("expected missing custom guard registration to fail before generation; output:\n%s\n%s", stdout, stderr) } output := stdout + "\n" + stderr - if strings.Contains(output, "audit generated app tests passed:") { - t.Fatalf("custom guard audit run must not claim runtime verification, got %q", output) - } - if !strings.Contains(output, "audit_guard_unverified") || !strings.Contains(output, "auth.required") || !strings.Contains(output, "explicit fixtures") { - t.Fatalf("expected unresolved custom guard finding, got %q", output) + if !strings.Contains(output, "custom guards require") || !strings.Contains(output, "RegisterGuards") { + t.Fatalf("expected early typed-registration diagnostic, got %q", output) } } @@ -806,6 +804,7 @@ import ( ) var Config = gowdk.Config{ + Features: gowdk.FeatureConfig{Auth: gowdk.AuthFeatureConfig{Enabled: true}}, Addons: []gowdk.Addon{ssr.Addon()}, } `) diff --git a/internal/gowdkcmd/build.go b/internal/gowdkcmd/build.go index 232f56fa..dffe4e81 100644 --- a/internal/gowdkcmd/build.go +++ b/internal/gowdkcmd/build.go @@ -13,12 +13,10 @@ import ( "github.com/cssbruno/gowdk/addons/ssr" "github.com/cssbruno/gowdk/internal/appgen" "github.com/cssbruno/gowdk/internal/buildgen" - "github.com/cssbruno/gowdk/internal/compiler" "github.com/cssbruno/gowdk/internal/contractscan" "github.com/cssbruno/gowdk/internal/gwdkanalysis" - "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/lang" - "github.com/cssbruno/gowdk/internal/source" + "github.com/cssbruno/gowdk/internal/projectcompile" ) const buildUsage = "usage: gowdk build [--config ] [--project-root ] [--env-file ] [--debug] [--timings[=]] [--ssr] [--allow-missing-backend] [--allow-insecure] [--obfuscate-assets] [--target ] [--module ] [--out ] [--app ] [--bin ] [--docker] [--docker-base ] [--deploy-recipe ] [--wasm ] [--backend-app ] [--backend-bin ] [--worker-app ] [--worker-bin ] [--cron-app ] [--cron-bin ] [files...]" @@ -259,73 +257,36 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec for _, diagnostic := range diagnostics { fmt.Fprintln(os.Stderr, diagnostic.String()) } - var ir gwdkir.Program - if err := timings.measure("ir_assembly", func() error { - ir = gwdkanalysis.BuildProgram(options.Config, app) - return nil + var snapshot projectcompile.Snapshot + var compileDiagnostics projectcompile.Diagnostics + if err := timings.measure("project_compilation", func() error { + var compileErr error + snapshot, compileDiagnostics, compileErr = projectcompile.Compile(options.Config, app, projectcompile.Options{ + ProjectRoot: options.ProjectRoot, Mode: projectcompile.ProjectMode, ScanContracts: true, + }) + return compileErr }); err != nil { return operationErrorFromCause(err) } + // Preserve the stable timing schema while the shared orchestrator owns the + // formerly command-local binding and validation subphases. + timings.addDuration("go_binding", 0) + timings.addDuration("ir_validation", 0) + if compileDiagnostics.HasErrors() { + return operationErrorFromCause(compileDiagnostics) + } + for _, diagnostic := range compileDiagnostics { + if diagnostic.Severity == "warning" { + fmt.Fprintln(os.Stderr, "warning: "+diagnostic.Message) + } + } + ir := snapshot.Analyzed.Program() + validated := snapshot.Validated + contractReport := snapshot.Contracts timings.counter("pages", len(ir.Pages)) timings.counter("components", len(ir.Components)) timings.counter("layouts", len(ir.Layouts)) timings.counter("endpoints", len(ir.Endpoints)) - var analyzed compiler.AnalyzedProgram - if err := timings.measure("go_binding", func() error { - var bindErr error - var bindings []source.BackendBinding - bindings, bindErr = compiler.EnrichProgram(options.Config, &ir) - if bindErr == nil { - analyzed = compiler.AnalyzedProgramWithBindings(ir, bindings) - } - return bindErr - }); err != nil { - return operationErrorFromCause(err) - } - var report compiler.ValidationErrors - if err := timings.measure("ir_validation", func() error { - _, report = compiler.ValidateAnalyzedProgramReport(options.Config, analyzed) - return nil - }); err != nil { - return operationErrorFromCause(err) - } - if report.HasErrors() { - return operationErrorFromCompiler("build failed", report, report) - } - for _, diagnostic := range report { - prefix := "" - if diagnostic.Severity == compiler.SeverityWarning { - prefix = "warning: " - } - fmt.Fprintln(os.Stderr, prefix+diagnostic.Error()) - } - var contractReport contractscan.Report - if err := timings.measure("contract_validation", func() error { - scanned, err := scanContractReport(options.ProjectRoot) - if err != nil { - return err - } - contractReport = scanned - linkIRContractReferencesFromReport(&ir, contractReport) - if err := compiler.ValidateContractReferences(ir.ContractRefs); err != nil { - return err - } - if err := compiler.ValidateRealtimeSubscriptionBindings(ir.RealtimeSubscriptions); err != nil { - return err - } - return compiler.ValidateQueryInvalidations(options.Config, ir.QueryInvalidations) - }); err != nil { - return operationErrorFromCause(err) - } - - var validated compiler.ValidatedProgram - if err := timings.measure("validated_snapshot", func() error { - var validateErr error - validated, validateErr = compiler.ValidateIR(options.Config, ir) - return validateErr - }); err != nil { - return operationErrorFromCause(err) - } if err := timings.measure("security_audit", func() error { return enforceBuildSecurityAudit(options, validated) @@ -334,23 +295,29 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec } var result buildgen.Result + asyncAPIPath := filepath.Join(outputDir, contractscan.AsyncAPIFile) if err := timings.measure("output_plan_writes", func() error { var buildErr error outputPlan, buildErr := buildgen.PlanBuildFromValidatedProgram(options.Config, validated, outputDir) if buildErr != nil { return buildErr } + asyncAPI, buildErr := contractscan.AsyncAPIPayload(contractReport, contractscan.AsyncAPIOptions{}) + if buildErr != nil { + return buildErr + } + if buildErr = outputPlan.AddOutputFile(contractscan.AsyncAPIFile, asyncAPI); buildErr != nil { + return buildErr + } + outputPlan.SetPrePublishValidation(func(staged buildgen.Result) error { + return enforceFinalBuildArtifactSecurityAudit(options, staged) + }) result, buildErr = buildgen.BuildFromPlan(outputPlan) return buildErr }); err != nil { printBuildgenBuildErrorReport(err, options.Debug) return operationErrorFromCause(err) } - if err := timings.measure("final_security_audit", func() error { - return enforceFinalBuildArtifactSecurityAudit(options, result) - }); err != nil { - return operationErrorFromCause(err) - } timings.counter("artifacts", len(result.Artifacts)) timings.counter("css_artifacts", len(result.CSSArtifacts)) timings.counter("asset_artifacts", len(result.AssetArtifacts)) @@ -383,14 +350,6 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec if result.SecurityManifestPath != "" { fmt.Println(result.SecurityManifestPath) } - var asyncAPIPath string - if err := timings.measure("asyncapi_report", func() error { - var writeErr error - asyncAPIPath, writeErr = contractscan.WriteAsyncAPI(outputDir, contractReport, contractscan.AsyncAPIOptions{}) - return writeErr - }); err != nil { - return operationErrorFromCause(err) - } if asyncAPIPath != "" { fmt.Println(asyncAPIPath) } @@ -408,6 +367,9 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec cronAppDir := request.CronAppDir cronBinaryPath := request.CronBinaryPath var buildReportEvents []buildgen.BuildEvent + packagingOptions := appgen.PackagingOptions{ + Environment: options.ProjectEnvironment.ForSubprocess(os.Environ()), + } if strings.TrimSpace(appDir) != "" { var app appgen.Result if err := timings.measure("app_generation", func() error { @@ -429,14 +391,15 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec fmt.Println(app.PackagePath) fmt.Println(app.MainPath) if strings.TrimSpace(binaryPath) != "" { - var built string + var packaged appgen.PackagingResult if err := timings.measure("binary_build", func() error { var buildErr error - built, buildErr = appgen.BuildBinary(app.AppDir, binaryPath) + packaged, buildErr = appgen.BuildBinaryWithOptions(app.AppDir, binaryPath, packagingOptions) return buildErr }); err != nil { return operationErrorFromCause(err) } + built := packaged.Path fmt.Println(built) buildReportEvents = append(buildReportEvents, buildgen.BuildEvent{ Level: buildgen.BuildEventInfo, @@ -444,6 +407,7 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec Kind: "binary_built", Message: "compiled generated app binary", Path: filepath.ToSlash(built), + Data: packaged.Metadata.Data(), }) if request.Docker { var artifacts dockerArtifacts @@ -479,15 +443,23 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec } } if strings.TrimSpace(wasmPath) != "" { - var built string + var packaged appgen.PackagingResult if err := timings.measure("wasm_build", func() error { var buildErr error - built, buildErr = appgen.BuildWASM(app.AppDir, wasmPath) + packaged, buildErr = appgen.BuildWASMWithOptions(app.AppDir, wasmPath, packagingOptions) return buildErr }); err != nil { return operationErrorFromCause(err) } - fmt.Println(built) + fmt.Println(packaged.Path) + buildReportEvents = append(buildReportEvents, buildgen.BuildEvent{ + Level: buildgen.BuildEventInfo, + Stage: "package", + Kind: "wasm_built", + Message: "compiled generated app WASM", + Path: filepath.ToSlash(packaged.Path), + Data: packaged.Metadata.Data(), + }) } } if strings.TrimSpace(backendAppDir) != "" { @@ -509,15 +481,23 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec fmt.Println(app.PackagePath) fmt.Println(app.MainPath) if strings.TrimSpace(backendBinaryPath) != "" { - var built string + var packaged appgen.PackagingResult if err := timings.measure("backend_binary_build", func() error { var buildErr error - built, buildErr = appgen.BuildBinary(app.AppDir, backendBinaryPath) + packaged, buildErr = appgen.BuildBinaryWithOptions(app.AppDir, backendBinaryPath, packagingOptions) return buildErr }); err != nil { return operationErrorFromCause(err) } - fmt.Println(built) + fmt.Println(packaged.Path) + buildReportEvents = append(buildReportEvents, buildgen.BuildEvent{ + Level: buildgen.BuildEventInfo, + Stage: "package", + Kind: "backend_binary_built", + Message: "compiled generated backend binary", + Path: filepath.ToSlash(packaged.Path), + Data: packaged.Metadata.Data(), + }) } } if strings.TrimSpace(workerAppDir) != "" { @@ -534,14 +514,15 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec fmt.Println(app.MainPath) buildReportEvents = append(buildReportEvents, contractRoleBuildEvents("worker", app.Contracts, nil, app.MainPath)...) if strings.TrimSpace(workerBinaryPath) != "" { - var built string + var packaged appgen.PackagingResult if err := timings.measure("worker_binary_build", func() error { var buildErr error - built, buildErr = appgen.BuildWorkerBinary(app.AppDir, workerBinaryPath) + packaged, buildErr = appgen.BuildWorkerBinaryWithOptions(app.AppDir, workerBinaryPath, packagingOptions) return buildErr }); err != nil { return operationErrorFromCause(err) } + built := packaged.Path fmt.Println(built) buildReportEvents = append(buildReportEvents, buildgen.BuildEvent{ Level: buildgen.BuildEventInfo, @@ -549,9 +530,11 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec Kind: "contract_role_binary_built", Message: "compiled generated contract worker binary", Path: filepath.ToSlash(built), - Data: map[string]string{ - "role": "worker", - }, + Data: func() map[string]string { + data := packaged.Metadata.Data() + data["role"] = "worker" + return data + }(), }) } } @@ -569,14 +552,15 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec fmt.Println(app.MainPath) buildReportEvents = append(buildReportEvents, contractRoleBuildEvents("cron", nil, app.Jobs, app.MainPath)...) if strings.TrimSpace(cronBinaryPath) != "" { - var built string + var packaged appgen.PackagingResult if err := timings.measure("cron_binary_build", func() error { var buildErr error - built, buildErr = appgen.BuildCronBinary(app.AppDir, cronBinaryPath) + packaged, buildErr = appgen.BuildCronBinaryWithOptions(app.AppDir, cronBinaryPath, packagingOptions) return buildErr }); err != nil { return operationErrorFromCause(err) } + built := packaged.Path fmt.Println(built) buildReportEvents = append(buildReportEvents, buildgen.BuildEvent{ Level: buildgen.BuildEventInfo, @@ -584,9 +568,11 @@ func buildOnce(options cliOptions, request buildRequest, timings *buildTimingRec Kind: "contract_role_binary_built", Message: "compiled generated contract cron binary", Path: filepath.ToSlash(built), - Data: map[string]string{ - "role": "cron", - }, + Data: func() map[string]string { + data := packaged.Metadata.Data() + data["role"] = "cron" + return data + }(), }) } } diff --git a/internal/gowdkcmd/build_audit.go b/internal/gowdkcmd/build_audit.go index 9478700b..f658497a 100644 --- a/internal/gowdkcmd/build_audit.go +++ b/internal/gowdkcmd/build_audit.go @@ -340,6 +340,9 @@ func finalBuildArtifactPaths(result buildgen.Result) []string { add(result.OpenAPIPath) add(result.SecurityManifestPath) add(result.BuildReportPath) + for _, path := range result.AdditionalOutputPaths { + add(path) + } return paths } diff --git a/internal/gowdkcmd/command_schema.go b/internal/gowdkcmd/command_schema.go new file mode 100644 index 00000000..dd602f0d --- /dev/null +++ b/internal/gowdkcmd/command_schema.go @@ -0,0 +1,232 @@ +package gowdkcmd + +import ( + "fmt" + "sort" + "strings" +) + +const completionUsage = "usage: gowdk completion " + +// FlagSpec is one completion/documentation view of a parser-owned flag. The +// canonical token comes from the CommandSpec usage line so help, docs, and +// completion cannot drift independently. +type FlagSpec struct { + Name string + Group string +} + +// FlagGroup names flags shared by multiple commands. Groups are descriptive; +// command membership is still determined from the command's canonical usage. +type FlagGroup struct { + Name string + Flags []string +} + +var commandFlagGroups = []FlagGroup{ + {Name: "project", Flags: []string{"--config", "--project-root", "--env-file", "--module", "--ssr"}}, + {Name: "output", Flags: []string{"--json", "--debug", "--timings"}}, + {Name: "security", Flags: []string{"--allow-insecure", "--allow-missing-backend"}}, +} + +func inspectCommandSpecs() []CommandSpec { + names := []string{"ir", "tree", "endpoint-graph", "asset-graph", "go-bindings"} + children := make([]CommandSpec, 0, len(names)) + for _, name := range names { + usage := fmt.Sprintf("usage: gowdk inspect %s [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...]", name) + children = append(children, CommandSpec{Name: name, Usage: staticCommandUsage(usage), Summary: "inspect validated compiler data"}) + } + return children +} + +func contractListCommandSpecs() []CommandSpec { + usage := staticCommandUsage("usage: gowdk list commands|queries|events|jobs [--json] [dir]") + return []CommandSpec{ + {Name: "commands", Usage: usage, Summary: "list commands"}, + {Name: "queries", Usage: usage, Summary: "list queries"}, + {Name: "events", Usage: usage, Summary: "list events"}, + {Name: "jobs", Usage: usage, Summary: "list jobs"}, + } +} + +func playgroundCommandSpecs() []CommandSpec { + usage := staticCommandUsage(playgroundUsage) + return []CommandSpec{ + {Name: "policy", Usage: usage, Summary: "inspect the sandbox policy"}, + {Name: "export", Usage: usage, Summary: "export a playground project"}, + {Name: "run", Usage: usage, Summary: "run an opted-in sandbox build"}, + } +} + +func completionCommandSpecs() []CommandSpec { + return []CommandSpec{ + {Name: "bash", Usage: staticCommandUsage("usage: gowdk completion bash"), Summary: "generate Bash completion"}, + {Name: "zsh", Usage: staticCommandUsage("usage: gowdk completion zsh"), Summary: "generate Zsh completion"}, + {Name: "fish", Usage: staticCommandUsage("usage: gowdk completion fish"), Summary: "generate Fish completion"}, + } +} + +func commandFlags(spec CommandSpec) []FlagSpec { + seen := map[string]bool{} + var flags []FlagSpec + for _, token := range strings.Fields(spec.Usage()) { + index := strings.Index(token, "--") + if index < 0 { + continue + } + token = token[index:] + end := len(token) + for offset, char := range token { + if offset > 1 && !(char == '-' || char >= 'a' && char <= 'z' || char >= '0' && char <= '9') { + end = offset + break + } + } + name := token[:end] + if name == "--" || seen[name] { + continue + } + seen[name] = true + flags = append(flags, FlagSpec{Name: name, Group: flagGroup(name)}) + } + sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name }) + return flags +} + +func flagGroup(name string) string { + for _, group := range commandFlagGroups { + for _, candidate := range group.Flags { + if candidate == name { + return group.Name + } + } + } + return "command" +} + +type commandRecord struct { + Path []string + Spec CommandSpec + Flags []FlagSpec + Summary string +} + +func commandRecords() []commandRecord { + var records []commandRecord + var visit func([]string, CommandSpec) + visit = func(parent []string, spec CommandSpec) { + path := append(append([]string(nil), parent...), spec.Name) + records = append(records, commandRecord{Path: path, Spec: spec, Flags: commandFlags(spec), Summary: spec.Summary}) + for _, child := range spec.Children { + visit(path, child) + } + } + for _, spec := range topLevelCommands { + visit(nil, spec) + } + return records +} + +func completionCommand(args []string) error { + if len(args) != 1 { + return fmt.Errorf("%s", completionUsage) + } + var output string + switch args[0] { + case "bash": + output = bashCompletion() + case "zsh": + output = zshCompletion() + case "fish": + output = fishCompletion() + case "markdown": + output = commandDocumentationMarkdown() + default: + return fmt.Errorf("unknown completion shell %q; expected bash, zsh, or fish", args[0]) + } + fmt.Print(output) + return nil +} + +func bashCompletion() string { + var lines []string + lines = append(lines, "_gowdk_complete() {", " local current=${COMP_WORDS[COMP_CWORD]}", " local words") + lines = append(lines, " case \"${COMP_WORDS[1]}\" in") + for _, spec := range topLevelCommands { + var words []string + for _, child := range spec.Children { + words = append(words, child.Name) + } + for _, flag := range commandFlags(spec) { + words = append(words, flag.Name) + } + lines = append(lines, fmt.Sprintf(" %s) words=%q ;;", spec.Name, strings.Join(words, " "))) + } + lines = append(lines, " *) words=\""+strings.Join(topLevelCommandNames(), " ")+"\" ;;", " esac", " COMPREPLY=($(compgen -W \"${words}\" -- \"${current}\"))", "}", "complete -F _gowdk_complete gowdk") + return strings.Join(lines, "\n") + "\n" +} + +func zshCompletion() string { + var entries []string + for _, spec := range topLevelCommands { + entries = append(entries, fmt.Sprintf("'%s:%s'", spec.Name, shellDescription(spec))) + } + var lines []string + lines = append(lines, "#compdef gowdk", "_arguments '1:command:(("+strings.Join(entries, " ")+"))'") + for _, record := range commandRecords() { + var flags []string + for _, flag := range record.Flags { + flags = append(flags, flag.Name) + } + lines = append(lines, "# gowdk "+strings.Join(record.Path, " ")+": "+strings.Join(flags, " ")) + } + return strings.Join(lines, "\n") + "\n" +} + +func fishCompletion() string { + var lines []string + for _, record := range commandRecords() { + if len(record.Path) == 1 { + lines = append(lines, fmt.Sprintf("complete -c gowdk -n '__fish_use_subcommand' -a %s -d %q", record.Path[0], shellDescription(record.Spec))) + continue + } + parent := strings.Join(record.Path[:len(record.Path)-1], " ") + lines = append(lines, fmt.Sprintf("complete -c gowdk -n '__fish_seen_subcommand_from %s' -a %s -d %q", strings.ReplaceAll(parent, " ", "' '"), record.Path[len(record.Path)-1], shellDescription(record.Spec))) + } + for _, spec := range topLevelCommands { + for _, flag := range commandFlags(spec) { + lines = append(lines, fmt.Sprintf("complete -c gowdk -n '__fish_seen_subcommand_from %s' -l %s", spec.Name, strings.TrimPrefix(flag.Name, "--"))) + } + } + return strings.Join(lines, "\n") + "\n" +} + +func topLevelCommandNames() []string { + names := make([]string, 0, len(topLevelCommands)) + for _, spec := range topLevelCommands { + names = append(names, spec.Name) + } + return names +} + +func shellDescription(spec CommandSpec) string { + if strings.TrimSpace(spec.Summary) != "" { + return strings.ReplaceAll(spec.Summary, "'", "") + } + description := strings.TrimSpace(spec.ListSuffix) + if description == "" { + return spec.Name + " command" + } + return strings.ReplaceAll(description, "'", "") +} + +func commandDocumentationMarkdown() string { + var builder strings.Builder + builder.WriteString("# CLI Command Schema\n\n") + builder.WriteString("This file is generated from `internal/gowdkcmd.CommandSpec`.\n\n") + for _, record := range commandRecords() { + builder.WriteString("## `gowdk " + strings.Join(record.Path, " ") + "`\n\n") + builder.WriteString("```text\n" + record.Spec.Usage() + "\n```\n\n") + } + return strings.TrimRight(builder.String(), "\n") + "\n" +} diff --git a/internal/gowdkcmd/command_schema_test.go b/internal/gowdkcmd/command_schema_test.go new file mode 100644 index 00000000..bec8562a --- /dev/null +++ b/internal/gowdkcmd/command_schema_test.go @@ -0,0 +1,53 @@ +package gowdkcmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCommandSchemaOwnsRecursiveHelpFlagsAndCompletions(t *testing.T) { + seen := map[string]bool{} + for _, record := range commandRecords() { + path := strings.Join(record.Path, " ") + if seen[path] { + t.Fatalf("duplicate command path %q", path) + } + seen[path] = true + if !strings.HasPrefix(record.Spec.Usage(), "usage: gowdk "+path) && len(record.Path) > 1 && record.Path[0] != "list" && record.Path[0] != "playground" { + t.Fatalf("usage for %q drifted from its path: %q", path, record.Spec.Usage()) + } + for _, flag := range record.Flags { + if !strings.Contains(record.Spec.Usage(), flag.Name) { + t.Fatalf("flag %q for %q is absent from canonical usage", flag.Name, path) + } + if flag.Group == "" { + t.Fatalf("flag %q for %q has no group", flag.Name, path) + } + } + } + + outputs := bashCompletion() + zshCompletion() + fishCompletion() + for _, spec := range topLevelCommands { + if !strings.Contains(outputs, spec.Name) { + t.Fatalf("completion output omits command %q", spec.Name) + } + for _, flag := range commandFlags(spec) { + if !strings.Contains(outputs, flag.Name) && !strings.Contains(outputs, strings.TrimPrefix(flag.Name, "--")) { + t.Fatalf("completion output omits %s flag %q", spec.Name, flag.Name) + } + } + } +} + +func TestPublishedCommandSchemaIsCurrent(t *testing.T) { + path := filepath.Join("..", "..", "docs", "reference", "cli-schema.md") + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := string(payload), commandDocumentationMarkdown(); got != want { + t.Fatalf("%s is stale; run scripts/generate-cli-schema.sh", path) + } +} diff --git a/internal/gowdkcmd/config_helper.go b/internal/gowdkcmd/config_helper.go index 051d2eee..8bce6cd5 100644 --- a/internal/gowdkcmd/config_helper.go +++ b/internal/gowdkcmd/config_helper.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/cssbruno/gowdk/internal/project" + "github.com/cssbruno/gowdk/runtime/envfile" ) const ( @@ -59,7 +60,11 @@ func runProjectHelperIfNeeded(args []string) (bool, error) { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), + helperEnvironment, err := projectHelperEnvironment(projectRoot, args) + if err != nil { + return true, err + } + cmd.Env = append(helperEnvironment, helperActiveEnv+"=1", fmt.Sprintf("GOWDK_HELPER_PROTOCOL_MIN=%d", helperProtocolMin), fmt.Sprintf("GOWDK_HELPER_PROTOCOL_MAX=%d", helperProtocolMax), @@ -75,6 +80,31 @@ func runProjectHelperIfNeeded(args []string) (bool, error) { return true, nil } +func projectHelperEnvironment(projectRoot string, args []string) ([]string, error) { + var explicit string + for index := 0; index < len(args); index++ { + if value, next, ok, missing := consumeValueFlag(args, index, "--env-file", true); ok { + if missing { + return nil, errors.New("--env-file requires a value") + } + explicit = value + index = next + } + } + path, isExplicit, err := envfile.LookupPath(projectRoot, explicit) + if err != nil { + return nil, err + } + environment, _, err := envfile.Load(path, isExplicit, os.Environ()) + if err != nil { + if isExplicit { + return nil, fmt.Errorf("load env file %q: %w", path, err) + } + return nil, fmt.Errorf("load discovered env file %q: %w", path, err) + } + return environment.ForSubprocess(os.Environ()), nil +} + func normalizeProjectHelperArgs(args []string) ([]string, error) { cwd, err := os.Getwd() if err != nil { @@ -318,7 +348,7 @@ func ensureProjectHelper(configPath string) (helperPath string, projectRoot stri if err != nil { return "", "", err } - cmd := exec.Command("go", "build", "-mod=mod", "-o", binPath, "./"+filepath.ToSlash(rel)) + cmd := exec.Command("go", "build", "-buildvcs=false", "-mod=mod", "-o", binPath, "./"+filepath.ToSlash(rel)) cmd.Dir = packageInfo.Module.Dir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/internal/gowdkcmd/dev.go b/internal/gowdkcmd/dev.go index 7d6276da..4787a193 100644 --- a/internal/gowdkcmd/dev.go +++ b/internal/gowdkcmd/dev.go @@ -235,21 +235,24 @@ func (state devBuildState) configChanged(change inputChange) bool { } type devServeState struct { - addr string - reload *liveReloadBroker - server *http.Server - staticDir string - process *devRuntimeProcess + addr string + reload *liveReloadBroker + server *http.Server + staticDir string + process *devRuntimeProcess + environment []string } func newDevServeState(addr string) *devServeState { return &devServeState{ - addr: addr, - reload: newLiveReloadBroker(), + addr: addr, + reload: newLiveReloadBroker(), + environment: os.Environ(), } } func (serve *devServeState) apply(state devBuildState, absDir string) error { + serve.environment = state.plan.Options.ProjectEnvironment.ForSubprocess(os.Environ()) if state.runtime.Enabled { return serve.useRuntime(state.runtime) } @@ -279,7 +282,7 @@ func (serve *devServeState) useStatic(absDir string) error { } func (serve *devServeState) useRuntime(runtime devRuntime) error { - if _, err := appgen.BuildBinary(runtime.AppDir, runtime.BinaryPath); err != nil { + if _, err := appgen.BuildBinaryWithOptions(runtime.AppDir, runtime.BinaryPath, appgen.PackagingOptions{Environment: serve.environment}); err != nil { return err } if serve.server != nil && serve.process == nil { @@ -314,6 +317,7 @@ func (serve *devServeState) useRuntime(runtime devRuntime) error { serve.process.stop() serve.process.plan = runtime } + serve.process.environment = append([]string(nil), serve.environment...) return serve.process.restart() } @@ -460,18 +464,19 @@ func freeDevRuntimeAddr() (string, error) { } type devRuntimeProcess struct { - plan devRuntime - addr string - listener net.Listener - mu sync.Mutex - cmd *exec.Cmd - waitDone chan error + plan devRuntime + addr string + listener net.Listener + environment []string + mu sync.Mutex + cmd *exec.Cmd + waitDone chan error } func (process *devRuntimeProcess) restart() error { process.stop() command := exec.Command(process.plan.BinaryPath) - command.Env = append(os.Environ(), "GOWDK_ADDR="+process.addr) + command.Env = append(append([]string(nil), process.environment...), "GOWDK_ADDR="+process.addr) command.Stdout = os.Stdout command.Stderr = os.Stderr if process.listener != nil { diff --git a/internal/gowdkcmd/dev_loop.go b/internal/gowdkcmd/dev_loop.go index 45f9ab83..7a022b27 100644 --- a/internal/gowdkcmd/dev_loop.go +++ b/internal/gowdkcmd/dev_loop.go @@ -13,11 +13,12 @@ import ( "github.com/cssbruno/gowdk" "github.com/cssbruno/gowdk/internal/buildgen" - "github.com/cssbruno/gowdk/internal/compiler" + "github.com/cssbruno/gowdk/internal/contractscan" "github.com/cssbruno/gowdk/internal/discover" "github.com/cssbruno/gowdk/internal/gwdkanalysis" "github.com/cssbruno/gowdk/internal/gwdkir" "github.com/cssbruno/gowdk/internal/lang" + "github.com/cssbruno/gowdk/internal/projectcompile" "github.com/cssbruno/gowdk/internal/viewanalysis" "github.com/cssbruno/gowdk/internal/viewmodel" ) @@ -100,35 +101,47 @@ func buildIncrementalSPALoaded(plan buildOptions, change inputChange) (bool, err timings.counter("incremental_component_changes", incrementalPlan.ComponentChanges) timings.counter("incremental_layout_changes", incrementalPlan.LayoutChanges) timings.counter("incremental_affected_pages", len(incrementalPlan.PageSources)) - var analyzed compiler.AnalyzedProgram - if err := timings.measure("ir_assembly", func() error { - var assembleErr error - analyzed, assembleErr = compiler.AnalyzeProgram(options.Config, app) - return assembleErr + var snapshot projectcompile.Snapshot + var compileDiagnostics projectcompile.Diagnostics + if err := timings.measure("project_compilation", func() error { + var compileErr error + snapshot, compileDiagnostics, compileErr = projectcompile.Compile(options.Config, app, projectcompile.Options{ + ProjectRoot: options.ProjectRoot, Mode: projectcompile.ProjectMode, ScanContracts: true, + }) + return compileErr }); err != nil { fmt.Fprintln(os.Stderr, err) return true, fmt.Errorf("build failed") } - var validated compiler.ValidatedProgram - if err := timings.measure("ir_validation", func() error { - var report compiler.ValidationErrors - validated, report = compiler.ValidateAnalyzedProgramReport(options.Config, analyzed) - if report.HasErrors() { - return report - } - return nil - }); err != nil { - var report compiler.ValidationErrors - if errors.As(err, &report) { - return true, newDevDiagnosticError("build failed", devOverlayDiagnosticsFromCompiler(report)) + if compileDiagnostics.HasErrors() { + overlay := make([]devOverlayDiagnostic, 0, len(compileDiagnostics)) + for _, diagnostic := range compileDiagnostics { + item := devOverlayDiagnostic{Code: diagnostic.Code, Severity: diagnostic.Severity, File: diagnostic.Source, Message: diagnostic.Message} + if diagnostic.Line > 0 { + item.Range = &devOverlayRange{Start: devOverlayPosition{Line: diagnostic.Line, Column: diagnostic.Column}} + } + overlay = append(overlay, item) } - fmt.Fprintln(os.Stderr, err) - return true, fmt.Errorf("build failed") + return true, newDevDiagnosticError("build failed", overlay) } + validated := snapshot.Validated var result buildgen.Result if err := timings.measure("output_plan_writes", func() error { - var buildErr error - result, buildErr = buildgen.BuildIncrementalFromValidatedProgram(options.Config, validated, outputDir, incrementalPlan.PageSources) + outputPlan, buildErr := buildgen.PlanBuildFromValidatedProgram(options.Config, validated, outputDir) + if buildErr != nil { + return buildErr + } + asyncAPI, buildErr := contractscan.AsyncAPIPayload(snapshot.Contracts, contractscan.AsyncAPIOptions{}) + if buildErr != nil { + return buildErr + } + if buildErr = outputPlan.AddOutputFile(contractscan.AsyncAPIFile, asyncAPI); buildErr != nil { + return buildErr + } + outputPlan.SetPrePublishValidation(func(staged buildgen.Result) error { + return enforceFinalBuildArtifactSecurityAudit(options, staged) + }) + result, buildErr = buildgen.BuildFromPlan(outputPlan) return buildErr }); err != nil { printBuildgenBuildErrorReport(err, options.Debug) @@ -284,9 +297,10 @@ type devComponentHMREntry struct { } const ( - devUpdateProtocolVersion = 1 + devUpdateProtocolVersion = 2 devUpdateActionReload = "reload" devUpdateActionComponentRemount = "component-remount" + devUpdateActionDocumentPatch = "patch" ) func devReloadPayload(reason string) string { @@ -354,17 +368,21 @@ func devIncrementalSPAUpdateLoaded(plan buildOptions, change inputChange) (devIn if diagnostics.HasErrors() { return devIncrementalSPAUpdate{}, false } + typedProgram := gwdkanalysis.BuildProgram(options.Config, app) + if len(typedProgram.Diagnostics) > 0 { + return devIncrementalSPAUpdate{}, false + } incrementalPlan, incremental := changedIncrementalSPAPages(app, change.Changed) if !incremental { return devIncrementalSPAUpdate{}, false } componentsByKey := map[string]gwdkir.Component{} - for _, component := range app.Components { + for _, component := range typedProgram.Components { componentsByKey[sourceComponentKey(component.Package, component.Name)] = component } pagesBySource := map[string]gwdkir.Page{} - for _, page := range app.Pages { + for _, page := range typedProgram.Pages { pagesBySource[page.Source] = page } @@ -386,13 +404,20 @@ func devComponentHMRPayloadLoaded(plan buildOptions, change inputChange) (string Action: devUpdateActionComponentRemount, Preserve: []string{"page-stores"}, } + remountDocument := false + reloadDocument := false for _, key := range update.plan.ComponentKeys { component, ok := update.componentsByKey[key] if !ok { return "", false } - if strings.TrimSpace(component.WASM.Package) != "" { - return "", false + if len(component.JS) > 0 || len(component.InlineJS) > 0 || len(component.Assets) > 0 { + reloadDocument = true + continue + } + if strings.TrimSpace(component.WASM.Package) != "" || len(component.CSS) > 0 { + remountDocument = true + continue } entry := devComponentHMREntry{ Name: component.Name, @@ -405,6 +430,16 @@ func devComponentHMRPayloadLoaded(plan buildOptions, change inputChange) (string payload.Components = append(payload.Components, entry) } payload.Routes = devRoutesForPageSources(update.pagesBySource, update.plan.PageSources) + if reloadDocument { + payload.Action = devUpdateActionReload + payload.Reason = "component-assets-changed" + payload.Preserve = nil + payload.Components = nil + } else if remountDocument { + payload.Action = devUpdateActionDocumentPatch + payload.Reason = "component-wasm-remount" + payload.Preserve = []string{"page-stores"} + } sort.Slice(payload.Components, func(i, j int) bool { if payload.Components[i].ID == payload.Components[j].ID { return payload.Components[i].Name < payload.Components[j].Name @@ -412,7 +447,7 @@ func devComponentHMRPayloadLoaded(plan buildOptions, change inputChange) (string return payload.Components[i].ID < payload.Components[j].ID }) encoded, ok := marshalDevUpdatePayload(payload) - return encoded, ok && len(payload.Components) > 0 && len(payload.Routes) > 0 + return encoded, ok && (len(payload.Components) > 0 || remountDocument || reloadDocument) && len(payload.Routes) > 0 } func devComponentStateShape(component gwdkir.Component) string { @@ -424,7 +459,9 @@ func devComponentStateShape(component gwdkir.Component) string { state := shape{ StateType: component.State.Type.Alias + "." + component.State.Type.Name, StateInit: component.State.Init.Alias + "." + component.State.Init.Name, - Client: strings.TrimSpace(component.Blocks.ClientBody), + } + if component.Blocks.ClientProgram != nil { + state.Client = component.Blocks.ClientProgram.Canonical() } if state.StateType == "." && state.StateInit == "." && state.Client == "" { return "" @@ -439,7 +476,7 @@ func devComponentStateShape(component gwdkir.Component) string { func devRouteReloadPayloadLoaded(plan buildOptions, change inputChange) (string, bool) { update, ok := devIncrementalSPAUpdateLoaded(plan, change) - if !ok || update.plan.LayoutChanges == 0 || update.plan.PageChanges != 0 || update.plan.ComponentChanges != 0 { + if !ok || update.plan.ComponentChanges != 0 || (update.plan.LayoutChanges == 0 && update.plan.PageChanges == 0) { return "", false } routes := devRoutesForPageSources(update.pagesBySource, update.plan.PageSources) @@ -447,13 +484,21 @@ func devRouteReloadPayloadLoaded(plan buildOptions, change inputChange) (string, return "", false } return marshalDevUpdatePayload(devComponentHMRPayload{ - Version: devUpdateProtocolVersion, - Action: devUpdateActionReload, - Reason: "route-scoped-layout", - Routes: routes, + Version: devUpdateProtocolVersion, + Action: devUpdateActionDocumentPatch, + Reason: devDocumentPatchReason(update.plan), + Routes: routes, + Preserve: []string{"page-stores", "compatible-island-state"}, }) } +func devDocumentPatchReason(plan incrementalSPAChangePlan) string { + if plan.PageChanges > 0 { + return "route-scoped-page" + } + return "route-scoped-layout" +} + func devRoutesForPageSources(pagesBySource map[string]gwdkir.Page, sources []string) []string { seenRoutes := map[string]bool{} var routes []string @@ -527,7 +572,7 @@ func newIncrementalDependencyIndex(app gwdkanalysis.Sources) (incrementalDepende func pageComponentDependencies(page gwdkir.Page, components map[string]gwdkir.Component, layouts map[string]gwdkir.Layout) map[string]bool { seen := map[string]bool{} - collectComponentDependenciesFromView(page.Package, page.Uses, page.Blocks.ViewBody, page.Blocks.ViewNodes, components, seen) + collectComponentDependenciesFromView(page.Package, page.Uses, page.Blocks.ViewNodes, components, seen) for _, ref := range page.Layouts { if layout, ok := resolvePageLayoutDependency(page.Package, page.Uses, ref, layouts); ok { collectLayoutComponentDependencies(layout, layouts, components, map[string]bool{}, seen) @@ -536,17 +581,8 @@ func pageComponentDependencies(page gwdkir.Page, components map[string]gwdkir.Co return seen } -func collectComponentDependenciesFromView(ownerPackage string, uses []gwdkir.Use, viewBody string, viewNodes []viewmodel.Node, components map[string]gwdkir.Component, seen map[string]bool) { - var refs []string - if len(viewNodes) > 0 { - refs = viewanalysis.ComponentReferencesFromNodes(viewNodes) - } else { - var err error - refs, err = viewanalysis.ComponentReferences(viewBody) - if err != nil { - return - } - } +func collectComponentDependenciesFromView(ownerPackage string, uses []gwdkir.Use, viewNodes []viewmodel.Node, components map[string]gwdkir.Component, seen map[string]bool) { + refs := viewanalysis.ComponentReferencesFromNodes(viewNodes) for _, ref := range refs { if component, ok := resolveComponentRef(ownerPackage, uses, ref, components); ok { collectComponentDependencies(component, components, seen) @@ -560,7 +596,7 @@ func collectLayoutComponentDependencies(layout gwdkir.Layout, layouts map[string return } seenLayouts[key] = true - collectComponentDependenciesFromView(layout.Package, layout.Uses, layout.Blocks.ViewBody, layout.Blocks.ViewNodes, components, seenComponents) + collectComponentDependenciesFromView(layout.Package, layout.Uses, layout.Blocks.ViewNodes, components, seenComponents) for _, ref := range layout.Layouts { if parent, ok := resolveLayoutDependency(layout.Package, layout.Uses, ref, layouts); ok { collectLayoutComponentDependencies(parent, layouts, components, seenLayouts, seenComponents) @@ -574,16 +610,7 @@ func collectComponentDependencies(component gwdkir.Component, components map[str return } seen[key] = true - var refs []string - if len(component.Blocks.ViewNodes) > 0 { - refs = viewanalysis.ComponentReferencesFromNodes(component.Blocks.ViewNodes) - } else { - var err error - refs, err = viewanalysis.ComponentReferences(component.Blocks.ViewBody) - if err != nil { - return - } - } + refs := viewanalysis.ComponentReferencesFromNodes(component.Blocks.ViewNodes) for _, ref := range refs { if child, ok := resolveComponentRef(component.Package, component.Uses, ref, components); ok { collectComponentDependencies(child, components, seen) diff --git a/internal/gowdkcmd/env.go b/internal/gowdkcmd/env.go index 38dea87c..6467c268 100644 --- a/internal/gowdkcmd/env.go +++ b/internal/gowdkcmd/env.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "strings" "github.com/cssbruno/gowdk" @@ -69,7 +68,7 @@ func envCommand(args []string) error { if err := loadProjectConfig(&options, configPath); err != nil { return err } - validationErr := project.ValidateRuntimeEnvironment(options.Config, os.LookupEnv) + validationErr := project.ValidateRuntimeEnvironment(options.Config, options.ProjectEnvironment.Lookup) report := newEnvCheckReport(options, validationErr) if jsonOutput { payload, err := json.MarshalIndent(report, "", " ") diff --git a/internal/gowdkcmd/go_bindings_report.go b/internal/gowdkcmd/go_bindings_report.go index ac327de0..e74ce5a6 100644 --- a/internal/gowdkcmd/go_bindings_report.go +++ b/internal/gowdkcmd/go_bindings_report.go @@ -4,8 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "go/ast" - "go/parser" "os/exec" "path/filepath" "sort" @@ -51,6 +49,7 @@ type goBindingInputFieldJSON struct { func buildGoBindingsReport(config gowdk.Config, ir gwdkir.Program) goBindingsReport { var bindings []goBindingJSON + bindings = append(bindings, interopGoBindings(config)...) metadata := compiler.BuildRouteMetadataFromIR(config, ir) inputFields := goBindingInputFieldsByEndpoint(ir) for _, endpoint := range metadata.Endpoints { @@ -89,6 +88,24 @@ func buildGoBindingsReport(config gowdk.Config, ir gwdkir.Program) goBindingsRep return goBindingsReport{Version: 1, Bindings: bindings} } +func interopGoBindings(config gowdk.Config) []goBindingJSON { + var bindings []goBindingJSON + appendHook := func(kind, signature string, ref gowdk.GoHookRef) { + if ref.ImportPath == "" { + return + } + bindings = append(bindings, goBindingJSON{ + Kind: kind, Source: ref.SourceFile, Symbol: ref.Function, + ExpectedSymbol: ref.Function, PackagePath: ref.ImportPath, + Status: "bound", Signature: signature, + Message: "explicit typed Config.Interop registration", + }) + } + appendHook("guards", "func() guard.Registry", config.Interop.Guards.Hook) + appendHook("auth_provider", "func() auth.Provider", config.Interop.AuthProvider.Hook) + return bindings +} + func goBindingInputFieldsByEndpoint(ir gwdkir.Program) map[string][]source.BackendInputField { out := map[string][]source.BackendInputField{} for _, endpoint := range ir.Endpoints { @@ -192,22 +209,7 @@ func buildDataGoBindings(page gwdkir.Page) []goBindingJSON { } ref, ok := goBindingBuildDataCall(page) if !ok { - var err error - ref, ok, err = parseGoBindingBuildDataCall(page.Blocks.BuildBody) - if err != nil { - return []goBindingJSON{{ - Kind: "build", - Source: page.Source, - SourceSpan: endpointSourceSpanJSON(page.Blocks.Spans.Build), - Package: page.Package, - PageID: page.ID, - Status: "invalid", - Message: err.Error(), - }} - } - if !ok { - return nil - } + return nil } binding := goBindingJSON{ Kind: "build", @@ -259,53 +261,6 @@ type goBindingBuildCallRef struct { Function string } -func parseGoBindingBuildDataCall(body string) (goBindingBuildCallRef, bool, error) { - lines := goBindingSignificantLines(body) - if len(lines) != 1 { - return goBindingBuildCallRef{}, false, nil - } - expr, ok := strings.CutPrefix(strings.TrimSpace(lines[0]), "=>") - if !ok { - return goBindingBuildCallRef{}, false, nil - } - expr = strings.TrimSpace(expr) - if strings.HasPrefix(expr, "{") { - return goBindingBuildCallRef{}, false, nil - } - parsed, err := parser.ParseExpr(expr) - if err != nil { - return goBindingBuildCallRef{}, true, fmt.Errorf("parse build call: %w", err) - } - call, ok := parsed.(*ast.CallExpr) - if !ok || len(call.Args) != 0 { - return goBindingBuildCallRef{}, false, nil - } - switch fun := call.Fun.(type) { - case *ast.Ident: - return goBindingBuildCallRef{Function: fun.Name}, true, nil - case *ast.SelectorExpr: - alias, ok := fun.X.(*ast.Ident) - if !ok { - return goBindingBuildCallRef{}, true, fmt.Errorf("build data call receiver must be an import alias") - } - return goBindingBuildCallRef{Alias: alias.Name, Function: fun.Sel.Name}, true, nil - default: - return goBindingBuildCallRef{}, false, nil - } -} - -func goBindingSignificantLines(body string) []string { - var lines []string - for _, line := range strings.Split(body, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - lines = append(lines, line) - } - return lines -} - func findGoBindingImport(imports []gwdkir.Import, alias string) (gwdkir.Import, bool) { for _, item := range imports { if item.Alias == alias { diff --git a/internal/gowdkcmd/go_bindings_report_test.go b/internal/gowdkcmd/go_bindings_report_test.go new file mode 100644 index 00000000..829c141a --- /dev/null +++ b/internal/gowdkcmd/go_bindings_report_test.go @@ -0,0 +1,26 @@ +package gowdkcmd + +import ( + "testing" + + "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/internal/gwdkir" + fixture "github.com/cssbruno/gowdk/testfixture/interop" +) + +func TestGoBindingsReportIncludesTypedInteropProviders(t *testing.T) { + config := gowdk.Config{Interop: gowdk.InteropConfig{ + Guards: gowdk.RegisterGuards(fixture.Guards), + AuthProvider: gowdk.RegisterAuthProvider(fixture.AuthProvider), + }} + report := buildGoBindingsReport(config, gwdkir.Program{}) + for _, expected := range []struct { + kind string + symbol string + }{{"guards", "Guards"}, {"auth_provider", "AuthProvider"}} { + binding, ok := findGoBinding(report.Bindings, expected.kind, expected.symbol) + if !ok || binding.Status != "bound" || binding.PackagePath != "github.com/cssbruno/gowdk/testfixture/interop" || binding.Source == "" { + t.Fatalf("missing inspectable %s provider: %#v", expected.kind, report.Bindings) + } + } +} diff --git a/internal/gowdkcmd/main.go b/internal/gowdkcmd/main.go index 07011be4..61cba939 100644 --- a/internal/gowdkcmd/main.go +++ b/internal/gowdkcmd/main.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/cssbruno/gowdk" + "github.com/cssbruno/gowdk/runtime/envfile" ) const version = "0.12.3" // x-release-please-version @@ -79,35 +80,14 @@ func commandHelpRequested(args []string) bool { } func nestedCommandUsage(args []string) (string, bool) { - if len(args) != 3 || (args[2] != "-h" && args[2] != "--help") { + if len(args) < 3 || (args[len(args)-1] != "-h" && args[len(args)-1] != "--help") { return "", false } - switch args[0] { - case "inspect": - switch args[1] { - case "ir", "tree", "endpoint-graph", "asset-graph", "go-bindings": - return fmt.Sprintf("usage: gowdk inspect %s [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...]", args[1]), true - } - case "generate": - if args[1] == "stubs" { - return generateUsage, true - } - case "env": - if args[1] == "check" { - return envUsage, true - } - case "list": - switch args[1] { - case "commands", "queries", "events", "jobs": - return "usage: gowdk list commands|queries|events|jobs [--json] [dir]", true - } - case "playground": - switch args[1] { - case "policy", "export", "run": - return playgroundUsage, true - } + descriptor, ok := commandSpec(args[:len(args)-1]) + if !ok || len(args) == 2 { + return "", false } - return "", false + return descriptor.Usage(), true } func commandUsage(command string) (string, bool) { @@ -117,56 +97,87 @@ func commandUsage(command string) (string, bool) { return "", false } -type topLevelCommandDescriptor struct { +type CommandSpec struct { Name string Handler func([]string) error Usage func() string ListSuffix string + Summary string + Children []CommandSpec } func staticCommandUsage(value string) func() string { return func() string { return value } } -var topLevelCommands = []topLevelCommandDescriptor{ - {Name: "version", Handler: printVersion, Usage: staticCommandUsage("usage: gowdk version [--json]"), ListSuffix: " [--json] print CLI version"}, - {Name: "init", Handler: initProject, Usage: staticCommandUsage(initUsage), ListSuffix: " [--force] [--tests] [--template ] [dir] scaffold a starter GOWDK project"}, - {Name: "add", Handler: addAddon, Usage: staticCommandUsage(addUsage), ListSuffix: " [--config ] [--base-url ] | add --list [--registry] [--json] wire or list addons"}, - {Name: "tokens", Handler: tokens, Usage: staticCommandUsage("usage: gowdk tokens "), ListSuffix: " print language tokens"}, - {Name: "fmt", Handler: format, Usage: staticCommandUsage("usage: gowdk fmt [--write] [--check] "), ListSuffix: " [--write] [--check] format .gwdk files (--check reports files that need formatting)"}, - {Name: "check", Handler: check, Usage: func() string { return projectCommandUsage("check", true) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--warnings-as-errors] [--standalone] [--ssr] [files...] parse and validate .gwdk files"}, - {Name: "env", Handler: envCommand, Usage: staticCommandUsage(envUsage), ListSuffix: " check [--config ] [--env-file ] [--json] validate deployment environment values"}, - {Name: "fix", Handler: fixCommand, Usage: staticCommandUsage(fixUsage), ListSuffix: " [--dry-run] [--code ] [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] apply registered safe diagnostic fixes"}, - {Name: "manifest", Handler: manifestJSON, Usage: func() string { return projectCommandUsage("manifest", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print validated manifest JSON"}, - {Name: "sitemap", Handler: siteMapJSON, Usage: func() string { return projectCommandUsage("sitemap", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print editor site-map JSON"}, - {Name: "routes", Handler: routesJSON, Usage: func() string { return projectCommandUsage("routes", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print route and endpoint metadata JSON"}, - {Name: "endpoints", Handler: endpointsJSONCommand, Usage: func() string { return projectCommandUsage("endpoints", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print endpoint metadata JSON"}, - {Name: "inspect", Handler: inspect, Usage: staticCommandUsage(inspectUsage), ListSuffix: " ir|tree|endpoint-graph|asset-graph|go-bindings [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] print validated compiler inspection JSON"}, - {Name: "generate", Handler: generate, Usage: staticCommandUsage(generateUsage), ListSuffix: " stubs [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] write missing action/API Go handler stubs"}, - {Name: "explain", Handler: explainDiagnostic, Usage: staticCommandUsage("usage: gowdk explain [--json] "), ListSuffix: " [--json] explain a diagnostic code and next steps"}, - {Name: "doctor", Handler: doctor, Usage: staticCommandUsage(doctorUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [files...] check local GOWDK environment and project health"}, - {Name: "test", Handler: gowdkTest, Usage: staticCommandUsage(testUsage), ListSuffix: " [--config ] [--env-file ] [--module ] [--target ] [--stage ] [--run ] [--timeout ] [--count ] [--cover] [--json] [--keep-workdir] [--browser-command ] [--ssr] [files...] run Go tests against generated app artifacts"}, - {Name: "audit", Handler: audit, Usage: staticCommandUsage(auditUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [--sarif[=]] [--diff ] [--schema[=report|security]] [--emit-tests[=]] [--force] [--run] [files...] check security posture, emit SARIF/JSON-Schema, diff against a previous report, and run optional runtime tests"}, - {Name: "contracts", Handler: contractsReport, Usage: staticCommandUsage("usage: gowdk contracts [--json] [dir]"), ListSuffix: " [--json] [dir] print Go contract registration metadata"}, - {Name: "graph", Handler: contractGraph, Usage: staticCommandUsage("usage: gowdk graph [--json] [dir]"), ListSuffix: " [--json] [dir] print command/event contract graph"}, - {Name: "trace", Handler: contractTrace, Usage: staticCommandUsage("usage: gowdk trace [--json] [dir]"), ListSuffix: " [--json] [dir] print one command/query/event/job contract trace"}, - {Name: "list", Handler: listContracts, Usage: staticCommandUsage("usage: gowdk list commands|queries|events|jobs [--json] [dir]"), ListSuffix: " commands|queries|events|jobs [--json] [dir] print filtered contract metadata"}, - {Name: "build", Handler: build, Usage: staticCommandUsage(buildUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--debug] [--timings[=]] [--ssr] [--allow-missing-backend] [--allow-insecure] [--obfuscate-assets] [--target ] [--module ] [--out ] [--app ] [--bin ] [--docker] [--docker-base ] [--deploy-recipe ] [--wasm ] [--backend-app ] [--backend-bin ] [files...] compile .gwdk files into build output"}, - {Name: "clean", Handler: clean, Usage: staticCommandUsage(cleanUsage), ListSuffix: " [--config ] [--target ] [--out ] [--dry-run] [--json] remove configured build outputs"}, - {Name: "dev", Handler: dev, Usage: devUsage, ListSuffix: " [--addr ] [--interval ] [build flags...] build, serve, rebuild, and live reload"}, - {Name: "preview", Handler: preview, Usage: previewUsage, ListSuffix: " [--addr ] [--hot] [build flags...] build and serve a local deploy preview"}, - {Name: "playground", Handler: playgroundCommand, Usage: staticCommandUsage(playgroundUsage), ListSuffix: " policy|export|run inspect sandbox policy, export projects, or run an opt-in sandbox build"}, - {Name: "serve", Handler: serve, Usage: staticCommandUsage("usage: gowdk serve --dir [--addr ]"), ListSuffix: " --dir [--addr ] serve generated build output locally"}, - {Name: "lsp", Handler: languageServer, Usage: staticCommandUsage(lspUsage), ListSuffix: " [--config ] [--project-root ] [--module ] [--ssr] start the language server over stdio"}, -} - -func topLevelCommand(name string) (topLevelCommandDescriptor, bool) { +var topLevelCommands []CommandSpec + +func init() { + topLevelCommands = []CommandSpec{ + {Name: "version", Handler: printVersion, Usage: staticCommandUsage("usage: gowdk version [--json]"), ListSuffix: " [--json] print CLI version"}, + {Name: "init", Handler: initProject, Usage: staticCommandUsage(initUsage), ListSuffix: " [--force] [--tests] [--template ] [dir] scaffold a starter GOWDK project"}, + {Name: "add", Handler: addAddon, Usage: staticCommandUsage(addUsage), ListSuffix: " [--config ] [--base-url ] | add --list [--registry] [--json] wire or list addons"}, + {Name: "tokens", Handler: tokens, Usage: staticCommandUsage("usage: gowdk tokens "), ListSuffix: " print language tokens"}, + {Name: "fmt", Handler: format, Usage: staticCommandUsage("usage: gowdk fmt [--write] [--check] "), ListSuffix: " [--write] [--check] format .gwdk files (--check reports files that need formatting)"}, + {Name: "check", Handler: check, Usage: func() string { return projectCommandUsage("check", true) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--warnings-as-errors] [--standalone] [--ssr] [files...] parse and validate .gwdk files"}, + {Name: "env", Handler: envCommand, Usage: staticCommandUsage(envUsage), ListSuffix: " check [--config ] [--env-file ] [--json] validate deployment environment values", Children: []CommandSpec{{Name: "check", Usage: staticCommandUsage(envUsage), Summary: "validate deployment environment values"}}}, + {Name: "fix", Handler: fixCommand, Usage: staticCommandUsage(fixUsage), ListSuffix: " [--dry-run] [--code ] [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] apply registered safe diagnostic fixes"}, + {Name: "manifest", Handler: manifestJSON, Usage: func() string { return projectCommandUsage("manifest", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print validated manifest JSON"}, + {Name: "sitemap", Handler: siteMapJSON, Usage: func() string { return projectCommandUsage("sitemap", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print editor site-map JSON"}, + {Name: "routes", Handler: routesJSON, Usage: func() string { return projectCommandUsage("routes", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print route and endpoint metadata JSON"}, + {Name: "endpoints", Handler: endpointsJSONCommand, Usage: func() string { return projectCommandUsage("endpoints", false) }, ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] print endpoint metadata JSON"}, + {Name: "inspect", Handler: inspect, Usage: staticCommandUsage(inspectUsage), ListSuffix: " ir|tree|endpoint-graph|asset-graph|go-bindings [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] print validated compiler inspection JSON", Children: inspectCommandSpecs()}, + {Name: "generate", Handler: generate, Usage: staticCommandUsage(generateUsage), ListSuffix: " stubs [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] write missing action/API Go handler stubs", Children: []CommandSpec{{Name: "stubs", Usage: staticCommandUsage(generateUsage), Summary: "write missing Go handler stubs"}}}, + {Name: "explain", Handler: explainDiagnostic, Usage: staticCommandUsage("usage: gowdk explain [--json] "), ListSuffix: " [--json] explain a diagnostic code and next steps"}, + {Name: "doctor", Handler: doctor, Usage: staticCommandUsage(doctorUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [files...] check local GOWDK environment and project health"}, + {Name: "test", Handler: gowdkTest, Usage: staticCommandUsage(testUsage), ListSuffix: " [--config ] [--env-file ] [--module ] [--target ] [--stage ] [--run ] [--timeout ] [--count ] [--cover] [--json] [--keep-workdir] [--browser-command ] [--ssr] [files...] run Go tests against generated app artifacts"}, + {Name: "audit", Handler: audit, Usage: staticCommandUsage(auditUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [--sarif[=]] [--diff ] [--schema[=report|security]] [--emit-tests[=]] [--force] [--run] [files...] check security posture, emit SARIF/JSON-Schema, diff against a previous report, and run optional runtime tests"}, + {Name: "contracts", Handler: contractsReport, Usage: staticCommandUsage("usage: gowdk contracts [--json] [dir]"), ListSuffix: " [--json] [dir] print Go contract registration metadata"}, + {Name: "graph", Handler: contractGraph, Usage: staticCommandUsage("usage: gowdk graph [--json] [dir]"), ListSuffix: " [--json] [dir] print command/event contract graph"}, + {Name: "trace", Handler: contractTrace, Usage: staticCommandUsage("usage: gowdk trace [--json] [dir]"), ListSuffix: " [--json] [dir] print one command/query/event/job contract trace"}, + {Name: "list", Handler: listContracts, Usage: staticCommandUsage("usage: gowdk list commands|queries|events|jobs [--json] [dir]"), ListSuffix: " commands|queries|events|jobs [--json] [dir] print filtered contract metadata", Children: contractListCommandSpecs()}, + {Name: "build", Handler: build, Usage: staticCommandUsage(buildUsage), ListSuffix: " [--config ] [--project-root ] [--env-file ] [--debug] [--timings[=]] [--ssr] [--allow-missing-backend] [--allow-insecure] [--obfuscate-assets] [--target ] [--module ] [--out ] [--app ] [--bin ] [--docker] [--docker-base ] [--deploy-recipe ] [--wasm ] [--backend-app ] [--backend-bin ] [files...] compile .gwdk files into build output"}, + {Name: "clean", Handler: clean, Usage: staticCommandUsage(cleanUsage), ListSuffix: " [--config ] [--target ] [--out ] [--dry-run] [--json] remove configured build outputs"}, + {Name: "dev", Handler: dev, Usage: devUsage, ListSuffix: " [--addr ] [--interval ] [build flags...] build, serve, rebuild, and live reload"}, + {Name: "preview", Handler: preview, Usage: previewUsage, ListSuffix: " [--addr ] [--hot] [build flags...] build and serve a local deploy preview"}, + {Name: "playground", Handler: playgroundCommand, Usage: staticCommandUsage(playgroundUsage), ListSuffix: " policy|export|run inspect sandbox policy, export projects, or run an opt-in sandbox build", Children: playgroundCommandSpecs()}, + {Name: "serve", Handler: serve, Usage: staticCommandUsage("usage: gowdk serve --dir [--addr ]"), ListSuffix: " --dir [--addr ] serve generated build output locally"}, + {Name: "lsp", Handler: languageServer, Usage: staticCommandUsage(lspUsage), ListSuffix: " [--config ] [--project-root ] [--module ] [--ssr] start the language server over stdio"}, + {Name: "completion", Handler: completionCommand, Usage: staticCommandUsage(completionUsage), ListSuffix: " generate shell completion from the command schema", Children: completionCommandSpecs()}, + } +} + +func topLevelCommand(name string) (CommandSpec, bool) { for _, descriptor := range topLevelCommands { if descriptor.Name == name { return descriptor, true } } - return topLevelCommandDescriptor{}, false + return CommandSpec{}, false +} + +func commandSpec(path []string) (CommandSpec, bool) { + if len(path) == 0 { + return CommandSpec{}, false + } + current, ok := topLevelCommand(path[0]) + if !ok { + return CommandSpec{}, false + } + for _, name := range path[1:] { + found := false + for _, child := range current.Children { + if child.Name == name { + current = child + found = true + break + } + } + if !found { + return CommandSpec{}, false + } + } + return current, true } func printVersion(args []string) error { @@ -217,6 +228,7 @@ type cliOptions struct { EnvFileExplicit bool EnvFileApplied []string EnvFileSkipped []string + ProjectEnvironment envfile.Environment } func appendModuleNames(moduleNames []string, value string) []string { diff --git a/internal/gowdkcmd/main_test.go b/internal/gowdkcmd/main_test.go index 52483b7a..2f5da649 100644 --- a/internal/gowdkcmd/main_test.go +++ b/internal/gowdkcmd/main_test.go @@ -2,6 +2,7 @@ package gowdkcmd import ( "archive/zip" + "context" "encoding/json" "errors" "fmt" @@ -16,6 +17,7 @@ import ( "runtime" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -222,6 +224,9 @@ view { if strings.TrimSpace(stdout) != "ok" || stderr != "" { t.Fatalf("unexpected check output stdout=%q stderr=%q", stdout, stderr) } + if _, exists := os.LookupEnv(secretName); exists { + t.Fatalf("project env loading must not mutate the CLI process") + } } func TestCheckCommandKeepsProcessEnvOverEnvFile(t *testing.T) { @@ -2884,7 +2889,8 @@ view { component Brand client { - func Toggle() {} + fn Toggle() { + } } view { @@ -2938,6 +2944,49 @@ func TestDevReloadPayloadUsesVersionedReloadAction(t *testing.T) { } } +func TestDevWASMComponentUpdateUsesStatelessDocumentPatch(t *testing.T) { + root := t.TempDir() + page := filepath.Join(root, "home.page.gwdk") + component := filepath.Join(root, "counter.cmp.gwdk") + config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, page, `package app + +page home +route "/" + +view { +
    +} +`) + writeCLIFile(t, component, `package app + +component Counter +wasm ./browser/counter + +view { + +} +`) + plan, err := loadBuildOptions([]string{"--config", config, "--out", filepath.Join(root, "dist"), page, component}) + if err != nil { + t.Fatal(err) + } + payload, ok := devComponentHMRPayloadLoaded(plan, inputChange{Changed: []string{component}}) + if !ok { + t.Fatal("expected WASM component update payload") + } + var decoded devComponentHMRPayload + if err := json.Unmarshal([]byte(payload), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Action != devUpdateActionDocumentPatch || decoded.Reason != "component-wasm-remount" { + t.Fatalf("unexpected WASM update: %#v", decoded) + } + if strings.Join(decoded.Preserve, ",") != "page-stores" || len(decoded.Components) != 0 { + t.Fatalf("WASM state must not be transferred: %#v", decoded) + } +} + func TestDevServeNotifyReloadUsesDevUpdateEvent(t *testing.T) { broker := newLiveReloadBroker() client := make(chan liveReloadEvent, 1) @@ -3013,7 +3062,7 @@ view { if err := json.Unmarshal([]byte(payload), &decoded); err != nil { t.Fatalf("invalid route reload payload JSON: %v\n%s", err, payload) } - if decoded.Version != devUpdateProtocolVersion || decoded.Action != devUpdateActionReload || decoded.Reason != "route-scoped-layout" { + if decoded.Version != devUpdateProtocolVersion || decoded.Action != devUpdateActionDocumentPatch || decoded.Reason != "route-scoped-layout" { t.Fatalf("unexpected route reload protocol fields: %#v", decoded) } if strings.Join(decoded.Routes, ",") != "/" { @@ -5125,7 +5174,7 @@ func TestRoutesCommandPrintsSSRRouteKind(t *testing.T) { page dashboard route "/dashboard" -server { +go server { } view { @@ -5756,6 +5805,25 @@ func TestInspectGoBindingsCommandPrintsBindingReport(t *testing.T) { writeCLITestModule(t, root, "example.com/gowdk-go-bindings") page := filepath.Join(root, "pages", "dashboard.page.gwdk") config := writeMinimalCLIConfig(t, root) + writeCLIFile(t, config, `package app + +import ( + "github.com/cssbruno/gowdk" + pages "example.com/gowdk-go-bindings/pages" +) + +var Config = gowdk.Config{Interop: gowdk.InteropConfig{Loads: []gowdk.LoadRegistration{ + gowdk.RegisterLoad("dashboard", pages.LoadDashboard), +}}} +`) + writeCLIFile(t, filepath.Join(root, "pages", "load.go"), `package pages + +import "github.com/cssbruno/gowdk/runtime/ssr" + +func LoadDashboard(ssr.LoadContext) (map[string]any, error) { + return map[string]any{"user": map[string]string{"name": "Ada"}}, nil +} +`) writeCLIFile(t, page, `package pages page dashboard @@ -5844,7 +5912,7 @@ func LoadPatientPage(ctx context.Context, query GetPatientPage) (PatientPageData t.Fatalf("unexpected go-bindings version: %d", report.Version) } assertGoBinding(t, report.Bindings, "build", "FeaturedCopyWithErrorForBuild", "unverified") - assertGoBinding(t, report.Bindings, "load", "LoadDashboard", "missing") + assertGoBinding(t, report.Bindings, "load", "LoadDashboard", "bound") assertGoBinding(t, report.Bindings, "action", "Save", "missing") assertGoBinding(t, report.Bindings, "api", "Session", "missing") assertGoBinding(t, report.Bindings, "fragment", "Summary", "unknown") @@ -6093,8 +6161,8 @@ server { view {
    - {issue.title} -
    + {issue.title} +
    } `) @@ -7853,9 +7921,13 @@ func TestLiveReloadFileHandlerInjectsScript(t *testing.T) { `events.addEventListener("dev-update"`, `events.addEventListener("component-hmr"`, `gowdk:dev-update`, - `DEV_UPDATE_VERSION = 1`, + `DEV_UPDATE_VERSION = 2`, `carryCompatibleIslandState`, + `carryDocumentIslandState`, `component-remount`, + `payload.action === "patch"`, + `gowdk:page-hmr`, + `focusTarget.focus`, `payload.action === "reload"`, `routes.some((route) => pathMatchesRoute(route, window.location.pathname))`, `fetchFreshDocument`, @@ -7870,6 +7942,120 @@ func TestLiveReloadFileHandlerInjectsScript(t *testing.T) { } } +func TestDevHMRDocumentPatchPreservesCompatibleStateAndCleansStaleDOMInBrowser(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is not installed") + } + chromium := "" + for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + chromium = candidate + break + } + } + if chromium == "" { + t.Skip("chromium is not installed") + } + probe := exec.Command(node, "-e", `require("node:module").createRequire(process.cwd()+"/gowdk-test.js").resolve("playwright")`) + probe.Dir, _ = os.Getwd() + if output, probeErr := probe.CombinedOutput(); probeErr != nil { + t.Skipf("playwright is not installed: %v\n%s", probeErr, output) + } + + reload := newLiveReloadBroker() + var fresh atomic.Bool + handler := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/__gowdk/reload": + reload.serve(w, request) + return + case "/ready": + reload.mu.Lock() + ready := len(reload.clients) > 0 + reload.mu.Unlock() + if ready { + _, _ = io.WriteString(w, "ready") + return + } + w.WriteHeader(http.StatusServiceUnavailable) + return + case "/trigger": + fresh.Store(true) + reload.notifyData("dev-update", `{"version":2,"action":"patch","routes":["/"],"preserve":["page-stores","compatible-island-state"]}`) + _, _ = io.WriteString(w, "ok") + return + case "/overlay": + reload.notifyData("build-error", `{"title":"GOWDK build failed","message":"synthetic failure"}`) + _, _ = io.WriteString(w, "ok") + return + case "/unrelated": + reload.notifyData("dev-update", `{"version":2,"action":"patch","routes":["/other"]}`) + _, _ = io.WriteString(w, "ok") + return + } + body := `Old
    old
    ` + if fresh.Load() { + body = `New
    new
    ` + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(injectLiveReloadScript([]byte(body))) + }) + server := httptest.NewServer(handler) + defer func() { + server.CloseClientConnections() + server.Close() + }() + + script := filepath.Join(t.TempDir(), "hmr-v2-browser.cjs") + writeCLIFile(t, script, `"use strict"; +const assert = require("node:assert/strict"); +const nodeModule = require("node:module"); +const { chromium } = nodeModule.createRequire(process.cwd()+"/gowdk-test.js")("playwright"); +(async () => { + const browser = await chromium.launch({ executablePath: process.argv[3], headless: true, args: ["--no-sandbox"] }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(5000); + await page.goto(process.argv[2]); + await page.focus("#focus-me"); + await page.waitForFunction(async () => { try { return (await fetch("/ready")).ok; } catch (_) { return false; } }); + await page.evaluate(() => fetch("/overlay")); + await page.locator("#__gowdk-error-overlay").waitFor(); + await page.evaluate(() => { window.__pageHMR = false; document.addEventListener("gowdk:page-hmr", () => { window.__pageHMR = true; }, { once: true }); }); + await page.evaluate(() => fetch("/trigger")); + await page.waitForFunction(() => window.__pageHMR === true); + assert.equal(await page.title(), "New"); + assert.equal(await page.locator('meta[name="description"]').getAttribute("content"), "new"); + assert.equal(await page.locator("#stale").count(), 0); + assert.equal(await page.locator("#current").count(), 1); + assert.equal(await page.locator("#__gowdk-error-overlay").count(), 0); + assert.equal(await page.locator('gowdk-island[data-gowdk-component-id="app.Counter"]').getAttribute("data-gowdk-state"), '{"count":7}'); + assert.equal(await page.locator('gowdk-island[data-gowdk-component-id="app.Changed"]').getAttribute("data-gowdk-state"), '{"count":0}'); + assert.equal(await page.locator('gowdk-island[data-gowdk-runtime="wasm"]').getAttribute("data-gowdk-state"), '{"opaque":0}'); + assert.ok(await page.evaluate(() => window.__mountedIslands > 0)); + assert.equal(await page.evaluate(() => document.activeElement && document.activeElement.id), "focus-me"); + assert.deepEqual(await page.evaluate(() => window.__gowdkStores.cleared), ["removed"]); + await page.evaluate(() => fetch("/unrelated")); + await page.waitForTimeout(200); + assert.equal(await page.title(), "New"); + } finally { await browser.close(); } +})().catch((error) => { console.error(error); process.exit(1); }); +`) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + command := exec.CommandContext(ctx, node, script, server.URL, chromium) + command.Dir, _ = os.Getwd() + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("HMR v2 browser test failed: %v\n%s", err, output) + } +} + func TestDevRuntimeProxyHandlerInjectsScript(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.URL.Path != "/" { diff --git a/internal/gowdkcmd/operation_error.go b/internal/gowdkcmd/operation_error.go index 676ef1cd..fb23c31c 100644 --- a/internal/gowdkcmd/operation_error.go +++ b/internal/gowdkcmd/operation_error.go @@ -10,6 +10,7 @@ import ( "github.com/cssbruno/gowdk/internal/compiler" "github.com/cssbruno/gowdk/internal/diagnostics" "github.com/cssbruno/gowdk/internal/lang" + "github.com/cssbruno/gowdk/internal/projectcompile" ) type OperationError struct { @@ -101,6 +102,24 @@ func operationErrorFromCause(cause error) error { if errors.As(cause, &compilerDiagnostics) { return operationErrorFromCompiler(summary, compilerDiagnostics, cause) } + var projectDiagnostics projectcompile.Diagnostics + if errors.As(cause, &projectDiagnostics) { + out := make([]devOverlayDiagnostic, 0, len(projectDiagnostics)) + for _, diagnostic := range projectDiagnostics { + item := devOverlayDiagnostic{ + Code: diagnostic.Code, Severity: diagnostic.Severity, + Message: diagnostic.Message, File: diagnostic.Source, + } + if diagnostic.Span.Start.Line > 0 { + item.Range = &devOverlayRange{ + Start: devOverlayPosition{Line: diagnostic.Span.Start.Line, Column: diagnostic.Span.Start.Column}, + End: devOverlayPosition{Line: diagnostic.Span.End.Line, Column: diagnostic.Span.End.Column}, + } + } + out = append(out, item) + } + return &OperationError{Summary: summary, Diagnostics: out, Cause: cause} + } var buildErr *buildgen.BuildError if errors.As(cause, &buildErr) { return &OperationError{ diff --git a/internal/gowdkcmd/project_inputs.go b/internal/gowdkcmd/project_inputs.go index 5b1f61c5..17e7a022 100644 --- a/internal/gowdkcmd/project_inputs.go +++ b/internal/gowdkcmd/project_inputs.go @@ -96,7 +96,7 @@ func loadProjectConfigWithPaths(options *cliOptions, configPath string, paths [] if err := loadProjectEnvFile(options, projectRoot); err != nil { return err } - config, err := project.LoadConfigStructural(resolvedConfigPath) + config, err := project.LoadConfigFileStructuralWithEnvironment(resolvedConfigPath, options.ProjectEnvironment.ForSubprocess(os.Environ())) if err != nil { return err } @@ -119,23 +119,8 @@ func validateNativeProjectConfigStructure(path string, config gowdk.Config) erro if strings.TrimSpace(path) == "" { path = project.DefaultConfigFile } - if err := config.Env.Validate(nil); err != nil { - return fmt.Errorf("%s env contract: %w", path, err) - } - if err := config.Lifecycle.Validate(); err != nil { - return fmt.Errorf("%s lifecycle contract: %w", path, err) - } - if err := config.I18N.Validate(); err != nil { - return fmt.Errorf("%s i18n policy: %w", path, err) - } - if err := config.Build.CORS.Validate(); err != nil { - return fmt.Errorf("%s CORS policy: %w", path, err) - } - if err := config.Build.CSRF.Validate(); err != nil { - return fmt.Errorf("%s CSRF policy: %w", path, err) - } - if err := gowdk.ValidateAddons(config.Addons); err != nil { - return fmt.Errorf("%s addons: %w", path, err) + if err := config.ValidateStructural(); err != nil { + return fmt.Errorf("%s config: %w", path, err) } return nil } @@ -145,7 +130,7 @@ func loadProjectEnvFile(options *cliOptions, projectRoot string) error { if err != nil { return err } - result, err := envfile.LoadIntoEnv(path, explicit) + environment, result, err := envfile.Load(path, explicit, os.Environ()) if err != nil { if explicit { return fmt.Errorf("load env file %q: %w", path, err) @@ -157,6 +142,7 @@ func loadProjectEnvFile(options *cliOptions, projectRoot string) error { options.EnvFileExplicit = result.Explicit options.EnvFileApplied = append([]string(nil), result.Applied...) options.EnvFileSkipped = append([]string(nil), result.Skipped...) + options.ProjectEnvironment = environment return nil } diff --git a/internal/gowdkcmd/serve.go b/internal/gowdkcmd/serve.go index 3ced2edf..dfc4e7e3 100644 --- a/internal/gowdkcmd/serve.go +++ b/internal/gowdkcmd/serve.go @@ -308,7 +308,7 @@ const liveReloadScriptTemplate = `