diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e455d52d..18b878ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,9 @@ jobs: - name: Build CLI run: go build ./cmd/gowdk + - name: Run broad Go static analysis + run: scripts/check-static-analysis.sh + - name: Check VS Code extension run: | node editors/vscode/scripts/sync-version.js --check @@ -75,6 +78,8 @@ jobs: - name: Check documentation run: | + sh scripts/check-cli-schema.sh + sh scripts/check-language-budget.sh sh scripts/check-docs-links.sh sh scripts/check-docs-style.sh sh scripts/check-removed-syntax.sh @@ -93,6 +98,9 @@ jobs: - name: Check example reports run: sh scripts/check-example-reports.sh + - name: Check build-iteration contract + run: sh scripts/check-build-iteration-example.sh + - name: Build login example run: | set -euxo pipefail 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/docs/compiler/build-report.md b/docs/compiler/build-report.md index 276bd2ae..ee582c2d 100644 --- a/docs/compiler/build-report.md +++ b/docs/compiler/build-report.md @@ -94,6 +94,13 @@ Current report events include: role app. - `contract_role_binary_built`: emitted when a worker or cron role binary is compiled. +- `binary_built`, `backend_binary_built`, `wasm_built`, and + `contract_role_binary_built` include the packaging envelope in `data`: + `goVersion`, `goos`, `goarch`, `cgoEnabled`, `trimpath`, `buildVCS`, + `moduleMode`, explicit `tags`, and `artifactSHA256`. This records the inputs + GOWDK controls without claiming bit-for-bit reproducibility across different + toolchains, operating systems, architectures, CGO toolchains, or dependency + graphs. ## CLI Debug Output diff --git a/docs/compiler/generated-output.md b/docs/compiler/generated-output.md index 953b9a85..4279d285 100644 --- a/docs/compiler/generated-output.md +++ b/docs/compiler/generated-output.md @@ -55,6 +55,17 @@ realtime surfaces that were enabled and validated at build time. Generated binaries speak HTTP. TLS, public host routing, secrets, durable storage, process supervision, and backups remain deployment responsibilities. +Binary and `--wasm` packaging is a read-only operation over the selected Go +module. GOWDK never runs `go mod tidy` while packaging. It invokes `go build` +with `-trimpath`, `-buildvcs=false`, and `-mod=vendor` when a vendor directory +exists or `-mod=readonly` otherwise. Ambient `GOFLAGS` are removed; build tags +must come from an explicit packaging input. + +The artifact is compiled to a same-directory temporary path, hashed with +SHA-256, then renamed into place. A failed compile or metadata read leaves an +existing artifact untouched. Windows publication temporarily moves an existing +artifact aside and restores it if the final rename fails. + ## Reports And Manifests | File | Source | @@ -81,6 +92,8 @@ The public `gowdk manifest` command is documented in Final file replacements use same-directory temporary files followed by atomic rename, so validation, formatting, manifest/report generation, and security manifest generation fail before touching committed files. +- Binary and WASM packaging follows the same publish-after-success rule and + does not rewrite the source module's `go.mod` or `go.sum`. - Document new generated files in this page, the build report page, or the reference page that owns the public contract. - New generated artifact kinds must declare whether they are public, diff --git a/docs/compiler/syntax-contributors.md b/docs/compiler/syntax-contributors.md index 99b58a9d..8cec6f2e 100644 --- a/docs/compiler/syntax-contributors.md +++ b/docs/compiler/syntax-contributors.md @@ -6,6 +6,10 @@ language contract. ## Required Path +Before starting, pass the seven-question proposal gate in +[V1 Language Budget](../language/v1-budget.md). A construct must be classified +as stable, experimental, planned, or deprecated before parser work begins. + 1. Update the source contract first: - `docs/language/` for public syntax or semantics. - `docs/reference/diagnostic-codes.md` for new or changed diagnostic codes. diff --git a/docs/engineering/ci.md b/docs/engineering/ci.md index 1b9e792e..6d53ba38 100644 --- a/docs/engineering/ci.md +++ b/docs/engineering/ci.md @@ -11,8 +11,9 @@ Required pull-request lanes: - `PR title`: conventional commit title check for pull requests. - `Verify`: the consolidated command gate. It runs supply-chain pins, Go module - tests, CLI build, VS Code extension checks, documentation checks, docs-site - compile, example reports, and the login example build smoke. + tests, CLI build, broad Go static analysis, VS Code extension checks, + documentation checks, docs-site compile, example reports, and the login + example build smoke. The consolidated `Verify` job intentionally favors quick signal and fewer PR status checks over broad coverage. Keep expensive or niche gates out of routine @@ -37,6 +38,7 @@ Run the same local checks before handoff when relevant: scripts/check-root-deps.sh scripts/check-supply-chain-pins.sh scripts/test-go-modules.sh + scripts/check-static-analysis.sh scripts/vulncheck-go-modules.sh go build ./cmd/gowdk scripts/check-dead-code.sh @@ -129,7 +131,25 @@ does not multiply runner cost: If one of these reveals nondeterministic output, either fix the generator in the same change or open a narrower issue naming the unstable file/report. -## Dead Code +## Broad Static Analysis + +`scripts/check-static-analysis.sh` discovers the root module and every nested +module through `scripts/go-modules.sh`. For each module it runs: + +```sh +go vet ./... +go run honnef.co/go/tools/cmd/staticcheck@v0.7.0 ./... +``` + +The command is a required hosted-CI gate. Its per-module headings make a +failure actionable without recreating the matrix locally. Staticcheck uses its +full default correctness, simplification, performance, and style set; it is no +longer limited to `U1000` or a starter package list. Platform-specific files +remain covered by their native build constraints. Add a narrow, explained +source suppression only when a diagnostic is intentionally inapplicable; this +gate has no package or diagnostic exclusions. + +## Focused Dead Code `scripts/check-dead-code.sh` runs two pinned analyzers over reviewed starter package sets: diff --git a/docs/engineering/compiler-hardening-plan.md b/docs/engineering/compiler-hardening-plan.md new file mode 100644 index 00000000..8db882e3 --- /dev/null +++ b/docs/engineering/compiler-hardening-plan.md @@ -0,0 +1,85 @@ +# Implementation Plan: Compiler And Extension Hardening + +## Context + +Implements `compiler-hardening-spec.md` and GitHub issues #664, #667, #669, +#670, #671, and #672. + +## Assumptions + +- Raw block text remains available for formatting, diagnostics, source maps, + inspection, CSS emission, and inline Go extraction, but not as a semantic + fallback after lowering. +- Generated output/app directories are GOWDK-owned generations. +- Legacy `Config.Addons` compatibility is required during 0.x. + +## Proposed Changes + +- Complete typed client/view/build/paths/server lowering and invariants; migrate + downstream consumers away from raw-body reparsing. +- Strengthen opaque compiler snapshot and output-plan APIs. +- Add `internal/projectcompile` for canonical project compilation and structured + diagnostics; migrate CLI/tooling consumers in bounded steps. +- Add `internal/publish` generation staging, atomic replacement, rollback, and + recovery; route static and generated-app publication through it. +- Add typed built-in feature configuration plus a versioned executable + extension contract and legacy addon adapter. +- Replace one-shot `go run` operations with a cached, context-bound addon host + using versioned request/response envelopes and bounded I/O. +- Update architecture, compiler pipeline, config/addon/generated-output docs, + requirements, and focused examples/tests. + +## Files Expected To Change + +- `gowdk.go`, `addons/*`, `internal/project/*` +- `internal/gwdkir`, `internal/gwdkanalysis`, `internal/compiler` +- `internal/projectcompile`, `internal/publish` +- `internal/buildgen`, `internal/appgen`, `internal/gowdkcmd`, `internal/lang`, + `internal/lsp` +- compiler/reference/product documentation and focused fixtures + +## Data And API Impact + +- Adds typed feature and extension configuration while retaining deprecated + addon compatibility. +- Adds an executable-host protocol version and structured error codes. +- Internal generator entry points increasingly require validated snapshots or + opaque plans; public language syntax and manifest versions do not change. + +## Tests + +- Unit: IR invariants, phase rejection, feature provenance, host protocol, + payload/path limits, transaction rollback/recovery. +- Integration: workspace validity agreement, repeated host reuse, static/app + failed publication preservation. +- End-to-end: representative project check/build and generated app build. +- Manual: inspect generated output and extension timeout diagnostics. + +## Verification Commands + +```sh +go test ./internal/gwdkast ./internal/gwdkanalysis ./internal/gwdkir +go test ./internal/compiler ./internal/project ./internal/projectcompile ./internal/publish +go test ./internal/buildgen ./internal/appgen ./internal/lang ./internal/lsp ./internal/gowdkcmd +go test ./... +go build ./cmd/gowdk +scripts/test-go-modules.sh +scripts/check-docs-links.sh +scripts/check-docs-style.sh +scripts/check-removed-syntax.sh +scripts/check-doc-versions.sh +``` + +## Rollback Plan + +- Revert consumers to the legacy addon adapter and direct phase entry points. +- Publication keeps the previous generation backup until the replacement is + committed; recovery restores it if commit fails. +- No persisted user data or language syntax migration is involved. + +## Risks + +- Wide compiler-consumer migration can expose hidden raw-source dependencies. +- Cross-platform directory replacement differs on Windows; rollback tests must + cover existing and absent destinations. +- Long-lived helper processes need deterministic cleanup after crash/timeout. diff --git a/docs/engineering/compiler-hardening-spec.md b/docs/engineering/compiler-hardening-spec.md new file mode 100644 index 00000000..f07399bc --- /dev/null +++ b/docs/engineering/compiler-hardening-spec.md @@ -0,0 +1,120 @@ +# Feature Spec: Compiler And Extension Hardening + +## Problem + +GOWDK can interpret source after lowering through both typed IR and raw block +bodies, exposes validation state through APIs that still accept ordinary IR, +publishes multi-file generations incrementally, duplicates project compilation +orchestration between CLI and tooling, and uses an unversioned one-shot process +bridge for executable addons. Those gaps can make commands disagree, expose +partial output, or let extension execution hang indefinitely. + +This specification covers GitHub issues #664, #667, #669, #670, #671, and +#672 as one dependency-ordered compiler hardening program. + +## Goals + +- Make typed lowered records the semantic source of truth after analysis. +- Make analyzed, validated, planned, and emitted phases distinct in Go APIs. +- Give project commands one reusable compilation snapshot service. +- Publish static and generated-app directories as committed generations. +- Separate built-in feature configuration from executable extensions. +- Version, bound, reuse, and diagnose the executable extension host. + +## Non-Goals + +- Change accepted `.gwdk` syntax or the compile-first rendering model. +- Make extensions a general runtime plugin system. +- Download extensions or execute untrusted remote code automatically. +- Move app-owned lifecycle services into the compiler extension host. + +## Users And Permissions + +- Primary users: GOWDK application authors, extension authors, editor users, + and GOWDK contributors. +- Roles or permissions: extension processes run with the invoking developer's + local permissions; GOWDK does not elevate privileges. +- Data visibility rules: protocol errors and stderr are bounded and redacted; + configuration secret values are never serialized into diagnostics. + +## User Flow + +1. GOWDK loads project configuration and discovers the selected source set. +2. A shared workspace service parses, analyzes, enriches, links, validates, and + returns one immutable compiled snapshot with structured diagnostics. +3. Output planners accept only the validated snapshot and produce complete + static/app artifact generations. +4. Publishers stage and audit the generation, then replace the committed + destination or roll back without exposing mixed files. +5. Built-in feature sections select compiler-owned behavior. Executable + extensions negotiate a supported protocol and explicit capabilities with + one reusable helper host per configuration digest. + +## Requirements + +### Functional + +- `gwdkir.CheckInvariants` rejects missing typed records for supported blocks. +- Compiler, build, app, LSP, manifest, and dev dependency analysis do not + reparse raw semantic block bodies after lowering. +- Layout composition and fragment rendering consume typed view nodes. +- Compiler validation is the only constructor of `ValidatedProgram`. +- Static and generated-app emitters reject zero or analyzed-only phase values. +- Route defaults, localization, schemas, bindings, guards, fragments, and + request-time metadata are finalized before emission. +- A workspace snapshot owns analysis, Go binding, contract scanning/linking, + and feature validation once per source set. Language tooling layers its + editor-only accessibility diagnostics onto that snapshot. +- Static output and generated app files are staged and committed as complete + directory generations; failed commits restore the prior generation. +- `Config.Features` selects built-in capabilities independently from executable + extensions; legacy `Config.Addons` remains a documented 0.x adapter. +- Executable extensions declare a protocol version, required/optional + capabilities, and supported compiler phases. +- The extension bridge performs a handshake, reuses one built helper/process, + honors cancellation and deadlines, bounds payloads/stderr, validates emitted + relative paths, and returns structured errors. + +### Non-Functional + +- Performance: compile snapshots and extension hosts are reusable within one + command; unchanged output remains deterministic. +- Reliability: interrupted staging is recoverable and stale staging/backup + directories are cleaned deterministically. +- Accessibility: language tooling retains the existing accessibility pass. +- Security/privacy: extension output paths cannot escape the generated app; + errors are bounded and redacted. +- Observability: snapshot stages and extension failures expose stable structured + codes without writing directly to command output streams. + +## Acceptance Criteria + +- [x] Issues #664, #667, #669, #670, #671, and #672 acceptance tests pass. +- [x] `gowdk check` and `gowdk build` agree on invalid project sources. +- [x] A publication failure leaves the prior static/app generation unchanged. +- [x] Repeated extension requests use one helper host and version handshake. +- [x] Existing 0.x addon configs continue through a documented compatibility + path while new configs can use typed built-in feature selection. +- [ ] Compiler, generator, project, and CLI focused tests plus the repository + quality gates pass. + +## Edge Cases + +- Empty supported blocks, parse recovery, and warning-only validation. +- Destination absent, destination already present, stale backup/staging data, + rename failure, and interruption between backup and publish rename. +- Unknown optional versus required extension capabilities. +- Protocol mismatch, helper crash, malformed/oversized JSON, timeout, + cancellation, unsafe generated paths, and bounded stderr. +- Legacy addons that combine feature markers with executable behavior. + +## Dependencies + +- Internal: parser/analyzer IR, compiler phases, buildgen, appgen, project + config loader, contract scanner, accessibility diagnostics, dev loop. +- External: Go toolchain processes only; no new production dependency. + +## Open Questions + +- None for this slice. Remote extension discovery and independently versioned + distribution remain outside this specification. diff --git a/docs/engineering/decisions/0018-phase-publication-and-extension-boundaries.md b/docs/engineering/decisions/0018-phase-publication-and-extension-boundaries.md new file mode 100644 index 00000000..9d6d889d --- /dev/null +++ b/docs/engineering/decisions/0018-phase-publication-and-extension-boundaries.md @@ -0,0 +1,74 @@ +# ADR 0018: Phase, Publication, And Extension Boundaries + +Date: 2026-08-20 + +Status: Accepted + +## Context + +Compiler consumers currently receive ordinary IR plus conventions about prior +validation, generated files are published individually, and built-in feature +markers share an abstraction and executable bridge with behaviorful extensions. +These are different trust and lifecycle boundaries but are not represented as +such in the architecture. + +## Decision + +GOWDK uses explicit boundaries for all three concerns: + +- source lowering produces analyzed typed IR; compiler validation alone creates + an opaque validated snapshot; output packages create opaque target plans; +- generated directories are staged, audited, and committed as complete + generations with rollback/recovery instead of being mutated during planning; +- built-in compiler behavior is selected through typed feature configuration, + while executable build-time extensions use a distinct versioned descriptor + and host protocol. Runtime services remain application lifecycle hooks. + +Raw source bodies remain available for formatting, diagnostics, source maps, +inspection, CSS payloads, and inline Go extraction. They are not semantic +fallback inputs after supported constructs have been lowered. + +The executable host is local, explicit, context-bound, payload-bounded, and +reused for a configuration digest. Unknown optional capabilities are ignored; +unknown required capabilities and incompatible protocol versions fail before +extension execution. + +## Consequences + +### Positive + +- Invalid or partially lowered programs cannot reach emitters accidentally. +- Check, build, dev, and tooling can share one validated snapshot. +- Failed builds retain the last committed output generation. +- Built-in feature selection no longer implies third-party code execution. +- Extension compatibility and failure behavior are inspectable and bounded. + +### Negative + +- Internal APIs become stricter and require coordinated migrations. +- Directory publication needs cross-platform rollback and recovery logic. +- Extension hosts add process lifecycle and protocol maintenance work. + +### Neutral + +- Existing 0.x addon constructors remain compatibility adapters during + migration; they do not define the long-term extension contract. +- No production dependency or public language syntax is added. + +## Alternatives Considered + +- Keep naming conventions around ordinary IR. Rejected because they do not + prevent phase misuse. +- Rely only on atomic per-file rename. Rejected because readers can still + observe mixed generations. +- Treat every feature selector as an executable plugin. Rejected because core + behavior and third-party execution have different trust boundaries. +- Start a helper for every request. Rejected because it is slow and makes + cancellation/version evolution brittle. + +## Follow-Up + +- Complete the migrations tracked by issues #664, #667, #669, #670, #671, + and #672. +- Remove the legacy addon adapter only through a separately documented 0.x + migration decision. diff --git a/docs/engineering/decisions/README.md b/docs/engineering/decisions/README.md index 4cab41ad..212823bc 100644 --- a/docs/engineering/decisions/README.md +++ b/docs/engineering/decisions/README.md @@ -28,6 +28,7 @@ a new ADR instead of rewriting its history. | [0015](0015-generated-binary-lifecycle-services.md) | Generated binary lifecycle service contracts | | [0016](0016-pure-go-helpers-from-bounded-client.md) | Pure Go helpers from bounded client code through WASM | | [0017](0017-callback-props-and-scoped-cells.md) | Callback props and scoped cells for parent-child communication | +| [0018](0018-phase-publication-and-extension-boundaries.md) | Typed compiler phases, committed output generations, and versioned executable extensions | ## Maintenance diff --git a/docs/engineering/dev-hmr-v2-plan.md b/docs/engineering/dev-hmr-v2-plan.md new file mode 100644 index 00000000..6e5ea753 --- /dev/null +++ b/docs/engineering/dev-hmr-v2-plan.md @@ -0,0 +1,58 @@ +# Implementation Plan: Development Update Protocol V2 + +## Context + +Implements `../product/dev-hmr-v2-spec.md` and GitHub issue #639. + +## Assumptions + +- Static SPA routes remain real HTML URLs. +- Full body replacement is acceptable only after compatibility checks and a + fresh-document fetch. +- WASM state is opaque and is never transferred. + +## Proposed Changes + +- Extend dev update payloads and dependency attribution for v2. +- Emit page/store compatibility markers in generated development HTML. +- Add browser patch logic for managed head/body replacement, state carryover, + island cleanup/remount, focus restoration, and one-shot reload fallback. +- Route page/layout/component changes to patch/remount/reload decisions. +- Extend browser and Go tests; update dev and product requirement docs. + +## Files Expected To Change + +- `internal/gowdkcmd/dev_loop.go`, `internal/gowdkcmd/serve.go` +- `internal/buildgen` development markers/runtime tests +- `docs/reference/dev.md`, `docs/product/requirements.md` + +## Data And API Impact + +- Dev-only SSE protocol advances from version 1 to version 2. +- Production manifest and runtime contracts do not change. + +## Tests + +- Unit: payload decisions, dependency attribution, route scoping. +- Integration: stale output cleanup and last-good output after failure. +- End-to-end: browser patch/remount/reload and production equivalence. +- Manual: run `gowdk dev` against page/layout/JS/WASM changes. + +## Verification Commands + +```sh +go test ./internal/gowdkcmd ./internal/buildgen +go test ./... +go build ./cmd/gowdk +``` + +## Rollback Plan + +- Revert emitted updates to version 1 component remount/full reload behavior; + production output is unaffected. + +## Risks + +- Replacing body content can lose focus or event listeners if cleanup/remount + ordering is wrong. +- Over-broad state preservation can retain values across incompatible shapes. diff --git a/docs/engineering/security-threat-model.md b/docs/engineering/security-threat-model.md index 77f8c280..ec43e502 100644 --- a/docs/engineering/security-threat-model.md +++ b/docs/engineering/security-threat-model.md @@ -37,7 +37,7 @@ current controls, and open follow-up areas for review. | Browser/client to API endpoints | Generated API routes, contract query routes | Method dispatch, configurable API body cap defaulting to 1 MiB, generated CSRF for state-changing API methods, rate-limit hook when addon is enabled | Public API hardening, typed helper expansion, and per-route policy are tracked in #24. | | Browser/client to fragments | Standalone fragments, action fragment responses | Fragment routing through generated handlers, escaped render core, no-store request-time responses; standalone fragments are GET-only and action fragments share the action cap | Broader auth/session policy remains planned. | | Browser/client to SSR `server {}` | Request-time SSR routes, route-local error pages | SSR feature gate, guard execution, safe local redirect helpers, panic boundaries, no-store failures | Full guard contract, route-local auth/session policy, and richer request-time error policy remain planned. | -| Guard metadata to user authorization | `guard` declarations, `GOWDKGuardRegistry`, `GOWDKAuthProvider` | Guards run before generated request-time user logic; native RBAC helpers are defense-in-depth only | Backend resource authorization remains app-owned; full guard response contract is planned. | +| Guard metadata to user authorization | `guard` declarations and typed `Config.Interop` providers | Guards run before generated request-time user logic; native RBAC helpers are defense-in-depth only | Backend resource authorization remains app-owned; full guard response contract is planned. | | Embedded build output to generated server | Embedded SPA assets, generated error pages, health endpoint | Generated server uses HTTP timeouts and `MaxHeaderBytes`; embedded output skips known secret/private/temp artifacts | Broader asset policy remains planned. | | VS Code extension to workspace | LSP/editor commands and workspace file access | Dependency-light local tooling; no production runtime authority | Extension command/file threat model needs focused review before broader editor automation. | | WASM islands to browser runtime | `go client {}`, component WASM assets, host loader | WASM is explicit and separate from backend handlers; browser-unsafe import validation exists | Production ABI hardening and user-code runtime validation remain planned. | diff --git a/docs/engineering/v1-hardening-plan.md b/docs/engineering/v1-hardening-plan.md new file mode 100644 index 00000000..77fbd129 --- /dev/null +++ b/docs/engineering/v1-hardening-plan.md @@ -0,0 +1,78 @@ +# V1 Contract Hardening Plan + +## Context + +Implements the contract in +[`docs/product/v1-hardening-spec.md`](../product/v1-hardening-spec.md) for issues +#771, #758, #726, #724, #722, #721, #720, #717, #716, #714, #697, and #695. + +## Assumptions + +- The current build-iteration example already matches the documented bounded + build-expression contract; it needs verification coverage, not broader + syntax. +- Existing public names remain source compatible where a safe migration path + exists. Pre-v1 magic hooks and inferred directive lanes may become explicit + migration diagnostics. +- No new production dependency is required. + +## Proposed Changes + +1. Harden config decoding and introduce immutable project environment overlays. +2. Split and validate the public config surface; add explicit provider refs. +3. Add the recursive CLI schema and multi-module static-analysis scripts. +4. Document and machine-check the v1 language budget; add explicit directive + lanes and explicit load/guard/auth bindings through AST, IR, diagnostics, + inspect/LSP, generated output, examples, and migration docs. +5. Make native/WASM packaging read-only, path-independent, atomic, and + reportable. +6. Bound LSP framing and document retention. +7. Add the catalog-backed coded-error bridge and use it across generated + request-time lanes. + +## Data And API Impact + +- New config/provider and runtime coded-error types are additive where possible. +- Generated Go changes registration and error-writing calls but remains normal + formatted adapter code. +- LSP embedding gains per-server `Limits`; defaults remain internal and finite. +- Build reports gain versioned packaging metadata without secrets or absolute + cache/work paths. + +## Tests + +- Unit: config AST failures, overlays, command schema, language budget, directive + lanes, binding resolution, coded errors, framing, and document accounting. +- Integration: generated app handlers, native/WASM packaging non-mutation and + failure preservation, CLI help/completions, examples. +- End-to-end: representative generated binary, LSP session, and identical + builds from different absolute roots in the supported reproducible lane. + +## Verification Commands + +```sh +go test ./internal/project ./internal/gowdkcmd ./internal/lang ./internal/compiler ./internal/lsp +go test ./internal/buildgen ./internal/appgen ./runtime/... +go build ./cmd/gowdk +scripts/check-static-analysis.sh +scripts/test-go-modules.sh +scripts/check-docs-links.sh +scripts/check-docs-style.sh +scripts/check-removed-syntax.sh +scripts/check-doc-versions.sh +``` + +## Rollback Plan + +- Revert each vertical slice independently. Generated artifacts are published + transactionally, so failed packaging does not require restoring output files. +- Keep migration diagnostics local to the compiler so a rollback does not alter + user Go packages or persisted data. + +## Risks + +- Language-lane migration touches many fixtures and generated-output goldens. +- Config compatibility needs strict tests so fail-closed behavior does not turn + supported executable configs into false errors. +- Reproducibility claims must stay inside the documented Go toolchain/target + envelope. diff --git a/docs/language/README.md b/docs/language/README.md index 5a0bcb6f..d77ee38b 100644 --- a/docs/language/README.md +++ b/docs/language/README.md @@ -32,6 +32,8 @@ view { - [Semantics](semantics.md) documents validation and render-mode rules. - [Specification](spec.md) is the compact language overview. - [Stability](stability.md) records construct stability and deprecation tiers. +- [V1 Language Budget](v1-budget.md) defines what stays core, experimental, or + outside the language and gates new syntax proposals. When prose and parser behavior disagree, fix the prose and the conformance coverage in the same change. Do not treat a planned form in a topic page as @@ -72,7 +74,8 @@ capability is complete. Check the relevant topic page and A public language change must update: -1. The relevant topic page and grammar or syntax page. +1. Its classification and proposal answers in the v1 language budget, then the + relevant topic page and grammar or syntax page. 2. The accept/reject conformance corpus or named integration coverage. 3. Stable diagnostics and the [Diagnostic Code Reference](../reference/diagnostic-codes.md), when applicable. diff --git a/docs/language/actions.md b/docs/language/actions.md index bea9c563..3362298c 100644 --- a/docs/language/actions.md +++ b/docs/language/actions.md @@ -117,10 +117,11 @@ Current behavior: user-owned Go behavior. - Actions declared on guarded pages share generated app guard backing with SSR pages and APIs. `auth.Addon` supplies `auth.required` and native RBAC session - guard backing when configured. Custom guards require `GOWDKGuardRegistry`; + guard backing when configured. Custom guards require + `Config.Interop.Guards = gowdk.RegisterGuards(package.Guards)`; native RBAC guard IDs such as `role:admin` and `permission:posts.write` - require `GOWDKAuthProvider` only without `auth.Addon`. Missing backing hooks - fail the generated app Go build. + require `Config.Interop.AuthProvider` only without `auth.Addon`. Missing + registrations fail compiler validation. Generated action handlers run guards before CSRF checks, form decoding, and user handler calls. Treat these as defense-in-depth redundancy for generated route/page access, never as backend resource authorization. If the page diff --git a/docs/language/api.md b/docs/language/api.md index 70c0aa78..a5c4da99 100644 --- a/docs/language/api.md +++ b/docs/language/api.md @@ -175,10 +175,10 @@ decoding, response shape, and webhook policy in normal Go handlers. APIs declared on guarded pages share generated app guard backing with SSR pages and actions. `auth.Addon` supplies `auth.required` and native RBAC session guard -backing when configured. Custom guards require `GOWDKGuardRegistry`; native RBAC +backing when configured. Custom guards require `Config.Interop.Guards`; native RBAC guard IDs such as `role:admin` and `permission:reports.read` require -`GOWDKAuthProvider` only without `auth.Addon`. Missing backing hooks fail the -generated app Go build. +`Config.Interop.AuthProvider` only without `auth.Addon`. Missing registrations +fail compiler validation. Generated API handlers run guards before user handler calls. Treat these as defense-in-depth redundancy for generated route/page access, never as backend resource authorization. If the page itself is protected, use request-time page diff --git a/docs/language/audit.md b/docs/language/audit.md index 3ab775f2..4599ca03 100644 --- a/docs/language/audit.md +++ b/docs/language/audit.md @@ -109,8 +109,8 @@ than pass or fail them for the wrong reason. For `gowdk audit --run`, native RBAC actor expectations use a test-only provider inside the temporary generated app. Production generated apps use `auth.Addon` -defaults when configured, or the app-owned `GOWDKAuthProvider` / -`GOWDKGuardRegistry` hooks documented for guarded routes. The audit runner does +defaults when configured, or the app-owned typed `Config.Interop` providers +documented for guarded routes. The audit runner does not synthesize app-owned custom guard callbacks; custom guard IDs are reported as `audit_guard_unverified` unless explicit generated-app guard fixtures are provided. diff --git a/docs/language/blocks.md b/docs/language/blocks.md index da720eff..87fd975f 100644 --- a/docs/language/blocks.md +++ b/docs/language/blocks.md @@ -59,9 +59,10 @@ api Health GET "/api/health" `go client {}` runs on the client only when it declares a `//go:wasmexport GOWDKMount` function; that browser lane is compiled with `GOOS=js GOARCH=wasm` and mounted by the generated page loader. - `go server {}` is - request-time and requires SSR or explicit hybrid request-time behavior; - current generated apps can bind `Load` from `go server {}`. + `go server {}` is request-time and requires SSR or explicit hybrid + request-time behavior. Page load functions now live in ordinary Go packages + and are mapped through `Config.Interop.Loads`; move older inline + `Load` functions before registering them. Generated app source writes default `go {}` and `go server {}` blocks as normal Go packages under `gowdk_go/`. `go addon. {}` is reserved for addon-owned validation and generated app file emission. diff --git a/docs/language/data.md b/docs/language/data.md index c11656f5..fd7c0dfa 100644 --- a/docs/language/data.md +++ b/docs/language/data.md @@ -9,7 +9,7 @@ Generated JavaScript does not own page loading policy. | --- | --- | --- | --- | | `paths {}` | build time | concrete dynamic SPA routes | Literal records only. Required for dynamic SPA pages unless the page uses request-time rendering. | | `build {}` | build time | static page data | Literal records plus imported or same-package Go functions, with optional `gowdk.BuildParams` route params. | -| `server {}` | request time | SSR page data | One same-package `Load` function returns `map[string]any` data or an exported typed result struct. | +| `server {}` | request time | SSR page data | One explicitly registered Go function returns `map[string]any` data or an exported typed result struct. | | `act` | request time | POST/action endpoint behavior | Same-package Go handler returns `runtime/response.Response`. | | `api` | request time | API endpoint behavior | Same-package Go handler returns `runtime/response.Response`. | | `fragment` | request time | partial endpoint behavior | `.gwdk` fragment markup plus an optional same-package Go hook for data and response decisions. | @@ -19,7 +19,8 @@ Generated JavaScript does not own page loading policy. - `build {}` data is rendered into generated static output. It must not depend on the incoming HTTP request. - `server {}` selects request-time SSR and requires the SSR addon. -- Generated SSR calls one same-package function named `Load`. +- Generated SSR calls the function mapped by + `gowdk.RegisterLoad(pageID, package.Function)` in `Config.Interop.Loads`. - Supported load signatures are: ```go @@ -32,7 +33,7 @@ func LoadDashboard(ssr.LoadContext) (DashboardData, error) - One `server {}` block can declare multiple fields. They come from the single returned map or typed result struct, including dotted paths such as `user.name`. -- Typed load result structs must be exported same-package structs. Exported +- Typed load result structs must be exported structs. Exported fields are visible by Go field name or `json` tag name; `json:"-"` hides a field. Generated SSR adapters convert top-level struct fields into the existing load-data map without runtime reflection. diff --git a/docs/language/grammar.md b/docs/language/grammar.md index 413293ee..273b76a8 100644 --- a/docs/language/grammar.md +++ b/docs/language/grammar.md @@ -66,7 +66,7 @@ same-package Go handlers. `gowdk build` parses the first literal `paths {}` and ```text literalReturn = "=>" whitespace* "{" literalField ("," literalField)* "}" literalField = ident ":" buildExpr -buildCall = "=>" whitespace* ident "." ident "()" +buildCall = "=>" whitespace* [ ident "." ] ident "()" ``` A `build {}` field value is a `buildExpr` whose raw text is captured at parse diff --git a/docs/language/markup.md b/docs/language/markup.md index d3b47e35..86dde9a1 100644 --- a/docs/language/markup.md +++ b/docs/language/markup.md @@ -118,7 +118,7 @@ Implemented today: or one Go-style `if` return followed by a fallback return. Computed values are read-only, can depend on props, state, and earlier computed values, and update dependent bindings after state changes. -- `g:if={boolExpr}`, `g:else-if={boolExpr}`, and `g:else` on sibling elements +- `g:if={boolExpr} g:lane="client"`, `g:else-if={boolExpr}`, and `g:else` on sibling elements inside stateful components. The static first render may mark inactive branches with `hidden`; after island mount, generated JavaScript mounts the active branch and unmounts inactive branches. @@ -162,12 +162,11 @@ Implemented today: - Island expressions can choose values with the Go-ish conditional expression `if Open { "open" } else { "closed" }`. - Elements inside stateful components can render Go-typed slice state with - `g:for={item in Items}` or `g:for={item, i in Items}` and a required scalar + `g:for={item in Items} g:lane="client"` or `g:for={item, i in Items} g:lane="client"` and a required scalar `g:key={item.ID}`. The first slice supports item field interpolation such as `{item.Name}`, index interpolation such as `{i}`, and keyed row reuse/reorder - during island render passes. Over component `state`/`store` this `g:for` is a - **client island**; over a `server {}` field the same `g:for` is a server list - (next item). The compiler infers the lane from the operand's data source. + during island render passes. The explicit client lane is checked against the + component state/store data source. - `g:transition="name"` on the same element as a client `g:if`, `g:else-if`, `g:else`, or keyed client `g:for` row. The runtime toggles `gowdk-transition`, `gowdk-transition-name`, `gowdk-transition-enter`, @@ -179,15 +178,16 @@ Implemented today: keyed row is reused at a different index, the runtime toggles `gowdk-animate`, `gowdk-animate-name`, and `gowdk-animate-move`. The value must be a literal CSS-safe identifier. -- `g:for`/`g:if` over a **`server {}` request-time field** render **server-side**. - `g:for={item in field}` (or `g:for={item, i in field}`) renders rows with +- `g:for`/`g:if` with `g:lane="server"` over a **`server {}` request-time + field** render **server-side**. + `g:for={item in field} g:lane="server"` (or `g:for={item, i in field} g:lane="server"`) renders rows with escape-by-default interpolation (`{item.Name}`, `{i}`); server lists nest — a - nested `g:for={child in item.children}` resolves its slice per parent row. - `g:if={field}` / `g:if={!field}` conditionally renders a branch, and a + nested `g:for={child in item.children} g:lane="server"` resolves its slice per parent row. + `g:if={field} g:lane="server"` / `g:if={!field} g:lane="server"` conditionally renders a branch, and a top-level server `g:if` accepts a full bool expression - (`g:if={count > 0 && status == "open"}`) evaluated at request time. The lane is - chosen by the data source — a declared `server {}` field is server-rendered; - `state`/`store` is a client island. See [ssr.md](ssr.md) for the full + (`g:if={count > 0 && status == "open"} g:lane="server"`) evaluated at request + time. The compiler rejects a lane that disagrees with data ownership. See + [ssr.md](ssr.md) for the full server-region contract. (`g:each`/`g:when` were unified into `g:for`/`g:if`; the old names parse to a migration nudge.) - Client handlers can mutate state arrays with compiler-owned built-ins: `append(Items, { Field: expr })`, `remove(Items, index)`, and @@ -211,7 +211,7 @@ Implemented today:

