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 @@
+
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 @@
+
| Keyboard | stocked |
| Mouse | stocked |
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 @@
+Modal body
Loaded from a partial action.
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 list | ready |
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 item | ready |
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 @@
+
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, `| Keyboard | stocked |
| Mouse | stocked |
`)
+ 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(`| %s | updated |
`, 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", `Modal body
Loaded from a partial action.
`), 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 item | ready |
`), 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 list | ready |
`)
+ 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 ``
-}
-
-func alertUploadHTML(message string) string {
- return ``
-}
-
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