11 Commits

Author SHA1 Message Date
9b83333275 Extract routes to getRoutes() function
All checks were successful
CI / build (push) Successful in 26s
Address review feedback to eliminate duplicated route configuration
and prevent potential inconsistencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:35:33 +00:00
3bd0659caf Add auth-protected app example
This example demonstrates OIDC authentication with the Iris framework:
- OIDC client setup and configuration
- Login/logout flow with async discovery
- Protected routes using auth guards
- User profile display from ID token claims
- Token information display and handling
- Reactive UI state for authentication status

Closes #7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:35:33 +00:00
7393778453 Remove unused responseWriter struct
All checks were successful
CI / build (push) Successful in 30s
Address review feedback to remove dead code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:34:16 +00:00
89efc9b41d Add hot reload for development
Implement automatic rebuild and browser reload during development:

- File watcher monitors .go files for changes with configurable extensions
- Builder compiles Go source to WASM on file changes
- LiveReload WebSocket server notifies connected browsers to reload
- DevServer combines all components for easy development setup
- HTML injection adds reload script automatically

Usage:
  dev := host.NewDevServer("public", "index.html", ".", "public/app.wasm")
  dev.ListenAndServe(":8080")

Closes #9

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:34:16 +00:00
9642fd008e Remove internal package import from todo example
All checks were successful
CI / build (push) Successful in 27s
Replace direct internal/element usage with public ui package
components to ensure examples only use the public API:

- Add ui.RawCheckbox for plain checkbox without label wrapper
- Add ui.Span for span elements with text content
- Add View.TextDecoration modifier for strikethrough styling
- Update todo example to use these public components

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:29:57 +00:00
505fa0f84a Add todo list example
This example demonstrates Iris reactive patterns:
- Signal holding a slice/list of todos
- Adding and removing items dynamically
- Component composition (inputRow, todoList, todoItem)
- Input handling with TextInput and Enter key support
- Conditional rendering (empty state, strikethrough for completed)

Closes #5

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:29:57 +00:00
d3ed52e4a6 Remove accidentally committed binary
All checks were successful
CI / build (push) Successful in 28s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:28:30 +00:00
f67347688b Add decrement button to counter example
Address review feedback to include a decrement button that
demonstrates reactive updates in both directions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:28:30 +00:00
7cd276a5e5 Add counter example demonstrating signals
Basic example showing:
- Creating a signal with NewSignal
- Reading value with Get()
- Writing value with Set()
- Button click handler
- Reactive text that updates when signal changes

Closes #4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:28:30 +00:00
27f0154bc9 Add dashboard example showing realistic admin panel
All checks were successful
CI / build (push) Successful in 28s
This example demonstrates all key Iris features for internal tool teams:
- Multiple pages with client-side routing
- Auth-protected sections with route guards
- Reactive data display with signals
- Form inputs (text, checkbox, slider, select)
- Component composition with reusable layouts

Test credentials: admin/admin (full access) or user/user (limited access)

Closes #8

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:11:37 +00:00
a13e215e31 Add multi-page app example demonstrating navigation
All checks were successful
CI / build (pull_request) Successful in 27s
CI / build (push) Successful in 28s
Add a comprehensive example showing client-side routing:
- Router setup with multiple routes (/, /about, /users, /admin)
- Route parameters (/users/:id) with the NumericIdGuard
- Navigation using Link component and programmatic Navigate/Back
- Route guards with AuthGuard for protected admin page
- Browser history integration with back/forward support

Closes #6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 17:01:47 +01:00
16 changed files with 2425 additions and 16 deletions

36
examples/counter/main.go Normal file
View File

