1 Commits

Author SHA1 Message Date
Ryan Hamamura
b3c2d3ae32 fix: remove context reaper to prevent background tabs from going stale
Some checks failed
CI / Build and Test (push) Failing after 35s
CI / Build and Test (pull_request) Failing after 35s
Background windows stopped updating because the reaper suspended contexts
after ContextSuspendAfter and fully reaped them after ContextTTL. Suspended
contexts had to re-run the page init function from scratch on reconnect,
losing the live-updating experience.

Contexts now live until the browser tab closes (beforeunload beacon) or
the server shuts down. The context map grows indefinitely — no background
reaper.

Removes: startReaper, reapOrphanedContexts, suspend/resume logic,
ContextSuspendAfter/ContextTTL config fields, lastSeenAt/suspended
context fields, and all associated tests.
2026-02-20 09:09:05 -10:00
25 changed files with 163 additions and 616 deletions

View File

@@ -5,10 +5,10 @@ Create a PR on Gitea, wait for CI, and squash-merge it. Push code to both remote
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 --login gitea --repo ryan/via --head <branch> --base main`. Reference related issues with `#X`. Only use `Closes #X` if the PR fully resolves the issue.
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 --login gitea --repo ryan/via <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`.
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: `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.

View File

@@ -16,5 +16,5 @@ Create a new release on Gitea. Push tags to both remotes.
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 --login gitea --repo ryan/via` using the notes.
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.

18
.gitignore vendored
View File

@@ -37,15 +37,21 @@ go.work.sum
# Air artifacts
*tmp/
# Example binaries and data files
internal/examples/*/[a-z]*[!.go]
internal/examples/shakespeare/shake.db
# 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
internal/examples/nats-chatroom/nats-chatroom
/nats-chatroom
# NATS data directory
data/
# Standalone experiments
nats-chatroom
# Claude Code worktrees
.claude/worktrees/

View File

@@ -74,13 +74,14 @@ func main() {
- **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
- **Context lifecycle** — background reaper cleans up disconnected contexts; configurable TTL
- **HTML DSL** — the `h` package provides type-safe Go-native HTML composition
## Examples
The `internal/examples/` directory contains 19 runnable examples:
The `internal/examples/` directory contains 14 runnable examples:
`chatroom` · `counter` · `countercomp` · `effectspike` · `greeter` · `keyboard` · `livereload` · `maplibre` · `middleware` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare` · `signup` · `spa`
`chatroom` · `counter` · `countercomp` · `greeter` · `keyboard` · `livereload` · `nats-chatroom` · `pathparams` · `picocss` · `plugins` · `pubsub-crud` · `realtimechart` · `session` · `shakespeare`
## Experimental

View File

@@ -6,13 +6,9 @@ set -o pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
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: Format code =="
go fmt ./...
echo "OK: formatting complete"
echo "== CI: Run go vet =="
if ! go vet ./...; then

View File

