Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb13839157 | |||
| f833498b65 | |||
| 6064ddd856 | |||
| dc56261b58 | |||
| c0f4782f2b | |||
| 47dcab8fea | |||
|
|
e63ebd1401 | ||
|
|
b26ded951f | ||
|
|
8bb1b99ae9 | ||
|
|
0d8bf04446 | ||
|
|
742212fd20 | ||
|
|
60009124c9 | ||
|
|
42b21348cb | ||
|
|
58ad9a2699 | ||
|
|
f3a9c8036f | ||
|
|
6763e1a420 | ||
|
|
5d61149fa3 | ||
|
|
08b7dbd17f | ||
|
|
cd2bfb6978 | ||
|
|
539a2ad504 | ||
|
|
11c6354da0 | ||
|
|
719b389be6 | ||
|
|
1384e49e14 | ||
|
|
785f11e52d | ||
|
|
2f19874c17 | ||
|
|
27b8540b71 | ||
|
|
532651552a | ||
|
|
2310e45d35 | ||
|
|
10b4838f8d | ||
|
|
5362614c3e | ||
|
|
e636970f7b | ||
|
|
f5158b866c | ||
|
|
2f6c5916ce | ||
|
|
0762ddbbc2 | ||
|
|
b7acfa6302 | ||
|
|
8aa91c577c | ||
|
|
6dcd54c88b | ||
|
|
2c44671d0e | ||
|
|
53e5733100 | ||
|
|
11543947bd | ||
|
|
e79bb0e1b0 | ||
|
|
d1e8e3a2ed | ||
|
|
4a7acbb630 | ||
|
|
a7ace9099f | ||
|
|
d8318af9c4 | ||
|
|
30cc6d88e6 | ||
|
|
88bd0f31df |
14
.claude/commands/pr.md
Normal file
14
.claude/commands/pr.md
Normal file
@@ -0,0 +1,14 @@
|
||||
Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remotes.
|
||||
|
||||
1. If in a worktree (working directory contains `.claude/worktrees/`), you are already on a feature branch — do NOT create a new one. Otherwise, create a new branch from main with a descriptive name.
|
||||
2. Stage and commit all changes with a clean, semantic commit message. No Claude attribution lines.
|
||||
3. Fetch latest main and rebase: `git fetch gitea main && git rebase gitea/main`.
|
||||
- If conflicts occur, abort the rebase (`git rebase --abort`), analyze the conflicting files, write a plan to resolve them, and present the plan to the user before proceeding.
|
||||
4. Push the branch to both remotes: `git push -u gitea <branch> && git push origin <branch>` (use `--force-with-lease` if already pushed).
|
||||
5. Create a Gitea PR: `tea pr create --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
|
||||
6. Wait for CI to pass: poll Gitea CI status. If CI fails, report the failure and stop — do not merge.
|
||||
7. Once CI passes, squash-merge on Gitea: `tea pr merge <index> --style squash` with a clean, semantic commit message including the PR number. No Claude attribution lines.
|
||||
8. Update local main and push to both remotes. If in a worktree, `main` is checked out in the primary tree, so run from there: `cd <primary-worktree> && git pull gitea main && git push origin main` (the primary worktree path is the repo root without `.claude/worktrees/…`). If not in a worktree: `git checkout main && git pull gitea main && git push origin main`.
|
||||
9. Clean up remote branches: `git push gitea --delete <branch> && git push origin --delete <branch>`.
|
||||
10. Prune refs: `git remote prune gitea && git remote prune origin`.
|
||||
11. Report the merged PR URL.
|
||||
20
.claude/commands/release.md
Normal file
20
.claude/commands/release.md
Normal file
@@ -0,0 +1,20 @@
|
||||
Create a new release on Gitea. Push tags to both remotes.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
1. **Worktree guard**: If the working directory is inside `.claude/worktrees/`, STOP and tell the user: "Releases must be created from a non-worktree session on main. Exit this worktree or start a new session, then run /release." Do not proceed.
|
||||
2. Verify you are on `main`. If not, STOP.
|
||||
3. Verify there are no uncommitted changes. If there are, STOP — they should go through a PR.
|
||||
4. Run `git pull --ff-only` on main. Fetch tags from all remotes.
|
||||
|
||||
## Release
|
||||
|
||||
5. Review commits since the last tag. Recommend a semver bump:
|
||||
- **major**: breaking/incompatible API changes
|
||||
- **minor**: new features, meaningful new behavior
|
||||
- **patch**: bug fixes, docs, refactoring with no new features
|
||||
Present the proposed version, bump rationale, and commit list. Wait for user approval.
|
||||
6. Tag the new version. Push the tag to both remotes: `git push gitea <tag> && git push origin <tag>`.
|
||||
7. Generate release notes grouped by type (features, fixes, chores).
|
||||
8. Create a Gitea release with `tea releases create` using the notes.
|
||||
9. Report the release URL and confirm all remotes are up to date.
|
||||
@@ -8,9 +8,6 @@ on:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
name: Build and Test
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -37,13 +37,12 @@ go.work.sum
|
||||
# Air artifacts
|
||||
*tmp/
|
||||
|
||||
# binaries
|
||||
internal/examples/chatroom/chatroom
|
||||
internal/examples/counter/counter
|
||||
internal/examples/countercomp/countercomp
|
||||
internal/examples/greeter/greeter
|
||||
internal/examples/livereload/livereload
|
||||
internal/examples/picocss/picocss
|
||||
internal/examples/plugins/plugins
|
||||
internal/examples/realtimechart/realtimechart
|
||||
internal/examples/shakespeare/shakespeare
|
||||
# Example binaries and data files
|
||||
internal/examples/*/[a-z]*[!.go]
|
||||
internal/examples/shakespeare/shake.db
|
||||
|
||||
# NATS data directory
|
||||
data/
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
20
CLAUDE.md
Normal file
20
CLAUDE.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Via Project Instructions
|
||||
|
||||
## Workflow
|
||||
|
||||
All changes go through PRs:
|
||||
|
||||
1. Enter a worktree (`EnterWorktree`) at session start.
|
||||
2. Make changes, commit with semantic messages.
|
||||
3. `/pr` to push, open a PR, wait for CI, and squash-merge.
|
||||
|
||||
## Releasing
|
||||
|
||||
Run `/release` from a **non-worktree session on main**. It tags and publishes
|
||||
what is already on main — it does not commit new changes.
|
||||
|
||||
## Worktree Usage
|
||||
|
||||
Always enter a worktree at the start of a session using the `EnterWorktree`
|
||||
tool. This prevents parallel Claude Code sessions from interfering with each
|
||||
other.
|
||||
74
README.md
74
README.md
@@ -1,30 +1,33 @@
|
||||
# ⚡Via
|
||||
# Via
|
||||
|
||||
Real-time engine for building reactive web applications in pure Go.
|
||||
|
||||
|
||||
## Why Via?
|
||||
Somewhere along the way, the web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks.
|
||||
|
||||
Via takes a radical stance:
|
||||
The web became tangled in layers of JavaScript, build chains, and frameworks stacked on frameworks. Via takes a different path.
|
||||
|
||||
- No templates.
|
||||
- No JavaScript.
|
||||
- No transpilation.
|
||||
- No hydration.
|
||||
- No front-end fatigue.
|
||||
- Single SSE stream.
|
||||
- Full reactivity.
|
||||
- Built-in Brotli compression.
|
||||
- Pure Go.
|
||||
**Philosophy**
|
||||
- No templates. No JavaScript. No transpilation. No hydration.
|
||||
- Views are pure Go functions. HTML is composed with a type-safe DSL.
|
||||
- A single SSE stream carries all reactivity — no WebSocket juggling, no polling.
|
||||
|
||||
**Batteries included**
|
||||
- Automatic CSRF protection on every action call
|
||||
- Token-bucket rate limiting (global defaults + per-action overrides)
|
||||
- Cookie-based sessions backed by SQLite
|
||||
- Pub/sub messaging with an embedded NATS backend
|
||||
- Structured logging via zerolog
|
||||
- Graceful shutdown with context draining
|
||||
- Brotli compression out of the box
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/go-via/via"
|
||||
"github.com/go-via/via/h"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
type Counter struct{ Count int }
|
||||
@@ -57,25 +60,42 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
## What's built in
|
||||
|
||||
## 🚧 Experimental
|
||||
<s>Via is still a newborn.</s> Via is taking its first steps!
|
||||
- Version `0.1.0` released.
|
||||
- Expect a little less chaos.
|
||||
- **Reactive views + signals** — bind state to the DOM; changes push over SSE automatically
|
||||
- **Components** — self-contained subcontexts with their own data, actions, and signals
|
||||
- **Sessions** — cookie-based, backed by SQLite via `scs`
|
||||
- **Pub/sub** — embedded NATS server with JetStream; generic `Publish[T]` / `Subscribe[T]` helpers
|
||||
- **CSRF protection** — automatic token generation and validation on every action
|
||||
- **Rate limiting** — token-bucket algorithm, configurable globally and per-action
|
||||
- **Event handling** — `OnClick`, `OnChange`, `OnSubmit`, `OnInput`, `OnFocus`, `OnBlur`, `OnMouseEnter`, `OnMouseLeave`, `OnScroll`, `OnDblClick`, `OnKeyDown`, and `OnKeyDownMap` for multi-key bindings
|
||||
- **Timed routines** — `OnInterval` auto-starts a ticker goroutine, returns a stop function, tied to context lifecycle
|
||||
- **Redirects** — `Redirect`, `ReplaceURL`, and format-string variants
|
||||
- **Plugin system** — `func(v *V)` hooks for integrating CSS/JS libraries
|
||||
- **Structured logging** — zerolog with configurable levels; console output in dev, JSON in production
|
||||
- **Graceful shutdown** — listens for SIGINT/SIGTERM, drains contexts, closes pub/sub
|
||||
- **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
|
||||
|
||||
## Examples
|
||||
|
||||
The `internal/examples/` directory contains 19 runnable examples:
|
||||
|
||||
`chatroom` · `counter` · `countercomp` · `effectspike` · `greeter` · `keyboard` · `livereload` · `maplibre` · `middleware` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` · `signup` · `spa`
|
||||
|
||||
## Experimental
|
||||
|
||||
Via is maturing — sessions, CSRF, rate limiting, pub/sub, and graceful shutdown are in place — but the API is still evolving. Expect breaking changes before `v1`.
|
||||
|
||||
## Contributing
|
||||
|
||||
- Via is intentionally minimal and opinionated — and so is contributing.
|
||||
- If you love Go, simplicity, and meaningful abstractions — Come along for the ride!
|
||||
- Fork, branch, build, tinker with things, submit a pull request.
|
||||
- Fork, branch, build, tinker, submit a pull request.
|
||||
- Keep every line purposeful.
|
||||
- Share feedback: open an issue or start a discussion.
|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
Via builds upon the work of these amazing projects:
|
||||
Via builds upon the work of these projects:
|
||||
|
||||
- 🚀 [Datastar](https://data-star.dev) - The hypermedia powerhouse at the core of Via. It powers browser reactivity through Signals and enables real-time HTML/Signal patches over an always-on SSE event stream.
|
||||
- 🧩 [Gomponents](https://maragu.dev/gomponents) - The awesome project that gifts Via with Go-native HTML composition superpowers through the `via/h` package.
|
||||
|
||||
> Thank you for building something that doesn’t just function — it inspires. 🫶
|
||||
- [Datastar](https://data-star.dev) — the hypermedia framework powering browser reactivity through signals and real-time HTML patches over SSE.
|
||||
- [Gomponents](https://maragu.dev/gomponents) — Go-native HTML composition that powers the `via/h` package.
|
||||
|
||||
162
actiontrigger.go
162
actiontrigger.go
@@ -3,6 +3,7 @@ package via
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
@@ -21,6 +22,10 @@ type triggerOpts struct {
|
||||
hasSignal bool
|
||||
signalID string
|
||||
value string
|
||||
window bool
|
||||
preventDefault bool
|
||||
debounce time.Duration
|
||||
throttle time.Duration
|
||||
}
|
||||
|
||||
type withSignalOpt struct {
|
||||
@@ -34,8 +39,65 @@ func (o withSignalOpt) apply(opts *triggerOpts) {
|
||||
opts.value = o.value
|
||||
}
|
||||
|
||||
type withWindowOpt struct{}
|
||||
|
||||
func (o withWindowOpt) apply(opts *triggerOpts) {
|
||||
opts.window = true
|
||||
}
|
||||
|
||||
// WithWindow makes the event listener attach to the window instead of the element.
|
||||
func WithWindow() ActionTriggerOption {
|
||||
return withWindowOpt{}
|
||||
}
|
||||
|
||||
type withPreventDefaultOpt struct{}
|
||||
|
||||
func (o withPreventDefaultOpt) apply(opts *triggerOpts) {
|
||||
opts.preventDefault = true
|
||||
}
|
||||
|
||||
// WithPreventDefault calls evt.preventDefault() for matched keys.
|
||||
func WithPreventDefault() ActionTriggerOption {
|
||||
return withPreventDefaultOpt{}
|
||||
}
|
||||
|
||||
type withDebounceOpt struct{ d time.Duration }
|
||||
|
||||
func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d }
|
||||
|
||||
// WithDebounce adds a debounce modifier to the event trigger.
|
||||
func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} }
|
||||
|
||||
type withThrottleOpt struct{ d time.Duration }
|
||||
|
||||
func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d }
|
||||
|
||||
// WithThrottle adds a throttle modifier to the event trigger.
|
||||
func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} }
|
||||
|
||||
// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers.
|
||||
func formatDuration(d time.Duration) string {
|
||||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||||
}
|
||||
|
||||
// buildAttrKey constructs a Datastar attribute key with modifiers.
|
||||
// Order: event → debounce/throttle → window.
|
||||
func buildAttrKey(event string, opts *triggerOpts) string {
|
||||
key := "on:" + event
|
||||
if opts.debounce > 0 {
|
||||
key += "__debounce." + formatDuration(opts.debounce)
|
||||
}
|
||||
if opts.throttle > 0 {
|
||||
key += "__throttle." + formatDuration(opts.throttle)
|
||||
}
|
||||
if opts.window {
|
||||
key += "__window"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// WithSignal sets a signal value before triggering the action.
|
||||
func WithSignal(sig *signal, value string) ActionTriggerOption {
|
||||
func WithSignal(sig *Signal, value string) ActionTriggerOption {
|
||||
return withSignalOpt{
|
||||
signalID: sig.ID(),
|
||||
value: fmt.Sprintf("'%s'", value),
|
||||
@@ -43,7 +105,7 @@ func WithSignal(sig *signal, value string) ActionTriggerOption {
|
||||
}
|
||||
|
||||
// WithSignalInt sets a signal to an int value before triggering the action.
|
||||
func WithSignalInt(sig *signal, value int) ActionTriggerOption {
|
||||
func WithSignalInt(sig *Signal, value int) ActionTriggerOption {
|
||||
return withSignalOpt{
|
||||
signalID: sig.ID(),
|
||||
value: strconv.Itoa(value),
|
||||
@@ -54,7 +116,7 @@ func buildOnExpr(base string, opts *triggerOpts) string {
|
||||
if !opts.hasSignal {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base)
|
||||
return fmt.Sprintf("$%s=%s,%s", opts.signalID, opts.value, base)
|
||||
}
|
||||
|
||||
func applyOptions(options ...ActionTriggerOption) triggerOpts {
|
||||
@@ -73,14 +135,62 @@ func actionURL(id string) string {
|
||||
// to element nodes in a view.
|
||||
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts))
|
||||
return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
|
||||
// to element nodes in a view.
|
||||
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
|
||||
return h.Data(buildAttrKey("change", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnSubmit returns a via.h DOM attribute that triggers on form submit.
|
||||
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("submit", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnInput returns a via.h DOM attribute that triggers on input (without debounce).
|
||||
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("input", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
|
||||
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("focus", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
|
||||
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("blur", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
|
||||
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("mouseenter", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
|
||||
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("mouseleave", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnScroll returns a via.h DOM attribute that triggers on scroll.
|
||||
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("scroll", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnDblClick returns a via.h DOM attribute that triggers on double click.
|
||||
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
|
||||
opts := applyOptions(options...)
|
||||
return h.Data(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts))
|
||||
}
|
||||
|
||||
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
|
||||
@@ -92,5 +202,45 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
|
||||
if key != "" {
|
||||
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
||||
}
|
||||
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||
return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||
}
|
||||
|
||||
// KeyBinding pairs a key with an action and per-binding options.
|
||||
type KeyBinding struct {
|
||||
Key string
|
||||
Action *actionTrigger
|
||||
Options []ActionTriggerOption
|
||||
}
|
||||
|
||||
// KeyBind creates a KeyBinding for use with OnKeyDownMap.
|
||||
func KeyBind(key string, action *actionTrigger, options ...ActionTriggerOption) KeyBinding {
|
||||
return KeyBinding{Key: key, Action: action, Options: options}
|
||||
}
|
||||
|
||||
// OnKeyDownMap produces a single window-scoped keydown attribute that dispatches
|
||||
// to different actions based on the pressed key. Each binding can reference a
|
||||
// different action and carry its own signal/preventDefault options.
|
||||
func OnKeyDownMap(bindings ...KeyBinding) h.H {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
expr := ""
|
||||
for i, b := range bindings {
|
||||
opts := applyOptions(b.Options...)
|
||||
|
||||
branch := ""
|
||||
if opts.preventDefault {
|
||||
branch = "evt.preventDefault(),"
|
||||
}
|
||||
branch += buildOnExpr(actionURL(b.Action.id), &opts)
|
||||
|
||||
if i > 0 {
|
||||
expr += " : "
|
||||
}
|
||||
expr += fmt.Sprintf("evt.key==='%s' ? (%s)", b.Key, branch)
|
||||
}
|
||||
expr += " : void 0"
|
||||
|
||||
return h.Data("on:keydown__window", expr)
|
||||
}
|
||||
|
||||
10
ci-check.sh
10
ci-check.sh
@@ -6,9 +6,13 @@ set -o pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
echo "== CI: Format code =="
|
||||
go fmt ./...
|
||||
echo "OK: formatting complete"
|
||||
echo "== CI: Check formatting =="
|
||||
if [ -n "$(gofmt -l .)" ]; then
|
||||
echo "ERROR: files not formatted:"
|
||||
gofmt -l .
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: all files formatted"
|
||||
|
||||
echo "== CI: Run go vet =="
|
||||
if ! go vet ./...; then
|
||||
|
||||
54
computed.go
Normal file
54
computed.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
// computedSignal is a read-only signal whose value is derived from other signals.
|
||||
// It recomputes on every read and is included in patches only when the value changes.
|
||||
type computedSignal struct {
|
||||
id string
|
||||
compute func() string
|
||||
lastVal string
|
||||
changed bool
|
||||
}
|
||||
|
||||
func (s *computedSignal) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *computedSignal) String() string {
|
||||
return s.compute()
|
||||
}
|
||||
|
||||
func (s *computedSignal) Int() int {
|
||||
if n, err := strconv.Atoi(s.String()); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *computedSignal) Bool() bool {
|
||||
val := strings.ToLower(s.String())
|
||||
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||
}
|
||||
|
||||
func (s *computedSignal) Text() h.H {
|
||||
return h.Span(h.Data("text", "$"+s.id))
|
||||
}
|
||||
|
||||
// recompute calls the compute function and sets changed if the value differs from lastVal.
|
||||
func (s *computedSignal) recompute() {
|
||||
val := s.compute()
|
||||
if val != s.lastVal {
|
||||
s.lastVal = val
|
||||
s.changed = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *computedSignal) patchValue() string {
|
||||
return s.lastVal
|
||||
}
|
||||
190
computed_test.go
Normal file
190
computed_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputedBasic(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
v.Page("/", func(c *Context) {
|
||||
sig1 := c.Signal("hello")
|
||||
sig2 := c.Signal("world")
|
||||
cs = c.Computed(func() string {
|
||||
return sig1.String() + " " + sig2.String()
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
assert.Equal(t, "hello world", cs.String())
|
||||
}
|
||||
|
||||
func TestComputedReactivity(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
var sig1 *Signal
|
||||
v.Page("/", func(c *Context) {
|
||||
sig1 = c.Signal("a")
|
||||
sig2 := c.Signal("b")
|
||||
cs = c.Computed(func() string {
|
||||
return sig1.String() + sig2.String()
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
assert.Equal(t, "ab", cs.String())
|
||||
sig1.SetValue("x")
|
||||
assert.Equal(t, "xb", cs.String())
|
||||
}
|
||||
|
||||
func TestComputedInt(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
v.Page("/", func(c *Context) {
|
||||
sig := c.Signal(21)
|
||||
cs = c.Computed(func() string {
|
||||
return fmt.Sprintf("%d", sig.Int()*2)
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
assert.Equal(t, 42, cs.Int())
|
||||
}
|
||||
|
||||
func TestComputedBool(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
v.Page("/", func(c *Context) {
|
||||
sig := c.Signal("true")
|
||||
cs = c.Computed(func() string {
|
||||
return sig.String()
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
assert.True(t, cs.Bool())
|
||||
}
|
||||
|
||||
func TestComputedText(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
v.Page("/", func(c *Context) {
|
||||
cs = c.Computed(func() string { return "hi" })
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := cs.Text().Render(&buf)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
|
||||
}
|
||||
|
||||
func TestComputedChangeDetection(t *testing.T) {
|
||||
v := New()
|
||||
var ctx *Context
|
||||
var sig *Signal
|
||||
v.Page("/", func(c *Context) {
|
||||
ctx = c
|
||||
sig = c.Signal("a")
|
||||
c.Computed(func() string {
|
||||
return sig.String() + "!"
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
// First patch includes computed (changed=true from init)
|
||||
patch1 := ctx.prepareSignalsForPatch()
|
||||
assert.NotEmpty(t, patch1)
|
||||
|
||||
// Second patch: nothing changed, computed should not be included
|
||||
patch2 := ctx.prepareSignalsForPatch()
|
||||
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
|
||||
// but computed should not appear since its value didn't change.
|
||||
hasComputed := false
|
||||
ctx.signals.Range(func(_, value any) bool {
|
||||
if cs, ok := value.(*computedSignal); ok {
|
||||
_, inPatch := patch2[cs.ID()]
|
||||
hasComputed = inPatch
|
||||
}
|
||||
return true
|
||||
})
|
||||
assert.False(t, hasComputed)
|
||||
|
||||
// After changing dependency, computed should reappear
|
||||
sig.SetValue("b")
|
||||
patch3 := ctx.prepareSignalsForPatch()
|
||||
found := false
|
||||
ctx.signals.Range(func(_, value any) bool {
|
||||
if cs, ok := value.(*computedSignal); ok {
|
||||
if v, ok := patch3[cs.ID()]; ok {
|
||||
assert.Equal(t, "b!", v)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestComputedInComponent(t *testing.T) {
|
||||
v := New()
|
||||
var cs *computedSignal
|
||||
var parentCtx *Context
|
||||
v.Page("/", func(c *Context) {
|
||||
parentCtx = c
|
||||
c.Component(func(comp *Context) {
|
||||
sig := comp.Signal("via")
|
||||
cs = comp.Computed(func() string {
|
||||
return "hello " + sig.String()
|
||||
})
|
||||
comp.View(func() h.H { return h.Div() })
|
||||
})
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
assert.Equal(t, "hello via", cs.String())
|
||||
// Verify it's stored on the parent page context
|
||||
found := false
|
||||
parentCtx.signals.Range(func(_, value any) bool {
|
||||
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestComputedIsReadOnly(t *testing.T) {
|
||||
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
|
||||
// This test exists as documentation — if someone adds those methods, the
|
||||
// interface assertion below will need updating and serve as a reminder.
|
||||
var cs interface{} = &computedSignal{}
|
||||
type writable interface {
|
||||
SetValue(any)
|
||||
}
|
||||
type bindable interface {
|
||||
Bind() h.H
|
||||
}
|
||||
_, isWritable := cs.(writable)
|
||||
_, isBindable := cs.(bindable)
|
||||
assert.False(t, isWritable, "computedSignal must not have SetValue")
|
||||
assert.False(t, isBindable, "computedSignal must not have Bind")
|
||||
}
|
||||
|
||||
func TestComputedInjectSignalsSkips(t *testing.T) {
|
||||
v := New()
|
||||
var ctx *Context
|
||||
var cs *computedSignal
|
||||
v.Page("/", func(c *Context) {
|
||||
ctx = c
|
||||
cs = c.Computed(func() string { return "fixed" })
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
// Simulate browser sending back a value for the computed signal — should be ignored
|
||||
ctx.injectSignals(map[string]any{
|
||||
cs.ID(): "injected",
|
||||
})
|
||||
assert.Equal(t, "fixed", cs.String())
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package via
|
||||
|
||||
import "github.com/alexedwards/scs/v2"
|
||||
import (
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type LogLevel int
|
||||
func ptr(l zerolog.Level) *zerolog.Level { return &l }
|
||||
|
||||
const (
|
||||
undefined LogLevel = iota
|
||||
LogLevelError
|
||||
LogLevelWarn
|
||||
LogLevelInfo
|
||||
LogLevelDebug
|
||||
var (
|
||||
LogLevelDebug = ptr(zerolog.DebugLevel)
|
||||
LogLevelInfo = ptr(zerolog.InfoLevel)
|
||||
LogLevelWarn = ptr(zerolog.WarnLevel)
|
||||
LogLevelError = ptr(zerolog.ErrorLevel)
|
||||
)
|
||||
|
||||
// Plugin is a func that can mutate the given *via.V app runtime. It is useful to integrate popular JS/CSS UI libraries or tools.
|
||||
@@ -23,9 +25,12 @@ type Options struct {
|
||||
// The http server address. e.g. ':3000'
|
||||
ServerAddress string
|
||||
|
||||
// Level of the logs to write to stdout.
|
||||
// Options: Error, Warn, Info, Debug.
|
||||
LogLvl LogLevel
|
||||
// LogLevel sets the minimum log level. nil keeps the default (Info).
|
||||
LogLevel *zerolog.Level
|
||||
|
||||
// Logger overrides the default logger entirely. When set, LogLevel and
|
||||
// DevMode have no effect on logging.
|
||||
Logger *zerolog.Logger
|
||||
|
||||
// The title of the HTML document.
|
||||
DocumentTitle string
|
||||
@@ -45,4 +50,18 @@ type Options struct {
|
||||
// DatastarPath is the URL path where the script is served.
|
||||
// Defaults to "/_datastar.js" if empty.
|
||||
DatastarPath string
|
||||
|
||||
// PubSub enables publish/subscribe messaging. When nil, an embedded NATS
|
||||
// server starts automatically in Start(). Supply any PubSub implementation
|
||||
// to replace it.
|
||||
PubSub PubSub
|
||||
|
||||
// Streams declares JetStream streams to create when Start() initializes
|
||||
// the embedded NATS server. Ignored when a custom PubSub is configured.
|
||||
Streams []StreamConfig
|
||||
|
||||
// ActionRateLimit configures the default token-bucket rate limiter for
|
||||
// action endpoints. Zero values use built-in defaults (10 req/s, burst 20).
|
||||
// Set Rate to -1 to disable rate limiting entirely.
|
||||
ActionRateLimit RateLimitConfig
|
||||
}
|
||||
|
||||
308
context.go
308
context.go
@@ -5,13 +5,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Context is the living bridge between Go and the browser.
|
||||
@@ -20,17 +21,27 @@ import (
|
||||
type Context struct {
|
||||
id string
|
||||
route string
|
||||
csrfToken string
|
||||
app *V
|
||||
view func() h.H
|
||||
routeParams map[string]string
|
||||
componentRegistry map[string]*Context
|
||||
parentPageCtx *Context
|
||||
patchChan chan patch
|
||||
actionRegistry map[string]func()
|
||||
actionLimiter *rate.Limiter
|
||||
actionRegistry map[string]actionEntry
|
||||
signals *sync.Map
|
||||
mu sync.RWMutex
|
||||
navMu sync.Mutex
|
||||
ctxDisposedChan chan struct{}
|
||||
pageStopChan chan struct{}
|
||||
reqCtx context.Context
|
||||
fields []*Field
|
||||
subscriptions []Subscription
|
||||
subsMu sync.Mutex
|
||||
disposeOnce sync.Once
|
||||
createdAt time.Time
|
||||
sseConnected atomic.Bool
|
||||
sseDisconnectedAt atomic.Pointer[time.Time]
|
||||
}
|
||||
|
||||
// View defines the UI rendered by this context.
|
||||
@@ -41,8 +52,12 @@ func (c *Context) View(f func() h.H) {
|
||||
if f == nil {
|
||||
panic("nil viewfn")
|
||||
}
|
||||
if c.app.layout != nil {
|
||||
c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
|
||||
} else {
|
||||
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
|
||||
}
|
||||
}
|
||||
|
||||
// Component registers a subcontext that has self contained data, actions and signals.
|
||||
// It returns the component's view as a DOM node fn that can be placed in the view
|
||||
@@ -73,7 +88,6 @@ func (c *Context) Component(initCtx func(c *Context)) func() h.H {
|
||||
compCtx.parentPageCtx = c
|
||||
}
|
||||
initCtx(compCtx)
|
||||
c.componentRegistry[id] = compCtx
|
||||
return compCtx.view
|
||||
}
|
||||
|
||||
@@ -98,39 +112,46 @@ func (c *Context) isComponent() bool {
|
||||
// h.Button(h.Text("Increment n"), increment.OnClick()),
|
||||
// )
|
||||
// })
|
||||
func (c *Context) Action(f func()) *actionTrigger {
|
||||
func (c *Context) Action(f func(), opts ...ActionOption) *actionTrigger {
|
||||
id := genRandID()
|
||||
if f == nil {
|
||||
c.app.logErr(c, "failed to bind action '%s' to context: nil func", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
entry := actionEntry{fn: f}
|
||||
for _, opt := range opts {
|
||||
opt(&entry)
|
||||
}
|
||||
|
||||
if c.isComponent() {
|
||||
c.parentPageCtx.actionRegistry[id] = f
|
||||
c.parentPageCtx.actionRegistry[id] = entry
|
||||
} else {
|
||||
c.actionRegistry[id] = f
|
||||
c.actionRegistry[id] = entry
|
||||
}
|
||||
return &actionTrigger{id}
|
||||
}
|
||||
|
||||
func (c *Context) getActionFn(id string) (func(), error) {
|
||||
if f, ok := c.actionRegistry[id]; ok {
|
||||
return f, nil
|
||||
func (c *Context) getAction(id string) (actionEntry, error) {
|
||||
if e, ok := c.actionRegistry[id]; ok {
|
||||
return e, nil
|
||||
}
|
||||
return nil, fmt.Errorf("action '%s' not found", id)
|
||||
return actionEntry{}, fmt.Errorf("action '%s' not found", id)
|
||||
}
|
||||
|
||||
// OnInterval starts a go routine that sets a time.Ticker with the given duration and executes
|
||||
// the given handler func() on every tick. Use *Routine.UpdateInterval to update the interval.
|
||||
func (c *Context) OnInterval(duration time.Duration, handler func()) *OnIntervalRoutine {
|
||||
var cn chan struct{}
|
||||
if c.isComponent() { // components use the chan on the parent page ctx
|
||||
cn = c.parentPageCtx.ctxDisposedChan
|
||||
// OnInterval starts a goroutine that executes handler on every tick of the given duration.
|
||||
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
|
||||
// Returns a func() that stops the interval when called.
|
||||
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
|
||||
var disposeCh, pageCh chan struct{}
|
||||
if c.isComponent() {
|
||||
disposeCh = c.parentPageCtx.ctxDisposedChan
|
||||
pageCh = c.parentPageCtx.pageStopChan
|
||||
} else {
|
||||
cn = c.ctxDisposedChan
|
||||
disposeCh = c.ctxDisposedChan
|
||||
pageCh = c.pageStopChan
|
||||
}
|
||||
r := newOnIntervalRoutine(cn, duration, handler)
|
||||
return r
|
||||
return newOnInterval(disposeCh, pageCh, duration, handler)
|
||||
}
|
||||
|
||||
// Signal creates a reactive signal and initializes it with the given value.
|
||||
@@ -152,11 +173,11 @@ func (c *Context) OnInterval(duration time.Duration, handler func()) *OnInterval
|
||||
// the Context before each action call.
|
||||
// If any signal value is updated by the server, the update is automatically sent to the
|
||||
// browser when using Sync() or SyncSignsls().
|
||||
func (c *Context) Signal(v any) *signal {
|
||||
func (c *Context) Signal(v any) *Signal {
|
||||
sigID := genRandID()
|
||||
if v == nil {
|
||||
c.app.logErr(c, "failed to bind signal: nil signal value")
|
||||
return &signal{
|
||||
return &Signal{
|
||||
id: sigID,
|
||||
val: "error",
|
||||
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
|
||||
@@ -168,7 +189,7 @@ func (c *Context) Signal(v any) *signal {
|
||||
v = string(j)
|
||||
}
|
||||
}
|
||||
sig := &signal{
|
||||
sig := &Signal{
|
||||
id: sigID,
|
||||
val: v,
|
||||
changed: true,
|
||||
@@ -185,6 +206,40 @@ func (c *Context) Signal(v any) *signal {
|
||||
|
||||
}
|
||||
|
||||
// Computed creates a read-only signal whose value is derived from the given function.
|
||||
// The function is called on every read (String/Int/Bool) for fresh values,
|
||||
// and during sync to detect changes for browser patches.
|
||||
//
|
||||
// Computed signals cannot be bound to inputs or set manually.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// full := c.Computed(func() string {
|
||||
// return first.String() + " " + last.String()
|
||||
// })
|
||||
// c.View(func() h.H {
|
||||
// return h.Span(full.Text())
|
||||
// })
|
||||
func (c *Context) Computed(fn func() string) *computedSignal {
|
||||
sigID := genRandID()
|
||||
initial := fn()
|
||||
cs := &computedSignal{
|
||||
id: sigID,
|
||||
compute: fn,
|
||||
lastVal: initial,
|
||||
changed: true,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.isComponent() {
|
||||
c.parentPageCtx.signals.Store(sigID, cs)
|
||||
} else {
|
||||
c.signals.Store(sigID, cs)
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
func (c *Context) injectSignals(sigs map[string]any) {
|
||||
if sigs == nil {
|
||||
c.app.logErr(c, "signal injection failed: nil signals")
|
||||
@@ -195,15 +250,15 @@ func (c *Context) injectSignals(sigs map[string]any) {
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for sigID, val := range sigs {
|
||||
if _, ok := c.signals.Load(sigID); !ok {
|
||||
c.signals.Store(sigID, &signal{
|
||||
item, ok := c.signals.Load(sigID)
|
||||
if !ok {
|
||||
c.signals.Store(sigID, &Signal{
|
||||
id: sigID,
|
||||
val: val,
|
||||
})
|
||||
continue
|
||||
}
|
||||
item, _ := c.signals.Load(sigID)
|
||||
if sig, ok := item.(*signal); ok {
|
||||
if sig, ok := item.(*Signal); ok {
|
||||
sig.val = val
|
||||
sig.changed = false
|
||||
}
|
||||
@@ -226,13 +281,21 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
||||
defer c.mu.RUnlock()
|
||||
updatedSigs := make(map[string]any)
|
||||
c.signals.Range(func(sigID, value any) bool {
|
||||
if sig, ok := value.(*signal); ok {
|
||||
switch sig := value.(type) {
|
||||
case *Signal:
|
||||
if sig.err != nil {
|
||||
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
||||
return true
|
||||
}
|
||||
if sig.changed {
|
||||
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
||||
sig.changed = false
|
||||
}
|
||||
case *computedSignal:
|
||||
sig.recompute()
|
||||
if sig.changed {
|
||||
updatedSigs[sigID.(string)] = sig.patchValue()
|
||||
sig.changed = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -253,15 +316,22 @@ func (c *Context) sendPatch(p patch) {
|
||||
// Sync pushes the current view state and signal changes to the browser immediately
|
||||
// over the live SSE event stream.
|
||||
func (c *Context) Sync() {
|
||||
elemsPatch := bytes.NewBuffer(make([]byte, 0))
|
||||
c.syncView(false)
|
||||
}
|
||||
|
||||
func (c *Context) syncView(viewTransition bool) {
|
||||
elemsPatch := new(bytes.Buffer)
|
||||
if err := c.view().Render(elemsPatch); err != nil {
|
||||
c.app.logErr(c, "sync view failed: %v", err)
|
||||
return
|
||||
}
|
||||
c.sendPatch(patch{patchTypeElements, elemsPatch.String()})
|
||||
typ := patchType(patchTypeElements)
|
||||
if viewTransition {
|
||||
typ = patchTypeElementsWithVT
|
||||
}
|
||||
c.sendPatch(patch{typ, elemsPatch.String()})
|
||||
|
||||
updatedSigs := c.prepareSignalsForPatch()
|
||||
|
||||
if len(updatedSigs) != 0 {
|
||||
outgoingSigs, _ := json.Marshal(updatedSigs)
|
||||
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
||||
@@ -318,6 +388,15 @@ func (c *Context) ExecScript(s string) {
|
||||
c.sendPatch(patch{patchTypeScript, s})
|
||||
}
|
||||
|
||||
// RedirectView sets a view that redirects the browser to the given URL.
|
||||
// Use this in middleware to abort the chain and redirect in one step.
|
||||
func (c *Context) RedirectView(url string) {
|
||||
c.View(func() h.H {
|
||||
c.Redirect(url)
|
||||
return h.Div()
|
||||
})
|
||||
}
|
||||
|
||||
// Redirect navigates the browser to the given URL.
|
||||
// This triggers a full page navigation - the current context will be disposed
|
||||
// and a new context created at the destination URL.
|
||||
@@ -349,11 +428,63 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
|
||||
c.ReplaceURL(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks.
|
||||
// resetPageState tears down page-specific state (intervals, subscriptions,
|
||||
// actions, signals, fields) without disposing the context itself. The SSE
|
||||
// connection and context lifetime are unaffected.
|
||||
func (c *Context) resetPageState() {
|
||||
close(c.pageStopChan)
|
||||
c.unsubscribeAll()
|
||||
c.mu.Lock()
|
||||
c.actionRegistry = make(map[string]actionEntry)
|
||||
c.signals = new(sync.Map)
|
||||
c.fields = nil
|
||||
c.pageStopChan = make(chan struct{})
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Navigate performs an SPA navigation to the given path. It resets page state,
|
||||
// runs the target page's init function (with middleware), and pushes the new
|
||||
// view over the existing SSE connection with a view transition animation.
|
||||
// If popstate is true, replaceState is used instead of pushState.
|
||||
func (c *Context) Navigate(path string, popstate bool) {
|
||||
c.navMu.Lock()
|
||||
defer c.navMu.Unlock()
|
||||
|
||||
route, initFn, params := c.app.matchRoute(path)
|
||||
if initFn == nil {
|
||||
c.Redirect(path)
|
||||
return
|
||||
}
|
||||
c.resetPageState()
|
||||
c.route = route
|
||||
c.injectRouteParams(params)
|
||||
initFn(c)
|
||||
c.syncView(true)
|
||||
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
|
||||
if popstate {
|
||||
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
|
||||
} else {
|
||||
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
|
||||
}
|
||||
}
|
||||
|
||||
// dispose idempotently tears down this context: unsubscribes all pubsub
|
||||
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
|
||||
func (c *Context) dispose() {
|
||||
c.disposeOnce.Do(func() {
|
||||
c.unsubscribeAll()
|
||||
c.stopAllRoutines()
|
||||
})
|
||||
}
|
||||
|
||||
// stopAllRoutines closes ctxDisposedChan, broadcasting to all listening
|
||||
// goroutines (OnInterval, SSE loop) that this context is done.
|
||||
func (c *Context) stopAllRoutines() {
|
||||
select {
|
||||
case c.ctxDisposedChan <- struct{}{}:
|
||||
case <-c.ctxDisposedChan:
|
||||
// already closed
|
||||
default:
|
||||
close(c.ctxDisposedChan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,12 +492,9 @@ func (c *Context) injectRouteParams(params map[string]string) {
|
||||
if params == nil {
|
||||
return
|
||||
}
|
||||
m := make(map[string]string)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
maps.Copy(m, params)
|
||||
c.routeParams = m
|
||||
|
||||
c.routeParams = params
|
||||
}
|
||||
|
||||
// GetPathParam retrieves the value from the page request URL for the given parameter name
|
||||
@@ -403,20 +531,116 @@ func (c *Context) Session() *Session {
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends data to the given subject via the configured PubSub backend.
|
||||
// Returns an error if no PubSub is configured. No-ops during panic-check init.
|
||||
func (c *Context) Publish(subject string, data []byte) error {
|
||||
if c.id == "" {
|
||||
return nil
|
||||
}
|
||||
if c.app.pubsub == nil {
|
||||
return fmt.Errorf("pubsub not configured")
|
||||
}
|
||||
return c.app.pubsub.Publish(subject, data)
|
||||
}
|
||||
|
||||
// Subscribe creates a subscription on the configured PubSub backend.
|
||||
// The subscription is tracked for automatic cleanup when the context is disposed.
|
||||
// Returns an error if no PubSub is configured. No-ops during panic-check init.
|
||||
func (c *Context) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
||||
if c.id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if c.app.pubsub == nil {
|
||||
return nil, fmt.Errorf("pubsub not configured")
|
||||
}
|
||||
sub, err := c.app.pubsub.Subscribe(subject, handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track on page context for cleanup (components use parent, like signals/actions)
|
||||
target := c
|
||||
if c.isComponent() {
|
||||
target = c.parentPageCtx
|
||||
}
|
||||
target.subsMu.Lock()
|
||||
target.subscriptions = append(target.subscriptions, sub)
|
||||
target.subsMu.Unlock()
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// unsubscribeAll cleans up all tracked subscriptions for this context and its components.
|
||||
func (c *Context) unsubscribeAll() {
|
||||
c.subsMu.Lock()
|
||||
subs := c.subscriptions
|
||||
c.subscriptions = nil
|
||||
c.subsMu.Unlock()
|
||||
for _, sub := range subs {
|
||||
sub.Unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// Field creates a signal with validation rules attached.
|
||||
// The initial value seeds both the signal and the reset target.
|
||||
// The field is tracked on the context so ValidateAll/ResetFields
|
||||
// can operate on all fields by default.
|
||||
func (c *Context) Field(initial any, rules ...Rule) *Field {
|
||||
f := &Field{
|
||||
Signal: c.Signal(initial),
|
||||
rules: rules,
|
||||
initialVal: initial,
|
||||
}
|
||||
target := c
|
||||
if c.isComponent() {
|
||||
target = c.parentPageCtx
|
||||
}
|
||||
target.fields = append(target.fields, f)
|
||||
return f
|
||||
}
|
||||
|
||||
// ValidateAll runs Validate on each field, returning true only if all pass.
|
||||
// With no arguments it validates every field tracked on this context.
|
||||
func (c *Context) ValidateAll(fields ...*Field) bool {
|
||||
if len(fields) == 0 {
|
||||
fields = c.fields
|
||||
}
|
||||
ok := true
|
||||
for _, f := range fields {
|
||||
if !f.Validate() {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetFields resets each field to its initial value and clears errors.
|
||||
// With no arguments it resets every field tracked on this context.
|
||||
func (c *Context) ResetFields(fields ...*Field) {
|
||||
if len(fields) == 0 {
|
||||
fields = c.fields
|
||||
}
|
||||
for _, f := range fields {
|
||||
f.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func newContext(id string, route string, v *V) *Context {
|
||||
if v == nil {
|
||||
log.Fatal("create context failed: app pointer is nil")
|
||||
panic("create context failed: app pointer is nil")
|
||||
}
|
||||
|
||||
return &Context{
|
||||
id: id,
|
||||
route: route,
|
||||
csrfToken: genCSRFToken(),
|
||||
routeParams: make(map[string]string),
|
||||
app: v,
|
||||
componentRegistry: make(map[string]*Context),
|
||||
actionRegistry: make(map[string]func()),
|
||||
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
|
||||
actionRegistry: make(map[string]actionEntry),
|
||||
signals: new(sync.Map),
|
||||
patchChan: make(chan patch, 1),
|
||||
patchChan: make(chan patch, 8),
|
||||
ctxDisposedChan: make(chan struct{}, 1),
|
||||
pageStopChan: make(chan struct{}),
|
||||
createdAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
177
docs/getting-started.md
Normal file
177
docs/getting-started.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Getting Started
|
||||
|
||||
Via is a server-side reactive web framework for Go. The browser connects over SSE (Server-Sent Events), and all state lives on the server — signals, actions, and view rendering happen in Go. The browser is a thin display layer that Datastar keeps in sync via DOM morphing.
|
||||
|
||||
## Core Loop
|
||||
|
||||
Every Via app follows the same pattern:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "My App",
|
||||
})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
count := 0
|
||||
step := c.Signal(1)
|
||||
|
||||
increment := c.Action(func() {
|
||||
count += step.Int()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.P(h.Textf("Count: %d", count)),
|
||||
h.Label(
|
||||
h.Text("Step: "),
|
||||
h.Input(h.Type("number"), step.Bind()),
|
||||
),
|
||||
h.Button(h.Text("+"), increment.OnClick()),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
```
|
||||
|
||||
What happens:
|
||||
|
||||
1. `via.New()` creates the app, starts an embedded NATS server, and registers internal routes (`/_sse`, `/_action/{id}`, `/_navigate`, `/_session/close`).
|
||||
2. `v.Config()` applies settings.
|
||||
3. `v.Page()` registers a route. The init function receives a `*Context` where you define signals, actions, and the view.
|
||||
4. `v.Start()` starts the HTTP server and blocks until SIGINT/SIGTERM.
|
||||
|
||||
When a browser hits the page, Via creates a new `Context`, runs the init function, renders the full HTML document, and opens an SSE connection. From that point, every `c.Sync()` re-renders the view and pushes a DOM patch to the browser.
|
||||
|
||||
## Configuration
|
||||
|
||||
```go
|
||||
v.Config(via.Options{
|
||||
DevMode: true,
|
||||
ServerAddress: ":8080",
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DocumentTitle: "My App",
|
||||
Plugins: []via.Plugin{MyPlugin},
|
||||
SessionManager: sm,
|
||||
PubSub: customBackend,
|
||||
ActionRateLimit: via.RateLimitConfig{Rate: 20, Burst: 40},
|
||||
})
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `DevMode` | `false` | Enables context persistence across restarts, console logger, and Datastar inspector widget |
|
||||
| `ServerAddress` | `":3000"` | HTTP listen address |
|
||||
| `LogLevel` | `InfoLevel` | Minimum log level. Use `via.LogLevelDebug`, `LogLevelInfo`, `LogLevelWarn`, `LogLevelError` |
|
||||
| `Logger` | (auto) | Replace the default logger entirely. When set, `LogLevel` and `DevMode` have no effect on logging |
|
||||
| `DocumentTitle` | `"⚡ Via"` | The `<title>` of the HTML document |
|
||||
| `Plugins` | `nil` | Slice of plugin functions executed during `Config()` |
|
||||
| `SessionManager` | in-memory | Cookie-based session manager. See [PubSub and Sessions](pubsub-and-sessions.md) |
|
||||
| `DatastarContent` | (embedded) | Custom Datastar JS bytes |
|
||||
| `DatastarPath` | `"/_datastar.js"` | URL path for the Datastar script |
|
||||
| `PubSub` | embedded NATS | Custom PubSub backend. Replaces the default NATS. See [PubSub and Sessions](pubsub-and-sessions.md) |
|
||||
| `ActionRateLimit` | `10 req/s, burst 20` | Default token-bucket rate limiter for action endpoints. Rate of `-1` disables limiting |
|
||||
|
||||
## Static Files
|
||||
|
||||
Serve files from a directory:
|
||||
|
||||
```go
|
||||
v.Static("/assets/", "./static")
|
||||
```
|
||||
|
||||
Or from an embedded filesystem:
|
||||
|
||||
```go
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
v.StaticFS("/assets/", staticFS)
|
||||
```
|
||||
|
||||
Both disable directory listing and return 404 for directory paths.
|
||||
|
||||
## Head and Foot Injection
|
||||
|
||||
Add elements to every page's `<head>` or end of `<body>`:
|
||||
|
||||
```go
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("/assets/style.css")),
|
||||
h.Meta(h.Attr("name", "viewport"), h.Attr("content", "width=device-width, initial-scale=1")),
|
||||
)
|
||||
|
||||
v.AppendToFoot(
|
||||
h.Script(h.Src("/assets/app.js")),
|
||||
)
|
||||
```
|
||||
|
||||
These are additive and affect all pages globally.
|
||||
|
||||
## Plugins
|
||||
|
||||
A plugin is a `func(v *via.V)` that mutates the app during configuration — registering routes, injecting assets, or applying middleware.
|
||||
|
||||
```go
|
||||
func PicoCSSPlugin(v *via.V) {
|
||||
v.HTTPServeMux().HandleFunc("GET /css/pico.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
w.Write(picoCSSBytes)
|
||||
})
|
||||
v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")))
|
||||
}
|
||||
|
||||
// Usage:
|
||||
v.Config(via.Options{
|
||||
Plugins: []via.Plugin{PicoCSSPlugin},
|
||||
})
|
||||
```
|
||||
|
||||
Plugins have full access to the `*V` public API: `HTTPServeMux()`, `AppendToHead()`, `AppendToFoot()`, `Config()`, etc.
|
||||
|
||||
## DevMode
|
||||
|
||||
Enable during development for a better feedback loop:
|
||||
|
||||
```go
|
||||
v.Config(via.Options{DevMode: true})
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
- **Console logger** — Human-readable log output with timestamps.
|
||||
- **Context persistence** — Saves context-to-route mappings to `.via/devmode/ctx.json`. On server restart, reconnecting browsers restore their state instead of getting a blank page. Pair with [Air](https://github.com/air-verse/air) for hot-reloading.
|
||||
- **Datastar inspector** — Injects a widget showing live signal values and SSE activity.
|
||||
|
||||
## Custom HTTP Handlers
|
||||
|
||||
Access the underlying `*http.ServeMux` for custom routes:
|
||||
|
||||
```go
|
||||
mux := v.HTTPServeMux()
|
||||
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
```
|
||||
|
||||
Register custom handlers before calling `v.Start()`.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
|
||||
- [Routing and Navigation](routing-and-navigation.md) — Multi-page apps, middleware, SPA navigation
|
||||
- [PubSub and Sessions](pubsub-and-sessions.md) — Real-time messaging, persistent sessions
|
||||
- [HTML DSL](html-dsl.md) — The `h` package reference
|
||||
- [Project Structure](project-structure.md) — Organizing files as your app grows
|
||||
164
docs/html-dsl.md
Normal file
164
docs/html-dsl.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# HTML DSL
|
||||
|
||||
Reference for the `h` package — Via's HTML builder.
|
||||
|
||||
## Overview
|
||||
|
||||
The `h` package wraps [gomponents](https://github.com/maragudk/gomponents) with a single interface:
|
||||
|
||||
```go
|
||||
type H interface {
|
||||
Render(w io.Writer) error
|
||||
}
|
||||
```
|
||||
|
||||
Every element, attribute, and text node implements `H`. Build HTML by nesting function calls:
|
||||
|
||||
```go
|
||||
import "github.com/ryanhamamura/via/h"
|
||||
|
||||
h.Div(h.Class("card"),
|
||||
h.H2(h.Text("Title")),
|
||||
h.P(h.Textf("Count: %d", count)),
|
||||
h.Button(h.Text("Click"), action.OnClick()),
|
||||
)
|
||||
```
|
||||
|
||||
For cleaner templates, use a dot import:
|
||||
|
||||
```go
|
||||
import . "github.com/ryanhamamura/via/h"
|
||||
|
||||
Div(Class("card"),
|
||||
H2(Text("Title")),
|
||||
P(Textf("Count: %d", count)),
|
||||
Button(Text("Click"), action.OnClick()),
|
||||
)
|
||||
```
|
||||
|
||||
## Text Nodes
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `Text(s)` | Escaped text node |
|
||||
| `Textf(fmt, args...)` | Escaped text with `fmt.Sprintf` |
|
||||
| `Raw(s)` | Unescaped raw HTML — use for trusted content like SVG |
|
||||
| `Rawf(fmt, args...)` | Unescaped raw HTML with `fmt.Sprintf` |
|
||||
|
||||
## Elements
|
||||
|
||||
Every element function takes `...H` children (elements, attributes, and text nodes mixed together) except `Style(v string)` and `Title(v string)` which take a single string.
|
||||
|
||||
### Document structure
|
||||
|
||||
`HTML`, `Head`, `Body`, `Main`, `Header`, `Footer`, `Section`, `Article`, `Aside`, `Nav`, `Div`, `Span`
|
||||
|
||||
### Headings
|
||||
|
||||
`H1`, `H2`, `H3`, `H4`, `H5`, `H6`
|
||||
|
||||
### Text
|
||||
|
||||
`P`, `A`, `Strong`, `Em`, `B`, `I`, `U`, `S`, `Small`, `Mark`, `Del`, `Ins`, `Sub`, `Sup`, `Abbr`, `Cite`, `Code`, `Pre`, `Samp`, `Kbd`, `Var`, `Q`, `BlockQuote`, `Dfn`, `Wbr`, `Br`, `Hr`
|
||||
|
||||
### Forms
|
||||
|
||||
`Form`, `Input`, `Textarea`, `Select`, `Option`, `OptGroup`, `Button`, `Label`, `FieldSet`, `Legend`, `DataList`, `Meter`, `Progress`
|
||||
|
||||
### Tables
|
||||
|
||||
`Table`, `THead`, `TBody`, `TFoot`, `Tr`, `Th`, `Td`, `Caption`, `Col`, `ColGroup`
|
||||
|
||||
### Lists
|
||||
|
||||
`Ul`, `Ol`, `Li`, `Dl`, `Dt`, `Dd`
|
||||
|
||||
### Media
|
||||
|
||||
`Img`, `Audio`, `Video`, `Source`, `Picture`, `Canvas`, `IFrame`, `Embed`, `Object`
|
||||
|
||||
### Other
|
||||
|
||||
`Details`, `Summary`, `Dialog`, `Template`, `NoScript`, `Figure`, `FigCaption`, `Address`, `Time`, `Base`, `Link`, `Meta`, `Script`, `Area`
|
||||
|
||||
### Special signatures
|
||||
|
||||
| Function | Signature | Notes |
|
||||
|----------|-----------|-------|
|
||||
| `Style(v)` | `func Style(v string) H` | Inline `style` attribute, not a container element |
|
||||
| `StyleEl(children...)` | `func StyleEl(children ...H) H` | The `<style>` element as a container |
|
||||
| `Title(v)` | `func Title(v string) H` | Sets `<title>` text |
|
||||
|
||||
## Attributes
|
||||
|
||||
### Generic
|
||||
|
||||
```go
|
||||
Attr("name", "value") // name="value"
|
||||
Attr("disabled") // boolean attribute (no value)
|
||||
```
|
||||
|
||||
`Attr` with no value produces a boolean attribute. With one value, it produces a name-value pair. More than one value panics.
|
||||
|
||||
### Named helpers
|
||||
|
||||
| Function | HTML output |
|
||||
|----------|-------------|
|
||||
| `ID(v)` | `id="v"` |
|
||||
| `Class(v)` | `class="v"` |
|
||||
| `Href(v)` | `href="v"` |
|
||||
| `Src(v)` | `src="v"` |
|
||||
| `Type(v)` | `type="v"` |
|
||||
| `Value(v)` | `value="v"` |
|
||||
| `Placeholder(v)` | `placeholder="v"` |
|
||||
| `Rel(v)` | `rel="v"` |
|
||||
| `Role(v)` | `role="v"` |
|
||||
| `Data(name, v)` | `data-name="v"` (auto-prefixes `data-`) |
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```go
|
||||
h.If(showError, h.P(h.Class("error"), h.Text("Something went wrong")))
|
||||
```
|
||||
|
||||
Returns the node when `true`, `nil` (renders nothing) when `false`.
|
||||
|
||||
## Datastar Helpers
|
||||
|
||||
These produce attributes used by Datastar for client-side reactivity.
|
||||
|
||||
| Function | Output | Description |
|
||||
|----------|--------|-------------|
|
||||
| `DataInit(expr)` | `data-init="expr"` | Initialize client-side state |
|
||||
| `DataEffect(expr)` | `data-effect="expr"` | Reactive side effect expression |
|
||||
| `DataIgnoreMorph()` | `data-ignore-morph` | Skip this element during DOM morph. See [SPA Navigation](routing-and-navigation.md#dataignoremorph) |
|
||||
| `DataViewTransition(name)` | `style="view-transition-name: name"` | Animate element across SPA navigations. See [View Transitions](routing-and-navigation.md#view-transitions) |
|
||||
|
||||
> `DataViewTransition` sets the entire `style` attribute. If you also need other inline styles, include `view-transition-name` directly in a `Style()` call.
|
||||
|
||||
## Utilities
|
||||
|
||||
### HTML5
|
||||
|
||||
Full HTML5 document template:
|
||||
|
||||
```go
|
||||
h.HTML5(h.HTML5Props{
|
||||
Title: "My Page",
|
||||
Description: "Page description",
|
||||
Language: "en",
|
||||
Head: []h.H{h.Link(h.Rel("stylesheet"), h.Href("/style.css"))},
|
||||
Body: []h.H{h.Div(h.Text("Hello"))},
|
||||
})
|
||||
```
|
||||
|
||||
Via uses this internally to render the initial page document. You typically don't need it directly.
|
||||
|
||||
### JoinAttrs
|
||||
|
||||
Joins attribute values from child nodes by spaces:
|
||||
|
||||
```go
|
||||
h.JoinAttrs("class", h.Class("card"), h.Class("active"))
|
||||
// → class="card active"
|
||||
```
|
||||
164
docs/project-structure.md
Normal file
164
docs/project-structure.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Project Structure
|
||||
|
||||
Via's closure-based page model pulls signals, actions, and views into a single scope — similar to Svelte's single-file components. This works well at every scale, but the way you organize files should evolve as your app grows.
|
||||
|
||||
## Stage 1: Everything in main.go
|
||||
|
||||
For small apps and prototypes, keep everything in `main.go`. This is the right choice when your app is under ~150 lines or has a single page.
|
||||
|
||||
Within the file, follow this ordering convention inside each page:
|
||||
|
||||
```go
|
||||
v.Page("/", func(c *via.Context) {
|
||||
// State — plain Go variables and signals
|
||||
count := 0
|
||||
step := c.Signal(1)
|
||||
|
||||
// Actions — event handlers that mutate state
|
||||
increment := c.Action(func() {
|
||||
count += step.Int()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// View — returns the HTML tree
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.P(h.Textf("Count: %d", count)),
|
||||
h.Button(h.Text("+"), increment.OnClick()),
|
||||
)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
State → signals → actions → view. This reads top-to-bottom and matches the data flow: state is declared, actions mutate it, the view renders it.
|
||||
|
||||
The [counter](../internal/examples/counter/main.go) and [greeter](../internal/examples/greeter/main.go) examples use this layout.
|
||||
|
||||
## Stage 2: Page per file
|
||||
|
||||
When `main.go` has multiple pages or exceeds ~150 lines, extract each page into its own file as a package-level function.
|
||||
|
||||
`main.go` becomes the app skeleton — setup, configuration, routes, and start:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "My App",
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("/css/pico.css")),
|
||||
)
|
||||
|
||||
v.Page("/", HomePage)
|
||||
v.Page("/chat", ChatPage)
|
||||
|
||||
v.Start()
|
||||
}
|
||||
```
|
||||
|
||||
Each page lives in its own file with a descriptive name:
|
||||
|
||||
```go
|
||||
// home.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func HomePage(c *via.Context) {
|
||||
greeting := c.Signal("Hello")
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(h.P(h.Text(greeting.String())))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Components follow the same pattern — keep them in the page file if single-use, or extract to their own file if reused across pages. Middleware goes in the same file as the route group it protects, or in `middleware.go` if shared.
|
||||
|
||||
```
|
||||
myapp/
|
||||
├── main.go # skeleton + routes
|
||||
├── home.go # func HomePage(c *via.Context)
|
||||
├── chat.go # func ChatPage(c *via.Context)
|
||||
└── middleware.go # shared middleware
|
||||
```
|
||||
|
||||
## Stage 3: Co-located CSS and shared types
|
||||
|
||||
As pages accumulate custom styling, CSS strings in Go become hard to maintain — no syntax highlighting, no linting. Extract them to `.css` files alongside the pages they belong to and use `//go:embed` to load them.
|
||||
|
||||
```go
|
||||
// main.go
|
||||
package main
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed chat.css
|
||||
var chatCSS string
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||
h.StyleEl(h.Raw(chatCSS)),
|
||||
)
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
When multiple pages share the same structs, extract them to `types.go`. Framework-agnostic domain logic (helpers, dummy data, business rules) gets its own file too.
|
||||
|
||||
```
|
||||
myapp/
|
||||
├── main.go # skeleton + routes + global styles
|
||||
├── home.go
|
||||
├── chat.go
|
||||
├── chat.css # //go:embed in main.go
|
||||
├── types.go # shared types
|
||||
└── userdata.go # helpers, dummy data
|
||||
```
|
||||
|
||||
The [nats-chatroom](../internal/examples/nats-chatroom/) example demonstrates this layout.
|
||||
|
||||
## CSS Approaches
|
||||
|
||||
Via doesn't prescribe a CSS strategy. Two approaches work well:
|
||||
|
||||
**CSS framework classes in Go code** — Use Pico, Tailwind, or similar. Classes go directly in the view via `h.Class()`. Good for rapid prototyping since there's nothing to extract.
|
||||
|
||||
```go
|
||||
h.Div(h.Class("container"),
|
||||
h.Button(h.Class("primary"), h.Text("Save")),
|
||||
)
|
||||
```
|
||||
|
||||
**Co-located `.css` files with `//go:embed`** — Write plain CSS in a separate file, embed it, and inject via `AppendToHead`. You get syntax highlighting, linting, and clean separation.
|
||||
|
||||
```go
|
||||
//go:embed chat.css
|
||||
var chatCSS string
|
||||
|
||||
// in main():
|
||||
v.AppendToHead(h.StyleEl(h.Raw(chatCSS)))
|
||||
```
|
||||
|
||||
Use a framework for quick prototypes and dashboards. Switch to co-located CSS files when you have significant custom styling or want tooling support.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Getting Started](getting-started.md) — The core loop and configuration
|
||||
- [State and Interactivity](state-and-interactivity.md) — Signals, actions, components, validation
|
||||
264
docs/pubsub-and-sessions.md
Normal file
264
docs/pubsub-and-sessions.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# PubSub and Sessions
|
||||
|
||||
Infrastructure for multi-user real-time communication and persistent state.
|
||||
|
||||
## PubSub
|
||||
|
||||
Via includes an embedded NATS server that starts automatically with `v.Start()`. No external services required — pub/sub works out of the box.
|
||||
|
||||
### Interface
|
||||
|
||||
```go
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Subscription interface {
|
||||
Unsubscribe() error
|
||||
}
|
||||
```
|
||||
|
||||
You can replace the default NATS with any backend implementing this interface via `Options.PubSub`.
|
||||
|
||||
### Basic pub/sub
|
||||
|
||||
```go
|
||||
// Subscribe to messages
|
||||
via.Subscribe(c, "chat.room.general", func(msg ChatMessage) {
|
||||
messages = append(messages, msg)
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
// Publish a message
|
||||
via.Publish(c, "chat.room.general", ChatMessage{
|
||||
User: username,
|
||||
Message: text,
|
||||
Time: time.Now().UnixMilli(),
|
||||
})
|
||||
```
|
||||
|
||||
The generic helpers `via.Publish[T]` and `via.Subscribe[T]` handle JSON marshaling/unmarshaling automatically. They are package-level functions (not methods) because Go doesn't support generic methods.
|
||||
|
||||
Raw byte-level access is also available on the context:
|
||||
|
||||
```go
|
||||
c.Publish("subject", []byte("raw data"))
|
||||
c.Subscribe("subject", func(data []byte) { /* ... */ })
|
||||
```
|
||||
|
||||
### Auto-cleanup
|
||||
|
||||
Subscriptions created via `c.Subscribe()` or `via.Subscribe()` are tracked on the context and automatically unsubscribed when:
|
||||
|
||||
- The context is disposed (browser disconnects, tab closes)
|
||||
- SPA navigation moves to a different page
|
||||
|
||||
You don't need to manually unsubscribe in normal usage.
|
||||
|
||||
### Custom backend
|
||||
|
||||
Replace the embedded NATS with your own PubSub implementation:
|
||||
|
||||
```go
|
||||
v.Config(via.Options{
|
||||
PubSub: myRedisBackend,
|
||||
})
|
||||
```
|
||||
|
||||
This disables the embedded NATS server. The `NATSConn()` and `JetStream()` accessors will return nil.
|
||||
|
||||
## JetStream
|
||||
|
||||
NATS JetStream provides persistent, replayable message streams. Useful for chat history, event logs, or any scenario where new subscribers need to catch up on past messages.
|
||||
|
||||
### Declaring streams
|
||||
|
||||
The recommended approach is to declare streams in `Options.Streams`. They are created automatically when `v.Start()` initializes the embedded NATS server:
|
||||
|
||||
```go
|
||||
v.Config(via.Options{
|
||||
Streams: []via.StreamConfig{{
|
||||
Name: "CHAT",
|
||||
Subjects: []string{"chat.>"},
|
||||
MaxMsgs: 1000,
|
||||
MaxAge: 24 * time.Hour,
|
||||
}},
|
||||
})
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `Name` | Stream name |
|
||||
| `Subjects` | NATS subjects to capture (supports wildcards: `>` matches all sub-levels) |
|
||||
| `MaxMsgs` | Maximum number of messages to retain |
|
||||
| `MaxAge` | Maximum age before messages are discarded |
|
||||
|
||||
For dynamic stream creation after startup, `EnsureStream` is also available:
|
||||
|
||||
```go
|
||||
err := via.EnsureStream(v, via.StreamConfig{
|
||||
Name: "EVENTS",
|
||||
Subjects: []string{"events.>"},
|
||||
MaxMsgs: 500,
|
||||
MaxAge: 12 * time.Hour,
|
||||
})
|
||||
```
|
||||
|
||||
### Replay history
|
||||
|
||||
Retrieve recent messages from a stream:
|
||||
|
||||
```go
|
||||
messages, err := via.ReplayHistory[ChatMessage](v, "chat.room.general", 50)
|
||||
```
|
||||
|
||||
Returns up to the last `limit` messages on the subject, deserialized as `T`. Use this when a new user joins and needs to see recent history.
|
||||
|
||||
### Direct NATS access
|
||||
|
||||
For advanced use cases, access the NATS connection and JetStream context directly:
|
||||
|
||||
```go
|
||||
nc := v.NATSConn() // *nats.Conn, nil if custom PubSub
|
||||
js := v.JetStream() // nats.JetStreamContext, nil if custom PubSub
|
||||
```
|
||||
|
||||
### PubSub accessor
|
||||
|
||||
Access the configured PubSub backend from the `V` instance:
|
||||
|
||||
```go
|
||||
ps := v.PubSub() // via.PubSub interface, nil if none configured
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
Via uses [SCS](https://github.com/alexedwards/scs) for cookie-based session management.
|
||||
|
||||
### Setup with SQLite
|
||||
|
||||
```go
|
||||
db, _ := sql.Open("sqlite3", "app.db")
|
||||
|
||||
sm, _ := via.NewSQLiteSessionManager(db)
|
||||
sm.Lifetime = 24 * time.Hour
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
|
||||
v.Config(via.Options{SessionManager: sm})
|
||||
```
|
||||
|
||||
`NewSQLiteSessionManager` creates the `sessions` table and index if they don't exist. The returned `*scs.SessionManager` can be configured further (lifetime, cookie settings) before passing to `Config`.
|
||||
|
||||
A default in-memory session manager is always available, even without explicit configuration. Use `NewSQLiteSessionManager` when you need sessions to survive server restarts.
|
||||
|
||||
### Session API
|
||||
|
||||
Access the session from any context:
|
||||
|
||||
```go
|
||||
s := c.Session()
|
||||
```
|
||||
|
||||
**Getters:**
|
||||
|
||||
| Method | Return type |
|
||||
|--------|-------------|
|
||||
| `s.Get(key)` | `any` |
|
||||
| `s.GetString(key)` | `string` |
|
||||
| `s.GetInt(key)` | `int` |
|
||||
| `s.GetBool(key)` | `bool` |
|
||||
| `s.GetFloat64(key)` | `float64` |
|
||||
| `s.GetTime(key)` | `time.Time` |
|
||||
| `s.GetBytes(key)` | `[]byte` |
|
||||
|
||||
**Pop** (get and delete — useful for flash messages):
|
||||
|
||||
| Method | Return type |
|
||||
|--------|-------------|
|
||||
| `s.Pop(key)` | `any` |
|
||||
| `s.PopString(key)` | `string` |
|
||||
| `s.PopInt(key)` | `int` |
|
||||
| `s.PopBool(key)` | `bool` |
|
||||
| `s.PopFloat64(key)` | `float64` |
|
||||
| `s.PopTime(key)` | `time.Time` |
|
||||
| `s.PopBytes(key)` | `[]byte` |
|
||||
|
||||
**Mutators:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `s.Set(key, val)` | Store a value |
|
||||
| `s.Delete(key)` | Remove a single key |
|
||||
| `s.Clear()` | Remove all session data |
|
||||
| `s.Destroy()` | Destroy the entire session (for logout) |
|
||||
| `s.RenewToken()` | Regenerate session ID (prevents session fixation — call after login) |
|
||||
|
||||
**Introspection:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `s.Exists(key)` | True if key exists |
|
||||
| `s.Keys()` | All keys in the session |
|
||||
| `s.ID()` | Session token (cookie value) |
|
||||
|
||||
All getters return zero values if the key doesn't exist or the session manager is nil.
|
||||
|
||||
### Auth pattern
|
||||
|
||||
A common login/logout flow using sessions and middleware:
|
||||
|
||||
```go
|
||||
// Middleware
|
||||
func authRequired(c *via.Context, next func()) {
|
||||
if c.Session().GetString("username") == "" {
|
||||
c.Session().Set("flash", "Please log in first")
|
||||
c.RedirectView("/login")
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Login page
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
user := c.Signal("")
|
||||
pass := c.Signal("")
|
||||
flash := c.Session().PopString("flash")
|
||||
|
||||
login := c.Action(func() {
|
||||
if authenticate(user.String(), pass.String()) {
|
||||
c.Session().RenewToken()
|
||||
c.Session().Set("username", user.String())
|
||||
c.Redirect("/dashboard")
|
||||
} else {
|
||||
flash = "Invalid credentials"
|
||||
c.Sync()
|
||||
}
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Form(login.OnSubmit(),
|
||||
h.If(flash != "", h.P(h.Text(flash))),
|
||||
h.Input(h.Type("text"), user.Bind(), h.Placeholder("Username")),
|
||||
h.Input(h.Type("password"), pass.Bind(), h.Placeholder("Password")),
|
||||
h.Button(h.Type("submit"), h.Text("Log In")),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Protected pages
|
||||
protected := v.Group("", authRequired)
|
||||
protected.Page("/dashboard", dashboardHandler)
|
||||
|
||||
// Logout action (inside a protected page)
|
||||
logout := c.Action(func() {
|
||||
c.Session().Destroy()
|
||||
c.Redirect("/login")
|
||||
})
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Call `RenewToken()` after login to prevent session fixation.
|
||||
- Use `PopString` for flash messages — they're read once then removed.
|
||||
- Use `RedirectView` in middleware, `Redirect` in actions. See the [gotcha in routing](routing-and-navigation.md#middleware).
|
||||
222
docs/routing-and-navigation.md
Normal file
222
docs/routing-and-navigation.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# Routing and Navigation
|
||||
|
||||
Multi-page app structure, middleware, and Via's SPA navigation system.
|
||||
|
||||
## Pages
|
||||
|
||||
Register a page with a route pattern and an init function:
|
||||
|
||||
```go
|
||||
v.Page("/", func(c *via.Context) {
|
||||
c.View(func() h.H {
|
||||
return h.H1(h.Text("Home"))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Routes use Go's standard `net/http.ServeMux` patterns. Via registers each page as a `GET` handler.
|
||||
|
||||
> **Gotcha:** Via runs every page init function at registration time (in a `defer/recover` block) to catch panics early. If your init function panics — e.g. by forgetting `c.View()` — the app crashes at startup, not at request time.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
Use `{param}` syntax in route patterns:
|
||||
|
||||
```go
|
||||
v.Page("/users/{id}/posts/{post_id}", func(c *via.Context) {
|
||||
userID := c.GetPathParam("id")
|
||||
postID := c.GetPathParam("post_id")
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.P(h.Textf("User %s, Post %s", userID, postID))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
`GetPathParam` returns an empty string if the parameter doesn't exist.
|
||||
|
||||
## Route Groups
|
||||
|
||||
Group pages under a shared prefix with shared middleware:
|
||||
|
||||
```go
|
||||
admin := v.Group("/admin", authRequired)
|
||||
admin.Page("/dashboard", dashboardHandler) // route: /admin/dashboard
|
||||
admin.Page("/settings", settingsHandler) // route: /admin/settings
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
Groups nest — the child inherits the parent's prefix and middleware:
|
||||
|
||||
```go
|
||||
admin := v.Group("/admin", authRequired)
|
||||
admin.Use(auditLog) // add middleware after creation
|
||||
|
||||
superAdmin := admin.Group("/super", superAdminOnly)
|
||||
superAdmin.Page("/nuke", nukeHandler) // route: /admin/super/nuke
|
||||
// middleware order: global → authRequired → auditLog → superAdminOnly → handler
|
||||
```
|
||||
|
||||
### Empty prefix
|
||||
|
||||
Use an empty prefix when you need shared middleware without a path prefix:
|
||||
|
||||
```go
|
||||
protected := v.Group("", authRequired)
|
||||
protected.Page("/dashboard", dashboardHandler) // route: /dashboard
|
||||
protected.Page("/profile", profileHandler) // route: /profile
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
```go
|
||||
type Middleware func(c *Context, next func())
|
||||
```
|
||||
|
||||
Call `next()` to continue the chain. Return without calling `next()` to abort — but set a view first.
|
||||
|
||||
```go
|
||||
func authRequired(c *via.Context, next func()) {
|
||||
if c.Session().GetString("username") == "" {
|
||||
c.Session().Set("flash", "Please log in")
|
||||
c.RedirectView("/login")
|
||||
return // don't call next — chain is aborted
|
||||
}
|
||||
next()
|
||||
}
|
||||
```
|
||||
|
||||
> **Gotcha:** Use `c.RedirectView()` in middleware, not `c.Redirect()`. The SSE connection isn't open yet during the initial page load, so `Redirect()` (which sends a patch over SSE) won't work. `RedirectView()` sets the view to one that triggers a redirect once SSE connects.
|
||||
|
||||
### Three levels
|
||||
|
||||
| Level | Registration | Scope |
|
||||
|-------|-------------|-------|
|
||||
| Global | `v.Use(mw...)` | Every page |
|
||||
| Group | `v.Group(prefix, mw...)` or `g.Use(mw...)` | Pages in the group |
|
||||
| Action | `c.Action(fn, via.WithMiddleware(mw...))` | A single action endpoint |
|
||||
|
||||
### Execution order
|
||||
|
||||
Middleware runs in registration order: global first, then group, then the handler.
|
||||
|
||||
```go
|
||||
v.Use(logger) // 1st
|
||||
admin := v.Group("/admin", auth) // 2nd
|
||||
admin.Use(audit) // 3rd
|
||||
admin.Page("/x", handler) // 4th
|
||||
// execution: logger → auth → audit → handler
|
||||
```
|
||||
|
||||
Action-level middleware runs after CSRF validation and rate limiting, when the action endpoint is invoked.
|
||||
|
||||
## SPA Navigation
|
||||
|
||||
Via intercepts same-origin link clicks and navigates without a full page reload. The SSE connection persists, and the new page's view is morphed into the DOM with a view transition.
|
||||
|
||||
### How it works
|
||||
|
||||
1. `navigate.js` (embedded in every page) intercepts clicks on `<a>` elements.
|
||||
2. For same-origin links, it POSTs to `/_navigate` with the context ID, CSRF token, and target URL.
|
||||
3. The server calls `c.Navigate()`, which:
|
||||
- Resets page state (stops intervals, unsubscribes PubSub, clears signals/actions/fields)
|
||||
- Runs the target page's init function (with middleware) on the **same context**
|
||||
- Pushes the new view via SSE with a view transition
|
||||
- Updates the browser URL via `history.pushState()`
|
||||
|
||||
### What gets cleaned up on navigate
|
||||
|
||||
- Intervals stop (via `pageStopChan`)
|
||||
- PubSub subscriptions are unsubscribed
|
||||
- Signals, actions, and fields are cleared
|
||||
- The new page starts completely fresh
|
||||
|
||||
The SSE connection and the context itself survive. This is what makes it an SPA — the existing stream is reused.
|
||||
|
||||
### Layouts
|
||||
|
||||
Define a layout to provide persistent chrome (nav bars, sidebars) that wraps every page:
|
||||
|
||||
```go
|
||||
v.Layout(func(content func() h.H) h.H {
|
||||
return h.Div(
|
||||
h.Nav(
|
||||
h.A(h.Href("/"), h.Text("Home")),
|
||||
h.A(h.Href("/counter"), h.Text("Counter")),
|
||||
h.A(h.Href("/clock"), h.Text("Clock")),
|
||||
),
|
||||
h.Main(content()),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
The `content` parameter is the page's view function. During SPA navigation, the entire layout + content is re-rendered and morphed — Datastar's morph algorithm (idiomorph) efficiently updates only the changed parts, so the nav bar stays visually stable while the main content transitions.
|
||||
|
||||
> **Gotcha:** Layout state does not persist across navigations in the way page state doesn't — the layout is re-rendered from scratch each time. If you need state that survives navigation (like a selected nav item), derive it from the current route rather than storing it in a variable.
|
||||
|
||||
### View transitions
|
||||
|
||||
Animate elements across page navigations using the browser View Transitions API:
|
||||
|
||||
```go
|
||||
// On the home page:
|
||||
h.H1(h.Text("Home"), h.DataViewTransition("page-title"))
|
||||
|
||||
// On the counter page:
|
||||
h.H1(h.Text("Counter"), h.DataViewTransition("page-title"))
|
||||
```
|
||||
|
||||
Elements with matching `view-transition-name` values animate smoothly during SPA navigation. `DataViewTransition` sets the CSS `view-transition-name` as an inline `style` attribute. If the element also needs other inline styles, set `view-transition-name` directly in a `Style()` call instead.
|
||||
|
||||
Via automatically includes the `<meta name="view-transition" content="same-origin">` tag to enable the API.
|
||||
|
||||
### Opting out
|
||||
|
||||
Add `data-via-no-boost` to links that should trigger a full page reload:
|
||||
|
||||
```go
|
||||
h.A(h.Href("/"), h.Text("Full Reload"), h.Attr("data-via-no-boost"))
|
||||
```
|
||||
|
||||
Links are also auto-ignored when:
|
||||
- They have a `target` attribute (e.g. `target="_blank"`)
|
||||
- Modifier keys are held (Ctrl, Meta, Shift, Alt)
|
||||
- The `href` starts with `#` or is cross-origin
|
||||
- The `href` is missing
|
||||
|
||||
### Programmatic navigation
|
||||
|
||||
Trigger SPA navigation from an action handler:
|
||||
|
||||
```go
|
||||
goCounter := c.Action(func() {
|
||||
c.Navigate("/counter", false)
|
||||
})
|
||||
```
|
||||
|
||||
The second parameter controls history behavior: `false` for `pushState` (normal navigation), `true` for `replaceState` (back/forward).
|
||||
|
||||
If the path doesn't match any registered route, `Navigate` falls back to `c.Redirect()` (full page navigation).
|
||||
|
||||
### DataIgnoreMorph
|
||||
|
||||
Prevent Datastar from overwriting an element during morph:
|
||||
|
||||
```go
|
||||
h.Div(h.ID("toast-container"), h.DataIgnoreMorph())
|
||||
```
|
||||
|
||||
The element and its subtree are skipped during DOM patches. Useful for elements with client-side state: a focused input, an animation, a third-party widget, or a toast notification container.
|
||||
|
||||
## Custom HTTP Handlers
|
||||
|
||||
Access the underlying mux for non-Via routes (APIs, webhooks, health checks):
|
||||
|
||||
```go
|
||||
mux := v.HTTPServeMux()
|
||||
mux.HandleFunc("GET /api/health", healthHandler)
|
||||
mux.HandleFunc("POST /api/webhook", webhookHandler)
|
||||
```
|
||||
|
||||
Register before `v.Start()`. These routes bypass Via's context/SSE system entirely.
|
||||
313
docs/state-and-interactivity.md
Normal file
313
docs/state-and-interactivity.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# State and Interactivity
|
||||
|
||||
This is the core reactive model — signals, actions, views, components, and validation.
|
||||
|
||||
## Context Lifecycle
|
||||
|
||||
A `*Context` is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function.
|
||||
|
||||
```
|
||||
Browser hits page → new Context created → init function runs → HTML rendered
|
||||
↓
|
||||
SSE connection opens ← browser loads page
|
||||
↓
|
||||
action fires → signals injected from browser → handler runs → Sync() → DOM patched
|
||||
```
|
||||
|
||||
The context lives until the browser tab closes (detected via a `beforeunload` beacon) or the server shuts down. There is no background reaper — contexts persist across temporary SSE disconnections so backgrounded tabs resume seamlessly.
|
||||
|
||||
During [SPA navigation](routing-and-navigation.md#spa-navigation), the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
|
||||
|
||||
## Signals
|
||||
|
||||
Signals are reactive values synchronized between server and browser. Create one with an initial value:
|
||||
|
||||
```go
|
||||
name := c.Signal("world")
|
||||
count := c.Signal(0)
|
||||
items := c.Signal([]string{"a", "b"})
|
||||
```
|
||||
|
||||
### Reading values
|
||||
|
||||
```go
|
||||
name.String() // "world"
|
||||
count.Int() // 0
|
||||
count.Bool() // false (parses "true", "1", "yes", "on")
|
||||
```
|
||||
|
||||
Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers.
|
||||
|
||||
### Writing values
|
||||
|
||||
```go
|
||||
name.SetValue("Via")
|
||||
c.SyncSignals() // push only changed signals to browser
|
||||
// or
|
||||
c.Sync() // re-render view AND push changed signals
|
||||
```
|
||||
|
||||
`SetValue` marks the signal as changed. The change is not sent to the browser until you call `Sync()` or `SyncSignals()`.
|
||||
|
||||
### Rendering in the view
|
||||
|
||||
```go
|
||||
// Two-way binding on an input — browser edits update the signal
|
||||
h.Input(h.Type("text"), name.Bind())
|
||||
|
||||
// Reactive text display — updates when the signal changes
|
||||
h.Span(name.Text())
|
||||
|
||||
// Read value at render time — static until next Sync()
|
||||
h.P(h.Textf("Count: %d", count.Int()))
|
||||
```
|
||||
|
||||
`Bind()` outputs a `data-bind` attribute for two-way binding. `Text()` outputs a `<span data-text="$signalID">` for reactive display.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are server-side event handlers. They run on the server when triggered by a browser event.
|
||||
|
||||
```go
|
||||
submit := c.Action(func() {
|
||||
// signals are already injected — read them here
|
||||
fmt.Println(name.String())
|
||||
count.SetValue(count.Int() + 1)
|
||||
c.Sync()
|
||||
})
|
||||
```
|
||||
|
||||
### Trigger methods
|
||||
|
||||
Attach an action to a DOM event by calling a trigger method in the view:
|
||||
|
||||
```go
|
||||
h.Button(h.Text("Submit"), submit.OnClick())
|
||||
h.Input(name.Bind(), submit.OnKeyDown("Enter"))
|
||||
h.Select(category.Bind(), filter.OnChange())
|
||||
h.Form(submit.OnSubmit())
|
||||
```
|
||||
|
||||
Available triggers:
|
||||
|
||||
| Method | Event | Notes |
|
||||
|--------|-------|-------|
|
||||
| `OnClick()` | `click` | |
|
||||
| `OnDblClick()` | `dblclick` | |
|
||||
| `OnChange()` | `change` | |
|
||||
| `OnInput()` | `input` | No debounce |
|
||||
| `OnSubmit()` | `submit` | |
|
||||
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |
|
||||
| `OnFocus()` | `focus` | |
|
||||
| `OnBlur()` | `blur` | |
|
||||
| `OnMouseEnter()` | `mouseenter` | |
|
||||
| `OnMouseLeave()` | `mouseleave` | |
|
||||
| `OnScroll()` | `scroll` | |
|
||||
|
||||
### Trigger options
|
||||
|
||||
Every trigger method accepts `ActionTriggerOption` values:
|
||||
|
||||
```go
|
||||
// Set a signal value before the action fires
|
||||
submit.OnClick(via.WithSignal(mode, "delete"))
|
||||
submit.OnClick(via.WithSignalInt(page, 3))
|
||||
|
||||
// Listen on window instead of the element
|
||||
submit.OnKeyDown("Escape", via.WithWindow())
|
||||
|
||||
// Prevent browser default behavior
|
||||
submit.OnKeyDown("ArrowDown", via.WithPreventDefault())
|
||||
```
|
||||
|
||||
### Multi-key dispatch
|
||||
|
||||
`OnKeyDownMap` binds multiple keys to different actions in a single attribute:
|
||||
|
||||
```go
|
||||
via.OnKeyDownMap(
|
||||
via.KeyBind("w", move, via.WithSignal(dir, "up")),
|
||||
via.KeyBind("s", move, via.WithSignal(dir, "down")),
|
||||
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
|
||||
)
|
||||
```
|
||||
|
||||
This produces a single `data-on:keydown__window` attribute. Place it on any element in the view.
|
||||
|
||||
### Action options
|
||||
|
||||
```go
|
||||
// Per-action rate limiting (overrides the context-level default)
|
||||
c.Action(handler, via.WithRateLimit(5, 10))
|
||||
|
||||
// Per-action middleware (runs after CSRF and rate-limit checks)
|
||||
c.Action(handler, via.WithMiddleware(requireAdmin))
|
||||
```
|
||||
|
||||
## Views and Sync
|
||||
|
||||
Every page handler must call `c.View()` to define the UI:
|
||||
|
||||
```go
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.P(h.Textf("Hello, %s!", name.String())),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
> **Gotcha:** If you forget `c.View()`, the app panics at startup during route registration — not at request time.
|
||||
|
||||
The view function is re-evaluated on every `c.Sync()`. The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM.
|
||||
|
||||
### Sync variants
|
||||
|
||||
| Method | What it sends |
|
||||
|--------|---------------|
|
||||
| `c.Sync()` | Re-renders the view HTML **and** pushes changed signals |
|
||||
| `c.SyncSignals()` | Pushes only changed signals, no view re-render |
|
||||
| `c.SyncElements(elem...)` | Pushes specific HTML elements to merge into the DOM. Each element **must have an ID** matching an existing DOM element |
|
||||
| `c.ExecScript(js)` | Sends JavaScript for the browser to execute (auto-removed after execution) |
|
||||
|
||||
Use `SyncSignals()` when only signal values changed and the view structure is the same. Use `SyncElements()` for targeted updates without re-rendering the entire view. Use `ExecScript()` to interact with client-side libraries (e.g. pushing data to a chart).
|
||||
|
||||
## Components
|
||||
|
||||
Extract reusable UI with `c.Component()`:
|
||||
|
||||
```go
|
||||
func counterFn(c *via.Context) {
|
||||
count := 0
|
||||
step := c.Signal(1)
|
||||
|
||||
increment := c.Action(func() {
|
||||
count += step.Int()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.P(h.Textf("Count: %d", count)),
|
||||
h.Input(h.Type("number"), step.Bind()),
|
||||
h.Button(h.Text("+"), increment.OnClick()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// In a page:
|
||||
v.Page("/", func(c *via.Context) {
|
||||
counter1 := c.Component(counterFn)
|
||||
counter2 := c.Component(counterFn)
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.H2(h.Text("Counter 1")), counter1(),
|
||||
h.H2(h.Text("Counter 2")), counter2(),
|
||||
)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — `c.Sync()` from a component re-renders the entire page view.
|
||||
|
||||
## Fields and Validation
|
||||
|
||||
Fields are signals with validation rules. Use them for form inputs:
|
||||
|
||||
```go
|
||||
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
|
||||
email := c.Field("", via.Required(), via.Email())
|
||||
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
|
||||
website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://"))
|
||||
```
|
||||
|
||||
### Built-in rules
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| `Required(msg...)` | Rejects empty/whitespace-only values |
|
||||
| `MinLen(n, msg...)` | Minimum character count (Unicode-aware) |
|
||||
| `MaxLen(n, msg...)` | Maximum character count (Unicode-aware) |
|
||||
| `Min(n, msg...)` | Minimum numeric value (parsed as int) |
|
||||
| `Max(n, msg...)` | Maximum numeric value (parsed as int) |
|
||||
| `Email(msg...)` | Email format regex |
|
||||
| `Pattern(re, msg...)` | Custom regex |
|
||||
| `Custom(fn)` | `func(string) error` — return non-nil to fail |
|
||||
|
||||
All rules accept an optional custom error message as the last argument.
|
||||
|
||||
### Using fields in views and actions
|
||||
|
||||
```go
|
||||
submit := c.Action(func() {
|
||||
if !c.ValidateAll() {
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
// Server-side validation
|
||||
if userExists(username.String()) {
|
||||
username.AddError("Username taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
createUser(username.String(), email.String())
|
||||
c.ResetFields()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Form(submit.OnSubmit(),
|
||||
h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")),
|
||||
h.If(username.HasError(), h.Small(h.Text(username.FirstError()))),
|
||||
|
||||
h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")),
|
||||
h.If(email.HasError(), h.Small(h.Text(email.FirstError()))),
|
||||
|
||||
h.Button(h.Type("submit"), h.Text("Sign Up")),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `field.Validate()` | Run rules, return true if all pass |
|
||||
| `field.HasError()` | True if any validation errors exist |
|
||||
| `field.FirstError()` | First error message, or `""` |
|
||||
| `field.Errors()` | All error messages |
|
||||
| `field.AddError(msg)` | Add a custom server-side error |
|
||||
| `field.ClearErrors()` | Remove all errors |
|
||||
| `field.Reset()` | Restore initial value and clear errors |
|
||||
| `c.ValidateAll(fields...)` | Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated |
|
||||
| `c.ResetFields(fields...)` | Reset given fields (or all if none specified) |
|
||||
|
||||
Fields embed `*signal`, so `Bind()`, `Text()`, `String()`, `Int()`, `Bool()`, `SetValue()`, and `ID()` all work.
|
||||
|
||||
## OnInterval
|
||||
|
||||
Run a function at regular intervals, tied to the page lifecycle:
|
||||
|
||||
```go
|
||||
stop := c.OnInterval(time.Second, func() {
|
||||
now = time.Now()
|
||||
c.Sync()
|
||||
})
|
||||
```
|
||||
|
||||
- Starts immediately — no separate start call needed.
|
||||
- Returns a `func()` that stops the interval (idempotent).
|
||||
- Automatically stops on context disposal (tab close) or SPA navigation away.
|
||||
- Call `c.Sync()` inside the handler to push updates to the browser.
|
||||
|
||||
## Navigation Helpers
|
||||
|
||||
| Method | Effect |
|
||||
|--------|--------|
|
||||
| `c.Redirect(url)` | Full page navigation. Disposes the context, browser loads a new page |
|
||||
| `c.Redirectf(fmt, args...)` | `Redirect` with `fmt.Sprintf` |
|
||||
| `c.RedirectView(url)` | Sets the view to trigger a redirect on SSE connect. Use in [middleware](routing-and-navigation.md#middleware) to abort the chain and redirect |
|
||||
| `c.ReplaceURL(url)` | Updates the browser URL bar without navigation. Useful for reflecting state in query params |
|
||||
| `c.ReplaceURLf(fmt, args...)` | `ReplaceURL` with `fmt.Sprintf` |
|
||||
| `c.Navigate(path, popstate)` | [SPA navigation](routing-and-navigation.md#spa-navigation). Resets page state, runs the target page handler on the same context, pushes the new view with a view transition |
|
||||
|
||||
> **Gotcha:** In middleware, use `c.RedirectView()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.
|
||||
58
field.go
Normal file
58
field.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package via
|
||||
|
||||
// Field is a signal with built-in validation rules and error state.
|
||||
// It embeds *Signal, so all signal methods (Bind, String, Int, Bool, SetValue, Text, ID)
|
||||
// work transparently.
|
||||
type Field struct {
|
||||
*Signal
|
||||
rules []Rule
|
||||
errors []string
|
||||
initialVal any
|
||||
}
|
||||
|
||||
// Validate runs all rules against the current value.
|
||||
// Clears previous errors, populates new ones, returns true if all rules pass.
|
||||
func (f *Field) Validate() bool {
|
||||
f.errors = nil
|
||||
val := f.String()
|
||||
for _, r := range f.rules {
|
||||
if err := r.validate(val); err != nil {
|
||||
f.errors = append(f.errors, err.Error())
|
||||
}
|
||||
}
|
||||
return len(f.errors) == 0
|
||||
}
|
||||
|
||||
// HasError returns true if this field has any validation errors.
|
||||
func (f *Field) HasError() bool {
|
||||
return len(f.errors) > 0
|
||||
}
|
||||
|
||||
// FirstError returns the first validation error message, or "" if valid.
|
||||
func (f *Field) FirstError() string {
|
||||
if len(f.errors) > 0 {
|
||||
return f.errors[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Errors returns all current validation error messages.
|
||||
func (f *Field) Errors() []string {
|
||||
return f.errors
|
||||
}
|
||||
|
||||
// AddError manually adds an error message (useful for server-side or cross-field validation).
|
||||
func (f *Field) AddError(msg string) {
|
||||
f.errors = append(f.errors, msg)
|
||||
}
|
||||
|
||||
// ClearErrors removes all validation errors from this field.
|
||||
func (f *Field) ClearErrors() {
|
||||
f.errors = nil
|
||||
}
|
||||
|
||||
// Reset restores the field value to its initial value and clears all errors.
|
||||
func (f *Field) Reset() {
|
||||
f.SetValue(f.initialVal)
|
||||
f.errors = nil
|
||||
}
|
||||
206
field_test.go
Normal file
206
field_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newTestField(initial any, rules ...Rule) *Field {
|
||||
v := New()
|
||||
var f *Field
|
||||
v.Page("/", func(c *Context) {
|
||||
f = c.Field(initial, rules...)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestFieldCreation(t *testing.T) {
|
||||
f := newTestField("hello", Required())
|
||||
assert.Equal(t, "hello", f.String())
|
||||
assert.NotEmpty(t, f.ID())
|
||||
}
|
||||
|
||||
func TestFieldSignalDelegation(t *testing.T) {
|
||||
f := newTestField(42)
|
||||
assert.Equal(t, "42", f.String())
|
||||
assert.Equal(t, 42, f.Int())
|
||||
|
||||
f.SetValue("new")
|
||||
assert.Equal(t, "new", f.String())
|
||||
|
||||
// Bind returns an h.H element
|
||||
assert.NotNil(t, f.Bind())
|
||||
}
|
||||
|
||||
func TestFieldValidateSingleRule(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
assert.False(t, f.Validate())
|
||||
assert.True(t, f.HasError())
|
||||
assert.Equal(t, "This field is required", f.FirstError())
|
||||
|
||||
f.SetValue("ok")
|
||||
assert.True(t, f.Validate())
|
||||
assert.False(t, f.HasError())
|
||||
assert.Equal(t, "", f.FirstError())
|
||||
}
|
||||
|
||||
func TestFieldValidateMultipleRules(t *testing.T) {
|
||||
f := newTestField("ab", Required(), MinLen(3))
|
||||
assert.False(t, f.Validate())
|
||||
errs := f.Errors()
|
||||
assert.Len(t, errs, 1)
|
||||
assert.Equal(t, "Must be at least 3 characters", errs[0])
|
||||
|
||||
f.SetValue("")
|
||||
assert.False(t, f.Validate())
|
||||
errs = f.Errors()
|
||||
assert.Len(t, errs, 2)
|
||||
}
|
||||
|
||||
func TestFieldErrors(t *testing.T) {
|
||||
f := newTestField("")
|
||||
assert.Nil(t, f.Errors())
|
||||
assert.False(t, f.HasError())
|
||||
assert.Equal(t, "", f.FirstError())
|
||||
}
|
||||
|
||||
func TestFieldAddError(t *testing.T) {
|
||||
f := newTestField("ok")
|
||||
f.AddError("username taken")
|
||||
assert.True(t, f.HasError())
|
||||
assert.Equal(t, "username taken", f.FirstError())
|
||||
assert.Len(t, f.Errors(), 1)
|
||||
}
|
||||
|
||||
func TestFieldClearErrors(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
f.Validate()
|
||||
assert.True(t, f.HasError())
|
||||
f.ClearErrors()
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestFieldReset(t *testing.T) {
|
||||
f := newTestField("initial", Required(), MinLen(3))
|
||||
f.SetValue("changed")
|
||||
f.AddError("some error")
|
||||
|
||||
f.Reset()
|
||||
assert.Equal(t, "initial", f.String())
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestValidateAll(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
c.Field("", Required(), MinLen(3))
|
||||
c.Field("", Required(), Email())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
// both empty → both fail
|
||||
assert.False(t, c.ValidateAll())
|
||||
})
|
||||
|
||||
v2 := New()
|
||||
v2.Page("/", func(c *Context) {
|
||||
c.Field("joe", Required(), MinLen(3))
|
||||
c.Field("joe@x.com", Required(), Email())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
assert.True(t, c.ValidateAll())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAllPartialFailure(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
good := c.Field("valid", Required())
|
||||
bad := c.Field("", Required())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
ok := c.ValidateAll()
|
||||
assert.False(t, ok)
|
||||
assert.False(t, good.HasError())
|
||||
assert.True(t, bad.HasError())
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateAllSelectiveArgs(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
a := c.Field("", Required())
|
||||
b := c.Field("ok", Required())
|
||||
cField := c.Field("", Required())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
// only validate a and b — cField should be untouched
|
||||
ok := c.ValidateAll(a, b)
|
||||
assert.False(t, ok)
|
||||
assert.True(t, a.HasError())
|
||||
assert.False(t, b.HasError())
|
||||
assert.False(t, cField.HasError(), "unselected field should not be validated")
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetFields(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
a := c.Field("a", Required())
|
||||
b := c.Field("b", Required())
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
a.SetValue("changed-a")
|
||||
b.SetValue("changed-b")
|
||||
a.AddError("err")
|
||||
|
||||
c.ResetFields()
|
||||
assert.Equal(t, "a", a.String())
|
||||
assert.Equal(t, "b", b.String())
|
||||
assert.False(t, a.HasError())
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetFieldsSelectiveArgs(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
a := c.Field("a")
|
||||
b := c.Field("b")
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
a.SetValue("changed-a")
|
||||
b.SetValue("changed-b")
|
||||
|
||||
// only reset a
|
||||
c.ResetFields(a)
|
||||
assert.Equal(t, "a", a.String())
|
||||
assert.Equal(t, "changed-b", b.String(), "unselected field should not be reset")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFieldValidateClearsPreviousErrors(t *testing.T) {
|
||||
f := newTestField("", Required())
|
||||
f.Validate()
|
||||
assert.True(t, f.HasError())
|
||||
|
||||
f.SetValue("ok")
|
||||
f.Validate()
|
||||
assert.False(t, f.HasError())
|
||||
}
|
||||
|
||||
func TestFieldCustomValidator(t *testing.T) {
|
||||
f := newTestField("bad", Custom(func(val string) error {
|
||||
if val == "bad" {
|
||||
return fmt.Errorf("no bad words")
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
assert.False(t, f.Validate())
|
||||
assert.Equal(t, "no bad words", f.FirstError())
|
||||
|
||||
f.SetValue("good")
|
||||
assert.True(t, f.Validate())
|
||||
}
|
||||
26
go.mod
26
go.mod
@@ -8,17 +8,35 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/delaneyj/toolbelt v0.9.1
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/nats-io/nats.go v1.48.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/starfederation/datastar-go v1.0.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/google/go-tpm v0.9.7 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.0 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
66
go.sum
66
go.sum
@@ -9,23 +9,64 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o=
|
||||
github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
|
||||
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
|
||||
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
|
||||
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
|
||||
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
|
||||
github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
|
||||
github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE=
|
||||
github.com/nats-io/nats-server/v2 v2.12.2/go.mod h1:j1AAttYeu7WnvD8HLJ+WWKNMSyxsqmZ160pNtCQRMyE=
|
||||
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/starfederation/datastar-go v1.0.3 h1:DnzgsJ6tDHDM6y5Nxsk0AGW/m8SyKch2vQg3P1xGTcU=
|
||||
github.com/starfederation/datastar-go v1.0.3/go.mod h1:stm83LQkhZkwa5GzzdPEN6dLuu8FVwxIv0w1DYkbD3w=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -34,8 +75,8 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
@@ -43,8 +84,19 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g
|
||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -11,3 +11,11 @@ func DataEffect(expression string) H {
|
||||
func DataIgnoreMorph() H {
|
||||
return Attr("data-ignore-morph")
|
||||
}
|
||||
|
||||
// DataViewTransition sets the view-transition-name CSS property on an element
|
||||
// via an inline style. Elements with matching names animate between pages
|
||||
// during SPA navigation. If the element also needs other inline styles,
|
||||
// include view-transition-name directly in the Style() call instead.
|
||||
func DataViewTransition(name string) H {
|
||||
return Attr("style", "view-transition-name: "+name)
|
||||
}
|
||||
|
||||
2
h/h.go
2
h/h.go
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// h.Div(
|
||||
// h.H1(h.Text("Hello, Via")),
|
||||
// h.P(h.Text("Pure Go. No tmplates.")),
|
||||
// h.P(h.Text("Pure Go. No templates.")),
|
||||
// )
|
||||
package h
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func main() {
|
||||
v.Config(via.Options{
|
||||
DevMode: true,
|
||||
DocumentTitle: "ViaChat",
|
||||
LogLvl: via.LogLevelInfo,
|
||||
LogLevel: via.LogLevelInfo,
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
|
||||
52
internal/examples/effectspike/main.go
Normal file
52
internal/examples/effectspike/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Spike to validate that Datastar's data-effect re-evaluates when signals are
|
||||
// updated via PatchSignals from the server, and that Via's hex signal IDs work
|
||||
// in $signalID expression syntax.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "data-effect Spike",
|
||||
ServerAddress: ":7332",
|
||||
DevMode: true,
|
||||
})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
x := c.Signal(0)
|
||||
y := c.Signal(0)
|
||||
|
||||
c.OnInterval(time.Second, func() {
|
||||
x.SetValue(rand.Intn(500))
|
||||
y.SetValue(rand.Intn(500))
|
||||
c.SyncSignals()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.Attr("style", "padding:1rem;font-family:sans-serif"),
|
||||
h.H1(h.Text("data-effect Spike")),
|
||||
h.P(h.Text("x: "), x.Text(), h.Text(" y: "), y.Text()),
|
||||
h.Div(
|
||||
h.ID("box"),
|
||||
h.Attr("style", "width:20px;height:20px;background:red;position:absolute"),
|
||||
h.DataEffect(fmt.Sprintf(
|
||||
"document.getElementById('box').style.left=$%s+'px';"+
|
||||
"document.getElementById('box').style.top=$%s+'px'",
|
||||
x.ID(), y.ID(),
|
||||
)),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
74
internal/examples/keyboard/main.go
Normal file
74
internal/examples/keyboard/main.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
const gridSize = 8
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{DocumentTitle: "Keyboard", ServerAddress: ":7331"})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
x, y := 0, 0
|
||||
dir := c.Signal("")
|
||||
|
||||
move := c.Action(func() {
|
||||
switch dir.String() {
|
||||
case "up":
|
||||
y = max(0, y-1)
|
||||
case "down":
|
||||
y = min(gridSize-1, y+1)
|
||||
case "left":
|
||||
x = max(0, x-1)
|
||||
case "right":
|
||||
x = min(gridSize-1, x+1)
|
||||
}
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
var rows []h.H
|
||||
for row := range gridSize {
|
||||
var cells []h.H
|
||||
for col := range gridSize {
|
||||
bg := "#e0e0e0"
|
||||
if col == x && row == y {
|
||||
bg = "#4a90d9"
|
||||
}
|
||||
cells = append(cells, h.Div(
|
||||
h.Attr("style", fmt.Sprintf(
|
||||
"width:48px;height:48px;background:%s;border:1px solid #ccc;",
|
||||
bg,
|
||||
)),
|
||||
))
|
||||
}
|
||||
rows = append(rows, h.Div(
|
||||
append([]h.H{h.Attr("style", "display:flex;")}, cells...)...,
|
||||
))
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
h.H1(h.Text("Keyboard Grid")),
|
||||
h.P(h.Text("Move with WASD or arrow keys")),
|
||||
h.Div(rows...),
|
||||
via.OnKeyDownMap(
|
||||
via.KeyBind("w", move, via.WithSignal(dir, "up")),
|
||||
via.KeyBind("a", move, via.WithSignal(dir, "left")),
|
||||
via.KeyBind("s", move, via.WithSignal(dir, "down")),
|
||||
via.KeyBind("d", move, via.WithSignal(dir, "right")),
|
||||
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowLeft", move, via.WithSignal(dir, "left"), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
|
||||
via.KeyBind("ArrowRight", move, via.WithSignal(dir, "right"), via.WithPreventDefault()),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
// "github.com/go-via/via-plugin-picocss/picocss"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
@@ -14,10 +13,7 @@ func main() {
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "Live Reload Demo",
|
||||
DevMode: true,
|
||||
LogLvl: via.LogLevelDebug,
|
||||
Plugins: []via.Plugin{
|
||||
// picocss.Default
|
||||
},
|
||||
LogLevel: via.LogLevelDebug,
|
||||
})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
|
||||
154
internal/examples/maplibre/main.go
Normal file
154
internal/examples/maplibre/main.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/ryanhamamura/via/maplibre"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "MapLibre GL Example",
|
||||
ServerAddress: ":7331",
|
||||
DevMode: true,
|
||||
Plugins: []via.Plugin{maplibre.Plugin},
|
||||
})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
m := maplibre.New(c, maplibre.Options{
|
||||
Style: "https://demotiles.maplibre.org/style.json",
|
||||
Center: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||
Zoom: 10,
|
||||
Height: "500px",
|
||||
})
|
||||
|
||||
m.AddControl("nav", maplibre.NavigationControl{})
|
||||
m.AddControl("scale", maplibre.ScaleControl{Unit: "metric"})
|
||||
|
||||
// Static markers with popups
|
||||
m.AddMarker("sf", maplibre.Marker{
|
||||
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||
Color: "#e74c3c",
|
||||
Popup: &maplibre.Popup{
|
||||
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
|
||||
},
|
||||
})
|
||||
m.AddMarker("oak", maplibre.Marker{
|
||||
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||
Color: "#2ecc71",
|
||||
Popup: &maplibre.Popup{
|
||||
Content: "<strong>Oakland</strong>",
|
||||
},
|
||||
})
|
||||
|
||||
// Signal-backed marker — server pushes position updates
|
||||
vehicleLng := c.Signal(-122.43)
|
||||
vehicleLat := c.Signal(37.77)
|
||||
|
||||
m.AddMarker("vehicle", maplibre.Marker{
|
||||
LngSignal: vehicleLng,
|
||||
LatSignal: vehicleLat,
|
||||
Color: "#9b59b6",
|
||||
})
|
||||
|
||||
c.OnInterval(time.Second, func() {
|
||||
vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02)
|
||||
vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02)
|
||||
c.SyncSignals()
|
||||
})
|
||||
|
||||
// Draggable marker — user drags, signals update
|
||||
pinLng := c.Signal(-122.41)
|
||||
pinLat := c.Signal(37.78)
|
||||
|
||||
m.AddMarker("pin", maplibre.Marker{
|
||||
LngSignal: pinLng,
|
||||
LatSignal: pinLat,
|
||||
Color: "#3498db",
|
||||
Draggable: true,
|
||||
})
|
||||
|
||||
// Click event — click to place a marker
|
||||
click := m.OnClick()
|
||||
handleClick := c.Action(func() {
|
||||
e := click.Data()
|
||||
m.AddMarker("clicked", maplibre.Marker{
|
||||
LngLat: e.LngLat,
|
||||
Color: "#f39c12",
|
||||
})
|
||||
})
|
||||
|
||||
// GeoJSON polygon source + fill layer
|
||||
m.AddSource("park", maplibre.GeoJSONSource{
|
||||
Data: map[string]any{
|
||||
"type": "Feature",
|
||||
"geometry": map[string]any{
|
||||
"type": "Polygon",
|
||||
"coordinates": []any{[]any{
|
||||
[]float64{-122.4547, 37.7654},
|
||||
[]float64{-122.4547, 37.7754},
|
||||
[]float64{-122.4387, 37.7754},
|
||||
[]float64{-122.4387, 37.7654},
|
||||
[]float64{-122.4547, 37.7654},
|
||||
}},
|
||||
},
|
||||
"properties": map[string]any{
|
||||
"name": "Golden Gate Park",
|
||||
},
|
||||
},
|
||||
})
|
||||
m.AddLayer(maplibre.Layer{
|
||||
ID: "park-fill",
|
||||
Type: "fill",
|
||||
Source: "park",
|
||||
Paint: map[string]any{
|
||||
"fill-color": "#2ecc71",
|
||||
"fill-opacity": 0.3,
|
||||
},
|
||||
})
|
||||
|
||||
// FlyTo actions using CameraOptions
|
||||
zoom14 := 14.0
|
||||
flyToSF := c.Action(func() {
|
||||
m.FlyTo(maplibre.CameraOptions{
|
||||
Center: &maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
|
||||
Zoom: &zoom14,
|
||||
})
|
||||
})
|
||||
|
||||
flyToOak := c.Action(func() {
|
||||
m.FlyTo(maplibre.CameraOptions{
|
||||
Center: &maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
|
||||
Zoom: &zoom14,
|
||||
})
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.Div(
|
||||
h.Attr("style", "max-width:960px;margin:0 auto;padding:1rem;font-family:sans-serif"),
|
||||
h.H1(h.Text("MapLibre GL Example")),
|
||||
m.Element(
|
||||
click.Input(handleClick.OnInput()),
|
||||
),
|
||||
h.Div(h.Attr("style", "margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap"),
|
||||
h.Button(h.Text("Fly to San Francisco"), flyToSF.OnClick()),
|
||||
h.Button(h.Text("Fly to Oakland"), flyToOak.OnClick()),
|
||||
),
|
||||
h.Div(h.Attr("style", "margin-top:0.5rem;font-size:0.9rem"),
|
||||
h.P(h.Text("Zoom: "), m.Zoom.Text()),
|
||||
h.P(h.Text("Center: "), m.CenterLng.Text(), h.Text(", "), m.CenterLat.Text()),
|
||||
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
|
||||
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
151
internal/examples/middleware/main.go
Normal file
151
internal/examples/middleware/main.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
ServerAddress: ":8080",
|
||||
DocumentTitle: "Middleware Example",
|
||||
})
|
||||
|
||||
// --- Middleware definitions ---
|
||||
|
||||
// requestLogger logs every page request to stdout.
|
||||
requestLogger := func(c *via.Context, next func()) {
|
||||
fmt.Printf("[%s] request\n", time.Now().Format("15:04:05"))
|
||||
next()
|
||||
}
|
||||
|
||||
// authRequired redirects unauthenticated users to /login.
|
||||
authRequired := func(c *via.Context, next func()) {
|
||||
if c.Session().GetString("role") == "" {
|
||||
c.RedirectView("/login")
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// auditLog prints the authenticated username to stdout.
|
||||
auditLog := func(c *via.Context, next func()) {
|
||||
fmt.Printf("[audit] user=%s\n", c.Session().GetString("username"))
|
||||
next()
|
||||
}
|
||||
|
||||
// superAdminOnly rejects non-superadmin users with a forbidden view.
|
||||
superAdminOnly := func(c *via.Context, next func()) {
|
||||
if c.Session().GetString("role") != "superadmin" {
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.H1(h.Text("Forbidden")),
|
||||
h.P(h.Text("Super-admin access required.")),
|
||||
h.A(h.Href("/admin/dashboard"), h.Text("Back to dashboard")),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// --- Route registration ---
|
||||
|
||||
v.Use(requestLogger) // global middleware
|
||||
|
||||
admin := v.Group("/admin", authRequired) // prefixed group
|
||||
admin.Use(auditLog) // Group.Use()
|
||||
superAdmin := admin.Group("/super", superAdminOnly) // nested group
|
||||
|
||||
// Public: redirect root to login
|
||||
v.Page("/", func(c *via.Context) {
|
||||
c.View(func() h.H {
|
||||
c.Redirect("/login")
|
||||
return h.Div()
|
||||
})
|
||||
})
|
||||
|
||||
// Public: login page with role-selection buttons
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
loginAdmin := c.Action(func() {
|
||||
c.Session().Set("role", "admin")
|
||||
c.Session().Set("username", "alice")
|
||||
c.Session().RenewToken()
|
||||
c.Redirect("/admin/dashboard")
|
||||
})
|
||||
|
||||
loginSuper := c.Action(func() {
|
||||
c.Session().Set("role", "superadmin")
|
||||
c.Session().Set("username", "bob")
|
||||
c.Session().RenewToken()
|
||||
c.Redirect("/admin/dashboard")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.H1(h.Text("Login")),
|
||||
h.P(h.Text("Choose a role:")),
|
||||
h.Button(h.Text("Login as Admin"), loginAdmin.OnClick()),
|
||||
h.Raw(" "),
|
||||
h.Button(h.Text("Login as Super Admin"), loginSuper.OnClick()),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Per-action middleware: only superadmins can invoke this action.
|
||||
requireSuperAdmin := func(c *via.Context, next func()) {
|
||||
if c.Session().GetString("role") != "superadmin" {
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Admin: dashboard (requires authRequired + auditLog)
|
||||
admin.Page("/dashboard", func(c *via.Context) {
|
||||
logout := c.Action(func() {
|
||||
c.Session().Delete("role")
|
||||
c.Session().Delete("username")
|
||||
c.Redirect("/login")
|
||||
})
|
||||
|
||||
dangerAction := c.Action(func() {
|
||||
fmt.Printf("[danger] executed by %s\n", c.Session().GetString("username"))
|
||||
c.Sync()
|
||||
}, via.WithMiddleware(requireSuperAdmin))
|
||||
|
||||
c.View(func() h.H {
|
||||
username := c.Session().GetString("username")
|
||||
role := c.Session().GetString("role")
|
||||
return h.Div(
|
||||
h.H1(h.Textf("Dashboard — %s (%s)", username, role)),
|
||||
h.Ul(
|
||||
h.Li(h.A(h.Href("/admin/super/settings"), h.Text("Super Admin Settings"))),
|
||||
),
|
||||
h.H2(h.Text("Danger Zone")),
|
||||
h.P(h.Text("This action is protected by per-action middleware (superadmin only):")),
|
||||
h.Button(h.Text("Delete Everything"), dangerAction.OnClick()),
|
||||
h.Br(),
|
||||
h.Br(),
|
||||
h.Button(h.Text("Logout"), logout.OnClick()),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Super-admin: settings (requires authRequired + auditLog + superAdminOnly)
|
||||
superAdmin.Page("/settings", func(c *via.Context) {
|
||||
c.View(func() h.H {
|
||||
username := c.Session().GetString("username")
|
||||
return h.Div(
|
||||
h.H1(h.Textf("Super Admin Settings — %s", username)),
|
||||
h.P(h.Text("Only super-admins can see this page.")),
|
||||
h.A(h.Href("/admin/dashboard"), h.Text("Back to dashboard")),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
79
internal/examples/nats-chatroom/README.md
Normal file
79
internal/examples/nats-chatroom/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# NATS Chatroom Example (Embedded)
|
||||
|
||||
A chatroom built with Via and an **embedded NATS server**, demonstrating pub/sub messaging as an alternative to the custom `Rooms` implementation in `../chatroom`.
|
||||
|
||||
Via includes an embedded NATS server that starts automatically — no external server required.
|
||||
|
||||
## Key Differences from Original Chatroom
|
||||
|
||||
| Aspect | Original (`../chatroom`) | This Example |
|
||||
|--------|-------------------------|--------------|
|
||||
| Pub/sub | Custom `Rooms` struct (~160 lines) | NATS subjects |
|
||||
| Member tracking | Manual `map[TU]Syncable` | NATS handles subscribers |
|
||||
| Publish timing | Ticker every 100ms + dirty flag | Instant delivery |
|
||||
| Durability | None (in-memory) | JetStream persists to disk |
|
||||
| Multi-instance | Not supported | Works across server instances |
|
||||
| External deps | None | **None** (NATS embedded in binary) |
|
||||
|
||||
## Run the Example
|
||||
|
||||
```bash
|
||||
go run ./internal/examples/nats-chatroom
|
||||
```
|
||||
|
||||
That's it. No separate NATS server needed.
|
||||
|
||||
Open multiple browser tabs at http://localhost:7331 to see messages broadcast across all clients.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Single Binary │
|
||||
│ │
|
||||
│ Browser A Embedded NATS Browser B │
|
||||
│ │ │ │ │
|
||||
│ │-- Via Action ---> │ │ │
|
||||
│ │ (Send msg) │ │ │
|
||||
│ │ │ │ │
|
||||
│ │ nc.Publish() │ │
|
||||
│ │ "chat.room.Go" │ │
|
||||
│ │ │ │ │
|
||||
│ │<-- Subscribe -----|---- Subscribe --->│ │
|
||||
│ │ callback │ callback │ │
|
||||
│ │ │ │ │
|
||||
│ │-- c.Sync() ------>│<--- c.Sync() -----| │
|
||||
│ │ (SSE) │ (SSE) │ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## JetStream Durability
|
||||
|
||||
Messages persist to disk via JetStream. Streams are declared in `Options.Streams` and created automatically when `v.Start()` initializes the embedded NATS server:
|
||||
|
||||
```go
|
||||
v.Config(via.Options{
|
||||
Streams: []via.StreamConfig{{
|
||||
Name: "CHAT",
|
||||
Subjects: []string{"chat.>"},
|
||||
MaxMsgs: 1000,
|
||||
MaxAge: 24 * time.Hour,
|
||||
}},
|
||||
})
|
||||
```
|
||||
|
||||
Stop and restart the app - chat history survives.
|
||||
|
||||
## Code Comparison
|
||||
|
||||
**Original chatroom - 160+ lines of custom pub/sub:**
|
||||
- `Rooms` struct with named rooms
|
||||
- `Room` with member tracking, mutex, dirty flag
|
||||
- Ticker-based publish loop
|
||||
- Manual join/leave channels
|
||||
|
||||
**This example - ~60 lines of NATS integration:**
|
||||
- `via.Subscribe(c, subject, handler)` for receiving
|
||||
- `via.Publish(c, subject, data)` for sending
|
||||
- Streams declared in `Options` — NATS handles delivery, no polling
|
||||
153
internal/examples/nats-chatroom/chat.css
Normal file
153
internal/examples/nats-chatroom/chat.css
Normal file
@@ -0,0 +1,153 @@
|
||||
body { margin: 0; }
|
||||
|
||||
/* Layout navbar */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
.app-nav .brand {
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
margin-right: auto;
|
||||
}
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav-links a {
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Chat page */
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 53px);
|
||||
}
|
||||
nav[role="tab-control"] ul li a[aria-current="page"] {
|
||||
background-color: var(--pico-primary-background);
|
||||
color: var(--pico-primary-inverse);
|
||||
border-bottom: 2px solid var(--pico-primary);
|
||||
}
|
||||
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--pico-muted-border-color);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-lg {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.bubble { flex: 1; }
|
||||
.bubble p { margin: 0; }
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.chat-input {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--pico-background-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
.chat-input fieldset {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* NATS badge with status dot */
|
||||
.nats-badge {
|
||||
background: #27AAE1;
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Profile page */
|
||||
.profile-page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--pico-card-background-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.preview-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.profile-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.emoji-option {
|
||||
padding: 0.375rem;
|
||||
font-size: 1.25rem;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.emoji-option:hover {
|
||||
background: var(--pico-muted-border-color);
|
||||
}
|
||||
.emoji-selected {
|
||||
border-color: var(--pico-primary);
|
||||
background: var(--pico-primary-focus);
|
||||
}
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.field-error {
|
||||
color: var(--pico-del-color);
|
||||
}
|
||||
148
internal/examples/nats-chatroom/chat.go
Normal file
148
internal/examples/nats-chatroom/chat.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
var (
|
||||
WithSignal = via.WithSignal
|
||||
)
|
||||
|
||||
func ChatPage(c *via.Context) {
|
||||
currentUser := UserInfo{
|
||||
Name: c.Session().GetString(SessionKeyUsername),
|
||||
Emoji: c.Session().GetString(SessionKeyEmoji),
|
||||
}
|
||||
|
||||
roomSignal := c.Signal("Go")
|
||||
statement := c.Signal("")
|
||||
|
||||
var messages []ChatMessage
|
||||
var messagesMu sync.Mutex
|
||||
currentRoom := "Go"
|
||||
|
||||
var currentSub via.Subscription
|
||||
|
||||
subscribeToRoom := func(room string) {
|
||||
if currentSub != nil {
|
||||
currentSub.Unsubscribe()
|
||||
}
|
||||
|
||||
subject := "chat.room." + room
|
||||
|
||||
if hist, err := via.ReplayHistory[ChatMessage](v, subject, 50); err == nil {
|
||||
messages = hist
|
||||
}
|
||||
|
||||
sub, _ := via.Subscribe(c, subject, func(msg ChatMessage) {
|
||||
messagesMu.Lock()
|
||||
messages = append(messages, msg)
|
||||
if len(messages) > 50 {
|
||||
messages = messages[len(messages)-50:]
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
c.Sync()
|
||||
})
|
||||
currentSub = sub
|
||||
currentRoom = room
|
||||
}
|
||||
|
||||
subscribeToRoom("Go")
|
||||
|
||||
// Heartbeat — keeps connected indicator alive
|
||||
connected := true
|
||||
c.OnInterval(30*time.Second, func() {
|
||||
connected = true
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
switchRoom := c.Action(func() {
|
||||
newRoom := roomSignal.String()
|
||||
if newRoom != currentRoom {
|
||||
messagesMu.Lock()
|
||||
messages = nil
|
||||
messagesMu.Unlock()
|
||||
subscribeToRoom(newRoom)
|
||||
c.Sync()
|
||||
}
|
||||
})
|
||||
|
||||
say := c.Action(func() {
|
||||
msg := statement.String()
|
||||
if msg == "" {
|
||||
msg = randomDevQuote()
|
||||
}
|
||||
statement.SetValue("")
|
||||
|
||||
via.Publish(c, "chat.room."+currentRoom, ChatMessage{
|
||||
User: currentUser,
|
||||
Message: msg,
|
||||
Time: time.Now().UnixMilli(),
|
||||
})
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
var tabs []h.H
|
||||
for _, name := range roomNames {
|
||||
isCurrent := name == currentRoom
|
||||
tabs = append(tabs, h.Li(
|
||||
h.A(
|
||||
h.If(isCurrent, h.Attr("aria-current", "page")),
|
||||
h.Text(name),
|
||||
switchRoom.OnClick(WithSignal(roomSignal, name)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
messagesMu.Lock()
|
||||
chatHistoryChildren := []h.H{
|
||||
h.Class("chat-history"),
|
||||
h.Script(h.Raw(`new MutationObserver(()=>scrollChatToBottom()).observe(document.querySelector('.chat-history'), {childList:true})`)),
|
||||
}
|
||||
for _, msg := range messages {
|
||||
chatHistoryChildren = append(chatHistoryChildren,
|
||||
h.Div(h.Class("chat-message"),
|
||||
h.Div(h.Class("avatar"), h.Attr("title", msg.User.Name), h.Text(msg.User.Emoji)),
|
||||
h.Div(h.Class("bubble"),
|
||||
h.P(h.Text(msg.Message)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
|
||||
_ = connected
|
||||
|
||||
return h.Div(h.Class("chat-page"),
|
||||
h.Nav(
|
||||
h.Attr("role", "tab-control"),
|
||||
h.Ul(tabs...),
|
||||
h.Span(h.Class("nats-badge"),
|
||||
h.Span(h.Class("status-dot")),
|
||||
h.Text("NATS"),
|
||||
),
|
||||
),
|
||||
h.Div(chatHistoryChildren...),
|
||||
h.Div(
|
||||
h.Class("chat-input"),
|
||||
h.DataIgnoreMorph(),
|
||||
currentUser.Avatar(),
|
||||
h.FieldSet(
|
||||
h.Attr("role", "group"),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Placeholder(currentUser.Name+" says..."),
|
||||
statement.Bind(),
|
||||
h.Attr("autofocus"),
|
||||
say.OnKeyDown("Enter"),
|
||||
),
|
||||
h.Button(h.Text("Send"), say.OnClick()),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
77
internal/examples/nats-chatroom/main.go
Normal file
77
internal/examples/nats-chatroom/main.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
//go:embed chat.css
|
||||
var chatCSS string
|
||||
|
||||
var v *via.V
|
||||
|
||||
func main() {
|
||||
v = via.New()
|
||||
v.Config(via.Options{
|
||||
DevMode: true,
|
||||
DocumentTitle: "NATS Chat",
|
||||
LogLevel: via.LogLevelInfo,
|
||||
ServerAddress: ":7331",
|
||||
Streams: []via.StreamConfig{{
|
||||
Name: "CHAT",
|
||||
Subjects: []string{"chat.>"},
|
||||
MaxMsgs: 1000,
|
||||
MaxAge: 24 * time.Hour,
|
||||
}},
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||
h.StyleEl(h.Raw(chatCSS)),
|
||||
h.Script(h.Raw(`
|
||||
function scrollChatToBottom() {
|
||||
const chatHistory = document.querySelector('.chat-history');
|
||||
if (chatHistory) chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
`)),
|
||||
)
|
||||
|
||||
v.Layout(func(content func() h.H) h.H {
|
||||
return h.Div(
|
||||
h.Nav(h.Class("app-nav"),
|
||||
h.A(h.Href("/"), h.Class("brand"), h.Text("NATS Chat")),
|
||||
h.Div(h.Class("nav-links"),
|
||||
h.A(h.Href("/"), h.Text("Chat")),
|
||||
h.A(h.Href("/profile"), h.Text("Profile")),
|
||||
),
|
||||
),
|
||||
h.Main(h.Class("container"),
|
||||
h.DataViewTransition("page-content"),
|
||||
content(),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
// Profile page — public, no auth required
|
||||
v.Page("/profile", ProfilePage)
|
||||
|
||||
// Auth middleware — redirects to profile if no identity set
|
||||
requireProfile := func(c *via.Context, next func()) {
|
||||
if c.Session().GetString(SessionKeyUsername) == "" {
|
||||
c.RedirectView("/profile")
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Chat page — protected by profile middleware
|
||||
protected := v.Group("", requireProfile)
|
||||
protected.Page("/", ChatPage)
|
||||
|
||||
log.Println("Starting NATS chatroom on :7331")
|
||||
v.Start()
|
||||
}
|
||||
111
internal/examples/nats-chatroom/profile.go
Normal file
111
internal/examples/nats-chatroom/profile.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func ProfilePage(c *via.Context) {
|
||||
existingName := c.Session().GetString(SessionKeyUsername)
|
||||
existingEmoji := c.Session().GetString(SessionKeyEmoji)
|
||||
if existingEmoji == "" {
|
||||
existingEmoji = emojiChoices[0]
|
||||
}
|
||||
|
||||
nameField := c.Field(existingName,
|
||||
via.Required("Display name is required"),
|
||||
via.MinLen(2, "Must be at least 2 characters"),
|
||||
via.MaxLen(20, "Must be at most 20 characters"),
|
||||
)
|
||||
selectedEmoji := c.Signal(existingEmoji)
|
||||
previewName := c.Computed(func() string {
|
||||
if name := nameField.String(); name != "" {
|
||||
return name
|
||||
}
|
||||
return "Your Name"
|
||||
})
|
||||
|
||||
saveToSession := func() bool {
|
||||
if !c.ValidateAll() {
|
||||
c.Sync()
|
||||
return false
|
||||
}
|
||||
c.Session().Set(SessionKeyUsername, nameField.String())
|
||||
c.Session().Set(SessionKeyEmoji, selectedEmoji.String())
|
||||
return true
|
||||
}
|
||||
|
||||
save := c.Action(func() {
|
||||
saveToSession()
|
||||
})
|
||||
|
||||
saveAndChat := c.Action(func() {
|
||||
if saveToSession() {
|
||||
c.Navigate("/", false)
|
||||
}
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
// Emoji grid
|
||||
emojiGrid := []h.H{h.Class("emoji-grid")}
|
||||
for _, emoji := range emojiChoices {
|
||||
cls := "emoji-option"
|
||||
if emoji == selectedEmoji.String() {
|
||||
cls += " emoji-selected"
|
||||
}
|
||||
emojiGrid = append(emojiGrid,
|
||||
h.Button(
|
||||
h.Class(cls),
|
||||
h.Type("button"),
|
||||
h.Text(emoji),
|
||||
save.OnClick(WithSignal(selectedEmoji, emoji)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Action buttons — "Start Chatting" only if editing is meaningful
|
||||
actionButtons := []h.H{h.Class("profile-actions")}
|
||||
if existingName != "" {
|
||||
actionButtons = append(actionButtons,
|
||||
h.Button(h.Text("Save"), save.OnClick(), h.Class("secondary")),
|
||||
)
|
||||
}
|
||||
actionButtons = append(actionButtons,
|
||||
h.Button(h.Text("Start Chatting"), saveAndChat.OnClick()),
|
||||
)
|
||||
|
||||
return h.Div(h.Class("profile-page"),
|
||||
h.H2(h.Text("Your Profile"), h.DataViewTransition("page-title")),
|
||||
|
||||
// Live preview
|
||||
h.Div(h.Class("profile-preview"),
|
||||
h.Div(h.Class("avatar avatar-lg"), h.Text(selectedEmoji.String())),
|
||||
h.Span(h.Class("preview-name"), previewName.Text()),
|
||||
),
|
||||
|
||||
h.Div(h.Class("profile-form"),
|
||||
// Name field
|
||||
h.Label(h.Text("Display Name"),
|
||||
h.Input(
|
||||
h.Type("text"),
|
||||
h.Placeholder("Enter a display name"),
|
||||
nameField.Bind(),
|
||||
h.Attr("autofocus"),
|
||||
saveAndChat.OnKeyDown("Enter"),
|
||||
h.If(nameField.HasError(), h.Attr("aria-invalid", "true")),
|
||||
),
|
||||
h.If(nameField.HasError(),
|
||||
h.Small(h.Class("field-error"), h.Text(nameField.FirstError())),
|
||||
),
|
||||
),
|
||||
|
||||
// Emoji picker
|
||||
h.Label(h.Text("Choose an Avatar")),
|
||||
h.Div(emojiGrid...),
|
||||
|
||||
// Actions
|
||||
h.Div(actionButtons...),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
30
internal/examples/nats-chatroom/types.go
Normal file
30
internal/examples/nats-chatroom/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import "github.com/ryanhamamura/via/h"
|
||||
|
||||
const (
|
||||
SessionKeyUsername = "username"
|
||||
SessionKeyEmoji = "emoji"
|
||||
)
|
||||
|
||||
type ChatMessage struct {
|
||||
User UserInfo `json:"user"`
|
||||
Message string `json:"message"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
Emoji string `json:"emoji"`
|
||||
}
|
||||
|
||||
func (u *UserInfo) Avatar() h.H {
|
||||
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.Emoji))
|
||||
}
|
||||
|
||||
var roomNames = []string{"Go", "Rust", "Python", "JavaScript", "Clojure"}
|
||||
|
||||
var emojiChoices = []string{
|
||||
"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦦", "🦁", "🐸",
|
||||
"🦄", "🐙", "🦀", "🐝", "🦋", "🐢", "🦉", "🐳", "🦈", "🐧",
|
||||
}
|
||||
22
internal/examples/nats-chatroom/userdata.go
Normal file
22
internal/examples/nats-chatroom/userdata.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "math/rand"
|
||||
|
||||
var quoteIdx = rand.Intn(len(devQuotes))
|
||||
var devQuotes = []string{
|
||||
"Just use NATS.",
|
||||
"Pub/sub all the things!",
|
||||
"Messages are the new API.",
|
||||
"JetStream for durability.",
|
||||
"No more polling.",
|
||||
"Event-driven architecture FTW.",
|
||||
"Decouple everything.",
|
||||
"NATS is fast.",
|
||||
"Subjects are like topics.",
|
||||
"Request-reply is cool.",
|
||||
}
|
||||
|
||||
func randomDevQuote() string {
|
||||
quoteIdx = (quoteIdx + 1) % len(devQuotes)
|
||||
return devQuotes[quoteIdx]
|
||||
}
|
||||
@@ -4,17 +4,12 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
// "github.com/go-via/via-plugin-picocss/picocss"
|
||||
. "github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
|
||||
v.Config(via.Options{
|
||||
// Plugins: []via.Plugin{picocss.Default},
|
||||
})
|
||||
|
||||
v.Page("/counters/{counter_id}/{start_at_step}", func(c *via.Context) {
|
||||
|
||||
counterID := c.GetPathParam("counter_id")
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
// "github.com/go-via/via-plugin-picocss/picocss"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
@@ -13,11 +12,6 @@ func main() {
|
||||
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "Via Counter",
|
||||
// Plugin is placed here. Use picocss.WithOptions(pococss.Options) to add the plugin
|
||||
// with a different color theme or to enable a classes for a wide range of colors.
|
||||
// Plugins: []via.Plugin{
|
||||
// picocss.Default,
|
||||
// },
|
||||
})
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
|
||||
270
internal/examples/pubsub-crud/main.go
Normal file
270
internal/examples/pubsub-crud/main.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
var WithSignal = via.WithSignal
|
||||
|
||||
type Bookmark struct {
|
||||
ID string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
type CRUDEvent struct {
|
||||
Action string `json:"action"`
|
||||
Title string `json:"title"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
var (
|
||||
bookmarks []Bookmark
|
||||
bookmarksMu sync.RWMutex
|
||||
)
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func findBookmark(id string) (Bookmark, int) {
|
||||
for i, bm := range bookmarks {
|
||||
if bm.ID == id {
|
||||
return bm, i
|
||||
}
|
||||
}
|
||||
return Bookmark{}, -1
|
||||
}
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DevMode: true,
|
||||
DocumentTitle: "Bookmarks",
|
||||
LogLevel: via.LogLevelInfo,
|
||||
ServerAddress: ":7331",
|
||||
Streams: []via.StreamConfig{{
|
||||
Name: "BOOKMARKS",
|
||||
Subjects: []string{"bookmarks.>"},
|
||||
MaxMsgs: 1000,
|
||||
MaxAge: 24 * time.Hour,
|
||||
}},
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/daisyui@4/dist/full.min.css")),
|
||||
h.Script(h.Src("https://cdn.tailwindcss.com")),
|
||||
)
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := randomHex(8)
|
||||
|
||||
titleSignal := c.Signal("")
|
||||
urlSignal := c.Signal("")
|
||||
targetIDSignal := c.Signal("")
|
||||
saveLabel := c.Computed(func() string {
|
||||
if targetIDSignal.String() != "" {
|
||||
return "Update Bookmark"
|
||||
}
|
||||
return "Add Bookmark"
|
||||
})
|
||||
|
||||
via.Subscribe(c, "bookmarks.events", func(evt CRUDEvent) {
|
||||
if evt.UserID == userID {
|
||||
return
|
||||
}
|
||||
safeTitle := html.EscapeString(evt.Title)
|
||||
var alertClass string
|
||||
switch evt.Action {
|
||||
case "created":
|
||||
alertClass = "alert-success"
|
||||
case "updated":
|
||||
alertClass = "alert-info"
|
||||
case "deleted":
|
||||
alertClass = "alert-error"
|
||||
}
|
||||
c.ExecScript(fmt.Sprintf(`(function(){
|
||||
var tc = document.getElementById('toast-container');
|
||||
if (!tc) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = 'alert %s';
|
||||
d.innerHTML = '<span>Bookmark "%s" %s</span>';
|
||||
tc.appendChild(d);
|
||||
setTimeout(function(){ d.remove(); }, 3000);
|
||||
})()`, alertClass, safeTitle, evt.Action))
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
save := c.Action(func() {
|
||||
title := titleSignal.String()
|
||||
url := urlSignal.String()
|
||||
if title == "" || url == "" {
|
||||
return
|
||||
}
|
||||
|
||||
targetID := targetIDSignal.String()
|
||||
action := "created"
|
||||
|
||||
bookmarksMu.Lock()
|
||||
if targetID != "" {
|
||||
if _, idx := findBookmark(targetID); idx >= 0 {
|
||||
bookmarks[idx].Title = title
|
||||
bookmarks[idx].URL = url
|
||||
action = "updated"
|
||||
}
|
||||
} else {
|
||||
bookmarks = append(bookmarks, Bookmark{
|
||||
ID: randomHex(8),
|
||||
Title: title,
|
||||
URL: url,
|
||||
})
|
||||
}
|
||||
bookmarksMu.Unlock()
|
||||
|
||||
titleSignal.SetValue("")
|
||||
urlSignal.SetValue("")
|
||||
targetIDSignal.SetValue("")
|
||||
|
||||
via.Publish(c, "bookmarks.events", CRUDEvent{
|
||||
Action: action,
|
||||
Title: title,
|
||||
UserID: userID,
|
||||
})
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
edit := c.Action(func() {
|
||||
id := targetIDSignal.String()
|
||||
bookmarksMu.RLock()
|
||||
bm, idx := findBookmark(id)
|
||||
bookmarksMu.RUnlock()
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
titleSignal.SetValue(bm.Title)
|
||||
urlSignal.SetValue(bm.URL)
|
||||
})
|
||||
|
||||
del := c.Action(func() {
|
||||
id := targetIDSignal.String()
|
||||
bookmarksMu.Lock()
|
||||
bm, idx := findBookmark(id)
|
||||
if idx >= 0 {
|
||||
bookmarks = append(bookmarks[:idx], bookmarks[idx+1:]...)
|
||||
}
|
||||
bookmarksMu.Unlock()
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
targetIDSignal.SetValue("")
|
||||
|
||||
via.Publish(c, "bookmarks.events", CRUDEvent{
|
||||
Action: "deleted",
|
||||
Title: bm.Title,
|
||||
UserID: userID,
|
||||
})
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
cancelEdit := c.Action(func() {
|
||||
titleSignal.SetValue("")
|
||||
urlSignal.SetValue("")
|
||||
targetIDSignal.SetValue("")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
isEditing := targetIDSignal.String() != ""
|
||||
|
||||
// Build table rows
|
||||
bookmarksMu.RLock()
|
||||
var rows []h.H
|
||||
for _, bm := range bookmarks {
|
||||
rows = append(rows, h.Tr(
|
||||
h.Td(h.Text(bm.Title)),
|
||||
h.Td(h.A(h.Href(bm.URL), h.Attr("target", "_blank"), h.Class("link link-primary"), h.Text(bm.URL))),
|
||||
h.Td(
|
||||
h.Div(h.Class("flex gap-1"),
|
||||
h.Button(h.Class("btn btn-xs btn-ghost"), h.Text("Edit"),
|
||||
edit.OnClick(WithSignal(targetIDSignal, bm.ID)),
|
||||
),
|
||||
h.Button(h.Class("btn btn-xs btn-ghost text-error"), h.Text("Delete"),
|
||||
del.OnClick(WithSignal(targetIDSignal, bm.ID)),
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
||||
bookmarksMu.RUnlock()
|
||||
|
||||
return h.Div(h.Class("min-h-screen bg-base-200"),
|
||||
// Navbar
|
||||
h.Div(h.Class("navbar bg-base-100 shadow-sm"),
|
||||
h.Div(h.Class("flex-1"),
|
||||
h.A(h.Class("btn btn-ghost text-xl"), h.Text("Bookmarks")),
|
||||
),
|
||||
h.Div(h.Class("flex-none"),
|
||||
h.Div(h.Class("badge badge-outline"), h.Text(userID[:8])),
|
||||
),
|
||||
),
|
||||
|
||||
h.Div(h.Class("container mx-auto p-4 max-w-3xl flex flex-col gap-4"),
|
||||
// Form card
|
||||
h.Div(h.Class("card bg-base-100 shadow"),
|
||||
h.Div(h.Class("card-body"),
|
||||
h.H2(h.Class("card-title"), saveLabel.Text()),
|
||||
h.Div(h.Class("flex flex-col gap-2"),
|
||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("Title"), titleSignal.Bind()),
|
||||
h.Input(h.Class("input input-bordered w-full"), h.Type("text"), h.Placeholder("https://example.com"), urlSignal.Bind()),
|
||||
h.Div(h.Class("card-actions justify-end"),
|
||||
h.If(isEditing,
|
||||
h.Button(h.Class("btn btn-ghost"), h.Text("Cancel"), cancelEdit.OnClick()),
|
||||
),
|
||||
h.Button(h.Class("btn btn-primary"), saveLabel.Text(), save.OnClick()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Table card
|
||||
h.Div(h.Class("card bg-base-100 shadow"),
|
||||
h.Div(h.Class("card-body"),
|
||||
h.H2(h.Class("card-title"), h.Text("All Bookmarks")),
|
||||
h.If(len(rows) == 0,
|
||||
h.P(h.Class("text-base-content/60"), h.Text("No bookmarks yet. Add one above!")),
|
||||
),
|
||||
h.If(len(rows) > 0,
|
||||
h.Div(h.Class("overflow-x-auto"),
|
||||
h.Table(h.Class("table"),
|
||||
h.THead(h.Tr(
|
||||
h.Th(h.Text("Title")),
|
||||
h.Th(h.Text("URL")),
|
||||
h.Th(h.Text("Actions")),
|
||||
)),
|
||||
h.TBody(rows...),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Toast container — ignored by morph so Sync() doesn't wipe active toasts
|
||||
h.Div(h.ID("toast-container"), h.Class("toast toast-end toast-top"), h.DataIgnoreMorph()),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
log.Println("Starting pubsub-crud example on :7331")
|
||||
v.Start()
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
// "github.com/go-via/via-plugin-picocss/picocss"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
@@ -14,11 +13,8 @@ func main() {
|
||||
v := via.New()
|
||||
|
||||
v.Config(via.Options{
|
||||
LogLvl: via.LogLevelDebug,
|
||||
LogLevel: via.LogLevelDebug,
|
||||
DevMode: true,
|
||||
Plugins: []via.Plugin{
|
||||
// picocss.Default,
|
||||
},
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
@@ -37,7 +33,9 @@ func main() {
|
||||
return 1000 / time.Duration(refreshRate.Int()) * time.Millisecond
|
||||
}
|
||||
|
||||
updateData := c.OnInterval(computedTickDuration(), func() {
|
||||
var stopUpdate func()
|
||||
startInterval := func() {
|
||||
stopUpdate = c.OnInterval(computedTickDuration(), func() {
|
||||
ts := time.Now().UnixMilli()
|
||||
val := rand.ExpFloat64() * 10
|
||||
|
||||
@@ -48,18 +46,20 @@ func main() {
|
||||
};
|
||||
`, ts, val))
|
||||
})
|
||||
updateData.Start()
|
||||
}
|
||||
startInterval()
|
||||
|
||||
updateRefreshRate := c.Action(func() {
|
||||
updateData.UpdateInterval(computedTickDuration())
|
||||
stopUpdate()
|
||||
startInterval()
|
||||
})
|
||||
|
||||
toggleIsLive := c.Action(func() {
|
||||
isLive = isLiveSig.Bool()
|
||||
if isLive {
|
||||
updateData.Start()
|
||||
startInterval()
|
||||
} else {
|
||||
updateData.Stop()
|
||||
stopUpdate()
|
||||
}
|
||||
})
|
||||
c.View(func() h.H {
|
||||
|
||||
@@ -29,7 +29,17 @@ func main() {
|
||||
SessionManager: sm,
|
||||
})
|
||||
|
||||
// Login page
|
||||
// Auth middleware — redirects unauthenticated users to /login
|
||||
authRequired := func(c *via.Context, next func()) {
|
||||
if c.Session().GetString("username") == "" {
|
||||
c.Session().Set("flash", "Please log in first")
|
||||
c.RedirectView("/login")
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Login page (public)
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
flash := c.Session().PopString("flash")
|
||||
usernameInput := c.Signal("")
|
||||
@@ -64,8 +74,10 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Dashboard page (protected)
|
||||
v.Page("/dashboard", func(c *via.Context) {
|
||||
// Protected pages
|
||||
protected := v.Group("", authRequired)
|
||||
|
||||
protected.Page("/dashboard", func(c *via.Context) {
|
||||
logout := c.Action(func() {
|
||||
c.Session().Set("flash", "Goodbye!")
|
||||
c.Session().Delete("username")
|
||||
@@ -74,14 +86,6 @@ func main() {
|
||||
|
||||
c.View(func() h.H {
|
||||
username := c.Session().GetString("username")
|
||||
|
||||
// Not logged in? Redirect to login
|
||||
if username == "" {
|
||||
c.Session().Set("flash", "Please log in first")
|
||||
c.Redirect("/login")
|
||||
return h.Div()
|
||||
}
|
||||
|
||||
flash := c.Session().PopString("flash")
|
||||
var flashMsg h.H
|
||||
if flash != "" {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DataSource interface {
|
||||
@@ -22,6 +22,9 @@ type ShakeDB struct {
|
||||
findByTextStmt *sql.Stmt
|
||||
}
|
||||
|
||||
// Prepare opens shake.db, a ~22 MB SQLite database of Shakespeare's works.
|
||||
// Download from https://github.com/nicholasgasior/gopher-fizzbuzz/raw/master/shake.db
|
||||
// and place it in this directory before running.
|
||||
func (shakeDB *ShakeDB) Prepare() {
|
||||
db, err := sql.Open("sqlite3", "shake.db")
|
||||
if err != nil {
|
||||
@@ -54,7 +57,7 @@ func main() {
|
||||
v.Config(via.Options{
|
||||
DevMode: true,
|
||||
DocumentTitle: "Search",
|
||||
LogLvl: via.LogLevelWarn,
|
||||
LogLevel: via.LogLevelWarn,
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
|
||||
Binary file not shown.
87
internal/examples/signup/main.go
Normal file
87
internal/examples/signup/main.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "Signup",
|
||||
ServerAddress: ":8080",
|
||||
})
|
||||
|
||||
v.AppendToHead(h.StyleEl(h.Raw(`
|
||||
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; }
|
||||
label { display: block; font-weight: 600; margin-top: 1rem; }
|
||||
input { display: block; width: 100%; padding: 0.4rem; margin-top: 0.25rem; box-sizing: border-box; }
|
||||
.error { color: #c00; font-size: 0.85rem; margin-top: 0.2rem; }
|
||||
.success { color: #080; margin-top: 1rem; }
|
||||
.actions { margin-top: 1.5rem; display: flex; gap: 0.5rem; }
|
||||
`)))
|
||||
|
||||
v.Page("/", func(c *via.Context) {
|
||||
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
|
||||
email := c.Field("", via.Required(), via.Email())
|
||||
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
|
||||
// Optional field — only validated when non-empty
|
||||
website := c.Field("", via.Pattern(`^$|^https?://\S+$`, "Must be a valid URL"))
|
||||
|
||||
var success string
|
||||
|
||||
signup := c.Action(func() {
|
||||
success = ""
|
||||
if !c.ValidateAll() {
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
// Server-side check
|
||||
if username.String() == "admin" {
|
||||
username.AddError("Username is already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
success = "Account created for " + username.String() + "!"
|
||||
c.ResetFields()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
reset := c.Action(func() {
|
||||
success = ""
|
||||
c.ResetFields()
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
h.H1(h.Text("Sign Up")),
|
||||
|
||||
h.Label(h.Text("Username")),
|
||||
h.Input(h.Type("text"), h.Placeholder("pick a username"), username.Bind()),
|
||||
h.If(username.HasError(), h.Div(h.Class("error"), h.Text(username.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Email")),
|
||||
h.Input(h.Type("email"), h.Placeholder("you@example.com"), email.Bind()),
|
||||
h.If(email.HasError(), h.Div(h.Class("error"), h.Text(email.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Age")),
|
||||
h.Input(h.Type("number"), h.Placeholder("your age"), age.Bind()),
|
||||
h.If(age.HasError(), h.Div(h.Class("error"), h.Text(age.FirstError()))),
|
||||
|
||||
h.Label(h.Text("Website (optional)")),
|
||||
h.Input(h.Type("url"), h.Placeholder("https://example.com"), website.Bind()),
|
||||
h.If(website.HasError(), h.Div(h.Class("error"), h.Text(website.FirstError()))),
|
||||
|
||||
h.Div(h.Class("actions"),
|
||||
h.Button(h.Text("Sign Up"), signup.OnClick()),
|
||||
h.Button(h.Text("Reset"), reset.OnClick()),
|
||||
),
|
||||
|
||||
h.If(success != "", h.P(h.Class("success"), h.Text(success))),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
v.Start()
|
||||
}
|
||||
91
internal/examples/spa/main.go
Normal file
91
internal/examples/spa/main.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
. "github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
DocumentTitle: "SPA Navigation",
|
||||
ServerAddress: ":7331",
|
||||
})
|
||||
|
||||
v.AppendToHead(
|
||||
Raw(`<link rel="preconnect" href="https://fonts.googleapis.com">`),
|
||||
Raw(`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`),
|
||||
Raw(`<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">`),
|
||||
Raw(`<style>body{font-family:'Inter',sans-serif;margin:0;background:#111;color:#eee}</style>`),
|
||||
)
|
||||
|
||||
v.Layout(func(content func() H) H {
|
||||
return Div(
|
||||
Nav(
|
||||
Style("display:flex;gap:1rem;padding:1rem;background:#222;"),
|
||||
A(Href("/"), Text("Home"), Style("color:#fff")),
|
||||
A(Href("/counter"), Text("Counter"), Style("color:#fff")),
|
||||
A(Href("/clock"), Text("Clock"), Style("color:#fff")),
|
||||
A(Href("https://github.com"), Text("GitHub (external)"), Style("color:#888")),
|
||||
A(Href("/"), Text("Full Reload"), Attr("data-via-no-boost"), Style("color:#f88")),
|
||||
),
|
||||
Main(Style("padding:1rem"), content()),
|
||||
)
|
||||
})
|
||||
|
||||
// Home page
|
||||
v.Page("/", func(c *via.Context) {
|
||||
goCounter := c.Action(func() { c.Navigate("/counter", false) })
|
||||
|
||||
c.View(func() H {
|
||||
return Div(
|
||||
H1(Text("Home"), DataViewTransition("page-title")),
|
||||
P(Text("Click the nav links above — no page reload, no white flash.")),
|
||||
P(Text("Or navigate programmatically:")),
|
||||
Button(Text("Go to Counter"), goCounter.OnClick()),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Counter page — demonstrates signals and actions survive within a page,
|
||||
// but reset on navigate away and back.
|
||||
v.Page("/counter", func(c *via.Context) {
|
||||
count := 0
|
||||
increment := c.Action(func() { count++; c.Sync() })
|
||||
goHome := c.Action(func() { c.Navigate("/", false) })
|
||||
|
||||
c.View(func() H {
|
||||
return Div(
|
||||
H1(Text("Counter"), DataViewTransition("page-title")),
|
||||
P(Textf("Count: %d", count)),
|
||||
Button(Text("+1"), increment.OnClick()),
|
||||
Button(Text("Go Home"), goHome.OnClick(), Style("margin-left:0.5rem")),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Clock page — demonstrates OnInterval cleanup on navigate.
|
||||
v.Page("/clock", func(c *via.Context) {
|
||||
now := time.Now().Format("15:04:05")
|
||||
c.OnInterval(time.Second, func() {
|
||||
now = time.Now().Format("15:04:05")
|
||||
c.Sync()
|
||||
})
|
||||
|
||||
c.View(func() H {
|
||||
return Div(
|
||||
H1(Text("Clock"), DataViewTransition("page-title")),
|
||||
P(Text("This page has an OnInterval that ticks every second.")),
|
||||
P(Textf("Current time: %s", now)),
|
||||
P(Text("Navigate away and back — the old interval stops, a new one starts.")),
|
||||
P(Textf("Proof this is a fresh page init: random = %d", time.Now().UnixNano()%1000)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
fmt.Println("SPA example running at http://localhost:7331")
|
||||
v.Start()
|
||||
}
|
||||
418
maplibre/js.go
Normal file
418
maplibre/js.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package maplibre
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// guard wraps JS code so it only runs when the map instance exists.
|
||||
// The body can reference the map as `m`.
|
||||
func guard(mapID, body string) string {
|
||||
return fmt.Sprintf(
|
||||
`(function(){var m=window.__via_maps&&window.__via_maps[%s];if(!m)return;%s})()`,
|
||||
jsonStr(mapID), body,
|
||||
)
|
||||
}
|
||||
|
||||
// jsonStr JSON-encodes a string for safe embedding in JS.
|
||||
func jsonStr(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// jsonVal JSON-encodes an arbitrary value for safe embedding in JS.
|
||||
func jsonVal(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// initScript generates the idempotent map initialization JS.
|
||||
func initScript(m *Map) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`(function(){if(window.__via_maps&&window.__via_maps[%[1]s])return;`,
|
||||
jsonStr(m.id),
|
||||
))
|
||||
|
||||
// Build constructor options object
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`var opts={container:%s,style:%s,center:[%s,%s],zoom:%s`,
|
||||
jsonStr("_vmap_"+m.id),
|
||||
jsonStr(m.opts.Style),
|
||||
formatFloat(m.opts.Center.Lng),
|
||||
formatFloat(m.opts.Center.Lat),
|
||||
formatFloat(m.opts.Zoom),
|
||||
))
|
||||
if m.opts.Bearing != 0 {
|
||||
b.WriteString(fmt.Sprintf(`,bearing:%s`, formatFloat(m.opts.Bearing)))
|
||||
}
|
||||
if m.opts.Pitch != 0 {
|
||||
b.WriteString(fmt.Sprintf(`,pitch:%s`, formatFloat(m.opts.Pitch)))
|
||||
}
|
||||
if m.opts.MinZoom != 0 {
|
||||
b.WriteString(fmt.Sprintf(`,minZoom:%s`, formatFloat(m.opts.MinZoom)))
|
||||
}
|
||||
if m.opts.MaxZoom != 0 {
|
||||
b.WriteString(fmt.Sprintf(`,maxZoom:%s`, formatFloat(m.opts.MaxZoom)))
|
||||
}
|
||||
|
||||
// Interaction toggles
|
||||
writeBoolOpt := func(name string, val *bool) {
|
||||
if val != nil {
|
||||
if *val {
|
||||
b.WriteString(fmt.Sprintf(`,%s:true`, name))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`,%s:false`, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
writeBoolOpt("scrollZoom", m.opts.ScrollZoom)
|
||||
writeBoolOpt("boxZoom", m.opts.BoxZoom)
|
||||
writeBoolOpt("dragRotate", m.opts.DragRotate)
|
||||
writeBoolOpt("dragPan", m.opts.DragPan)
|
||||
writeBoolOpt("keyboard", m.opts.Keyboard)
|
||||
writeBoolOpt("doubleClickZoom", m.opts.DoubleClickZoom)
|
||||
writeBoolOpt("touchZoomRotate", m.opts.TouchZoomRotate)
|
||||
writeBoolOpt("touchPitch", m.opts.TouchPitch)
|
||||
writeBoolOpt("renderWorldCopies", m.opts.RenderWorldCopies)
|
||||
|
||||
if m.opts.MaxBounds != nil {
|
||||
b.WriteString(fmt.Sprintf(`,maxBounds:[[%s,%s],[%s,%s]]`,
|
||||
formatFloat(m.opts.MaxBounds.SW.Lng), formatFloat(m.opts.MaxBounds.SW.Lat),
|
||||
formatFloat(m.opts.MaxBounds.NE.Lng), formatFloat(m.opts.MaxBounds.NE.Lat)))
|
||||
}
|
||||
|
||||
b.WriteString(`};`)
|
||||
|
||||
// Merge Extra options
|
||||
if len(m.opts.Extra) > 0 {
|
||||
extra, _ := json.Marshal(m.opts.Extra)
|
||||
b.WriteString(fmt.Sprintf(`Object.assign(opts,%s);`, string(extra)))
|
||||
}
|
||||
|
||||
b.WriteString(`var map=new maplibregl.Map(opts);`)
|
||||
b.WriteString(`if(!window.__via_maps)window.__via_maps={};`)
|
||||
b.WriteString(fmt.Sprintf(`window.__via_maps[%s]=map;`, jsonStr(m.id)))
|
||||
b.WriteString(`map._via_markers={};map._via_popups={};map._via_controls={};`)
|
||||
|
||||
// Pre-render sources, layers, markers, popups, controls run on 'load'
|
||||
hasLoad := len(m.sources) > 0 || len(m.layers) > 0 || len(m.markers) > 0 || len(m.popups) > 0 || len(m.controls) > 0
|
||||
if hasLoad {
|
||||
b.WriteString(`map.on('load',function(){`)
|
||||
for _, src := range m.sources {
|
||||
b.WriteString(fmt.Sprintf(`map.addSource(%s,%s);`, jsonStr(src.id), src.js))
|
||||
}
|
||||
for _, layer := range m.layers {
|
||||
if layer.Before != "" {
|
||||
b.WriteString(fmt.Sprintf(`map.addLayer(%s,%s);`, layer.toJS(), jsonStr(layer.Before)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`map.addLayer(%s);`, layer.toJS()))
|
||||
}
|
||||
}
|
||||
for _, me := range m.markers {
|
||||
b.WriteString(markerBodyJS(m.id, me.id, me.marker))
|
||||
}
|
||||
for _, pe := range m.popups {
|
||||
b.WriteString(popupBodyJS(pe.id, pe.popup))
|
||||
}
|
||||
for _, ce := range m.controls {
|
||||
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
|
||||
}
|
||||
b.WriteString(`});`)
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
for _, ev := range m.events {
|
||||
b.WriteString(eventListenerJS(m.id, ev))
|
||||
}
|
||||
|
||||
// Sync viewport signals on moveend via hidden inputs
|
||||
b.WriteString(fmt.Sprintf(`map.on('moveend',function(){`+
|
||||
`var c=map.getCenter();`+
|
||||
`var el=document.getElementById(%[1]s);if(!el)return;`+
|
||||
`var inputs=el.querySelectorAll('input[data-bind]');`+
|
||||
`inputs.forEach(function(inp){`+
|
||||
`var sig=inp.getAttribute('data-bind');`+
|
||||
`if(sig===%[2]s)inp.value=c.lng;`+
|
||||
`else if(sig===%[3]s)inp.value=c.lat;`+
|
||||
`else if(sig===%[4]s)inp.value=map.getZoom();`+
|
||||
`else if(sig===%[5]s)inp.value=map.getBearing();`+
|
||||
`else if(sig===%[6]s)inp.value=map.getPitch();`+
|
||||
`else return;`+
|
||||
`inp.dispatchEvent(new Event('input',{bubbles:true}));`+
|
||||
`});`+
|
||||
`});`,
|
||||
jsonStr("_vwrap_"+m.id),
|
||||
jsonStr(m.CenterLng.ID()),
|
||||
jsonStr(m.CenterLat.ID()),
|
||||
jsonStr(m.Zoom.ID()),
|
||||
jsonStr(m.Bearing.ID()),
|
||||
jsonStr(m.Pitch.ID()),
|
||||
))
|
||||
|
||||
// ResizeObserver for auto-resize
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`var ro=new ResizeObserver(function(){map.resize();});`+
|
||||
`ro.observe(document.getElementById(%s));`,
|
||||
jsonStr("_vmap_"+m.id),
|
||||
))
|
||||
|
||||
// MutationObserver to clean up on DOM removal (SPA nav)
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`var container=document.getElementById(%[1]s);`+
|
||||
`if(container){var mo=new MutationObserver(function(){`+
|
||||
`if(!document.contains(container)){`+
|
||||
`mo.disconnect();ro.disconnect();map.remove();`+
|
||||
`delete window.__via_maps[%[2]s];`+
|
||||
`}});`+
|
||||
`mo.observe(document.body,{childList:true,subtree:true});}`,
|
||||
jsonStr("_vmap_"+m.id),
|
||||
jsonStr(m.id),
|
||||
))
|
||||
|
||||
b.WriteString(`})()`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// markerBodyJS generates JS to add a marker, assuming `map` is in scope.
|
||||
func markerBodyJS(mapID, markerID string, mk Marker) string {
|
||||
var b strings.Builder
|
||||
opts := "{"
|
||||
if mk.Color != "" {
|
||||
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
|
||||
}
|
||||
if mk.Draggable {
|
||||
opts += `draggable:true,`
|
||||
}
|
||||
opts += "}"
|
||||
|
||||
// Determine initial position
|
||||
if mk.LngSignal != nil && mk.LatSignal != nil {
|
||||
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
|
||||
opts, mk.LngSignal.String(), mk.LatSignal.String()))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(`var mk=new maplibregl.Marker(%s).setLngLat([%s,%s]);`,
|
||||
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
|
||||
}
|
||||
|
||||
if mk.Popup != nil {
|
||||
b.WriteString(popupConstructorJS(*mk.Popup, "pk"))
|
||||
b.WriteString(`mk.setPopup(pk);`)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(`mk.addTo(map);map._via_markers[%s]=mk;`, jsonStr(markerID)))
|
||||
|
||||
// Dragend → signal writeback
|
||||
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
|
||||
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dragendHandlerJS generates JS that writes marker position back to signal hidden inputs on dragend.
|
||||
func dragendHandlerJS(mapID, markerID string, mk Marker) string {
|
||||
return fmt.Sprintf(
|
||||
`mk.on('dragend',function(){`+
|
||||
`var pos=mk.getLngLat();`+
|
||||
`var el=document.getElementById(%[1]s);if(!el)return;`+
|
||||
`var inputs=el.querySelectorAll('input[data-bind]');`+
|
||||
`inputs.forEach(function(inp){`+
|
||||
`var sig=inp.getAttribute('data-bind');`+
|
||||
`if(sig===%[2]s){inp.value=pos.lng;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||
`if(sig===%[3]s){inp.value=pos.lat;inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||
`});`+
|
||||
`});`,
|
||||
jsonStr("_vwrap_"+mapID),
|
||||
jsonStr(mk.LngSignal.ID()),
|
||||
jsonStr(mk.LatSignal.ID()),
|
||||
)
|
||||
}
|
||||
|
||||
// markerEffectExpr generates a data-effect expression that moves a signal-backed marker
|
||||
// when its signals change.
|
||||
func markerEffectExpr(mapID, markerID string, mk Marker) string {
|
||||
// Read signals before the guard so Datastar tracks them as dependencies
|
||||
// even when the map/marker hasn't loaded yet on first evaluation.
|
||||
return fmt.Sprintf(
|
||||
`var lng=$%s,lat=$%s;`+
|
||||
`var m=window.__via_maps&&window.__via_maps[%s];`+
|
||||
`if(m&&m._via_markers[%s]){`+
|
||||
`m._via_markers[%s].setLngLat([lng,lat])}`,
|
||||
mk.LngSignal.ID(), mk.LatSignal.ID(),
|
||||
jsonStr(mapID), jsonStr(markerID), jsonStr(markerID),
|
||||
)
|
||||
}
|
||||
|
||||
// addMarkerJS generates a self-contained IIFE to add a marker post-render.
|
||||
func addMarkerJS(mapID, markerID string, mk Marker) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||
jsonStr(mapID)))
|
||||
// Remove existing marker with same ID
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`if(map._via_markers[%[1]s]){map._via_markers[%[1]s].remove();delete map._via_markers[%[1]s];}`,
|
||||
jsonStr(markerID)))
|
||||
b.WriteString(markerBodyJS(mapID, markerID, mk))
|
||||
b.WriteString(`})()`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// removeMarkerJS generates JS to remove a marker. Expects `m` in scope (used inside guard).
|
||||
func removeMarkerJS(markerID string) string {
|
||||
return fmt.Sprintf(
|
||||
`if(m._via_markers[%[1]s]){m._via_markers[%[1]s].remove();delete m._via_markers[%[1]s];}`,
|
||||
jsonStr(markerID))
|
||||
}
|
||||
|
||||
// popupBodyJS generates JS to show a popup, assuming `map` is in scope.
|
||||
func popupBodyJS(popupID string, p Popup) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(popupConstructorJS(p, "p"))
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`p.setLngLat([%s,%s]).addTo(map);map._via_popups[%s]=p;`,
|
||||
formatFloat(p.LngLat.Lng), formatFloat(p.LngLat.Lat), jsonStr(popupID)))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// showPopupJS generates a self-contained IIFE to show a popup post-render.
|
||||
func showPopupJS(mapID, popupID string, p Popup) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`(function(){var map=window.__via_maps&&window.__via_maps[%s];if(!map)return;`,
|
||||
jsonStr(mapID)))
|
||||
// Close existing popup with same ID
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`if(map._via_popups[%[1]s]){map._via_popups[%[1]s].remove();delete map._via_popups[%[1]s];}`,
|
||||
jsonStr(popupID)))
|
||||
b.WriteString(popupBodyJS(popupID, p))
|
||||
b.WriteString(`})()`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// closePopupJS generates JS to close a popup. Expects `m` in scope (used inside guard).
|
||||
func closePopupJS(popupID string) string {
|
||||
return fmt.Sprintf(
|
||||
`if(m._via_popups[%[1]s]){m._via_popups[%[1]s].remove();delete m._via_popups[%[1]s];}`,
|
||||
jsonStr(popupID))
|
||||
}
|
||||
|
||||
// popupConstructorJS generates JS to create a Popup object stored in varName.
|
||||
func popupConstructorJS(p Popup, varName string) string {
|
||||
opts := "{"
|
||||
if p.HideCloseButton {
|
||||
opts += `closeButton:false,`
|
||||
}
|
||||
if p.MaxWidth != "" {
|
||||
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
|
||||
}
|
||||
opts += "}"
|
||||
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
|
||||
varName, opts, jsonStr(p.Content))
|
||||
}
|
||||
|
||||
// --- Control JS ---
|
||||
|
||||
// controlBodyJS generates JS to add a control, assuming `map` is in scope.
|
||||
func controlBodyJS(controlID string, ctrl Control) string {
|
||||
return fmt.Sprintf(
|
||||
`var ctrl=%s;map.addControl(ctrl,%s);map._via_controls[%s]=ctrl;`,
|
||||
ctrl.controlJS(), jsonStr(ctrl.controlPosition()), jsonStr(controlID))
|
||||
}
|
||||
|
||||
// addControlJS generates a self-contained IIFE to add a control post-render.
|
||||
func addControlJS(mapID, controlID string, ctrl Control) string {
|
||||
return fmt.Sprintf(
|
||||
`(function(){var map=window.__via_maps&&window.__via_maps[%[1]s];if(!map)return;`+
|
||||
`if(map._via_controls[%[2]s]){map.removeControl(map._via_controls[%[2]s]);delete map._via_controls[%[2]s];}`+
|
||||
`var ctrl=%[3]s;map.addControl(ctrl,%[4]s);map._via_controls[%[2]s]=ctrl;`+
|
||||
`})()`,
|
||||
jsonStr(mapID), jsonStr(controlID), ctrl.controlJS(), jsonStr(ctrl.controlPosition()))
|
||||
}
|
||||
|
||||
// removeControlJS generates JS to remove a control. Expects `m` in scope.
|
||||
func removeControlJS(controlID string) string {
|
||||
return fmt.Sprintf(
|
||||
`if(m._via_controls[%[1]s]){m.removeControl(m._via_controls[%[1]s]);delete m._via_controls[%[1]s];}`,
|
||||
jsonStr(controlID))
|
||||
}
|
||||
|
||||
// --- Event JS ---
|
||||
|
||||
// eventListenerJS generates JS to register a map event listener that writes
|
||||
// event data to a hidden signal input.
|
||||
func eventListenerJS(mapID string, ev eventEntry) string {
|
||||
var handler string
|
||||
if ev.layerID != "" {
|
||||
handler = fmt.Sprintf(
|
||||
`map.on(%[1]s,%[2]s,function(e){`+
|
||||
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y],layerID:%[2]s};`+
|
||||
`if(e.features)d.features=e.features.map(function(f){return JSON.parse(JSON.stringify(f))});`+
|
||||
`var el=document.getElementById(%[3]s);if(!el)return;`+
|
||||
`var inp=el.querySelector('input[data-bind=%[4]s]');`+
|
||||
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||
`});`,
|
||||
jsonStr(ev.event), jsonStr(ev.layerID),
|
||||
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
|
||||
)
|
||||
} else {
|
||||
handler = fmt.Sprintf(
|
||||
`map.on(%[1]s,function(e){`+
|
||||
`var d={lngLat:{Lng:e.lngLat.lng,Lat:e.lngLat.lat},point:[e.point.x,e.point.y]};`+
|
||||
`var el=document.getElementById(%[2]s);if(!el)return;`+
|
||||
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
|
||||
`if(inp){inp.value=JSON.stringify(d);inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
|
||||
`});`,
|
||||
jsonStr(ev.event),
|
||||
jsonStr("_vwrap_"+mapID), jsonStr(ev.signal.ID()),
|
||||
)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// --- Camera options JS ---
|
||||
|
||||
// cameraOptionsJS converts CameraOptions to a JS object literal string.
|
||||
func cameraOptionsJS(opts CameraOptions) string {
|
||||
obj := map[string]any{}
|
||||
if opts.Center != nil {
|
||||
obj["center"] = []float64{opts.Center.Lng, opts.Center.Lat}
|
||||
}
|
||||
if opts.Zoom != nil {
|
||||
obj["zoom"] = *opts.Zoom
|
||||
}
|
||||
if opts.Bearing != nil {
|
||||
obj["bearing"] = *opts.Bearing
|
||||
}
|
||||
if opts.Pitch != nil {
|
||||
obj["pitch"] = *opts.Pitch
|
||||
}
|
||||
if opts.Duration != nil {
|
||||
obj["duration"] = *opts.Duration
|
||||
}
|
||||
if opts.Speed != nil {
|
||||
obj["speed"] = *opts.Speed
|
||||
}
|
||||
if opts.Curve != nil {
|
||||
obj["curve"] = *opts.Curve
|
||||
}
|
||||
if opts.Padding != nil {
|
||||
obj["padding"] = map[string]int{
|
||||
"top": opts.Padding.Top,
|
||||
"bottom": opts.Padding.Bottom,
|
||||
"left": opts.Padding.Left,
|
||||
"right": opts.Padding.Right,
|
||||
}
|
||||
}
|
||||
if opts.Animate != nil {
|
||||
obj["animate"] = *opts.Animate
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func formatFloat(f float64) string {
|
||||
return fmt.Sprintf("%g", f)
|
||||
}
|
||||
1
maplibre/maplibre-gl.css
Normal file
1
maplibre/maplibre-gl.css
Normal file
File diff suppressed because one or more lines are too long
59
maplibre/maplibre-gl.js
Normal file
59
maplibre/maplibre-gl.js
Normal file
File diff suppressed because one or more lines are too long
440
maplibre/maplibre.go
Normal file
440
maplibre/maplibre.go
Normal file
@@ -0,0 +1,440 @@
|
||||
// Package maplibre provides a Go API for MapLibre GL JS maps within Via applications.
|
||||
//
|
||||
// It follows the same ExecScript + DataIgnoreMorph pattern used for other client-side
|
||||
// JS library integrations (e.g. ECharts in the realtimechart example).
|
||||
package maplibre
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
)
|
||||
|
||||
//go:embed maplibre-gl.js
|
||||
var maplibreJS []byte
|
||||
|
||||
//go:embed maplibre-gl.css
|
||||
var maplibreCSS []byte
|
||||
|
||||
// Plugin serves the embedded MapLibre GL JS/CSS and injects them into the document head.
|
||||
func Plugin(v *via.V) {
|
||||
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.js", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
_, _ = w.Write(maplibreJS)
|
||||
})
|
||||
v.HTTPServeMux().HandleFunc("GET /_maplibre/maplibre-gl.css", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
_, _ = w.Write(maplibreCSS)
|
||||
})
|
||||
v.AppendToHead(
|
||||
h.Link(h.Rel("stylesheet"), h.Href("/_maplibre/maplibre-gl.css")),
|
||||
h.Script(h.Src("/_maplibre/maplibre-gl.js")),
|
||||
)
|
||||
}
|
||||
|
||||
// Map represents a MapLibre GL map instance bound to a Via context.
|
||||
type Map struct {
|
||||
// Viewport signals — readable with .Text(), .String(), etc.
|
||||
CenterLng *via.Signal
|
||||
CenterLat *via.Signal
|
||||
Zoom *via.Signal
|
||||
Bearing *via.Signal
|
||||
Pitch *via.Signal
|
||||
|
||||
id string
|
||||
ctx *via.Context
|
||||
opts Options
|
||||
|
||||
sources []sourceEntry
|
||||
layers []Layer
|
||||
markers []markerEntry
|
||||
popups []popupEntry
|
||||
events []eventEntry
|
||||
controls []controlEntry
|
||||
|
||||
rendered bool
|
||||
}
|
||||
|
||||
// New creates a Map bound to the given Via context with the provided options.
|
||||
// It registers viewport signals on the context for browser → server sync.
|
||||
func New(c *via.Context, opts Options) *Map {
|
||||
if opts.Width == "" {
|
||||
opts.Width = "100%"
|
||||
}
|
||||
if opts.Height == "" {
|
||||
opts.Height = "400px"
|
||||
}
|
||||
|
||||
m := &Map{
|
||||
id: genID(),
|
||||
ctx: c,
|
||||
opts: opts,
|
||||
}
|
||||
|
||||
m.CenterLng = c.Signal(opts.Center.Lng)
|
||||
m.CenterLat = c.Signal(opts.Center.Lat)
|
||||
m.Zoom = c.Signal(opts.Zoom)
|
||||
m.Bearing = c.Signal(opts.Bearing)
|
||||
m.Pitch = c.Signal(opts.Pitch)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Element returns the h.H DOM tree for the map. Call this once inside your View function.
|
||||
// After Element() is called, subsequent source/layer/marker/popup operations
|
||||
// use ExecScript instead of accumulating for the init script.
|
||||
//
|
||||
// Extra children are appended inside the wrapper div (useful for event inputs
|
||||
// and data-effect binding elements).
|
||||
func (m *Map) Element(extra ...h.H) h.H {
|
||||
m.rendered = true
|
||||
|
||||
children := []h.H{
|
||||
h.ID("_vwrap_" + m.id),
|
||||
// Map container — morph-ignored so MapLibre's DOM isn't destroyed on Sync()
|
||||
h.Div(
|
||||
h.ID("_vmap_"+m.id),
|
||||
h.DataIgnoreMorph(),
|
||||
h.Attr("style", fmt.Sprintf("width:%s;height:%s", m.opts.Width, m.opts.Height)),
|
||||
),
|
||||
// Hidden inputs for viewport signal binding (outside morph-ignored zone)
|
||||
h.Input(h.Type("hidden"), m.CenterLng.Bind()),
|
||||
h.Input(h.Type("hidden"), m.CenterLat.Bind()),
|
||||
h.Input(h.Type("hidden"), m.Zoom.Bind()),
|
||||
h.Input(h.Type("hidden"), m.Bearing.Bind()),
|
||||
h.Input(h.Type("hidden"), m.Pitch.Bind()),
|
||||
}
|
||||
|
||||
// data-effect elements for signal-backed markers
|
||||
for _, me := range m.markers {
|
||||
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
|
||||
children = append(children, h.Div(
|
||||
h.Attr("style", "display:none"),
|
||||
h.DataEffect(markerEffectExpr(m.id, me.id, me.marker)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden inputs for signal-backed marker position writeback (drag → signal)
|
||||
for _, me := range m.markers {
|
||||
if me.marker.LngSignal != nil && me.marker.LatSignal != nil {
|
||||
children = append(children,
|
||||
h.Input(h.Type("hidden"), me.marker.LngSignal.Bind()),
|
||||
h.Input(h.Type("hidden"), me.marker.LatSignal.Bind()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
children = append(children, extra...)
|
||||
|
||||
// Init script last
|
||||
children = append(children, h.Script(h.Raw(initScript(m))))
|
||||
|
||||
return h.Div(children...)
|
||||
}
|
||||
|
||||
// --- Viewport readers (signal → Go) ---
|
||||
|
||||
// Center returns the current map center from synced signals.
|
||||
func (m *Map) Center() LngLat {
|
||||
return LngLat{
|
||||
Lng: parseFloat(m.CenterLng.String()),
|
||||
Lat: parseFloat(m.CenterLat.String()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Camera methods ---
|
||||
|
||||
// FlyTo animates the map to the target camera state.
|
||||
func (m *Map) FlyTo(opts CameraOptions) {
|
||||
m.exec(fmt.Sprintf(`m.flyTo(%s);`, cameraOptionsJS(opts)))
|
||||
}
|
||||
|
||||
// EaseTo eases the map to the target camera state.
|
||||
func (m *Map) EaseTo(opts CameraOptions) {
|
||||
m.exec(fmt.Sprintf(`m.easeTo(%s);`, cameraOptionsJS(opts)))
|
||||
}
|
||||
|
||||
// JumpTo jumps the map to the target camera state without animation.
|
||||
func (m *Map) JumpTo(opts CameraOptions) {
|
||||
m.exec(fmt.Sprintf(`m.jumpTo(%s);`, cameraOptionsJS(opts)))
|
||||
}
|
||||
|
||||
// FitBounds fits the map to the given bounds with optional camera options.
|
||||
func (m *Map) FitBounds(bounds LngLatBounds, opts ...CameraOptions) {
|
||||
boundsJS := fmt.Sprintf("[[%s,%s],[%s,%s]]",
|
||||
formatFloat(bounds.SW.Lng), formatFloat(bounds.SW.Lat),
|
||||
formatFloat(bounds.NE.Lng), formatFloat(bounds.NE.Lat))
|
||||
if len(opts) > 0 {
|
||||
m.exec(fmt.Sprintf(`m.fitBounds(%s,%s);`, boundsJS, cameraOptionsJS(opts[0])))
|
||||
} else {
|
||||
m.exec(fmt.Sprintf(`m.fitBounds(%s);`, boundsJS))
|
||||
}
|
||||
}
|
||||
|
||||
// Stop aborts any in-progress camera animation.
|
||||
func (m *Map) Stop() {
|
||||
m.exec(`m.stop();`)
|
||||
}
|
||||
|
||||
// SetCenter sets the map center without animation.
|
||||
func (m *Map) SetCenter(ll LngLat) {
|
||||
m.exec(fmt.Sprintf(`m.setCenter([%s,%s]);`,
|
||||
formatFloat(ll.Lng), formatFloat(ll.Lat)))
|
||||
}
|
||||
|
||||
// SetZoom sets the map zoom level without animation.
|
||||
func (m *Map) SetZoom(z float64) {
|
||||
m.exec(fmt.Sprintf(`m.setZoom(%s);`, formatFloat(z)))
|
||||
}
|
||||
|
||||
// SetBearing sets the map bearing without animation.
|
||||
func (m *Map) SetBearing(b float64) {
|
||||
m.exec(fmt.Sprintf(`m.setBearing(%s);`, formatFloat(b)))
|
||||
}
|
||||
|
||||
// SetPitch sets the map pitch without animation.
|
||||
func (m *Map) SetPitch(p float64) {
|
||||
m.exec(fmt.Sprintf(`m.setPitch(%s);`, formatFloat(p)))
|
||||
}
|
||||
|
||||
// SetStyle changes the map's style URL.
|
||||
func (m *Map) SetStyle(url string) {
|
||||
m.exec(fmt.Sprintf(`m.setStyle(%s);`, jsonStr(url)))
|
||||
}
|
||||
|
||||
// --- Source methods ---
|
||||
|
||||
// AddSource adds a source to the map.
|
||||
func (m *Map) AddSource(id string, src Source) {
|
||||
js := src.sourceJS()
|
||||
if !m.rendered {
|
||||
m.sources = append(m.sources, sourceEntry{id: id, js: js})
|
||||
return
|
||||
}
|
||||
m.exec(fmt.Sprintf(`m.addSource(%s,%s);`, jsonStr(id), js))
|
||||
}
|
||||
|
||||
// RemoveSource removes a source from the map.
|
||||
func (m *Map) RemoveSource(id string) {
|
||||
if !m.rendered {
|
||||
for i, s := range m.sources {
|
||||
if s.id == id {
|
||||
m.sources = append(m.sources[:i], m.sources[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.exec(fmt.Sprintf(`m.removeSource(%s);`, jsonStr(id)))
|
||||
}
|
||||
|
||||
// UpdateGeoJSONSource replaces the data of an existing GeoJSON source.
|
||||
func (m *Map) UpdateGeoJSONSource(sourceID string, data any) {
|
||||
m.exec(fmt.Sprintf(`m.getSource(%s).setData(%s);`, jsonStr(sourceID), jsonVal(data)))
|
||||
}
|
||||
|
||||
// --- Layer methods ---
|
||||
|
||||
// AddLayer adds a layer to the map.
|
||||
func (m *Map) AddLayer(layer Layer) {
|
||||
if !m.rendered {
|
||||
m.layers = append(m.layers, layer)
|
||||
return
|
||||
}
|
||||
before := "undefined"
|
||||
if layer.Before != "" {
|
||||
before = jsonStr(layer.Before)
|
||||
}
|
||||
m.exec(fmt.Sprintf(`m.addLayer(%s,%s);`, layer.toJS(), before))
|
||||
}
|
||||
|
||||
// RemoveLayer removes a layer from the map.
|
||||
func (m *Map) RemoveLayer(id string) {
|
||||
if !m.rendered {
|
||||
for i, l := range m.layers {
|
||||
if l.ID == id {
|
||||
m.layers = append(m.layers[:i], m.layers[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.exec(fmt.Sprintf(`m.removeLayer(%s);`, jsonStr(id)))
|
||||
}
|
||||
|
||||
// SetPaintProperty sets a paint property on a layer.
|
||||
func (m *Map) SetPaintProperty(layerID, name string, value any) {
|
||||
m.exec(fmt.Sprintf(`m.setPaintProperty(%s,%s,%s);`,
|
||||
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||
}
|
||||
|
||||
// SetLayoutProperty sets a layout property on a layer.
|
||||
func (m *Map) SetLayoutProperty(layerID, name string, value any) {
|
||||
m.exec(fmt.Sprintf(`m.setLayoutProperty(%s,%s,%s);`,
|
||||
jsonStr(layerID), jsonStr(name), jsonVal(value)))
|
||||
}
|
||||
|
||||
// --- Marker methods ---
|
||||
|
||||
// AddMarker adds or replaces a marker on the map.
|
||||
func (m *Map) AddMarker(id string, marker Marker) {
|
||||
if !m.rendered {
|
||||
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
|
||||
return
|
||||
}
|
||||
js := addMarkerJS(m.id, id, marker)
|
||||
m.ctx.ExecScript(js)
|
||||
}
|
||||
|
||||
// RemoveMarker removes a marker from the map.
|
||||
func (m *Map) RemoveMarker(id string) {
|
||||
if !m.rendered {
|
||||
for i, me := range m.markers {
|
||||
if me.id == id {
|
||||
m.markers = append(m.markers[:i], m.markers[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.exec(removeMarkerJS(id))
|
||||
}
|
||||
|
||||
// --- Popup methods ---
|
||||
|
||||
// ShowPopup shows a standalone popup on the map.
|
||||
func (m *Map) ShowPopup(id string, popup Popup) {
|
||||
if !m.rendered {
|
||||
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
|
||||
return
|
||||
}
|
||||
js := showPopupJS(m.id, id, popup)
|
||||
m.ctx.ExecScript(js)
|
||||
}
|
||||
|
||||
// ClosePopup closes a standalone popup on the map.
|
||||
func (m *Map) ClosePopup(id string) {
|
||||
if !m.rendered {
|
||||
for i, pe := range m.popups {
|
||||
if pe.id == id {
|
||||
m.popups = append(m.popups[:i], m.popups[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.exec(closePopupJS(id))
|
||||
}
|
||||
|
||||
// --- Control methods ---
|
||||
|
||||
// AddControl adds a control to the map.
|
||||
func (m *Map) AddControl(id string, ctrl Control) {
|
||||
if !m.rendered {
|
||||
m.controls = append(m.controls, controlEntry{id: id, ctrl: ctrl})
|
||||
return
|
||||
}
|
||||
m.exec(addControlJS(m.id, id, ctrl))
|
||||
}
|
||||
|
||||
// RemoveControl removes a control from the map.
|
||||
func (m *Map) RemoveControl(id string) {
|
||||
if !m.rendered {
|
||||
for i, ce := range m.controls {
|
||||
if ce.id == id {
|
||||
m.controls = append(m.controls[:i], m.controls[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.exec(removeControlJS(id))
|
||||
}
|
||||
|
||||
// --- Event methods ---
|
||||
|
||||
// OnClick returns a MapEvent that fires on map click.
|
||||
func (m *Map) OnClick() *MapEvent {
|
||||
return m.on("click", "")
|
||||
}
|
||||
|
||||
// OnLayerClick returns a MapEvent that fires on click of a specific layer.
|
||||
func (m *Map) OnLayerClick(layerID string) *MapEvent {
|
||||
return m.on("click", layerID)
|
||||
}
|
||||
|
||||
// OnMouseMove returns a MapEvent that fires on map mouse movement.
|
||||
func (m *Map) OnMouseMove() *MapEvent {
|
||||
return m.on("mousemove", "")
|
||||
}
|
||||
|
||||
// OnContextMenu returns a MapEvent that fires on right-click.
|
||||
func (m *Map) OnContextMenu() *MapEvent {
|
||||
return m.on("contextmenu", "")
|
||||
}
|
||||
|
||||
func (m *Map) on(event, layerID string) *MapEvent {
|
||||
sig := m.ctx.Signal("")
|
||||
ev := &MapEvent{signal: sig}
|
||||
m.events = append(m.events, eventEntry{
|
||||
event: event,
|
||||
layerID: layerID,
|
||||
signal: sig,
|
||||
})
|
||||
return ev
|
||||
}
|
||||
|
||||
// --- Escape hatch ---
|
||||
|
||||
// Exec runs arbitrary JS with the map available as `m`.
|
||||
func (m *Map) Exec(js string) {
|
||||
m.exec(js)
|
||||
}
|
||||
|
||||
// exec sends guarded JS to the browser via ExecScript.
|
||||
func (m *Map) exec(body string) {
|
||||
m.ctx.ExecScript(guard(m.id, body))
|
||||
}
|
||||
|
||||
// --- MapEvent ---
|
||||
|
||||
// MapEvent wraps a signal that receives map event data as JSON.
|
||||
type MapEvent struct {
|
||||
signal *via.Signal
|
||||
}
|
||||
|
||||
// Bind returns the data-bind attribute for this event's signal.
|
||||
func (e *MapEvent) Bind() h.H { return e.signal.Bind() }
|
||||
|
||||
// Data parses the event signal's JSON value into EventData.
|
||||
func (e *MapEvent) Data() EventData {
|
||||
var d EventData
|
||||
json.Unmarshal([]byte(e.signal.String()), &d)
|
||||
return d
|
||||
}
|
||||
|
||||
// Input creates a hidden input wired to this event's signal.
|
||||
// Pass action triggers (e.g. handleClick.OnInput()) as attrs.
|
||||
func (e *MapEvent) Input(attrs ...h.H) h.H {
|
||||
all := append([]h.H{h.Type("hidden"), e.Bind()}, attrs...)
|
||||
return h.Input(all...)
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
|
||||
func genID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
359
maplibre/types.go
Normal file
359
maplibre/types.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package maplibre
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ryanhamamura/via"
|
||||
)
|
||||
|
||||
// LngLat represents a geographic coordinate.
|
||||
type LngLat struct {
|
||||
Lng float64
|
||||
Lat float64
|
||||
}
|
||||
|
||||
// LngLatBounds represents a rectangular geographic area.
|
||||
type LngLatBounds struct {
|
||||
SW LngLat
|
||||
NE LngLat
|
||||
}
|
||||
|
||||
// Padding represents padding in pixels on each side of the map viewport.
|
||||
type Padding struct {
|
||||
Top int
|
||||
Bottom int
|
||||
Left int
|
||||
Right int
|
||||
}
|
||||
|
||||
// Options configures the initial map state.
|
||||
type Options struct {
|
||||
// Style is the map style URL (required).
|
||||
Style string
|
||||
|
||||
Center LngLat
|
||||
Zoom float64
|
||||
Bearing float64
|
||||
Pitch float64
|
||||
MinZoom float64
|
||||
MaxZoom float64
|
||||
|
||||
// CSS dimensions for the map container. Defaults: "100%", "400px".
|
||||
Width string
|
||||
Height string
|
||||
|
||||
// Interaction toggles (nil = MapLibre default)
|
||||
ScrollZoom *bool
|
||||
BoxZoom *bool
|
||||
DragRotate *bool
|
||||
DragPan *bool
|
||||
Keyboard *bool
|
||||
DoubleClickZoom *bool
|
||||
TouchZoomRotate *bool
|
||||
TouchPitch *bool
|
||||
RenderWorldCopies *bool
|
||||
|
||||
MaxBounds *LngLatBounds
|
||||
|
||||
// Extra is merged last into the MapLibre constructor options object,
|
||||
// allowing pass-through of any option not covered above.
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
// --- Source interface ---
|
||||
|
||||
// Source is implemented by map data sources (GeoJSON, vector, raster, etc.).
|
||||
type Source interface {
|
||||
sourceJS() string
|
||||
}
|
||||
|
||||
// GeoJSONSource provides inline GeoJSON data to MapLibre.
|
||||
// Data should be a GeoJSON-marshalable value (struct, map, or json.RawMessage).
|
||||
type GeoJSONSource struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
func (s GeoJSONSource) sourceJS() string {
|
||||
data, _ := json.Marshal(s.Data)
|
||||
return `{"type":"geojson","data":` + string(data) + `}`
|
||||
}
|
||||
|
||||
// VectorSource references a vector tile source.
|
||||
type VectorSource struct {
|
||||
URL string
|
||||
Tiles []string
|
||||
}
|
||||
|
||||
func (s VectorSource) sourceJS() string {
|
||||
obj := map[string]any{"type": "vector"}
|
||||
if s.URL != "" {
|
||||
obj["url"] = s.URL
|
||||
}
|
||||
if len(s.Tiles) > 0 {
|
||||
obj["tiles"] = s.Tiles
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RasterSource references a raster tile source.
|
||||
type RasterSource struct {
|
||||
URL string
|
||||
Tiles []string
|
||||
TileSize int
|
||||
}
|
||||
|
||||
func (s RasterSource) sourceJS() string {
|
||||
obj := map[string]any{"type": "raster"}
|
||||
if s.URL != "" {
|
||||
obj["url"] = s.URL
|
||||
}
|
||||
if len(s.Tiles) > 0 {
|
||||
obj["tiles"] = s.Tiles
|
||||
}
|
||||
if s.TileSize > 0 {
|
||||
obj["tileSize"] = s.TileSize
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RawSource is an escape hatch that passes an arbitrary JSON-marshalable
|
||||
// value directly as a MapLibre source definition.
|
||||
type RawSource struct {
|
||||
Value any
|
||||
}
|
||||
|
||||
func (s RawSource) sourceJS() string {
|
||||
b, _ := json.Marshal(s.Value)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// --- Control interface ---
|
||||
|
||||
// Control is implemented by map controls (navigation, scale, etc.).
|
||||
type Control interface {
|
||||
controlJS() string
|
||||
controlPosition() string
|
||||
}
|
||||
|
||||
// NavigationControl adds zoom and rotation buttons.
|
||||
type NavigationControl struct {
|
||||
Position string // "top-right" (default), "top-left", "bottom-right", "bottom-left"
|
||||
ShowCompass *bool
|
||||
ShowZoom *bool
|
||||
VisualizeRoll *bool
|
||||
VisualizePitch *bool
|
||||
}
|
||||
|
||||
func (c NavigationControl) controlJS() string {
|
||||
opts := map[string]any{}
|
||||
if c.ShowCompass != nil {
|
||||
opts["showCompass"] = *c.ShowCompass
|
||||
}
|
||||
if c.ShowZoom != nil {
|
||||
opts["showZoom"] = *c.ShowZoom
|
||||
}
|
||||
if c.VisualizeRoll != nil {
|
||||
opts["visualizeRoll"] = *c.VisualizeRoll
|
||||
}
|
||||
if c.VisualizePitch != nil {
|
||||
opts["visualizePitch"] = *c.VisualizePitch
|
||||
}
|
||||
b, _ := json.Marshal(opts)
|
||||
return "new maplibregl.NavigationControl(" + string(b) + ")"
|
||||
}
|
||||
|
||||
func (c NavigationControl) controlPosition() string {
|
||||
if c.Position == "" {
|
||||
return "top-right"
|
||||
}
|
||||
return c.Position
|
||||
}
|
||||
|
||||
// ScaleControl displays a scale bar.
|
||||
type ScaleControl struct {
|
||||
Position string // default "bottom-left"
|
||||
MaxWidth int
|
||||
Unit string // "metric", "imperial", "nautical"
|
||||
}
|
||||
|
||||
func (c ScaleControl) controlJS() string {
|
||||
opts := map[string]any{}
|
||||
if c.MaxWidth > 0 {
|
||||
opts["maxWidth"] = c.MaxWidth
|
||||
}
|
||||
if c.Unit != "" {
|
||||
opts["unit"] = c.Unit
|
||||
}
|
||||
b, _ := json.Marshal(opts)
|
||||
return "new maplibregl.ScaleControl(" + string(b) + ")"
|
||||
}
|
||||
|
||||
func (c ScaleControl) controlPosition() string {
|
||||
if c.Position == "" {
|
||||
return "bottom-left"
|
||||
}
|
||||
return c.Position
|
||||
}
|
||||
|
||||
// GeolocateControl adds a button to track the user's location.
|
||||
type GeolocateControl struct {
|
||||
Position string // default "top-right"
|
||||
}
|
||||
|
||||
func (c GeolocateControl) controlJS() string {
|
||||
return "new maplibregl.GeolocateControl()"
|
||||
}
|
||||
|
||||
func (c GeolocateControl) controlPosition() string {
|
||||
if c.Position == "" {
|
||||
return "top-right"
|
||||
}
|
||||
return c.Position
|
||||
}
|
||||
|
||||
// FullscreenControl adds a fullscreen toggle button.
|
||||
type FullscreenControl struct {
|
||||
Position string // default "top-right"
|
||||
}
|
||||
|
||||
func (c FullscreenControl) controlJS() string {
|
||||
return "new maplibregl.FullscreenControl()"
|
||||
}
|
||||
|
||||
func (c FullscreenControl) controlPosition() string {
|
||||
if c.Position == "" {
|
||||
return "top-right"
|
||||
}
|
||||
return c.Position
|
||||
}
|
||||
|
||||
// --- Camera options ---
|
||||
|
||||
// CameraOptions configures animated camera movements (FlyTo, EaseTo, JumpTo).
|
||||
// Nil pointer fields are omitted from the JS call.
|
||||
type CameraOptions struct {
|
||||
Center *LngLat
|
||||
Zoom *float64
|
||||
Bearing *float64
|
||||
Pitch *float64
|
||||
Duration *int // milliseconds
|
||||
Speed *float64 // FlyTo only
|
||||
Curve *float64 // FlyTo only
|
||||
Padding *Padding
|
||||
Animate *bool
|
||||
}
|
||||
|
||||
// --- Layer ---
|
||||
|
||||
// Layer describes a MapLibre style layer.
|
||||
type Layer struct {
|
||||
ID string
|
||||
Type string
|
||||
Source string
|
||||
SourceLayer string
|
||||
Paint map[string]any
|
||||
Layout map[string]any
|
||||
Filter any
|
||||
MinZoom float64
|
||||
MaxZoom float64
|
||||
|
||||
// Before inserts this layer before the given layer ID in the stack.
|
||||
Before string
|
||||
}
|
||||
|
||||
func (l Layer) toJS() string {
|
||||
obj := map[string]any{
|
||||
"id": l.ID,
|
||||
"type": l.Type,
|
||||
}
|
||||
if l.Source != "" {
|
||||
obj["source"] = l.Source
|
||||
}
|
||||
if l.SourceLayer != "" {
|
||||
obj["source-layer"] = l.SourceLayer
|
||||
}
|
||||
if l.Paint != nil {
|
||||
obj["paint"] = l.Paint
|
||||
}
|
||||
if l.Layout != nil {
|
||||
obj["layout"] = l.Layout
|
||||
}
|
||||
if l.Filter != nil {
|
||||
obj["filter"] = l.Filter
|
||||
}
|
||||
if l.MinZoom > 0 {
|
||||
obj["minzoom"] = l.MinZoom
|
||||
}
|
||||
if l.MaxZoom > 0 {
|
||||
obj["maxzoom"] = l.MaxZoom
|
||||
}
|
||||
b, _ := json.Marshal(obj)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// --- Marker ---
|
||||
|
||||
// Marker describes a map marker.
|
||||
type Marker struct {
|
||||
LngLat LngLat // static position (used when signals are nil)
|
||||
Color string
|
||||
Draggable bool
|
||||
Popup *Popup
|
||||
|
||||
// Signal-backed position. When set, signals drive marker position reactively.
|
||||
// Initial position is read from the signal values. LngLat is ignored when signals are set.
|
||||
// If Draggable is true, drag updates write back to these signals.
|
||||
LngSignal *via.Signal
|
||||
LatSignal *via.Signal
|
||||
}
|
||||
|
||||
// Popup describes a map popup.
|
||||
//
|
||||
// Content is rendered as HTML via MapLibre's setHTML. Do not pass untrusted
|
||||
// user input without sanitizing it first.
|
||||
type Popup struct {
|
||||
Content string // HTML content
|
||||
LngLat LngLat
|
||||
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
|
||||
MaxWidth string
|
||||
}
|
||||
|
||||
// --- Event data ---
|
||||
|
||||
// EventData contains data from a map event (click, mousemove, etc.).
|
||||
type EventData struct {
|
||||
LngLat LngLat `json:"lngLat"`
|
||||
Point [2]float64 `json:"point"`
|
||||
Features []json.RawMessage `json:"features,omitempty"`
|
||||
LayerID string `json:"layerID,omitempty"`
|
||||
}
|
||||
|
||||
// --- Internal accumulation entries ---
|
||||
|
||||
type sourceEntry struct {
|
||||
id string
|
||||
js string
|
||||
}
|
||||
|
||||
type markerEntry struct {
|
||||
id string
|
||||
marker Marker
|
||||
}
|
||||
|
||||
type popupEntry struct {
|
||||
id string
|
||||
popup Popup
|
||||
}
|
||||
|
||||
type eventEntry struct {
|
||||
event string
|
||||
layerID string
|
||||
signal *via.Signal
|
||||
}
|
||||
|
||||
type controlEntry struct {
|
||||
id string
|
||||
ctrl Control
|
||||
}
|
||||
82
middleware.go
Normal file
82
middleware.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package via
|
||||
|
||||
// Middleware wraps a page init function. Call next to continue the chain;
|
||||
// return without calling next to abort (set a view first, e.g. RedirectView).
|
||||
type Middleware func(c *Context, next func())
|
||||
|
||||
// Group is a route group with a shared prefix and middleware stack.
|
||||
type Group struct {
|
||||
v *V
|
||||
prefix string
|
||||
middleware []Middleware
|
||||
}
|
||||
|
||||
// Use appends middleware to the global stack.
|
||||
// Global middleware runs before every page handler.
|
||||
func (v *V) Use(mw ...Middleware) {
|
||||
v.middleware = append(v.middleware, mw...)
|
||||
}
|
||||
|
||||
// Group creates a route group with the given path prefix and middleware.
|
||||
// Routes registered on the group are prefixed and run the group's middleware
|
||||
// after any global middleware.
|
||||
func (v *V) Group(prefix string, mw ...Middleware) *Group {
|
||||
return &Group{
|
||||
v: v,
|
||||
prefix: prefix,
|
||||
middleware: mw,
|
||||
}
|
||||
}
|
||||
|
||||
// Page registers a route on this group. The full route is the group prefix
|
||||
// concatenated with route.
|
||||
func (g *Group) Page(route string, initContextFn func(c *Context)) {
|
||||
fullRoute := g.prefix + route
|
||||
allMw := make([]Middleware, 0, len(g.v.middleware)+len(g.middleware))
|
||||
allMw = append(allMw, g.v.middleware...)
|
||||
allMw = append(allMw, g.middleware...)
|
||||
wrapped := chainMiddleware(allMw, initContextFn)
|
||||
g.v.page(fullRoute, initContextFn, wrapped)
|
||||
}
|
||||
|
||||
// Group creates a nested sub-group that inherits this group's prefix and
|
||||
// middleware, then adds its own.
|
||||
func (g *Group) Group(prefix string, mw ...Middleware) *Group {
|
||||
combined := make([]Middleware, len(g.middleware), len(g.middleware)+len(mw))
|
||||
copy(combined, g.middleware)
|
||||
combined = append(combined, mw...)
|
||||
return &Group{
|
||||
v: g.v,
|
||||
prefix: g.prefix + prefix,
|
||||
middleware: combined,
|
||||
}
|
||||
}
|
||||
|
||||
// Use appends middleware to this group's stack.
|
||||
func (g *Group) Use(mw ...Middleware) {
|
||||
g.middleware = append(g.middleware, mw...)
|
||||
}
|
||||
|
||||
// WithMiddleware returns an ActionOption that attaches middleware to an action.
|
||||
// Action middleware runs after CSRF/rate-limit checks and signal injection.
|
||||
func WithMiddleware(mw ...Middleware) ActionOption {
|
||||
return func(e *actionEntry) {
|
||||
e.middleware = append(e.middleware, mw...)
|
||||
}
|
||||
}
|
||||
|
||||
// chainMiddleware wraps handler with the given middleware, outer-first.
|
||||
func chainMiddleware(mws []Middleware, handler func(*Context)) func(*Context) {
|
||||
if len(mws) == 0 {
|
||||
return handler
|
||||
}
|
||||
chained := handler
|
||||
for i := len(mws) - 1; i >= 0; i-- {
|
||||
mw := mws[i]
|
||||
next := chained
|
||||
chained = func(c *Context) {
|
||||
mw(c, func() { next(c) })
|
||||
}
|
||||
}
|
||||
return chained
|
||||
}
|
||||
340
middleware_test.go
Normal file
340
middleware_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMiddlewareRunsBeforeHandler(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
v.Use(func(c *Context, next func()) {
|
||||
order = append(order, "mw")
|
||||
next()
|
||||
})
|
||||
v.Page("/", func(c *Context) {
|
||||
order = append(order, "handler")
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
// Reset after registration (panic-check runs the raw handler)
|
||||
order = nil
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, []string{"mw", "handler"}, order)
|
||||
}
|
||||
|
||||
func TestMiddlewareAbortSkipsHandler(t *testing.T) {
|
||||
handlerCalled := false
|
||||
|
||||
v := New()
|
||||
v.Use(func(c *Context, next func()) {
|
||||
c.RedirectView("/other")
|
||||
})
|
||||
v.Page("/", func(c *Context) {
|
||||
handlerCalled = true
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
handlerCalled = false
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.False(t, handlerCalled)
|
||||
}
|
||||
|
||||
func TestMiddlewareChainOrder(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
for _, label := range []string{"A", "B", "C"} {
|
||||
l := label
|
||||
v.Use(func(c *Context, next func()) {
|
||||
order = append(order, l)
|
||||
next()
|
||||
})
|
||||
}
|
||||
v.Page("/", func(c *Context) {
|
||||
order = append(order, "handler")
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
order = nil
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
|
||||
assert.Equal(t, []string{"A", "B", "C", "handler"}, order)
|
||||
}
|
||||
|
||||
func TestGroupPrefixRouting(t *testing.T) {
|
||||
v := New()
|
||||
g := v.Group("/admin")
|
||||
g.Page("/dashboard", func(c *Context) {
|
||||
c.View(func() h.H { return h.Div(h.Text("admin dashboard")) })
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/dashboard", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "admin dashboard")
|
||||
}
|
||||
|
||||
func TestGroupMiddlewareAppliesToGroupOnly(t *testing.T) {
|
||||
var groupMwCalled bool
|
||||
|
||||
v := New()
|
||||
g := v.Group("/admin", func(c *Context, next func()) {
|
||||
groupMwCalled = true
|
||||
next()
|
||||
})
|
||||
g.Page("/panel", func(c *Context) {
|
||||
c.View(func() h.H { return h.Div(h.Text("panel")) })
|
||||
})
|
||||
v.Page("/public", func(c *Context) {
|
||||
c.View(func() h.H { return h.Div(h.Text("public")) })
|
||||
})
|
||||
|
||||
// Hit public page — group middleware should NOT run
|
||||
groupMwCalled = false
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/public", nil))
|
||||
assert.False(t, groupMwCalled)
|
||||
assert.Contains(t, w.Body.String(), "public")
|
||||
|
||||
// Hit group page — group middleware should run
|
||||
groupMwCalled = false
|
||||
w = httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/panel", nil))
|
||||
assert.True(t, groupMwCalled)
|
||||
assert.Contains(t, w.Body.String(), "panel")
|
||||
}
|
||||
|
||||
func TestGlobalMiddlewareAppliesToGroupPages(t *testing.T) {
|
||||
var globalCalled bool
|
||||
|
||||
v := New()
|
||||
v.Use(func(c *Context, next func()) {
|
||||
globalCalled = true
|
||||
next()
|
||||
})
|
||||
g := v.Group("/admin")
|
||||
g.Page("/dash", func(c *Context) {
|
||||
c.View(func() h.H { return h.Div(h.Text("dash")) })
|
||||
})
|
||||
|
||||
globalCalled = false
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/dash", nil))
|
||||
|
||||
assert.True(t, globalCalled)
|
||||
assert.Contains(t, w.Body.String(), "dash")
|
||||
}
|
||||
|
||||
func TestNestedGroupInheritsPrefixAndMiddleware(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
admin := v.Group("/admin", func(c *Context, next func()) {
|
||||
order = append(order, "admin")
|
||||
next()
|
||||
})
|
||||
superAdmin := admin.Group("/super", func(c *Context, next func()) {
|
||||
order = append(order, "super")
|
||||
next()
|
||||
})
|
||||
superAdmin.Page("/secret", func(c *Context) {
|
||||
order = append(order, "handler")
|
||||
c.View(func() h.H { return h.Div(h.Text("secret")) })
|
||||
})
|
||||
|
||||
order = nil
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/admin/super/secret", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, []string{"admin", "super", "handler"}, order)
|
||||
assert.Contains(t, w.Body.String(), "secret")
|
||||
}
|
||||
|
||||
func TestGroupUse(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
g := v.Group("/api")
|
||||
g.Use(func(c *Context, next func()) {
|
||||
order = append(order, "added-later")
|
||||
next()
|
||||
})
|
||||
g.Page("/items", func(c *Context) {
|
||||
order = append(order, "handler")
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
order = nil
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/api/items", nil))
|
||||
|
||||
assert.Equal(t, []string{"added-later", "handler"}, order)
|
||||
}
|
||||
|
||||
func TestRedirectViewSetsValidView(t *testing.T) {
|
||||
v := New()
|
||||
v.Page("/test", func(c *Context) {
|
||||
c.RedirectView("/somewhere")
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "<!doctype html>")
|
||||
}
|
||||
|
||||
func TestGlobalAndGroupMiddlewareOrder(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
v.Use(func(c *Context, next func()) {
|
||||
order = append(order, "global")
|
||||
next()
|
||||
})
|
||||
g := v.Group("/g", func(c *Context, next func()) {
|
||||
order = append(order, "group")
|
||||
next()
|
||||
})
|
||||
g.Page("/page", func(c *Context) {
|
||||
order = append(order, "handler")
|
||||
c.View(func() h.H { return h.Div() })
|
||||
})
|
||||
|
||||
order = nil
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/g/page", nil))
|
||||
|
||||
assert.Equal(t, []string{"global", "group", "handler"}, order)
|
||||
}
|
||||
|
||||
// --- Action middleware tests ---
|
||||
|
||||
func TestActionMiddlewareRunsBeforeAction(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
c := newContext("test", "/", v)
|
||||
|
||||
mw := func(_ *Context, next func()) {
|
||||
order = append(order, "mw")
|
||||
next()
|
||||
}
|
||||
|
||||
trigger := c.Action(func() {
|
||||
order = append(order, "action")
|
||||
}, WithMiddleware(mw))
|
||||
|
||||
entry, err := c.getAction(trigger.id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
|
||||
|
||||
assert.Equal(t, []string{"mw", "action"}, order)
|
||||
}
|
||||
|
||||
func TestActionMiddlewareAbortSkipsAction(t *testing.T) {
|
||||
actionCalled := false
|
||||
|
||||
v := New()
|
||||
c := newContext("test", "/", v)
|
||||
|
||||
mw := func(_ *Context, next func()) {
|
||||
// don't call next — action should not run
|
||||
}
|
||||
|
||||
trigger := c.Action(func() {
|
||||
actionCalled = true
|
||||
}, WithMiddleware(mw))
|
||||
|
||||
entry, err := c.getAction(trigger.id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
|
||||
|
||||
assert.False(t, actionCalled)
|
||||
}
|
||||
|
||||
func TestActionMiddlewareChainOrder(t *testing.T) {
|
||||
var order []string
|
||||
|
||||
v := New()
|
||||
c := newContext("test", "/", v)
|
||||
|
||||
var mws []Middleware
|
||||
for _, label := range []string{"A", "B", "C"} {
|
||||
l := label
|
||||
mws = append(mws, func(_ *Context, next func()) {
|
||||
order = append(order, l)
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
trigger := c.Action(func() {
|
||||
order = append(order, "action")
|
||||
}, WithMiddleware(mws...))
|
||||
|
||||
entry, err := c.getAction(trigger.id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
|
||||
|
||||
assert.Equal(t, []string{"A", "B", "C", "action"}, order)
|
||||
}
|
||||
|
||||
func TestActionMiddlewareCombinedWithRateLimit(t *testing.T) {
|
||||
v := New()
|
||||
c := newContext("test", "/", v)
|
||||
|
||||
mw := func(_ *Context, next func()) { next() }
|
||||
|
||||
trigger := c.Action(func() {}, WithRateLimit(5, 10), WithMiddleware(mw))
|
||||
|
||||
entry, err := c.getAction(trigger.id)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, entry.limiter)
|
||||
assert.Len(t, entry.middleware, 1)
|
||||
}
|
||||
|
||||
func TestGroupWithEmptyPrefix(t *testing.T) {
|
||||
var mwCalled bool
|
||||
|
||||
v := New()
|
||||
g := v.Group("", func(c *Context, next func()) {
|
||||
mwCalled = true
|
||||
next()
|
||||
})
|
||||
g.Page("/dashboard", func(c *Context) {
|
||||
c.View(func() h.H { return h.Div(h.Text("dash")) })
|
||||
})
|
||||
|
||||
mwCalled = false
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, httptest.NewRequest("GET", "/dashboard", nil))
|
||||
|
||||
assert.True(t, mwCalled)
|
||||
assert.Contains(t, w.Body.String(), "dash")
|
||||
}
|
||||
210
nats.go
Normal file
210
nats.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/delaneyj/toolbelt/embeddednats"
|
||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// defaultNATS is the process-scoped embedded NATS server.
|
||||
type defaultNATS struct {
|
||||
server *embeddednats.Server
|
||||
nc *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
cancel context.CancelFunc
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
sharedNATS *defaultNATS
|
||||
sharedNATSOnce sync.Once
|
||||
sharedNATSErr error
|
||||
)
|
||||
|
||||
// getSharedNATS returns a process-level singleton embedded NATS server.
|
||||
// The server starts once and is reused across all V instances.
|
||||
func getSharedNATS() (*defaultNATS, error) {
|
||||
sharedNATSOnce.Do(func() {
|
||||
sharedNATS, sharedNATSErr = startDefaultNATS()
|
||||
})
|
||||
return sharedNATS, sharedNATSErr
|
||||
}
|
||||
|
||||
func startDefaultNATS() (dn *defaultNATS, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("nats server panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "via-nats-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ns, err := embeddednats.New(ctx,
|
||||
embeddednats.WithDirectory(dataDir),
|
||||
embeddednats.WithNATSServerOptions(&natsserver.Options{
|
||||
JetStream: true,
|
||||
StoreDir: dataDir,
|
||||
Port: -1,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("start embedded nats: %w", err)
|
||||
}
|
||||
ready := make(chan struct{})
|
||||
go func() {
|
||||
ns.WaitForServer()
|
||||
close(ready)
|
||||
}()
|
||||
select {
|
||||
case <-ready:
|
||||
case <-time.After(10 * time.Second):
|
||||
ns.Close()
|
||||
cancel()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("embedded nats server did not start within 10s")
|
||||
}
|
||||
|
||||
nc, err := ns.Client()
|
||||
if err != nil {
|
||||
ns.Close()
|
||||
cancel()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("connect nats client: %w", err)
|
||||
}
|
||||
|
||||
js, err := nc.JetStream()
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
ns.Close()
|
||||
cancel()
|
||||
os.RemoveAll(dataDir)
|
||||
return nil, fmt.Errorf("init jetstream: %w", err)
|
||||
}
|
||||
|
||||
return &defaultNATS{
|
||||
server: ns,
|
||||
nc: nc,
|
||||
js: js,
|
||||
cancel: cancel,
|
||||
dataDir: dataDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *defaultNATS) Publish(subject string, data []byte) error {
|
||||
return n.nc.Publish(subject, data)
|
||||
}
|
||||
|
||||
func (n *defaultNATS) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
||||
sub, err := n.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
handler(msg.Data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// natsRef wraps a shared defaultNATS as a PubSub. Close is a no-op because
|
||||
// the underlying server is process-scoped and outlives individual V instances.
|
||||
type natsRef struct {
|
||||
dn *defaultNATS
|
||||
}
|
||||
|
||||
func (r *natsRef) Publish(subject string, data []byte) error {
|
||||
return r.dn.Publish(subject, data)
|
||||
}
|
||||
|
||||
func (r *natsRef) Subscribe(subject string, handler func(data []byte)) (Subscription, error) {
|
||||
return r.dn.Subscribe(subject, handler)
|
||||
}
|
||||
|
||||
func (r *natsRef) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NATSConn returns the underlying NATS connection from the built-in embedded
|
||||
// server, or nil if a custom PubSub backend is in use.
|
||||
func (v *V) NATSConn() *nats.Conn {
|
||||
if v.defaultNATS != nil {
|
||||
return v.defaultNATS.nc
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// JetStream returns the JetStream context from the built-in embedded server,
|
||||
// or nil if a custom PubSub backend is in use.
|
||||
func (v *V) JetStream() nats.JetStreamContext {
|
||||
if v.defaultNATS != nil {
|
||||
return v.defaultNATS.js
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamConfig holds the parameters for creating or updating a JetStream stream.
|
||||
type StreamConfig struct {
|
||||
Name string
|
||||
Subjects []string
|
||||
MaxMsgs int64
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// EnsureStream creates or updates a JetStream stream matching cfg.
|
||||
func EnsureStream(v *V, cfg StreamConfig) error {
|
||||
js := v.JetStream()
|
||||
if js == nil {
|
||||
return fmt.Errorf("jetstream not available")
|
||||
}
|
||||
_, err := js.AddStream(&nats.StreamConfig{
|
||||
Name: cfg.Name,
|
||||
Subjects: cfg.Subjects,
|
||||
Retention: nats.LimitsPolicy,
|
||||
MaxMsgs: cfg.MaxMsgs,
|
||||
MaxAge: cfg.MaxAge,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplayHistory fetches the last limit messages from subject,
|
||||
// deserializing each as T. Returns an empty slice if nothing is available.
|
||||
func ReplayHistory[T any](v *V, subject string, limit int) ([]T, error) {
|
||||
js := v.JetStream()
|
||||
if js == nil {
|
||||
return nil, fmt.Errorf("jetstream not available")
|
||||
}
|
||||
sub, err := js.SubscribeSync(subject, nats.DeliverAll(), nats.OrderedConsumer())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
var msgs []T
|
||||
for {
|
||||
raw, err := sub.NextMsg(200 * time.Millisecond)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var msg T
|
||||
if json.Unmarshal(raw.Data, &msg) == nil {
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 && len(msgs) > limit {
|
||||
msgs = msgs[len(msgs)-limit:]
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
146
nats_test.go
Normal file
146
nats_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupNATSTest creates a *V with an embedded NATS server.
|
||||
// Skips the test if NATS fails to start (e.g. port conflict in CI).
|
||||
func setupNATSTest(t *testing.T) *V {
|
||||
t.Helper()
|
||||
v := New()
|
||||
dn, err := getSharedNATS()
|
||||
if err != nil {
|
||||
v.Shutdown()
|
||||
t.Skipf("embedded NATS unavailable: %v", err)
|
||||
}
|
||||
v.defaultNATS = dn
|
||||
v.pubsub = &natsRef{dn: dn}
|
||||
t.Cleanup(v.Shutdown)
|
||||
return v
|
||||
}
|
||||
|
||||
func TestPubSub_RoundTrip(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
var received []byte
|
||||
done := make(chan struct{})
|
||||
|
||||
c := newContext("test-ctx", "/", v)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
_, err := c.Subscribe("test.topic", func(data []byte) {
|
||||
received = data
|
||||
close(done)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = c.Publish("test.topic", []byte("hello"))
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for message")
|
||||
}
|
||||
assert.Equal(t, []byte("hello"), received)
|
||||
}
|
||||
|
||||
func TestPubSub_MultipleSubscribers(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
var results []string
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
c1 := newContext("ctx-1", "/", v)
|
||||
c1.View(func() h.H { return h.Div() })
|
||||
c2 := newContext("ctx-2", "/", v)
|
||||
c2.View(func() h.H { return h.Div() })
|
||||
|
||||
c1.Subscribe("broadcast", func(data []byte) {
|
||||
mu.Lock()
|
||||
results = append(results, "c1:"+string(data))
|
||||
mu.Unlock()
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
c2.Subscribe("broadcast", func(data []byte) {
|
||||
mu.Lock()
|
||||
results = append(results, "c2:"+string(data))
|
||||
mu.Unlock()
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
c1.Publish("broadcast", []byte("msg"))
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for messages")
|
||||
}
|
||||
|
||||
assert.Len(t, results, 2)
|
||||
assert.Contains(t, results, "c1:msg")
|
||||
assert.Contains(t, results, "c2:msg")
|
||||
}
|
||||
|
||||
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
c := newContext("cleanup-ctx", "/", v)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
c.Subscribe("room.1", func(data []byte) {})
|
||||
c.Subscribe("room.2", func(data []byte) {})
|
||||
|
||||
assert.Len(t, c.subscriptions, 2)
|
||||
|
||||
c.unsubscribeAll()
|
||||
assert.Empty(t, c.subscriptions)
|
||||
}
|
||||
|
||||
func TestPubSub_ManualUnsubscribe(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
c := newContext("unsub-ctx", "/", v)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
called := false
|
||||
sub, err := c.Subscribe("topic", func(data []byte) {
|
||||
called = true
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sub.Unsubscribe()
|
||||
|
||||
c.Publish("topic", []byte("ignored"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
// Panic-check context has id=""
|
||||
c := newContext("", "/", v)
|
||||
|
||||
err := c.Publish("topic", []byte("data"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
sub, err := c.Subscribe("topic", func(data []byte) {})
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, sub)
|
||||
}
|
||||
51
navigate.js
Normal file
51
navigate.js
Normal file
@@ -0,0 +1,51 @@
|
||||
(function() {
|
||||
const meta = document.querySelector('meta[data-signals]');
|
||||
if (!meta) return;
|
||||
const raw = meta.getAttribute('data-signals');
|
||||
const parsed = JSON.parse(raw.replace(/'/g, '"'));
|
||||
const ctxID = parsed['via-ctx'];
|
||||
const csrf = parsed['via-csrf'];
|
||||
if (!ctxID || !csrf) return;
|
||||
|
||||
function navigate(url, popstate) {
|
||||
const params = new URLSearchParams({
|
||||
'via-ctx': ctxID,
|
||||
'via-csrf': csrf,
|
||||
'url': url,
|
||||
});
|
||||
if (popstate) params.set('popstate', '1');
|
||||
fetch('/_navigate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: params.toString()
|
||||
}).then(function(res) {
|
||||
if (!res.ok) window.location.href = url;
|
||||
}).catch(function() {
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target;
|
||||
while (el && el.tagName !== 'A') el = el.parentElement;
|
||||
if (!el) return;
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
||||
if (el.hasAttribute('target')) return;
|
||||
if (el.hasAttribute('data-via-no-boost')) return;
|
||||
var href = el.getAttribute('href');
|
||||
if (!href || href.startsWith('#')) return;
|
||||
try {
|
||||
var url = new URL(href, window.location.origin);
|
||||
if (url.origin !== window.location.origin) return;
|
||||
e.preventDefault();
|
||||
navigate(url.pathname + url.search + url.hash);
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
var ready = false;
|
||||
window.addEventListener('popstate', function() {
|
||||
if (!ready) return;
|
||||
navigate(window.location.pathname + window.location.search + window.location.hash, true);
|
||||
});
|
||||
setTimeout(function() { ready = true; }, 0);
|
||||
})();
|
||||
16
pubsub.go
Normal file
16
pubsub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package via
|
||||
|
||||
// PubSub is an interface for publish/subscribe messaging backends.
|
||||
// By default, Start() launches an embedded NATS server if no backend
|
||||
// has been configured. Supply a custom implementation via
|
||||
// Config(Options{PubSub: yourBackend}) to override.
|
||||
type PubSub interface {
|
||||
Publish(subject string, data []byte) error
|
||||
Subscribe(subject string, handler func(data []byte)) (Subscription, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Subscription represents an active subscription that can be manually unsubscribed.
|
||||
type Subscription interface {
|
||||
Unsubscribe() error
|
||||
}
|
||||
23
pubsub_helpers.go
Normal file
23
pubsub_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package via
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Publish JSON-marshals msg and publishes to subject.
|
||||
func Publish[T any](c *Context, subject string, msg T) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Publish(subject, data)
|
||||
}
|
||||
|
||||
// Subscribe JSON-unmarshals each message as T and calls handler.
|
||||
func Subscribe[T any](c *Context, subject string, handler func(T)) (Subscription, error) {
|
||||
return c.Subscribe(subject, func(data []byte) {
|
||||
var msg T
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return
|
||||
}
|
||||
handler(msg)
|
||||
})
|
||||
}
|
||||
66
pubsub_helpers_test.go
Normal file
66
pubsub_helpers_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPublishSubscribe_RoundTrip(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
type event struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
var got event
|
||||
done := make(chan struct{})
|
||||
|
||||
c := newContext("typed-ctx", "/", v)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
_, err := Subscribe(c, "events", func(e event) {
|
||||
got = e
|
||||
close(done)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = Publish(c, "events", event{Name: "click", Count: 42})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for message")
|
||||
}
|
||||
assert.Equal(t, "click", got.Name)
|
||||
assert.Equal(t, 42, got.Count)
|
||||
}
|
||||
|
||||
func TestSubscribe_SkipsBadJSON(t *testing.T) {
|
||||
v := setupNATSTest(t)
|
||||
|
||||
type msg struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
called := false
|
||||
c := newContext("bad-json-ctx", "/", v)
|
||||
c.View(func() h.H { return h.Div() })
|
||||
|
||||
_, err := Subscribe(c, "topic", func(m msg) {
|
||||
called = true
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Publish raw invalid JSON — handler should silently skip
|
||||
err = c.Publish("topic", []byte("not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
assert.False(t, called)
|
||||
}
|
||||
49
ratelimit.go
Normal file
49
ratelimit.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package via
|
||||
|
||||
import "golang.org/x/time/rate"
|
||||
|
||||
const (
|
||||
defaultActionRate float64 = 10.0
|
||||
defaultActionBurst int = 20
|
||||
)
|
||||
|
||||
// RateLimitConfig configures token-bucket rate limiting for actions.
|
||||
// Zero values fall back to defaults. Rate of -1 disables limiting entirely.
|
||||
type RateLimitConfig struct {
|
||||
Rate float64
|
||||
Burst int
|
||||
}
|
||||
|
||||
// ActionOption configures per-action behaviour when passed to Context.Action.
|
||||
type ActionOption func(*actionEntry)
|
||||
|
||||
type actionEntry struct {
|
||||
fn func()
|
||||
limiter *rate.Limiter // nil = use context default
|
||||
middleware []Middleware
|
||||
}
|
||||
|
||||
// WithRateLimit returns an ActionOption that gives this action its own
|
||||
// token-bucket limiter, overriding the context-level default.
|
||||
func WithRateLimit(r float64, burst int) ActionOption {
|
||||
return func(e *actionEntry) {
|
||||
e.limiter = newLimiter(RateLimitConfig{Rate: r, Burst: burst}, defaultActionRate, defaultActionBurst)
|
||||
}
|
||||
}
|
||||
|
||||
// newLimiter creates a *rate.Limiter from cfg, substituting defaults for zero
|
||||
// values. A Rate of -1 disables limiting (returns nil).
|
||||
func newLimiter(cfg RateLimitConfig, defaultRate float64, defaultBurst int) *rate.Limiter {
|
||||
r := cfg.Rate
|
||||
b := cfg.Burst
|
||||
if r == -1 {
|
||||
return nil
|
||||
}
|
||||
if r == 0 {
|
||||
r = defaultRate
|
||||
}
|
||||
if b == 0 {
|
||||
b = defaultBurst
|
||||
}
|
||||
return rate.NewLimiter(rate.Limit(r), b)
|
||||
}
|
||||
101
ratelimit_test.go
Normal file
101
ratelimit_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLimiter_Defaults(t *testing.T) {
|
||||
l := newLimiter(RateLimitConfig{}, defaultActionRate, defaultActionBurst)
|
||||
require.NotNil(t, l)
|
||||
assert.InDelta(t, defaultActionRate, float64(l.Limit()), 0.001)
|
||||
assert.Equal(t, defaultActionBurst, l.Burst())
|
||||
}
|
||||
|
||||
func TestNewLimiter_CustomValues(t *testing.T) {
|
||||
l := newLimiter(RateLimitConfig{Rate: 5, Burst: 10}, defaultActionRate, defaultActionBurst)
|
||||
require.NotNil(t, l)
|
||||
assert.InDelta(t, 5.0, float64(l.Limit()), 0.001)
|
||||
assert.Equal(t, 10, l.Burst())
|
||||
}
|
||||
|
||||
func TestNewLimiter_DisabledWithNegativeRate(t *testing.T) {
|
||||
l := newLimiter(RateLimitConfig{Rate: -1}, defaultActionRate, defaultActionBurst)
|
||||
assert.Nil(t, l)
|
||||
}
|
||||
|
||||
func TestTokenBucket_AllowsBurstThenRejects(t *testing.T) {
|
||||
l := newLimiter(RateLimitConfig{Rate: 1, Burst: 3}, 1, 3)
|
||||
require.NotNil(t, l)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
assert.True(t, l.Allow(), "request %d should be allowed within burst", i)
|
||||
}
|
||||
assert.False(t, l.Allow(), "request beyond burst should be rejected")
|
||||
}
|
||||
|
||||
func TestWithRateLimit_CreatesLimiter(t *testing.T) {
|
||||
entry := actionEntry{fn: func() {}}
|
||||
opt := WithRateLimit(2, 4)
|
||||
opt(&entry)
|
||||
|
||||
require.NotNil(t, entry.limiter)
|
||||
assert.InDelta(t, 2.0, float64(entry.limiter.Limit()), 0.001)
|
||||
assert.Equal(t, 4, entry.limiter.Burst())
|
||||
}
|
||||
|
||||
func TestContextAction_WithRateLimit(t *testing.T) {
|
||||
v := New()
|
||||
c := newContext("test-rl", "/", v)
|
||||
|
||||
called := false
|
||||
c.Action(func() { called = true }, WithRateLimit(1, 2))
|
||||
|
||||
// Verify the entry has its own limiter
|
||||
for _, entry := range c.actionRegistry {
|
||||
require.NotNil(t, entry.limiter)
|
||||
assert.InDelta(t, 1.0, float64(entry.limiter.Limit()), 0.001)
|
||||
assert.Equal(t, 2, entry.limiter.Burst())
|
||||
}
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
func TestContextAction_DefaultNoPerActionLimiter(t *testing.T) {
|
||||
v := New()
|
||||
c := newContext("test-no-rl", "/", v)
|
||||
|
||||
c.Action(func() {})
|
||||
|
||||
for _, entry := range c.actionRegistry {
|
||||
assert.Nil(t, entry.limiter, "entry without WithRateLimit should have nil limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextLimiter_DefaultsApplied(t *testing.T) {
|
||||
v := New()
|
||||
c := newContext("test-ctx-limiter", "/", v)
|
||||
|
||||
require.NotNil(t, c.actionLimiter)
|
||||
assert.InDelta(t, defaultActionRate, float64(c.actionLimiter.Limit()), 0.001)
|
||||
assert.Equal(t, defaultActionBurst, c.actionLimiter.Burst())
|
||||
}
|
||||
|
||||
func TestContextLimiter_DisabledViaConfig(t *testing.T) {
|
||||
v := New()
|
||||
v.actionRateLimit = RateLimitConfig{Rate: -1}
|
||||
c := newContext("test-disabled", "/", v)
|
||||
|
||||
assert.Nil(t, c.actionLimiter)
|
||||
}
|
||||
|
||||
func TestContextLimiter_CustomConfig(t *testing.T) {
|
||||
v := New()
|
||||
v.Config(Options{ActionRateLimit: RateLimitConfig{Rate: 50, Burst: 100}})
|
||||
c := newContext("test-custom", "/", v)
|
||||
|
||||
require.NotNil(t, c.actionLimiter)
|
||||
assert.InDelta(t, 50.0, float64(c.actionLimiter.Limit()), 0.001)
|
||||
assert.Equal(t, 100, c.actionLimiter.Burst())
|
||||
}
|
||||
74
routine.go
74
routine.go
@@ -1,76 +1,34 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OnIntervalRoutine allows for defining concurrent goroutines safely. Goroutines started by *OnIntervalRoutine
|
||||
// are tied to the *Context lifecycle.
|
||||
type OnIntervalRoutine struct {
|
||||
mu sync.RWMutex
|
||||
ctxDisposed chan struct{}
|
||||
localInterrupt chan struct{}
|
||||
isRunning atomic.Bool
|
||||
routineFn func()
|
||||
tckDuration time.Duration
|
||||
updateTkrChan chan time.Duration
|
||||
}
|
||||
func newOnInterval(ctxDisposedChan, pageStopChan chan struct{}, duration time.Duration, handler func()) func() {
|
||||
localInterrupt := make(chan struct{})
|
||||
var stopped atomic.Bool
|
||||
|
||||
// UpdateInterval sets a new interval duration for the internal *time.Ticker. If the provided
|
||||
// duration is equal of less than 0, UpdateInterval does nothing.
|
||||
func (r *OnIntervalRoutine) UpdateInterval(d time.Duration) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.tckDuration = d
|
||||
r.updateTkrChan <- d
|
||||
|
||||
}
|
||||
|
||||
// Start executes the predifined goroutine. If no predifined goroutine exists, or it already
|
||||
// started, Start does nothing.
|
||||
func (r *OnIntervalRoutine) Start() {
|
||||
if !r.isRunning.CompareAndSwap(false, true) || r.routineFn == nil {
|
||||
return
|
||||
}
|
||||
go r.routineFn()
|
||||
}
|
||||
|
||||
// Stop interrupts the predifined goroutine. If no predifined goroutine exists, or it already
|
||||
// ustopped, Stop does nothing.
|
||||
func (r *OnIntervalRoutine) Stop() {
|
||||
if !r.isRunning.CompareAndSwap(true, false) || r.routineFn == nil {
|
||||
return
|
||||
}
|
||||
r.localInterrupt <- struct{}{}
|
||||
}
|
||||
|
||||
func newOnIntervalRoutine(ctxDisposedChan chan struct{},
|
||||
duration time.Duration, handler func()) *OnIntervalRoutine {
|
||||
r := &OnIntervalRoutine{
|
||||
ctxDisposed: ctxDisposedChan,
|
||||
localInterrupt: make(chan struct{}),
|
||||
updateTkrChan: make(chan time.Duration),
|
||||
}
|
||||
r.tckDuration = duration
|
||||
r.routineFn = func() {
|
||||
r.mu.RLock()
|
||||
tkr := time.NewTicker(r.tckDuration)
|
||||
r.mu.RUnlock()
|
||||
defer tkr.Stop() // clean up the ticker when routine stops
|
||||
go func() {
|
||||
tkr := time.NewTicker(duration)
|
||||
defer tkr.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.ctxDisposed: // dispose of the routine when ctx is disposed
|
||||
case <-ctxDisposedChan:
|
||||
return
|
||||
case <-r.localInterrupt: // dispose of the routine on interrupt signal
|
||||
case <-pageStopChan:
|
||||
return
|
||||
case <-localInterrupt:
|
||||
return
|
||||
case d := <-r.updateTkrChan:
|
||||
tkr.Reset(d)
|
||||
case <-tkr.C:
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
if stopped.CompareAndSwap(false, true) {
|
||||
close(localInterrupt)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
130
rule.go
Normal file
130
rule.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Rule defines a single validation check for a Field.
|
||||
type Rule struct {
|
||||
validate func(val string) error
|
||||
}
|
||||
|
||||
// Required rejects empty or whitespace-only values.
|
||||
func Required(msg ...string) Rule {
|
||||
m := "This field is required"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if strings.TrimSpace(val) == "" {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// MinLen rejects values shorter than n characters.
|
||||
func MinLen(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at least %d characters", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if utf8.RuneCountInString(val) < n {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// MaxLen rejects values longer than n characters.
|
||||
func MaxLen(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at most %d characters", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if utf8.RuneCountInString(val) > n {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Min parses the value as an integer and rejects values less than n.
|
||||
func Min(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at least %d", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
v, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return errors.New("Must be a valid number")
|
||||
}
|
||||
if v < n {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Max parses the value as an integer and rejects values greater than n.
|
||||
func Max(n int, msg ...string) Rule {
|
||||
m := fmt.Sprintf("Must be at most %d", n)
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
v, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return errors.New("Must be a valid number")
|
||||
}
|
||||
if v > n {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Pattern rejects values that don't match the regular expression re.
|
||||
func Pattern(re string, msg ...string) Rule {
|
||||
m := "Invalid format"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
compiled := regexp.MustCompile(re)
|
||||
return Rule{func(val string) error {
|
||||
if !compiled.MatchString(val) {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
var emailRegexp = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
// Email rejects values that don't look like an email address.
|
||||
func Email(msg ...string) Rule {
|
||||
m := "Invalid email address"
|
||||
if len(msg) > 0 {
|
||||
m = msg[0]
|
||||
}
|
||||
return Rule{func(val string) error {
|
||||
if !emailRegexp.MatchString(val) {
|
||||
return errors.New(m)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// Custom creates a rule from a user-provided validation function.
|
||||
// The function should return nil for valid input and an error for invalid input.
|
||||
func Custom(fn func(string) error) Rule {
|
||||
return Rule{validate: fn}
|
||||
}
|
||||
116
rule_test.go
Normal file
116
rule_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRequired(t *testing.T) {
|
||||
r := Required()
|
||||
assert.NoError(t, r.validate("hello"))
|
||||
assert.Error(t, r.validate(""))
|
||||
assert.Error(t, r.validate(" "))
|
||||
}
|
||||
|
||||
func TestRequiredCustomMessage(t *testing.T) {
|
||||
r := Required("name needed")
|
||||
err := r.validate("")
|
||||
assert.EqualError(t, err, "name needed")
|
||||
}
|
||||
|
||||
func TestMinLen(t *testing.T) {
|
||||
r := MinLen(3)
|
||||
assert.NoError(t, r.validate("abc"))
|
||||
assert.NoError(t, r.validate("abcd"))
|
||||
assert.Error(t, r.validate("ab"))
|
||||
assert.Error(t, r.validate(""))
|
||||
}
|
||||
|
||||
func TestMinLenCustomMessage(t *testing.T) {
|
||||
r := MinLen(5, "too short")
|
||||
err := r.validate("ab")
|
||||
assert.EqualError(t, err, "too short")
|
||||
}
|
||||
|
||||
func TestMaxLen(t *testing.T) {
|
||||
r := MaxLen(5)
|
||||
assert.NoError(t, r.validate("abc"))
|
||||
assert.NoError(t, r.validate("abcde"))
|
||||
assert.Error(t, r.validate("abcdef"))
|
||||
}
|
||||
|
||||
func TestMaxLenCustomMessage(t *testing.T) {
|
||||
r := MaxLen(2, "too long")
|
||||
err := r.validate("abc")
|
||||
assert.EqualError(t, err, "too long")
|
||||
}
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
r := Min(5)
|
||||
assert.NoError(t, r.validate("5"))
|
||||
assert.NoError(t, r.validate("10"))
|
||||
assert.Error(t, r.validate("4"))
|
||||
assert.Error(t, r.validate("abc"))
|
||||
}
|
||||
|
||||
func TestMinCustomMessage(t *testing.T) {
|
||||
r := Min(10, "need 10+")
|
||||
err := r.validate("3")
|
||||
assert.EqualError(t, err, "need 10+")
|
||||
}
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
r := Max(10)
|
||||
assert.NoError(t, r.validate("10"))
|
||||
assert.NoError(t, r.validate("5"))
|
||||
assert.Error(t, r.validate("11"))
|
||||
assert.Error(t, r.validate("abc"))
|
||||
}
|
||||
|
||||
func TestMaxCustomMessage(t *testing.T) {
|
||||
r := Max(5, "too big")
|
||||
err := r.validate("6")
|
||||
assert.EqualError(t, err, "too big")
|
||||
}
|
||||
|
||||
func TestPattern(t *testing.T) {
|
||||
r := Pattern(`^\d{3}$`)
|
||||
assert.NoError(t, r.validate("123"))
|
||||
assert.Error(t, r.validate("12"))
|
||||
assert.Error(t, r.validate("abcd"))
|
||||
}
|
||||
|
||||
func TestPatternCustomMessage(t *testing.T) {
|
||||
r := Pattern(`^\d+$`, "digits only")
|
||||
err := r.validate("abc")
|
||||
assert.EqualError(t, err, "digits only")
|
||||
}
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
r := Email()
|
||||
assert.NoError(t, r.validate("user@example.com"))
|
||||
assert.NoError(t, r.validate("a.b+c@foo.co"))
|
||||
assert.Error(t, r.validate("notanemail"))
|
||||
assert.Error(t, r.validate("@example.com"))
|
||||
assert.Error(t, r.validate("user@"))
|
||||
assert.Error(t, r.validate(""))
|
||||
}
|
||||
|
||||
func TestEmailCustomMessage(t *testing.T) {
|
||||
r := Email("bad email")
|
||||
err := r.validate("nope")
|
||||
assert.EqualError(t, err, "bad email")
|
||||
}
|
||||
|
||||
func TestCustom(t *testing.T) {
|
||||
r := Custom(func(val string) error {
|
||||
if val != "magic" {
|
||||
return fmt.Errorf("must be magic")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
assert.NoError(t, r.validate("magic"))
|
||||
assert.EqualError(t, r.validate("other"), "must be magic")
|
||||
}
|
||||
50
signal.go
50
signal.go
@@ -9,27 +9,27 @@ import (
|
||||
)
|
||||
|
||||
// Signal represents a value that is reactive in the browser. Signals
|
||||
// are synct with the server right before an action triggers.
|
||||
// are synced with the server right before an action triggers.
|
||||
//
|
||||
// Use Bind() to connect a signal to an input and Text() to display it
|
||||
// reactively on an html element.
|
||||
type signal struct {
|
||||
type Signal struct {
|
||||
id string
|
||||
val any
|
||||
changed bool
|
||||
err error
|
||||
}
|
||||
|
||||
// ID returns the signal ID
|
||||
func (s *signal) ID() string {
|
||||
// ID returns the signal ID.
|
||||
func (s *Signal) ID() string {
|
||||
return s.id
|
||||
}
|
||||
|
||||
// Err returns a signal error or nil if it contains no error.
|
||||
//
|
||||
// It is useful to check for errors after updating signals with
|
||||
// dinamic values.
|
||||
func (s *signal) Err() error {
|
||||
// dynamic values.
|
||||
func (s *Signal) Err() error {
|
||||
return s.err
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (s *signal) Err() error {
|
||||
// Example:
|
||||
//
|
||||
// h.Input(h.Type("number"), mysignal.Bind())
|
||||
func (s *signal) Bind() h.H {
|
||||
func (s *Signal) Bind() h.H {
|
||||
return h.Data("bind", s.id)
|
||||
}
|
||||
|
||||
@@ -48,59 +48,35 @@ func (s *signal) Bind() h.H {
|
||||
// Example:
|
||||
//
|
||||
// h.Div(mysignal.Text())
|
||||
func (s *signal) Text() h.H {
|
||||
func (s *Signal) Text() h.H {
|
||||
return h.Span(h.Data("text", "$"+s.id))
|
||||
}
|
||||
|
||||
// SetValue updates the signal’s value and marks it for synchronization with the browser.
|
||||
// The change will be propagated to the browser using *Context.Sync() or *Context.SyncSignals().
|
||||
func (s *signal) SetValue(v any) {
|
||||
func (s *Signal) SetValue(v any) {
|
||||
s.val = v
|
||||
s.changed = true
|
||||
s.err = nil
|
||||
}
|
||||
|
||||
// String return the signal value as a string.
|
||||
func (s *signal) String() string {
|
||||
// String returns the signal value as a string.
|
||||
func (s *Signal) String() string {
|
||||
return fmt.Sprintf("%v", s.val)
|
||||
}
|
||||
|
||||
// Bool tries to read the signal value as a bool.
|
||||
// Returns the value or false on failure.
|
||||
func (s *signal) Bool() bool {
|
||||
func (s *Signal) Bool() bool {
|
||||
val := strings.ToLower(s.String())
|
||||
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||
}
|
||||
|
||||
// Int tries to read the signal value as an int.
|
||||
// Returns the value or 0 on failure.
|
||||
func (s *signal) Int() int {
|
||||
func (s *Signal) Int() int {
|
||||
if n, err := strconv.Atoi(s.String()); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Int64 tries to read the signal value as an int64.
|
||||
// Returns the value or 0 on failure.
|
||||
func (s *signal) Int64() int64 {
|
||||
if n, err := strconv.ParseInt(s.String(), 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Float64 tries to read the signal value as a float64.
|
||||
// Returns the value or 0.0 on failure.
|
||||
func (s *signal) Float() float64 {
|
||||
if n, err := strconv.ParseFloat(s.String(), 64); err == nil {
|
||||
return n
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// Bytes tries to read the signal value as a []byte
|
||||
// Returns the value or an empty []byte on failure.
|
||||
func (s *signal) Bytes() []byte {
|
||||
return []byte(s.String())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
// "net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
@@ -27,7 +26,7 @@ func TestSignalReturnAsString(t *testing.T) {
|
||||
for _, testcase := range testcases {
|
||||
t.Run(testcase.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sig *signal
|
||||
var sig *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
sig = c.Signal(testcase.given)
|
||||
@@ -57,7 +56,7 @@ func TestSignalReturnAsStringComplexTypes(t *testing.T) {
|
||||
for _, testcase := range testcases {
|
||||
t.Run(testcase.desc, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var sig *signal
|
||||
var sig *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
c.View(func() h.H { return nil })
|
||||
|
||||
143
static_test.go
Normal file
143
static_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStatic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644)
|
||||
os.WriteFile(filepath.Join(dir, "sub", "nested.txt"), []byte("nested"), 0644)
|
||||
|
||||
v := New()
|
||||
v.Static("/assets/", dir)
|
||||
|
||||
t.Run("serves file", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/assets/hello.txt", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "hello world", w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("serves nested file", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/assets/sub/nested.txt", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "nested", w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/assets/", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("subdirectory listing returns 404", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/assets/sub/", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing file returns 404", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/assets/nope.txt", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaticAutoSlash(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "ok.txt"), []byte("ok"), 0644)
|
||||
|
||||
v := New()
|
||||
v.Static("/files", dir) // no trailing slash
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/files/ok.txt", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "ok", w.Body.String())
|
||||
}
|
||||
|
||||
func TestStaticFS(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"style.css": {Data: []byte("body{}")},
|
||||
"js/app.js": {Data: []byte("console.log('hi')")},
|
||||
}
|
||||
|
||||
v := New()
|
||||
v.StaticFS("/static/", fsys)
|
||||
|
||||
t.Run("serves file", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/static/style.css", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "body{}", w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("serves nested file", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/static/js/app.js", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "console.log('hi')", w.Body.String())
|
||||
})
|
||||
|
||||
t.Run("directory listing returns 404", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/static/", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
|
||||
t.Run("missing file returns 404", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/static/nope.css", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaticFSAutoSlash(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"ok.txt": {Data: []byte("ok")},
|
||||
}
|
||||
|
||||
v := New()
|
||||
v.StaticFS("/embed", fsys) // no trailing slash
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/embed/ok.txt", nil)
|
||||
v.mux.ServeHTTP(w, r)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "ok", w.Body.String())
|
||||
}
|
||||
|
||||
// Verify StaticFS accepts the fs.FS interface (compile-time check).
|
||||
var _ fs.FS = fstest.MapFS{}
|
||||
468
via.go
468
via.go
@@ -7,21 +7,27 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
ossignal "os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/starfederation/datastar-go/datastar"
|
||||
)
|
||||
@@ -29,68 +35,79 @@ import (
|
||||
//go:embed datastar.js
|
||||
var datastarJS []byte
|
||||
|
||||
//go:embed navigate.js
|
||||
var navigateJS []byte
|
||||
|
||||
// V is the root application.
|
||||
// It manages page routing, user sessions, and SSE connections for live updates.
|
||||
type V struct {
|
||||
cfg Options
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
logger zerolog.Logger
|
||||
contextRegistry map[string]*Context
|
||||
contextRegistryMutex sync.RWMutex
|
||||
documentHeadIncludes []h.H
|
||||
documentFootIncludes []h.H
|
||||
devModePageInitFnMap map[string]func(*Context)
|
||||
pageRegistry map[string]func(*Context)
|
||||
sessionManager *scs.SessionManager
|
||||
pubsub PubSub
|
||||
defaultNATS *defaultNATS
|
||||
actionRateLimit RateLimitConfig
|
||||
datastarPath string
|
||||
datastarContent []byte
|
||||
datastarOnce sync.Once
|
||||
middleware []Middleware
|
||||
layout func(func() h.H) h.H
|
||||
}
|
||||
|
||||
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
|
||||
if c != nil && c.id != "" {
|
||||
evt = evt.Str("via-ctx", c.id)
|
||||
}
|
||||
return evt
|
||||
}
|
||||
|
||||
func (v *V) logFatal(format string, a ...any) {
|
||||
log.Printf("[fatal] msg=%q", fmt.Sprintf(format, a...))
|
||||
v.logEvent(v.logger.WithLevel(zerolog.FatalLevel), nil).Msgf(format, a...)
|
||||
}
|
||||
|
||||
func (v *V) logErr(c *Context, format string, a ...any) {
|
||||
cRef := ""
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
log.Printf("[error] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
v.logEvent(v.logger.Error(), c).Msgf(format, a...)
|
||||
}
|
||||
|
||||
func (v *V) logWarn(c *Context, format string, a ...any) {
|
||||
cRef := ""
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
if v.cfg.LogLvl >= LogLevelWarn {
|
||||
log.Printf("[warn] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
}
|
||||
v.logEvent(v.logger.Warn(), c).Msgf(format, a...)
|
||||
}
|
||||
|
||||
func (v *V) logInfo(c *Context, format string, a ...any) {
|
||||
cRef := ""
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
if v.cfg.LogLvl >= LogLevelInfo {
|
||||
log.Printf("[info] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
}
|
||||
v.logEvent(v.logger.Info(), c).Msgf(format, a...)
|
||||
}
|
||||
|
||||
func (v *V) logDebug(c *Context, format string, a ...any) {
|
||||
cRef := ""
|
||||
if c != nil && c.id != "" {
|
||||
cRef = fmt.Sprintf("via-ctx=%q ", c.id)
|
||||
}
|
||||
if v.cfg.LogLvl == LogLevelDebug {
|
||||
log.Printf("[debug] %smsg=%q", cRef, fmt.Sprintf(format, a...))
|
||||
v.logEvent(v.logger.Debug(), c).Msgf(format, a...)
|
||||
}
|
||||
|
||||
func newConsoleLogger(level zerolog.Level) zerolog.Logger {
|
||||
return zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}).
|
||||
With().Timestamp().Logger().Level(level)
|
||||
}
|
||||
|
||||
// Config overrides the default configuration with the given options.
|
||||
func (v *V) Config(cfg Options) {
|
||||
if cfg.LogLvl != undefined {
|
||||
v.cfg.LogLvl = cfg.LogLvl
|
||||
if cfg.Logger != nil {
|
||||
v.logger = *cfg.Logger
|
||||
} else if cfg.LogLevel != nil || cfg.DevMode != v.cfg.DevMode {
|
||||
level := zerolog.InfoLevel
|
||||
if cfg.LogLevel != nil {
|
||||
level = *cfg.LogLevel
|
||||
}
|
||||
if cfg.DevMode {
|
||||
v.logger = newConsoleLogger(level)
|
||||
} else {
|
||||
v.logger = zerolog.New(os.Stderr).With().Timestamp().Logger().Level(level)
|
||||
}
|
||||
}
|
||||
if cfg.DocumentTitle != "" {
|
||||
v.cfg.DocumentTitle = cfg.DocumentTitle
|
||||
@@ -117,6 +134,16 @@ func (v *V) Config(cfg Options) {
|
||||
if cfg.DatastarPath != "" {
|
||||
v.datastarPath = cfg.DatastarPath
|
||||
}
|
||||
if cfg.PubSub != nil {
|
||||
v.defaultNATS = nil
|
||||
v.pubsub = cfg.PubSub
|
||||
}
|
||||
if cfg.Streams != nil {
|
||||
v.cfg.Streams = cfg.Streams
|
||||
}
|
||||
if cfg.ActionRateLimit.Rate != 0 || cfg.ActionRateLimit.Burst != 0 {
|
||||
v.actionRateLimit = cfg.ActionRateLimit
|
||||
}
|
||||
}
|
||||
|
||||
// AppendToHead appends the given h.H nodes to the head of the base HTML document.
|
||||
@@ -150,8 +177,16 @@ func (v *V) AppendToFoot(elements ...h.H) {
|
||||
// })
|
||||
// })
|
||||
func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
wrapped := chainMiddleware(v.middleware, initContextFn)
|
||||
v.page(route, initContextFn, wrapped)
|
||||
}
|
||||
|
||||
// page registers a route with separate raw and wrapped init functions.
|
||||
// raw is used for the panic-check at registration time; wrapped includes
|
||||
// any middleware and is used as the live handler.
|
||||
func (v *V) page(route string, raw, wrapped func(*Context)) {
|
||||
v.ensureDatastarHandler()
|
||||
// check for panics
|
||||
// check for panics using the raw handler (no middleware)
|
||||
func() {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
@@ -160,14 +195,14 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
}
|
||||
}()
|
||||
c := newContext("", "", v)
|
||||
initContextFn(c)
|
||||
raw(c)
|
||||
c.view()
|
||||
c.stopAllRoutines()
|
||||
}()
|
||||
|
||||
// save page init function allows devmode to restore persisted ctx later
|
||||
v.pageRegistry[route] = wrapped
|
||||
if v.cfg.DevMode {
|
||||
v.devModePageInitFnMap[route] = initContextFn
|
||||
v.devModePageInitFnMap[route] = wrapped
|
||||
}
|
||||
v.mux.HandleFunc("GET "+route, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v.logDebug(nil, "GET %s", r.URL.String())
|
||||
@@ -181,7 +216,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
c.reqCtx = r.Context()
|
||||
routeParams := extractParams(route, r.URL.Path)
|
||||
c.injectRouteParams(routeParams)
|
||||
initContextFn(c)
|
||||
wrapped(c)
|
||||
v.registerCtx(c)
|
||||
if v.cfg.DevMode {
|
||||
v.devModePersist(c)
|
||||
@@ -189,10 +224,12 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
headElements := []h.H{h.Script(h.Type("module"), h.Src(v.datastarPath))}
|
||||
headElements = append(headElements, v.documentHeadIncludes...)
|
||||
headElements = append(headElements,
|
||||
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
|
||||
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s','via-csrf':'%s'}", id, c.csrfToken))),
|
||||
h.Meta(h.Data("init", "@get('/_sse')")),
|
||||
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
|
||||
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
|
||||
h.Meta(h.Attr("name", "view-transition"), h.Attr("content", "same-origin")),
|
||||
h.Script(h.Raw(string(navigateJS))),
|
||||
)
|
||||
|
||||
bodyElements := []h.H{c.view()}
|
||||
@@ -206,7 +243,6 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
Title: v.cfg.DocumentTitle,
|
||||
Head: headElements,
|
||||
Body: bodyElements,
|
||||
HTMLAttrs: []h.H{},
|
||||
})
|
||||
_ = view.Render(w)
|
||||
}))
|
||||
@@ -215,17 +251,17 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
|
||||
func (v *V) registerCtx(c *Context) {
|
||||
v.contextRegistryMutex.Lock()
|
||||
defer v.contextRegistryMutex.Unlock()
|
||||
if c == nil {
|
||||
v.logErr(c, "failed to add nil context to registry")
|
||||
return
|
||||
}
|
||||
v.contextRegistry[c.id] = c
|
||||
v.logDebug(c, "new context added to registry")
|
||||
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
|
||||
v.logDebug(nil, "number of sessions in registry: %d", len(v.contextRegistry))
|
||||
}
|
||||
|
||||
func (v *V) currSessionNum() int {
|
||||
return len(v.contextRegistry)
|
||||
func (v *V) cleanupCtx(c *Context) {
|
||||
c.dispose()
|
||||
if v.cfg.DevMode {
|
||||
v.devModeRemovePersisted(c)
|
||||
}
|
||||
v.unregisterCtx(c)
|
||||
}
|
||||
|
||||
func (v *V) unregisterCtx(c *Context) {
|
||||
@@ -237,7 +273,7 @@ func (v *V) unregisterCtx(c *Context) {
|
||||
defer v.contextRegistryMutex.Unlock()
|
||||
v.logDebug(c, "ctx removed from registry")
|
||||
delete(v.contextRegistry, c.id)
|
||||
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
|
||||
v.logDebug(nil, "number of sessions in registry: %d", len(v.contextRegistry))
|
||||
}
|
||||
|
||||
func (v *V) getCtx(id string) (*Context, error) {
|
||||
@@ -249,14 +285,95 @@ func (v *V) getCtx(id string) (*Context, error) {
|
||||
return nil, fmt.Errorf("ctx '%s' not found", id)
|
||||
}
|
||||
|
||||
// Start starts the Via HTTP server on the given address.
|
||||
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
|
||||
// signal is received, then performs a graceful shutdown.
|
||||
func (v *V) Start() {
|
||||
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
||||
if v.pubsub == nil {
|
||||
dn, err := getSharedNATS()
|
||||
if err != nil {
|
||||
v.logWarn(nil, "embedded NATS unavailable: %v", err)
|
||||
} else {
|
||||
v.defaultNATS = dn
|
||||
v.pubsub = &natsRef{dn: dn}
|
||||
}
|
||||
}
|
||||
|
||||
for _, sc := range v.cfg.Streams {
|
||||
if err := EnsureStream(v, sc); err != nil {
|
||||
v.logger.Fatal().Err(err).Msgf("failed to create stream %q", sc.Name)
|
||||
}
|
||||
}
|
||||
|
||||
handler := http.Handler(v.mux)
|
||||
if v.sessionManager != nil {
|
||||
handler = v.sessionManager.LoadAndSave(v.mux)
|
||||
}
|
||||
log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, handler))
|
||||
v.server = &http.Server{
|
||||
Addr: v.cfg.ServerAddress,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- v.server.ListenAndServe()
|
||||
}()
|
||||
|
||||
v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
ossignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
v.logInfo(nil, "received signal %v, shutting down", sig)
|
||||
case err := <-errCh:
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
v.logger.Fatal().Err(err).Msg("http server failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
v.Shutdown()
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server and all contexts.
|
||||
// Safe for programmatic or test use.
|
||||
func (v *V) Shutdown() {
|
||||
v.logInfo(nil, "draining all contexts")
|
||||
v.drainAllContexts()
|
||||
|
||||
if v.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := v.server.Shutdown(ctx); err != nil {
|
||||
v.logErr(nil, "http server shutdown error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if v.pubsub != nil {
|
||||
if err := v.pubsub.Close(); err != nil {
|
||||
v.logErr(nil, "pubsub close error: %v", err)
|
||||
}
|
||||
}
|
||||
v.defaultNATS = nil
|
||||
|
||||
v.logInfo(nil, "shutdown complete")
|
||||
}
|
||||
|
||||
func (v *V) drainAllContexts() {
|
||||
v.contextRegistryMutex.Lock()
|
||||
contexts := make([]*Context, 0, len(v.contextRegistry))
|
||||
for _, c := range v.contextRegistry {
|
||||
contexts = append(contexts, c)
|
||||
}
|
||||
v.contextRegistry = make(map[string]*Context)
|
||||
v.contextRegistryMutex.Unlock()
|
||||
|
||||
for _, c := range contexts {
|
||||
v.logDebug(c, "disposing context")
|
||||
c.dispose()
|
||||
}
|
||||
v.logInfo(nil, "drained %d context(s)", len(contexts))
|
||||
}
|
||||
|
||||
// HTTPServeMux returns the underlying HTTP request multiplexer to enable user extentions, middleware and
|
||||
@@ -268,6 +385,51 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
||||
return v.mux
|
||||
}
|
||||
|
||||
// PubSub returns the configured PubSub backend, or nil if none is set.
|
||||
func (v *V) PubSub() PubSub {
|
||||
return v.pubsub
|
||||
}
|
||||
|
||||
// Static serves files from a filesystem directory at the given URL prefix.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// v.Static("/assets/", "./public")
|
||||
func (v *V) Static(urlPrefix, dir string) {
|
||||
if !strings.HasSuffix(urlPrefix, "/") {
|
||||
urlPrefix += "/"
|
||||
}
|
||||
fileServer := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||
}
|
||||
|
||||
// StaticFS serves files from an [fs.FS] at the given URL prefix.
|
||||
// This is useful with //go:embed filesystems.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// //go:embed static
|
||||
// var staticFiles embed.FS
|
||||
// v.StaticFS("/assets/", staticFiles)
|
||||
func (v *V) StaticFS(urlPrefix string, fsys fs.FS) {
|
||||
if !strings.HasSuffix(urlPrefix, "/") {
|
||||
urlPrefix += "/"
|
||||
}
|
||||
fileServer := http.StripPrefix(urlPrefix, http.FileServerFS(fsys))
|
||||
v.mux.Handle("GET "+urlPrefix, noDirListing(fileServer))
|
||||
}
|
||||
|
||||
// noDirListing wraps a file server handler to return 404 for directory requests.
|
||||
func noDirListing(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (v *V) ensureDatastarHandler() {
|
||||
v.datastarOnce.Do(func() {
|
||||
v.mux.HandleFunc("GET "+v.datastarPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -277,36 +439,39 @@ func (v *V) ensureDatastarHandler() {
|
||||
})
|
||||
}
|
||||
|
||||
func loadDevModeMap(path string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
defer file.Close()
|
||||
json.NewDecoder(file).Decode(&m)
|
||||
return m
|
||||
}
|
||||
|
||||
func saveDevModeMap(path string, m map[string]string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
return json.NewEncoder(file).Encode(m)
|
||||
}
|
||||
|
||||
func (v *V) devModePersist(c *Context) {
|
||||
p := filepath.Join(".via", "devmode", "ctx.json")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||
log.Fatalf("failed to create directory for devmode files: %v", err)
|
||||
v.logFatal("failed to create directory for devmode files: %v", err)
|
||||
}
|
||||
|
||||
// load persisted list from file, or empty list if file not found
|
||||
file, err := os.Open(p)
|
||||
ctxRegMap := make(map[string]string)
|
||||
if err == nil {
|
||||
json.NewDecoder(file).Decode(&ctxRegMap)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// add ctx to persisted list
|
||||
ctxRegMap := loadDevModeMap(p)
|
||||
if _, ok := ctxRegMap[c.id]; !ok {
|
||||
ctxRegMap[c.id] = c.route
|
||||
}
|
||||
|
||||
// write persisted list to file
|
||||
file, err = os.Create(p)
|
||||
if err != nil {
|
||||
v.logErr(c, "devmode failed to percist ctx: %v", err)
|
||||
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
if err := encoder.Encode(ctxRegMap); err != nil {
|
||||
v.logErr(c, "devmode failed to persist ctx")
|
||||
if err := saveDevModeMap(p, ctxRegMap); err != nil {
|
||||
v.logErr(c, "devmode failed to persist ctx: %v", err)
|
||||
}
|
||||
v.logDebug(c, "devmode persisted ctx to file")
|
||||
}
|
||||
@@ -314,30 +479,11 @@ func (v *V) devModePersist(c *Context) {
|
||||
func (v *V) devModeRemovePersisted(c *Context) {
|
||||
p := filepath.Join(".via", "devmode", "ctx.json")
|
||||
|
||||
// load persisted list from file, or empty list if file not found
|
||||
file, err := os.Open(p)
|
||||
ctxRegMap := make(map[string]string)
|
||||
if err == nil {
|
||||
json.NewDecoder(file).Decode(&ctxRegMap)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// remove ctx to persisted list
|
||||
if _, ok := ctxRegMap[c.id]; !ok {
|
||||
ctxRegMap := loadDevModeMap(p)
|
||||
delete(ctxRegMap, c.id)
|
||||
}
|
||||
|
||||
// write persisted list to file
|
||||
file, err = os.Create(p)
|
||||
if err != nil {
|
||||
v.logErr(c, "devmode failed to remove percisted ctx: %v", err)
|
||||
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
if err := encoder.Encode(ctxRegMap); err != nil {
|
||||
v.logErr(c, "devmode failed to remove persisted ctx")
|
||||
if err := saveDevModeMap(p, ctxRegMap); err != nil {
|
||||
v.logErr(c, "devmode failed to remove persisted ctx: %v", err)
|
||||
}
|
||||
v.logDebug(c, "devmode removed persisted ctx from file")
|
||||
}
|
||||
@@ -377,6 +523,7 @@ type patchType int
|
||||
|
||||
const (
|
||||
patchTypeElements = iota
|
||||
patchTypeElementsWithVT
|
||||
patchTypeSignals
|
||||
patchTypeScript
|
||||
patchTypeRedirect
|
||||
@@ -394,15 +541,16 @@ func New() *V {
|
||||
|
||||
v := &V{
|
||||
mux: mux,
|
||||
logger: newConsoleLogger(zerolog.InfoLevel),
|
||||
contextRegistry: make(map[string]*Context),
|
||||
devModePageInitFnMap: make(map[string]func(*Context)),
|
||||
pageRegistry: make(map[string]func(*Context)),
|
||||
sessionManager: scs.New(),
|
||||
datastarPath: "/_datastar.js",
|
||||
datastarContent: datastarJS,
|
||||
cfg: Options{
|
||||
DevMode: false,
|
||||
ServerAddress: ":3000",
|
||||
LogLvl: LogLevelInfo,
|
||||
DocumentTitle: "⚡ Via",
|
||||
},
|
||||
}
|
||||
@@ -419,7 +567,9 @@ func New() *V {
|
||||
}
|
||||
c, err := v.getCtx(cID)
|
||||
if err != nil {
|
||||
v.logErr(nil, "sse stream failed to start: %v", err)
|
||||
v.logInfo(nil, "context expired, reloading client: %s", cID)
|
||||
sse := datastar.NewSSE(w, r)
|
||||
sse.ExecuteScript("window.location.reload()")
|
||||
return
|
||||
}
|
||||
c.reqCtx = r.Context()
|
||||
@@ -429,29 +579,53 @@ func New() *V {
|
||||
// use last-event-id to tell if request is a sse reconnect
|
||||
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
|
||||
|
||||
// Drain stale patches on reconnect so the client gets fresh state
|
||||
if c.sseDisconnectedAt.Load() != nil {
|
||||
for {
|
||||
select {
|
||||
case <-c.patchChan:
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
}
|
||||
c.sseConnected.Store(true)
|
||||
c.sseDisconnectedAt.Store(nil)
|
||||
v.logDebug(c, "SSE connection established")
|
||||
|
||||
go func() {
|
||||
c.Sync()
|
||||
}()
|
||||
go c.Sync()
|
||||
|
||||
keepalive := time.NewTicker(30 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sse.Context().Done():
|
||||
v.logDebug(c, "SSE connection ended")
|
||||
c.sseConnected.Store(false)
|
||||
dcNow := time.Now()
|
||||
c.sseDisconnectedAt.Store(&dcNow)
|
||||
return
|
||||
case patch, ok := <-c.patchChan:
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
case <-c.ctxDisposedChan:
|
||||
v.logDebug(c, "context disposed, closing SSE")
|
||||
return
|
||||
case <-keepalive.C:
|
||||
sse.PatchSignals([]byte("{}"))
|
||||
case patch := <-c.patchChan:
|
||||
switch patch.typ {
|
||||
case patchTypeElements:
|
||||
if err := sse.PatchElements(patch.content); err != nil {
|
||||
// Only log if connection wasn't closed (avoids noise during shutdown/tests)
|
||||
if sse.Context().Err() == nil {
|
||||
v.logErr(c, "PatchElements failed: %v", err)
|
||||
}
|
||||
}
|
||||
case patchTypeElementsWithVT:
|
||||
if err := sse.PatchElements(patch.content, datastar.WithViewTransitions()); err != nil {
|
||||
if sse.Context().Err() == nil {
|
||||
v.logErr(c, "PatchElements (view transition) failed: %v", err)
|
||||
}
|
||||
}
|
||||
case patchTypeSignals:
|
||||
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
|
||||
if sse.Context().Err() == nil {
|
||||
@@ -494,13 +668,29 @@ func New() *V {
|
||||
v.logErr(nil, "action '%s' failed: %v", actionID, err)
|
||||
return
|
||||
}
|
||||
csrfToken, _ := sigs["via-csrf"].(string)
|
||||
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
||||
v.logWarn(c, "action '%s' rejected: invalid CSRF token", actionID)
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
||||
v.logWarn(c, "action '%s' rate limited", actionID)
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
c.reqCtx = r.Context()
|
||||
actionFn, err := c.getActionFn(actionID)
|
||||
entry, err := c.getAction(actionID)
|
||||
if err != nil {
|
||||
v.logDebug(c, "action '%s' failed: %v", actionID, err)
|
||||
return
|
||||
}
|
||||
// log err if actionFn panics
|
||||
if entry.limiter != nil && !entry.limiter.Allow() {
|
||||
v.logWarn(c, "action '%s' rate limited (per-action)", actionID)
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// log err if action panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
v.logErr(c, "action '%s' failed: %v", actionID, r)
|
||||
@@ -508,13 +698,50 @@ func New() *V {
|
||||
}()
|
||||
|
||||
c.injectSignals(sigs)
|
||||
actionFn()
|
||||
if len(entry.middleware) > 0 {
|
||||
chainMiddleware(entry.middleware, func(_ *Context) { entry.fn() })(c)
|
||||
} else {
|
||||
entry.fn()
|
||||
}
|
||||
})
|
||||
|
||||
v.mux.HandleFunc("POST /_navigate", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
cID := r.FormValue("via-ctx")
|
||||
csrfToken := r.FormValue("via-csrf")
|
||||
navURL := r.FormValue("url")
|
||||
popstate := r.FormValue("popstate") == "1"
|
||||
|
||||
if cID == "" || navURL == "" || !strings.HasPrefix(navURL, "/") {
|
||||
http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
c, err := v.getCtx(cID)
|
||||
if err != nil {
|
||||
v.logErr(nil, "navigate failed: %v", err)
|
||||
http.Error(w, "context not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
|
||||
v.logWarn(c, "navigate rejected: invalid CSRF token")
|
||||
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if c.actionLimiter != nil && !c.actionLimiter.Allow() {
|
||||
v.logWarn(c, "navigate rate limited")
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
c.reqCtx = r.Context()
|
||||
v.logDebug(c, "SPA navigate to %s", navURL)
|
||||
c.Navigate(navURL, popstate)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading body: %v", err)
|
||||
v.logErr(nil, "error reading body: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -525,20 +752,23 @@ func New() *V {
|
||||
v.logErr(c, "failed to handle session close: %v", err)
|
||||
return
|
||||
}
|
||||
c.stopAllRoutines()
|
||||
v.logDebug(c, "session close event triggered")
|
||||
if v.cfg.DevMode {
|
||||
v.devModeRemovePersisted(c)
|
||||
}
|
||||
v.unregisterCtx(c)
|
||||
v.cleanupCtx(c)
|
||||
})
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func genRandID() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func genCSRFToken() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)[:8]
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func extractParams(pattern, path string) map[string]string {
|
||||
@@ -553,8 +783,24 @@ func extractParams(pattern, path string) map[string]string {
|
||||
key := p[i][1 : len(p[i])-1] // remove {}
|
||||
params[key] = u[i]
|
||||
} else if p[i] != u[i] {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// matchRoute finds the registered page init function and extracted params for the given path.
|
||||
func (v *V) matchRoute(path string) (route string, initFn func(*Context), params map[string]string) {
|
||||
for pattern, fn := range v.pageRegistry {
|
||||
if p := extractParams(pattern, path); p != nil {
|
||||
return pattern, fn, p
|
||||
}
|
||||
}
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Layout sets a layout function that wraps every page's view.
|
||||
// The layout receives the page content as a function and returns the full view.
|
||||
func (v *V) Layout(f func(func() h.H) h.H) {
|
||||
v.layout = f
|
||||
}
|
||||
|
||||
320
via_test.go
320
via_test.go
@@ -1,9 +1,13 @@
|
||||
package via
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ryanhamamura/via/h"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -86,7 +90,7 @@ func TestCustomDatastarPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSignal(t *testing.T) {
|
||||
var sig *signal
|
||||
var sig *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
sig = c.Signal("test")
|
||||
@@ -102,7 +106,7 @@ func TestSignal(t *testing.T) {
|
||||
|
||||
func TestAction(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
var sig *signal
|
||||
var sig *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
@@ -123,11 +127,266 @@ func TestAction(t *testing.T) {
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:click")
|
||||
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
||||
assert.Contains(t, body, "data-on:change")
|
||||
assert.Contains(t, body, "data-on:keydown")
|
||||
assert.Contains(t, body, "/_action/")
|
||||
}
|
||||
|
||||
func TestEventTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attr string
|
||||
buildEl func(trigger *actionTrigger) h.H
|
||||
}{
|
||||
{"OnSubmit", "data-on:submit", func(tr *actionTrigger) h.H { return h.Form(tr.OnSubmit()) }},
|
||||
{"OnInput", "data-on:input", func(tr *actionTrigger) h.H { return h.Input(tr.OnInput()) }},
|
||||
{"OnFocus", "data-on:focus", func(tr *actionTrigger) h.H { return h.Input(tr.OnFocus()) }},
|
||||
{"OnBlur", "data-on:blur", func(tr *actionTrigger) h.H { return h.Input(tr.OnBlur()) }},
|
||||
{"OnMouseEnter", "data-on:mouseenter", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseEnter()) }},
|
||||
{"OnMouseLeave", "data-on:mouseleave", func(tr *actionTrigger) h.H { return h.Div(tr.OnMouseLeave()) }},
|
||||
{"OnScroll", "data-on:scroll", func(tr *actionTrigger) h.H { return h.Div(tr.OnScroll()) }},
|
||||
{"OnDblClick", "data-on:dblclick", func(tr *actionTrigger) h.H { return h.Div(tr.OnDblClick()) }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H { return tt.buildEl(trigger) })
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, tt.attr)
|
||||
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("WithSignal", func(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
var sig *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
sig = c.Signal("val")
|
||||
c.View(func() h.H {
|
||||
return h.Div(trigger.OnDblClick(WithSignal(sig, "x")))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:dblclick")
|
||||
assert.Contains(t, body, "$"+sig.ID()+"='x'")
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnKeyDownWithWindow(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Div(trigger.OnKeyDown("Enter", WithWindow()))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:keydown__window")
|
||||
assert.Contains(t, body, "evt.key==='Enter'")
|
||||
}
|
||||
|
||||
func TestOnKeyDownMap(t *testing.T) {
|
||||
t.Run("multiple bindings with different actions", func(t *testing.T) {
|
||||
var move, shoot *actionTrigger
|
||||
var dir *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
dir = c.Signal("none")
|
||||
move = c.Action(func() {})
|
||||
shoot = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
OnKeyDownMap(
|
||||
KeyBind("w", move, WithSignal(dir, "up")),
|
||||
KeyBind("ArrowUp", move, WithSignal(dir, "up"), WithPreventDefault()),
|
||||
KeyBind(" ", shoot, WithPreventDefault()),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
|
||||
// Single attribute, window-scoped
|
||||
assert.Contains(t, body, "data-on:keydown__window")
|
||||
|
||||
// Key dispatching
|
||||
assert.Contains(t, body, "evt.key==='w'")
|
||||
assert.Contains(t, body, "evt.key==='ArrowUp'")
|
||||
assert.Contains(t, body, "evt.key===' '")
|
||||
|
||||
// Different actions referenced
|
||||
assert.Contains(t, body, "/_action/"+move.id)
|
||||
assert.Contains(t, body, "/_action/"+shoot.id)
|
||||
|
||||
// preventDefault only on ArrowUp and space branches
|
||||
assert.Contains(t, body, "evt.key==='ArrowUp' ? (evt.preventDefault()")
|
||||
assert.Contains(t, body, "evt.key===' ' ? (evt.preventDefault()")
|
||||
|
||||
// 'w' branch should NOT have preventDefault
|
||||
assert.NotContains(t, body, "evt.key==='w' ? (evt.preventDefault()")
|
||||
})
|
||||
|
||||
t.Run("WithSignal per binding", func(t *testing.T) {
|
||||
var move *actionTrigger
|
||||
var dir *Signal
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
dir = c.Signal("none")
|
||||
move = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Div(
|
||||
OnKeyDownMap(
|
||||
KeyBind("w", move, WithSignal(dir, "up")),
|
||||
KeyBind("s", move, WithSignal(dir, "down")),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
|
||||
assert.Contains(t, body, "$"+dir.ID()+"='up'")
|
||||
assert.Contains(t, body, "$"+dir.ID()+"='down'")
|
||||
})
|
||||
|
||||
t.Run("empty bindings returns nil", func(t *testing.T) {
|
||||
result := OnKeyDownMap()
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{200 * time.Millisecond, "200ms"},
|
||||
{1 * time.Second, "1000ms"},
|
||||
{50 * time.Millisecond, "50ms"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, formatDuration(tt.d))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAttrKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event string
|
||||
opts triggerOpts
|
||||
want string
|
||||
}{
|
||||
{"bare event", "click", triggerOpts{}, "on:click"},
|
||||
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
|
||||
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
|
||||
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
|
||||
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
|
||||
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithDebounce(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:click__debounce.300ms")
|
||||
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||
}
|
||||
|
||||
func TestWithThrottle(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Div(trigger.OnScroll(WithThrottle(100 * time.Millisecond)))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
|
||||
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||
}
|
||||
|
||||
func TestWithDebounceOnChange(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:change__debounce.200ms")
|
||||
assert.Contains(t, body, "/_action/"+trigger.id)
|
||||
}
|
||||
|
||||
func TestDebounceWithWindow(t *testing.T) {
|
||||
var trigger *actionTrigger
|
||||
v := New()
|
||||
v.Page("/", func(c *Context) {
|
||||
trigger = c.Action(func() {})
|
||||
c.View(func() h.H {
|
||||
return h.Div(trigger.OnKeyDown("Enter", WithDebounce(150*time.Millisecond), WithWindow()))
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
v.mux.ServeHTTP(w, req)
|
||||
body := w.Body.String()
|
||||
assert.Contains(t, body, "data-on:keydown__debounce.150ms__window")
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
v := New()
|
||||
v.Config(Options{DocumentTitle: "Test"})
|
||||
@@ -140,3 +399,58 @@ func TestPage_PanicsOnNoView(t *testing.T) {
|
||||
v.Page("/", func(c *Context) {})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCleanupCtxIdempotent(t *testing.T) {
|
||||
v := New()
|
||||
c := newContext("idempotent-1", "/", v)
|
||||
v.registerCtx(c)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
v.cleanupCtx(c)
|
||||
v.cleanupCtx(c)
|
||||
})
|
||||
|
||||
_, err := v.getCtx("idempotent-1")
|
||||
assert.Error(t, err, "context should be removed after cleanup")
|
||||
}
|
||||
|
||||
func TestDevModeRemovePersistedFix(t *testing.T) {
|
||||
v := New()
|
||||
v.cfg.DevMode = true
|
||||
|
||||
dir := filepath.Join(t.TempDir(), ".via", "devmode")
|
||||
p := filepath.Join(dir, "ctx.json")
|
||||
assert.NoError(t, os.MkdirAll(dir, 0755))
|
||||
|
||||
// Write a persisted context
|
||||
ctxRegMap := map[string]string{"test-ctx-1": "/"}
|
||||
f, err := os.Create(p)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, json.NewEncoder(f).Encode(ctxRegMap))
|
||||
f.Close()
|
||||
|
||||
// Patch devModeRemovePersisted to use our temp path by calling it
|
||||
// directly — we need to override the path. Instead, test via the
|
||||
// actual function by temporarily changing the working dir.
|
||||
origDir, _ := os.Getwd()
|
||||
assert.NoError(t, os.Chdir(t.TempDir()))
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// Re-create the structure in the temp dir
|
||||
assert.NoError(t, os.MkdirAll(filepath.Join(".via", "devmode"), 0755))
|
||||
p2 := filepath.Join(".via", "devmode", "ctx.json")
|
||||
f2, _ := os.Create(p2)
|
||||
json.NewEncoder(f2).Encode(map[string]string{"test-ctx-1": "/"})
|
||||
f2.Close()
|
||||
|
||||
c := newContext("test-ctx-1", "/", v)
|
||||
v.devModeRemovePersisted(c)
|
||||
|
||||
// Read back and verify
|
||||
f3, err := os.Open(p2)
|
||||
assert.NoError(t, err)
|
||||
defer f3.Close()
|
||||
var result map[string]string
|
||||
assert.NoError(t, json.NewDecoder(f3).Decode(&result))
|
||||
assert.Empty(t, result, "persisted context should be removed")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user