@@ -0,0 +1,36 @@
//go:build js && wasm
package main
import (
"fmt"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
func main() {
// Create a signal with initial value 0
count := reactive.NewSignal(0)
view := ui.NewView()
// Reactive text that updates when count changes
view.Child(ui.TextFromFunction(func() string {
return fmt.Sprintf("Count: %d", count.Get())
}))
// Button that increments the count on click
view.Child(ui.Button(func() {
count.Set(count.Get() + 1)
}, ui.TextFromString("Increment")))
// Button that decrements the count on click
view.Child(ui.Button(func() {
count.Set(count.Get() - 1)
}, ui.TextFromString("Decrement")))
ui.NewApp(view)
select {}
}

669
examples/dashboard/main.go Normal file
View File

@@ -0,0 +1,669 @@
//go:build js && wasm
// Dashboard example demonstrating a realistic internal tool admin panel.
// This example showcases:
// - Multiple pages with routing
// - Auth-protected sections
// - Reactive data display
// - Form inputs
// - Component composition
package main
import (
"fmt"
"strconv"
"git.flowmade.one/flowmade-one/iris/navigation"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
// Application state - simulated auth and data
var (
isAuthenticated = reactive.NewSignal(false)
currentUser = reactive.NewSignal("")
userRole = reactive.NewSignal("guest")
)
// Simulated dashboard metrics
var (
totalUsers = reactive.NewSignal(1247)
activeOrders = reactive.NewSignal(89)
revenue = reactive.NewSignal(45230.50)
pendingTasks = reactive.NewSignal(12)
)
// Simulated user list
type User struct {
ID int
Name string
Email string
Role string
Active bool
}
var users = reactive.NewSignal([]User{
{1, "Alice Johnson", "alice@example.com", "admin", true},
{2, "Bob Smith", "bob@example.com", "user", true},
{3, "Carol White", "carol@example.com", "user", false},
{4, "Dave Brown", "dave@example.com", "moderator", true},
})
// Settings state
var (
darkMode = reactive.NewSignal(false)
emailNotifications = reactive.NewSignal(true)
apiEndpoint = reactive.NewSignal("https://api.example.com")
refreshInterval = reactive.NewSignal(30)
)
func main() {
// Define routes with guards
routes := []navigation.Route{
{Path: "/", Handler: func(params map[string]string) ui.View { return homeView() }},
{Path: "/login", Handler: func(params map[string]string) ui.View { return loginView() }},
{Path: "/users", Handler: func(params map[string]string) ui.View { return usersView() }, Guards: []navigation.RouteGuard{authGuard}},
{Path: "/users/:id", Handler: userDetailView, Guards: []navigation.RouteGuard{authGuard}},
{Path: "/settings", Handler: func(params map[string]string) ui.View { return settingsView() }, Guards: []navigation.RouteGuard{authGuard}},
{Path: "/admin", Handler: func(params map[string]string) ui.View { return adminView() }, Guards: []navigation.RouteGuard{authGuard, adminGuard}},
}
router := navigation.NewRouter(routes)
router.SetNotFoundHandler(notFoundView)
navigation.SetGlobalRouter(router)
// Create the main app layout
app := dashboardLayout()
ui.NewApp(app)
select {}
}
// Auth guard - checks if user is authenticated
func authGuard(route *navigation.Route, params map[string]string) bool {
if !isAuthenticated.Get() {
navigation.Navigate("/login")
return false
}
return true
}
// Admin guard - checks if user has admin role
func adminGuard(route *navigation.Route, params map[string]string) bool {
return userRole.Get() == "admin"
}
// Main dashboard layout with sidebar and content area
func dashboardLayout() ui.View {
sidebar := createSidebar()
content := createContentArea()
return ui.FlexLayout(
[]string{"240px", "1fr"},
sidebar,
content,
).MinHeight("100vh")
}
// Sidebar with navigation links
func createSidebar() ui.View {
return ui.VerticalGroup(
// Logo/Brand
ui.TextFromString("Iris Dashboard").
Font(ui.NewFont().Size("24px").Weight("bold")).
Padding("20px").
Color("#fff"),
ui.Divider().Background("#444"),
// Navigation links
navLink("/", "Home"),
navLink("/users", "Users"),
navLink("/settings", "Settings"),
navLink("/admin", "Admin"),
ui.Spacer(),
// User info at bottom
ui.VerticalGroup(
ui.Divider().Background("#444"),
ui.TextFromFunction(func() string {
if isAuthenticated.Get() {
return "Logged in as: " + currentUser.Get()
}
return "Not logged in"
}).Color("#aaa").Padding("10px"),
loginLogoutButton(),
),
).Background("#1a1a2e").Width("240px").MinHeight("100vh").Padding("0")
}
func navLink(path, label string) ui.View {
return navigation.Link(path,
ui.TextFromString(label).Color("#ccc"),
).Padding("12px 20px").Cursor("pointer")
}
func loginLogoutButton() ui.View {
return ui.Button(func() {
if isAuthenticated.Get() {
isAuthenticated.Set(false)
currentUser.Set("")
userRole.Set("guest")
navigation.Navigate("/")
} else {
navigation.Navigate("/login")
}
}, ui.TextFromFunction(func() string {
if isAuthenticated.Get() {
return "Logout"
}
return "Login"
}).Color("#fff")).
Background("#e94560").
Padding("10px 20px").
Border("none").
BorderRadius("4px").
Cursor("pointer").
Margin("10px")
}
// Content area that shows the current route view
func createContentArea() ui.View {
view := ui.NewView()
reactive.NewEffect(func() {
router := navigation.GetGlobalRouter()
if router != nil {
currentView := router.GetCurrentView()
view.Element().Set("innerHTML", "")
view.Child(currentView)
}
})
return view.Background("#f0f0f0").Padding("20px").Overflow("auto")
}
// Home view - dashboard overview
func homeView() ui.View {
return ui.VerticalGroup(
ui.TextFromString("Dashboard Overview").
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
// Metrics cards
ui.HorizontalGroup(
metricCard("Total Users", func() string { return strconv.Itoa(totalUsers.Get()) }, "#4ecca3"),
metricCard("Active Orders", func() string { return strconv.Itoa(activeOrders.Get()) }, "#e94560"),
metricCard("Revenue", func() string { return fmt.Sprintf("$%.2f", revenue.Get()) }, "#16c79a"),
metricCard("Pending Tasks", func() string { return strconv.Itoa(pendingTasks.Get()) }, "#f39c12"),
).Gap("20px").Margin("20px 0"),
// Quick actions
ui.TextFromString("Quick Actions").
Font(ui.NewFont().Size("20px").Weight("bold")).
Color("#333").
Margin("20px 0 10px 0"),
ui.HorizontalGroup(
actionButton("Add User", func() { navigation.Navigate("/users") }),
actionButton("View Reports", func() { /* simulated action */ }),
actionButton("Export Data", func() { /* simulated action */ }),
).Gap("10px"),
// Recent activity (simulated)
ui.TextFromString("Recent Activity").
Font(ui.NewFont().Size("20px").Weight("bold")).
Color("#333").
Margin("30px 0 10px 0"),
activityItem("User Alice updated profile", "2 minutes ago"),
activityItem("New order #1234 received", "15 minutes ago"),
activityItem("System backup completed", "1 hour ago"),
).AlignLeft().Gap("5px")
}
func metricCard(title string, valueFn func() string, color string) ui.View {
return ui.VerticalGroup(
ui.TextFromString(title).Color("#666").Font(ui.NewFont().Size("14px")),
ui.TextFromFunction(valueFn).
Font(ui.NewFont().Size("32px").Weight("bold")).
Color(color),
).Background("#fff").
Padding("20px").
BorderRadius("8px").
BoxShadow("0 2px 4px rgba(0,0,0,0.1)").
Width("200px").
AlignCenter()
}
func actionButton(label string, action func()) ui.View {
return ui.Button(action,
ui.TextFromString(label).Color("#fff"),
).Background("#0077b6").
Padding("12px 24px").
Border("none").
BorderRadius("4px").
Cursor("pointer")
}
func activityItem(text, time string) ui.View {
return ui.HorizontalGroup(
ui.TextFromString(text).Color("#333"),
ui.Spacer(),
ui.TextFromString(time).Color("#999").Font(ui.NewFont().Size("12px")),
).Background("#fff").
Padding("15px").
BorderRadius("4px").
Margin("5px 0")
}
// Login view
func loginView() ui.View {
username := reactive.NewSignal("")
password := reactive.NewSignal("")
errorMsg := reactive.NewSignal("")
return ui.VerticalGroup(
ui.TextFromString("Login").
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
ui.VerticalGroup(
ui.TextFromString("Username").Color("#666").Margin("0 0 5px 0"),
ui.TextInput(&username, "Enter username").
Padding("10px").
Border("1px solid #ddd").
BorderRadius("4px").
Width("300px"),
ui.TextFromString("Password").Color("#666").Margin("15px 0 5px 0"),
ui.TextInput(&password, "Enter password").
Padding("10px").
Border("1px solid #ddd").
BorderRadius("4px").
Width("300px"),
ui.TextFromFunction(func() string {
return errorMsg.Get()
}).Color("#e94560").Margin("10px 0"),
ui.Button(func() {
// Simulated authentication
u := username.Get()
p := password.Get()
if u == "admin" && p == "admin" {
isAuthenticated.Set(true)
currentUser.Set("admin")
userRole.Set("admin")
navigation.Navigate("/")
} else if u == "user" && p == "user" {
isAuthenticated.Set(true)
currentUser.Set("user")
userRole.Set("user")
navigation.Navigate("/")
} else {
errorMsg.Set("Invalid credentials. Try admin/admin or user/user")
}
}, ui.TextFromString("Login").Color("#fff")).
Background("#0077b6").
Padding("12px 40px").
Border("none").
BorderRadius("4px").
Cursor("pointer").
Margin("20px 0"),
).Background("#fff").
Padding("30px").
BorderRadius("8px").
BoxShadow("0 2px 10px rgba(0,0,0,0.1)").
Gap("5px"),
).AlignCenter().Padding("50px")
}
// Users view - list and manage users
func usersView() ui.View {
searchTerm := reactive.NewSignal("")
return ui.VerticalGroup(
ui.TextFromString("User Management").
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
// Search bar
ui.HorizontalGroup(
ui.TextInput(&searchTerm, "Search users...").
Padding("10px").
Border("1px solid #ddd").
BorderRadius("4px").
Width("300px"),
ui.Button(func() {
// Add new user simulation
currentUsers := users.Get()
newID := len(currentUsers) + 1
currentUsers = append(currentUsers, User{
ID: newID,
Name: fmt.Sprintf("New User %d", newID),
Email: fmt.Sprintf("user%d@example.com", newID),
Role: "user",
Active: true,
})
users.Set(currentUsers)
totalUsers.Set(totalUsers.Get() + 1)
}, ui.TextFromString("Add User").Color("#fff")).
Background("#4ecca3").
Padding("10px 20px").
Border("none").
BorderRadius("4px").
Cursor("pointer"),
).Gap("10px").Margin("20px 0"),
// User list
ui.VerticalGroup(
userListHeader(),
userList(&searchTerm),
).Background("#fff").
BorderRadius("8px").
BoxShadow("0 2px 4px rgba(0,0,0,0.1)"),
).AlignLeft().Gap("10px")
}
func userListHeader() ui.View {
return ui.FlexLayout(
[]string{"60px", "1fr", "1fr", "100px", "100px"},
ui.TextFromString("ID").Font(ui.NewFont().Weight("bold")).Color("#666"),
ui.TextFromString("Name").Font(ui.NewFont().Weight("bold")).Color("#666"),
ui.TextFromString("Email").Font(ui.NewFont().Weight("bold")).Color("#666"),
ui.TextFromString("Role").Font(ui.NewFont().Weight("bold")).Color("#666"),
ui.TextFromString("Status").Font(ui.NewFont().Weight("bold")).Color("#666"),
).Padding("15px").BorderBottom("2px solid #eee")
}
func userList(searchTerm *reactive.Signal[string]) ui.View {
view := ui.NewView()
reactive.NewEffect(func() {
view.Element().Set("innerHTML", "")
search := searchTerm.Get()
for _, user := range users.Get() {
// Simple search filter
if search != "" && !containsIgnoreCase(user.Name, search) && !containsIgnoreCase(user.Email, search) {
continue
}
view.Child(userRow(user))
}
})
return view
}
func userRow(user User) ui.View {
statusColor := "#4ecca3"
statusText := "Active"
if !user.Active {
statusColor = "#e94560"
statusText = "Inactive"
}
return ui.FlexLayout(
[]string{"60px", "1fr", "1fr", "100px", "100px"},
ui.TextFromString(strconv.Itoa(user.ID)).Color("#333"),
navigation.Link(
fmt.Sprintf("/users/%d", user.ID),
ui.TextFromString(user.Name).Color("#0077b6"),
),
ui.TextFromString(user.Email).Color("#666"),
ui.TextFromString(user.Role).Color("#333"),
ui.TextFromString(statusText).Color(statusColor),
).Padding("15px").BorderBottom("1px solid #eee")
}
// User detail view
func userDetailView(params map[string]string) ui.View {
userID := params["id"]
id, _ := strconv.Atoi(userID)
var user *User
for _, u := range users.Get() {
if u.ID == id {
userCopy := u
user = &userCopy
break
}
}
if user == nil {
return ui.TextFromString("User not found").Color("#e94560")
}
return ui.VerticalGroup(
ui.Button(func() {
navigation.Navigate("/users")
}, ui.TextFromString("Back to Users").Color("#0077b6")).
Background("transparent").
Border("none").
Cursor("pointer").
Padding("0").
Margin("0 0 20px 0"),
ui.TextFromString(user.Name).
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
ui.VerticalGroup(
detailRow("ID", strconv.Itoa(user.ID)),
detailRow("Email", user.Email),
detailRow("Role", user.Role),
detailRow("Status", map[bool]string{true: "Active", false: "Inactive"}[user.Active]),
).Background("#fff").
Padding("20px").
BorderRadius("8px").
BoxShadow("0 2px 4px rgba(0,0,0,0.1)").
Gap("10px").
Margin("20px 0"),
).AlignLeft()
}
func detailRow(label, value string) ui.View {
return ui.HorizontalGroup(
ui.TextFromString(label+":").Font(ui.NewFont().Weight("bold")).Color("#666").Width("100px"),
ui.TextFromString(value).Color("#333"),
).Gap("10px")
}
// Settings view
func settingsView() ui.View {
return ui.VerticalGroup(
ui.TextFromString("Settings").
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
// Appearance section
settingsSection("Appearance",
ui.Checkbox(&darkMode, "Enable Dark Mode"),
),
// Notifications section
settingsSection("Notifications",
ui.Checkbox(&emailNotifications, "Email Notifications"),
),
// API Settings section
settingsSection("API Configuration",
ui.VerticalGroup(
ui.TextFromString("API Endpoint").Color("#666").Margin("0 0 5px 0"),
ui.TextInput(&apiEndpoint, "API URL").
Padding("10px").
Border("1px solid #ddd").
BorderRadius("4px").
Width("400px"),
ui.TextFromString("Refresh Interval (seconds)").Color("#666").Margin("15px 0 5px 0"),
ui.HorizontalGroup(
ui.Slider(&refreshInterval, 5, 120),
ui.TextFromFunction(func() string {
return fmt.Sprintf("%ds", refreshInterval.Get())
}).Color("#333").Width("50px"),
).Gap("10px").AlignCenter(),
).Gap("5px"),
),
// Current settings display
ui.VerticalGroup(
ui.TextFromString("Current Configuration").
Font(ui.NewFont().Size("18px").Weight("bold")).
Color("#333"),
ui.TextFromFunction(func() string {
return fmt.Sprintf("Dark Mode: %v | Notifications: %v | Refresh: %ds",
darkMode.Get(), emailNotifications.Get(), refreshInterval.Get())
}).Color("#666").Font(ui.NewFont().Size("14px")),
).Background("#f8f8f8").
Padding("15px").
BorderRadius("4px").
Margin("20px 0"),
// Save button
ui.Button(func() {
// Simulated save action
fmt.Println("Settings saved!")
}, ui.TextFromString("Save Settings").Color("#fff")).
Background("#0077b6").
Padding("12px 30px").
Border("none").
BorderRadius("4px").
Cursor("pointer"),
).AlignLeft().Gap("10px").Padding("0 0 20px 0")
}
func settingsSection(title string, content ui.View) ui.View {
return ui.VerticalGroup(
ui.TextFromString(title).
Font(ui.NewFont().Size("18px").Weight("bold")).
Color("#333"),
content,
).Background("#fff").
Padding("20px").
BorderRadius("8px").
BoxShadow("0 2px 4px rgba(0,0,0,0.1)").
Gap("15px").
Margin("20px 0")
}
// Admin view - protected section
func adminView() ui.View {
return ui.VerticalGroup(
ui.TextFromString("Admin Panel").
Font(ui.NewFont().Size("28px").Weight("bold")).
Color("#333"),
ui.TextFromString("This section is only accessible to administrators.").
Color("#666").
Margin("10px 0"),
// Admin actions
ui.VerticalGroup(
ui.TextFromString("System Actions").
Font(ui.NewFont().Size("18px").Weight("bold")).
Color("#333"),
ui.HorizontalGroup(
adminActionButton("Clear Cache", func() {
fmt.Println("Cache cleared!")
}),
adminActionButton("Restart Services", func() {
fmt.Println("Services restarted!")
}),
adminActionButton("Run Backup", func() {
fmt.Println("Backup started!")
}),
).Gap("10px"),
).Background("#fff").
Padding("20px").
BorderRadius("8px").
BoxShadow("0 2px 4px rgba(0,0,0,0.1)").
Gap("15px").
Margin("20px 0"),
// Danger zone
ui.VerticalGroup(
ui.TextFromString("Danger Zone").
Font(ui.NewFont().Size("18px").Weight("bold")).
Color("#e94560"),
ui.TextFromString("These actions are irreversible. Use with caution.").
Color("#999").
Font(ui.NewFont().Size("14px")),
ui.Button(func() {
fmt.Println("Reset initiated!")
}, ui.TextFromString("Reset All Data").Color("#fff")).
Background("#e94560").
Padding("10px 20px").
Border("none").
BorderRadius("4px").
Cursor("pointer"),
).Background("#fff").
Padding("20px").
BorderRadius("8px").
Border("2px solid #e94560").
Gap("10px").
Margin("20px 0"),
).AlignLeft().Gap("10px")
}
func adminActionButton(label string, action func()) ui.View {
return ui.Button(action,
ui.TextFromString(label).Color("#fff"),
).Background("#6c757d").
Padding("10px 20px").
Border("none").
BorderRadius("4px").
Cursor("pointer")
}
// 404 Not Found view
func notFoundView() ui.View {
return ui.VerticalGroup(
ui.TextFromString("404").
Font(ui.NewFont().Size("72px").Weight("bold")).
Color("#e94560"),
ui.TextFromString("Page Not Found").
Font(ui.NewFont().Size("24px")).
Color("#666"),
ui.Button(func() {
navigation.Navigate("/")
}, ui.TextFromString("Go Home").Color("#fff")).
Background("#0077b6").
Padding("12px 30px").
Border("none").
BorderRadius("4px").
Cursor("pointer").
Margin("20px 0"),
).AlignCenter().Padding("50px")
}
// Helper function for case-insensitive search
func containsIgnoreCase(str, substr string) bool {
return len(str) >= len(substr) && contains(toLower(str), toLower(substr))
}
func toLower(s string) string {
result := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'A' && c <= 'Z' {
result[i] = c + 32
} else {
result[i] = c
}
}
return string(result)
}
func contains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

265
examples/multipage/main.go Normal file
View File

@@ -0,0 +1,265 @@
//go:build js && wasm
// Package main demonstrates Iris client-side routing capabilities.
//
// This example shows:
// - Router setup with multiple routes
// - Route parameters (/users/:id)
// - Navigation between pages using Link and Navigate
// - Route guards for protected routes
// - History management (back/forward)
package main
import (
"fmt"
"git.flowmade.one/flowmade-one/iris/navigation"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
// Global auth state to demonstrate route guards
var isAuthenticated = reactive.NewSignal(false)
func main() {
// Define routes with their handlers and guards
routes := []navigation.Route{
{Path: "/", Handler: homePage},
{Path: "/about", Handler: aboutPage},
{Path: "/users", Handler: usersPage},
{Path: "/users/:id", Handler: userDetailPage, Guards: []navigation.RouteGuard{
navigation.NumericIdGuard(),
}},
{Path: "/admin", Handler: adminPage, Guards: []navigation.RouteGuard{
navigation.AuthGuard(func() bool { return isAuthenticated.Get() }),
}},
}
// Create router and set up navigation
router := navigation.NewRouter(routes)
router.SetNotFoundHandler(notFoundPage)
navigation.SetGlobalRouter(router)
// Mount the app with router support
ui.NewAppWithRouter(router)
// Keep the application running
select {}
}
// homePage renders the landing page with navigation links
func homePage(params map[string]string) ui.View {
return pageLayout("Home",
ui.VerticalGroup(
ui.TextFromString("Welcome to the Iris Multi-Page Demo").
Color("#333").
Padding("16px"),
ui.TextFromString("This example demonstrates client-side routing with:").
Color("#666").
Padding("8px"),
ui.VerticalGroup(
ui.TextFromString("- Multiple routes and page navigation").Color("#888"),
ui.TextFromString("- Route parameters (see /users/:id)").Color("#888"),
ui.TextFromString("- Route guards for protected pages").Color("#888"),
ui.TextFromString("- Browser history integration").Color("#888"),
).Padding("8px 24px"),
ui.TextFromString("Use the navigation bar above to explore.").
Color("#666").
Padding("16px"),
),
)
}
// aboutPage renders information about the demo
func aboutPage(params map[string]string) ui.View {
return pageLayout("About",
ui.VerticalGroup(
ui.TextFromString("About Iris Navigation").
Color("#333").
Padding("16px"),
ui.TextFromString("The navigation package provides:").
Color("#666").
Padding("8px"),
ui.VerticalGroup(
ui.TextFromString("Router - Define routes with path patterns and handlers").Color("#888"),
ui.TextFromString("RouteGuard - Protect routes with custom logic").Color("#888"),
ui.TextFromString("HistoryManager - Integrate with browser history").Color("#888"),
ui.TextFromString("Link - Create navigational elements").Color("#888"),
ui.TextFromString("Navigate/Back/Forward - Programmatic navigation").Color("#888"),
).Padding("8px 24px"),
),
)
}
// usersPage renders a list of users with links to their detail pages
func usersPage(params map[string]string) ui.View {
users := []struct {
ID string
Name string
}{
{"1", "Alice"},
{"2", "Bob"},
{"3", "Charlie"},
{"4", "Diana"},
}
var userLinks []ui.View
for _, user := range users {
// Capture user in closure
u := user
userLinks = append(userLinks,
navigation.Link(fmt.Sprintf("/users/%s", u.ID),
ui.TextFromString(fmt.Sprintf("View %s (ID: %s)", u.Name, u.ID)).
Color("#0066cc"),
).Padding("8px").Cursor("pointer"),
)
}
return pageLayout("Users",
ui.VerticalGroup(
ui.TextFromString("User Directory").
Color("#333").
Padding("16px"),
ui.TextFromString("Click a user to see their details:").
Color("#666").
Padding("8px"),
ui.VerticalGroup(userLinks...).Padding("8px"),
),
)
}
// userDetailPage shows details for a specific user using the :id parameter
func userDetailPage(params map[string]string) ui.View {
userID := params["id"]
// Simulated user data
userData := map[string]struct {
Name string
Email string
Role string
}{
"1": {"Alice", "alice@example.com", "Admin"},
"2": {"Bob", "bob@example.com", "User"},
"3": {"Charlie", "charlie@example.com", "User"},
"4": {"Diana", "diana@example.com", "Moderator"},
}
user, exists := userData[userID]
if !exists {
return pageLayout("User Not Found",
ui.VerticalGroup(
ui.TextFromString(fmt.Sprintf("User with ID %s not found", userID)).
Color("#ff4444").
Padding("16px"),
ui.Button(func() {
navigation.Navigate("/users")
}, ui.TextFromString("Back to Users")).
Padding("8px"),
),
)
}
return pageLayout(fmt.Sprintf("User: %s", user.Name),
ui.VerticalGroup(
ui.TextFromString(fmt.Sprintf("User Details (ID: %s)", userID)).
Color("#333").
Padding("16px"),
ui.VerticalGroup(
ui.TextFromString(fmt.Sprintf("Name: %s", user.Name)).Color("#666"),
ui.TextFromString(fmt.Sprintf("Email: %s", user.Email)).Color("#666"),
ui.TextFromString(fmt.Sprintf("Role: %s", user.Role)).Color("#666"),
).Padding("8px 24px"),
ui.HorizontalGroup(
ui.Button(func() {
navigation.Back()
}, ui.TextFromString("Go Back")).Padding("8px"),
ui.Button(func() {
navigation.Navigate("/users")
}, ui.TextFromString("All Users")).Padding("8px"),
).Padding("16px"),
),
)
}
// adminPage is a protected route that requires authentication
func adminPage(params map[string]string) ui.View {
return pageLayout("Admin",
ui.VerticalGroup(
ui.TextFromString("Admin Dashboard").
Color("#333").
Padding("16px"),
ui.TextFromString("Welcome to the protected admin area!").
Color("#28a745").
Padding("8px"),
ui.TextFromString("This page is protected by an AuthGuard.").
Color("#666").
Padding("8px"),
ui.Button(func() {
isAuthenticated.Set(false)
navigation.Navigate("/")
}, ui.TextFromString("Logout")).
Padding("8px").
Background("#dc3545").
Foreground("#fff"),
),
)
}
// notFoundPage renders when no route matches
func notFoundPage() ui.View {
return pageLayout("404",
ui.VerticalGroup(
ui.TextFromString("404 - Page Not Found").
Color("#ff4444").
Padding("16px"),
ui.TextFromString("The page you are looking for does not exist.").
Color("#666").
Padding("8px"),
ui.Button(func() {
navigation.Navigate("/")
}, ui.TextFromString("Go Home")).Padding("8px"),
),
)
}
// pageLayout provides consistent page structure with navigation
func pageLayout(title string, content ui.View) ui.View {
return ui.VerticalGroup(
navBar(),
ui.VerticalGroup(
ui.TextFromString(title).
Color("#333").
Padding("8px").
Background("#f0f0f0").
Width("100%"),
content,
).Padding("16px"),
).MinHeight("100vh")
}
// navBar creates the navigation header with links
func navBar() ui.View {
return ui.HorizontalGroup(
navigation.Link("/", ui.TextFromString("Home").Color("#fff")),
navigation.Link("/about", ui.TextFromString("About").Color("#fff")),
navigation.Link("/users", ui.TextFromString("Users").Color("#fff")),
authButton(),
).Background("#333").Padding("8px 16px").AlignItems("center")
}
// authButton shows login/admin based on auth state
func authButton() ui.View {
return ui.Button(func() {
if isAuthenticated.Get() {
navigation.Navigate("/admin")
} else {
isAuthenticated.Set(true)
navigation.Navigate("/admin")
}
}, ui.TextFromFunction(func() string {
if isAuthenticated.Get() {
return "Admin"
}
return "Login"
}).Color("#fff")).Background("transparent").Border("1px solid #fff").Padding("4px 12px").Cursor("pointer")
}

195
examples/todo/main.go Normal file
View File

@@ -0,0 +1,195 @@
//go:build js && wasm
package main
import (
"fmt"
"syscall/js"
"git.flowmade.one/flowmade-one/iris/reactive"
"git.flowmade.one/flowmade-one/iris/ui"
)
// Todo represents a single todo item
type Todo struct {
ID int
Text string
Completed bool
}
func main() {
// Signal holding the list of todos
todos := reactive.NewSignal([]Todo{})
// Signal for the input field
inputText := reactive.NewSignal("")
// Counter for generating unique IDs
nextID := reactive.NewSignal(1)
// Create the main app view
view := ui.VerticalGroup(
// Title
ui.TextFromString("Todo List").Padding("16px 0"),
// Input row: text input + add button
inputRow(&inputText, func() {
text := inputText.Get()
if text == "" {
return
}
// Add new todo
id := nextID.Get()
nextID.Set(id + 1)
currentTodos := todos.Get()
newTodos := append(currentTodos, Todo{
ID: id,
Text: text,
Completed: false,
})
todos.Set(newTodos)
// Clear input
inputText.Set("")
}),
// Todo list
todoList(&todos),
// Summary text showing completed count
ui.TextFromFunction(func() string {
allTodos := todos.Get()
if len(allTodos) == 0 {
return "No todos yet"
}
completed := 0
for _, t := range allTodos {
if t.Completed {
completed++
}
}
return fmt.Sprintf("%d of %d completed", completed, len(allTodos))
}).Padding("16px 0"),
).MaxWidth("400px").Padding("24px")
ui.NewApp(view)
select {}
}
// inputRow creates the input field and add button
func inputRow(inputText *reactive.Signal[string], onAdd func()) ui.View {
row := ui.NewView()
row.Display("flex").Gap("8px").Width("100%")
// Text input
input := ui.TextInput(inputText, "What needs to be done?")
input.Width("100%")
// Handle Enter key to add todo
input.Element().OnWithEvent("keypress", func(event js.Value) {
if event.Get("key").String() == "Enter" {
onAdd()
}
})
// Add button
addBtn := ui.Button(onAdd, ui.TextFromString("Add"))
addBtn.Padding("8px 16px")
row.Child(input)
row.Child(addBtn)
return row
}
// todoList renders the list of todos reactively
func todoList(todos *reactive.Signal[[]Todo]) ui.View {
container := ui.NewView()
container.Width("100%")
// Effect that re-renders the list when todos change
reactive.NewEffect(func() {
allTodos := todos.Get()
// Clear existing children
container.Element().ClearChildren()
// Render each todo item
for _, todo := range allTodos {
item := todoItem(todo, todos)
container.Child(item)
}
// Show empty state if no todos
if len(allTodos) == 0 {
emptyText := ui.TextFromString("Add your first todo above")
emptyText.Color("#666").Padding("16px 0").TextAlign("center")
container.Child(emptyText)
}
})
return container
}
// todoItem renders a single todo item with toggle and delete
func todoItem(todo Todo, todos *reactive.Signal[[]Todo]) ui.View {
row := ui.NewView()
row.Display("flex").Gap("12px").Padding("8px 0").Width("100%").AlignItems("center")
row.BorderBottom("1px solid #eee")
// Completed checkbox using public ui.RawCheckbox
checkbox := ui.RawCheckbox(todo.Completed, func(isChecked bool) {
toggleTodo(todos, todo.ID, isChecked)
})
// Todo text with strikethrough if completed
textView := ui.NewView()
textView.Display("flex").Width("100%")
text := ui.Span(todo.Text)
if todo.Completed {
text.TextDecoration("line-through").Color("#999")
}
textView.Child(text)
// Delete button
deleteBtn := ui.Button(func() {
deleteTodo(todos, todo.ID)
}, ui.TextFromString("X"))
deleteBtn.Padding("4px 8px")
row.Child(checkbox)
row.Child(textView)
row.Child(deleteBtn)
return row
}
// toggleTodo updates the completed status of a todo
func toggleTodo(todos *reactive.Signal[[]Todo], id int, completed bool) {
currentTodos := todos.Get()
newTodos := make([]Todo, len(currentTodos))
for i, t := range currentTodos {
if t.ID == id {
newTodos[i] = Todo{ID: t.ID, Text: t.Text, Completed: completed}
} else {
newTodos[i] = t
}
}
todos.Set(newTodos)
}
// deleteTodo removes a todo from the list
func deleteTodo(todos *reactive.Signal[[]Todo], id int) {
currentTodos := todos.Get()
newTodos := make([]Todo, 0, len(currentTodos))
for _, t := range currentTodos {
if t.ID != id {
newTodos = append(newTodos, t)
}
}
todos.Set(newTodos)
}

View File

@@ -36,9 +36,9 @@ myapp/
The server serves files from the specified public directory. Any request path maps directly to files: The server serves files from the specified public directory. Any request path maps directly to files:
- `/` `public/index.html` - `/` -> `public/index.html`
- `/app.wasm` `public/app.wasm` - `/app.wasm` -> `public/app.wasm`
- `/styles.css` `public/styles.css` - `/styles.css` -> `public/styles.css`
**SPA Fallback**: Unknown paths and directories fall back to `index.html`, enabling client-side routing. **SPA Fallback**: Unknown paths and directories fall back to `index.html`, enabling client-side routing.
@@ -68,28 +68,109 @@ Gzip compression is automatically applied to compressible content types when the
Binary assets (PNG, JPEG, etc.) are served uncompressed since they're already compressed. Binary assets (PNG, JPEG, etc.) are served uncompressed since they're already compressed.
## Development vs Production ## Hot Reload (Development)
### Development The `DevServer` provides automatic rebuild and browser reload during development:
```go
package main
import (
"git.flowmade.one/flowmade-one/iris/host"
)
func main() {
// Create dev server with hot reload
dev := host.NewDevServer(
"public", // Static files directory
"index.html", // Index file
".", // Source directory to watch
"public/app.wasm", // WASM output path
)
// Start watching and serving
dev.ListenAndServe(":8080")
}
```
### How It Works
1. **File Watcher**: Monitors `.go` files in the source directory for changes
2. **Auto Build**: Runs `GOOS=js GOARCH=wasm go build` when changes are detected
3. **Browser Reload**: Injects a WebSocket client into HTML pages that triggers reload on build success
### Directory Structure for Development
```
myapp/
├── main.go # WASM application entry point
├── server/
│ └── main.go # Dev server (code above)
└── public/
├── index.html
├── app.wasm # Generated by dev server
└── wasm_exec.js
```
### Workflow
1. Start the dev server: `go run ./server`
2. Open `http://localhost:8080` in your browser
3. Edit your Go source files
4. Save - the browser automatically reloads with your changes
### Custom Watch Configuration
```go
// Watch additional file types
watcher := host.NewWatcher(
".",
onChangeCallback,
host.WithExtensions(".go", ".html", ".css"),
host.WithInterval(100*time.Millisecond),
)
```
### Manual Components
For advanced use cases, you can use the individual components:
```go
// File watcher
watcher := host.NewWatcher(".", func() {
log.Println("Files changed")
})
watcher.Start()
defer watcher.Stop()
// WASM builder
builder := host.NewBuilder(".", "public/app.wasm")
result := builder.Build()
if result.Success {
log.Println("Build succeeded")
}
// Live reload (WebSocket notifications)
lr := host.NewLiveReload()
http.Handle("/__livereload", lr)
lr.NotifyReload() // Triggers reload in all connected browsers
```
## Production
For production, use the standard `Server` instead of `DevServer`:
```go ```go
func main() { func main() {
server := host.New("public", "index.html") server := host.New("public", "index.html")
http.ListenAndServe(":8080", server) log.Fatal(http.ListenAndServe(":8080", server))
} }
``` ```
Run with: Compile and deploy:
```bash
go run server.go
```
### Production
For production, compile the server and run as a binary:
```bash ```bash
go build -o server ./server.go go build -o server ./server
./server ./server
``` ```

85
host/builder.go Normal file
View File

@@ -0,0 +1,85 @@
package host
import (
"bytes"
"fmt"
"os/exec"
"sync"
)
// Builder compiles Go source to WASM.
type Builder struct {
sourceDir string
outputPath string
mu sync.Mutex
building bool
}
// BuildResult contains the result of a build operation.
type BuildResult struct {
Success bool
Output string
Error error
}
// NewBuilder creates a new WASM builder.
// sourceDir is the directory containing Go source files.
// outputPath is the target WASM file path.
func NewBuilder(sourceDir, outputPath string) *Builder {
return &Builder{
sourceDir: sourceDir,
outputPath: outputPath,
}
}
// Build compiles the Go source to WASM.
func (b *Builder) Build() BuildResult {
b.mu.Lock()
if b.building {
b.mu.Unlock()
return BuildResult{
Success: false,
Output: "Build already in progress",
Error: fmt.Errorf("build already in progress"),
}
}
b.building = true
b.mu.Unlock()
defer func() {
b.mu.Lock()
b.building = false
b.mu.Unlock()
}()
cmd := exec.Command("go", "build", "-o", b.outputPath, b.sourceDir)
cmd.Env = append(cmd.Environ(), "GOOS=js", "GOARCH=wasm")
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String() + stderr.String()
if err != nil {
return BuildResult{
Success: false,
Output: output,
Error: err,
}
}
return BuildResult{
Success: true,
Output: output,
}
}
// IsBuilding returns true if a build is currently in progress.
func (b *Builder) IsBuilding() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.building
}

222
host/devserver.go Normal file
View File

@@ -0,0 +1,222 @@
package host
import (
"compress/gzip"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// DevServer provides a development server with hot reload support.
// It watches source files, rebuilds WASM on changes, and notifies
// connected browsers to reload.
type DevServer struct {
publicDir string
indexFile string
sourceDir string
outputPath string
watcher *Watcher
builder *Builder
liveReload *LiveReload
logger *log.Logger
}
// DevServerOption configures a DevServer.
type DevServerOption func(*DevServer)
// WithLogger sets a custom logger for the dev server.
func WithLogger(logger *log.Logger) DevServerOption {
return func(d *DevServer) {
d.logger = logger
}
}
// NewDevServer creates a new development server with hot reload.
//
// Parameters:
// - publicDir: Directory containing static files (e.g., "public")
// - indexFile: The fallback HTML file (e.g., "index.html")
// - sourceDir: Directory containing Go source files to watch (e.g., ".")
// - outputPath: Target path for compiled WASM file (e.g., "public/app.wasm")
func NewDevServer(publicDir, indexFile, sourceDir, outputPath string, opts ...DevServerOption) *DevServer {
d := &DevServer{
publicDir: publicDir,
indexFile: indexFile,
sourceDir: sourceDir,
outputPath: outputPath,
logger: log.New(os.Stdout, "[iris] ", log.LstdFlags),
}
for _, opt := range opts {
opt(d)
}
d.liveReload = NewLiveReload()
d.builder = NewBuilder(sourceDir, outputPath)
d.watcher = NewWatcher(sourceDir, d.onFileChange,
WithExtensions(".go"),
WithInterval(500*time.Millisecond),
)
return d
}
// Start begins watching for file changes.
func (d *DevServer) Start() error {
// Initial build
d.logger.Println("Performing initial build...")
result := d.builder.Build()
if !result.Success {
d.logger.Printf("Initial build failed: %v\n%s", result.Error, result.Output)
} else {
d.logger.Println("Initial build succeeded")
}
// Start watcher
d.logger.Printf("Watching %s for changes...", d.sourceDir)
return d.watcher.Start()
}
// Stop stops the file watcher.
func (d *DevServer) Stop() {
d.watcher.Stop()
}
// ServeHTTP implements http.Handler interface.
func (d *DevServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Handle live reload WebSocket endpoint
if r.URL.Path == "/__livereload" {
d.liveReload.ServeHTTP(w, r)
return
}
p := filepath.Join(d.publicDir, filepath.Clean(r.URL.Path))
if info, err := os.Stat(p); err != nil {
d.serveHTMLWithInjection(w, r, filepath.Join(d.publicDir, d.indexFile))
return
} else if info.IsDir() {
d.serveHTMLWithInjection(w, r, filepath.Join(d.publicDir, d.indexFile))
return
}
// Check if this is an HTML file
if strings.HasSuffix(p, ".html") {
d.serveHTMLWithInjection(w, r, p)
return
}
// Serve other files with compression
d.serveFileWithCompression(w, r, p)
}
func (d *DevServer) onFileChange() {
d.logger.Println("File change detected, rebuilding...")
result := d.builder.Build()
if !result.Success {
d.logger.Printf("Build failed: %v\n%s", result.Error, result.Output)
return
}
d.logger.Println("Build succeeded, reloading browsers...")
d.liveReload.NotifyReload()
}
func (d *DevServer) serveHTMLWithInjection(w http.ResponseWriter, r *http.Request, filePath string) {
// Read HTML file
content, err := os.ReadFile(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Inject reload script
content = InjectReloadScript(content)
// Set headers
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
// Check if client accepts gzip
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
gz := gzip.NewWriter(w)
defer gz.Close()
gz.Write(content)
} else {
w.Write(content)
}
}
func (d *DevServer) serveFileWithCompression(w http.ResponseWriter, r *http.Request, filePath string) {
// Check if client accepts gzip
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
http.ServeFile(w, r, filePath)
return
}
// Open and read file
file, err := os.Open(filePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
defer file.Close()
// Get file info for headers
fileInfo, err := file.Stat()
if err != nil {
http.Error(w, "Error reading file", http.StatusInternalServerError)
return
}
// Set content type based on file extension
contentType := getContentType(filepath.Ext(filePath))
w.Header().Set("Content-Type", contentType)
// Disable caching in dev mode for WASM files
if strings.HasSuffix(filePath, ".wasm") {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
// Check if we should compress this file type
if shouldCompress(contentType) {
// Set gzip headers
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
// Create gzip writer
gz := gzip.NewWriter(w)
defer gz.Close()
// Copy file content through gzip
_, err = io.Copy(gz, file)
if err != nil {
return
}
} else {
// Serve without compression
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
}
}
// ListenAndServe starts the development server on the specified address.
func (d *DevServer) ListenAndServe(addr string) error {
if err := d.Start(); err != nil {
return fmt.Errorf("failed to start watcher: %w", err)
}
d.logger.Printf("Development server running on http://%s", addr)
return http.ListenAndServe(addr, d)
}

80
host/inject.go Normal file
View File

@@ -0,0 +1,80 @@
package host
import (
"bytes"
"strings"
)
// ReloadScript is the JavaScript code injected into HTML pages for hot reload.
const ReloadScript = `<script>
(function() {
var wsUrl = 'ws://' + window.location.host + '/__livereload';
var ws;
var reconnectAttempts = 0;
var maxReconnectDelay = 5000;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[iris] Hot reload connected');
reconnectAttempts = 0;
};
ws.onmessage = function(event) {
if (event.data === 'reload') {
console.log('[iris] Reloading...');
window.location.reload();
}
};
ws.onclose = function() {
var delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
console.log('[iris] Connection lost. Reconnecting in ' + delay + 'ms...');
setTimeout(connect, delay);
};
ws.onerror = function() {
ws.close();
};
}
connect();
})();
</script>`
// InjectReloadScript injects the live reload script into HTML content.
// The script is inserted just before the closing </body> tag.
func InjectReloadScript(html []byte) []byte {
// Try to inject before </body>
bodyEnd := bytes.LastIndex(html, []byte("</body>"))
if bodyEnd != -1 {
result := make([]byte, 0, len(html)+len(ReloadScript))
result = append(result, html[:bodyEnd]...)
result = append(result, []byte(ReloadScript)...)
result = append(result, html[bodyEnd:]...)
return result
}
// Try to inject before </html>
htmlEnd := bytes.LastIndex(html, []byte("</html>"))
if htmlEnd != -1 {
result := make([]byte, 0, len(html)+len(ReloadScript))
result = append(result, html[:htmlEnd]...)
result = append(result, []byte(ReloadScript)...)
result = append(result, html[htmlEnd:]...)
return result
}
// Append at the end as fallback
result := make([]byte, 0, len(html)+len(ReloadScript))
result = append(result, html...)
result = append(result, []byte(ReloadScript)...)
return result
}
// IsHTMLContent checks if the content type indicates HTML.
func IsHTMLContent(contentType string) bool {
return strings.Contains(contentType, "text/html")
}

102
host/inject_test.go Normal file
View File

@@ -0,0 +1,102 @@
package host
import (
"strings"
"testing"
)
func TestInjectReloadScript_BeforeBody(t *testing.T) {
html := []byte(`<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<body>
<h1>Hello</h1>
</body>
</html>`)
result := InjectReloadScript(html)
// Should contain the script
if !strings.Contains(string(result), "livereload") {
t.Error("Expected result to contain livereload script")
}
// Script should be before </body>
bodyIndex := strings.Index(string(result), "</body>")
scriptIndex := strings.Index(string(result), "<script>")
if scriptIndex > bodyIndex {
t.Error("Expected script to be injected before </body>")
}
}
func TestInjectReloadScript_BeforeHTML(t *testing.T) {
// HTML without body tag
html := []byte(`<!DOCTYPE html>
<html>
<head><title>Test</title></head>
<div>Hello</div>
</html>`)
result := InjectReloadScript(html)
// Should contain the script
if !strings.Contains(string(result), "livereload") {
t.Error("Expected result to contain livereload script")
}
// Script should be before </html>
htmlIndex := strings.Index(string(result), "</html>")
scriptIndex := strings.Index(string(result), "<script>")
if scriptIndex > htmlIndex {
t.Error("Expected script to be injected before </html>")
}
}
func TestInjectReloadScript_Fallback(t *testing.T) {
// Minimal HTML without body or html closing tags
html := []byte(`<div>Hello</div>`)
result := InjectReloadScript(html)
// Should contain the script
if !strings.Contains(string(result), "livereload") {
t.Error("Expected result to contain livereload script")
}
// Should be appended at the end
if !strings.HasSuffix(string(result), "</script>") {
t.Error("Expected script to be appended at the end")
}
}
func TestInjectReloadScript_ContainsReconnect(t *testing.T) {
html := []byte(`<html><body></body></html>`)
result := InjectReloadScript(html)
// Should contain reconnection logic
if !strings.Contains(string(result), "reconnect") {
t.Error("Expected script to contain reconnection logic")
}
}
func TestIsHTMLContent(t *testing.T) {
tests := []struct {
contentType string
expected bool
}{
{"text/html", true},
{"text/html; charset=utf-8", true},
{"application/json", false},
{"text/css", false},
{"application/javascript", false},
}
for _, tt := range tests {
result := IsHTMLContent(tt.contentType)
if result != tt.expected {
t.Errorf("IsHTMLContent(%q) = %v, want %v", tt.contentType, result, tt.expected)
}
}
}

184
host/livereload.go Normal file
View File

@@ -0,0 +1,184 @@
package host
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"io"
"net"
"net/http"
"sync"
)
// LiveReload manages WebSocket connections for browser reload notifications.
type LiveReload struct {
mu sync.RWMutex
clients map[*wsConn]bool
}
// NewLiveReload creates a new LiveReload manager.
func NewLiveReload() *LiveReload {
return &LiveReload{
clients: make(map[*wsConn]bool),
}
}
// ServeHTTP handles WebSocket upgrade requests.
func (lr *LiveReload) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := lr.upgrade(w, r)
if err != nil {
http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest)
return
}
lr.addClient(conn)
defer lr.removeClient(conn)
// Keep connection alive and handle pings
conn.readLoop()
}
// NotifyReload sends a reload message to all connected clients.
func (lr *LiveReload) NotifyReload() {
lr.mu.RLock()
defer lr.mu.RUnlock()
for conn := range lr.clients {
conn.send([]byte("reload"))
}
}
// ClientCount returns the number of connected clients.
func (lr *LiveReload) ClientCount() int {
lr.mu.RLock()
defer lr.mu.RUnlock()
return len(lr.clients)
}
func (lr *LiveReload) addClient(conn *wsConn) {
lr.mu.Lock()
defer lr.mu.Unlock()
lr.clients[conn] = true
}
func (lr *LiveReload) removeClient(conn *wsConn) {
lr.mu.Lock()
defer lr.mu.Unlock()
delete(lr.clients, conn)
conn.close()
}
func (lr *LiveReload) upgrade(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
if r.Header.Get("Upgrade") != "websocket" {
return nil, io.ErrUnexpectedEOF
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
return nil, io.ErrUnexpectedEOF
}
// Compute accept key
h := sha1.New()
h.Write([]byte(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
acceptKey := base64.StdEncoding.EncodeToString(h.Sum(nil))
// Hijack the connection
hijacker, ok := w.(http.Hijacker)
if !ok {
return nil, io.ErrUnexpectedEOF
}
conn, bufrw, err := hijacker.Hijack()
if err != nil {
return nil, err
}
// Send upgrade response
bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
bufrw.WriteString("Upgrade: websocket\r\n")
bufrw.WriteString("Connection: Upgrade\r\n")
bufrw.WriteString("Sec-WebSocket-Accept: " + acceptKey + "\r\n")
bufrw.WriteString("\r\n")
bufrw.Flush()
return &wsConn{
conn: conn,
rw: bufrw,
}, nil
}
// wsConn is a minimal WebSocket connection.
type wsConn struct {
conn net.Conn
rw *bufio.ReadWriter
mu sync.Mutex
closed bool
}
func (c *wsConn) send(data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return io.ErrClosedPipe
}
// Write text frame
frame := makeFrame(1, data) // 1 = text frame
_, err := c.conn.Write(frame)
return err
}
func (c *wsConn) close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
// Send close frame
frame := makeFrame(8, nil) // 8 = close frame
c.conn.Write(frame)
c.conn.Close()
}
func (c *wsConn) readLoop() {
buf := make([]byte, 1024)
for {
_, err := c.rw.Read(buf)
if err != nil {
return
}
// We don't process incoming messages, just keep connection alive
}
}
func makeFrame(opcode byte, data []byte) []byte {
length := len(data)
var frame []byte
if length < 126 {
frame = make([]byte, 2+length)
frame[0] = 0x80 | opcode // FIN + opcode
frame[1] = byte(length)
copy(frame[2:], data)
} else if length < 65536 {
frame = make([]byte, 4+length)
frame[0] = 0x80 | opcode
frame[1] = 126
binary.BigEndian.PutUint16(frame[2:], uint16(length))
copy(frame[4:], data)
} else {
frame = make([]byte, 10+length)
frame[0] = 0x80 | opcode
frame[1] = 127
binary.BigEndian.PutUint64(frame[2:], uint64(length))
copy(frame[10:], data)
}
return frame
}