@@ -1,6 +1,7 @@
package via
import (
"fmt"
"strconv"
"strings"
@@ -50,5 +51,5 @@ func (s *computedSignal) recompute() {
}
func (s *computedSignal) patchValue() string {
return s.lastVal
return fmt.Sprintf("%v", s.lastVal)
}

View File

@@ -25,7 +25,7 @@ type Context struct {
app *V
view func() h.H
routeParams map[string]string
parentPageCtx *Context
parentPageCtx *Context
patchChan chan patch
actionLimiter *rate.Limiter
actionRegistry map[string]actionEntry
@@ -39,9 +39,9 @@ type Context struct {
subscriptions []Subscription
subsMu sync.Mutex
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
}
// View defines the UI rendered by this context.
@@ -630,17 +630,17 @@ func newContext(id string, route string, v *V) *Context {
}
return &Context{
id: id,
route: route,
csrfToken: genCSRFToken(),
routeParams: make(map[string]string),
app: v,
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(),
id: id,
route: route,
csrfToken: genCSRFToken(),
routeParams: make(map[string]string),
app: v,
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
pageStopChan: make(chan struct{}),
createdAt: time.Now(),
}
}

View File

@@ -94,7 +94,7 @@ Available triggers:
|--------|-------|-------|
| `OnClick()` | `click` | |
| `OnDblClick()` | `dblclick` | |
| `OnChange()` | `change` | |
| `OnChange()` | `change` | 200ms debounce |
| `OnInput()` | `input` | No debounce |
| `OnSubmit()` | `submit` | |
| `OnKeyDown(key)` | `keydown` | Filtered by key name (e.g. `"Enter"`, `"Escape"`) |

2
h/h.go
View File

@@ -5,7 +5,7 @@
//
// h.Div(
// h.H1(h.Text("Hello, Via")),
// h.P(h.Text("Pure Go. No templates.")),
// h.P(h.Text("Pure Go. No tmplates.")),
// )
package h

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h"
)
@@ -14,6 +15,9 @@ func main() {
DocumentTitle: "Live Reload Demo",
DevMode: true,
LogLevel: via.LogLevelDebug,
Plugins: []via.Plugin{
// picocss.Default
},
})
v.Page("/", func(c *via.Context) {

View File

@@ -1,11 +1,7 @@
package main
import (
"fmt"
"math"
"math/rand"
"strconv"
"sync"
"time"
"github.com/ryanhamamura/via"
@@ -13,135 +9,13 @@ import (
"github.com/ryanhamamura/via/maplibre"
)
type posMsg struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
}
var (
vehicleOnce sync.Once
vehicle struct {
mu sync.RWMutex
lng, lat float64
}
)
// shipState tracks a ship lerping along a loop of waypoints.
type shipState struct {
lng, lat float64
waypoints [][2]float64 // [lng, lat] pairs
wpIdx int // index of next target waypoint
progress float64 // 0..1 toward next waypoint
speed float64 // progress increment per tick
}
func (s *shipState) tick() {
s.progress += s.speed
for s.progress >= 1 {
s.progress -= 1
s.wpIdx = (s.wpIdx + 1) % len(s.waypoints)
}
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
s.lng = from[0] + (to[0]-from[0])*s.progress
s.lat = from[1] + (to[1]-from[1])*s.progress
}
// heading returns clockwise degrees from north (for SVG rotation).
func (s *shipState) heading() float64 {
from := s.waypoints[(s.wpIdx-1+len(s.waypoints))%len(s.waypoints)]
to := s.waypoints[s.wpIdx]
dx := to[0] - from[0]
dy := to[1] - from[1]
// atan2 gives angle from +X axis; convert to CW from north
return math.Mod(math.Atan2(dx, dy)*180/math.Pi+360, 360)
}
var (
fleetOnce sync.Once
fleet struct {
mu sync.RWMutex
ships [3]shipState
}
)
const shipSVG = `<svg width="48" height="28" viewBox="0 0 80 44" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M2 30 L10 42 L70 42 L78 30 Z" fill="#1b3a5c"/>` +
`<rect x="12" y="24" width="56" height="6" rx="1" fill="#2c5f8a"/>` +
`<rect x="18" y="14" width="46" height="10" rx="1" fill="#2c5f8a" stroke="#1b3a5c" stroke-width="0.5"/>` +
`<rect x="8" y="12" width="9" height="12" rx="1" fill="#d5dfe8" stroke="#2c5f8a" stroke-width="0.5"/>` +
`<rect x="9.5" y="13" width="6" height="4" rx="0.5" fill="#85c1e9"/>` +
`<rect x="10" y="4" width="5" height="8" rx="0.5" fill="#1b3a5c"/>` +
`<rect x="9.5" y="2" width="6" height="3" rx="0.5" fill="#c0392b"/>` +
`</svg>`
func main() {
v := via.New()
v.Config(via.Options{
DocumentTitle: "MapLibre GL Example",
ServerAddress: ":7331",
DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin},
ActionRateLimit: via.RateLimitConfig{Rate: 60, Burst: 120},
})
// Single goroutine moves the vehicle — all clients read the same position.
vehicle.lng = -122.43
vehicle.lat = 37.77
vehicleOnce.Do(func() {
go func() {
for {
time.Sleep(time.Second)
vehicle.mu.Lock()
vehicle.lng = -122.43 + (rand.Float64()-0.5)*0.02
vehicle.lat = 37.77 + (rand.Float64()-0.5)*0.02
vehicle.mu.Unlock()
}
}()
})
// Fleet of ships following waypoint loops through SF Bay.
fleetOnce.Do(func() {
fleet.ships = [3]shipState{
{ // Golden Gate → Alcatraz → Pier 39 → back out
waypoints: [][2]float64{
{-122.478, 37.819}, {-122.423, 37.827},
{-122.410, 37.809}, {-122.423, 37.827},
},
speed: 0.03,
},
{ // Oakland → Treasure Island → Angel Island → loop
waypoints: [][2]float64{
{-122.330, 37.795}, {-122.370, 37.823},
{-122.432, 37.860}, {-122.370, 37.823},
},
speed: 0.02,
},
{ // Sausalito → Pier 39 ferry route
waypoints: [][2]float64{
{-122.480, 37.859}, {-122.435, 37.840},
{-122.410, 37.809}, {-122.435, 37.840},
},
speed: 0.025,
},
}
// Set initial positions.
for i := range fleet.ships {
s := &fleet.ships[i]
s.lng = s.waypoints[0][0]
s.lat = s.waypoints[0][1]
s.wpIdx = 1
}
go func() {
for {
time.Sleep(time.Second)
fleet.mu.Lock()
for i := range fleet.ships {
fleet.ships[i].tick()
}
fleet.mu.Unlock()
}
}()
DocumentTitle: "MapLibre GL Example",
ServerAddress: ":7331",
DevMode: true,
Plugins: []via.Plugin{maplibre.Plugin},
})
v.Page("/", func(c *via.Context) {
@@ -159,102 +33,35 @@ func main() {
m.AddMarker("sf", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.4194, Lat: 37.7749},
Color: "#e74c3c",
Scale: 1.3,
Popup: &maplibre.Popup{
Content: "<strong>San Francisco</strong><p>The Golden City</p>",
},
})
noCloseOnClick := false
m.AddMarker("oak", maplibre.Marker{
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71",
Opacity: 0.7,
LngLat: maplibre.LngLat{Lng: -122.2711, Lat: 37.8044},
Color: "#2ecc71",
Popup: &maplibre.Popup{
Content: "<strong>Oakland</strong>",
Anchor: "bottom",
Offset: [2]float64{0, -10},
CloseOnClick: &noCloseOnClick,
Content: "<strong>Oakland</strong>",
},
})
// Animated container ships following waypoint routes
shipNames := [3]string{"MSC Adriatica", "Evergreen Harmony", "Maersk Aurora"}
type shipSignals struct{ lng, lat, rot *via.Signal }
var ships [3]shipSignals
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng = c.Signal(s.lng)
ships[i].lat = c.Signal(s.lat)
// SVG bow points right (east), so subtract 90° from the north-based heading.
ships[i].rot = c.Signal(s.heading() - 90)
m.AddMarker(fmt.Sprintf("ship-%d", i), maplibre.Marker{
LngSignal: ships[i].lng,
LatSignal: ships[i].lat,
RotationSignal: ships[i].rot,
Element: shipSVG,
Anchor: "center",
Popup: &maplibre.Popup{
Content: fmt.Sprintf("<strong>%s</strong>", shipNames[i]),
},
})
}
fleet.mu.RUnlock()
// Custom SVG vehicle marker — reads shared Go state
// 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,
Element: `<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="10" cy="10" r="9" fill="#9b59b6" stroke="#fff" stroke-width="2"/>` +
`</svg>`,
Anchor: "center",
Color: "#9b59b6",
})
c.OnInterval(time.Second, func() {
vehicle.mu.RLock()
lng, lat := vehicle.lng, vehicle.lat
vehicle.mu.RUnlock()
vehicleLng.SetValue(lng)
vehicleLat.SetValue(lat)
fleet.mu.RLock()
for i, s := range fleet.ships {
ships[i].lng.SetValue(s.lng)
ships[i].lat.SetValue(s.lat)
ships[i].rot.SetValue(s.heading() - 90)
}
fleet.mu.RUnlock()
vehicleLng.SetValue(-122.43 + (rand.Float64()-0.5)*0.02)
vehicleLat.SetValue(37.77 + (rand.Float64()-0.5)*0.02)
c.SyncSignals()
})
// Yellow click marker — synced across clients via PubSub
clickLng := c.Signal(-122.4194)
clickLat := c.Signal(37.7749)
m.AddMarker("clicked", maplibre.Marker{
LngSignal: clickLng,
LatSignal: clickLat,
Color: "#f39c12",
})
via.Subscribe(c, "map.click", func(msg posMsg) {
clickLng.SetValue(msg.Lng)
clickLat.SetValue(msg.Lat)
c.SyncSignals()
})
click := m.OnClick()
handleClick := c.Action(func() {
e := click.Data()
via.Publish(c, "map.click", posMsg{Lng: e.LngLat.Lng, Lat: e.LngLat.Lat})
})
// Blue draggable pin — synced across clients via PubSub
// Draggable marker — user drags, signals update
pinLng := c.Signal(-122.41)
pinLat := c.Signal(37.78)
@@ -265,16 +72,14 @@ func main() {
Draggable: true,
})
via.Subscribe(c, "map.pin", func(msg posMsg) {
pinLng.SetValue(msg.Lng)
pinLat.SetValue(msg.Lat)
c.SyncSignals()
})
handlePinDrag := c.Action(func() {
lng, _ := strconv.ParseFloat(pinLng.String(), 64)
lat, _ := strconv.ParseFloat(pinLat.String(), 64)
via.Publish(c, "map.pin", posMsg{Lng: lng, Lat: lat})
// 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
@@ -306,7 +111,7 @@ func main() {
},
})
// FlyTo actions
// FlyTo actions using CameraOptions
zoom14 := 14.0
flyToSF := c.Action(func() {
m.FlyTo(maplibre.CameraOptions{
@@ -329,7 +134,6 @@ func main() {
h.H1(h.Text("MapLibre GL Example")),
m.Element(
click.Input(handleClick.OnInput()),
h.Input(h.Type("hidden"), pinLng.Bind(), handlePinDrag.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()),
@@ -338,7 +142,6 @@ func main() {
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("Click: "), clickLng.Text(), h.Text(", "), clickLat.Text()),
h.P(h.Text("Vehicle: "), vehicleLng.Text(), h.Text(", "), vehicleLat.Text()),
h.P(h.Text("Draggable Pin: "), pinLng.Text(), h.Text(", "), pinLat.Text()),
),

View File

@@ -4,12 +4,17 @@ 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.

View File

@@ -2,6 +2,7 @@ package main
import (
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h"
)
@@ -12,6 +13,11 @@ 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) {

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/ryanhamamura/via"
// "github.com/go-via/via-plugin-picocss/picocss"
"github.com/ryanhamamura/via/h"
)
@@ -14,7 +15,10 @@ func main() {
v.Config(via.Options{
LogLevel: via.LogLevelDebug,
DevMode: true,
DevMode: true,
Plugins: []via.Plugin{
// picocss.Default,
},
})
v.AppendToHead(

View File

@@ -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,9 +22,6 @@ 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 {

Binary file not shown.

View File

@@ -113,19 +113,9 @@ func initScript(m *Map) string {
}
for _, me := range m.markers {
b.WriteString(markerBodyJS(m.id, me.id, me.marker))
if me.handle != nil {
for _, ev := range me.handle.events {
b.WriteString(markerEventListenerJS(m.id, ev))
}
}
}
for _, pe := range m.popups {
b.WriteString(popupBodyJS(pe.id, pe.popup))
if pe.handle != nil {
for _, ev := range pe.handle.events {
b.WriteString(popupEventListenerJS(m.id, ev))
}
}
}
for _, ce := range m.controls {
b.WriteString(controlBodyJS(ce.id, ce.ctrl))
@@ -189,51 +179,13 @@ func initScript(m *Map) 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
// Use a wrapper div for custom elements so MapLibre rotates the div
// while we can independently flip the inner element to prevent inversion.
if mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _mkEl=document.createElement('div');`+
`_mkEl.style.display='inline-block';_mkEl.style.lineHeight='0';`+
`_mkEl.innerHTML=%s;`,
jsonStr(mk.Element)))
}
opts := "{"
if mk.Element != "" {
opts += `element:_mkEl,`
} else if mk.Color != "" {
if mk.Color != "" {
opts += fmt.Sprintf(`color:%s,`, jsonStr(mk.Color))
}
if mk.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(mk.Anchor))
}
// When both Element and RotationSignal are set, skip initial rotation
// in opts — we apply it post-creation with flip normalization.
if mk.RotationSignal != nil && mk.Element == "" {
opts += fmt.Sprintf(`rotation:%s,`, mk.RotationSignal.String())
} else if mk.RotationSignal == nil && mk.Rotation != 0 {
opts += fmt.Sprintf(`rotation:%s,`, formatFloat(mk.Rotation))
}
if mk.Draggable {
opts += `draggable:true,`
}
if mk.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(mk.Offset[0]), formatFloat(mk.Offset[1]))
}
if mk.Scale != 0 {
opts += fmt.Sprintf(`scale:%s,`, formatFloat(mk.Scale))
}
if mk.Opacity != 0 {
opts += fmt.Sprintf(`opacity:%s,`, formatFloat(mk.Opacity))
}
if mk.OpacityWhenCovered != 0 {
opts += fmt.Sprintf(`opacityWhenCovered:%s,`, formatFloat(mk.OpacityWhenCovered))
}
if mk.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(mk.ClassName))
}
opts += "}"
// Determine initial position
@@ -245,36 +197,24 @@ func markerBodyJS(mapID, markerID string, mk Marker) string {
opts, formatFloat(mk.LngLat.Lng), formatFloat(mk.LngLat.Lat)))
}
// Apply initial rotation with flip normalization for custom elements.
if mk.RotationSignal != nil && mk.Element != "" {
b.WriteString(fmt.Sprintf(
`var _r=%s,_f=_r>90||_r<-90;if(_f)_r=_r>0?_r-180:_r+180;`+
`mk.setRotation(_r);`+
`var _ch=_mkEl.firstElementChild;if(_ch&&_f)_ch.style.transform='scaleX(-1)';`,
mk.RotationSignal.String()))
}
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)))
// Drag → throttled live signal writeback + dragend final writeback
// Dragend → signal writeback
if mk.Draggable && mk.LngSignal != nil && mk.LatSignal != nil {
b.WriteString(dragHandlerJS(mapID, mk))
b.WriteString(dragendHandlerJS(mapID, markerID, mk))
}
return b.String()
}
// dragHandlerJS generates JS that writes marker position to signal hidden inputs
// during drag (throttled via requestAnimationFrame) and on dragend (unthrottled).
func dragHandlerJS(mapID string, mk Marker) string {
// Shared writeback logic extracted into a local function for both handlers.
// 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(
`var _raf=0;mk._dragging=false;`+
`function _wb(){`+
`mk.on('dragend',function(){`+
`var pos=mk.getLngLat();`+
`var el=document.getElementById(%[1]s);if(!el)return;`+
`var inputs=el.querySelectorAll('input[data-bind]');`+
@@ -283,67 +223,30 @@ func dragHandlerJS(mapID string, mk Marker) string {
`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}))}`+
`});`+
`}`+
`mk.on('dragstart',function(){mk._dragging=true;});`+
`mk.on('drag',function(){if(_raf)return;_raf=requestAnimationFrame(function(){_raf=0;_wb()});});`+
`mk.on('dragend',function(){cancelAnimationFrame(_raf);_raf=0;_wb();mk._dragging=false;});`,
`});`,
jsonStr("_vwrap_"+mapID),
jsonStr(mk.LngSignal.ID()),
jsonStr(mk.LatSignal.ID()),
)
}
// markerEventListenerJS generates JS for a marker event listener.
// Assumes `mk` (the marker) is in scope.
func markerEventListenerJS(mapID string, ev markerEventEntry) string {
return fmt.Sprintf(
`mk.on(%[1]s,function(){`+
`var pos=mk.getLngLat();`+
`var d={lngLat:{Lng:pos.lng,Lat:pos.lat},point:[0,0]};`+
`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()),
)
}
// 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.
var b strings.Builder
b.WriteString(fmt.Sprintf(`var lng=$%s,lat=$%s;`, mk.LngSignal.ID(), mk.LatSignal.ID()))
if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`var rot=$%s;`, mk.RotationSignal.ID()))
}
b.WriteString(fmt.Sprintf(
`var m=window.__via_maps&&window.__via_maps[%s];`+
`if(m&&m._via_markers[%[2]s]&&!m._via_markers[%[2]s]._dragging){`+
`m._via_markers[%[2]s].setLngLat([lng,lat])`,
jsonStr(mapID), jsonStr(markerID)))
if mk.RotationSignal != nil && mk.Element != "" {
// Normalize rotation to [-90,90] and horizontally flip the inner
// element when |rotation| > 90° to prevent upside-down markers.
b.WriteString(fmt.Sprintf(
`;var _mk=m._via_markers[%[1]s],_f=rot>90||rot<-90;`+
`if(_f)rot=rot>0?rot-180:rot+180;`+
`_mk.setRotation(rot);`+
`var _ch=_mk.getElement().firstElementChild;`+
`if(_ch)_ch.style.transform=_f?'scaleX(-1)':''`,
jsonStr(markerID)))
} else if mk.RotationSignal != nil {
b.WriteString(fmt.Sprintf(`;m._via_markers[%s].setRotation(rot)`, jsonStr(markerID)))
}
b.WriteString(`}`)
return b.String()
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, events []markerEventEntry) string {
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;`,
@@ -353,9 +256,6 @@ func addMarkerJS(mapID, markerID string, mk Marker, events []markerEventEntry) s
`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))
for _, ev := range events {
b.WriteString(markerEventListenerJS(mapID, ev))
}
b.WriteString(`})()`)
return b.String()
}
@@ -378,7 +278,7 @@ func popupBodyJS(popupID string, p Popup) string {
}
// showPopupJS generates a self-contained IIFE to show a popup post-render.
func showPopupJS(mapID, popupID string, p Popup, events []popupEventEntry) string {
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;`,
@@ -388,9 +288,6 @@ func showPopupJS(mapID, popupID string, p Popup, events []popupEventEntry) strin
`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))
for _, ev := range events {
b.WriteString(popupEventListenerJS(mapID, ev))
}
b.WriteString(`})()`)
return b.String()
}
@@ -411,50 +308,11 @@ func popupConstructorJS(p Popup, varName string) string {
if p.MaxWidth != "" {
opts += fmt.Sprintf(`maxWidth:%s,`, jsonStr(p.MaxWidth))
}
if p.CloseOnClick != nil {
if *p.CloseOnClick {
opts += `closeOnClick:true,`
} else {
opts += `closeOnClick:false,`
}
}
if p.CloseOnMove != nil {
if *p.CloseOnMove {
opts += `closeOnMove:true,`
} else {
opts += `closeOnMove:false,`
}
}
if p.Anchor != "" {
opts += fmt.Sprintf(`anchor:%s,`, jsonStr(p.Anchor))
}
if p.Offset != [2]float64{} {
opts += fmt.Sprintf(`offset:[%s,%s],`, formatFloat(p.Offset[0]), formatFloat(p.Offset[1]))
}
if p.ClassName != "" {
opts += fmt.Sprintf(`className:%s,`, jsonStr(p.ClassName))
}
opts += "}"
return fmt.Sprintf(`var %s=new maplibregl.Popup(%s).setHTML(%s);`,
varName, opts, jsonStr(p.Content))
}
// popupEventListenerJS generates JS for a popup event listener.
// Assumes `p` (the popup) is in scope.
func popupEventListenerJS(mapID string, ev popupEventEntry) string {
// open/close carry no spatial data — write a timestamp as change trigger.
return fmt.Sprintf(
`p.on(%[1]s,function(){`+
`var el=document.getElementById(%[2]s);if(!el)return;`+
`var inp=el.querySelector('input[data-bind=%[3]s]');`+
`if(inp){inp.value=Date.now();inp.dispatchEvent(new Event('input',{bubbles:true}))}`+
`});`,
jsonStr(ev.event),
jsonStr("_vwrap_"+mapID),
jsonStr(ev.signal.ID()),
)
}
// --- Control JS ---
// controlBodyJS generates JS to add a control, assuming `map` is in scope.

