From 447e4b84c2fe1801c03a068f11592ab60f114c9c Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Mon, 29 Jun 2026 05:31:05 +0000 Subject: [PATCH 1/2] examples(draft-form): server-side formnovalidate recipe + e2e (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps livetemplate to v0.15.0 and adds a runnable example + recipe page for its server-side formnovalidate honoring. - examples/draft-form: a post editor with a required title and two buttons — "Publish" (validates) and "Save draft" (formnovalidate). Both call the same ctx.ValidateForm(); it enforces for Publish and skips for the formnovalidate Save draft, on every tier. Mounted at /apps/draft-form/ and (WS-disabled) /apps/draft-form/no-js/ to show the no-JS native-POST path, where the kebab-case save-draft button name also routes to SaveDraft. - content/recipes/formnovalidate.md: walks the feature with the live embeds, source includes, the all-tiers/no-client-code point, and a trust note (convenience, not a security boundary). - e2e/draft_form_test.go: self-contained chromedp test (own httptest server) capturing all four signals — console, server logs, WS frames, rendered HTML. Scenario 1 (save-draft + empty title → saved as draft) is the server-side proof of #239; scenario 2 documents that the browser's native required check blocks an empty Publish before any round-trip on the JS tier. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/site/main.go | 16 ++ content/recipes/formnovalidate.md | 83 ++++++++ content/recipes/index.md | 1 + e2e/draft_form_test.go | 318 ++++++++++++++++++++++++++++++ examples/draft-form/draft.go | 48 +++++ examples/draft-form/draft.tmpl | 22 +++ examples/draft-form/handler.go | 57 ++++++ go.mod | 4 +- go.sum | 10 +- 9 files changed, 548 insertions(+), 11 deletions(-) create mode 100644 content/recipes/formnovalidate.md create mode 100644 e2e/draft_form_test.go create mode 100644 examples/draft-form/draft.go create mode 100644 examples/draft-form/draft.tmpl create mode 100644 examples/draft-form/handler.go diff --git a/cmd/site/main.go b/cmd/site/main.go index 7d988e2..010dd11 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -23,6 +23,7 @@ import ( "github.com/livetemplate/docs/examples/counter" counterbasic "github.com/livetemplate/docs/examples/counter-basic" + draftform "github.com/livetemplate/docs/examples/draft-form" "github.com/livetemplate/docs/examples/greet" greetloading "github.com/livetemplate/docs/examples/greet-loading" greetloadingserver "github.com/livetemplate/docs/examples/greet-loading-server" @@ -123,6 +124,21 @@ func main() { livetemplate.WithWebSocketDisabled(), ))) + // draft-form demonstrates server-side formnovalidate: "Publish" validates + // the required title; "Save draft" carries formnovalidate so the SAME + // ctx.ValidateForm() call is skipped. The default mount keeps WebSocket on; + // the /no-js/ mount is WS-disabled (mountStripped rewrites the PRG Location + // back under the prefix) so the reader can watch the skip hold over a plain + // HTTP form POST — the tier where the kebab-case save-draft button name also + // routes to SaveDraft verbatim. + mux.Handle("/apps/draft-form/", http.StripPrefix("/apps/draft-form", draftform.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) + mux.Handle("/apps/draft-form/no-js/", mountStripped("/apps/draft-form/no-js", draftform.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + livetemplate.WithWebSocketDisabled(), + ))) + // Step 3 — greet-loading: the HTML-declared loading spinner (a class the // client toggles on pending/done). WS-disabled like the other middle // steps; the loading lifecycle resolves over either transport. diff --git a/content/recipes/formnovalidate.md b/content/recipes/formnovalidate.md new file mode 100644 index 0000000..bf41a5d --- /dev/null +++ b/content/recipes/formnovalidate.md @@ -0,0 +1,83 @@ +--- +title: "Skip validation with formnovalidate" +description: "Let one button on a form bypass server-side validation — a 'Save draft' next to 'Publish' — using the standard HTML formnovalidate attribute, honored on every tier with no client code." +source_repo: https://github.com/livetemplate/docs +source_path: content/recipes/formnovalidate.md +--- + +# Skip validation with `formnovalidate` + +A form often has two submit buttons that mean different things. **Publish** should +enforce every rule — the title is required, the URL must parse. **Save draft** +should save whatever you have so far, half-finished and all, so you can come back +later. HTML already has an attribute for exactly this: `formnovalidate` on a submit +control tells the browser to skip its native constraint check for that button. + +LiveTemplate honors `formnovalidate` **on the server too**. `ctx.ValidateForm()` +checks which control submitted the form, and when that control carried +`formnovalidate`, validation is skipped — so the same call enforces the rules for +**Publish** and waves them through for **Save draft**. No client code, no second +code path, and it works whether the submit arrived over the WebSocket, an HTTP +`fetch()`, or a plain no-JS form POST. + +```embed-lvt path="/apps/draft-form/" upstream="http://localhost:9091" height="320px" +``` + +Leave the title empty and press **Publish** — you get a "required" error. Leave it +empty and press **Save draft** — it saves as a draft. Same field, same validation +call, different button. + +## One template, two buttons + +The only markup that matters is the `formnovalidate` attribute on the second button: + +```html include="/examples/draft-form/draft.tmpl" lines="14-19" +``` + +`required` on the input drives the browser's native check (and the server's — the +framework infers the rule from the same attribute). `formnovalidate` on +`save-draft` opts that button out of both. + +## One validation call, honored by submitter + +Both actions call `ctx.ValidateForm()`. It enforces for `Publish` and skips for +`SaveDraft` — not because the handlers differ, but because the framework knows +which button was clicked: + +```go include="/examples/draft-form/draft.go" lines="21-48" +``` + +At parse time the framework records every submit control that carries +`formnovalidate` into the form schema (`FormSchema.NoValidateSubmitters`, keyed by +the control's `name`). At request time it compares the form's *submitter* — the +clicked button — against that set, and `ctx.ValidateForm()` returns `nil` early +when it matches. Because the decision is keyed on the submitter rather than the +action, it holds even under `lvt-on:submit` routing, where the action is the +handler and the submitter is a separate button. + +## It works without JavaScript + +The skip is enforced on the server, so it survives all the way down to a plain +form POST. The mount below is the same app with `WithWebSocketDisabled()`; with JS +off, the browser submits natively and the server still skips validation for the +`save-draft` button. + +```embed-lvt path="/apps/draft-form/no-js/" upstream="http://localhost:9091" height="320px" +``` + +On the no-JS tier the button's `name` *is* the action verbatim, so the kebab-case +`name="save-draft"` routes to the Go method `SaveDraft` (LiveTemplate matches +kebab-case, camelCase, snake_case, and PascalCase action names). That is why the +documented ` + + + + + diff --git a/examples/draft-form/handler.go b/examples/draft-form/handler.go new file mode 100644 index 0000000..33512bf --- /dev/null +++ b/examples/draft-form/handler.go @@ -0,0 +1,57 @@ +// Package draftform demonstrates server-side formnovalidate honoring: a post +// editor where "Publish" validates the required title and "Save draft" (a +// formnovalidate button) skips validation — the same ctx.ValidateForm() call, +// different outcome by submitter, on every tier. cmd/site mounts it at +// /apps/draft-form/ and (WS-disabled) at /apps/draft-form/no-js/. +package draftform + +import ( + "embed" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/livetemplate/livetemplate" +) + +//go:embed draft.tmpl +var templateFS embed.FS + +var ( + tmplPath string + tmplOnce sync.Once +) + +// extractTemplate writes the embedded template to a temp file so livetemplate's +// file-based loader can parse it at runtime (mirrors examples/greet-validate). +func extractTemplate() string { + tmplOnce.Do(func() { + dir, err := os.MkdirTemp("", "draft-form-tmpl-*") + if err != nil { + log.Fatalf("draft-form: mkdtemp: %v", err) + } + data, err := templateFS.ReadFile("draft.tmpl") + if err != nil { + log.Fatalf("draft-form: read embedded tmpl: %v", err) + } + tmplPath = filepath.Join(dir, "draft.tmpl") + if err := os.WriteFile(tmplPath, data, 0o644); err != nil { + log.Fatalf("draft-form: write tmpl: %v", err) + } + }) + return tmplPath +} + +// Handler returns the draft-form app as an http.Handler ready to mount. The +// initial state is an empty editor; callers supply environment-specific options +// (origin allowlists, WithWebSocketDisabled) via opts. +func Handler(opts ...livetemplate.Option) http.Handler { + baseOpts := []livetemplate.Option{ + livetemplate.WithParseFiles(extractTemplate()), + livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}), + } + tmpl := livetemplate.Must(livetemplate.New("draft-form", append(baseOpts, opts...)...)) + return tmpl.Handle(&Controller{}, livetemplate.AsState(&State{})) +} diff --git a/go.mod b/go.mod index 49fcd57..225fd1d 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/chromedp/chromedp v0.14.2 github.com/go-playground/validator/v10 v10.30.2 github.com/gorilla/websocket v1.5.3 - github.com/livetemplate/livetemplate v0.14.0 + github.com/livetemplate/livetemplate v0.15.0 github.com/livetemplate/lvt v0.1.6 github.com/livetemplate/lvt/components v0.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -36,8 +36,6 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/redis/go-redis/v9 v9.17.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/tdewolff/minify/v2 v2.24.8 // indirect - github.com/tdewolff/parse/v2 v2.8.5 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/net v0.51.0 // indirect diff --git a/go.sum b/go.sum index a8cd8f6..424c688 100644 --- a/go.sum +++ b/go.sum @@ -97,8 +97,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/livetemplate/livetemplate v0.14.0 h1:9RoA4c3gLpqjzj0DFnUrBLj+zmlrhQtX8C7V7v8LD3k= -github.com/livetemplate/livetemplate v0.14.0/go.mod h1:GMvZKyPUq8LSGfgD3pftKOHa6v+I+RDYyff2mNjeAYs= +github.com/livetemplate/livetemplate v0.15.0 h1:QC+3E4rhUjpvwbPZk5WvwVtUpwwadpyO9dw/pV145Xs= +github.com/livetemplate/livetemplate v0.15.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ= github.com/livetemplate/lvt v0.1.6 h1:1rDU5hDo+EtZ0mT+868wYD9czF2EHEgdacS4kpIUPQ4= github.com/livetemplate/lvt v0.1.6/go.mod h1:OrTdx3zvh0WeuugVueQoRG3ILRNJe/dThErxKsos6Rw= github.com/livetemplate/lvt/components v0.1.2 h1:MM2M5IZnsUAu0py9ZbtcQCo0bvUrL4Z3Ly/yDkYNyag= @@ -159,12 +159,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE= -github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw= -github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU= -github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= -github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= -github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/testcontainers/testcontainers-go v0.39.0 h1:uCUJ5tA+fcxbFAB0uP3pIK3EJ2IjjDUHFSZ1H1UxAts= github.com/testcontainers/testcontainers-go v0.39.0/go.mod h1:qmHpkG7H5uPf/EvOORKvS6EuDkBUPE3zpVGaH9NL7f8= github.com/testcontainers/testcontainers-go/modules/redis v0.39.0 h1:p54qELdCx4Gftkxzf44k9RJRRhaO/S5ehP9zo8SUTLM= From cab4cc38fe77ab2f536bd382c644f51413a9cca9 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Mon, 29 Jun 2026 05:34:06 +0000 Subject: [PATCH 2/2] test(draft-form): make chromium ExecPath conditional for CI portability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardcoded /run/current-system/sw/bin/chromium path is the local dev box's binary. CI (ubuntu-latest) has google-chrome-stable on PATH, which chromedp's default allocator finds — so only apply ExecPath when the local chromium binary actually exists, otherwise forcing a nonexistent path breaks the CI e2e run. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/draft_form_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/e2e/draft_form_test.go b/e2e/draft_form_test.go index e1dcc1a..7f790c1 100644 --- a/e2e/draft_form_test.go +++ b/e2e/draft_form_test.go @@ -22,6 +22,7 @@ import ( "log/slog" "net/http" "net/http/httptest" + "os" "strings" "sync" "testing" @@ -34,8 +35,9 @@ import ( draftform "github.com/livetemplate/docs/examples/draft-form" ) -// chromiumPath is the chromium binary available in this environment; there is -// no google-chrome, so the default exec allocator's lookup would fail. +// chromiumPath is the chromium binary on the local dev box (no google-chrome +// there). It's used only when present; CI relies on the default allocator +// finding google-chrome-stable on PATH instead. const chromiumPath = "/run/current-system/sw/bin/chromium" // clientJSURL is the CDN bundle the draft.tmpl loads. The test needs it to @@ -69,12 +71,17 @@ func (s *syncBuf) String() string { func newDraftCtx(t *testing.T) (context.Context, context.CancelFunc) { t.Helper() allocOpts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.ExecPath(chromiumPath), chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("no-sandbox", true), chromedp.WSURLReadTimeout(45*time.Second), ) + // Pin chromium only when it exists at this path (local dev env). In CI + // (ubuntu-latest) chromedp's default allocator finds google-chrome-stable on + // PATH, so forcing a nonexistent ExecPath would break the run. + if _, err := os.Stat(chromiumPath); err == nil { + allocOpts = append(allocOpts, chromedp.ExecPath(chromiumPath)) + } allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), allocOpts...) ctx, cancel := chromedp.NewContext(allocCtx) timeoutCtx, timeoutCancel := context.WithTimeout(ctx, 45*time.Second)