Loading

{:then results}
    -
  • {item.Name}
  • +
  • {item.Name}
{:catch err}

{err.message}

@@ -249,9 +249,9 @@ These are the supported `g:` directives in `view {}` markup: modifiers are `.prevent`, `.stop`, `.once`, `.capture`, `.debounce(duration)`, and `.throttle(duration)`. - `g:ref={name}` inside stateful components. -- `g:if={boolExpr}`, `g:else-if={boolExpr}`, and `g:else` inside stateful +- `g:if={boolExpr} g:lane="client"`, `g:else-if={boolExpr}`, and `g:else` inside stateful components. -- `g:for={item in Items}` or `g:for={item, i in Items}` with required +- `g:for={item in Items} g:lane="client"` or `g:for={item, i in Items} g:lane="client"` with required `g:key={scalarExpr}` inside stateful components. - `g:transition="name"` on client `g:if` branches or keyed client `g:for` rows. - `g:animate="name"` on keyed client `g:for` rows. @@ -282,8 +282,8 @@ authors provide CSS: ```gwdk view { -
Details
-
  • +
    Details
    +
  • {item.Name}
  • } diff --git a/docs/language/semantics.md b/docs/language/semantics.md index 0230d2fd..1d16b914 100644 --- a/docs/language/semantics.md +++ b/docs/language/semantics.md @@ -61,9 +61,8 @@ `import interop "github.com/..."`; dynamic `paths {}` builds pass route params to helpers that declare one `gowdk.BuildParams` argument. - `server {}` runs at request time for SSR or request-time hybrid pages. - Generated SSR supports `=> { field, user.name }` declarations and - same-package Go load functions named `Load` that receive - `ssr.LoadContext`. + Generated SSR supports `=> { field, user.name }` declarations and explicit + `Config.Interop.Loads` functions that receive `ssr.LoadContext`. - `view {}` records block presence and raw body text for the current app-shell HTML subset. SPA builds interpolate route params and component props in text and attribute values, escaping the result. diff --git a/docs/language/ssr.md b/docs/language/ssr.md index 6beda29f..fb47919e 100644 --- a/docs/language/ssr.md +++ b/docs/language/ssr.md @@ -17,15 +17,15 @@ SSR is optional and must not become the default framework identity. `runtime/app.TypedParams(ctx)` before guards, load functions, or rendering run. Invalid typed params return 400; missing params return 404. - Generated SSR supports declared identifier and dotted-path fields such as - `server { => { user, title, account.plan } }` and calls a same-package exported - Go function named `Load`. `` is the explicit `page` value - when present, otherwise the filename-derived page ID. + `server { => { user, title, account.plan } }`. `Config.Interop.Loads` + explicitly maps the page ID to an exported Go function with + `gowdk.RegisterLoad("dashboard", dashboard.Load)`. - Supported load function signatures are `func LoadDashboard(ssr.LoadContext) map[string]any`, `func LoadDashboard(ssr.LoadContext) (map[string]any, error)`, `func LoadDashboard(ssr.LoadContext) DashboardData`, and `func LoadDashboard(ssr.LoadContext) (DashboardData, error)`. - Typed result structs must be exported same-package structs. Exported fields + Typed result structs must be exported structs. Exported fields are exposed by Go field name or `json` tag name, and `json:"-"` hides a field. Returned values replace generated SSR placeholders with request-time HTML escaping. Dotted paths resolve through nested maps with string keys, structs, @@ -77,46 +77,31 @@ SSR is optional and must not become the default framework identity. route can be gated before HTML is returned. `runtime/guard` exposes `Context`, `Registry`, and ordered guard execution contracts. Generated SSR, action, API, and fragment handlers run declared guards before user - logic. A guarded generated app will not compile unless required guard backing - functions exist. Ordinary guard errors fail closed with HTTP 403. Guards can + logic. Missing backing registrations are compiler diagnostics. Ordinary guard + errors fail closed with HTTP 403. Guards can intentionally return `runtime/guard.RedirectTo`, `runtime/guard.Redirect`, or `runtime/guard.Respond` errors to write no-store redirects or custom responses. Native RBAC guard IDs use `role:` and `permission:` and resolve through an application-owned `runtime/auth.Provider`. -Generated app packages that include at least one guarded SSR, action, API, or -fragment route require backing functions in the generated app package unless -`auth.Addon` supplies them. With `auth.Addon(auth.Options{...})`, generated -startup configures the session manager, registers `auth.required`, and uses that -session manager for native `role:` / `permission:` guards. +Declare request-time loads and custom backing providers explicitly in config. +Registrations accept real Go function values and live in ordinary app packages: ```go -func GOWDKGuardRegistry() gowdkguard.Registry // required when custom guard IDs are used -func GOWDKAuthProvider() auth.Provider // required when role:/permission: IDs are used without auth.Addon +Interop: gowdk.InteropConfig{ + Loads: []gowdk.LoadRegistration{ + gowdk.RegisterLoad("dashboard", dashboard.Load), + }, + Guards: gowdk.RegisterGuards(security.Guards), + AuthProvider: gowdk.RegisterAuthProvider(security.AuthProvider), +}, ``` -Define custom guards in app startup code that is compiled with the generated app -package. If this function is missing while custom guard IDs are declared, `go -build` fails. - -```go -package gowdkapp - -import gowdkguard "github.com/cssbruno/gowdk/runtime/guard" - -func GOWDKGuardRegistry() gowdkguard.Registry { - return gowdkguard.Registry{ - "auth.required": func(ctx gowdkguard.Context) error { - return nil - }, - } -} -``` - -For native RBAC guards, define only the application-owned principal source. If -this function is missing while `role:` or `permission:` guard IDs are declared, -`go build` fails. +`RegisterLoad` replaces the `Load` naming convention. Custom guards need +`RegisterGuards`. Native `role:`/`permission:` guards need +`RegisterAuthProvider` only when `auth.Addon` is not configured. The addon still +supplies `auth.required` and its session-backed provider automatically. ```go import ( @@ -125,7 +110,7 @@ import ( gowdkauth "github.com/cssbruno/gowdk/runtime/auth" ) -func GOWDKAuthProvider() gowdkauth.Provider { +func AuthProvider() gowdkauth.Provider { return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { return &gowdkauth.Principal{ ID: "user-1", @@ -136,18 +121,19 @@ func GOWDKAuthProvider() gowdkauth.Provider { } ``` -Feature packages that declare page, action, or API handlers should not import -the generated `gowdkapp` package. Keep registration in the generated app -package to avoid import cycles. +Feature packages never import the generated `gowdkapp` package. Missing typed +registrations fail during compiler validation and appear in `inspect +go-bindings`. Native RBAC guards are a defense-in-depth redundancy layer for generated route/page access. They must never replace backend authorization for protected resources in normal Go handlers and services. -## Lane inference: one directive, two lanes +## Explicit directive lanes -GOWDK has two execution lanes for `g:for` and `g:if`, and the compiler picks the -lane from the **data source**, not from a separate directive: +GOWDK has two execution lanes for `g:for` and `g:if`. Source declares the lane +with `g:lane="server"` or `g:lane="client"`, and the compiler verifies it +against data ownership: - When the operand is a **`server {}` request-time field** (or, when nested, the enclosing row item), `g:for`/`g:if` render **server-side** at request time, with @@ -156,10 +142,11 @@ lane from the **data source**, not from a separate directive: - When the operand is **client `state`/`store`**, `g:for`/`g:if` bind a **reactive client island**. -So `g:for={col in columns}` over a `server {}` field is a server-rendered list, -while `g:for={todo in todos}` over component `state` is a client island — same -directive, lane chosen by where the data lives. A name that is neither a declared -`server {}` field nor client state is rejected. There are no separate `g:each`/`g:when` directives; the lane is inferred. +So `g:for={col in columns} g:lane="server"` over a `server {}` field is a +server-rendered list, while component state uses +`g:for={todo in todos} g:lane="client"`. A missing lane gets +`directive_lane_required`; a declaration that disagrees with its data gets +`directive_lane_mismatch`. There are no separate `g:each`/`g:when` directives. ## Server-rendered lists (`g:for` over `server {}`) @@ -174,9 +161,9 @@ guard public server { => { columns } } view {
    -
    +

    {col.title}

    -
    +
    {issue.id} {issue.title}
    @@ -193,16 +180,15 @@ func LoadBoard(ssr.LoadContext) (map[string]any, error) { Contract: -- A top-level `g:for` over a declared `server {}` field renders server-side. The - same `g:for` over component `state`/`store` is a client island instead — the - lane follows the source. +- A top-level `g:for` with `g:lane="server"` must reference a declared + `server {}` field. Component `state`/`store` requires `g:lane="client"`. - Rows interpolate the item with `{item.Field}` (dotted paths such as `{item.author.name}` are supported) and the optional index with - `g:for={item, i in field}` then `{i}`. Field values are matched against map + `g:for={item, i in field} g:lane="server"` then `{i}`. Field values are matched against map keys, exported Go struct fields, or json tags, and are always escaped. -- Server lists nest. A nested `g:for={child in item.children}` must reference the +- Server lists nest. A nested `g:for={child in item.children} g:lane="server"` must reference the enclosing row item; its slice is resolved per parent row. Nested directives - inherit the server lane. + declare `g:lane="server"` too. - Rows support static markup, item interpolation, nested `g:for`, and nested `g:if` only. Components, other client directives (`g:on:*`, `g:bind:*`, islands), and `g:unsafe-html` are not part of a server row. Request-time @@ -224,8 +210,8 @@ guard public server { => { count, status } } view {
    -

    0 && status == "open"}>You have {count} open items

    -

    No issues yet

    +

    0 && status == "open"} g:lane="server">You have {count} open items

    +

    No issues yet

    } ``` @@ -239,9 +225,8 @@ func LoadBoard(ssr.LoadContext) (map[string]any, error) { Contract: -- A top-level `g:if` whose condition references a `server {}` field renders - server-side; over client `state`/`store` the same `g:if` is a client - conditional instead. +- A top-level `g:if` with `g:lane="server"` must reference a `server {}` field; + client `state`/`store` uses `g:lane="client"`. - A top-level server `g:if` accepts a full bool expression — comparisons (`==`, `!=`, `<`, `<=`, `>`, `>=`), logic (`&&`, `||`, `!`), and literals — over `server {}` fields, evaluated at request time. A value with no operator is a @@ -252,10 +237,10 @@ Contract: - A `g:if` branch shares the enclosing scope: a top-level branch interpolates `server {}` fields (`{count}`); a `g:if` inside a server `g:for` row references the row item (`{issue.id}`), and a **nested** server `g:if` is a single row - field (`g:if={issue.urgent}`), not a compound expression. + field (`g:if={issue.urgent} g:lane="server"`), not a compound expression. - Server `g:for` and `g:if` nest in either direction: a list inside a branch, a conditional inside a row. -- The empty/else branch is a sibling `g:if={!field}`. `g:else`/`g:else-if` are +- The empty/else branch is a sibling `g:if={!field} g:lane="server"`. `g:else`/`g:else-if` are client-only chains and cannot follow a server `g:if`. - A server-rendered `g:if` requires the SSR addon and a request-time page; it has no SPA/static output form. diff --git a/docs/language/stability.md b/docs/language/stability.md index 4a6dec81..1a6d5af4 100644 --- a/docs/language/stability.md +++ b/docs/language/stability.md @@ -7,7 +7,7 @@ page does the same for the language constructs themselves, so a user or tooling author can tell which syntax is safe to depend on and which is still moving. It complements, and is pinned by, the machine-checked -[Conformance Corpus](conformance.md): a `Stable` or `Partial` construct should +[Conformance Corpus](conformance.md): a `Stable` or `Experimental` construct should have an `accept/` case, and a `Planned`/`Deprecated` construct should have a `reject/` case asserting the diagnostic code named below. @@ -15,7 +15,7 @@ have an `accept/` case, and a `Planned`/`Deprecated` construct should have a - **Stable**: accepted by the current compiler and not expected to change shape within 0.x without a deprecation step. -- **Partial**: accepted for a narrower slice than the final contract; the syntax +- **Experimental**: accepted for a narrower slice than the final contract; the syntax is real but its capability will grow. - **Planned**: not accepted as source behavior yet; using it is rejected with the listed diagnostic code so it cannot become accidental behavior. @@ -36,14 +36,14 @@ neither the table nor the registry can drift without failing a test. | `package` | Stable | Required first declaration. | | `import` | Stable | Go import for colocated blocks. | | `use` | Stable | Package-scoped component import. | -| `paths {}` | Partial | Literal `=> { field: "value" }` records only. | -| `build {}` | Partial | Literal records and no-argument Go calls. | -| `server {}` | Partial | Request-time server-lane data; requires the SSR addon. | +| `paths {}` | Experimental | Literal `=> { field: "value" }` records only. | +| `build {}` | Experimental | Literal records and no-argument Go calls. | +| `server {}` | Experimental | Request-time server-lane data; requires the SSR addon. | | `view {}` | Stable | Markup; see directives below. | | `style {}` | Stable | Scoped CSS body. | -| `client {}` | Partial | Bounded component client language. | -| `go {}` / `go build {}` / `go server {}` / `go client {}` / `go addon.* {}` | Partial | Colocated Go lanes. | -| `store` / `props` / `state` / `emits` | Partial | Component contracts. | +| `client {}` | Experimental | Bounded component client language. | +| `go {}` / `go build {}` / `go server {}` / `go client {}` / `go addon.* {}` | Experimental | Colocated Go lanes. | +| `store` / `props` / `state` / `emits` | Experimental | Component contracts. | | Unknown top-level block | Planned | Rejected with `unsupported_top_level_block`. | ## Metadata Keywords @@ -60,7 +60,7 @@ All metadata keywords are **Stable**. The canonical list is `lang.MetadataKeywor | `image` | Stable | | `robots` | Stable | | `noindex` | Stable | -| `jsonld` | Partial | +| `jsonld` | Experimental | | `preload` | Stable | | `prefetch` | Stable | | `layout` | Stable | @@ -83,22 +83,23 @@ Supported exact-name directives (the closed set in | Directive | Tier | Notes | | --- | --- | --- | -| `g:if` | Stable | Conditional render. Server-side over a `server {}` field; a client island over state/store. `g:else-if`/`g:else` are client-only chains. | -| `g:for` / `g:key` | Stable | List render. Server-side over a `server {}` field; a client island over state/store. The lane is inferred from the operand. | -| `g:bind:value` / `g:bind:checked` | Partial | Two-way bindings. | -| `g:on:*` | Partial | Event handlers with `.prevent`/`.stop`/`.once`/`.capture`/`.debounce`/`.throttle`. | -| `g:post` / `g:target` / `g:swap` | Partial | Progressive form/fragment submission. | -| `g:max-file-size` / `g:max-files` | Partial | Server-side upload policy for multipart action forms. | -| `g:message:*` | Partial | `required`, `minlength`, `maxlength`, `pattern`. | -| `g:island` | Partial | `js` or `wasm` island. | -| `g:command` / `g:query` | Partial | Contract web adapters. | -| `g:subscribe` | Partial | Realtime presentation-event subscription metadata on query-owned elements. | -| `g:event` | Partial | Parses to explain backend-owned domain events. | +| `g:if` | Stable | Conditional render. Requires `g:lane="server"` over `server {}` data or `g:lane="client"` over state/store. `g:else-if`/`g:else` are client-only chains. | +| `g:for` / `g:key` | Stable | List render. Requires an explicit `g:lane` on the `g:for` element. | +| `g:lane` | Stable | String literal `server` or `client` beside every `g:for`/`g:if`; declarations that disagree with data ownership fail. | +| `g:bind:value` / `g:bind:checked` | Experimental | Two-way bindings. | +| `g:on:*` | Experimental | Event handlers with `.prevent`/`.stop`/`.once`/`.capture`/`.debounce`/`.throttle`. | +| `g:post` / `g:target` / `g:swap` | Experimental | Progressive form/fragment submission. | +| `g:max-file-size` / `g:max-files` | Experimental | Server-side upload policy for multipart action forms. | +| `g:message:*` | Experimental | `required`, `minlength`, `maxlength`, `pattern`. | +| `g:island` | Experimental | `js` or `wasm` island. | +| `g:command` / `g:query` | Experimental | Contract web adapters. | +| `g:subscribe` | Experimental | Realtime presentation-event subscription metadata on query-owned elements. | +| `g:event` | Experimental | Parses to explain backend-owned domain events. | | `g:unsafe-html` | Stable | Raw HTML escape hatch; `unsafe_raw_html` is reported. | -| `g:ref` | Partial | Client reference. | -| `g:slot` | Partial | Named/scoped slot. | -| `g:transition` | Partial | CSS class/state hooks for client `g:if` branches and keyed client `g:for` rows. | -| `g:animate` | Partial | CSS class/state hooks for keyed client `g:for` row moves. | +| `g:ref` | Experimental | Client reference. | +| `g:slot` | Experimental | Named/scoped slot. | +| `g:transition` | Experimental | CSS class/state hooks for client `g:if` branches and keyed client `g:for` rows. | +| `g:animate` | Experimental | CSS class/state hooks for keyed client `g:for` row moves. | Component calls also accept `g:bind:` for exported child state fields. HTML elements remain limited to `g:bind:value` and `g:bind:checked`. @@ -107,7 +108,7 @@ fields. HTML elements remain limited to `g:bind:value` and `g:bind:checked`. | Construct | Tier | Notes | | --- | --- | --- | -| `{#await}` | Partial | Client-island async placeholder for `fetchJSON[T](urlExpr)` with pending, `{:then name}`, and optional `{:catch err}` branches. | +| `{#await}` | Experimental | Client-island async placeholder for `fetchJSON[T](urlExpr)` with pending, `{:then name}`, and optional `{:catch err}` branches. | Planned directives are rejected. They currently surface as the generic `parse_error` rather than the intended `unsupported_markup_directive` code; that @@ -130,6 +131,6 @@ and likewise currently surfaces as `parse_error` (intended: | --- | --- | --- | | `act` | Stable | `act POST ""`; POST only today. | | `api` | Stable | `api ""`; GET/POST/PUT/PATCH/DELETE. | -| `fragment` | Partial | First-slice partial updates. | +| `fragment` | Experimental | First-slice partial updates. | | `act` block form | Deprecated | `act { ... }`; rejected with `old_action_block_syntax`. | | `api` block form | Deprecated | `api { ... }`; rejected with `old_api_block_syntax`. | diff --git a/docs/language/syntax.md b/docs/language/syntax.md index 9d82e0e1..ed49bdd6 100644 --- a/docs/language/syntax.md +++ b/docs/language/syntax.md @@ -438,7 +438,7 @@ block:

    Loading

    {:then items}
      -
    • {item.Name}
    • +
    • {item.Name}
    {:catch err}

    {err.message}

    @@ -497,7 +497,7 @@ client { `len(value)` accepts strings and arrays and returns `int`. `lower(value)` and `upper(value)` accept strings and return strings. `contains(value, query)` accepts strings and returns `bool`; it is intended for small component-local -filters such as `g:if={contains(lower(item.Name), lower(Query))}` inside +filters such as `g:if={contains(lower(item.Name), lower(Query))} g:lane="client"` inside `g:for`. `string(value)` converts scalar values to `string`. `int(value)` and `float(value)` accept strings or numeric values and return the requested numeric type. @@ -660,7 +660,7 @@ Elements inside stateful components can use first-slice conditional rendering: ```gwdk view { -
    Open content
    +
    Open content
    Loading
    Closed
    } @@ -676,8 +676,8 @@ list rendering: ```gwdk view { -
  • {item.Name}
  • -
  • {i}: {item.Name}
  • +
  • {item.Name}
  • +
  • {i}: {item.Name}
  • } ``` diff --git a/docs/language/v1-budget.md b/docs/language/v1-budget.md new file mode 100644 index 00000000..64f812fd --- /dev/null +++ b/docs/language/v1-budget.md @@ -0,0 +1,54 @@ +# V1 Language Budget + +GOWDK v1 is a small compiler language around Go, HTML, and CSS. New syntax must +remove more app complexity than it adds to parser, analyzer, formatter, LSP, +generated-output, and migration contracts. + +## Core + +- Metadata, routes, layouts, imports, components, `view {}`, and `style {}`. +- Build-time pages by default; `paths {}` expands dynamic SPA routes. +- `act`, `api`, and fragments for request-time backend behavior. +- `server {}` / `go server {}` for the explicit non-default request-time page + lane. +- `build {}` for bounded data assembly: literals, bounded collection + expressions, and explicit no-argument calls into ordinary Go. +- `client {}` for bounded UI event/state orchestration only. + +## Experimental + +Every accepted construct marked `Experimental` in +[Language Construct Stability](stability.md) is inside the implementation but +outside the frozen v1 core. It must retain conformance or named integration +coverage and may change through an explicit migration diagnostic. + +## Planned Or Out Of Budget + +- General computation belongs in Go, not a growing `build {}` expression + language. Add data-shaping primitives only when they stay deterministic, + bounded, and materially clearer than a Go helper. +- TypeScript is transform-only. GOWDK may strip/compile supported TypeScript + syntax for browser assets; it does not implement a second type checker or + server-side TypeScript runtime. +- Browser client code does not own routing truth, authorization, validation, + durable state, cache policy, or business workflows. +- Foreign template mini-languages and implicit execution-lane inference are + outside the v1 budget. + +## Proposal Gate + +Before accepting a new language construct, answer all of these in the feature +spec or issue: + +1. What user problem cannot be solved clearly with existing syntax plus Go? +2. Is the construct core or experimental, and what is its migration story? +3. Which execution lane owns it: build, server, or client? +4. What are its deterministic resource bounds and failure diagnostics? +5. Which AST, IR, formatter, inspect, LSP, and generated-output contracts change? +6. Which conformance accept/reject case or named integration test covers it? +7. What existing syntax can be removed or kept out because this is added? + +`scripts/check-language-budget.sh` is the CI gate. It rejects a supported +keyword/directive missing from the stability registry, a registry construct +missing from the published classification, or a construct without corpus or +named integration coverage. diff --git a/docs/learning/native.md b/docs/learning/native.md index f14e93f4..cb6338ef 100644 --- a/docs/learning/native.md +++ b/docs/learning/native.md @@ -97,8 +97,8 @@ Use installed `gowdk` commands inside an initialized app. Use - Read [guards](../language/guards.md) and [hooks](../reference/hooks.md). - Inspect the protected flagship route in `examples/flagship/src/app/dashboard.page.gwdk`. -- Custom guards need generated-app hooks; the flagship `Makefile` copies - `apphooks/flagship_hooks.go.txt` before building the binary. +- Custom guards use `gowdk.RegisterGuards` in the flagship `Config.Interop`; + ordinary feature packages never import the generated app package. ## Lesson 13: Use A Database From Go diff --git a/docs/product/dev-hmr-v2-spec.md b/docs/product/dev-hmr-v2-spec.md new file mode 100644 index 00000000..7542ddae --- /dev/null +++ b/docs/product/dev-hmr-v2-spec.md @@ -0,0 +1,93 @@ +# Feature Spec: Development Update Protocol V2 + +## Problem + +The current dev-update v1 protocol preserves compatible JavaScript island +state only for mapped component edits. Page, layout, source-set, WASM, and +broader dependency changes reload the entire document, and the protocol does +not describe patch/remount compatibility or stale cleanup explicitly. + +## Goals + +- Patch compatible page and layout generations without stale DOM/head state. +- Preserve page-store and JavaScript island state only across compatible + shapes. +- Remount WASM islands without transferring opaque WASM state. +- Fall back to one deterministic reload for unsupported or unattributed edits. +- Keep all HMR behavior development-only. + +## Non-Goals + +- Production hydration or a browser-owned routing model. +- State transfer across incompatible schemas or WASM instances. +- HMR for generated runtime/ABI changes. + +## Users And Permissions + +- Primary users: developers running `gowdk dev`. +- Roles or permissions: local development only. +- Data visibility rules: update payloads contain generated route/component + identifiers and shape hashes, not application data. + +## User Flow + +1. A successful incremental rebuild attributes changed inputs to routes. +2. The server emits a v2 update with a patch, remount, or reload decision. +3. The browser checks protocol and state-shape compatibility, fetches the fresh + document, synchronizes managed head/body content, cleans old islands, and + remounts current JavaScript/WASM roots. +4. Any failed check performs one full reload. + +## Requirements + +### Functional + +- Version 2 payloads name `patch`, `component-remount`, or `reload` actions, + affected routes, preservation policy, and compatibility boundaries. +- Page and layout patches replace stale body and managed head metadata. +- Compatible page-store and JavaScript island state is carried forward. +- Incompatible stores/islands remount from fresh seeds or reload as declared. +- WASM roots remount without state transfer. +- Added/removed/renamed components and generated assets disappear after the + committed rebuild. +- Imported components, component CSS/assets/stores, and layouts participate in + dependency attribution where the compiler IR provides ownership. +- Build failures keep the last committed output and use the existing overlay. + +### Non-Functional + +- Performance: one fresh-document fetch per patch update. +- Reliability: a patch failure triggers exactly one reload. +- Accessibility: focus is restored by stable element ID/name when possible. +- Security/privacy: HMR stays injected by the dev server and is absent from + production assets. +- Observability: stable `gowdk:dev-update`, `gowdk:component-hmr`, and + `gowdk:page-hmr` events describe outcomes. + +## Acceptance Criteria + +- [x] Compatible component, page, and layout edits preserve documented state. +- [x] Incompatible/unattributed edits reliably reload once. +- [x] Removed DOM, head metadata, components, and assets do not remain active. +- [x] WASM roots rebuild/remount without state transfer. +- [x] Browser tests cover preservation, incompatible shapes, cleanup, + navigation, WASM fallback/remount, overlay recovery, and forced reload. +- [x] Production-generated output is byte-identical with or without dev HMR. + +## Edge Cases + +- Current tab is outside all affected routes. +- Duplicate component roots, removed root, renamed component, missing stable + focus target, malformed fresh HTML, and fetch failure. +- Store shape changes while island shape stays the same and vice versa. +- Unknown protocol version or runtime ABI marker. + +## Dependencies + +- Internal: incremental dependency graph, transactional publication, generated + island/store markers, dev SSE bridge, browser test harness. +- External: none in production; browser tests use the existing Node tooling. + +## Open Questions + +- None for v2. Cross-navigation state preservation remains out of scope. diff --git a/docs/product/language-server.md b/docs/product/language-server.md index 2a24fcfa..43c20076 100644 --- a/docs/product/language-server.md +++ b/docs/product/language-server.md @@ -72,11 +72,33 @@ Developers editing `.gwdk` files need live feedback from the same language tooli ### Non-Functional - Performance: validate one open buffer quickly enough for interactive editing. -- Reliability: malformed protocol messages should return JSON-RPC errors instead of crashing. +- Reliability: malformed JSON inside a valid frame returns a JSON-RPC parse + error and the session continues. Ambiguous, truncated, or oversized framing + terminates the stdio session because the next frame boundary is not safe to + infer. - Accessibility: editor clients should receive standard diagnostics and completion metadata. - Security/privacy: no network access and no external process execution inside the language server. - Observability: protocol errors should be written to stderr. +The default per-session limits are finite and embedding callers can override +them through `lsp.ProjectOptions.Limits` without mutable global state: + +| Input | Default limit | +| --- | ---: | +| One header line | 8 KiB | +| All headers | 64 KiB | +| Header count | 64 | +| One JSON-RPC body | 16 MiB | +| One open document | 8 MiB | +| All retained open-document text | 64 MiB | +| Open documents | 256 | + +`Content-Length` is an unsigned decimal byte count. Identical duplicate values +are tolerated; conflicting duplicates, overflow, signs, missing values, and +values above the body limit are fatal framing errors. Logs include only a +stable rejection code and byte/count metadata, never the raw body or source +text. + ## Acceptance Criteria - [x] `gowdk lsp` starts and answers an LSP `initialize` request. @@ -100,6 +122,10 @@ Developers editing `.gwdk` files need live feedback from the same language tooli recursive-descent outline pass over the shared tokenizer (ADR 0010). - [x] `gowdk/tree` returns the versioned inspect tree projection for open project documents. +- [x] Header lines, aggregate headers, header count, message bodies, document + size, retained text, and open-document count have tested finite limits. +- [x] An over-limit full-text change preserves the last valid document snapshot + and sends an editor-facing `window/showMessage` notification. - [x] `go test ./...` and `go build ./cmd/gowdk` pass. ## Edge Cases diff --git a/docs/product/requirements.md b/docs/product/requirements.md index e146c7fe..d51b9314 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -38,7 +38,7 @@ language references, compiler docs, and examples. | PRD-010 | Provide CSS processor addon extension points without adding Tailwind to the compiler core or runtime core. | High | Partial | `FeatureCSS`, `addons/css`, configured stylesheet links, compile-time CSS processors, discovered CSS inputs, extracted literal classes, `css` page selection, generated page CSS output, CSS asset manifest entries, page-aware processor stylesheet selections, component CSS AST/IR scope and hash metadata, emitted scoped component CSS linked only from pages that use the component, emitted component `asset` files, scoped selector/keyframe rewriting, deterministic CSS ordering, native config-helper execution for importable addon values, an experimental Tailwind v4 standalone-CLI wrapper, and generated CSS/component asset content-hashed emitted filenames are implemented; richer CSS processor addon capabilities remain planned. | | PRD-011 | Support embedded assets and one-binary serving. | High | Partial | `addons/embed` and `runtime/asset` boundaries exist; `gowdk serve` can serve generated build output locally; `gowdk build --app` can generate an embedded app, `--bin` can compile it into one binary, `--docker` can emit a minimal non-root Dockerfile and `.dockerignore` beside that binary, `--deploy-recipe` can emit optional static/systemd/Caddy/Nginx/split frontend-backend starter files, and `--wasm` can compile a Go `js/wasm` artifact for SPA pages, feature-bound action/API handlers, action redirects, action fragments, standalone concrete or dynamic fragments, concrete or dynamic SSR pages with declared `server {}` identifier or dotted paths, and concrete or dynamic hybrid request-time pages with or without declared `server {}` data. CI now starts the generated one-binary embed example and verifies both `/_gowdk/health` and the embedded page response. | | PRD-012 | Support server fragments for partial updates without full-page SSR. | Medium | Partial | `addons/partial`, generated client runtime emission, generated action fragment responses for partial POSTs, standalone concrete and dynamic fragment routes with raw and typed route params for request-time hooks, generated required-field validation fragments for partial POSTs, generated CSRF validation when enabled, and first-slice generated JavaScript islands for local component state are implemented. Richer fragment rendering and broader local client-side reactivity remain planned. | -| PRD-013 | Complete request-time page rendering with `server {}`, guards, layouts, and error handling. | Medium | Partial | `addons/ssr` registers the SSR feature. `runtime/ssr` provides load context, route registration, request-aware layout composition, safe local redirect errors, default error-handler contracts, and declared load path resolution. `runtime/guard` provides shared guard context/registry/execution plus no-store redirect/custom-response helpers for generated SSR/action/API/fragment routes, and `runtime/auth` provides thin native RBAC principal/provider helpers for defense-in-depth generated route access gates, and `addons/auth` exposes an app-owned revocable session-store contract with authorization-version checks while generated auth-addon startup remains signed-cookie; backend authorization remains normal Go code and is never replaced by guard metadata. Generated embedded apps can serve concrete and dynamic request-time SSR pages rendered from `view {}` and literal or imported `build {}` data, generated SSR/action/API/fragment routes use `auth.Addon` defaults for `auth.required` and native `role:`/`permission:` session guards when configured; otherwise they require `GOWDKGuardRegistry` for custom guard IDs and `GOWDKAuthProvider` for native RBAC guard IDs, fail Go compilation when required backing hooks are missing, run declared guards before user logic, and have generated-binary coverage for registered guard success and redirect paths, `server { => { field, user.name } }` execution calls same-package Go load functions through `ssr.LoadContext`, optional generated `404.html`/`500.html` pages are used by runtime app error responses, SSR routes can declare `error "/errors/page.html"` for route-local generated load/render failure and route panic pages, action/API declarations can declare endpoint-local `error` pages for generated panic boundaries, and generated SSR/action/API lanes have no-store panic boundaries. | +| PRD-013 | Complete request-time page rendering with `server {}`, guards, layouts, and error handling. | Medium | Partial | `addons/ssr` registers the SSR feature. `runtime/ssr` provides load context, route registration, request-aware layout composition, safe local redirect errors, default error-handler contracts, and declared load path resolution. Typed `Config.Interop` registrations bind page loads, custom guard registries, and auth providers before generation; missing bindings are compiler diagnostics and `inspect go-bindings` exposes their Go references. `runtime/guard`, `runtime/auth`, and `addons/auth` provide fail-closed route gates while backend authorization remains app-owned. Generated embedded apps serve concrete and dynamic request-time pages, use route/layout/global error documents, recover request-time panics, and keep generated error responses no-store. | | PRD-014 | Add optional WASM islands after the core compiler and action flow are stable. | Low | Partial | Component-level `wasm` declarations make normal calls to that component emit WASM and loader assets under `assets/gowdk/islands/`; explicit `g:island="wasm"` remains supported as a call-site override. Declared `wasm` browser-side Go packages and page-level `go client {}` mounts are compiled with `GOOS=js GOARCH=wasm`, checked for browser-unsafe imports, ship the Go `wasm_exec.js` runtime asset, instantiate through Go runtime imports when needed, and validate required GOWDK ABI exports. Browser-runtime integration coverage exercises the generated host loader mount, event, patch, emit, cleanup, invalid-patch rejection, shared page-store participation, and persistence contract; `runtime/wasm` exposes payload/result helpers for Go exports. Fuller user-code runtime validation remains planned. | | PRD-015 | Provide language tools for `.gwdk` token inspection, formatting, validation, manifest output, and LSP editor integration. | High | Implemented | `internal/lang`, `internal/lsp`, `internal/inspectreport`, and CLI commands exist, including source-linked inspect tree, endpoint graph output, and Go binding inspection. | | PRD-016 | Define the current hybrid request-time page contract without adding separate page syntax. | High | Implemented | [Hybrid Lifecycle Contract](hybrid-lifecycle-spec.md) is the source of truth. Pages default to SPA; config-selected hybrid pages and request-time pages with effective hybrid mode use the integrated request-time lane, require the SSR feature, skip build-time prerender output with `request_time_page_skipped`, expose `hybrid` in route reports, and keep streaming, browser-owned server-data refresh, non-HTTP revalidation, and implicit action invalidation unsupported until a future source contract exists. | diff --git a/docs/product/v1-hardening-spec.md b/docs/product/v1-hardening-spec.md new file mode 100644 index 00000000..fb0bb2e1 --- /dev/null +++ b/docs/product/v1-hardening-spec.md @@ -0,0 +1,99 @@ +# V1 Contract Hardening + +## Problem + +GOWDK has mature compiler and runtime slices, but several pre-v1 boundaries are +still implicit or weakly bounded. Configuration can silently accept unsupported +AST forms, project env loading mutates process state, generated packaging can +modify module files, LSP input and retained documents are unbounded, command +metadata is duplicated, and request-time errors are not connected to the +existing locale catalogs. + +## Goals + +- Make configuration, environment, packaging, CLI, LSP, and static-analysis + behavior deterministic and bounded. +- Make source execution lanes and Go hook bindings explicit and inspectable. +- Reuse `runtime/i18n` for stable user-facing runtime error codes. +- Preserve build-time pages as the default and generated Go as adapter glue. + +## Non-Goals + +- Translating compiler diagnostics. +- Adding a second catalog or expression language. +- Guaranteeing identical binaries across different Go toolchains, targets, CGO + toolchains, tags, or semantic inputs. +- Supporting unbounded LSP messages or documents. + +## Requirements + +### Configuration and environment + +- AST-only config loading fails closed for unknown fields, unkeyed literals, + duplicate fields, and unsupported expressions; supported dynamic Go values + delegate to the executable loader. +- Project env files produce immutable overlays. Project loading never calls + `os.Setenv`; config validation and child processes receive explicit lookups + and environment slices. +- Root config types remain import-compatible but are split by concern. Invalid + CSRF policy, target topology, identifiers, schedules, and provider references + fail validation before generation. + +### CLI and CI + +- One recursive command schema owns command/subcommand names, flag groups, + usage, documentation records, and shell completion records. +- CI runs broad `go vet` over every repository Go module and reviewed + Staticcheck checks beyond `U1000`, with actionable per-module output. + +### Language and bindings + +- The v1 language budget classifies core, experimental, planned, and migration + syntax. New syntax proposals use one complete compiler/tooling checklist. +- `g:for` and `g:if` carry an explicit `g:lane="server|client"`; the compiler + rejects missing, mixed, or ownership-mismatched lane declarations. Tooling + reports the declared/resolved lane. +- SSR loads, custom guards, and auth providers use explicit Go symbol + references. The former magic names remain migration-only diagnostics. + +### Packaging and LSP + +- Native and WASM packaging use read-only module resolution, `-trimpath`, + `-buildvcs=false`, temporary sibling outputs, atomic publication, and safe + reproducibility metadata including SHA-256. +- LSP header lines, aggregate headers, header count, message bodies, open + document count, individual documents, and aggregate retained text are bounded + by per-server limits. Framing failures have stable codes and fatality policy. + +### Localized runtime errors + +- A runtime error value carries a stable code, safe default message, variables, + and optional cause/status. +- `runtime/i18n` resolves that value through existing string-key catalogs and + falls back to the default message. +- Generated validation, action/API, guard/auth, fragment, and SSR error + responses expose the stable code and localized safe message. + +## Acceptance Criteria + +- [x] Issues #771, #758, #726, #724, #722, #721, #720, #717, #716, #714, + #697, and #695 have focused tests covering their published acceptance items. +- [x] Generated examples and public docs use only current contracts. +- [x] Root tests, nested-module tests, CLI build, docs gates, static analysis, + and representative generated-app builds pass. + +## Edge Cases + +- Conflicting duplicate `Content-Length` values terminate the LSP session; + identical duplicates are accepted. +- An over-limit document update preserves the last valid snapshot. +- A failed package build preserves the previous valid artifact. +- Missing translations and unknown locales use the safe default message. +- Process environment values override env-file values without modifying either + source map. + +## Dependencies + +- Internal: compiler IR, project loader, generated app plans, runtime response, + runtime i18n, LSP, and command metadata. +- External: Go toolchain and the already-pinned Staticcheck tool only. diff --git a/docs/reference/addons.md b/docs/reference/addons.md index 107ce72c..60468769 100644 --- a/docs/reference/addons.md +++ b/docs/reference/addons.md @@ -44,9 +44,9 @@ the rest are opt-in extension points. (`sitemap.xml`/`robots.txt`), and `gowdk.GoBlockConsumer.GeneratedGo` (files relative to the generated app directory, formatted before writing). 4. **Runtime hook registration** — generated apps register runtime hooks from - user-owned Go in the generated package, for example - `RegisterRateLimiter(*ratelimit.Limiter)`, custom `GOWDKGuardRegistry` - entries, `GOWDKAuthProvider() auth.Provider`, or + explicit typed `Config.Interop` providers, for example custom guard + registries and auth providers, plus generated APIs such as + `RegisterRateLimiter(*ratelimit.Limiter)` or `RegisterContractEventSink(...)`. The built-in auth addon is the narrow exception: `auth.Addon(auth.Options{...})` wires its own session provider and `auth.required` guard. GOWDK never calls third-party runtime code implicitly; @@ -100,7 +100,7 @@ preserved exactly. build-time extension options. - `runtime/` packages provide request-time helpers used by generated apps and application Go. -- Generated app hooks such as `RegisterRateLimiter` and `GOWDKAuthProvider` +- Typed config providers and generated APIs such as `RegisterRateLimiter` wire application-owned runtime objects. External addons are not implicit background services. - `gowdk.NewAddon(name, features...)` is only a marker for feature checks unless @@ -473,9 +473,9 @@ if err != nil { cookie, err := sessions.Cookie(auth.Principal{ID: userID, Roles: []string{"user"}}) ``` -Custom guard IDs still require `GOWDKGuardRegistry`. Native `role:` and -`permission:` guards require `GOWDKAuthProvider` only when the auth addon is not -configured. +Custom guard IDs require `Config.Interop.Guards`. Native `role:` and +`permission:` guards require `Config.Interop.AuthProvider` only when the auth +addon is not configured. GOWDK owns generated guard dispatch, CSRF validation, signed session cookie helpers, the revocable session interface, and native RBAC checks. Application Go diff --git a/docs/reference/cli-schema.md b/docs/reference/cli-schema.md new file mode 100644 index 00000000..5783ed99 --- /dev/null +++ b/docs/reference/cli-schema.md @@ -0,0 +1,285 @@ +# CLI Command Schema + +This file is generated from `internal/gowdkcmd.CommandSpec`. + +## `gowdk version` + +```text +usage: gowdk version [--json] +``` + +## `gowdk init` + +```text +usage: gowdk init [--force] [--tests] [--template ] [dir] +``` + +## `gowdk add` + +```text +usage: gowdk add [--config ] [--base-url ] | gowdk add --list [--registry] [--json] +``` + +## `gowdk tokens` + +```text +usage: gowdk tokens +``` + +## `gowdk fmt` + +```text +usage: gowdk fmt [--write] [--check] +``` + +## `gowdk check` + +```text +usage: gowdk check [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--warnings-as-errors] [--standalone] [--ssr] [files...] +``` + +## `gowdk env` + +```text +usage: gowdk env check [--config ] [--env-file ] [--json] +``` + +## `gowdk env check` + +```text +usage: gowdk env check [--config ] [--env-file ] [--json] +``` + +## `gowdk fix` + +```text +usage: gowdk fix [--dry-run] [--code ] [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk manifest` + +```text +usage: gowdk manifest [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk sitemap` + +```text +usage: gowdk sitemap [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk routes` + +```text +usage: gowdk routes [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk endpoints` + +```text +usage: gowdk endpoints [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk inspect` + +```text +usage: gowdk inspect ir|tree|endpoint-graph|asset-graph|go-bindings [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk inspect ir` + +```text +usage: gowdk inspect ir [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk inspect tree` + +```text +usage: gowdk inspect tree [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk inspect endpoint-graph` + +```text +usage: gowdk inspect endpoint-graph [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk inspect asset-graph` + +```text +usage: gowdk inspect asset-graph [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk inspect go-bindings` + +```text +usage: gowdk inspect go-bindings [--config ] [--project-root ] [--env-file ] [--module ] [--json] [--ssr] [files...] +``` + +## `gowdk generate` + +```text +usage: gowdk generate stubs [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk generate stubs` + +```text +usage: gowdk generate stubs [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [files...] +``` + +## `gowdk explain` + +```text +usage: gowdk explain [--json] +``` + +## `gowdk doctor` + +```text +usage: gowdk doctor [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [files...] +``` + +## `gowdk test` + +```text +usage: gowdk test [--config ] [--env-file ] [--module ] [--target ] [--stage ] [--run ] [--timeout ] [--count ] [--cover] [--json] [--keep-workdir] [--browser-command ] [--ssr] [files...] +``` + +## `gowdk audit` + +```text +usage: gowdk audit [--config ] [--project-root ] [--env-file ] [--module ] [--ssr] [--json] [--sarif[=]] [--diff ] [--schema[=report|security]] [--emit-tests[=]] [--check-tests[=]] [--force] [--run] [--run-timeout=] [files...] +``` + +## `gowdk contracts` + +```text +usage: gowdk contracts [--json] [dir] +``` + +## `gowdk graph` + +```text +usage: gowdk graph [--json] [dir] +``` + +## `gowdk trace` + +```text +usage: gowdk trace [--json] [dir] +``` + +## `gowdk list` + +```text +usage: gowdk list commands|queries|events|jobs [--json] [dir] +``` + +## `gowdk list commands` + +```text +usage: gowdk list commands|queries|events|jobs [--json] [dir] +``` + +## `gowdk list queries` + +```text +usage: gowdk list commands|queries|events|jobs [--json] [dir] +``` + +## `gowdk list events` + +```text +usage: gowdk list commands|queries|events|jobs [--json] [dir] +``` + +## `gowdk list jobs` + +```text +usage: gowdk list commands|queries|events|jobs [--json] [dir] +``` + +## `gowdk build` + +```text +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...] +``` + +## `gowdk clean` + +```text +usage: gowdk clean [--config ] [--target ] [--out ] [--dry-run] [--json] +``` + +## `gowdk dev` + +```text +usage: gowdk dev [--addr ] [--interval ] [build flags...] +``` + +## `gowdk preview` + +```text +usage: gowdk preview [--addr ] [--hot] [build flags...] +``` + +## `gowdk playground` + +```text +usage: gowdk playground policy [--json] | gowdk playground export --dir --out [--json] | gowdk playground run --dir --out --allow-hosted-execution (--module-cache | --allow-shared-module-cache) +``` + +## `gowdk playground policy` + +```text +usage: gowdk playground policy [--json] | gowdk playground export --dir --out [--json] | gowdk playground run --dir --out --allow-hosted-execution (--module-cache | --allow-shared-module-cache) +``` + +## `gowdk playground export` + +```text +usage: gowdk playground policy [--json] | gowdk playground export --dir --out [--json] | gowdk playground run --dir --out --allow-hosted-execution (--module-cache | --allow-shared-module-cache) +``` + +## `gowdk playground run` + +```text +usage: gowdk playground policy [--json] | gowdk playground export --dir --out [--json] | gowdk playground run --dir --out --allow-hosted-execution (--module-cache | --allow-shared-module-cache) +``` + +## `gowdk serve` + +```text +usage: gowdk serve --dir [--addr ] +``` + +## `gowdk lsp` + +```text +usage: gowdk lsp [--config ] [--project-root ] [--module ] [--ssr] +``` + +## `gowdk completion` + +```text +usage: gowdk completion +``` + +## `gowdk completion bash` + +```text +usage: gowdk completion bash +``` + +## `gowdk completion zsh` + +```text +usage: gowdk completion zsh +``` + +## `gowdk completion fish` + +```text +usage: gowdk completion fish +``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cfba54c2..75b3a475 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,5 +1,9 @@ # CLI Reference +The generated [CLI command schema](cli-schema.md) is the exact recursive +command/help surface used for shell completion. Regenerate it with +`scripts/generate-cli-schema.sh`; CI rejects drift. + The current CLI includes language tooling, an initial build-output command, generated embedded app output, and a local output-serving command for development. @@ -159,8 +163,9 @@ gowdk lsp [--config ] [--project-root ] [--module ] [--ssr] `gowdk.config.go`, including `check`, `doctor`, `test`, `audit`, `manifest`, `sitemap`, `routes`, `endpoints`, `inspect`, `generate stubs`, and `build`; forwarded through `dev` and `preview` as a build flag. Values from the file - are applied only when the process environment does not already define the - name. Without the flag, commands auto-load `.env.` from the + form an explicit per-project subprocess overlay and never mutate the CLI + process; an existing process value still wins. Without the flag, commands + auto-load `.env.` from the project root when `GOWDK_ENV` is set and that file exists, otherwise `.env` when present. Secret values are never printed; `doctor --json` reports only the env-file path and variable names for file/process sources. diff --git a/docs/reference/config.md b/docs/reference/config.md index aceca2ca..42438a82 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -13,10 +13,18 @@ type Config struct { Build BuildConfig CSS CSSConfig I18N I18NConfig + Features FeatureConfig + Extensions []Extension Addons []Addon } ``` +The public surface is organized by ownership: `config.go` owns the root and +source/render selection, `config_build.go` owns packaging and runtime policy, +`config_css.go` owns CSS input/output, and `config_validation.go` owns the +fail-fast cross-field contract. `Config.ValidateStructural` is the canonical +entry point used by project loading. + ## Source `gowdk.config.go` is required for CLI commands that compile, validate, inspect, @@ -222,6 +230,7 @@ type I18NConfig struct { Locales []LocaleConfig DefaultLocale string OmitDefaultPrefix bool + Errors i18n.ErrorBundle } type LocaleConfig struct { @@ -256,6 +265,9 @@ Typed message catalogs live in `runtime/i18n`. The package provides: - `FormatPlural`, `FormatNumber`, `FormatDate`, and `FormatTime` for bounded, dependency-free formatting. These helpers are deterministic core helpers, not a CLDR or ICU MessageFormat replacement. +- `ErrorBundle`, `ErrorCode`, and `UserMessage` bridge stable generated/runtime + error codes to those same catalogs. Missing translations fall back to the + safe default carried by the error. ## Generated API CORS @@ -332,32 +344,38 @@ listeners, protocol bridges, and app-owned servers. MCP adapters belong in app code or an external package that returns lifecycle services; GOWDK does not ship a core MCP addon or runtime package. -## Generated App Request Guards - -When generated SSR, action, API, or fragment routes declare `guard`, the -generated app package can expose guard registration hooks. If `auth.Addon` is -configured, generated startup registers the default `auth.required` guard and a -session-backed provider for native `role:` / `permission:` guard IDs from the -addon options. +## Explicit Go Interop -Custom guard IDs still require a generated app hook: +`Config.Interop` connects request-time loads, custom guards, and native RBAC to +real exported Go functions. The registration constructors accept function +values, so Go rename/find-references tooling sees every edge and the compiler +can report a missing binding before generated app compilation. ```go -package gowdkapp +Interop: gowdk.InteropConfig{ + Loads: []gowdk.LoadRegistration{ + gowdk.RegisterLoad("dashboard", dashboard.Load), + }, + Guards: gowdk.RegisterGuards(security.Guards), + AuthProvider: gowdk.RegisterAuthProvider(security.AuthProvider), +}, +``` -import gowdkguard "github.com/cssbruno/gowdk/runtime/guard" +Provider signatures: -func GOWDKGuardRegistry() gowdkguard.Registry { - return gowdkguard.Registry{ - "auth.required": func(ctx gowdkguard.Context) error { - return nil - }, - } -} +```go +func Load(ssr.LoadContext) (DashboardData, error) +func Guards() guard.Registry +func AuthProvider() auth.Provider ``` -Native RBAC guard IDs such as `role:admin` and `permission:patients.read` use -an application-owned principal source instead of a custom guard function: +`RegisterLoad` supports the same map or typed-struct load return signatures as +the SSR contract. Page renames do not silently change a `Load` symbol; +the page ID is explicit in the registration. `inspect go-bindings --json` +reports page-load, guard, and auth references. + +Native RBAC guard IDs such as `role:admin` and `permission:patients.read` use an +application-owned principal source: ```go import ( @@ -366,17 +384,18 @@ import ( gowdkauth "github.com/cssbruno/gowdk/runtime/auth" ) -func GOWDKAuthProvider() gowdkauth.Provider { +func AuthProvider() gowdkauth.Provider { return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { return &gowdkauth.Principal{ID: "user-1", Roles: []string{"admin"}}, nil }) } ``` -This file belongs with generated app startup code, not inside feature packages -that declare handlers. Missing required backing functions fail the generated app -Go build when no addon supplies them. Guard errors still return HTTP 403 before -SSR load functions, action decoding, API handlers, or user business logic run. +These functions belong in normal feature or integration packages; they do not +import the generated `gowdkapp` package. Missing registrations produce +`missing_load_registration`, `missing_guard_registration`, or +`missing_auth_registration`. Guard errors still return HTTP 403 before user +logic runs. Native RBAC guards are a defense-in-depth redundancy layer for generated route/page access. They must never replace backend authorization inside @@ -498,9 +517,13 @@ gowdk build --env-file .env.production If `--env-file` is omitted, GOWDK auto-loads `.env.` from the project root when `GOWDK_ENV` is set and the file exists, otherwise `.env` when -present. Process environment values always win over file values. The file is -only a value source for the same validation contract; it does not bypass -`Required` or `MinBytes`. +present. Project loading parses the file into a workspace-scoped overlay; it +does not call `os.Setenv` or retain package-global values. Process environment +values always win, and config helpers/build subprocesses receive a derived +environment explicitly. Reloading or concurrently loading another project +therefore cannot leak file values between workspaces. The file is only a value +source for the same validation contract; it does not bypass `Required` or +`MinBytes`. Environment files accept `NAME=value` assignment lines up to 1 MiB (1,048,576 bytes), excluding the line ending; multiline continuation is not supported. @@ -545,6 +568,7 @@ type BuildConfig struct { ObfuscateAssets bool Head gowdk.HeadConfig CSRF gowdk.CSRFConfig + CORS gowdk.CORSConfig SecurityHeaders gowdk.SecurityHeadersConfig BodyLimits gowdk.BodyLimitsConfig AllowMissingBackend bool @@ -563,7 +587,6 @@ type HeadConfig struct { } type CSRFConfig struct { - Enabled bool Disabled bool SecretEnv string VerificationSecretEnvs []string @@ -671,9 +694,11 @@ signing secret from `SecretEnv` or `GOWDK_CSRF_SECRET`, inject a hidden token field into served HTML POST forms, and validate POSTs before generated decoding or user handlers run. Invalid or missing tokens return HTTP 403 with `invalid csrf token` and `Cache-Control: no-store`. Set `Disabled: true` only -for an intentional non-production/test opt-out. `Enabled` is retained for older -configs but is no longer required. `CookieName`, `FieldName`, and `HeaderName` -override the generated token transport names. +for an intentional non-production/test opt-out. The old `Enabled` compatibility +field was removed because the zero value already means enabled; using it now +fails config loading as an unknown field. `Disabled` cannot be combined with +secret, token-name, or insecure-cookie fields. `CookieName`, `FieldName`, and +`HeaderName` override the generated token transport names. `Insecure` is for local HTTP development only: it disables the Secure cookie flag, uses the default cookie name `gowdk-csrf` instead of `__Host-gowdk-csrf`, and rejects explicit `__Host-`/`__Secure-` cookie names @@ -704,8 +729,8 @@ checks and generated errors. Use it for app-owned headers such as `X-Frame-Options`. Keep TLS-boundary headers such as `Strict-Transport-Security` at the HTTPS edge unless the generated app is directly responsible for TLS. -`BodyLimits` controls generated request body caps in bytes. Omitted or -non-positive values use the default 1 MiB cap. `ActionBytes` applies to +`BodyLimits` controls generated request body caps in bytes. Zero uses the +default 1 MiB cap; negative limits fail configuration validation. `ActionBytes` applies to generated action POST handlers and web command form adapters before form decoding, including multipart action forms. Per-file upload policy is declared on file controls with `g:max-file-size`, `g:max-files`, and MIME `accept`. @@ -762,9 +787,43 @@ uses `global.css` as the default CSS input when present. `css`. Generated page CSS defaults to `assets/gowdk/.css` and hrefs under `/assets/gowdk/`. -## Addons +## Built-in features + +Use `Features` for compiler-owned behavior in new configurations: + +```go +var Config = gowdk.Config{ + Features: gowdk.FeatureConfig{ + SPA: true, + Actions: true, + SSR: true, + Auth: gowdk.AuthFeatureConfig{ + Enabled: true, + Session: gowdk.AuthSessionOptions{SecretEnv: "GOWDK_AUTH_SESSION_SECRET"}, + }, + SEO: gowdk.SEOFeatureConfig{ + Enabled: true, + Options: gowdk.SEOOptions{BaseURL: "https://example.com"}, + }, + }, +} +``` + +Typed feature configuration is separate from executable compiler extensions. +An extension in `Config.Extensions` must declare an +`ExtensionDescriptor` with protocol version `gowdk.ExtensionProtocolVersion`, +the phases it participates in, and required or optional versioned +capabilities. The executable config host performs a version handshake, reuses +the process within a command, applies request deadlines and payload limits, and +rejects generated paths that are absolute, duplicate, or escape their output +root. + +Extensions are build-time compiler participants, not runtime plugins. Runtime +services continue to use generated app hooks and runtime packages. + +## Addons compatibility -`Addons` registers optional features such as spa, actions, partial, SSR, API, +`Addons` is the deprecated 0.x compatibility lane for optional features such as spa, actions, partial, SSR, API, embed, CSS, contracts, realtime, auth, DB helpers, rate limiting, and SEO output. Current validation uses feature registration for render-mode, realtime, and other compiler checks; SPA builds invoke addons that implement @@ -788,7 +847,7 @@ constructor into `gowdk.config.go`. `gowdk add seo` requires command rewrites literal `Config.Addons` lists only; if `Addons` is computed by Go code, edit the config manually. -The config helper executes addon constructors as normal Go. Tooling that edits +Existing addon configs remain supported. The config helper executes addon constructors as normal Go. Tooling that edits `Config.Addons` recognizes built-in addon constructors when they are imported from their canonical package paths. Most are no-argument constructors; `addons/auth` accepts the generated-app-safe session options subset diff --git a/docs/reference/dev.md b/docs/reference/dev.md index 8f57152b..56b11549 100644 --- a/docs/reference/dev.md +++ b/docs/reference/dev.md @@ -82,13 +82,14 @@ Dev rebuild complete: generated app restarted: proxy http:// -> http://` roots, the dev bridge fetches the fresh document, @@ -102,21 +103,25 @@ into the fresh root only when the old and new markers match. A missing or changed marker remounts from the fresh document seed instead of preserving potentially incompatible local state. -Layout-only incremental SPA rebuilds send a route-scoped `reload` payload for -the pages that use the changed layout. Browser tabs outside those routes do not -reload. +Compatible page and layout rebuilds fetch one fresh document, replace stale +body content, synchronize managed title/meta/canonical/stylesheet/store-seed +head nodes, and remount current islands. Compatible page stores and JavaScript +island state remain in memory; changed shape markers reset from the fresh seed. +Focus is restored by stable `id` or `name` when possible. Browser tabs outside +the affected routes do not patch. -The dev bridge falls back to one full-page reload for page changes, source-set -changes, added or removed inputs, generated app/runtime mode, WASM output or -component WASM islands, and component changes that cannot be mapped to matching -island boundaries on the current page. Broader cross-component state transfer, -WASM state transfer, and page/layout DOM patching remain outside the current -HMR contract. +Changed WASM component roots use a document patch so the old instance is +destroyed and the new instance mounts without opaque WASM state transfer. The +dev bridge falls back to one full-page reload for source-set changes, added or +removed inputs, generated app/runtime mode, standalone WASM output, or changes +that cannot be mapped safely. Generated-app rebuild and runtime 5xx overlay delivery use the dev-only proxy bridge. Unsupported HMR cases continue to use the full-page reload fallback described above. The dev-update bridge is injected only by `gowdk dev`; normal -production-generated assets are unchanged by enabling the dev loop. +production-generated assets are unchanged by enabling the dev loop. Successful +updates emit `gowdk:dev-update` and either `gowdk:component-hmr` or +`gowdk:page-hmr`. ## Browser Overlay diff --git a/docs/reference/diagnostic-codes.md b/docs/reference/diagnostic-codes.md index 6a958a60..ecb7ca70 100644 --- a/docs/reference/diagnostic-codes.md +++ b/docs/reference/diagnostic-codes.md @@ -108,10 +108,13 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `source_line_too_long`, `unsupported_literal_record_syntax`, `unsupported_top_level_block`, `unsupported_layout_metadata`, `invalid_component_prop`, - `unsupported_component_prop_type`, `unterminated_string`. + `unsupported_component_prop_type`, `directive_lane_required`, + `directive_lane_invalid`, `unterminated_string`. - Environment files: `env_file_line_too_long`. - Packages and imports: `missing_package_declaration`, `package_mismatch`, `go_package_error`, `invalid_go_import`, `duplicate_go_import_alias`. +- Explicit Go interop: `missing_load_registration`, + `missing_guard_registration`, `missing_auth_registration`. - GOWDK source imports: `duplicate_gowdk_use_alias`, `unknown_gowdk_use_package`, `unknown_gowdk_use_alias`, `unknown_gowdk_component`, `unsupported_gowdk_use_scope`. @@ -123,6 +126,7 @@ Parser diagnostics emit stable codes for common unsupported syntax and keep `missing_page_guard`, `public_guard_exclusive`, and `guard_requires_request_render`. - Server-lane view directives (`g:for`/`g:if` over `server {}` data): + `directive_lane_mismatch`, `server_for_invalid`, `server_for_nested_scope`, `server_if_invalid`, `server_if_nested_scope`, `server_load_field_conflict`, `server_load_field_unknown`, diff --git a/docs/reference/errors.md b/docs/reference/errors.md index bd4b6a3d..aadd1864 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -16,6 +16,9 @@ Expected errors are user-owned handler results: 404, 403, 422, or 500. - Return `response.NewHandlerError(status, message, cause)` when a generated action or API handler should fail with a specific HTTP status. +- Prefer `response.NewExpectedCode(kind, code, defaultMessage, vars, cause)` for + user-facing failures. JSON responses expose `{ok:false,error:{code,message}}`; + non-JSON responses expose `X-GOWDK-Error-Code`. - Return ordinary Go errors only for failures where HTTP 500 is acceptable. Generated action and API adapters use `response.HandlerStatus`, defaulting to HTTP 500. @@ -37,6 +40,31 @@ Unexpected errors are generated-lane failures: `invalid csrf token`, or `validation failed`. - Generated form decoding and validation do not echo submitted values. +## Stable Codes And Localization + +Runtime error codes are separate from compiler diagnostic codes. Configure +localized messages by specializing the existing typed catalog: + +```go +var runtimeErrors = i18n.NewErrorBundle("en", map[string]i18n.Catalog[i18n.ErrorCode]{ + "en": i18n.NewCatalog("en", map[i18n.ErrorCode]string{ + "validation_required": "{field} is required", + }), + "pt": i18n.NewCatalog("pt", map[i18n.ErrorCode]string{ + "validation_required": "{field} é obrigatório", + }), +}) + +var Config = gowdk.Config{ + I18N: gowdk.I18NConfig{Errors: runtimeErrors}, +} +``` + +Generated validation, action/API JSON, guard, auth, and fragment failures carry +stable codes. Catalog lookup uses the active route locale, formats variables, +and falls back to the safe default message when a key or locale is absent. +`validation.Error` exposes `Field`, `Code`, `Message`, and optional `Vars`. + ## Generated Error Pages Generated embedded apps load these optional HTML files from build output: diff --git a/docs/reference/go-interop.md b/docs/reference/go-interop.md index 0c167be1..d27201d5 100644 --- a/docs/reference/go-interop.md +++ b/docs/reference/go-interop.md @@ -105,8 +105,14 @@ by `go_package_error`. ## Load Functions -Request-time pages with `server {}` bind same-package functions named -`Load`: +Request-time pages with `server {}` bind an explicit function from +`Config.Interop.Loads`: + +```go +gowdk.RegisterLoad("dashboard", dashboard.LoadDashboard) +``` + +The registered function supports these signatures: ```go func LoadDashboard(ssr.LoadContext) map[string]any @@ -115,7 +121,7 @@ func LoadDashboard(ssr.LoadContext) DashboardData func LoadDashboard(ssr.LoadContext) (DashboardData, error) ``` -Typed load result structs must be exported same-package structs. Exported +Typed load result structs must be exported structs. Exported fields are visible to `server {}` declarations by Go field name or `json` tag name, and `json:"-"` hides a field. diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md index f9a63a2b..88fed0c8 100644 --- a/docs/reference/hooks.md +++ b/docs/reference/hooks.md @@ -116,18 +116,23 @@ native `role:` / `permission:` guard IDs. Non-public page guards also require request-time page rendering for the page GET route; build-time SPA pages emit static HTML and cannot enforce frontend access. -Custom guard IDs still belong in generated app startup code: +Custom guard IDs are registered from ordinary application packages through +`Config.Interop`: ```go -import gowdkguard "github.com/cssbruno/gowdk/runtime/guard" - -func GOWDKGuardRegistry() gowdkguard.Registry { +func Guards() gowdkguard.Registry { return gowdkguard.Registry{ "auth.required": func(ctx gowdkguard.Context) error { return nil }, } } + +var Config = gowdk.Config{ + Interop: gowdk.InteropConfig{ + Guards: gowdk.RegisterGuards(Guards), + }, +} ``` SSR-facing guard code should import `runtime/ssr` for `GuardRegistry` and @@ -143,10 +148,10 @@ With `auth.Addon`, generated startup resolves those IDs through the configured session manager. App handlers can issue or clear the same session cookie through `auth.DefaultSessions()`. -Without `auth.Addon`, generated app packages with native RBAC guard IDs require: +Without `auth.Addon`, register an application-owned provider factory: ```go -func GOWDKAuthProvider() auth.Provider +AuthProvider: gowdk.RegisterAuthProvider(AuthProvider) ``` Define the application-owned principal source from generated app startup code: @@ -158,7 +163,7 @@ import ( gowdkauth "github.com/cssbruno/gowdk/runtime/auth" ) -func GOWDKAuthProvider() gowdkauth.Provider { +func AuthProvider() gowdkauth.Provider { return gowdkauth.ProviderFunc(func(request *http.Request) (*gowdkauth.Principal, error) { return &gowdkauth.Principal{ ID: "user-1", @@ -175,8 +180,8 @@ RBAC guard behavior: - `permission:` requires the principal to have that permission. - Multiple guard IDs are enforced in declaration order, so multiple RBAC guards are an AND check. -- A missing `GOWDKAuthProvider` function fails at Go compile time when no auth - addon provider is configured. A nil principal, provider error, or missing +- A missing typed auth registration is a compiler diagnostic before app + generation. A nil principal, provider error, or missing role/permission fails closed with HTTP 403. - GOWDK does not manage users, passwords, OAuth, sessions, tenants, or storage. The auth provider adapts application-owned identity into `auth.Principal`. @@ -196,7 +201,7 @@ contract): add `server {}` or `go server {}` with the SSR addon when the page itself is protected. - Guards run in declaration order. -- Missing custom guard backing code fails at Go compile time. +- Missing custom guard registration is a compiler diagnostic before generation. - Guard errors fail closed with HTTP 403. - Guards run before action decoding, API handler calls, fragment hooks, SSR `server {}`, and user business logic. @@ -216,7 +221,7 @@ import ( gowdkresponse "github.com/cssbruno/gowdk/runtime/response" ) -func GOWDKGuardRegistry() gowdkguard.Registry { +func Guards() gowdkguard.Registry { return gowdkguard.Registry{ "auth.required": func(ctx gowdkguard.Context) error { return gowdkguard.RedirectTo("/login") @@ -228,6 +233,8 @@ func GOWDKGuardRegistry() gowdkguard.Registry { } ``` +Register it with `gowdk.RegisterGuards(Guards)` in `Config.Interop`. + Guard redirects must be local absolute paths. Protocol-relative URLs, backslashes, newlines, and non-3xx redirect statuses are rejected before the generated app can write them. diff --git a/docs/reference/routing.md b/docs/reference/routing.md index 5019b4b9..295b9a43 100644 --- a/docs/reference/routing.md +++ b/docs/reference/routing.md @@ -414,8 +414,9 @@ fragment handler metadata today. Typed SSR load result structs are supported for declared `server {}` data. Typed action-result data accessors are deferred until action result contracts are stable. -`server { => { field, user.name } }` execution calls same-package Go -`Load` functions at request time through `ssr.LoadContext`. Returned +`server { => { field, user.name } }` execution calls the Go function explicitly +mapped with `gowdk.RegisterLoad(pageID, package.Function)` at request time +through `ssr.LoadContext`. Returned declared identifiers and dotted paths are resolved from nested maps with string keys, typed result structs, pointers, interfaces, exported Go field names, and `json` tag names, then HTML-escaped into generated placeholders. 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/build-iteration/README.md b/examples/build-iteration/README.md index 9662269c..65e6b19d 100644 --- a/examples/build-iteration/README.md +++ b/examples/build-iteration/README.md @@ -19,6 +19,11 @@ shape **lists** declaratively at build time and reduce them back to scalars that ## Contract +This example uses only the public build-data grammar pinned in +[`docs/language/syntax.md`](../../docs/language/syntax.md). CI runs +`scripts/check-build-iteration-example.sh` so the example cannot drift beyond +that compiler contract. + - Bracket forms (`[...]` list literals and comprehensions, `{...}` object literals) are whole field-value forms. Compose multi-step transforms by binding an intermediate list field and reading it back with `field("name")` — exactly diff --git a/examples/flagship/README.md b/examples/flagship/README.md index 07da3d90..5db504b6 100644 --- a/examples/flagship/README.md +++ b/examples/flagship/README.md @@ -67,9 +67,9 @@ The main generated routes are: - The fragment template under `src/app/fragments/` exercises the current low-level custom response-body compatibility path. New application markup belongs in `.gwdk`; generated typed fragment data binding remains planned. -- `apphooks/flagship_hooks.go.txt` is copied into the generated app package before - binary compilation so custom guards and the optional rate limiter can be wired - through the generated app hook surface. +- `gowdk.config.go` registers page loads and custom guards through typed + `Config.Interop` function references. `apphooks/flagship_hooks.go.txt` remains + only for the optional generated-package rate limiter API. - Generated output in `.gowdk/`, `dist/`, and `bin/` is intentionally ignored. ## Demo Credentials @@ -81,10 +81,9 @@ and signs the demo session cookie. ## Current Limitations -- Custom guards and rate limiter registration are generated-app hooks today, so - `make build` prepares `apphooks/flagship_hooks.go.txt` before compiling the - binary. Running `gowdk build --target flagship` directly from a clean tree will - miss those hooks. +- The rate limiter still uses a generated-package registration API, so `make + build` prepares `apphooks/flagship_hooks.go.txt`. Guards and SSR loads do not + depend on that copied file. - The WASM island uses the current call-site placeholder path. A real browser Go WASM package can replace it when the example needs browser-owned Go logic. - Contract command/query adapters are local in-process web adapters; realtime 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/README.md b/examples/i18n/README.md index 69a6881b..bfa6a2fb 100644 --- a/examples/i18n/README.md +++ b/examples/i18n/README.md @@ -8,6 +8,21 @@ This example shows the first localization slice: catalog without hand-maintained source line metadata. - `home.page.gwdk` calls a Go build helper that reads `gowdk.BuildParams.LocaleCode()`. +- `Config.I18N.Errors` reuses `runtime/i18n` typed catalogs for stable runtime + error codes. Handlers return a code, safe default, and variables; generated + action/API/guard/fragment rendering chooses the localized message. + +Application handlers do not hard-code translated text: + +```go +return gowdkresponse.Response{}, gowdkresponse.NewExpectedCode( + gowdkresponse.ErrorValidation, + "patient_missing", + "Patient {id} was not found", + map[string]string{"id": patientID}, + err, +) +``` Build it from the repository root: 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 = `