74
host/livereload_test.go Normal file
View File

@@ -0,0 +1,74 @@
package host
import (
"testing"
)
func TestLiveReload_ClientCount(t *testing.T) {
lr := NewLiveReload()
if count := lr.ClientCount(); count != 0 {
t.Errorf("Expected 0 clients, got %d", count)
}
}
func TestMakeFrame_SmallPayload(t *testing.T) {
data := []byte("reload")
frame := makeFrame(1, data)
// First byte: FIN + opcode
if frame[0] != 0x81 {
t.Errorf("Expected first byte 0x81, got 0x%02x", frame[0])
}
// Second byte: length (no mask, small payload)
if frame[1] != byte(len(data)) {
t.Errorf("Expected length %d, got %d", len(data), frame[1])
}
// Payload
payload := frame[2:]
if string(payload) != string(data) {
t.Errorf("Expected payload %q, got %q", data, payload)
}
}
func TestMakeFrame_MediumPayload(t *testing.T) {
// Create payload between 126 and 65535 bytes
data := make([]byte, 200)
for i := range data {
data[i] = byte(i % 256)
}
frame := makeFrame(1, data)
// First byte: FIN + opcode
if frame[0] != 0x81 {
t.Errorf("Expected first byte 0x81, got 0x%02x", frame[0])
}
// Second byte: 126 indicates extended length
if frame[1] != 126 {
t.Errorf("Expected length indicator 126, got %d", frame[1])
}
// Extended length (2 bytes, big endian)
extLen := int(frame[2])<<8 | int(frame[3])
if extLen != len(data) {
t.Errorf("Expected extended length %d, got %d", len(data), extLen)
}
}
func TestMakeFrame_CloseFrame(t *testing.T) {
frame := makeFrame(8, nil)
// First byte: FIN + close opcode
if frame[0] != 0x88 {
t.Errorf("Expected first byte 0x88, got 0x%02x", frame[0])
}
// Second byte: 0 length
if frame[1] != 0 {
t.Errorf("Expected length 0, got %d", frame[1])
}
}