View File

@@ -122,37 +122,13 @@ func (m *Map) Element(extra ...h.H) h.H {
}
}
// Hidden inputs for signal-backed marker position/rotation writeback
// 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()),
)
if me.marker.RotationSignal != nil {
children = append(children,
h.Input(h.Type("hidden"), me.marker.RotationSignal.Bind()),
)
}
}
// Hidden inputs for marker event signals
if me.handle != nil {
for _, ev := range me.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.Bind()),
)
}
}
}
// Hidden inputs for popup event signals
for _, pe := range m.popups {
if pe.handle != nil {
for _, ev := range pe.handle.events {
children = append(children,
h.Input(h.Type("hidden"), ev.signal.Bind()),
)
}
}
}
@@ -309,43 +285,13 @@ func (m *Map) SetLayoutProperty(layerID, name string, value any) {
// --- Marker methods ---
// AddMarker adds or replaces a marker on the map.
// The returned MarkerHandle can be used to subscribe to marker-level events.
func (m *Map) AddMarker(id string, marker Marker) *MarkerHandle {
h := &MarkerHandle{markerID: id, m: m}
func (m *Map) AddMarker(id string, marker Marker) {
if !m.rendered {
m.markers = append(m.markers, markerEntry{id: id, marker: marker, handle: h})
return h
m.markers = append(m.markers, markerEntry{id: id, marker: marker})
return
}
js := addMarkerJS(m.id, id, marker, h.events)
js := addMarkerJS(m.id, id, marker)
m.ctx.ExecScript(js)
return h
}
// OnClick returns a MapEvent that fires when this marker is clicked.
func (h *MarkerHandle) OnClick() *MapEvent {
return h.on("click")
}
// OnDragStart returns a MapEvent that fires when dragging starts.
func (h *MarkerHandle) OnDragStart() *MapEvent {
return h.on("dragstart")
}
// OnDrag returns a MapEvent that fires during dragging.
func (h *MarkerHandle) OnDrag() *MapEvent {
return h.on("drag")
}
// OnDragEnd returns a MapEvent that fires when dragging ends.
func (h *MarkerHandle) OnDragEnd() *MapEvent {
return h.on("dragend")
}
func (h *MarkerHandle) on(event string) *MapEvent {
sig := h.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
h.events = append(h.events, markerEventEntry{event: event, signal: sig})
return ev
}
// RemoveMarker removes a marker from the map.
@@ -365,33 +311,13 @@ func (m *Map) RemoveMarker(id string) {
// --- Popup methods ---
// ShowPopup shows a standalone popup on the map.
// The returned PopupHandle can be used to subscribe to popup events.
func (m *Map) ShowPopup(id string, popup Popup) *PopupHandle {
ph := &PopupHandle{popupID: id, m: m}
func (m *Map) ShowPopup(id string, popup Popup) {
if !m.rendered {
m.popups = append(m.popups, popupEntry{id: id, popup: popup, handle: ph})
return ph
m.popups = append(m.popups, popupEntry{id: id, popup: popup})
return
}
js := showPopupJS(m.id, id, popup, ph.events)
js := showPopupJS(m.id, id, popup)
m.ctx.ExecScript(js)
return ph
}
// OnOpen returns a MapEvent that fires when the popup opens.
func (ph *PopupHandle) OnOpen() *MapEvent {
return ph.on("open")
}
// OnClose returns a MapEvent that fires when the popup closes.
func (ph *PopupHandle) OnClose() *MapEvent {
return ph.on("close")
}
func (ph *PopupHandle) on(event string) *MapEvent {
sig := ph.m.ctx.Signal("")
ev := &MapEvent{signal: sig}
ph.events = append(ph.events, popupEventEntry{event: event, signal: sig})
return ev
}
// ClosePopup closes a standalone popup on the map.

View File

@@ -302,43 +302,11 @@ type Marker struct {
Draggable bool
Popup *Popup
// Element is raw HTML/SVG used as a custom marker instead of the
// default pin. When set, Color is ignored.
// Do not pass untrusted user input without sanitizing it first.
Element string
// Anchor controls which part of the element sits at the coordinate.
// Values: "center" (default for custom elements), "bottom" (default
// for the pin), "top", "left", "right", "top-left", etc.
Anchor string
// Rotation is clockwise degrees. Useful for directional icons (ships, vehicles).
// Ignored when RotationSignal is set.
Rotation float64
// 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
// RotationSignal drives marker rotation reactively. When set, Rotation is ignored.
RotationSignal *via.Signal
// Offset is a pixel offset from the anchor point as [x, y].
Offset [2]float64
// Scale is a scaling factor for the default marker pin (0 = omit, MapLibre default 1).
Scale float64
// Opacity is the marker opacity 01 (0 = omit, MapLibre default 1).
Opacity float64
// OpacityWhenCovered is the marker opacity when behind 3D terrain (0 = omit).
OpacityWhenCovered float64
// ClassName is a CSS class added to the marker container element.
ClassName string
}
// Popup describes a map popup.
@@ -348,25 +316,8 @@ type Marker struct {
type Popup struct {
Content string // HTML content
LngLat LngLat
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
HideCloseButton bool // true removes the close button (MapLibre shows it by default)
MaxWidth string
// CloseOnClick controls whether the popup closes on map click.
// nil = MapLibre default (true).
CloseOnClick *bool
// CloseOnMove controls whether the popup closes on map move.
// nil = MapLibre default (false).
CloseOnMove *bool
// Anchor forces the popup anchor position ("top", "bottom", "left", "right", etc.).
Anchor string
// Offset is a pixel offset from the anchor as [x, y].
Offset [2]float64
// ClassName is a CSS class added to the popup container.
ClassName string
}
// --- Event data ---
@@ -386,40 +337,14 @@ type sourceEntry struct {
js string
}
// MarkerHandle is returned by AddMarker and allows subscribing to marker events.
type MarkerHandle struct {
markerID string
m *Map
events []markerEventEntry
}
type markerEventEntry struct {
event string
signal *via.Signal
}
type markerEntry struct {
id string
marker Marker
handle *MarkerHandle
}
// PopupHandle is returned by ShowPopup and allows subscribing to popup events.
type PopupHandle struct {
popupID string
m *Map
events []popupEventEntry
}
type popupEventEntry struct {
event string
signal *via.Signal
}
type popupEntry struct {
id string
popup Popup
handle *PopupHandle
id string
popup Popup
}
type eventEntry struct {

View File

@@ -80,3 +80,4 @@ func (s *Signal) Int() int {
}
return 0
}

View File

@@ -1,6 +1,7 @@
package via
import (
// "net/http/httptest"
"testing"
"github.com/ryanhamamura/via/h"

View File

@@ -81,8 +81,8 @@ func TestStaticAutoSlash(t *testing.T) {
func TestStaticFS(t *testing.T) {
fsys := fstest.MapFS{
"style.css": {Data: []byte("body{}")},
"js/app.js": {Data: []byte("console.log('hi')")},
"style.css": {Data: []byte("body{}")},
"js/app.js": {Data: []byte("console.log('hi')")},
}
v := New()

73
via.go
View File

@@ -240,10 +240,10 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
bodyElements = append(bodyElements, h.Raw("<dataspa-inspector/>"))
}
view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
})
Title: v.cfg.DocumentTitle,
Head: headElements,
Body: bodyElements,
})
_ = view.Render(w)
}))
}
@@ -439,39 +439,36 @@ 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 {
v.logFatal("failed to create directory for devmode files: %v", err)
}
ctxRegMap := loadDevModeMap(p)
// 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
if _, ok := ctxRegMap[c.id]; !ok {
ctxRegMap[c.id] = c.route
}
if err := saveDevModeMap(p, ctxRegMap); err != nil {
v.logErr(c, "devmode failed to persist ctx: %v", err)
// 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")
}
v.logDebug(c, "devmode persisted ctx to file")
}
@@ -479,11 +476,27 @@ func (v *V) devModePersist(c *Context) {
func (v *V) devModeRemovePersisted(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json")
ctxRegMap := loadDevModeMap(p)
// 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()
delete(ctxRegMap, c.id)
if err := saveDevModeMap(p, ctxRegMap); err != nil {
v.logErr(c, "devmode failed to remove persisted ctx: %v", err)
// 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")
}
v.logDebug(c, "devmode removed persisted ctx from file")
}

View File

@@ -134,9 +134,9 @@ func TestAction(t *testing.T) {
func TestEventTypes(t *testing.T) {
tests := []struct {
name string
attr string
buildEl func(trigger *actionTrigger) h.H
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()) }},