diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64997cf3..e455d52d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,6 @@ on: pull_request: types: - opened - - edited - reopened - synchronize - ready_for_review @@ -16,6 +15,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: pr-title: name: PR title @@ -31,19 +34,8 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} run: scripts/check-pr-title.sh "$PR_TITLE" - supply-chain-pins: - name: Supply-chain pins - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Check pinned workflow and publish tooling inputs - run: scripts/check-supply-chain-pins.sh - - go-tests: - name: Go tests + verify: + name: Verify runs-on: ubuntu-latest steps: @@ -56,6 +48,14 @@ jobs: go-version: '1.26.4' cache: true + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '24' + + - name: Check supply-chain pins + run: scripts/check-supply-chain-pins.sh + - name: Run Go module tests run: | go version @@ -63,135 +63,22 @@ jobs: scripts/check-root-deps.sh scripts/test-go-modules.sh - vulnerabilities: - name: Reachable vulnerabilities - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Check reachable Go vulnerabilities - run: scripts/vulncheck-go-modules.sh - - runtime-race: - name: Runtime race detector - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Run focused runtime race tests - run: scripts/test-runtime-race.sh - - cli-build: - name: CLI build - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - name: Build CLI run: go build ./cmd/gowdk - dead-code: - name: Dead code - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Check focused dead code - run: scripts/check-dead-code.sh - - vscode-extension: - name: VS Code extension - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 - with: - node-version: '24' - - - name: Check VS Code extension syntax + - name: Check VS Code extension run: | node editors/vscode/scripts/sync-version.js --check node --check editors/vscode/extension.js node --check editors/vscode/extension-core.js + node --test editors/vscode/*.test.js - - name: Run VS Code extension unit tests - run: node --test editors/vscode/*.test.js - - docs: - name: Documentation links - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Check documentation links - run: sh scripts/check-docs-links.sh - - - name: Check documentation style - run: sh scripts/check-docs-style.sh - - - name: Check for removed source syntax in docs - run: sh scripts/check-removed-syntax.sh - - - name: Check docs do not pin a release version - run: sh scripts/check-doc-versions.sh - - docs-site: - name: Docs site compile - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true + - name: Check documentation + run: | + sh scripts/check-docs-links.sh + sh scripts/check-docs-style.sh + sh scripts/check-removed-syntax.sh + sh scripts/check-doc-versions.sh - name: Build and smoke docs site against in-tree GOWDK working-directory: docs-site @@ -203,105 +90,10 @@ jobs: go test ./... go vet ./... - example-reports: - name: Example reports - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - name: Check example reports run: sh scripts/check-example-reports.sh - parser-fuzz: - name: Parser fuzz smoke - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Run parser fuzz smoke - env: - GOWDK_FUZZTIME: 1000x - run: scripts/test-parser-fuzz.sh - - generated-app-integration: - name: Generated app integration - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Run generated app integration tests - run: scripts/test-generated-app-integration.sh - - generated-output-determinism: - name: Generated output determinism - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Check generated output determinism - run: scripts/test-generated-output-determinism.sh - - generated-output: - name: Generated output smoke - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - - name: Set up Go - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: '1.26.4' - cache: true - - - name: Smoke build SPA example output - run: | - rm -rf /tmp/gowdk-build - go run ./cmd/gowdk build --out /tmp/gowdk-build examples/pages/home.page.gwdk examples/pages/hero.cmp.gwdk - test -f /tmp/gowdk-build/gowdk-routes.json - test -f /tmp/gowdk-build/gowdk-assets.json - - - name: Smoke build dynamic SPA output - run: | - rm -rf /tmp/gowdk-dynamic-build - go run ./cmd/gowdk build --out /tmp/gowdk-dynamic-build examples/pages/blog-post.page.gwdk - test -f /tmp/gowdk-dynamic-build/blog/hello-gowdk/index.html - test -f /tmp/gowdk-dynamic-build/blog/compile-first/index.html - - - name: Build login backend example + - name: Build login example run: | set -euxo pipefail cd examples/login @@ -318,65 +110,3 @@ jobs: test -n "$login_css" test -f "$output/$login_css" go test ./src/features/auth - - - name: Smoke build CSS example output - run: | - rm -rf /tmp/gowdk-css-build - go run ./cmd/gowdk build --config examples/css/gowdk.config.go --out /tmp/gowdk-css-build examples/css/styled.page.gwdk - grep -F '' /tmp/gowdk-css-build/styled/index.html - - - name: Smoke build SEO addon output - run: | - rm -rf /tmp/gowdk-seo-build - go run ./cmd/gowdk build --config examples/seo/gowdk.config.go --out /tmp/gowdk-seo-build examples/seo/*.gwdk - test -f /tmp/gowdk-seo-build/sitemap.xml - test -f /tmp/gowdk-seo-build/robots.txt - grep -F 'https://example.com/seo/blog/hello-gowdk' /tmp/gowdk-seo-build/sitemap.xml - grep -F 'Sitemap: https://example.com/sitemap.xml' /tmp/gowdk-seo-build/robots.txt - - - name: Smoke build embedded binary example - run: | - rm -rf /tmp/gowdk-embed-build /tmp/gowdk-embed-app /tmp/gowdk-embed-site - go run ./cmd/gowdk build --out /tmp/gowdk-embed-build --app /tmp/gowdk-embed-app --bin /tmp/gowdk-embed-site examples/embed/site.page.gwdk - test -x /tmp/gowdk-embed-site - GOWDK_SMOKE_ADDR=127.0.0.1:18085 scripts/smoke-generated-binary.sh /tmp/gowdk-embed-site /embed "Embedded GOWDK" - - - name: Smoke build generated WASM artifact - run: | - rm -rf /tmp/gowdk-wasm-build /tmp/gowdk-wasm-app /tmp/gowdk-site.wasm - go run ./cmd/gowdk build --out /tmp/gowdk-wasm-build --app /tmp/gowdk-wasm-app --wasm /tmp/gowdk-site.wasm examples/embed/site.page.gwdk - scripts/smoke-generated-wasm.sh /tmp/gowdk-site.wasm - - - name: Smoke build hybrid example output - run: | - rm -rf /tmp/gowdk-hybrid-build /tmp/gowdk-hybrid-app /tmp/gowdk-hybrid-site - go run ./cmd/gowdk build --ssr --out /tmp/gowdk-hybrid-build --app /tmp/gowdk-hybrid-app --bin /tmp/gowdk-hybrid-site examples/ssr/hybrid-static.page.gwdk - test -x /tmp/gowdk-hybrid-site - go run ./cmd/gowdk routes --ssr examples/ssr/hybrid-static.page.gwdk > /tmp/gowdk-hybrid-routes.json - grep -F '"kind": "ssr"' /tmp/gowdk-hybrid-routes.json - grep -F '"pageId": "hybrid.static"' /tmp/gowdk-hybrid-routes.json - grep -F '"code": "spa_disabled"' /tmp/gowdk-hybrid-routes.json - - - name: Smoke build component asset output - run: | - rm -rf /tmp/gowdk-component-assets - go run ./cmd/gowdk build --out /tmp/gowdk-component-assets examples/components/assets/*.gwdk - test -f /tmp/gowdk-component-assets/components/assets/index.html - grep -F '"assets/gowdk/components/componentassets/AssetBadge/badge.svg"' /tmp/gowdk-component-assets/gowdk-assets.json - grep -F 'assets/gowdk/components/componentassets/AssetBadge/badge.' /tmp/gowdk-component-assets/gowdk-assets.json - - - name: Smoke build WASM island example output - run: | - rm -rf /tmp/gowdk-wasm-island - go run ./cmd/gowdk build --out /tmp/gowdk-wasm-island examples/components/wasm/*.gwdk - test -f /tmp/gowdk-wasm-island/components/wasm/index.html - test -f /tmp/gowdk-wasm-island/assets/gowdk/islands/componentwasm/WasmCounter.wasm - test -f /tmp/gowdk-wasm-island/assets/gowdk/islands/componentwasm/WasmCounter.wasm.js - test -f /tmp/gowdk-wasm-island/assets/gowdk/islands/wasm_exec.js - - - name: Smoke build persisted store example output - run: | - rm -rf /tmp/gowdk-store-persist - go run ./cmd/gowdk build --out /tmp/gowdk-store-persist examples/store-persist/*.gwdk - test -f /tmp/gowdk-store-persist/shop/index.html - grep -F 'data-gowdk-persist="local"' /tmp/gowdk-store-persist/shop/index.html diff --git a/docs/engineering/ci.md b/docs/engineering/ci.md index 9385de77..1b9e792e 100644 --- a/docs/engineering/ci.md +++ b/docs/engineering/ci.md @@ -1,38 +1,22 @@ # CI -Hosted CI is split by concern in `.github/workflows/ci.yml`. Local verification -remains the fastest pre-handoff gate. +Hosted CI stays small: `.github/workflows/ci.yml` exposes one title gate and +one lean verification gate. Heavier audit, fuzz, race, determinism, and broad +generated-output checks stay local or release-oriented instead of running on +every pull request. ## Hosted Jobs Required pull-request lanes: -- `Go tests`: root dependency check and all Go module tests on Linux. -- `Reachable vulnerabilities`: `scripts/vulncheck-go-modules.sh`. -- `Runtime race detector`: `scripts/test-runtime-race.sh` on Linux for the - explicit shared-state runtime package list. -- `CLI build`: `go build ./cmd/gowdk`. -- `Dead code`: `scripts/check-dead-code.sh` runs pinned Staticcheck `U1000` - and x/tools `deadcode` analyzers over the focused compiler, CLI, and - runtime-contract package sets documented below. -- `VS Code extension`: extension version sync, Node syntax checks, and unit - tests. -- `Documentation links`: `scripts/check-docs-links.sh`. -- `Documentation style`: `scripts/check-docs-style.sh` checks heading order and - language-tagged fenced code blocks. Long prose paragraphs are warnings. -- `Removed source syntax`: `scripts/check-removed-syntax.sh` (runs in the - `Documentation links` job) flags pre-v0.6.0 source forms that the script lists - but that linger in docs as if still active. They are allowed only in changelog, - migration, and diagnostics references, in test fixtures, and on lines carrying - a `removed-syntax-ok` marker. -- `Example reports`: `scripts/check-example-reports.sh`. -- `Parser fuzz smoke`: `scripts/test-parser-fuzz.sh` with - `GOWDK_FUZZTIME=1000x`. -- `Generated app integration`: `scripts/test-generated-app-integration.sh`. -- `Generated output determinism`: - `scripts/test-generated-output-determinism.sh`. -- `Generated output smoke`: representative build output, binary, WASM, SSR, - CSS, SEO, component asset, and login example checks. +- `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. + +The consolidated `Verify` job intentionally favors quick signal and fewer PR +status checks over broad coverage. Keep expensive or niche gates out of routine +PR CI unless they protect an active release or regression class. Release automation lives outside pull-request CI: @@ -216,18 +200,8 @@ workflow; run this locally only when an artifact needs an extra manual check. Require these checks before merging to `main`: -- `Go tests` -- `Reachable vulnerabilities` -- `Runtime race detector` -- `CLI build` -- `Dead code` -- `VS Code extension` -- `Documentation links` -- `Example reports` -- `Parser fuzz smoke` -- `Generated app integration` -- `Generated output determinism` -- `Generated output smoke` +- `PR title` +- `Verify` Do not require release or manual publishing workflows for normal pull requests. Use them only for release readiness and publication. diff --git a/examples/README.md b/examples/README.md index 61a6ef83..8319bc8d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,9 +36,13 @@ go run ./cmd/gowdk check --config examples/tailwind/gowdk.config.go examples/tai Validate the broad source set with SSR enabled: ```sh -go run ./cmd/gowdk check --ssr examples/pages/*.gwdk examples/marketing/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/components/wasm/*.gwdk examples/store-persist/*.gwdk examples/embed/*.gwdk examples/seo/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk +scripts/check-example-reports.sh ``` +The checked source inventory lives in `examples/smoke-sources.txt`; the report +script, this index, and CI docs should point to that file instead of copying the +glob list. + Focused directories such as `examples/endpoints`, `examples/auth-guard`, and `examples/flagship` include `Makefile` targets that run the local checks used by CI. diff --git a/examples/endpoints/README.md b/examples/endpoints/README.md index 24d3c634..33454b6c 100644 --- a/examples/endpoints/README.md +++ b/examples/endpoints/README.md @@ -2,7 +2,9 @@ This cookbook groups broader native endpoint examples in one generated app. The `.gwdk` files declare endpoint metadata and forms; `src/endpoints/handlers.go` -owns request behavior, validation, redirects, JSON, and fragments in normal Go. +owns request behavior, validation, redirects, and JSON in normal Go. Fragment +markup lives in `src/endpoints/fragments/*.html` templates so handlers pass data +and choose response shapes instead of assembling HTML. Run from this directory: @@ -19,7 +21,7 @@ GOWDK_CSRF_SECRET=development-endpoints-csrf-secret-32b GOWDK_ADDR=127.0.0.1:809 | --- | --- | | `contact.page.gwdk` | Contact action redirect plus inline validation fragment response. | | `settings.page.gwdk` | Settings save/reset actions returning partial fragments. | -| `upload.page.gwdk` | Multipart action form with generated file policy and typed `form.File` handler input. | +| `upload.page.gwdk` | Multipart action form with generated file policy and `form.Data` handler input. | | `api.page.gwdk` | Typed generated API handlers for session, search, and JSON CRUD plus a raw request webhook escape hatch. | | `fragments.page.gwdk` | Inline validation, table row, list refresh, modal body, and dashboard card fragments. | @@ -42,5 +44,5 @@ GOWDK_CSRF_SECRET=development-endpoints-csrf-secret-32b GOWDK_ADDR=127.0.0.1:809 validation remains in `src/endpoints/handlers.go`. - Multipart action uploads enforce generated request/file limits, but storage, scanning, and persistence remain user-owned Go behavior. -- Fragment hooks and static fragment bodies are HTML strings today; richer - typed fragment render helpers are still planned. +- Dynamic fragment handlers use embedded `html/template` files today; richer + generated typed fragment render helpers are still planned. diff --git a/examples/endpoints/src/endpoints/fragments/contact-result.html b/examples/endpoints/src/endpoints/fragments/contact-result.html new file mode 100644 index 00000000..257fb21f --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/contact-result.html @@ -0,0 +1 @@ +

{{.Message}}

diff --git a/examples/endpoints/src/endpoints/fragments/dashboard-card.html b/examples/endpoints/src/endpoints/fragments/dashboard-card.html new file mode 100644 index 00000000..f9e52a6b --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/dashboard-card.html @@ -0,0 +1 @@ +

Dashboard card

Refreshed by a fragment response.

diff --git a/examples/endpoints/src/endpoints/fragments/inline-validation.html b/examples/endpoints/src/endpoints/fragments/inline-validation.html new file mode 100644 index 00000000..84ccf2f4 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/inline-validation.html @@ -0,0 +1 @@ +

Request-time inline validation fragment.

diff --git a/examples/endpoints/src/endpoints/fragments/inventory-list.html b/examples/endpoints/src/endpoints/fragments/inventory-list.html new file mode 100644 index 00000000..bd14ae42 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/inventory-list.html @@ -0,0 +1 @@ +KeyboardstockedMousestocked diff --git a/examples/endpoints/src/endpoints/fragments/inventory-row.html b/examples/endpoints/src/endpoints/fragments/inventory-row.html new file mode 100644 index 00000000..dc38f9c0 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/inventory-row.html @@ -0,0 +1 @@ +{{.Item}}updated diff --git a/examples/endpoints/src/endpoints/fragments/modal-body.html b/examples/endpoints/src/endpoints/fragments/modal-body.html new file mode 100644 index 00000000..d07bcce0 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/modal-body.html @@ -0,0 +1 @@ + diff --git a/examples/endpoints/src/endpoints/fragments/runtime-dashboard-card.html b/examples/endpoints/src/endpoints/fragments/runtime-dashboard-card.html new file mode 100644 index 00000000..b888e5a3 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/runtime-dashboard-card.html @@ -0,0 +1 @@ +

Request-time card

Ready.

diff --git a/examples/endpoints/src/endpoints/fragments/runtime-inventory-list.html b/examples/endpoints/src/endpoints/fragments/runtime-inventory-list.html new file mode 100644 index 00000000..824d3c57 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/runtime-inventory-list.html @@ -0,0 +1 @@ +Dynamic listready diff --git a/examples/endpoints/src/endpoints/fragments/runtime-inventory-row.html b/examples/endpoints/src/endpoints/fragments/runtime-inventory-row.html new file mode 100644 index 00000000..52e9f592 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/runtime-inventory-row.html @@ -0,0 +1 @@ +Dynamic itemready diff --git a/examples/endpoints/src/endpoints/fragments/runtime-modal-body.html b/examples/endpoints/src/endpoints/fragments/runtime-modal-body.html new file mode 100644 index 00000000..18a1e22b --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/runtime-modal-body.html @@ -0,0 +1 @@ + diff --git a/examples/endpoints/src/endpoints/fragments/settings-reset.html b/examples/endpoints/src/endpoints/fragments/settings-reset.html new file mode 100644 index 00000000..ab45db20 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/settings-reset.html @@ -0,0 +1 @@ +

Settings reset to defaults.

diff --git a/examples/endpoints/src/endpoints/fragments/settings-result.html b/examples/endpoints/src/endpoints/fragments/settings-result.html new file mode 100644 index 00000000..e91af0bf --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/settings-result.html @@ -0,0 +1 @@ +

Saved theme {{.Theme}} with email {{.Email}}.

diff --git a/examples/endpoints/src/endpoints/fragments/upload-alert.html b/examples/endpoints/src/endpoints/fragments/upload-alert.html new file mode 100644 index 00000000..6df27e2e --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/upload-alert.html @@ -0,0 +1 @@ +

{{.Message}}

diff --git a/examples/endpoints/src/endpoints/fragments/upload-result.html b/examples/endpoints/src/endpoints/fragments/upload-result.html new file mode 100644 index 00000000..1ae7d334 --- /dev/null +++ b/examples/endpoints/src/endpoints/fragments/upload-result.html @@ -0,0 +1 @@ +

Received {{.Filename}} ({{.ContentType}}, {{.Bytes}} bytes streamed) with caption {{.Caption}}.

diff --git a/examples/endpoints/src/endpoints/handlers.go b/examples/endpoints/src/endpoints/handlers.go index b10c55ad..bd36a53a 100644 --- a/examples/endpoints/src/endpoints/handlers.go +++ b/examples/endpoints/src/endpoints/handlers.go @@ -2,8 +2,8 @@ package endpoints import ( "context" - "fmt" - "html" + "embed" + "html/template" "io" "net/http" "strings" @@ -14,6 +14,11 @@ import ( "github.com/cssbruno/gowdk/runtime/response" ) +//go:embed fragments/*.html +var fragmentFiles embed.FS + +var fragmentTemplates = template.Must(template.ParseFS(fragmentFiles, "fragments/*.html")) + func Contact(_ context.Context, values form.Values) (response.Response, error) { if contactInvalid(values) { return response.RedirectTo("/endpoints/contact?invalid=1"), nil @@ -23,89 +28,85 @@ func Contact(_ context.Context, values form.Values) (response.Response, error) { func ValidateContact(_ context.Context, values form.Values) (response.Response, error) { if contactInvalid(values) { - return response.FragmentFor("#contact-result", alertHTML("Email and a 12 character message are required.")), nil + return response.FragmentFor("#contact-result", renderFragment("contact-result.html", map[string]string{"Message": "Email and a 12 character message are required."})), nil } - return response.FragmentFor("#contact-result", alertHTML("Contact request is ready to submit.")), nil + return response.FragmentFor("#contact-result", renderFragment("contact-result.html", map[string]string{"Message": "Contact request is ready to submit."})), nil } func SaveSettings(_ context.Context, values form.Values) (response.Response, error) { theme := valueOr(values.First("theme"), "system") email := valueOr(values.First("email"), "off") - body := fmt.Sprintf(`

Saved theme %s with email %s.

`, escape(theme), escape(email)) - return response.FragmentFor("#settings-result", body), nil + return response.FragmentFor("#settings-result", renderFragment("settings-result.html", map[string]string{"Theme": theme, "Email": email})), nil } func ResetSettings(context.Context, form.Values) (response.Response, error) { - return response.FragmentFor("#settings-result", `

Settings reset to defaults.

`), nil -} - -type UploadInput struct { - Avatar form.File `form:"avatar"` - Caption string `form:"caption"` + return response.FragmentFor("#settings-result", renderFragment("settings-reset.html", nil)), nil } -func UploadAvatar(_ context.Context, input UploadInput) (response.Response, error) { - uploaded, err := input.Avatar.Open() +func UploadAvatar(_ context.Context, input form.Data) (response.Response, error) { + files := input.Files["avatar"] + if len(files) == 0 { + return response.FragmentFor("#upload-result", renderFragment("upload-alert.html", map[string]string{"Message": "Upload is required."})), nil + } + avatar := files[0] + uploaded, err := avatar.Open() if err != nil { - return response.FragmentFor("#upload-result", alertUploadHTML("Upload could not be opened.")), nil + return response.FragmentFor("#upload-result", renderFragment("upload-alert.html", map[string]string{"Message": "Upload could not be opened."})), nil } defer func() { _ = uploaded.Close() }() bytes, err := io.Copy(io.Discard, uploaded) if err != nil { - return response.FragmentFor("#upload-result", alertUploadHTML("Upload could not be read.")), nil + return response.FragmentFor("#upload-result", renderFragment("upload-alert.html", map[string]string{"Message": "Upload could not be read."})), nil } - caption := strings.TrimSpace(input.Caption) + caption := strings.TrimSpace(input.Values.First("caption")) if caption == "" { caption = "uncaptioned" } - body := fmt.Sprintf(`

Received %s (%s, %d bytes streamed) with caption %s.

`, - escape(input.Avatar.Filename), - escape(input.Avatar.ContentType), - bytes, - escape(caption), - ) - return response.FragmentFor("#upload-result", body), nil + return response.FragmentFor("#upload-result", renderFragment("upload-result.html", uploadResult{ + Filename: avatar.Filename, + ContentType: avatar.ContentType, + Bytes: bytes, + Caption: caption, + })), nil } func RefreshInventory(context.Context, form.Values) (response.Response, error) { - return response.FragmentSwap("#inventory", response.SwapOuterHTML, `KeyboardstockedMousestocked`) + return response.FragmentSwap("#inventory", response.SwapOuterHTML, renderFragment("inventory-list.html", nil)) } func UpdateInventoryRow(_ context.Context, values form.Values) (response.Response, error) { item := valueOr(values.First("item"), "Keyboard") - body := fmt.Sprintf(`%supdated`, escape(item)) - return response.FragmentFor("#inventory", body), nil + return response.FragmentFor("#inventory", renderFragment("inventory-row.html", map[string]string{"Item": item})), nil } func OpenModal(context.Context, form.Values) (response.Response, error) { - return response.FragmentFor("#modal-body", ``), nil + return response.FragmentFor("#modal-body", renderFragment("modal-body.html", nil)), nil } func RefreshDashboardCard(context.Context, form.Values) (response.Response, error) { - body := `

Dashboard card

Refreshed by a fragment response.

` - return response.FragmentSwap("#dashboard-card", response.SwapOuterHTML, body) + return response.FragmentSwap("#dashboard-card", response.SwapOuterHTML, renderFragment("dashboard-card.html", nil)) } func InlineValidation(context.Context) (response.Response, error) { - return response.FragmentFor("#inline-validation", `

Request-time inline validation fragment.

`), nil + return response.FragmentFor("#inline-validation", renderFragment("inline-validation.html", nil)), nil } func InventoryRow(context.Context) (response.Response, error) { - return response.FragmentFor("#inventory", `Dynamic itemready`), nil + return response.FragmentFor("#inventory", renderFragment("runtime-inventory-row.html", nil)), nil } func InventoryList(context.Context) (response.Response, error) { - return response.FragmentSwap("#inventory", response.SwapOuterHTML, `Dynamic listready`) + return response.FragmentSwap("#inventory", response.SwapOuterHTML, renderFragment("runtime-inventory-list.html", nil)) } func ModalBody(context.Context) (response.Response, error) { - return response.FragmentFor("#modal-body", ``), nil + return response.FragmentFor("#modal-body", renderFragment("runtime-modal-body.html", nil)), nil } func DashboardCard(context.Context) (response.Response, error) { - return response.FragmentSwap("#dashboard-card", response.SwapOuterHTML, `

Request-time card

Ready.

`) + return response.FragmentSwap("#dashboard-card", response.SwapOuterHTML, renderFragment("runtime-dashboard-card.html", nil)) } type SessionResult struct { @@ -225,14 +226,6 @@ func contactInvalid(values form.Values) bool { return strings.TrimSpace(values.First("email")) == "" || len(strings.TrimSpace(values.First("message"))) < 12 } -func alertHTML(message string) string { - return `

` + escape(message) + `

` -} - -func alertUploadHTML(message string) string { - return `

` + escape(message) + `

` -} - func valueOr(value, fallback string) string { value = strings.TrimSpace(value) if value == "" { @@ -241,6 +234,17 @@ func valueOr(value, fallback string) string { return value } -func escape(value string) string { - return html.EscapeString(value) +type uploadResult struct { + Filename string + ContentType string + Bytes int64 + Caption string +} + +func renderFragment(name string, data any) string { + var out strings.Builder + if err := fragmentTemplates.ExecuteTemplate(&out, name, data); err != nil { + panic(err) + } + return out.String() } diff --git a/examples/flagship/README.md b/examples/flagship/README.md index 5439747f..d81d772d 100644 --- a/examples/flagship/README.md +++ b/examples/flagship/README.md @@ -33,7 +33,7 @@ Expected build outputs: | Local component state and WASM island call site | `src/app/counter.cmp.gwdk`, `src/ui/counter.go` | | Action login form with generated field validation | `src/app/home.page.gwdk`, `src/app/app.go` | | API status endpoint | `src/app/home.page.gwdk`, `src/app/app.go` | -| Server fragment and partial form update | `src/app/home.page.gwdk`, `src/app/app.go` | +| Server fragment and partial form update | `src/app/home.page.gwdk`, `src/app/fragments/summary.html`, `src/app/app.go` | | Protected SSR dashboard with `server {}` | `src/app/dashboard.page.gwdk`, `apphooks/flagship_hooks.go.txt` | | Hybrid request-time page | `src/app/hybrid.page.gwdk` | | Component asset and configured CSS | `src/app/asset-badge.cmp.gwdk`, `src/app/badge.svg`, `styles/flagship.css` | @@ -63,6 +63,8 @@ The main generated routes are: CSS, assets, and island metadata. - Go packages under `src/` own credentials, session state, endpoint behavior, build-time data, SSR load data, contracts, and island state shapes. +- Fragment templates under `src/app/fragments/` own dynamic partial markup + until generated typed fragment render helpers exist. - `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. diff --git a/examples/flagship/src/app/app.go b/examples/flagship/src/app/app.go index 13a6c320..66b985e3 100644 --- a/examples/flagship/src/app/app.go +++ b/examples/flagship/src/app/app.go @@ -6,9 +6,10 @@ import ( "crypto/rand" "crypto/sha256" "crypto/subtle" + "embed" "encoding/base64" "fmt" - "html" + "html/template" "net/http" "os" "strings" @@ -21,6 +22,11 @@ import ( "github.com/cssbruno/gowdk/runtime/ssr" ) +//go:embed fragments/*.html +var fragmentFiles embed.FS + +var fragmentTemplates = template.Must(template.ParseFS(fragmentFiles, "fragments/*.html")) + const sessionCookie = "gowdk_flagship_session" func Login(_ context.Context, values form.Values) (response.Response, error) { @@ -73,7 +79,10 @@ func RefreshSummary(_ context.Context, values form.Values) (response.Response, e if topic == "" { topic = "compiler" } - return response.FragmentFor("#summary", summaryHTML(topic, time.Now())), nil + return response.FragmentFor("#summary", renderFragment("summary.html", summaryFragment{ + Topic: topic, + Refreshed: time.Now().Format(time.Kitchen), + })), nil } func Status(_ context.Context, request *http.Request) (response.Response, error) { @@ -159,11 +168,6 @@ func currentSession(request *http.Request) (session, bool) { return current, true } -func summaryHTML(topic string, refreshed time.Time) string { - escaped := html.EscapeString(topic) - return fmt.Sprintf(`

%s summary

Updated by a generated partial action at %s.

`, escaped, refreshed.Format(time.Kitchen)) -} - func sign(value string) string { return value + "." + signature(value) } @@ -207,3 +211,16 @@ func env(name, fallback string) string { } return value } + +type summaryFragment struct { + Topic string + Refreshed string +} + +func renderFragment(name string, data any) string { + var out strings.Builder + if err := fragmentTemplates.ExecuteTemplate(&out, name, data); err != nil { + panic(err) + } + return out.String() +} diff --git a/examples/flagship/src/app/fragments/summary.html b/examples/flagship/src/app/fragments/summary.html new file mode 100644 index 00000000..37925f8d --- /dev/null +++ b/examples/flagship/src/app/fragments/summary.html @@ -0,0 +1 @@ +

{{.Topic}} summary

Updated by a generated partial action at {{.Refreshed}}.

diff --git a/examples/login/README.md b/examples/login/README.md index 2ac3d031..3a54eedd 100644 --- a/examples/login/README.md +++ b/examples/login/README.md @@ -97,3 +97,8 @@ GOWDK_ADDR=127.0.0.1:8090 GOWDK_BACKEND_ORIGIN=http://127.0.0.1:8091 bin/login-f - `POST /`: bound from `act login` to `auth.Login`. - `POST /dashboard`: bound from `act logout` to `auth.Logout`. - `GET /api/session`: bound from `api session` to `auth.Session`. + +This example demonstrates action/API/session plumbing with signed cookies. The +dashboard remains public so `gowdk build` works without generated-app guard +hooks. For protected pages, the built-in auth addon, and native role guards, see +`examples/auth-guard/`. diff --git a/examples/smoke-sources.txt b/examples/smoke-sources.txt new file mode 100644 index 00000000..03da81ed --- /dev/null +++ b/examples/smoke-sources.txt @@ -0,0 +1,21 @@ +# Source of truth for broad example report checks. +# Keep one shell glob per line, relative to the repository root. +examples/pages/*.gwdk +examples/marketing/*.gwdk +examples/actions/*.gwdk +examples/partials/*.gwdk +examples/api/*.gwdk +examples/ssr/*.gwdk +examples/go-interop/*.gwdk +examples/build-iteration/*.gwdk +examples/components/base/*.gwdk +examples/components/css/*.gwdk +examples/components/assets/*.gwdk +examples/components/wasm/*.gwdk +examples/store-persist/*.gwdk +examples/embed/*.gwdk +examples/css/*.gwdk +examples/tailwind/*.gwdk +examples/contracts/*.gwdk +examples/security/*.gwdk +examples/seo/*.gwdk diff --git a/internal/appgen/appgen_test.go b/internal/appgen/appgen_test.go index ab8167cb..6b70c3c3 100644 --- a/internal/appgen/appgen_test.go +++ b/internal/appgen/appgen_test.go @@ -2956,6 +2956,52 @@ func TestGenerateWritesTypedBoundActionHandlers(t *testing.T) { } } +func TestGenerateMultipartDataActionDoesNotDeclareUnusedValues(t *testing.T) { + outputDir := t.TempDir() + appDir := filepath.Join(t.TempDir(), "app") + result, err := GenerateWithOptions(outputDir, appDir, Options{Actions: []ActionEndpoint{{ + PageID: "Upload", + ActionName: "UploadAvatar", + Method: http.MethodPost, + Route: "/upload", + Guards: []string{"public"}, + InputFields: []string{"avatar", "caption"}, + UploadFields: []ActionUploadField{{ + Field: "avatar", + MaxFiles: 1, + MaxBytes: 2048, + AllowedContentTypes: []string{"image/png"}, + }}, + Binding: source.BackendBinding{ + Status: source.BackendBindingBound, + ImportPath: "example.com/app/upload", + PackageName: "upload", + FunctionName: "UploadAvatar", + Signature: source.BackendSignatureActionData, + }, + }}}) + 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{ + `data := gowdkform.FromMultipartForm(request.MultipartForm)`, + `gowdkform.DecodeExpectedData(data, gowdkform.Schema{Fields: []gowdkform.Field{{Name: "avatar", File: &gowdkform.FilePolicy{MaxFiles: 1, MaxBytes: 2048, AllowedContentTypes: []string{"image/png"}}}, {Name: "caption"}}})`, + `result, err := upload.UploadAvatar(ctx, data)`, + } { + if !strings.Contains(source, expected) { + t.Fatalf("expected generated app source to contain %q:\n%s", expected, source) + } + } + if strings.Contains(source, "values := data.Values") { + t.Fatalf("did not expect unused multipart values declaration:\n%s", source) + } +} + func TestBoundActionDecoderRejectsUnsupportedInputFieldType(t *testing.T) { defer func() { recovered := recover() diff --git a/internal/appgen/source_actions.go b/internal/appgen/source_actions.go index 02837431..d4000ed2 100644 --- a/internal/appgen/source_actions.go +++ b/internal/appgen/source_actions.go @@ -122,7 +122,7 @@ func sortedActionAdapters(actions []BackendActionAdapter) []BackendActionAdapter func actionNeedsValues(action BackendActionAdapter) bool { if actionUsesMultipart(action) { - return true + return multipartActionNeedsValues(action) } if action.Binding.Status != source.BackendBindingBound { return true @@ -133,6 +133,23 @@ func actionNeedsValues(action BackendActionAdapter) bool { return action.Binding.Signature != source.BackendSignatureAction0 } +func multipartActionNeedsValues(action BackendActionAdapter) bool { + if action.ValidatesInput && len(action.ValidationRules) > 0 { + return true + } + if action.Binding.Status != source.BackendBindingBound { + return true + } + switch action.Binding.Signature { + case source.BackendSignatureActionValues: + return true + case source.BackendSignatureActionForm, source.BackendSignatureActionFormPtr: + return !boundActionDecoderUsesData(action) + default: + return false + } +} + func actionFuncDecl(actions []BackendActionAdapter, csrf bool, rateLimit bool) *ast.FuncDecl { if len(actions) == 0 { return funcDecl("action", actionParams(), boolResults(), []ast.Stmt{returnBool(false)}) @@ -187,10 +204,13 @@ func actionCaseStmts(action BackendActionAdapter, csrf bool, rateLimit bool) []a stmts = append(stmts, actionParseFormStmts(action, csrf)...) switch { case actionUsesMultipart(action): - stmts = append(stmts, - define([]ast.Expr{id("data")}, call(sel("gowdkform", "FromMultipartForm"), selExpr(id("request"), "MultipartForm"))), - define([]ast.Expr{id("values")}, selExpr(id("data"), "Values")), - ) + stmts = append(stmts, define([]ast.Expr{id("data")}, call(sel("gowdkform", "FromMultipartForm"), selExpr(id("request"), "MultipartForm")))) + if actionNeedsValues(action) { + stmts = append(stmts, &ast.DeclStmt{Decl: &ast.GenDecl{Tok: token.VAR, Specs: []ast.Spec{&ast.ValueSpec{ + Names: []*ast.Ident{id("values")}, + Type: sel("gowdkform", "Values"), + }}}}) + } case actionNeedsData(action): stmts = append(stmts, define([]ast.Expr{id("values")}, call(sel("gowdkform", "FromURLValues"), selExpr(id("request"), "PostForm"))), @@ -354,18 +374,25 @@ func expectedValuesStmts(action BackendActionAdapter) []ast.Stmt { } func expectedDataStmts(action BackendActionAdapter) []ast.Stmt { - return []ast.Stmt{&ast.IfStmt{ + elseStmts := []ast.Stmt{ + assign([]ast.Expr{id("data")}, id("decodedData")), + } + if actionNeedsValues(action) { + elseStmts = append(elseStmts, assign([]ast.Expr{id("values")}, selExpr(id("data"), "Values"))) + } + stmts := []ast.Stmt{&ast.IfStmt{ Init: define([]ast.Expr{id("decodedData"), id("err")}, call(sel("gowdkform", "DecodeExpectedData"), id("data"), actionFormSchemaExpr(action))), Cond: notNil("err"), Body: block( writeNoStoreErrorStmt(sel("http", "StatusBadRequest"), "invalid form"), returnBool(true), ), - Else: block( - assign([]ast.Expr{id("data")}, id("decodedData")), - assign([]ast.Expr{id("values")}, selExpr(id("data"), "Values")), - ), + Else: block(elseStmts...), }} + if actionNeedsValues(action) { + stmts = append(stmts, assign([]ast.Expr{id("_")}, id("values"))) + } + return stmts } func actionRequiredValidationStmts(action BackendActionAdapter) []ast.Stmt { diff --git a/internal/gowdkcmd/release_workflow_test.go b/internal/gowdkcmd/release_workflow_test.go index c425fbfd..e5574203 100644 --- a/internal/gowdkcmd/release_workflow_test.go +++ b/internal/gowdkcmd/release_workflow_test.go @@ -47,11 +47,10 @@ func TestEditorReleaseWorkflowCoverage(t *testing.T) { func TestReleaseTrustWorkflowCoverage(t *testing.T) { releaseText := readWorkflow(t, "../../.github/workflows/release.yml") - smokeText := readWorkflow(t, "../../.github/workflows/release-smoke.yml") - cacheText := readWorkflow(t, "../../.github/workflows/cache-maintenance.yml") exampleReportText := readWorkflow(t, "../../scripts/check-example-reports.sh") for _, expected := range []string{ + "workflow_dispatch", "go version", "go env GOVERSION", "version --json", @@ -82,27 +81,13 @@ func TestReleaseTrustWorkflowCoverage(t *testing.T) { } for _, expected := range []string{ - "workflow_dispatch", - "scripts/smoke-release-artifact.sh", "gowdk-linux-amd64", "gowdk-darwin-amd64", "gowdk-darwin-arm64", "gowdk-windows-amd64.exe", } { - if !strings.Contains(smokeText, expected) { - t.Fatalf("expected %q in release-smoke.yml:\n%s", expected, smokeText) - } - } - - for _, expected := range []string{ - "workflow_dispatch", - "schedule:", - "actions: write", - "scripts/prune-github-caches.sh", - "GOWDK_CACHE_PRUNE_KEEP", - } { - if !strings.Contains(cacheText, expected) { - t.Fatalf("expected %q in cache-maintenance.yml:\n%s", expected, cacheText) + if !strings.Contains(releaseText, expected) { + t.Fatalf("expected %q in release.yml:\n%s", expected, releaseText) } } } diff --git a/scripts/check-example-reports.sh b/scripts/check-example-reports.sh index cd13ee5e..db8f992a 100755 --- a/scripts/check-example-reports.sh +++ b/scripts/check-example-reports.sh @@ -1,12 +1,11 @@ #!/usr/bin/env sh set -eu -examples="examples/pages/*.gwdk examples/marketing/*.gwdk examples/actions/*.gwdk examples/partials/*.gwdk examples/api/*.gwdk examples/ssr/*.gwdk examples/go-interop/*.gwdk examples/build-iteration/*.gwdk examples/components/base/*.gwdk examples/components/css/*.gwdk examples/components/assets/*.gwdk examples/components/wasm/*.gwdk examples/store-persist/*.gwdk examples/embed/*.gwdk examples/css/*.gwdk examples/tailwind/*.gwdk examples/contracts/*.gwdk examples/security/*.gwdk" +inventory="examples/smoke-sources.txt" +examples="$(sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$inventory" | tr '\n' ' ')" extended=false if [ "${1:-}" = "--extended" ]; then extended=true -else - examples="$examples examples/seo/*.gwdk" fi go run ./cmd/gowdk check --ssr $examples