169
host/watcher.go Normal file
View File

@@ -0,0 +1,169 @@
package host
import (
"os"
"path/filepath"
"sync"
"time"
)
// Watcher monitors files for changes and triggers callbacks.
type Watcher struct {
dir string
extensions []string
onChange func()
interval time.Duration
mu sync.Mutex
modTimes map[string]time.Time
stop chan struct{}
wg sync.WaitGroup
}
// WatcherOption configures a Watcher.
type WatcherOption func(*Watcher)
// WithExtensions sets file extensions to watch (e.g., ".go", ".html").
func WithExtensions(exts ...string) WatcherOption {
return func(w *Watcher) {
w.extensions = exts
}
}
// WithInterval sets the polling interval for file changes.
func WithInterval(d time.Duration) WatcherOption {
return func(w *Watcher) {
w.interval = d
}
}
// NewWatcher creates a new file watcher that monitors the specified directory.
func NewWatcher(dir string, onChange func(), opts ...WatcherOption) *Watcher {
w := &Watcher{
dir: dir,
extensions: []string{".go"},
onChange: onChange,
interval: 500 * time.Millisecond,
modTimes: make(map[string]time.Time),
stop: make(chan struct{}),
}
for _, opt := range opts {
opt(w)
}
return w
}
// Start begins watching for file changes.
func (w *Watcher) Start() error {
// Initial scan to populate mod times
if err := w.scan(false); err != nil {
return err
}
w.wg.Add(1)
go w.watch()
return nil
}
// Stop stops the watcher.
func (w *Watcher) Stop() {
close(w.stop)
w.wg.Wait()
}
func (w *Watcher) watch() {
defer w.wg.Done()
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-w.stop:
return
case <-ticker.C:
if err := w.scan(true); err != nil {
// Log error but continue watching
continue
}
}
}
}
func (w *Watcher) scan(notify bool) error {
w.mu.Lock()
defer w.mu.Unlock()
changed := false
seen := make(map[string]bool)
err := filepath.Walk(w.dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip files we can't access
}
if info.IsDir() {
// Skip hidden directories and vendor
name := info.Name()
if len(name) > 0 && name[0] == '.' {
return filepath.SkipDir
}
if name == "vendor" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
// Check if extension matches
ext := filepath.Ext(path)
if !w.matchesExtension(ext) {
return nil
}
seen[path] = true
modTime := info.ModTime()
if prev, exists := w.modTimes[path]; !exists {
// New file
w.modTimes[path] = modTime
if notify {
changed = true
}
} else if !modTime.Equal(prev) {
// Modified file
w.modTimes[path] = modTime
changed = true
}
return nil
})
if err != nil {
return err
}
// Check for deleted files
for path := range w.modTimes {
if !seen[path] {
delete(w.modTimes, path)
changed = true
}
}
if changed && notify && w.onChange != nil {
w.onChange()
}
return nil
}
func (w *Watcher) matchesExtension(ext string) bool {
for _, e := range w.extensions {
if e == ext {
return true
}
}
return false
}

