Compare commits

6 Commits

18 changed files with 740 additions and 244 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
nebula
.osgrep

292
AGENTS.md
View File

@@ -1,227 +1,133 @@
# AGENTS.md - Nebula Project Guidelines
# NEBULA PROJECT KNOWLEDGE BASE
## Project Overview
**Generated:** 2026-01-11 | **Commit:** fcbe848 | **Branch:** feat/pkg
Nebula is a Go/Templ wallet application with HTMX 4 for server-driven UI and WebAwesome (wa-*) web components.
## OVERVIEW
**Stack**: Go 1.25+ | templ v0.3.977 | htmx 4.0.0-alpha5 | WebAwesome | SQLite WASM
Go/Templ wallet UI framework using HTMX 4 for server-driven interactions and WebAwesome (`wa-*`) web components. Hybrid: root is a library (`package nebula`), `cmd/server/` is the reference app.
## Build & Run Commands
```bash
# Generate Go code from .templ files (REQUIRED after any .templ changes)
templ generate
# Build the project
go build ./...
# Run development server (serves on :8080)
go run main.go
# Watch mode for templ files
templ generate --watch
# Full build + run + open browser
make all
```
## Directory Structure
## STRUCTURE
```
nebula/
├── models/ # Data types (MVC models)
├── views/ # Page templates (.templ)
├── layouts/ # Base layouts (DashboardLayout, CenteredLayout)
├── components/ # Reusable UI components
├── handlers/ # HTTP route handlers
├── _migrate/ # HTML prototypes to convert
├── HTMX.md # htmx 4 patterns documentation
── SQLC.md # Database schema documentation
├── cmd/server/ # App entry: main.go + routes.go (handlers HERE, not handlers/)
├── views/ # Page templates (.templ) - XxxPage, XxxContent, XxxWithStepper
├── components/ # Reusable UI (sidebar, stepper, navbar, charts)
├── layouts/ # Base (HTML shell), App (dashboard), Centered (auth)
├── models/ # Data structs + DefaultXxxData() factories
├── htmx/ # HTMX 4 context/request/response helpers
├── pkg/config/ # Config models for middleware injection
── _migrate/ # HTML prototypes pending conversion (technical debt)
├── middleware.go # Injects HTMX + Config into request context
├── mount.go # Mount() helper for handler registration
├── options.go # WithXxx() functional options
└── config.go # DefaultConfig() + Config struct
```
## Code Style
## WHERE TO LOOK
### Go Files
| Task | Location | Notes |
|------|----------|-------|
| Add route | `cmd/server/routes.go` | Go 1.22+ patterns: `GET /path/{param}` |
| Add page | `views/` + `models/` | Create XxxPage + model, register in routes.go |
| HTMX patterns | `HTMX.md` | `<hx-partial>`, morphing, SSE, OOB updates |
| Modify layout | `layouts/base.templ` | CDN scripts, htmx-config meta tag |
| WebAwesome ref | [webawesome.com](https://webawesome.com) | All `wa-*` components |
| Migrate HTML | `_migrate/` -> `views/` | See MIGRATION.md |
- Standard `gofmt` formatting
- Package-level types in `models/` directory
- Handlers in `handlers/routes.go` with pattern: `handleX(w http.ResponseWriter, r *http.Request)`
- Use `r.Header.Get("HX-Request") == "true"` to detect HTMX requests
- Return partials for HTMX, full pages for direct navigation
## CONVENTIONS
### Templ Files (.templ)
### Naming
- One main page template per file: `PageName(data ModelType) templ.Component`
- Helper functions lowercase: `helperName()`
- CSS-in-templ using `templ css` blocks or `<style>` tags
- Component parameters use Go types from `models/` package
| Type | Pattern | Example |
|------|---------|---------|
| Page template | `XxxPage(data)` | `DashboardPage(data, tab)` |
| Partial (HTMX) | `XxxContent`, `XxxTab` | `DashboardContent(data, tab)` |
| OOB update | `XxxWithStepper`, `XxxWithOOB` | `WelcomeStepWithStepper(step)` |
| Model | `XxxData`, `XxxState` | `DashboardData`, `LoginState` |
| Factory | `DefaultXxxData()` | `DefaultDashboardData()` |
| Handler | `handleXxx` | `handleDashboard` |
### Naming Conventions
| Type | Convention | Example |
|------|------------|---------|
| Page template | `XxxPage` | `SettingsPage`, `DashboardPage` |
| Partial template | `XxxContent`, `XxxTab` | `DashboardContent`, `ProfileTab` |
| OOB update | `XxxWithOOB`, `XxxWithStepper` | `LoginStepWithOOB` |
| Model struct | PascalCase | `SettingsData`, `ProfileSettings` |
| Handler func | `handleXxx` | `handleSettings`, `handleDashboard` |
## HTMX 4 Patterns
### Partial Updates
```html
<div hx-get="/endpoint" hx-target="#target" hx-swap="innerHTML">
```
### Out-of-Band Updates
### HTMX 4 Request Pattern
```go
templ ComponentWithOOB() {
@MainContent()
<div id="sidebar" hx-swap-oob="true">
@UpdatedSidebar()
func handleXxx(w http.ResponseWriter, r *http.Request) {
data := models.DefaultXxxData()
if r.Header.Get("HX-Request") == "true" {
views.XxxContent(data).Render(r.Context(), w) // Partial
return
}
views.XxxPage(data).Render(r.Context(), w) // Full page
}
```
### OOB Updates (Multi-Target)
```templ
templ XxxStepWithStepper(step int) {
@XxxStepContent(step)
<div id="stepper-container" hx-swap-oob="innerHTML">
@components.Stepper(step, steps)
</div>
}
```
### HTMX Request Detection
### Templ Syntax Quick Ref
```go
if r.Header.Get("HX-Request") == "true" {
views.PartialContent(data).Render(r.Context(), w)
return
}
views.FullPage(data).Render(r.Context(), w)
```templ
if cond { } // Conditional
for _, item := range items { } // Loop
<input disabled?={ isDisabled } /> // Bool attr
<div class={ "base " + extra }> // String interpolation
@ChildComponent(props) // Composition
@templ.Raw(html) // Raw HTML (avoid)
```
## WebAwesome Components
## ANTI-PATTERNS (THIS PROJECT)
Use `wa-*` web components for UI:
| DO NOT | Why |
|--------|-----|
| Edit `*_templ.go` files | Generated by `templ generate` - changes purged |
| Use separate `htmx-ext-*` packages | HTMX 4 bundles extensions at `/dist/ext/` |
| Rely on implicit inheritance | Use `:inherited` modifier (`hx-target:inherited`) |
| Use `TopBar` or `TopBarStyles` | DEPRECATED - use `wa-page` slots instead |
| Manual DOM manipulation | Use `hx-*` attributes; server controls state |
| Put handlers in `handlers/` | Docs outdated - use `cmd/server/routes.go` |
```html
<wa-button variant="brand">Click me</wa-button>
<wa-card>Content</wa-card>
<wa-tab-group>
<wa-tab panel="one">Tab 1</wa-tab>
<wa-tab-panel name="one">Panel 1</wa-tab-panel>
</wa-tab-group>
<wa-icon name="user"></wa-icon>
<wa-avatar initials="JD"></wa-avatar>
```
## Route Registration
All routes in `handlers/routes.go`:
```go
func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /path", handlePath)
mux.HandleFunc("POST /path/action", handleAction)
}
```
Go 1.22+ routing patterns:
- `GET /path` - method prefix
- `GET /path/{param}` - path parameters via `r.PathValue("param")`
- `GET /path/{param...}` - catch-all
## Model Pattern
Models in `models/` package with `DefaultXxxData()` factory functions:
```go
// models/example.go
package models
type ExampleData struct {
Field1 string
Field2 int
Items []Item
}
func DefaultExampleData() ExampleData {
return ExampleData{
Field1: "default",
Items: []Item{...},
}
}
```
## Migration Pattern (HTML to Templ)
When converting `_migrate/*.html` files:
1. Create model types in `models/xxx.go`
2. Create view in `views/xxx.templ`
3. Add route in `handlers/routes.go`
4. Update sidebar active state in `components/sidebar.templ`
### Templ Syntax Reference
```go
// Conditionals
if condition {
<div>shown</div>
}
// Loops
for _, item := range items {
<div>{ item.Name }</div>
}
// Boolean attributes
<input disabled?={ isDisabled } />
<wa-tab active?={ tab == "profile" }>
// String interpolation
<div class={ "base " + extraClass }>
<div data-id={ item.ID }>
// Raw HTML (use sparingly)
@templ.Raw(htmlString)
// Component composition
@ChildComponent(props)
```
## Testing
## COMMANDS
```bash
# Run all tests
go test ./...
# Generate templ (REQUIRED after .templ changes)
templ generate
# Run with verbose
go test -v ./...
# Dev server on :8080
go run ./cmd/server
# OR
make start
# Run specific package
go test ./handlers/...
# Full cycle: generate + start + open browser
make all
# Build binary
make build # outputs ./nebula
# Watch mode
templ generate --watch
```
## Common Tasks
## STACK VERSIONS
### Add New Page
- **Go**: 1.25.5 (cutting-edge)
- **templ**: v0.3.977
- **htmx**: 4.0.0-alpha5 (CDN)
- **WebAwesome**: Kit 47c7425b971f443c (CDN)
- **D3.js**: v7 (charts)
1. Create `models/newpage.go` with data types
2. Create `views/newpage.templ` with page template
3. Add handler in `handlers/routes.go`
4. Add nav item in `components/sidebar.templ` if needed
## NOTES
### Add New Component
1. Create in `components/newcomp.templ`
2. Import in views: `import "nebula/components"`
3. Use: `@components.NewComp(props)`
### Modify Layout
Layouts in `layouts/`:
- `base.templ` - HTML document wrapper with htmx CDN
- `app.templ` - Dashboard app shell with sidebar
- `centered.templ` - Centered auth pages
## Dependencies
- **templ**: Template engine - `go install github.com/a-h/templ/cmd/templ@latest`
- **htmx 4**: Loaded from CDN in `layouts/base.templ`
- **WebAwesome**: Loaded from CDN in `layouts/base.templ`
- **No tests exist** - `go test ./...` returns "no test files"
- **No CI/CD** - No `.github/workflows/`
- **SQLite WASM** mentioned in docs but not implemented
- **Documentation references `handlers/`** - doesn't exist; handlers are in `cmd/server/routes.go`
- Config split: root `config.go` (app config) vs `pkg/config/` (HTMX feature flags)
- Middleware chain: `nebula.Middleware(opts...)(mux)` injects context for templates

View File

@@ -4,10 +4,13 @@ all: gen start open
gen:
@templ generate
build:
@go build -o nebula ./cmd/server
start:
@go run main.go
@go run ./cmd/server
open:
@open http://localhost:8080
.PHONY: gen start
.PHONY: gen build start open all

29
cmd/server/main.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"fmt"
"log"
"net/http"
"nebula"
)
func main() {
mux := http.NewServeMux()
// Register application routes
registerRoutes(mux)
// Apply nebula middleware
handler := nebula.Middleware(
nebula.WithBasePath("/"),
nebula.WithAppTitle("Sonr Wallet"),
nebula.WithTheme("dark"),
nebula.WithTransitions(true),
nebula.WithPreload(true),
)(mux)
addr := ":8080"
fmt.Printf("Starting server at http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, handler))
}

View File

@@ -1,4 +1,4 @@
package handlers
package main
import (
"net/http"
@@ -8,7 +8,7 @@ import (
"nebula/views"
)
func RegisterRoutes(mux *http.ServeMux) {
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", handleWelcome)
mux.HandleFunc("GET /welcome", handleWelcome)
mux.HandleFunc("GET /welcome/step/{step}", handleWelcomeStep)

19
config.go Normal file
View File

@@ -0,0 +1,19 @@
package nebula
import (
"context"
"nebula/pkg/config"
)
type Config = config.Config
type HTMXConfig = config.HTMXConfig
type SSEConfig = config.SSEConfig
func DefaultConfig() Config {
return config.Default()
}
func ConfigFromContext(ctx context.Context) *Config {
return config.FromContext(ctx)
}

36
htmx/context.go Normal file
View File

@@ -0,0 +1,36 @@
package htmx
import "context"
type contextKey string
const htmxContextKey contextKey = "htmx"
type Context struct {
IsHTMX bool
IsPartial bool
IsBoosted bool
Target string
Trigger string
TriggerID string
CurrentURL string
}
func WithContext(ctx context.Context, hc *Context) context.Context {
return context.WithValue(ctx, htmxContextKey, hc)
}
func FromContext(ctx context.Context) *Context {
if hc, ok := ctx.Value(htmxContextKey).(*Context); ok {
return hc
}
return &Context{}
}
func IsHTMXRequest(ctx context.Context) bool {
return FromContext(ctx).IsHTMX
}
func IsPartialRequest(ctx context.Context) bool {
return FromContext(ctx).IsPartial
}

51
htmx/request.go Normal file
View File

@@ -0,0 +1,51 @@
package htmx
import "net/http"
func IsRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
func IsPartial(r *http.Request) bool {
return r.Header.Get("HX-Request-Type") == "partial"
}
func IsBoosted(r *http.Request) bool {
return r.Header.Get("HX-Boosted") == "true"
}
func IsHistoryRestore(r *http.Request) bool {
return r.Header.Get("HX-History-Restore-Request") == "true"
}
func GetTarget(r *http.Request) string {
return r.Header.Get("HX-Target")
}
func GetTrigger(r *http.Request) string {
return r.Header.Get("HX-Trigger")
}
func GetTriggerName(r *http.Request) string {
return r.Header.Get("HX-Trigger-Name")
}
func GetCurrentURL(r *http.Request) string {
return r.Header.Get("HX-Current-URL")
}
func GetPromptResponse(r *http.Request) string {
return r.Header.Get("HX-Prompt")
}
func ExtractContext(r *http.Request) *Context {
return &Context{
IsHTMX: IsRequest(r),
IsPartial: IsPartial(r),
IsBoosted: IsBoosted(r),
Target: GetTarget(r),
Trigger: GetTrigger(r),
TriggerID: GetTrigger(r),
CurrentURL: GetCurrentURL(r),
}
}

103
htmx/response.go Normal file
View File

@@ -0,0 +1,103 @@
package htmx
import (
"encoding/json"
"net/http"
)
type Response struct {
w http.ResponseWriter
}
func NewResponse(w http.ResponseWriter) *Response {
return &Response{w: w}
}
func (r *Response) Header() http.Header {
return r.w.Header()
}
func (r *Response) Location(url string) *Response {
r.w.Header().Set("HX-Location", url)
return r
}
func (r *Response) LocationWithTarget(url, target string) *Response {
data, _ := json.Marshal(map[string]string{"path": url, "target": target})
r.w.Header().Set("HX-Location", string(data))
return r
}
func (r *Response) PushURL(url string) *Response {
r.w.Header().Set("HX-Push-Url", url)
return r
}
func (r *Response) ReplaceURL(url string) *Response {
r.w.Header().Set("HX-Replace-Url", url)
return r
}
func (r *Response) Redirect(url string) *Response {
r.w.Header().Set("HX-Redirect", url)
return r
}
func (r *Response) Refresh() *Response {
r.w.Header().Set("HX-Refresh", "true")
return r
}
func (r *Response) Reswap(strategy string) *Response {
r.w.Header().Set("HX-Reswap", strategy)
return r
}
func (r *Response) ReswapWithModifiers(strategy string) *Response {
r.w.Header().Set("HX-Reswap", strategy)
return r
}
func (r *Response) Retarget(selector string) *Response {
r.w.Header().Set("HX-Retarget", selector)
return r
}
func (r *Response) Reselect(selector string) *Response {
r.w.Header().Set("HX-Reselect", selector)
return r
}
func (r *Response) Trigger(event string) *Response {
r.w.Header().Set("HX-Trigger", event)
return r
}
func (r *Response) TriggerWithData(event string, data any) *Response {
payload, _ := json.Marshal(map[string]any{event: data})
r.w.Header().Set("HX-Trigger", string(payload))
return r
}
func (r *Response) TriggerAfterSwap(event string) *Response {
r.w.Header().Set("HX-Trigger-After-Swap", event)
return r
}
func (r *Response) TriggerAfterSettle(event string) *Response {
r.w.Header().Set("HX-Trigger-After-Settle", event)
return r
}
func (r *Response) StopPolling() *Response {
r.w.WriteHeader(286)
return r
}
func (r *Response) NoContent() {
r.w.WriteHeader(http.StatusNoContent)
}
func (r *Response) PreventSwap() *Response {
return r.Reswap("none")
}

View File

@@ -1,36 +1,44 @@
package layouts
// Base provides the HTML document structure for all pages
import "nebula/pkg/config"
templ Base(title string) {
<!DOCTYPE html>
<html lang="en" class="wa-cloak wa-theme-dark">
<html lang="en" class={ "wa-cloak", config.FromContext(ctx).ThemeClass() }>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="color-scheme" content="dark light"/>
<title>{ title } - Sonr Motr Wallet</title>
<meta name="htmx-config" content={ config.FromContext(ctx).HTMXConfigJSON() }/>
<title>{ title } - { config.FromContext(ctx).AppTitle }</title>
<script src="https://kit.webawesome.com/47c7425b971f443c.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>
if config.FromContext(ctx).Preload {
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js"></script>
}
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<style>
:root {
--wa-color-primary: #17c2ff;
--sidebar-width: 64px;
}
html, body {
min-height: 100%;
padding: 0;
margin: 0;
cursor: default;
}
wa-button, a, [onclick], [hx-get], [hx-post] {
cursor: pointer;
}
</style>
@baseStyles()
</head>
<body>
{ children... }
</body>
</html>
}
templ baseStyles() {
<style>
:root {
--wa-color-primary: #17c2ff;
--sidebar-width: 64px;
}
html, body {
min-height: 100%;
padding: 0;
margin: 0;
cursor: default;
}
wa-button, a, [onclick], [hx-get], [hx-post] {
cursor: pointer;
}
</style>
}

View File

@@ -8,7 +8,8 @@ package layouts
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
// Base provides the HTML document structure for all pages
import "nebula/pkg/config"
func Base(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -30,20 +31,86 @@ func Base(title string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"wa-cloak wa-theme-dark\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"color-scheme\" content=\"dark light\"><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 11, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
var templ_7745c5c3_Var2 = []any{"wa-cloak", config.FromContext(ctx).ThemeClass()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - Sonr Motr Wallet</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js\"></script><script src=\"https://cdn.jsdelivr.net/npm/d3@7\"></script><style>\n\t\t\t\t:root {\n\t\t\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t\t\t--sidebar-width: 64px;\n\t\t\t\t}\n\t\t\t\thtml, body {\n\t\t\t\t\tmin-height: 100%;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tcursor: default;\n\t\t\t\t}\n\t\t\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t}\n\t\t\t</style></head><body>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<html lang=\"en\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"color-scheme\" content=\"dark light\"><meta name=\"htmx-config\" content=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(config.FromContext(ctx).HTMXConfigJSON())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 12, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 13, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " - ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(config.FromContext(ctx).AppTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 13, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</title><script src=\"https://kit.webawesome.com/47c7425b971f443c.js\" crossorigin=\"anonymous\"></script><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/htmx.min.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if config.FromContext(ctx).Preload {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<script src=\"https://cdn.jsdelivr.net/npm/htmx.org@4.0.0-alpha5/dist/ext/hx-preload.js\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<script src=\"https://cdn.jsdelivr.net/npm/d3@7\"></script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = baseStyles().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</head><body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -51,7 +118,36 @@ func Base(title string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func baseStyles() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<style>\n\t\t:root {\n\t\t\t--wa-color-primary: #17c2ff;\n\t\t\t--sidebar-width: 64px;\n\t\t}\n\t\thtml, body {\n\t\t\tmin-height: 100%;\n\t\t\tpadding: 0;\n\t\t\tmargin: 0;\n\t\t\tcursor: default;\n\t\t}\n\t\twa-button, a, [onclick], [hx-get], [hx-post] {\n\t\t\tcursor: pointer;\n\t\t}\n\t</style>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

18
main.go
View File

@@ -1,18 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"nebula/handlers"
)
func main() {
mux := http.NewServeMux()
handlers.RegisterRoutes(mux)
addr := ":8080"
fmt.Printf("Starting server at http://localhost%s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}

38
middleware.go Normal file
View File

@@ -0,0 +1,38 @@
package nebula
import (
"net/http"
"nebula/htmx"
"nebula/pkg/config"
)
func Middleware(opts ...Option) func(http.Handler) http.Handler {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hxCtx := htmx.ExtractContext(r)
ctx := htmx.WithContext(r.Context(), hxCtx)
ctx = config.WithContext(ctx, &cfg)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func SSEMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "text/event-stream" {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
}
next.ServeHTTP(w, r)
})
}
}

40
mount.go Normal file
View File

@@ -0,0 +1,40 @@
package nebula
import (
"net/http"
)
// Handler creates a new http.Handler with nebula middleware applied.
// The handler parameter is wrapped with HTMX context and config middleware.
func Handler(handler http.Handler, opts ...Option) http.Handler {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
var h http.Handler = handler
h = Middleware(opts...)(h)
if cfg.SSE.Enabled {
h = SSEMiddleware()(h)
}
return h
}
// Mount registers a handler at the configured base path on the provided mux.
// Use this to mount your application routes with nebula middleware.
func Mount(mux *http.ServeMux, handler http.Handler, opts ...Option) {
cfg := DefaultConfig()
for _, opt := range opts {
opt(&cfg)
}
h := Handler(handler, opts...)
if cfg.BasePath == "/" {
mux.Handle("/", h)
} else {
mux.Handle(cfg.BasePath+"/", http.StripPrefix(cfg.BasePath, h))
}
}

78
options.go Normal file
View File

@@ -0,0 +1,78 @@
package nebula
type Option func(*Config)
func WithBasePath(path string) Option {
return func(c *Config) {
c.BasePath = path
}
}
func WithAppTitle(title string) Option {
return func(c *Config) {
c.AppTitle = title
}
}
func WithTheme(theme string) Option {
return func(c *Config) {
c.Theme = theme
}
}
func WithTransitions(enabled bool) Option {
return func(c *Config) {
c.HTMX.Transitions = enabled
}
}
func WithImplicitInheritance(enabled bool) Option {
return func(c *Config) {
c.HTMX.ImplicitInheritance = enabled
}
}
func WithSelfRequestsOnly(enabled bool) Option {
return func(c *Config) {
c.HTMX.SelfRequestsOnly = enabled
}
}
func WithTimeout(ms int) Option {
return func(c *Config) {
c.HTMX.Timeout = ms
}
}
func WithNoSwap(codes ...string) Option {
return func(c *Config) {
c.HTMX.NoSwap = codes
}
}
func WithSSE(enabled bool) Option {
return func(c *Config) {
c.SSE.Enabled = enabled
}
}
func WithSSEReconnect(enabled bool, delay, maxDelay, maxAttempts int) Option {
return func(c *Config) {
c.SSE.Reconnect = enabled
c.SSE.ReconnectDelay = delay
c.SSE.ReconnectMaxDelay = maxDelay
c.SSE.ReconnectMaxAttempts = maxAttempts
}
}
func WithPreload(enabled bool) Option {
return func(c *Config) {
c.Preload = enabled
}
}
func WithMorphing(enabled bool) Option {
return func(c *Config) {
c.Morphing = enabled
}
}

106
pkg/config/config.go Normal file
View File

@@ -0,0 +1,106 @@
package config
import (
"context"
"encoding/json"
"fmt"
)
type contextKey struct{}
type Config struct {
BasePath string
AppTitle string
Theme string
HTMX HTMXConfig
SSE SSEConfig
Preload bool
Morphing bool
}
type HTMXConfig struct {
Transitions bool
ImplicitInheritance bool
SelfRequestsOnly bool
Timeout int
NoSwap []string
}
type SSEConfig struct {
Enabled bool
Reconnect bool
ReconnectDelay int
ReconnectMaxDelay int
ReconnectMaxAttempts int
ReconnectJitter float64
PauseInBackground bool
}
func Default() Config {
return Config{
BasePath: "/",
AppTitle: "Sonr Wallet",
Theme: "dark",
HTMX: HTMXConfig{
Transitions: true,
ImplicitInheritance: false,
SelfRequestsOnly: true,
Timeout: 0,
NoSwap: []string{"204", "304"},
},
SSE: SSEConfig{
Enabled: true,
Reconnect: true,
ReconnectDelay: 500,
ReconnectMaxDelay: 60000,
ReconnectMaxAttempts: 10,
ReconnectJitter: 0.3,
PauseInBackground: false,
},
Preload: true,
Morphing: true,
}
}
func WithContext(ctx context.Context, cfg *Config) context.Context {
return context.WithValue(ctx, contextKey{}, cfg)
}
func FromContext(ctx context.Context) *Config {
if cfg, ok := ctx.Value(contextKey{}).(*Config); ok {
return cfg
}
defaultCfg := Default()
return &defaultCfg
}
func (c *Config) HTMXConfigJSON() string {
cfg := map[string]any{
"transitions": c.HTMX.Transitions,
"implicitInheritance": c.HTMX.ImplicitInheritance,
"selfRequestsOnly": c.HTMX.SelfRequestsOnly,
"timeout": c.HTMX.Timeout,
"noSwap": c.HTMX.NoSwap,
}
if c.SSE.Enabled {
cfg["streams"] = map[string]any{
"reconnect": c.SSE.Reconnect,
"reconnectDelay": c.SSE.ReconnectDelay,
"reconnectMaxDelay": c.SSE.ReconnectMaxDelay,
"reconnectMaxAttempts": c.SSE.ReconnectMaxAttempts,
"reconnectJitter": c.SSE.ReconnectJitter,
"pauseInBackground": c.SSE.PauseInBackground,
}
}
data, err := json.Marshal(cfg)
if err != nil {
return "{}"
}
return string(data)
}
func (c *Config) ThemeClass() string {
return fmt.Sprintf("wa-theme-%s", c.Theme)
}

BIN
server Executable file

Binary file not shown.