216
host/watcher_test.go Normal file
View File

@@ -0,0 +1,216 @@
package host
import (
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatcher_DetectsNewFiles(t *testing.T) {
dir := t.TempDir()
// Create initial file
initialFile := filepath.Join(dir, "initial.go")
if err := os.WriteFile(initialFile, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Create new file
newFile := filepath.Join(dir, "new.go")
if err := os.WriteFile(newFile, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Wait for detection
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count == 0 {
t.Error("Expected change to be detected for new file")
}
}
func TestWatcher_DetectsModifiedFiles(t *testing.T) {
dir := t.TempDir()
// Create initial file
file := filepath.Join(dir, "test.go")
if err := os.WriteFile(file, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Ensure mod time will be different
time.Sleep(100 * time.Millisecond)
// Modify file
if err := os.WriteFile(file, []byte("package main\n// updated"), 0644); err != nil {
t.Fatal(err)
}
// Wait for detection
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count == 0 {
t.Error("Expected change to be detected for modified file")
}
}
func TestWatcher_DetectsDeletedFiles(t *testing.T) {
dir := t.TempDir()
// Create initial file
file := filepath.Join(dir, "test.go")
if err := os.WriteFile(file, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Delete file
if err := os.Remove(file); err != nil {
t.Fatal(err)
}
// Wait for detection
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count == 0 {
t.Error("Expected change to be detected for deleted file")
}
}
func TestWatcher_IgnoresNonMatchingExtensions(t *testing.T) {
dir := t.TempDir()
// Create initial go file
goFile := filepath.Join(dir, "main.go")
if err := os.WriteFile(goFile, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond), WithExtensions(".go"))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Create non-matching file
txtFile := filepath.Join(dir, "readme.txt")
if err := os.WriteFile(txtFile, []byte("readme"), 0644); err != nil {
t.Fatal(err)
}
// Wait
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count != 0 {
t.Error("Expected no change detected for non-matching extension")
}
}
func TestWatcher_CustomExtensions(t *testing.T) {
dir := t.TempDir()
// Create initial file
file := filepath.Join(dir, "style.css")
if err := os.WriteFile(file, []byte("body {}"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond), WithExtensions(".css", ".html"))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Ensure mod time will be different
time.Sleep(100 * time.Millisecond)
// Modify css file
if err := os.WriteFile(file, []byte("body { color: red; }"), 0644); err != nil {
t.Fatal(err)
}
// Wait for detection
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count == 0 {
t.Error("Expected change to be detected for custom extension")
}
}
func TestWatcher_SkipsHiddenDirs(t *testing.T) {
dir := t.TempDir()
// Create hidden directory with go file
hiddenDir := filepath.Join(dir, ".hidden")
if err := os.MkdirAll(hiddenDir, 0755); err != nil {
t.Fatal(err)
}
// Create a visible go file first
visibleFile := filepath.Join(dir, "visible.go")
if err := os.WriteFile(visibleFile, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
var changeCount int32
w := NewWatcher(dir, func() {
atomic.AddInt32(&changeCount, 1)
}, WithInterval(50*time.Millisecond))
if err := w.Start(); err != nil {
t.Fatal(err)
}
defer w.Stop()
// Create file in hidden directory
hiddenFile := filepath.Join(hiddenDir, "hidden.go")
if err := os.WriteFile(hiddenFile, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Wait
time.Sleep(200 * time.Millisecond)
if count := atomic.LoadInt32(&changeCount); count != 0 {
t.Error("Expected no change detected for hidden directory")
}
}

View File

@@ -205,4 +205,23 @@ func NumberInput(value *reactive.Signal[float64], placeholder string) View {
}) })
return View{input} return View{input}
} }
// RawCheckbox creates a plain checkbox input without a label wrapper.
// The onChange callback is called with the new checked state when the user clicks.
func RawCheckbox(checked bool, onChange func(bool)) View {
checkbox := element.NewElement("input")
checkbox.Attr("type", "checkbox")
// Set initial state
if checked {
checkbox.Set("checked", true)
}
// Call onChange when user clicks
checkbox.On("change", func() {
newValue := checkbox.Get("checked").Bool()
onChange(newValue)
})
return View{checkbox}
}

View File

@@ -170,3 +170,8 @@ func (v View) PointerEvents(value string) View {
v.e.PointerEvents(value) v.e.PointerEvents(value)
return v return v
} }
func (v View) TextDecoration(value string) View {
v.e.Get("style").Call("setProperty", "text-decoration", value)
return v
}

View File

@@ -21,3 +21,10 @@ func TextFromFunction(fn func() string) View {
return View{textNode} return View{textNode}
} }
// Span creates a span element with the given text content.
func Span(text string) View {
v := View{element.NewElement("span")}
v.e.Set("textContent", text)
return v
}