package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
frontend "github.com/YoshihideShirai/marionette/frontend"
"github.com/YoshihideShirai/marionette/frontend/assets"
)
// Context gives handlers controlled access to application state and request data.
type Context struct {
Writer http.ResponseWriter
Request *http.Request
Local map[string]any
app *App
flashes []FlashMessage
session map[string]string
}
type FlashLevel = frontend.FlashLevel
type FlashMessage = frontend.FlashMessage
const (
FlashSuccess = frontend.FlashSuccess
FlashError = frontend.FlashError
FlashInfo = frontend.FlashInfo
FlashWarn = frontend.FlashWarn
)
const flashCookieName = "marionette_flash"
const sessionCookieName = "marionette_session"
func (c *Context) Param(name string) string {
if c.Request == nil {
return ""
}
return c.Request.PathValue(name)
}
func (c *Context) FormValue(name string) string {
if c.Request == nil {
return ""
}
return c.Request.FormValue(name)
}
func (c *Context) Query(name string) string {
if c.Request == nil {
return ""
}
return c.Request.URL.Query().Get(name)
}
// SetGlobal writes a value into app-wide state shared by all users.
func (c *Context) SetGlobal(key string, value any) {
if c.app == nil {
return
}
c.app.mu.Lock()
defer c.app.mu.Unlock()
c.app.state[key] = value
}
// GetGlobal reads a value from app-wide state shared by all users.
func (c *Context) GetGlobal(key string) any {
if c.app == nil {
return nil
}
c.app.mu.RLock()
defer c.app.mu.RUnlock()
return c.app.state[key]
}
// GetGlobalSnapshot reads app-wide state and returns clone(value) while the state lock is held.
func (c *Context) GetGlobalSnapshot(key string, clone func(any) any) any {
if clone == nil {
return c.GetGlobal(key)
}
if c.app == nil {
return clone(nil)
}
c.app.mu.RLock()
defer c.app.mu.RUnlock()
return clone(c.app.state[key])
}
// UpdateGlobal atomically reads, transforms, and writes app-wide state.
func (c *Context) UpdateGlobal(key string, fn func(old any) any) any {
if c.app == nil {
return fn(nil)
}
return c.app.UpdateGlobal(key, fn)
}
// GetGlobalInt reads app-wide state and type-asserts it to int.
func (c *Context) GetGlobalInt(key string) int {
v, ok := c.GetGlobal(key).(int)
if !ok {
return 0
}
return v
}
// IncrementGlobalInt atomically adds delta to an int in app-wide state.
func (c *Context) IncrementGlobalInt(key string, delta int) int {
next, _ := c.UpdateGlobal(key, func(old any) any {
oldInt, _ := old.(int)
return oldInt + delta
}).(int)
return next
}
func (c *Context) AddFlash(level FlashLevel, message string) {
secure := false
if c.app != nil {
c.app.mu.RLock()
secure = c.app.cookieSecure
c.app.mu.RUnlock()
}
trimmed := strings.TrimSpace(message)
if trimmed == "" {
return
}
c.flashes = append(c.flashes, FlashMessage{Level: level, Message: trimmed})
encoded, err := encodeFlashes(c.flashes)
if err != nil {
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: flashCookieName,
Value: encoded,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
func (c *Context) FlashSuccess(message string) { c.AddFlash(FlashSuccess, message) }
func (c *Context) FlashError(message string) { c.AddFlash(FlashError, message) }
func (c *Context) FlashInfo(message string) { c.AddFlash(FlashInfo, message) }
func (c *Context) FlashWarn(message string) { c.AddFlash(FlashWarn, message) }
func (c *Context) Flashes() []FlashMessage {
if len(c.flashes) == 0 {
return nil
}
out := make([]FlashMessage, len(c.flashes))
copy(out, c.flashes)
return out
}
func (c *Context) SetSession(key, value string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
if c.session == nil {
c.session = map[string]string{}
}
c.session[key] = value
c.writeSessionCookie()
}
func (c *Context) Session(key string) string {
if c.session == nil {
return ""
}
return c.session[key]
}
func (c *Context) ClearSession() {
c.session = map[string]string{}
c.writeSessionCookie()
}
func (c *Context) writeSessionCookie() {
if c.Writer == nil {
return
}
secure := false
if c.app != nil {
c.app.mu.RLock()
secure = c.app.cookieSecure
c.app.mu.RUnlock()
}
encoded, err := encodeSession(c.session)
if err != nil {
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: encoded,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
// Handler transforms state into a UI node in response to a user event.
type Handler func(*Context) frontend.Node
// Stream yields server-sent HTML fragments for incremental, server-driven UI updates.
type Stream func(yield func(frontend.Node) bool)
// StreamHandler transforms request state into an incremental stream.
type StreamHandler func(*Context) Stream
// PageOptions configures the full-page HTML shell for a page route.
type PageOptions struct {
Title string
}
// PageOption updates page route options.
type PageOption func(*PageOptions)
// WithTitle sets the HTML document title for a page route.
func WithTitle(title string) PageOption {
return func(options *PageOptions) {
options.Title = strings.TrimSpace(title)
}
}
type pageRoute struct {
handler Handler
options PageOptions
}
// AssetPolicy controls which asset URLs Marionette may emit into generated shells.
type AssetPolicy = assets.AssetPolicy
// AssetMode describes whether generated shells may depend on network-hosted assets.
type AssetMode string
const (
// AssetModeOnline allows Marionette's default CDN-backed asset resolution.
AssetModeOnline AssetMode = "online"
// AssetModeOffline rejects http:// and https:// asset URLs during shell rendering.
AssetModeOffline AssetMode = "offline"
)
// App is a minimal Go-only UI runtime for htmx driven desktop/web views.
type App struct {
mu sync.RWMutex
state map[string]any
pages map[string]pageRoute
actions map[string]Handler
streams map[string]StreamHandler
assets []assetRoute
cookieSecure bool
shellAssets frontend.ShellAssets
assetPolicy AssetPolicy
assetMode AssetMode
}
func New() *App {
return &App{
state: map[string]any{},
pages: map[string]pageRoute{},
actions: map[string]Handler{},
streams: map[string]StreamHandler{},
assets: []assetRoute{},
cookieSecure: false,
shellAssets: frontend.ShellAssets{},
assetMode: AssetModeOnline,
}
}
// SetAssetPolicy replaces the policy used to validate asset URLs emitted into full-page shells.
func (a *App) SetAssetPolicy(policy AssetPolicy) {
a.mu.Lock()
defer a.mu.Unlock()
a.assetPolicy = policy
}
// SetAssetMode switches between online and offline asset validation modes.
func (a *App) SetAssetMode(mode AssetMode) error {
a.mu.Lock()
defer a.mu.Unlock()
switch mode {
case "", AssetModeOnline:
a.assetMode = AssetModeOnline
a.assetPolicy.ForbidExternalURLs = false
case AssetModeOffline:
a.assetMode = AssetModeOffline
a.assetPolicy.ForbidExternalURLs = true
default:
return fmt.Errorf("unknown asset mode: %s", mode)
}
return nil
}
// UseAssets replaces the provider used to resolve built-in framework/library CSS and JS URLs.
func (a *App) UseAssets(provider assets.AssetProvider) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.UseAssets(provider)
}
// UseAssetProvider replaces the provider used to resolve built-in framework/library CSS and JS URLs.
func (a *App) UseAssetProvider(provider assets.AssetProvider) {
a.UseAssets(provider)
}
// UseOfflineAssets resolves Marionette's built-in framework/library CSS and JS from basePath.
// Pair this with App.Assets(basePath, fsys) to serve vendor assets from a local or embedded fs.FS.
func (a *App) UseOfflineAssets(basePath string) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.UseAssets(assets.NewLocalAssetProvider(basePath))
a.assetMode = AssetModeOffline
a.assetPolicy.ForbidExternalURLs = true
}
// EnableHTMX controls whether the default HTMX runtime is included in full-page shells.
// It is enabled by default for compatibility.
func (a *App) EnableHTMX(enable bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.EnableHTMX(enable)
}
// DisableHTMX prevents the default HTMX runtime from being included in full-page shells.
func (a *App) DisableHTMX() { a.EnableHTMX(false) }
// EnableCharts controls whether the default Chart.js runtime and chart bootstrap are included in full-page shells.
// It is enabled by default for compatibility.
func (a *App) EnableCharts(enable bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.EnableCharts(enable)
}
// DisableCharts prevents the default Chart.js runtime and chart bootstrap from being included in full-page shells.
func (a *App) DisableCharts() { a.EnableCharts(false) }
// EnableServerSentEvents controls whether the default EventSource connector runtime is included in full-page shells.
// The connector listens for elements with data-marionette-sse-url and applies hx-swap-oob fragments from StreamAction events.
func (a *App) EnableServerSentEvents(enable bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.EnableServerSentEvents(enable)
}
// EnableSSE includes Marionette's default EventSource connector runtime in full-page shells.
func (a *App) EnableSSE() { a.EnableServerSentEvents(true) }
// UseStyleTemplate replaces framework stylesheet/script imports.
// Call AddStylesheet/AddScript after this if you want extra imports.
func (a *App) UseStyleTemplate(tpl frontend.StyleTemplate) {
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.UseStyleTemplate(tpl)
}
// UseStyleTemplateByName applies a built-in style template preset.
func (a *App) UseStyleTemplateByName(name string) error {
tpl, ok := frontend.StyleTemplateByName(strings.TrimSpace(name))
if !ok {
return fmt.Errorf("unknown style template: %s", name)
}
a.UseStyleTemplate(tpl)
return nil
}
func (a *App) UseDaisyUITemplate() {
a.UseStyleTemplate(frontend.DaisyUITemplate)
}
func (a *App) UseTailwindCSSTemplate() {
a.UseStyleTemplate(frontend.TailwindCSSTemplate)
}
func (a *App) SetCookieSecure(secure bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.cookieSecure = secure
}
// AddStylesheet adds a stylesheet link to the full-page HTML shell.
func (a *App) AddStylesheet(href string) {
href = strings.TrimSpace(href)
if href == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.AddStylesheet(href)
}
// AddStyle adds trusted inline CSS to the full-page HTML shell.
func (a *App) AddStyle(css string) {
css = strings.TrimSpace(css)
if css == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.AddStyle(template.CSS(css))
}
// AddScript adds an external JavaScript file to the full-page HTML shell.
func (a *App) AddScript(src string) {
src = strings.TrimSpace(src)
if src == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.AddScript(src)
}
// AddJavaScript adds trusted inline JavaScript to the full-page HTML shell.
func (a *App) AddJavaScript(js string) {
js = strings.TrimSpace(js)
if js == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
a.shellAssets.AddJavaScript(template.JS(js))
}
// SetGlobal writes a value into app-wide state shared by all users.
func (a *App) SetGlobal(key string, value any) {
a.mu.Lock()
defer a.mu.Unlock()
a.state[key] = value
}
// GetGlobal reads a value from app-wide state shared by all users.
func (a *App) GetGlobal(key string) any {
a.mu.RLock()
defer a.mu.RUnlock()
return a.state[key]
}
// GetGlobalSnapshot reads app-wide state and returns clone(value) while the state lock is held.
func (a *App) GetGlobalSnapshot(key string, clone func(any) any) any {
if clone == nil {
return a.GetGlobal(key)
}
a.mu.RLock()
defer a.mu.RUnlock()
return clone(a.state[key])
}
// UpdateGlobal atomically reads, transforms, and writes app-wide state.
func (a *App) UpdateGlobal(key string, fn func(old any) any) any {
a.mu.Lock()
defer a.mu.Unlock()
old := a.state[key]
next := fn(old)
a.state[key] = next
return next
}
// GetGlobalInt reads app-wide state and type-asserts it to int.
func (a *App) GetGlobalInt(key string) int {
v, ok := a.GetGlobal(key).(int)
if !ok {
return 0
}
return v
}
// IncrementGlobalInt atomically adds delta to an int in app-wide state.
func (a *App) IncrementGlobalInt(key string, delta int) int {
next, _ := a.UpdateGlobal(key, func(old any) any {
oldInt, _ := old.(int)
return oldInt + delta
}).(int)
return next
}
// Page registers a full-page GET view.
func (a *App) Page(path string, fn Handler, options ...PageOption) {
a.pages[normalizePagePath(path)] = pageRoute{handler: fn, options: applyPageOptions(options)}
}
// Action registers a POST-only htmx endpoint. name should not include leading slash.
func (a *App) Action(name string, fn Handler) {
a.actions[normalizeActionPath(name)] = fn
}
// StreamAction registers a GET endpoint that writes server-sent HTML fragments.
// Each yielded node is rendered and sent as a JSON SSE message with an html field.
func (a *App) StreamAction(name string, fn StreamHandler) {
a.streams[normalizeActionPath(name)] = fn
}
// Render defines the main root view for initial load.
func (a *App) Render(fn Handler, options ...PageOption) {
a.Page("/", fn, options...)
}
// Handle registers an htmx endpoint. name should not include leading slash.
func (a *App) Handle(name string, fn Handler) {
a.Action(name, fn)
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
a.registerAssetRoutes(mux)
for path, route := range a.pages {
localPath := path
localRoute := route
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if localPath == "/" && r.URL.Path != "/" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := a.newContext(w, r)
a.renderAndWritePage(w, localRoute.handler(ctx), localRoute.options)
})
}
for path, fn := range a.streams {
localFn := fn
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ctx := a.newContext(w, r)
writeEventStream(w, localFn(ctx))
})
}
for path, fn := range a.actions {
localFn := fn
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := a.newContext(w, r)
renderAndWriteFragment(w, localFn(ctx))
})
}
if _, ok := a.pages["/"]; !ok {
mux.HandleFunc("/", a.handleMissingIndex)
}
return mux
}
func (a *App) newContext(w http.ResponseWriter, r *http.Request) *Context {
flashes := decodeFlashes(r)
session := decodeSession(r)
if len(flashes) > 0 {
a.mu.RLock()
secure := a.cookieSecure
a.mu.RUnlock()
clearFlashCookie(w, secure)
}
local := map[string]any{}
return &Context{Writer: w, Request: r, Local: local, app: a, flashes: flashes, session: session}
}
func clearFlashCookie(w http.ResponseWriter, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: flashCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
})
}
func decodeFlashes(r *http.Request) []FlashMessage {
cookie, err := r.Cookie(flashCookieName)
if err != nil || cookie.Value == "" {
return nil
}
raw, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return nil
}
var flashes []FlashMessage
if err := json.Unmarshal(raw, &flashes); err != nil {
return nil
}
out := make([]FlashMessage, 0, len(flashes))
for _, f := range flashes {
if strings.TrimSpace(f.Message) == "" {
continue
}
switch f.Level {
case FlashSuccess, FlashError, FlashInfo, FlashWarn:
out = append(out, f)
}
}
return out
}
func encodeFlashes(flashes []FlashMessage) (string, error) {
encodedJSON, err := json.Marshal(flashes)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encodedJSON), nil
}
func (a *App) handleMissingIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Error(w, "missing app.Page or app.Render registration for /", http.StatusInternalServerError)
}
func (a *App) renderAndWritePage(w http.ResponseWriter, node frontend.Node, pageOptions PageOptions) {
rootHTML, err := node.Render()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
page, err := shellWithOptions(rootHTML, a.shellOptions(pageOptions))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeHTML(w, page)
}
func (a *App) shellOptions(pageOptions PageOptions) shellOptions {
a.mu.RLock()
defer a.mu.RUnlock()
return shellOptions{
Title: pageOptions.Title,
StyleTemplate: a.shellAssets.StyleTemplate,
FrameworkStylesheets: append([]string(nil), a.shellAssets.FrameworkStylesheets...),
FrameworkScripts: append([]string(nil), a.shellAssets.FrameworkScripts...),
Stylesheets: append([]string(nil), a.shellAssets.Stylesheets...),
Styles: append([]template.CSS(nil), a.shellAssets.Styles...),
AssetProvider: a.shellAssets.AssetProvider,
AssetPolicy: a.assetPolicy,
Scripts: append([]string(nil), a.shellAssets.Scripts...),
JavaScripts: append([]template.JS(nil), a.shellAssets.JavaScripts...),
DisableHTMX: a.shellAssets.DisableHTMX,
DisableCharts: a.shellAssets.DisableCharts,
EnableSSE: a.shellAssets.EnableSSE,
}
}
func applyPageOptions(options []PageOption) PageOptions {
var pageOptions PageOptions
for _, option := range options {
if option != nil {
option(&pageOptions)
}
}
return pageOptions
}
func (a *App) Run(addr string) error {
fmt.Printf("marionette listening at http://%s\n", addr)
return http.ListenAndServe(addr, a.Handler())
}
func writeEventStream(w http.ResponseWriter, stream Stream) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, _ := w.(http.Flusher)
if stream == nil {
fmt.Fprint(w, "event: done\ndata: {}\n\n")
if flusher != nil {
flusher.Flush()
}
return
}
stream(func(node frontend.Node) bool {
htmlOut, err := node.Render()
if err != nil {
encoded, _ := json.Marshal(map[string]string{"error": err.Error()})
fmt.Fprintf(w, "event: error\ndata: %s\n\n", encoded)
if flusher != nil {
flusher.Flush()
}
return false
}
encoded, _ := json.Marshal(map[string]string{"html": string(htmlOut)})
fmt.Fprintf(w, "event: html\ndata: %s\n\n", encoded)
if flusher != nil {
flusher.Flush()
}
return true
})
fmt.Fprint(w, "event: done\ndata: {}\n\n")
if flusher != nil {
flusher.Flush()
}
}
func renderAndWriteFragment(w http.ResponseWriter, node frontend.Node) {
htmlOut, err := node.Render()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeHTML(w, string(htmlOut))
}
func normalizePagePath(path string) string {
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
return "/" + path
}
return path
}
func normalizeActionPath(name string) string {
return "/" + strings.TrimPrefix(name, "/")
}
func decodeSession(r *http.Request) map[string]string {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return map[string]string{}
}
raw, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return map[string]string{}
}
var session map[string]string
if err := json.Unmarshal(raw, &session); err != nil {
return map[string]string{}
}
if session == nil {
return map[string]string{}
}
return session
}
func encodeSession(session map[string]string) (string, error) {
encodedJSON, err := json.Marshal(session)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encodedJSON), nil
}
package backend
import (
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"net/url"
"path"
"strings"
"time"
frontendassets "github.com/YoshihideShirai/marionette/frontend/assets"
)
type assetRoute struct {
prefix string
fsys fs.FS
options AssetOptions
}
type AssetOptions struct {
MaxAge time.Duration
Immutable bool
Index bool
Download bool
ContentTypes map[string]string
}
type AssetOption func(*AssetOptions)
// WithAssetCache adds a public Cache-Control max-age to served assets.
func WithAssetCache(maxAge time.Duration) AssetOption {
return func(options *AssetOptions) {
options.MaxAge = maxAge
}
}
// WithAssetImmutable adds immutable to Cache-Control when asset caching is enabled.
func WithAssetImmutable() AssetOption {
return func(options *AssetOptions) {
options.Immutable = true
}
}
// WithAssetIndex allows directory index responses from the underlying file server.
func WithAssetIndex(enabled bool) AssetOption {
return func(options *AssetOptions) {
options.Index = enabled
}
}
// WithAssetDownload serves assets with Content-Disposition: attachment.
func WithAssetDownload() AssetOption {
return func(options *AssetOptions) {
options.Download = true
}
}
// WithAssetContentTypes sets Content-Type by file extension before serving assets.
func WithAssetContentTypes(types map[string]string) AssetOption {
return func(options *AssetOptions) {
if len(types) == 0 {
return
}
options.ContentTypes = make(map[string]string, len(types))
for ext, contentType := range types {
ext = normalizeAssetExtension(ext)
contentType = strings.TrimSpace(contentType)
if ext == "" || contentType == "" {
continue
}
options.ContentTypes[ext] = contentType
}
}
}
// Assets serves files from fsys under prefix, for example /assets/app.css.
func (a *App) Assets(prefix string, fsys fs.FS, options ...AssetOption) {
if fsys == nil {
return
}
normalized := normalizeAssetPrefix(prefix)
if normalized == "" {
return
}
route := assetRoute{
prefix: normalized,
fsys: fsys,
options: applyAssetOptions(options),
}
a.mu.Lock()
defer a.mu.Unlock()
a.assets = append(a.assets, route)
}
// Downloads serves files from fsys under prefix as attachment downloads.
func (a *App) Downloads(prefix string, fsys fs.FS, options ...AssetOption) {
downloadOptions := make([]AssetOption, 0, len(options)+1)
downloadOptions = append(downloadOptions, WithAssetDownload())
downloadOptions = append(downloadOptions, options...)
a.Assets(prefix, fsys, downloadOptions...)
}
// Asset returns an application asset URL using the first registered asset prefix.
func (a *App) Asset(name string) string {
assetName := normalizeAssetName(name)
if assetName == "" {
return ""
}
if isAbsoluteAssetURL(assetName) {
return assetName
}
a.mu.RLock()
defer a.mu.RUnlock()
if len(a.assets) == 0 {
return "/" + escapeAssetPath(assetName)
}
return a.assets[0].prefix + "/" + escapeAssetPath(assetName)
}
// Asset returns an application asset URL using the parent app.
func (c *Context) Asset(name string) string {
if c.app == nil {
return "/" + escapeAssetPath(normalizeAssetName(name))
}
return c.app.Asset(name)
}
func (a *App) registerAssetRoutes(mux *http.ServeMux) {
a.mu.RLock()
routes := append([]assetRoute(nil), a.assets...)
a.mu.RUnlock()
for _, route := range routes {
localRoute := route
mux.Handle(localRoute.prefix+"/", localRoute.handler())
}
}
func (r assetRoute) handler() http.Handler {
fileServer := http.StripPrefix(r.prefix+"/", http.FileServer(http.FS(r.fsys)))
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name, err := url.PathUnescape(strings.TrimPrefix(req.URL.Path, r.prefix+"/"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if assetPathEscapesRoot(name) {
http.NotFound(w, req)
return
}
name = strings.TrimPrefix(path.Clean("/"+name), "/")
if name == "." || name == "" {
http.NotFound(w, req)
return
}
info, err := fs.Stat(r.fsys, name)
if err != nil {
if fsErrNotExist(err) {
http.NotFound(w, req)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if info.IsDir() && !r.options.Index {
http.NotFound(w, req)
return
}
r.setHeaders(w, name)
fileServer.ServeHTTP(w, req)
})
}
func (r assetRoute) setHeaders(w http.ResponseWriter, name string) {
if contentType := r.contentType(name); contentType != "" && w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", contentType)
}
if r.options.Download {
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
"filename": path.Base(name),
}))
}
if r.options.MaxAge > 0 {
value := fmt.Sprintf("public, max-age=%d", int(r.options.MaxAge.Seconds()))
if r.options.Immutable {
value += ", immutable"
}
w.Header().Set("Cache-Control", value)
}
}
func (r assetRoute) contentType(name string) string {
ext := normalizeAssetExtension(path.Ext(name))
if ext == "" {
return ""
}
if contentType := r.options.ContentTypes[ext]; contentType != "" {
return contentType
}
return mime.TypeByExtension(ext)
}
func applyAssetOptions(options []AssetOption) AssetOptions {
var out AssetOptions
for _, option := range options {
if option != nil {
option(&out)
}
}
return out
}
func normalizeAssetPrefix(prefix string) string {
prefix = strings.TrimSpace(prefix)
if prefix == "" || prefix == "/" {
return ""
}
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
return strings.TrimSuffix(path.Clean(prefix), "/")
}
func normalizeAssetName(name string) string {
name = strings.TrimSpace(name)
if name == "" || isAbsoluteAssetURL(name) {
return name
}
return strings.TrimPrefix(path.Clean("/"+name), "/")
}
func normalizeAssetExtension(ext string) string {
ext = strings.TrimSpace(strings.ToLower(ext))
if ext == "" {
return ""
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return ext
}
func assetPathEscapesRoot(name string) bool {
for _, segment := range strings.Split(name, "/") {
if segment == ".." {
return true
}
}
return false
}
func escapeAssetPath(name string) string {
segments := strings.Split(name, "/")
for i, segment := range segments {
segments[i] = url.PathEscape(segment)
}
return strings.Join(segments, "/")
}
func isAbsoluteAssetURL(name string) bool {
return frontendassets.IsExternalURL(name) || strings.HasPrefix(name, "data:")
}
func fsErrNotExist(err error) bool {
return errors.Is(err, fs.ErrNotExist)
}
package backend
import (
"html/template"
"net/http"
frontend "github.com/YoshihideShirai/marionette/frontend"
)
type shellOptions = frontend.ShellOptions
func shell(content template.HTML) (string, error) {
return frontend.Shell(content)
}
func shellWithOptions(content template.HTML, options shellOptions) (string, error) {
return frontend.ShellWithOptions(content, options)
}
func writeHTML(w http.ResponseWriter, body string) {
frontend.WriteHTML(w, body)
}
package backend
import "strings"
const textStreamStatePrefix = "__marionette_text_stream:"
// TextStreamOptions configures a server-side text stream owned by Context.
type TextStreamOptions struct {
// Name identifies the stream within the app state.
Name string
// Text is the complete text that will be revealed chunk by chunk.
Text string
// ChunkSize controls how many word chunks are revealed by each AdvanceTextStream call.
// Values less than 1 use the default chunk size.
ChunkSize int
}
// TextStreamStep is the result of advancing a server-side text stream.
type TextStreamStep struct {
Content string
Delta string
Done bool
Active bool
Cursor int
Total int
}
type textStreamState struct {
Text string
Cursor int
ChunkSize int
}
const defaultTextStreamChunkSize = 5
// StartTextStream stores a text stream in app state so later actions can advance it.
func (c *Context) StartTextStream(options TextStreamOptions) {
name := strings.TrimSpace(options.Name)
if c == nil || name == "" {
return
}
chunkSize := options.ChunkSize
if chunkSize <= 0 {
chunkSize = defaultTextStreamChunkSize
}
c.SetGlobal(textStreamKey(name), textStreamState{Text: options.Text, ChunkSize: chunkSize})
}
// AdvanceTextStream reveals the next chunk of a server-side text stream.
func (c *Context) AdvanceTextStream(name string) TextStreamStep {
key := textStreamKey(name)
if key == "" || c == nil {
return TextStreamStep{Done: true}
}
var step TextStreamStep
c.UpdateGlobal(key, func(old any) any {
state, ok := old.(textStreamState)
if !ok {
step = TextStreamStep{Done: true}
return old
}
chunks := splitTextStreamChunks(state.Text)
chunkSize := state.ChunkSize
if chunkSize <= 0 {
chunkSize = defaultTextStreamChunkSize
}
next := state.Cursor + chunkSize
if next > len(chunks) {
next = len(chunks)
}
if next <= 0 {
step = TextStreamStep{Done: true, Active: true, Total: len(chunks)}
return nil
}
done := next >= len(chunks)
step = TextStreamStep{
Content: strings.Join(chunks[:next], ""),
Delta: strings.Join(chunks[state.Cursor:next], ""),
Done: done,
Active: true,
Cursor: next,
Total: len(chunks),
}
if done {
return nil
}
state.Cursor = next
state.ChunkSize = chunkSize
return state
})
return step
}
// ResetTextStream clears a server-side text stream from app state.
func (c *Context) ResetTextStream(name string) {
key := textStreamKey(name)
if key == "" || c == nil {
return
}
c.SetGlobal(key, nil)
}
// TextStreamActive reports whether a named server-side text stream can still advance.
func (c *Context) TextStreamActive(name string) bool {
key := textStreamKey(name)
if key == "" || c == nil {
return false
}
_, ok := c.GetGlobal(key).(textStreamState)
return ok
}
func textStreamKey(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
return textStreamStatePrefix + name
}
func splitTextStreamChunks(text string) []string {
fields := strings.Fields(text)
if len(fields) == 0 {
return []string{text}
}
chunks := make([]string, len(fields))
for i, field := range fields {
if i == 0 {
chunks[i] = field
continue
}
chunks[i] = " " + field
}
return chunks
}
package desktop
import (
"context"
"errors"
"time"
"github.com/YoshihideShirai/marionette/backend"
)
// Run starts app on a private localhost server and opens it in a native WebView.
func Run(app *backend.App, options Options) error {
if app == nil {
return errors.New("desktop: app is nil")
}
options = normalizeOptions(options)
server, err := startLocalServer(app.Handler())
if err != nil {
return err
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
return openWebView(server.URL, options)
}
package desktop
import "strings"
// Options configures the native desktop window used to host a Marionette app.
type Options struct {
Title string
Width int
Height int
Debug bool
}
func normalizeOptions(options Options) Options {
options.Title = strings.TrimSpace(options.Title)
if options.Title == "" {
options.Title = "Marionette"
}
if options.Width <= 0 {
options.Width = 1200
}
if options.Height <= 0 {
options.Height = 800
}
return options
}
package desktop
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
)
type localServer struct {
URL string
server *http.Server
errc chan error
shutdownOnce sync.Once
shutdownErr error
}
func startLocalServer(handler http.Handler) (*localServer, error) {
if handler == nil {
return nil, errors.New("desktop: handler is nil")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
srv := &http.Server{Handler: handler}
local := &localServer{
URL: fmt.Sprintf("http://%s/", listener.Addr().String()),
server: srv,
errc: make(chan error, 1),
}
go func() {
err := srv.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
local.errc <- err
}()
return local, nil
}
func (s *localServer) Shutdown(ctx context.Context) error {
if s == nil || s.server == nil {
return nil
}
s.shutdownOnce.Do(func() {
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
}
if err := s.server.Shutdown(ctx); err != nil {
s.shutdownErr = err
return
}
s.shutdownErr = <-s.errc
})
return s.shutdownErr
}
//go:build !marionette_desktop
package desktop
import "fmt"
var openWebView = defaultOpenWebView
func defaultOpenWebView(_ string, _ Options) error {
return fmt.Errorf("desktop: WebView support is not enabled; rebuild with -tags marionette_desktop")
}
package assets
import (
"fmt"
"strings"
)
// AssetPolicy controls which asset URLs Marionette may emit into generated shells.
type AssetPolicy struct {
// ForbidExternalURLs rejects http:// and https:// asset URLs so pages can be audited for offline use.
ForbidExternalURLs bool
}
// IsExternalURL reports whether value is an absolute network URL that can require online access.
func IsExternalURL(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
// ValidateURL returns an error when value violates policy.
func ValidateURL(policy AssetPolicy, kind, value string) error {
if !policy.ForbidExternalURLs || !IsExternalURL(value) {
return nil
}
if kind = strings.TrimSpace(kind); kind == "" {
kind = "asset"
}
return fmt.Errorf("asset policy forbids external URL for %s: %s", kind, value)
}
package assets
import (
"path"
"strings"
)
// AssetName identifies a framework/library asset that can be resolved by an AssetProvider.
type AssetName string
const (
DaisyUI AssetName = "daisyui"
TailwindCSSBrowser AssetName = "tailwindcss-browser"
ChartJS AssetName = "chartjs"
HTMX AssetName = "htmx"
)
const (
// DaisyUICSSFile is the default self-hosted DaisyUI stylesheet file name.
DaisyUICSSFile = "daisyui.css"
// TailwindCSSBrowserJSFile is the default self-hosted Tailwind browser runtime file name.
TailwindCSSBrowserJSFile = "tailwindcss-browser.js"
// HTMXJSFile is the default self-hosted HTMX runtime file name.
HTMXJSFile = "htmx.min.js"
// ChartJSFile is the default self-hosted Chart.js UMD bundle file name.
ChartJSFile = "chart.umd.js"
)
// AssetProvider resolves known Marionette framework/library names to CSS and JS URLs.
type AssetProvider interface {
StylesheetURL(name AssetName) (string, bool)
ScriptURL(name AssetName) (string, bool)
}
// AssetResolver is kept as a descriptive alias for providers used by renderers.
type AssetResolver = AssetProvider
// CDNAssetProvider resolves framework/library assets to Marionette's default CDN URLs.
type CDNAssetProvider struct{}
// DefaultProvider is the provider used when an app or shell does not specify one.
var DefaultProvider AssetProvider = CDNAssetProvider{}
func (CDNAssetProvider) StylesheetURL(name AssetName) (string, bool) {
switch name {
case DaisyUI:
return DaisyUICSSURL, true
default:
return "", false
}
}
func (CDNAssetProvider) ScriptURL(name AssetName) (string, bool) {
switch name {
case TailwindCSSBrowser:
return TailwindBrowserURL, true
case ChartJS:
return ChartJSURL, true
case HTMX:
return HTMXURL, true
default:
return "", false
}
}
// LocalAssetProvider resolves assets to paths below a local/static base URL.
type LocalAssetProvider struct {
BasePath string
Stylesheets map[AssetName]string
Scripts map[AssetName]string
}
// EmbeddedAssetProvider resolves embedded static assets exposed under a URL prefix.
type EmbeddedAssetProvider = LocalAssetProvider
// NewLocalAssetProvider creates a local provider with Marionette's default file names.
func NewLocalAssetProvider(basePath string) LocalAssetProvider {
return LocalAssetProvider{
BasePath: basePath,
Stylesheets: DefaultLocalStylesheets(),
Scripts: DefaultLocalScripts(),
}
}
// NewEmbeddedAssetProvider creates an embedded provider with Marionette's default file names.
func NewEmbeddedAssetProvider(basePath string) EmbeddedAssetProvider {
return EmbeddedAssetProvider(NewLocalAssetProvider(basePath))
}
func DefaultLocalStylesheets() map[AssetName]string {
return map[AssetName]string{
DaisyUI: DaisyUICSSFile,
}
}
func DefaultLocalScripts() map[AssetName]string {
return map[AssetName]string{
TailwindCSSBrowser: TailwindCSSBrowserJSFile,
ChartJS: ChartJSFile,
HTMX: HTMXJSFile,
}
}
func (p LocalAssetProvider) StylesheetURL(name AssetName) (string, bool) {
file := strings.TrimSpace(p.Stylesheets[name])
if file == "" {
return "", false
}
return assetURL(p.BasePath, file), true
}
func (p LocalAssetProvider) ScriptURL(name AssetName) (string, bool) {
file := strings.TrimSpace(p.Scripts[name])
if file == "" {
return "", false
}
return assetURL(p.BasePath, file), true
}
func assetURL(basePath, name string) string {
basePath = strings.TrimRight(strings.TrimSpace(basePath), "/")
name = strings.TrimLeft(strings.TrimSpace(name), "/")
if basePath == "" {
return name
}
if strings.HasPrefix(basePath, "http://") || strings.HasPrefix(basePath, "https://") {
return basePath + "/" + name
}
if strings.HasPrefix(basePath, "/") {
return path.Join(basePath, name)
}
return path.Join("/", basePath, name)
}
package chartjs
import (
"encoding/json"
"fmt"
"strings"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
)
type ChartType = shared.ChartType
const (
ChartTypeBar = shared.ChartTypeBar
ChartTypeLine = shared.ChartTypeLine
ChartTypePie = shared.ChartTypePie
ChartTypeDoughnut = shared.ChartTypeDoughnut
ChartTypeScatter = shared.ChartTypeScatter
)
type ChartDataset = shared.ChartDataset
type ChartPoint = shared.ChartPoint
type ChartOptions = shared.ChartOptions
type ChartProps = shared.ChartProps
type FallbackRow struct {
Label string
Values []string
}
func ConfigJSON(props ChartProps) (string, error) {
chartType := strings.TrimSpace(string(props.Type))
if chartType == "" {
chartType = string(ChartTypeLine)
}
datasets := make([]map[string]any, 0, len(props.Datasets))
for _, dataset := range props.Datasets {
data := any(dataset.Data)
if len(dataset.Points) > 0 {
data = dataset.Points
}
item := map[string]any{"label": strings.TrimSpace(dataset.Label), "data": data}
if color := strings.TrimSpace(dataset.BackgroundColor); color != "" {
item["backgroundColor"] = color
} else if colors := compactColors(dataset.BackgroundColors); len(colors) > 0 {
item["backgroundColor"] = colors
} else if colors := defaultColors(chartType, len(dataset.Data)); len(colors) > 0 {
item["backgroundColor"] = colors
}
if color := strings.TrimSpace(dataset.BorderColor); color != "" {
item["borderColor"] = color
} else if colors := compactColors(dataset.BorderColors); len(colors) > 0 {
item["borderColor"] = colors
} else if colors := defaultBorderColors(chartType, len(dataset.Data)); len(colors) > 0 {
item["borderColor"] = colors
}
if dataset.Fill {
item["fill"] = true
}
if dataset.Tension > 0 {
item["tension"] = dataset.Tension
}
datasets = append(datasets, item)
}
options := map[string]any{
"responsive": true,
"maintainAspectRatio": false,
"plugins": map[string]any{"legend": map[string]any{"display": !props.Options.HideLegend}},
}
if props.Options.AspectRatio > 0 {
options["maintainAspectRatio"] = true
options["aspectRatio"] = props.Options.AspectRatio
}
if chartType != string(ChartTypePie) && chartType != string(ChartTypeDoughnut) {
options["scales"] = Scales(props.Options)
}
payload := map[string]any{"type": chartType, "data": map[string]any{"labels": props.Labels, "datasets": datasets}, "options": options}
b, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(b), nil
}
func compactColors(colors []string) []string {
if len(colors) == 0 {
return nil
}
compacted := make([]string, 0, len(colors))
for _, color := range colors {
if color = strings.TrimSpace(color); color != "" {
compacted = append(compacted, color)
}
}
return compacted
}
func defaultColors(chartType string, count int) []string {
if count <= 1 {
return nil
}
switch chartType {
case string(ChartTypeBar), string(ChartTypePie), string(ChartTypeDoughnut):
palette := []string{"#2563eb", "#14b8a6", "#f59e0b", "#8b5cf6", "#ef4444", "#22c55e"}
if count > len(palette) {
count = len(palette)
}
return palette[:count]
default:
return nil
}
}
func defaultBorderColors(chartType string, count int) []string {
if count <= 1 {
return nil
}
switch chartType {
case string(ChartTypeBar):
palette := []string{"#2563eb", "#14b8a6", "#f59e0b", "#8b5cf6", "#ef4444", "#22c55e"}
if count > len(palette) {
count = len(palette)
}
return palette[:count]
case string(ChartTypePie), string(ChartTypeDoughnut):
colors := make([]string, count)
for i := range colors {
colors[i] = "#ffffff"
}
return colors
default:
return nil
}
}
func Scales(options ChartOptions) map[string]any {
x := map[string]any{}
y := map[string]any{"beginAtZero": options.BeginAtZero}
if options.Stacked {
x["stacked"] = true
y["stacked"] = true
}
if label := strings.TrimSpace(options.XAxisLabel); label != "" {
x["title"] = map[string]any{"display": true, "text": label}
}
if label := strings.TrimSpace(options.YAxisLabel); label != "" {
y["title"] = map[string]any{"display": true, "text": label}
}
return map[string]any{"x": x, "y": y}
}
func FallbackText(props ChartProps) string {
title := strings.TrimSpace(props.Title)
if title == "" {
title = "Chart"
}
return title + " data is available in the fallback table below."
}
func FallbackRows(props ChartProps) []FallbackRow {
rows := make([]FallbackRow, 0, len(props.Labels))
for i, label := range props.Labels {
values := make([]string, 0, len(props.Datasets))
for _, dataset := range props.Datasets {
if i < len(dataset.Points) {
values = append(values, fmt.Sprintf("%g, %g", dataset.Points[i].X, dataset.Points[i].Y))
continue
}
if i >= len(dataset.Data) {
values = append(values, "")
continue
}
values = append(values, fmt.Sprint(dataset.Data[i]))
}
rows = append(rows, FallbackRow{Label: label, Values: values})
}
return rows
}
package frontend
import (
"strings"
rdf "github.com/rocketlaunchr/dataframe-go"
components "github.com/YoshihideShirai/marionette/frontend/components"
daisy "github.com/YoshihideShirai/marionette/frontend/daisyui"
)
func ThemeToggleButton(props ComponentProps) Node { return daisy.ThemeToggleButton(props) }
func InputWithOptions(name, value string, options InputOptions) Node {
return daisy.InputWithOptions(name, value, options)
}
func ActionForm(props ActionFormProps, children ...Node) Node {
return daisy.ActionForm(props, children...)
}
func FormField(control Node, props FormFieldProps) Node { return daisy.FormField(control, props) }
func Modal(props ModalProps) Node { return daisy.Modal(props) }
func ModalWithVariants(props ModalProps, variant ModalVariantProps) Node {
return daisy.ModalWithVariants(props, variant)
}
func Toast(props ToastProps) Node {
alertProps := AlertContentProps{
Title: props.Title,
Description: props.Description,
Icon: textIcon(props.Icon),
Props: ComponentProps{Variant: props.Props.Variant},
}
return daisy.ToastWithContent(props.Props, daisy.AlertWithContent(alertProps))
}
func ToastWithContent(props ComponentProps, children ...Node) Node {
return daisy.ToastWithContent(props, children...)
}
func Alert(props AlertProps) Node {
return daisy.AlertWithContent(AlertContentProps{
Title: props.Title,
Description: props.Description,
Icon: textIcon(props.Icon),
Props: props.Props,
})
}
func AlertWithContent(props AlertContentProps, children ...Node) Node {
return daisy.AlertWithContent(props, children...)
}
func textIcon(icon string) Node {
if strings.TrimSpace(icon) == "" {
return nil
}
return daisy.TextNode(strings.TrimSpace(icon))
}
func Skeleton(props SkeletonProps) Node { return daisy.Skeleton(props.Rows, props.Props) }
func Progress(props ProgressProps) Node { return daisy.Progress(props) }
func EmptyState(props EmptyStateProps) Node {
return daisy.EmptyState(props)
}
func DataFrameComponent(df *rdf.DataFrame, props TableProps) Node { return DataFrame(df, props) }
func Badge(props BadgeProps) Node { return daisy.Badge(props) }
func Actions(props ActionsProps, children ...Node) Node { return daisy.Actions(props, children...) }
func Divider(props DividerProps) Node { return daisy.Divider(props) }
func TextComponent(props TextProps) Node { return daisy.Text(props) }
func FontIcon(props FontIconProps) Node { return daisy.FontIcon(props) }
func HiddenField(name, value string) Node { return daisy.HiddenField(name, value) }
func Stack(props StackProps, children ...Node) Node { return daisy.Stack(props, children...) }
func Grid(props GridProps, children ...Node) Node { return components.Grid(props, children...) }
func Split(props SplitProps) Node { return daisy.Split(props) }
func PageHeader(props PageHeaderProps) Node { return daisy.PageHeader(props) }
func Region(props RegionProps, children ...Node) Node { return components.Region(props, children...) }
func Box(props BoxProps, children ...Node) Node { return daisy.Box(props, children...) }
func AppShell(props AppShellProps) Node { return daisy.AppShell(props) }
func Card(props CardProps, children ...Node) Node {
return daisy.Card(props.Title, props.Description, props.Actions, children, props.Props)
}
func CardWithVariants(props CardProps, variant CardVariantProps, children ...Node) Node {
return daisy.CardWithVariants(props, variant, children...)
}
func Section(props SectionProps, children ...Node) Node { return daisy.Section(props, children...) }
package components
import (
"bytes"
"html/template"
"strings"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
)
var gridTmpl = template.Must(template.New("components/grid").Parse(`{{define "components/grid" -}}
<div class="{{.Class}}">
{{range .Children}}{{.}}{{end}}
</div>
{{- end}}`))
type gridNode struct {
className string
children []shared.Node
}
func Grid(props shared.GridProps, children ...shared.Node) shared.Node {
return gridNode{className: gridClass(props), children: children}
}
func (n gridNode) Render() (template.HTML, error) {
children := make([]template.HTML, 0, len(n.children))
for _, child := range n.children {
if child == nil {
children = append(children, "")
continue
}
rendered, err := child.Render()
if err != nil {
return "", err
}
children = append(children, rendered)
}
var b bytes.Buffer
err := gridTmpl.ExecuteTemplate(&b, "components/grid", struct {
Class string
Children []template.HTML
}{Class: n.className, Children: children})
return template.HTML(b.String()), err
}
func Region(props shared.RegionProps, children ...shared.Node) shared.Node {
attrs := map[string]string{"id": strings.TrimSpace(props.ID)}
if props.Props.Class != "" {
attrs["class"] = strings.TrimSpace(props.Props.Class)
}
return lowhtml.ElementNode{Tag: "div", Attrs: attrs, Children: children}
}
func gridClass(props shared.GridProps) string {
base := []string{"grid", gapClass(props.Gap), gridColumnsClass(props.Columns, props.MinColumnWidth)}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func gapClass(gap string) string {
switch strings.TrimSpace(gap) {
case "none", "0":
return "gap-0"
case "xs":
return "gap-1"
case "sm":
return "gap-2"
case "lg":
return "gap-6"
case "xl":
return "gap-8"
default:
return "gap-4"
}
}
func gridColumnsClass(columns, minColumnWidth string) string {
switch strings.TrimSpace(minColumnWidth) {
case "sm":
return "grid-cols-[repeat(auto-fit,minmax(14rem,1fr))]"
case "md":
return "grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]"
case "lg":
return "grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]"
}
switch strings.TrimSpace(columns) {
case "1":
return "grid-cols-1"
case "2":
return "grid-cols-1 md:grid-cols-2"
case "4":
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4"
default:
return "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
}
}
func joinClass(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
out = append(out, trimmed)
}
}
return strings.Join(out, " ")
}
package frontend
import "strings"
// このファイルはコンポーネント用クラス名の組み立て関数を定義する。
// classNameに関するロジック追加時はここを起点に配置する。
func buttonClass(props ComponentProps) string {
base := []string{"btn", "w-fit", buttonVariantClass(props.Variant), buttonSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func linkClass(props ComponentProps, hasIcon, iconOnly bool) string {
var base []string
if props.Variant != "" || props.Size != "" {
base = []string{buttonClass(props)}
if iconOnly {
base = append(base, "btn-square")
}
} else {
base = []string{"link", "link-hover", "w-fit"}
if hasIcon {
base = append(base, "inline-flex", "items-center", "gap-1")
}
if props.Class != "" {
base = append(base, props.Class)
}
}
if props.Disabled {
base = append(base, "pointer-events-none", "cursor-not-allowed", "opacity-50")
}
return joinClass(base...)
}
func inputClass(props ComponentProps) string {
variantClass := ""
if props.Variant == "ghost" {
variantClass = "input-ghost"
}
base := []string{"input", "w-full", variantClass, inputSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func selectClass(props ComponentProps) string {
variantClass := ""
if props.Variant == "ghost" {
variantClass = "select-ghost"
}
base := []string{"select", "w-full", variantClass, selectSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func textareaClass(props ComponentProps) string {
variantClass := ""
if props.Variant == "ghost" {
variantClass = "textarea-ghost"
}
base := []string{"textarea", "w-full", variantClass, textareaSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func buttonVariantClass(variant string) string {
tokens := strings.Fields(strings.TrimSpace(variant))
if len(tokens) == 0 {
return "btn-primary"
}
classes := make([]string, 0, len(tokens))
for _, token := range tokens {
switch token {
case "default", "base":
// plain DaisyUI button (no tone class)
case "primary":
classes = append(classes, "btn-primary")
case "secondary":
classes = append(classes, "btn-secondary")
case "accent":
classes = append(classes, "btn-accent")
case "neutral":
classes = append(classes, "btn-neutral")
case "info":
classes = append(classes, "btn-info")
case "success":
classes = append(classes, "btn-success")
case "warning":
classes = append(classes, "btn-warning")
case "danger", "error":
classes = append(classes, "btn-error")
case "ghost":
classes = append(classes, "btn-ghost")
case "link":
classes = append(classes, "btn-link")
case "outline":
classes = append(classes, "btn-outline")
case "dash", "dashed":
classes = append(classes, "btn-dash")
case "soft":
classes = append(classes, "btn-soft")
case "glass":
classes = append(classes, "btn-glass")
case "active":
classes = append(classes, "btn-active")
case "disabled":
classes = append(classes, "btn-disabled")
case "wide":
classes = append(classes, "btn-wide")
case "block":
classes = append(classes, "btn-block")
case "square":
classes = append(classes, "btn-square")
case "circle":
classes = append(classes, "btn-circle")
}
}
if len(classes) == 0 {
return "btn-primary"
}
return joinClass(classes...)
}
func buttonSizeClass(size string) string {
switch strings.TrimSpace(size) {
case "xs":
return "btn-xs"
case "sm":
return "btn-sm"
case "md", "":
return ""
case "lg":
return "btn-lg"
case "xl":
return "btn-xl"
default:
return ""
}
}
func inputSizeClass(size string) string {
return daisySizeClass("input", size)
}
func selectSizeClass(size string) string {
return daisySizeClass("select", size)
}
func textareaSizeClass(size string) string {
return daisySizeClass("textarea", size)
}
func checkboxClass(props ComponentProps) string {
base := []string{"checkbox", checkboxVariantClass(props.Variant), checkboxSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func checkboxVariantClass(variant string) string {
switch strings.ToLower(strings.TrimSpace(variant)) {
case "primary":
return "checkbox-primary"
case "secondary":
return "checkbox-secondary"
case "accent":
return "checkbox-accent"
case "neutral":
return "checkbox-neutral"
case "info":
return "checkbox-info"
case "success":
return "checkbox-success"
case "warning":
return "checkbox-warning"
case "error", "danger":
return "checkbox-error"
default:
return ""
}
}
func checkboxSizeClass(size string) string {
return daisySizeClass("checkbox", size)
}
func radioClass(props ComponentProps) string {
base := []string{"radio", radioVariantClass(props.Variant), radioSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func radioVariantClass(variant string) string {
switch strings.ToLower(strings.TrimSpace(variant)) {
case "primary":
return "radio-primary"
case "secondary":
return "radio-secondary"
case "accent":
return "radio-accent"
case "neutral":
return "radio-neutral"
case "info":
return "radio-info"
case "success":
return "radio-success"
case "warning":
return "radio-warning"
case "error", "danger":
return "radio-error"
default:
return ""
}
}
func radioSizeClass(size string) string {
return daisySizeClass("radio", size)
}
func switchClass(props ComponentProps) string {
base := []string{"toggle", toggleVariantClass(props.Variant), toggleSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func toggleVariantClass(variant string) string {
switch strings.ToLower(strings.TrimSpace(variant)) {
case "primary":
return "toggle-primary"
case "secondary":
return "toggle-secondary"
case "accent":
return "toggle-accent"
case "neutral":
return "toggle-neutral"
case "info":
return "toggle-info"
case "success":
return "toggle-success"
case "warning":
return "toggle-warning"
case "error", "danger":
return "toggle-error"
default:
return ""
}
}
func toggleSizeClass(size string) string {
return daisySizeClass("toggle", size)
}
func daisySizeClass(prefix, size string) string {
switch strings.TrimSpace(size) {
case "xs", "sm", "md", "lg", "xl":
return prefix + "-" + strings.TrimSpace(size)
default:
return ""
}
}
func badgeClass(props ComponentProps) string {
base := []string{"badge", badgeVariantClass(props.Variant), badgeSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func badgeVariantClass(variant string) string {
switch strings.TrimSpace(variant) {
case "primary":
return "badge-primary"
case "secondary":
return "badge-secondary"
case "accent":
return "badge-accent"
case "danger", "error":
return "badge-error"
case "outline":
return "badge-outline"
case "ghost":
return "badge-ghost"
default:
return ""
}
}
func badgeSizeClass(size string) string {
switch strings.TrimSpace(size) {
case "sm":
return "badge-sm"
case "lg":
return "badge-lg"
default:
return ""
}
}
func actionsClass(props ActionsProps) string {
base := []string{"flex", "items-center", gapClass(props.Gap), actionsAlignClass(props.Align)}
if props.Wrap {
base = append(base, "flex-wrap")
}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func actionsAlignClass(align string) string {
switch strings.TrimSpace(align) {
case "center":
return "justify-center"
case "end", "right":
return "justify-end"
case "between":
return "justify-between"
default:
return "justify-start"
}
}
func dividerClass(props DividerProps) string {
base := []string{"divider", dividerSpacingClass(props.Spacing)}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func dividerSpacingClass(spacing string) string {
switch strings.TrimSpace(spacing) {
case "none":
return "my-0"
case "xs":
return "my-1"
case "sm":
return "my-2"
case "lg":
return "my-6"
default:
return ""
}
}
func textClass(props TextProps) string {
base := []string{textSizeClass(props.Size), textWeightClass(props.Weight), textToneClass(props.Tone)}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func textSizeClass(size string) string {
switch strings.TrimSpace(size) {
case "xs":
return "text-xs"
case "sm":
return "text-sm"
case "lg":
return "text-lg"
case "xl":
return "text-xl"
case "2xl":
return "text-2xl"
case "3xl":
return "text-3xl"
default:
return ""
}
}
func textWeightClass(weight string) string {
switch strings.TrimSpace(weight) {
case "medium":
return "font-medium"
case "semibold":
return "font-semibold"
case "bold":
return "font-bold"
default:
return ""
}
}
func textToneClass(tone string) string {
switch strings.TrimSpace(tone) {
case "muted":
return "text-base-content/60"
case "subtle":
return "text-base-content/70"
default:
return ""
}
}
func boxClass(props BoxProps) string {
base := []string{boxToneClass(props.Tone), boxPaddingClass(props.Padding)}
if props.Border {
base = append(base, "border border-base-300")
}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func boxToneClass(tone string) string {
switch strings.TrimSpace(tone) {
case "base":
return "bg-base-100"
case "muted":
return "bg-base-200"
default:
return ""
}
}
func boxPaddingClass(padding string) string {
switch strings.TrimSpace(padding) {
case "none":
return "p-0"
case "sm":
return "p-3"
case "lg":
return "p-6"
default:
return "p-4"
}
}
func appShellClass(props ComponentProps) string {
return joinClass("grid gap-6 lg:grid-cols-[16rem_minmax(0,1fr)]", props.Class)
}
func feedbackClass(component string, props ComponentProps) string {
base := []string{"ui-feedback", "ui-feedback-" + component, feedbackVariantClass(props.Variant), feedbackSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func feedbackVariantClass(variant string) string {
switch variant {
case "success", "info", "warning", "error":
return "ui-feedback-" + variant
default:
return "ui-feedback-info"
}
}
func feedbackSizeClass(size string) string {
switch size {
case "sm", "lg":
return "ui-feedback-" + size
default:
return "ui-feedback-md"
}
}
func stackClass(props StackProps) string {
base := []string{"flex", stackDirectionClass(props.Direction), gapClass(props.Gap), alignClass(props.Align), justifyClass(props.Justify)}
if props.Wrap {
base = append(base, "flex-wrap")
}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func stackDirectionClass(direction string) string {
switch strings.TrimSpace(direction) {
case "horizontal", "row":
return "flex-row"
default:
return "flex-col"
}
}
func gridClass(props GridProps) string {
base := []string{"grid", gapClass(props.Gap), gridColumnsClass(props.Columns, props.MinColumnWidth)}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func splitClass(props SplitProps) string {
base := []string{"grid", "items-start", gapClass(props.Gap), splitColumnsClass(props.AsideWidth)}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func splitPaneClass(pane string, reverseOnMobile bool) string {
base := []string{"min-w-0"}
if !reverseOnMobile {
return joinClass(base...)
}
if pane == "main" {
base = append(base, "order-2", "lg:order-1")
} else {
base = append(base, "order-1", "lg:order-2")
}
return joinClass(base...)
}
func pageHeaderClass(props ComponentProps) string {
return joinClass("flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", props.Class)
}
func containerClass(props ContainerProps) string {
base := []string{containerMaxWidthClass(props.MaxWidth), containerPaddingClass(props.Padding)}
if props.Centered {
base = append(base, "mx-auto")
}
if props.Props.Class != "" {
base = append(base, props.Props.Class)
}
return joinClass(base...)
}
func cardClass(props ComponentProps) string {
return joinClass("card bg-base-100 shadow-sm", props.Class)
}
func cardBodyClass(props CardProps) string {
return joinClass("card-body", gapClass(props.Gap))
}
func sectionClass(props ComponentProps) string {
return joinClass("space-y-4", props.Class)
}
func chartClass(props ComponentProps) string {
return joinClass("card bg-base-100 shadow-sm", props.Class)
}
func imageClass(props ComponentProps) string {
return joinClass("space-y-2", props.Class)
}
func imageFrameClass(props ImageProps) string {
return joinClass("overflow-hidden rounded-box bg-base-200", imageAspectClass(props.AspectRatio))
}
func imageElementClass(props ImageProps) string {
base := []string{"block", "w-full", imageObjectFitClass(props.ObjectFit)}
if strings.TrimSpace(props.AspectRatio) != "" {
base = append(base, "h-full")
} else {
base = append(base, "h-auto")
}
return joinClass(base...)
}
func imageAspectClass(aspectRatio string) string {
switch strings.TrimSpace(aspectRatio) {
case "square":
return "aspect-square"
case "video":
return "aspect-video"
case "wide":
return "aspect-[16/9]"
case "portrait":
return "aspect-[3/4]"
default:
return ""
}
}
func imageObjectFitClass(objectFit string) string {
switch strings.TrimSpace(objectFit) {
case "contain":
return "object-contain"
case "fill":
return "object-fill"
case "none":
return "object-none"
case "scale-down":
return "object-scale-down"
default:
return "object-cover"
}
}
func progressClass(props ComponentProps) string {
base := []string{"progress", "w-full", progressVariantClass(props.Variant), progressSizeClass(props.Size)}
if props.Class != "" {
base = append(base, props.Class)
}
return joinClass(base...)
}
func progressVariantClass(variant string) string {
switch strings.TrimSpace(variant) {
case "primary":
return "progress-primary"
case "secondary":
return "progress-secondary"
case "accent":
return "progress-accent"
case "success":
return "progress-success"
case "info":
return "progress-info"
case "warning":
return "progress-warning"
case "danger", "error":
return "progress-error"
default:
return ""
}
}
func progressSizeClass(size string) string {
switch strings.TrimSpace(size) {
case "sm":
return "h-1"
case "lg":
return "h-4"
default:
return "h-2"
}
}
func gapClass(gap string) string {
switch strings.TrimSpace(gap) {
case "none", "0":
return "gap-0"
case "xs":
return "gap-1"
case "sm":
return "gap-2"
case "lg":
return "gap-6"
case "xl":
return "gap-8"
default:
return "gap-4"
}
}
func alignClass(align string) string {
switch strings.TrimSpace(align) {
case "start":
return "items-start"
case "center":
return "items-center"
case "end":
return "items-end"
default:
return "items-stretch"
}
}
func justifyClass(justify string) string {
switch strings.TrimSpace(justify) {
case "center":
return "justify-center"
case "end":
return "justify-end"
case "between":
return "justify-between"
default:
return "justify-start"
}
}
func gridColumnsClass(columns, minColumnWidth string) string {
switch strings.TrimSpace(minColumnWidth) {
case "sm":
return "grid-cols-[repeat(auto-fit,minmax(14rem,1fr))]"
case "md":
return "grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]"
case "lg":
return "grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]"
}
switch strings.TrimSpace(columns) {
case "1":
return "grid-cols-1"
case "2":
return "grid-cols-1 md:grid-cols-2"
case "4":
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4"
default:
return "grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
}
}
func splitColumnsClass(asideWidth string) string {
switch strings.TrimSpace(asideWidth) {
case "sm":
return "lg:grid-cols-[minmax(0,1fr)_16rem]"
case "lg":
return "lg:grid-cols-[minmax(0,1fr)_28rem]"
default:
return "lg:grid-cols-[minmax(0,1fr)_22rem]"
}
}
func containerMaxWidthClass(maxWidth string) string {
switch strings.TrimSpace(maxWidth) {
case "sm":
return "max-w-3xl"
case "md":
return "max-w-5xl"
case "full":
return "max-w-none"
default:
return "max-w-7xl"
}
}
func containerPaddingClass(padding string) string {
switch strings.TrimSpace(padding) {
case "none", "0":
return "p-0"
case "sm":
return "p-3"
case "lg":
return "p-8"
default:
return "p-6"
}
}
func joinClass(parts ...string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if strings.TrimSpace(part) == "" {
continue
}
filtered = append(filtered, strings.TrimSpace(part))
}
return strings.Join(filtered, " ")
}
package frontend
import (
"bytes"
"fmt"
"html/template"
"strings"
chartjs "github.com/YoshihideShirai/marionette/frontend/chartjs"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
"github.com/yuin/goldmark"
)
// このファイルはNode生成ロジックを定義する。
// コンポーネントの描画処理はここに追加する。
func Button(label string, props ComponentProps) Node {
return componentButton(label, "button", props)
}
func ButtonWithVariants(label string, props ButtonVariantProps) Node {
variants := make([]string, 0, len(props.Variants))
for _, v := range props.Variants {
variants = append(variants, string(v))
}
return Button(label, ComponentProps{
Class: props.Class,
Variant: strings.Join(variants, " "),
Size: string(props.Size),
Disabled: props.Disabled,
})
}
func SubmitButton(label string, props ComponentProps) Node {
return componentButton(label, "submit", props)
}
func InputWithVariants(name, value string, props InputVariantProps) Node {
return Input(name, value, ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
})
}
func ProgressWithVariants(value, max float64, label string, props ProgressVariantProps) Node {
return Progress(ProgressProps{
Value: value,
Max: max,
Label: label,
Props: ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
},
})
}
func LoginButton(props LoginButtonProps) Node {
buttonType := strings.TrimSpace(props.Type)
if buttonType == "" {
buttonType = "button"
}
label := strings.TrimSpace(props.Label)
return templateNode{
name: "components/login_button",
data: struct {
Class string
Type string
Label string
IconSVG template.HTML
Disabled bool
}{
Class: buttonClass(props.Props),
Type: buttonType,
Label: label,
IconSVG: props.IconSVG,
Disabled: props.Props.Disabled,
},
}
}
func IconButton(props IconButtonProps) Node {
buttonType := strings.TrimSpace(props.Type)
if buttonType == "" {
buttonType = "button"
}
position := strings.ToLower(strings.TrimSpace(props.IconPosition))
iconEnd := position == "end" || position == "right"
label := strings.TrimSpace(props.Label)
return templateNode{
name: "components/icon_button",
data: struct {
Class string
Type string
Label string
IconSVG template.HTML
IconStart bool
IconEnd bool
Disabled bool
}{
Class: buttonClass(props.Props),
Type: buttonType,
Label: label,
IconSVG: props.IconSVG,
IconStart: !iconEnd,
IconEnd: iconEnd,
Disabled: props.Props.Disabled,
},
}
}
func MenuIcon(props ComponentProps) Node {
return lowhtml.ElementNode{Tag: "svg", Attrs: map[string]string{
"xmlns": "http://www.w3.org/2000/svg",
"fill": "none",
"viewBox": "0 0 24 24",
"class": joinClass("inline-block h-6 w-6 stroke-current", props.Class),
}, Children: []Node{
lowhtml.ElementNode{Tag: "path", Attrs: map[string]string{
"stroke-linecap": "round",
"stroke-linejoin": "round",
"stroke-width": "2",
"d": "M4 6h16M4 12h16M4 18h16",
}},
}}
}
func Link(props LinkProps) Node {
href := strings.TrimSpace(props.Href)
if href == "" || props.Props.Disabled {
href = "#"
}
target := strings.TrimSpace(props.Target)
if target == "" && props.External {
target = "_blank"
}
rel := strings.TrimSpace(props.Rel)
if rel == "" && (props.External || target == "_blank") {
rel = "noopener noreferrer"
}
filename := strings.TrimSpace(props.Filename)
download := props.Download || filename != ""
label := strings.TrimSpace(props.Label)
icon := strings.TrimSpace(props.Icon)
ariaLabel := strings.TrimSpace(props.AriaLabel)
if ariaLabel == "" && icon != "" && label != "" {
ariaLabel = label
}
return templateNode{
name: "components/link",
data: struct {
Class string
Label string
Icon string
Href string
Target string
Rel string
Download bool
Filename string
AriaLabel string
Disabled bool
}{
Class: linkClass(props.Props, icon != "", label == ""),
Label: label,
Icon: icon,
Href: href,
Target: target,
Rel: rel,
Download: download,
Filename: filename,
AriaLabel: ariaLabel,
Disabled: props.Props.Disabled,
},
}
}
func ExternalLink(label, href string, props ComponentProps) Node {
return Link(LinkProps{
Label: label,
Href: href,
External: true,
Props: props,
})
}
func ExternalIconLink(icon, ariaLabel, href string, props ComponentProps) Node {
return Link(LinkProps{
Icon: icon,
AriaLabel: ariaLabel,
Href: href,
External: true,
Props: props,
})
}
func DownloadLink(label, href, filename string, props ComponentProps) Node {
return Link(LinkProps{
Label: label,
Href: href,
Download: true,
Filename: filename,
Props: props,
})
}
func componentButton(label, buttonType string, props ComponentProps) Node {
return templateNode{
name: "components/button",
data: struct {
Class string
Type string
Label string
Disabled bool
}{
Class: buttonClass(props),
Type: buttonType,
Label: label,
Disabled: props.Disabled,
},
}
}
func Table(props TableProps) Node {
rows := make([]struct {
Cells []template.HTML
}, 0, len(props.Rows))
for _, row := range props.Rows {
cells := make([]template.HTML, 0, len(row.Cells))
for _, cell := range row.Cells {
cellHTML, err := renderNode(cell)
if err != nil {
return renderErrorNode{err: err}
}
cells = append(cells, cellHTML)
}
rows = append(rows, struct {
Cells []template.HTML
}{Cells: cells})
}
return templateNode{
name: "components/table",
data: struct {
Columns []TableColumn
Rows []struct{ Cells []template.HTML }
EmptyTitle string
EmptyDescription string
QueryStateName string
SelectedFilters []DataFrameFilter
}{
Columns: props.Columns,
Rows: rows,
EmptyTitle: strings.TrimSpace(props.EmptyTitle),
EmptyDescription: strings.TrimSpace(props.EmptyDescription),
QueryStateName: strings.TrimSpace(props.QueryStateName),
SelectedFilters: props.SelectedFilters,
},
}
}
func Chart(props ChartProps) Node {
config, err := chartjs.ConfigJSON(props)
if err != nil {
return renderErrorNode{err: err}
}
height := props.Height
if height <= 0 {
height = 320
}
ariaLabel := strings.TrimSpace(props.AriaLabel)
if ariaLabel == "" {
ariaLabel = strings.TrimSpace(props.Title)
}
if ariaLabel == "" {
ariaLabel = "Chart"
}
return templateNode{
name: "components/chart",
data: struct {
Class string
Title string
Description string
AriaLabel string
Height int
Config template.JS
Labels []string
Datasets []ChartDataset
Rows []chartjs.FallbackRow
FallbackText string
QueryStateName string
QueryStateLabel string
}{
Class: chartClass(props.Props),
Title: strings.TrimSpace(props.Title),
Description: strings.TrimSpace(props.Description),
AriaLabel: ariaLabel,
Height: height,
Config: template.JS(config),
Labels: props.Labels,
Datasets: props.Datasets,
Rows: chartjs.FallbackRows(props),
FallbackText: chartjs.FallbackText(props),
QueryStateName: strings.TrimSpace(props.QueryStateName),
QueryStateLabel: strings.TrimSpace(props.QueryStateLabel),
},
}
}
func Image(props ImageProps) Node {
src := strings.TrimSpace(props.Src)
if src == "" {
return renderErrorNode{err: fmt.Errorf("image src is required")}
}
loading := strings.TrimSpace(props.Loading)
if loading == "" {
loading = "lazy"
}
decoding := strings.TrimSpace(props.Decoding)
if decoding == "" {
decoding = "async"
}
return templateNode{
name: "components/image",
data: struct {
Class string
FrameClass string
ImageClass string
Src string
Alt string
Caption string
Width int
Height int
Loading string
Decoding string
}{
Class: imageClass(props.Props),
FrameClass: imageFrameClass(props),
ImageClass: imageElementClass(props),
Src: src,
Alt: props.Alt,
Caption: strings.TrimSpace(props.Caption),
Width: props.Width,
Height: props.Height,
Loading: loading,
Decoding: decoding,
},
}
}
func Pagination(props PaginationProps) Node {
page := props.Page
if page < 1 {
page = 1
}
totalPages := props.TotalPages
if totalPages < 1 {
totalPages = 1
}
return templateNode{
name: "components/pagination",
data: struct {
Page int
TotalPages int
PrevHref string
NextHref string
}{
Page: page,
TotalPages: totalPages,
PrevHref: strings.TrimSpace(props.PrevHref),
NextHref: strings.TrimSpace(props.NextHref),
},
}
}
func PaginationWithVariants(props PaginationProps, variant PaginationVariantProps) Node {
return lowhtml.ElementNode{
Tag: "div",
Attrs: map[string]string{"class": joinClass(variant.Class, "pagination-variant", string(variant.Variant), string(variant.Size))},
Children: []Node{Pagination(props)},
}
}
func Tabs(props TabsProps) Node {
items := make([]TabsItem, 0, len(props.Items))
for _, item := range props.Items {
items = append(items, TabsItem{
Label: strings.TrimSpace(item.Label),
Href: strings.TrimSpace(item.Href),
Active: item.Active,
Disabled: item.Disabled,
})
}
ariaLabel := strings.TrimSpace(props.AriaLabel)
if ariaLabel == "" {
ariaLabel = "tabs"
}
return templateNode{
name: "components/tabs",
data: struct {
Class string
AriaLabel string
Items []TabsItem
}{
Class: joinClass("tabs tabs-boxed", props.Props.Class),
AriaLabel: ariaLabel,
Items: items,
},
}
}
func TabsWithVariants(props TabsProps, variant TabsVariantProps) Node {
props.Props.Class = joinClass(props.Props.Class, variant.Class)
props.Props.Variant = strings.TrimSpace(joinClass(props.Props.Variant, string(variant.Variant)))
if strings.TrimSpace(props.Props.Size) == "" {
props.Props.Size = string(variant.Size)
}
if variant.Disabled {
props.Props.Disabled = true
}
return Tabs(props)
}
func Breadcrumb(props BreadcrumbProps) Node {
items := make([]BreadcrumbItem, 0, len(props.Items))
for _, item := range props.Items {
items = append(items, BreadcrumbItem{
Label: strings.TrimSpace(item.Label),
Href: strings.TrimSpace(item.Href),
Active: item.Active,
})
}
ariaLabel := strings.TrimSpace(props.AriaLabel)
if ariaLabel == "" {
ariaLabel = "breadcrumb"
}
return templateNode{
name: "components/breadcrumb",
data: struct {
Class string
AriaLabel string
Items []BreadcrumbItem
}{
Class: joinClass("breadcrumbs text-sm", props.Props.Class),
AriaLabel: ariaLabel,
Items: items,
},
}
}
func checkboxComponent(props CheckboxComponentProps) Node {
return templateNode{
name: "components/checkbox",
data: struct {
Label string
Name string
Value string
Class string
Checked bool
Disabled bool
}{
Label: strings.TrimSpace(props.Label),
Name: strings.TrimSpace(props.Name),
Value: strings.TrimSpace(props.Value),
Class: checkboxClass(props.Props),
Checked: props.Checked,
Disabled: props.Props.Disabled,
},
}
}
func CheckboxWithVariants(name, value, label string, checked bool, props CheckboxVariantProps) Node {
return Checkbox(CheckboxComponentProps{
Name: name,
Value: value,
Label: label,
Checked: checked,
Props: ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
},
})
}
func radioGroupComponent(props RadioGroupComponentProps) Node {
items := make([]RadioItem, 0, len(props.Items))
for _, item := range props.Items {
items = append(items, RadioItem{
Label: strings.TrimSpace(item.Label),
Value: strings.TrimSpace(item.Value),
Checked: item.Checked,
Disabled: item.Disabled,
})
}
ariaLabel := strings.TrimSpace(props.AriaLabel)
if ariaLabel == "" {
ariaLabel = "radio group"
}
return templateNode{
name: "components/radio_group",
data: struct {
Name string
Class string
AriaLabel string
Items []RadioItem
Disabled bool
}{
Name: strings.TrimSpace(props.Name),
Class: radioClass(props.Props),
AriaLabel: ariaLabel,
Items: items,
Disabled: props.Props.Disabled,
},
}
}
func RadioGroupWithVariants(name, ariaLabel string, items []RadioItem, props RadioVariantProps) Node {
return radioGroupComponent(RadioGroupComponentProps{
Name: name,
AriaLabel: ariaLabel,
Items: items,
Props: ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
},
})
}
func switchComponent(props SwitchComponentProps) Node {
return templateNode{
name: "components/switch",
data: struct {
Label string
Name string
Value string
Class string
Checked bool
Disabled bool
}{
Label: strings.TrimSpace(props.Label),
Name: strings.TrimSpace(props.Name),
Value: strings.TrimSpace(props.Value),
Class: switchClass(props.Props),
Checked: props.Checked,
Disabled: props.Props.Disabled,
},
}
}
func SwitchWithVariants(name, value, label string, checked bool, props SwitchVariantProps) Node {
return switchComponent(SwitchComponentProps{
Name: name,
Value: value,
Label: label,
Checked: checked,
Props: ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
},
})
}
func Container(props ContainerProps, children ...Node) Node {
return layoutChildrenNode("components/container", containerClass(props), children)
}
func Markdown(props MarkdownProps) Node {
var out bytes.Buffer
if err := goldmark.Convert([]byte(strings.TrimSpace(props.Content)), &out); err != nil {
return renderErrorNode{err: err}
}
return templateNode{
name: "components/markdown",
data: struct {
Class string
Content template.HTML
}{
Class: strings.TrimSpace(props.Props.Class),
Content: template.HTML(out.String()),
},
}
}
func layoutChildrenNode(name, className string, children []Node) Node {
childHTML, err := renderNodes(children)
if err != nil {
return renderErrorNode{err: err}
}
return templateNode{
name: name,
data: struct {
Class string
Children []template.HTML
}{
Class: className,
Children: childHTML,
},
}
}
func renderNode(node Node) (template.HTML, error) {
if node == nil {
return "", nil
}
return node.Render()
}
func renderNodes(nodes []Node) ([]template.HTML, error) {
rendered := make([]template.HTML, 0, len(nodes))
for _, node := range nodes {
html, err := renderNode(node)
if err != nil {
return nil, err
}
rendered = append(rendered, html)
}
return rendered, nil
}
type renderErrorNode struct {
err error
}
func (n renderErrorNode) Render() (template.HTML, error) {
return "", n.err
}
package frontend
import (
"html/template"
"sync"
"github.com/YoshihideShirai/marionette/internal/componenttmpl"
)
// This file keeps frontend wired to the shared component template renderer.
// Template parsing, execution, and cache ownership live in internal/componenttmpl;
// this package only resolves the repository template root.
type templateNode struct {
name string
data any
}
var (
componentTemplateSource *componenttmpl.Source
componentTemplateSourceErr error
componentTemplateSourceOnce sync.Once
)
func (n templateNode) Render() (template.HTML, error) {
source := componentTemplateSourceForPackage()
if componentTemplateSourceErr != nil {
return "", componentTemplateSourceErr
}
return componenttmpl.Node{
Source: source,
Name: n.name,
Data: n.data,
}.Render()
}
func componentTemplateSourceForPackage() *componenttmpl.Source {
componentTemplateSourceOnce.Do(func() {
componentTemplateSource, componentTemplateSourceErr = componenttmpl.NewSourceFromCaller(1, "..", "templates", "components")
})
return componentTemplateSource
}
package frontend
import "fmt"
// このファイルはTableコンポーネントのProps/DTO型と補助関数を定義する。
// テーブル表示に関する型・変換ロジックをここに集約する。
type TableColumn struct {
Label string
SortKey string
SortHref string
SortActive bool
}
type TableComponentRow struct {
Cells []Node
}
func TableRowValues(values ...any) TableComponentRow {
cells := make([]Node, 0, len(values))
for _, value := range values {
switch v := value.(type) {
case nil:
cells = append(cells, textNode(""))
case Node:
cells = append(cells, v)
default:
cells = append(cells, textNode(fmt.Sprint(v)))
}
}
return TableComponentRow{Cells: cells}
}
type TableProps struct {
Columns []TableColumn
Rows []TableComponentRow
EmptyTitle string
EmptyDescription string
View DataFrameViewProps
QueryStateName string
SelectedFilters []DataFrameFilter
}
package daisyui
import (
"strconv"
"strings"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
)
type DrawerProps struct {
ID string
Class string
ContentClass string
SideClass string
OverlayClass string
Content shared.Node
Side shared.Node
}
func DrawerWithProps(props DrawerProps) shared.Node {
drawerID := strings.TrimSpace(props.ID)
if drawerID == "" {
drawerID = "drawer"
}
overlayClass := strings.TrimSpace(props.OverlayClass)
if overlayClass == "" {
overlayClass = "drawer-overlay"
}
return node("div", map[string]string{"class": strings.TrimSpace("drawer " + props.Class)},
node("input", map[string]string{"id": drawerID, "type": "checkbox", "class": "drawer-toggle"}),
node("div", map[string]string{"class": strings.TrimSpace("drawer-content " + props.ContentClass)}, props.Content),
node("div", map[string]string{"class": strings.TrimSpace("drawer-side " + props.SideClass)},
node("label", map[string]string{"for": drawerID, "aria-label": "close sidebar", "class": overlayClass}),
props.Side,
),
)
}
type NavbarProps struct {
Class string
}
func NavbarWithProps(props NavbarProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("navbar " + props.Class)}, children...)
}
type MenuProps struct {
Class string
}
func MenuWithProps(props MenuProps, items ...shared.Node) shared.Node {
return node("ul", map[string]string{"class": strings.TrimSpace("menu " + props.Class)}, items...)
}
type MenuLinkProps struct {
Label string
Href string
Icon string
Active bool
Class string
}
func MenuTitle(label string) shared.Node {
return node("li", map[string]string{"class": "menu-title"}, textNode("span", nil, label))
}
func MenuLink(props MenuLinkProps) shared.Node {
className := strings.TrimSpace(props.Class)
if props.Active {
className = strings.TrimSpace(className + " active")
}
children := make([]shared.Node, 0, 2)
if props.Icon != "" {
children = append(children, textNode("span", map[string]string{"class": "w-6 text-center"}, props.Icon))
}
children = append(children, lowhtml.ElementNode{Tag: "span", Text: props.Label})
return node("li", nil, node("a", map[string]string{"class": className, "href": props.Href}, children...))
}
type StatsProps struct {
Class string
}
type StatProps struct {
Title string
Value string
Description string
Figure shared.Node
Class string
TitleClass string
ValueClass string
DescriptionClass string
FigureClass string
}
func StatsWithProps(props StatsProps, items ...shared.Node) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("stats " + props.Class)}, items...)
}
func StatItem(props StatProps) shared.Node {
children := make([]shared.Node, 0, 4)
if props.Figure != nil {
children = append(children, node("div", map[string]string{"class": strings.TrimSpace("stat-figure " + props.FigureClass)}, props.Figure))
}
children = append(children,
textNode("div", map[string]string{"class": strings.TrimSpace("stat-title " + props.TitleClass)}, props.Title),
textNode("div", map[string]string{"class": strings.TrimSpace("stat-value " + props.ValueClass)}, props.Value),
textNode("div", map[string]string{"class": strings.TrimSpace("stat-desc " + props.DescriptionClass)}, props.Description),
)
return node("div", map[string]string{"class": strings.TrimSpace("stat " + props.Class)}, children...)
}
type CardPanelProps struct {
Title string
Description string
Class string
BodyClass string
Actions shared.Node
}
func CardPanel(props CardPanelProps, children ...shared.Node) shared.Node {
bodyChildren := make([]shared.Node, 0, len(children)+3)
if props.Title != "" {
bodyChildren = append(bodyChildren, textNode("h2", map[string]string{"class": "card-title"}, props.Title))
}
if props.Description != "" {
bodyChildren = append(bodyChildren, textNode("p", map[string]string{"class": "text-sm text-base-content/60"}, props.Description))
}
bodyChildren = append(bodyChildren, children...)
if props.Actions != nil {
bodyChildren = append(bodyChildren, node("div", map[string]string{"class": "card-actions justify-end"}, props.Actions))
}
return node("div", map[string]string{"class": strings.TrimSpace("card " + props.Class)},
node("div", map[string]string{"class": strings.TrimSpace("card-body " + props.BodyClass)}, bodyChildren...),
)
}
type TableProps struct {
Headers []string
Rows [][]shared.Node
Class string
WrapperClass string
HeaderClasses []string
HeaderSortables []bool
CellClasses [][]string
Empty shared.Node
EmptyColSpan int
}
func TableWithProps(props TableProps) shared.Node {
headers := make([]shared.Node, 0, len(props.Headers))
for index, header := range props.Headers {
attrs := map[string]string{}
if className := stringAt(props.HeaderClasses, index); className != "" {
attrs["class"] = className
}
if boolAt(props.HeaderSortables, index) {
attrs["aria-sort"] = "none"
headers = append(headers, node("th", attrs,
node("span", map[string]string{"class": "inline-flex items-center gap-1"},
textNode("span", nil, header),
textNode("span", map[string]string{"class": "text-base-content/40", "aria-hidden": "true"}, "↕"),
),
))
continue
}
headers = append(headers, textNode("th", attrs, header))
}
bodyRows := make([]shared.Node, 0, len(props.Rows))
if len(props.Rows) == 0 && props.Empty != nil {
colSpan := props.EmptyColSpan
if colSpan < 1 {
colSpan = len(props.Headers)
}
bodyRows = append(bodyRows, node("tr", nil, node("td", map[string]string{"class": "py-8 text-center text-base-content/60", "colspan": strconv.Itoa(colSpan)}, props.Empty)))
}
for rowIndex, row := range props.Rows {
cells := make([]shared.Node, 0, len(row))
for cellIndex, cell := range row {
attrs := map[string]string{}
if className := tableCellClass(props.CellClasses, rowIndex, cellIndex); className != "" {
attrs["class"] = className
}
cells = append(cells, node("td", attrs, cell))
}
bodyRows = append(bodyRows, node("tr", nil, cells...))
}
return node("div", map[string]string{"class": strings.TrimSpace("overflow-x-auto " + props.WrapperClass)},
node("table", map[string]string{"class": strings.TrimSpace("table " + props.Class)},
node("thead", nil, node("tr", nil, headers...)),
node("tbody", nil, bodyRows...),
),
)
}
func stringAt(values []string, index int) string {
if index < 0 || index >= len(values) {
return ""
}
return strings.TrimSpace(values[index])
}
func boolAt(values []bool, index int) bool {
return index >= 0 && index < len(values) && values[index]
}
func tableCellClass(values [][]string, rowIndex, cellIndex int) string {
if rowIndex < 0 || rowIndex >= len(values) {
return ""
}
return stringAt(values[rowIndex], cellIndex)
}
func ProgressWithClass(value, max float64, className string) shared.Node {
return node("progress", map[string]string{
"class": strings.TrimSpace("progress " + className),
"value": strconv.FormatFloat(value, 'f', -1, 64),
"max": strconv.FormatFloat(max, 'f', -1, 64),
})
}
func ButtonWithAttrs(label string, props shared.ComponentProps, attrs map[string]string) shared.Node {
buttonAttrs := map[string]string{}
for key, value := range attrs {
buttonAttrs[key] = value
}
buttonAttrs["class"] = strings.TrimSpace("btn " + props.Class + " " + buttonAttrs["class"])
if props.Disabled {
buttonAttrs["disabled"] = "disabled"
}
return textNode("button", buttonAttrs, label)
}
type ActionFormOptions struct {
Action string
Target string
Swap string
Method string
Class string
}
func ActionFormWithOptions(props ActionFormOptions, children ...shared.Node) shared.Node {
method := strings.ToLower(strings.TrimSpace(props.Method))
if method == "" {
method = "post"
}
attrs := map[string]string{"method": method, "class": strings.TrimSpace(props.Class)}
if props.Action != "" {
attrs["action"] = props.Action
attrs["hx-"+method] = props.Action
}
if props.Target != "" {
attrs["hx-target"] = props.Target
}
if props.Swap != "" {
attrs["hx-swap"] = props.Swap
}
return node("form", attrs, children...)
}
func ButtonContentWithAttrs(props shared.ComponentProps, attrs map[string]string, children ...shared.Node) shared.Node {
buttonAttrs := map[string]string{}
for key, value := range attrs {
buttonAttrs[key] = value
}
buttonAttrs["class"] = strings.TrimSpace("btn " + props.Class + " " + buttonAttrs["class"])
if props.Disabled {
buttonAttrs["disabled"] = "disabled"
}
return node("button", buttonAttrs, children...)
}
func AvatarPlaceholder(label string, outerClass string, innerClass string) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("avatar placeholder " + outerClass)},
node("div", map[string]string{"class": innerClass}, textNode("span", nil, label)),
)
}
package daisyui
import (
"strconv"
"strings"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
)
func node(tag string, attrs map[string]string, children ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: tag, Attrs: attrs, Children: children}
}
func textNode(tag string, attrs map[string]string, text string) shared.Node {
return lowhtml.ElementNode{Tag: tag, Attrs: attrs, Text: text}
}
func joinClass(parts ...string) string {
classes := make([]string, 0, len(parts))
for _, part := range parts {
classes = append(classes, strings.Fields(part)...)
}
return strings.Join(classes, " ")
}
func daisySizeClass(prefix, size string) string {
switch strings.TrimSpace(size) {
case "xs", "sm", "md", "lg", "xl":
return prefix + "-" + strings.TrimSpace(size)
default:
return ""
}
}
func Button(label string, props shared.ComponentProps) shared.Node {
className := strings.TrimSpace("btn " + props.Class)
attrs := map[string]string{"class": className}
if props.Disabled {
attrs["disabled"] = "disabled"
}
return textNode("button", attrs, label)
}
func Alert(title, description string, props shared.ComponentProps) shared.Node {
return AlertWithContent(shared.AlertContentProps{Title: title, Description: description, Props: props})
}
func AlertWithContent(props shared.AlertContentProps, children ...shared.Node) shared.Node {
alertChildren := make([]shared.Node, 0, len(children)+3)
if props.Icon != nil {
alertChildren = append(alertChildren, props.Icon)
}
if len(children) > 0 {
alertChildren = append(alertChildren, children...)
} else if body := alertBody(props.Title, props.Description); body != nil {
alertChildren = append(alertChildren, body)
}
if props.Actions != nil {
alertChildren = append(alertChildren, props.Actions)
}
return node("div", map[string]string{"class": alertClass(props.Props), "role": "alert"}, alertChildren...)
}
func alertBody(title, description string) shared.Node {
bodyChildren := []shared.Node{}
if strings.TrimSpace(title) != "" {
bodyChildren = append(bodyChildren, textNode("h3", map[string]string{"class": "font-bold"}, title))
}
if strings.TrimSpace(description) != "" {
bodyChildren = append(bodyChildren, textNode("div", map[string]string{"class": "text-xs"}, description))
}
if len(bodyChildren) == 0 {
return nil
}
return node("div", nil, bodyChildren...)
}
func alertClass(props shared.ComponentProps) string {
return joinClass("alert", alertVariantClass(props.Variant), props.Class)
}
func alertVariantClass(variant string) string {
switch strings.ToLower(strings.TrimSpace(variant)) {
case "info":
return "alert-info"
case "success":
return "alert-success"
case "warning":
return "alert-warning"
case "error", "danger":
return "alert-error"
default:
return ""
}
}
func Card(title, description string, actions shared.Node, children []shared.Node, props shared.ComponentProps) shared.Node {
cardChildren := make([]shared.Node, 0, len(children)+1)
if title != "" || description != "" || actions != nil {
headerChildren := []shared.Node{}
if title != "" {
headerChildren = append(headerChildren, textNode("h2", map[string]string{"class": "card-title"}, title))
}
if description != "" {
headerChildren = append(headerChildren, textNode("p", nil, description))
}
if actions != nil {
headerChildren = append(headerChildren, node("div", map[string]string{"class": "card-actions justify-end"}, actions))
}
cardChildren = append(cardChildren, node("div", map[string]string{"class": "card-body"}, headerChildren...))
}
cardChildren = append(cardChildren, children...)
return node("div", map[string]string{"class": strings.TrimSpace("card bg-base-100 shadow-sm " + props.Class)}, cardChildren...)
}
func Input(name, value string, props shared.ComponentProps) shared.Node {
attrs := map[string]string{
"name": name,
"value": value,
"class": joinClass("input", "w-full", daisySizeClass("input", props.Size), props.Class),
}
if props.Disabled {
attrs["disabled"] = "disabled"
}
return node("input", attrs)
}
func Toast(title, description string, props shared.ComponentProps) shared.Node {
return ToastWithContent(props, Alert(title, description, shared.ComponentProps{Variant: props.Variant}))
}
func ToastWithContent(props shared.ComponentProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": joinClass("toast", props.Class)}, children...)
}
func Modal(props shared.ModalProps) shared.Node {
attrs := map[string]string{"class": "modal"}
if props.Open {
attrs["open"] = "open"
}
return node("dialog", attrs,
node("div", map[string]string{"class": "modal-box"},
textNode("h3", map[string]string{"class": "font-bold text-lg"}, props.Title),
props.Body,
node("div", map[string]string{"class": "modal-action"}, props.Actions),
),
node("form", map[string]string{"method": "dialog", "class": "modal-backdrop"}, textNode("button", nil, "close")),
)
}
func Select(name string, options []shared.SelectOption, props shared.ComponentProps) shared.Node {
children := make([]shared.Node, 0, len(options))
for _, opt := range options {
attrs := map[string]string{"value": opt.Value}
if opt.Selected {
attrs["selected"] = "selected"
}
children = append(children, textNode("option", attrs, opt.Label))
}
return node("select", map[string]string{
"name": name,
"class": joinClass("select", daisySizeClass("select", props.Size), props.Class),
}, children...)
}
func Tabs(props shared.TabsProps) shared.Node {
tabNodes := make([]shared.Node, 0, len(props.Items))
for _, item := range props.Items {
attrs := map[string]string{"class": "tab", "role": "tab", "type": "button", "aria-selected": "false"}
if item.Active {
attrs["class"] += " tab-active"
attrs["aria-selected"] = "true"
}
if item.Disabled {
attrs["class"] += " tab-disabled"
attrs["disabled"] = "disabled"
}
tabNodes = append(tabNodes, textNode("button", attrs, item.Label))
}
attrs := map[string]string{"class": strings.TrimSpace("tabs " + props.Props.Class), "role": "tablist"}
if props.AriaLabel != "" {
attrs["aria-label"] = props.AriaLabel
}
return node("div", attrs, tabNodes...)
}
func Badge(props shared.BadgeProps) shared.Node {
return textNode("span", map[string]string{"class": strings.TrimSpace("badge " + props.Props.Class)}, props.Label)
}
func Skeleton(rows int, props shared.ComponentProps) shared.Node {
if rows <= 0 {
rows = 3
}
items := make([]shared.Node, 0, rows)
for i := 0; i < rows; i++ {
items = append(items, node("div", map[string]string{"class": "skeleton h-4 w-full"}))
}
return node("div", map[string]string{"class": strings.TrimSpace("space-y-2 " + props.Class)}, items...)
}
func Progress(props shared.ProgressProps) shared.Node {
max := props.Max
if max <= 0 {
max = 100
}
attrs := map[string]string{"class": progressClass(props.Props), "max": strconv.FormatFloat(max, 'f', -1, 64)}
if !props.Indeterminate {
attrs["value"] = strconv.FormatFloat(props.Value, 'f', -1, 64)
}
return node("progress", attrs, textNode("span", nil, props.Label))
}
func CardWithVariants(props shared.CardProps, variant shared.InputVariantProps, children ...shared.Node) shared.Node {
props.Props.Class = strings.TrimSpace(strings.Join([]string{props.Props.Class, variant.Class}, " "))
props.Props.Variant = strings.TrimSpace(strings.Join([]string{props.Props.Variant, string(variant.Variant)}, " "))
if props.Props.Size == "" {
props.Props.Size = string(variant.Size)
}
if variant.Disabled {
props.Props.Disabled = true
}
return Card(props.Title, props.Description, props.Actions, children, props.Props)
}
func ModalWithVariants(props shared.ModalProps, variant shared.InputVariantProps) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace(strings.Join([]string{variant.Class, "modal-variant", string(variant.Variant), string(variant.Size)}, " "))}, Modal(props))
}
func progressClass(props shared.ComponentProps) string {
classes := []string{"progress", "w-full", progressVariantClass(props.Variant), progressSizeClass(props.Size), props.Class}
return strings.TrimSpace(strings.Join(classes, " "))
}
func progressVariantClass(variant string) string {
switch strings.TrimSpace(variant) {
case "primary":
return "progress-primary"
case "secondary":
return "progress-secondary"
case "accent":
return "progress-accent"
case "success":
return "progress-success"
case "info":
return "progress-info"
case "warning":
return "progress-warning"
case "error":
return "progress-error"
default:
return ""
}
}
func progressSizeClass(size string) string {
switch strings.TrimSpace(size) {
case "xs":
return "h-0.5"
case "sm":
return "h-1"
case "lg":
return "h-4"
case "xl":
return "h-5"
default:
return "h-2"
}
}
func Checkbox(props shared.CheckboxComponentProps) shared.Node {
inputAttrs := map[string]string{"type": "checkbox", "class": joinClass("checkbox", daisySizeClass("checkbox", props.Props.Size), props.Props.Class), "name": props.Name, "value": props.Value}
if props.Checked {
inputAttrs["checked"] = "checked"
}
if props.Props.Disabled {
inputAttrs["disabled"] = "disabled"
}
return node("label", map[string]string{"class": "label cursor-pointer gap-2"}, node("input", inputAttrs), textNode("span", map[string]string{"class": "label"}, props.Label))
}
func RadioGroup(props shared.RadioGroupComponentProps) shared.Node {
items := make([]shared.Node, 0, len(props.Items))
for _, item := range props.Items {
attrs := map[string]string{"type": "radio", "name": props.Name, "value": item.Value, "class": joinClass("radio", daisySizeClass("radio", props.Props.Size))}
if item.Checked {
attrs["checked"] = "checked"
}
if props.Props.Disabled || item.Disabled {
attrs["disabled"] = "disabled"
}
items = append(items, node("label", map[string]string{"class": "label cursor-pointer gap-2"}, node("input", attrs), textNode("span", map[string]string{"class": "label"}, item.Label)))
}
return node("div", map[string]string{"class": strings.TrimSpace("space-y-2 " + props.Props.Class)}, items...)
}
func Switch(props shared.SwitchComponentProps) shared.Node {
attrs := map[string]string{"type": "checkbox", "class": joinClass("toggle", daisySizeClass("toggle", props.Props.Size), props.Props.Class), "name": props.Name, "value": props.Value}
if props.Checked {
attrs["checked"] = "checked"
}
if props.Props.Disabled {
attrs["disabled"] = "disabled"
}
return node("label", map[string]string{"class": "label cursor-pointer gap-2"}, node("input", attrs), textNode("span", map[string]string{"class": "label"}, props.Label))
}
func Pagination(props shared.PaginationProps) shared.Node {
return node("div", map[string]string{"class": "join"},
textNode("a", map[string]string{"class": "join-item btn", "href": props.PrevHref}, "«"),
textNode("button", map[string]string{"class": "join-item btn"}, strconv.Itoa(props.Page)),
textNode("a", map[string]string{"class": "join-item btn", "href": props.NextHref}, "»"),
)
}
func EmptyState(props shared.EmptyStateProps) shared.Node {
if props.Skeleton {
rows := props.Rows
if rows <= 0 {
rows = 3
}
children := make([]shared.Node, 0, rows)
for i := 0; i < rows; i++ {
children = append(children, node("div", map[string]string{"class": "skeleton h-4 w-full"}))
}
return node("div", map[string]string{"class": "space-y-2", "aria-busy": "true", "aria-live": "polite"}, children...)
}
return node("div", map[string]string{"class": strings.TrimSpace("hero bg-base-200 rounded-box " + props.Props.Class)},
node("div", map[string]string{"class": "hero-content text-center"},
node("div", nil, textNode("h2", map[string]string{"class": "text-2xl font-bold"}, props.Title), textNode("p", nil, props.Description)),
),
)
}
func PageHeader(props shared.PageHeaderProps) shared.Node {
return node("header", map[string]string{"class": strings.TrimSpace("mb-6 space-y-2 " + props.Props.Class)},
textNode("h1", map[string]string{"class": "text-3xl font-bold"}, props.Title),
textNode("p", map[string]string{"class": "text-base-content/70"}, props.Description),
props.Actions,
)
}
func Section(props shared.SectionProps, children ...shared.Node) shared.Node {
nodes := make([]shared.Node, 0, len(children)+2)
if props.Title != "" {
nodes = append(nodes, textNode("h2", map[string]string{"class": "text-xl font-semibold"}, props.Title))
}
if props.Description != "" {
nodes = append(nodes, textNode("p", map[string]string{"class": "text-base-content/70"}, props.Description))
}
nodes = append(nodes, children...)
return node("section", map[string]string{"class": strings.TrimSpace("space-y-4 " + props.Props.Class)}, nodes...)
}
func Grid(props shared.GridProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": gridClass(props)}, children...)
}
func Stack(props shared.StackProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": stackClass(props)}, children...)
}
func stackClass(props shared.StackProps) string {
className := []string{"flex", stackDirectionClass(props.Direction), gapClass(props.Gap), alignClass(props.Align), justifyClass(props.Justify)}
if props.Wrap {
className = append(className, "flex-wrap")
}
if props.Props.Class != "" {
className = append(className, props.Props.Class)
}
return strings.TrimSpace(strings.Join(className, " "))
}
func stackDirectionClass(direction string) string {
switch strings.TrimSpace(direction) {
case "horizontal", "row":
return "flex-row"
default:
return "flex-col"
}
}
func gridClass(props shared.GridProps) string {
className := []string{"grid", gapClass(props.Gap), gridColumnsClass(props.Columns, props.MinColumnWidth)}
if props.Props.Class != "" {
className = append(className, props.Props.Class)
}
return strings.TrimSpace(strings.Join(className, " "))
}
func gapClass(gap string) string {
switch strings.TrimSpace(gap) {
case "none", "0":
return "gap-0"
case "xs":
return "gap-1"
case "sm":
return "gap-2"
case "lg":
return "gap-6"
case "xl":
return "gap-8"
default:
return "gap-4"
}
}
func alignClass(align string) string {
switch strings.TrimSpace(align) {
case "start":
return "items-start"
case "center":
return "items-center"
case "end":
return "items-end"
default:
return "items-stretch"
}
}
func justifyClass(justify string) string {
switch strings.TrimSpace(justify) {
case "center":
return "justify-center"
case "end":
return "justify-end"
case "between":
return "justify-between"
default:
return "justify-start"
}
}
func gridColumnsClass(columns, minColumnWidth string) string {
switch strings.TrimSpace(minColumnWidth) {
case "sm":
return "grid-cols-[repeat(auto-fit,minmax(14rem,1fr))]"
case "md":
return "grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]"
case "lg":
return "grid-cols-[repeat(auto-fit,minmax(22rem,1fr))]"
}
switch strings.TrimSpace(columns) {
case "1":
return "grid-cols-1"
case "2":
return "grid-cols-1 sm:grid-cols-2"
case "3":
return "grid-cols-1 md:grid-cols-3"
case "4":
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4"
default:
return strings.TrimSpace(columns)
}
}
func Breadcrumb(props shared.BreadcrumbProps) shared.Node {
items := make([]shared.Node, 0, len(props.Items))
for _, item := range props.Items {
items = append(items, node("li", nil, textNode("a", map[string]string{"href": item.Href}, item.Label)))
}
return node("div", map[string]string{"class": strings.TrimSpace("breadcrumbs text-sm " + props.Props.Class)},
node("ul", nil, items...),
)
}
func Divider(props shared.DividerProps) shared.Node {
className := "divider"
if props.Props.Class != "" {
className += " " + props.Props.Class
}
if modifier := dividerModifierClass(props.Spacing); modifier != "" {
className += " " + modifier
}
return node("div", map[string]string{"class": className})
}
func dividerModifierClass(value string) string {
switch strings.TrimSpace(value) {
case "neutral", "primary", "secondary", "accent", "success", "warning", "info", "error":
return "divider-" + strings.TrimSpace(value)
case "vertical", "horizontal", "start", "end":
return "divider-" + strings.TrimSpace(value)
default:
return ""
}
}
func Actions(props shared.ActionsProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("flex items-center gap-2 " + props.Props.Class)}, children...)
}
func HiddenField(name, value string) shared.Node {
return node("input", map[string]string{"type": "hidden", "name": name, "value": value})
}
func Box(props shared.BoxProps, children ...shared.Node) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("rounded-box border border-base-300 p-4 " + props.Props.Class)}, children...)
}
func AppShell(props shared.AppShellProps) shared.Node {
attrs := map[string]string{"class": strings.TrimSpace("min-h-screen bg-base-100 " + props.Props.Class)}
if props.ID != "" {
attrs["id"] = props.ID
}
mainAttrs := map[string]string{"class": "mx-auto w-full max-w-7xl p-4 md:p-6"}
if props.MainID != "" {
mainAttrs["id"] = props.MainID
}
return node("div", attrs,
props.Sidebar,
props.Flashes,
props.Header,
node("main", mainAttrs, props.Content),
)
}
func Image(props shared.ImageProps) shared.Node {
attrs := map[string]string{"src": props.Src, "alt": props.Alt, "class": strings.TrimSpace("rounded-lg " + props.Props.Class)}
if props.Width > 0 {
attrs["width"] = strconv.Itoa(props.Width)
}
if props.Height > 0 {
attrs["height"] = strconv.Itoa(props.Height)
}
return node("figure", nil, node("img", attrs), textNode("figcaption", map[string]string{"class": "text-sm mt-2"}, props.Caption))
}
func Chart(props shared.ChartProps) shared.Node {
return node("div", map[string]string{"class": strings.TrimSpace("card bg-base-100 border border-base-300 " + props.Props.Class)},
node("div", map[string]string{"class": "card-body"},
textNode("h3", map[string]string{"class": "card-title"}, props.Title),
textNode("p", map[string]string{"class": "text-sm opacity-70"}, props.Description),
),
)
}
func Form(props shared.FormProps, children ...shared.Node) shared.Node {
attrs := map[string]string{
"method": props.Method,
"action": props.Action,
"class": strings.TrimSpace("space-y-4 " + props.Class),
}
if props.ID != "" {
attrs["id"] = props.ID
}
return node("form", attrs, children...)
}
func ActionForm(props shared.ActionFormProps, children ...shared.Node) shared.Node {
method := strings.ToLower(strings.TrimSpace(props.Method))
if method == "" {
method = "post"
}
attrs := map[string]string{
"method": method,
"action": props.Action,
"class": strings.TrimSpace("space-y-4 " + props.Props.Class),
}
attrs["hx-"+method] = props.Action
if props.Target != "" {
attrs["hx-target"] = props.Target
}
if props.Swap != "" {
attrs["hx-swap"] = props.Swap
}
return node("form", attrs, children...)
}
func FormField(control shared.Node, props shared.FormFieldProps) shared.Node {
legend := props.Label
if props.Required {
legend += " *"
}
children := []shared.Node{textNode("legend", map[string]string{"class": "fieldset-legend"}, legend), control}
if props.Hint != "" {
children = append(children, textNode("p", map[string]string{"class": "label"}, props.Hint))
}
if props.Error != "" {
children = append(children, textNode("p", map[string]string{"class": "label text-error"}, props.Error))
}
return node("fieldset", map[string]string{"class": "fieldset w-full"}, children...)
}
func Textarea(name, value string, options shared.TextareaOptions) shared.Node {
attrs := map[string]string{
"name": name,
"class": joinClass("textarea", "w-full", daisySizeClass("textarea", options.Props.Size), options.Props.Class),
}
if options.Rows > 0 {
attrs["rows"] = strconv.Itoa(options.Rows)
}
if options.Placeholder != "" {
attrs["placeholder"] = options.Placeholder
}
if options.Required {
attrs["required"] = "required"
}
return textNode("textarea", attrs, value)
}
func Region(props shared.RegionProps, children ...shared.Node) shared.Node {
attrs := map[string]string{
"class": strings.TrimSpace("space-y-3 " + props.Props.Class),
}
if props.ID != "" {
attrs["id"] = props.ID
}
return node("section", attrs, children...)
}
func Split(props shared.SplitProps) shared.Node {
wrapClass := "flex flex-col gap-4"
if props.ReverseOnMobile {
wrapClass = "flex flex-col-reverse gap-4"
}
if props.Props.Class != "" {
wrapClass += " " + props.Props.Class
}
return node("div", map[string]string{"class": wrapClass},
node("div", map[string]string{"class": "flex-1"}, props.Main),
node("aside", map[string]string{"class": "w-full lg:w-80"}, props.Aside),
)
}
func Container(props shared.ContainerProps, children ...shared.Node) shared.Node {
className := "w-full"
if props.MaxWidth != "" {
className += " " + props.MaxWidth
} else {
className += " max-w-7xl"
}
if props.Centered {
className += " mx-auto"
}
if props.Padding != "" {
className += " " + props.Padding
}
if props.Props.Class != "" {
className += " " + props.Props.Class
}
return node("div", map[string]string{"class": className}, children...)
}
func ThemeToggleButton(props shared.ComponentProps) shared.Node {
className := strings.TrimSpace("btn btn-ghost " + props.Class)
return node("button", map[string]string{
"class": className,
"type": "button",
"aria-label": "Toggle theme: system, light, dark",
"aria-pressed": "false",
"data-mrn-theme-toggle": "true",
"data-mrn-theme-mode": "system",
"onclick": "globalThis.mrnToggleTheme()",
},
textNode("span", map[string]string{"aria-hidden": "true", "data-mrn-theme-icon": "true"}, "◐"),
)
}
func Text(props shared.TextProps) shared.Node {
return textNode("p", map[string]string{"class": strings.TrimSpace(props.Size + " " + props.Weight + " " + props.Props.Class)}, props.Text)
}
func FontIcon(props shared.FontIconProps) shared.Node {
attrs := map[string]string{"class": strings.TrimSpace(props.Library + " " + props.Name + " " + props.Props.Class)}
if props.AriaLabel != "" {
attrs["aria-label"] = props.AriaLabel
}
if props.Decorative {
attrs["aria-hidden"] = "true"
}
return node("i", attrs)
}
func ThemeToggle(props shared.ComponentProps) shared.Node {
return ThemeToggleButton(props)
}
func HTMXTable(headers []string, rows ...shared.TableRowData) shared.Node {
headerNodes := make([]shared.Node, 0, len(headers))
for _, h := range headers {
headerNodes = append(headerNodes, textNode("th", nil, h))
}
rowNodes := make([]shared.Node, 0, len(rows))
for _, r := range rows {
cells := make([]shared.Node, 0, len(r.Cells))
for _, c := range r.Cells {
cells = append(cells, node("td", nil, c))
}
rowNodes = append(rowNodes, node("tr", nil, cells...))
}
return node("div", map[string]string{"class": "overflow-x-auto"},
node("table", map[string]string{"class": "table table-zebra w-full"},
node("thead", nil, node("tr", nil, headerNodes...)),
node("tbody", nil, rowNodes...),
),
)
}
func TableRow(cells ...shared.Node) shared.TableRowData {
return shared.TableRowData{Cells: cells}
}
func SubmitButton(label string, props shared.ComponentProps) shared.Node {
btn := Button(label, props)
return btn
}
func InputWithOptions(name, value string, options shared.InputOptions) shared.Node {
inputType := strings.TrimSpace(options.Type)
if inputType == "" {
inputType = "text"
}
attrs := map[string]string{
"name": name,
"value": value,
"type": inputType,
"class": strings.TrimSpace("input w-full " + options.Props.Class),
}
if options.Placeholder != "" {
attrs["placeholder"] = options.Placeholder
}
return node("input", attrs)
}
func FileUpload(name string, required bool, props ...shared.ComponentProps) shared.Node {
p := shared.ComponentProps{}
if len(props) > 0 {
p = props[0]
}
attrs := map[string]string{"type": "file", "name": name, "class": strings.TrimSpace("file-input w-full " + p.Class)}
if required {
attrs["required"] = "required"
}
return node("input", attrs)
}
func Sidebar(brand, title string, items ...shared.SidebarItem) shared.Node {
nodes := make([]shared.Node, 0, len(items))
for _, item := range items {
nodes = append(nodes, node("li", nil, textNode("a", map[string]string{"href": item.Href}, item.Label)))
}
return node("aside", map[string]string{"class": "w-80 bg-base-200 p-4"},
textNode("div", map[string]string{"class": "text-lg font-bold mb-1"}, brand),
textNode("div", map[string]string{"class": "text-sm opacity-70 mb-4"}, title),
node("ul", map[string]string{"class": "menu"}, nodes...),
)
}
func SidebarLink(label, href string) shared.SidebarItem {
return shared.SidebarItem{Label: label, Href: href}
}
func DownloadLink(label, href, filename string, props shared.ComponentProps) shared.Node {
attrs := map[string]string{"href": href, "download": filename, "class": strings.TrimSpace("link link-primary " + props.Class)}
return textNode("a", attrs, label)
}
func DrawerLayout(drawerID string, navbar, content shared.Node, sidebarItems []shared.Node) shared.Node {
if drawerID == "" {
drawerID = "drawer"
}
items := make([]shared.Node, 0, len(sidebarItems))
for _, item := range sidebarItems {
items = append(items, node("li", nil, item))
}
return node("div", map[string]string{"class": "drawer lg:drawer-open"},
node("input", map[string]string{"id": drawerID, "type": "checkbox", "class": "drawer-toggle"}),
node("div", map[string]string{"class": "drawer-content flex flex-col"}, navbar, content),
node("div", map[string]string{"class": "drawer-side"},
node("label", map[string]string{"for": drawerID, "aria-label": "close sidebar", "class": "drawer-overlay"}),
node("ul", map[string]string{"class": "menu bg-base-200 min-h-full w-80 p-4"}, items...),
),
)
}
func DrawerNavbar(drawerID, title string, desktopItems []shared.Node) shared.Node {
if drawerID == "" {
drawerID = "drawer"
}
if title == "" {
title = "Navbar Title"
}
menuItems := make([]shared.Node, 0, len(desktopItems))
for _, item := range desktopItems {
menuItems = append(menuItems, node("li", nil, item))
}
return node("div", map[string]string{"class": "navbar bg-base-300 w-full"},
node("div", map[string]string{"class": "flex-none lg:hidden"},
node("label", map[string]string{"for": drawerID, "aria-label": "open sidebar", "class": "btn btn-square btn-ghost"},
node("svg", map[string]string{"xmlns": "http://www.w3.org/2000/svg", "fill": "none", "viewBox": "0 0 24 24", "class": "inline-block h-6 w-6 stroke-current"},
node("path", map[string]string{"stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", "d": "M4 6h16M4 12h16M4 18h16"}),
),
),
),
textNode("div", map[string]string{"class": "mx-2 flex-1 px-2"}, title),
node("div", map[string]string{"class": "hidden flex-none lg:block"},
node("ul", map[string]string{"class": "menu menu-horizontal"}, menuItems...),
),
)
}
package daisyui
import (
"strconv"
"strings"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
)
func H1(children ...shared.Node) shared.Node {
return node("h1", map[string]string{"class": "text-4xl font-bold"}, children...)
}
func H2(children ...shared.Node) shared.Node {
return node("h2", map[string]string{"class": "text-3xl font-bold"}, children...)
}
func H3(children ...shared.Node) shared.Node {
return node("h3", map[string]string{"class": "text-2xl font-semibold"}, children...)
}
func H4(children ...shared.Node) shared.Node {
return node("h4", map[string]string{"class": "text-xl font-semibold"}, children...)
}
func TextNode(text string) shared.Node { return textNode("span", nil, text) }
func PrimaryButton(label string, props shared.ComponentProps) shared.Node {
if props.Variant == "" {
props.Variant = "primary"
}
props.Class = strings.TrimSpace("btn-primary " + props.Class)
return Button(label, props)
}
func SecondaryButton(label string, props shared.ComponentProps) shared.Node {
if props.Variant == "" {
props.Variant = "secondary"
}
props.Class = strings.TrimSpace("btn-secondary " + props.Class)
return Button(label, props)
}
func GhostButton(label string, props shared.ComponentProps) shared.Node {
if props.Variant == "" {
props.Variant = "ghost"
}
props.Class = strings.TrimSpace("btn-ghost " + props.Class)
return Button(label, props)
}
// Avatar follows daisyUI's avatar markup: .avatar > .w-*/mask wrapper > img
func Avatar(src, alt, class string) shared.Node {
return lowhtml.ElementNode{
Tag: "div",
Attrs: map[string]string{"class": "avatar"},
Children: []shared.Node{
lowhtml.ElementNode{
Tag: "div",
Attrs: map[string]string{"class": class},
Children: []shared.Node{lowhtml.ElementNode{Tag: "img", Attrs: map[string]string{"src": src, "alt": alt}}},
},
},
}
}
func Navbar(start, center, end shared.Node) shared.Node {
return node("div", map[string]string{"class": "navbar bg-base-100 shadow-sm"},
node("div", map[string]string{"class": "navbar-start"}, start),
node("div", map[string]string{"class": "navbar-center"}, center),
node("div", map[string]string{"class": "navbar-end"}, end),
)
}
func Hero(title, description string, actions ...shared.Node) shared.Node {
children := []shared.Node{
textNode("h1", map[string]string{"class": "text-5xl font-bold"}, title),
textNode("p", map[string]string{"class": "py-6"}, description),
}
if len(actions) > 0 {
children = append(children, node("div", map[string]string{"class": "flex gap-2"}, actions...))
}
return node("div", map[string]string{"class": "hero bg-base-200 rounded-box"},
node("div", map[string]string{"class": "hero-content text-center"},
node("div", map[string]string{"class": "max-w-md"}, children...),
),
)
}
func Menu(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": "menu bg-base-200 rounded-box"}, Children: listItemChildren("", items)}
}
func Footer(children ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "footer", Attrs: map[string]string{"class": "footer sm:footer-horizontal bg-base-200 text-base-content p-10"}, Children: children}
}
func Drawer(id string, side, content shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "drawer"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"id": id, "type": "checkbox", "class": "drawer-toggle"}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "drawer-content"}, Children: []shared.Node{content}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "drawer-side"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "label", Attrs: map[string]string{"for": id, "aria-label": "close sidebar", "class": "drawer-overlay"}},
side,
}},
}}
}
func Stat(title, value, desc string) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "stats shadow"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "stat"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "stat-title"}, Text: title},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "stat-value"}, Text: value},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "stat-desc"}, Text: desc},
}},
}}
}
func Steps(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": "steps steps-vertical lg:steps-horizontal"}, Children: items}
}
func Step(label string, active bool) shared.Node {
className := "step"
if active {
className = "step step-primary"
}
return lowhtml.ElementNode{Tag: "li", Attrs: map[string]string{"class": className}, Text: label}
}
func Timeline(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": "timeline timeline-vertical"}, Children: items}
}
func TimelineItem(startLabel, endLabel string, content shared.Node) shared.Node {
children := []shared.Node{}
if startLabel != "" {
children = append(children, lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "timeline-start"}, Text: startLabel})
}
children = append(children,
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "timeline-middle"}, Text: "●"},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "timeline-end timeline-box"}, Children: []shared.Node{content}},
)
if endLabel != "" {
children = append(children, lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "timeline-end"}, Text: endLabel})
}
return lowhtml.ElementNode{Tag: "li", Children: children}
}
func Collapse(title string, content shared.Node, open bool) shared.Node {
inputAttrs := map[string]string{"type": "checkbox"}
if open {
inputAttrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse collapse-arrow bg-base-100 border border-base-300"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: inputAttrs},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse-title font-semibold"}, Text: title},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse-content text-sm"}, Children: []shared.Node{content}},
}}
}
func MockupWindow(title string, content shared.Node) shared.Node {
_ = title
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-window"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "px-4 py-16 bg-base-200"}, Children: []shared.Node{content}},
}}
}
func Kbd(text string) shared.Node {
return lowhtml.ElementNode{Tag: "kbd", Attrs: map[string]string{"class": "kbd"}, Text: text}
}
func Code(text string) shared.Node {
return lowhtml.ElementNode{Tag: "code", Attrs: map[string]string{"class": "bg-base-200 rounded px-1 py-0.5"}, Text: text}
}
func Indicator(item, target shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "indicator"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": "indicator-item"}, Children: []shared.Node{item}},
target,
}}
}
func Link(label, href string, props shared.ComponentProps) shared.Node {
className := "link"
if props.Class != "" {
className += " " + props.Class
}
return lowhtml.ElementNode{Tag: "a", Attrs: map[string]string{"class": className, "href": href}, Text: label}
}
func Dropdown(trigger, menu shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "dropdown"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"tabindex": "0", "role": "button"}, Children: []shared.Node{trigger}},
dropdownContent(menu),
}}
}
func dropdownContent(content shared.Node) shared.Node {
if n, ok := content.(lowhtml.ElementNode); ok {
n.Attrs = cloneAttrs(n.Attrs)
n.Attrs["class"] = appendClass(n.Attrs["class"], "dropdown-content")
n.Attrs["tabindex"] = "-1"
return n
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"tabindex": "-1", "class": "dropdown-content"}, Children: []shared.Node{content}}
}
func Tooltip(text string, child shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "tooltip", "data-tip": text}, Children: []shared.Node{child}}
}
func Loading(sizeClass string) shared.Node {
className := "loading loading-spinner"
if sizeClass != "" {
className += " " + sizeClass
}
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": className}}
}
func RadialProgress(value int, sizeClass string) shared.Node {
valueText := strconv.Itoa(value)
attrs := map[string]string{"class": strings.TrimSpace("radial-progress " + sizeClass), "style": "--value:" + valueText + ";", "role": "progressbar", "aria-valuenow": valueText}
return lowhtml.ElementNode{Tag: "div", Attrs: attrs, Text: valueText + "%"}
}
func Rating(name string, max int, checked int) shared.Node {
stars := make([]shared.Node, 0, max)
for i := 1; i <= max; i++ {
attrs := map[string]string{"type": "radio", "name": name, "class": "mask mask-star-2 bg-orange-400", "value": strconv.Itoa(i)}
if i == checked {
attrs["checked"] = "checked"
}
stars = append(stars, lowhtml.ElementNode{Tag: "input", Attrs: attrs})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "rating"}, Children: stars}
}
func Range(name string, value int, min int, max int) shared.Node {
return lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"type": "range", "name": name, "value": strconv.Itoa(value), "min": strconv.Itoa(min), "max": strconv.Itoa(max), "class": "range"}}
}
func Toggle(name string, checked bool) shared.Node {
attrs := map[string]string{"type": "checkbox", "name": name, "class": "toggle"}
if checked {
attrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "input", Attrs: attrs}
}
func ToggleVariant(name string, checked bool, variant string) shared.Node {
className := "toggle"
switch strings.ToLower(strings.TrimSpace(variant)) {
case "primary":
className += " toggle-primary"
case "secondary":
className += " toggle-secondary"
case "accent":
className += " toggle-accent"
case "neutral":
className += " toggle-neutral"
case "info":
className += " toggle-info"
case "success":
className += " toggle-success"
case "warning":
className += " toggle-warning"
case "danger", "error":
className += " toggle-error"
}
attrs := map[string]string{"type": "checkbox", "name": name, "class": className}
if checked {
attrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "input", Attrs: attrs}
}
func ToggleWithIcons(name string, checked bool, className string) shared.Node {
inputAttrs := map[string]string{"type": "checkbox", "name": name}
if checked {
inputAttrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "label", Attrs: map[string]string{"class": strings.TrimSpace("toggle text-base-content " + className)}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: inputAttrs},
lowhtml.ElementNode{Tag: "svg", Attrs: map[string]string{"aria-label": "enabled", "xmlns": "http://www.w3.org/2000/svg", "viewBox": "0 0 24 24"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "g", Attrs: map[string]string{"stroke-linejoin": "round", "stroke-linecap": "round", "stroke-width": "4", "fill": "none", "stroke": "currentColor"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "path", Attrs: map[string]string{"d": "M20 6 9 17l-5-5"}},
}},
}},
lowhtml.ElementNode{Tag: "svg", Attrs: map[string]string{"aria-label": "disabled", "xmlns": "http://www.w3.org/2000/svg", "viewBox": "0 0 24 24", "fill": "none", "stroke": "currentColor", "stroke-width": "4", "stroke-linecap": "round", "stroke-linejoin": "round"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "path", Attrs: map[string]string{"d": "M18 6 6 18"}},
lowhtml.ElementNode{Tag: "path", Attrs: map[string]string{"d": "m6 6 12 12"}},
}},
}}
}
func Join(children ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "join"}, Children: children}
}
func Mask(shapeClass string, child shared.Node) shared.Node {
className := "mask " + shapeClass
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": className}, Children: []shared.Node{child}}
}
func Carousel(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "carousel w-full"}, Children: items}
}
func CarouselItem(id string, child shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"id": id, "class": "carousel-item w-full"}, Children: []shared.Node{child}}
}
func ChatBubble(content shared.Node, end bool) shared.Node {
position := "chat-start"
if end {
position = "chat-end"
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "chat " + position}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "chat-bubble"}, Children: []shared.Node{content}},
}}
}
func Countdown(value int) shared.Node {
valueText := strconv.Itoa(value)
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": "countdown font-mono text-2xl", "aria-live": "polite", "aria-label": valueText}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"style": "--value:" + valueText + ";"}, Text: valueText},
}}
}
func Status(colorClass string) shared.Node {
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": "status " + colorClass}}
}
func Dock(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "dock"}, Children: items}
}
func Fieldset(legend string, fields ...shared.Node) shared.Node {
children := []shared.Node{lowhtml.ElementNode{Tag: "legend", Attrs: map[string]string{"class": "fieldset-legend"}, Text: legend}}
children = append(children, fields...)
return lowhtml.ElementNode{Tag: "fieldset", Attrs: map[string]string{"class": "fieldset"}, Children: children}
}
func Label(text string) shared.Node {
return lowhtml.ElementNode{Tag: "label", Attrs: map[string]string{"class": "label"}, Text: text}
}
func Validator(message string) shared.Node {
return lowhtml.ElementNode{Tag: "p", Attrs: map[string]string{"class": "validator-hint"}, Text: message}
}
func BrowserMockup(content shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-browser"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-browser-toolbar"}, Children: []shared.Node{lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "input"}, Text: "https://example.com"}}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "grid place-content-center h-80"}, Children: []shared.Node{content}},
}}
}
func PhoneMockup(content shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-phone"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-phone-camera"}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-phone-display"}, Children: []shared.Node{content}},
}}
}
func CodeMockup(lines ...string) shared.Node {
children := make([]shared.Node, 0, len(lines))
for _, line := range lines {
children = append(children, lowhtml.ElementNode{Tag: "pre", Attrs: map[string]string{"data-prefix": "$"}, Children: []shared.Node{lowhtml.ElementNode{Tag: "code", Text: line}}})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "mockup-code"}, Children: children}
}
func Calendar(content shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "card bg-base-100 border border-base-300"}, Children: []shared.Node{content}}
}
func Filter(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "filter"}, Children: items}
}
func Diff(before, after shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "figure", Attrs: map[string]string{"class": "diff aspect-16/9"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "diff-item-1"}, Children: []shared.Node{before}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "diff-item-2"}, Children: []shared.Node{after}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "diff-resizer"}},
}}
}
func listItemChildren(itemClass string, items []shared.Node) []shared.Node {
children := make([]shared.Node, 0, len(items))
for _, item := range items {
if n, ok := item.(lowhtml.ElementNode); ok && n.Tag == "li" {
if itemClass != "" {
n.Attrs = cloneAttrs(n.Attrs)
n.Attrs["class"] = appendClass(n.Attrs["class"], itemClass)
}
children = append(children, n)
continue
}
attrs := map[string]string{}
if itemClass != "" {
attrs["class"] = itemClass
}
children = append(children, lowhtml.ElementNode{Tag: "li", Attrs: attrs, Children: []shared.Node{item}})
}
return children
}
func List(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": "list bg-base-100 rounded-box shadow-md"}, Children: listItemChildren("list-row", items)}
}
func Table(headers []string, rows ...[]shared.Node) shared.Node {
headersNode := make([]shared.Node, 0, len(headers))
for _, h := range headers {
headersNode = append(headersNode, lowhtml.ElementNode{Tag: "th", Text: h})
}
tbodyRows := make([]shared.Node, 0, len(rows))
for _, row := range rows {
cells := make([]shared.Node, 0, len(row))
for _, cell := range row {
cells = append(cells, lowhtml.ElementNode{Tag: "td", Children: []shared.Node{cell}})
}
tbodyRows = append(tbodyRows, lowhtml.ElementNode{Tag: "tr", Children: cells})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "overflow-x-auto"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "table", Attrs: map[string]string{"class": "table"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "thead", Children: []shared.Node{lowhtml.ElementNode{Tag: "tr", Children: headersNode}}},
lowhtml.ElementNode{Tag: "tbody", Children: tbodyRows},
}},
}}
}
func TextRotate(words []string, animationClass string) shared.Node {
className := strings.TrimSpace("text-rotate " + animationClass)
items := make([]shared.Node, 0, len(words))
for _, w := range words {
items = append(items, lowhtml.ElementNode{Tag: "span", Text: w})
}
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": className}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "span", Children: items},
}}
}
func Hover3DCard(content shared.Node) shared.Node {
children := make([]shared.Node, 0, 9)
children = append(children, content)
for i := 0; i < 8; i++ {
children = append(children, lowhtml.ElementNode{Tag: "div"})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "hover-3d"}, Children: children}
}
func HoverGallery(items ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "figure", Attrs: map[string]string{"class": "hover-gallery"}, Children: items}
}
func Accordion(title string, content shared.Node, open bool) shared.Node {
inputAttrs := map[string]string{"type": "radio", "name": "accordion"}
if open {
inputAttrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse collapse-arrow bg-base-100 border border-base-300"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: inputAttrs},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse-title font-semibold"}, Text: title},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "collapse-content text-sm"}, Children: []shared.Node{content}},
}}
}
func FAB(icon shared.Node, label string) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "fab"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "button", Attrs: map[string]string{"class": "btn btn-lg btn-circle btn-primary", "aria-label": label}, Children: []shared.Node{icon}},
}}
}
func SpeedDial(trigger shared.Node, items ...shared.Node) shared.Node {
children := make([]shared.Node, 0, len(items)+1)
children = append(children, lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"tabindex": "0", "role": "button", "class": "btn btn-lg btn-circle btn-primary"}, Children: []shared.Node{trigger}})
children = append(children, items...)
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "fab"}, Children: children}
}
func Swap(onNode, offNode shared.Node, active bool) shared.Node {
attrs := map[string]string{"class": "swap"}
if active {
attrs["class"] = "swap swap-active"
}
return lowhtml.ElementNode{Tag: "label", Attrs: attrs, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"type": "checkbox"}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "swap-on"}, Children: []shared.Node{onNode}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "swap-off"}, Children: []shared.Node{offNode}},
}}
}
func ThemeController(options ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Children: options}
}
// ThemeControllerOption renders a daisyUI theme-controller radio input.
// See: https://daisyui.com/components/theme-controller/
func ThemeControllerOption(theme string, checked bool, className string) shared.Node {
attrs := map[string]string{
"type": "radio",
"name": "theme-buttons",
"class": strings.TrimSpace("theme-controller " + className),
"value": theme,
"aria-label": theme,
}
if checked {
attrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "input", Attrs: attrs}
}
func DockItem(child shared.Node, active bool) shared.Node {
attrs := map[string]string{}
if active {
attrs["class"] = "dock-active"
}
return lowhtml.ElementNode{Tag: "button", Attrs: attrs, Children: []shared.Node{child}}
}
func FilterItem(label string, active bool) shared.Node {
attrs := map[string]string{"class": "btn btn-sm", "type": "radio", "name": "filter", "aria-label": label}
if active {
attrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "input", Attrs: attrs}
}
func CalendarGrid(days ...shared.Node) shared.Node {
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "grid grid-cols-7 gap-1"}, Children: days}
}
func ButtonWithVariants(label string, variant string, size string, style string, props shared.ComponentProps) shared.Node {
classes := []string{}
if variant != "" {
classes = append(classes, "btn-"+variant)
}
if size != "" {
classes = append(classes, "btn-"+size)
}
if style != "" {
classes = append(classes, "btn-"+style)
}
props.Class = strings.TrimSpace(strings.Join(append(classes, props.Class), " "))
return Button(label, props)
}
func InputWithVariants(name, value, color, size, style string, props shared.ComponentProps) shared.Node {
classes := []string{}
if color != "" {
classes = append(classes, "input-"+color)
}
if sizeClass := daisySizeClass("input", size); sizeClass != "" {
classes = append(classes, sizeClass)
}
if style != "" && style != "bordered" {
classes = append(classes, "input-"+style)
}
props.Class = strings.TrimSpace(strings.Join(append(classes, props.Class), " "))
return Input(name, value, props)
}
func SelectWithVariants(name string, options []shared.SelectOption, color, size, style string, props shared.ComponentProps) shared.Node {
classes := []string{}
if color != "" {
classes = append(classes, "select-"+color)
}
if sizeClass := daisySizeClass("select", size); sizeClass != "" {
classes = append(classes, sizeClass)
}
if style != "" && style != "bordered" {
classes = append(classes, "select-"+style)
}
props.Class = strings.TrimSpace(strings.Join(append(classes, props.Class), " "))
return Select(name, options, props)
}
func TextareaWithVariants(name, value, color, size, style string, options shared.TextareaOptions) shared.Node {
classes := []string{}
if color != "" {
classes = append(classes, "textarea-"+color)
}
if sizeClass := daisySizeClass("textarea", size); sizeClass != "" {
classes = append(classes, sizeClass)
}
if style != "" && style != "bordered" {
classes = append(classes, "textarea-"+style)
}
options.Props.Class = strings.TrimSpace(strings.Join(append(classes, options.Props.Class), " "))
return Textarea(name, value, options)
}
func ProgressWithVariant(value, max float64, label, color string, props shared.ComponentProps) shared.Node {
if color != "" {
props.Class = strings.TrimSpace("progress-" + color + " " + props.Class)
}
return Progress(shared.ProgressProps{Value: value, Max: max, Label: label, Props: props})
}
func BadgeWithVariant(label, color, size, style string, props shared.ComponentProps) shared.Node {
classes := []string{"badge"}
if color != "" {
classes = append(classes, "badge-"+color)
}
if size != "" {
classes = append(classes, "badge-"+size)
}
if style != "" {
classes = append(classes, "badge-"+style)
}
props.Class = strings.TrimSpace(strings.Join(append(classes, props.Class), " "))
return Badge(shared.BadgeProps{Label: label, Props: props})
}
func CheckboxWithVariants(name, value, label, color, size string, checked bool, props shared.ComponentProps) shared.Node {
classes := []string{}
if color != "" {
classes = append(classes, "checkbox-"+color)
}
if sizeClass := daisySizeClass("checkbox", size); sizeClass != "" {
classes = append(classes, sizeClass)
}
props.Class = strings.TrimSpace(strings.Join(append(classes, props.Class), " "))
return Checkbox(shared.CheckboxComponentProps{Name: name, Value: value, Label: label, Checked: checked, Props: props})
}
func RadioGroupWithVariants(name, color, size string, items []shared.RadioItem, props shared.ComponentProps) shared.Node {
inputClass := "radio"
if color != "" {
inputClass += " radio-" + color
}
if sizeClass := daisySizeClass("radio", size); sizeClass != "" {
inputClass += " " + sizeClass
}
children := make([]shared.Node, 0, len(items))
for _, item := range items {
attrs := map[string]string{"type": "radio", "name": name, "value": item.Value, "class": inputClass}
if item.Checked {
attrs["checked"] = "checked"
}
if props.Disabled || item.Disabled {
attrs["disabled"] = "disabled"
}
children = append(children,
lowhtml.ElementNode{Tag: "label", Attrs: map[string]string{"class": "label cursor-pointer gap-2"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: attrs},
textNode("span", map[string]string{"class": "label"}, item.Label),
}},
)
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": strings.TrimSpace("space-y-2 " + props.Class)}, Children: children}
}
func RangeWithVariants(name string, value int, min int, max int, color string, size string) shared.Node {
classes := []string{"range"}
if color != "" {
classes = append(classes, "range-"+color)
}
if size != "" {
classes = append(classes, "range-"+size)
}
return lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"type": "range", "name": name, "value": strconv.Itoa(value), "min": strconv.Itoa(min), "max": strconv.Itoa(max), "class": strings.Join(classes, " ")}}
}
func RatingWithVariants(name string, max int, checked int, size string, half bool, allowClear bool) shared.Node {
classes := []string{"rating"}
if size != "" {
classes = append(classes, "rating-"+size)
}
if half {
classes = append(classes, "rating-half")
}
stars := make([]shared.Node, 0, max+1)
if allowClear {
stars = append(stars, lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"type": "radio", "name": name, "class": "rating-hidden", "value": "0"}})
}
for i := 1; i <= max; i++ {
attrs := map[string]string{"type": "radio", "name": name, "class": "mask mask-star-2 bg-orange-400", "value": strconv.Itoa(i)}
if i == checked {
attrs["checked"] = "checked"
}
stars = append(stars, lowhtml.ElementNode{Tag: "input", Attrs: attrs})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": strings.Join(classes, " ")}, Children: stars}
}
func ToastWithPlacement(children []shared.Node, horizontal, vertical string, className string) shared.Node {
classes := []string{}
if horizontal != "" {
classes = append(classes, "toast-"+horizontal)
}
if vertical != "" {
classes = append(classes, "toast-"+vertical)
}
if className != "" {
classes = append(classes, className)
}
return ToastWithContent(shared.ComponentProps{Class: strings.Join(classes, " ")}, children...)
}
func TooltipWithVariants(text string, child shared.Node, placement string, color string, open bool) shared.Node {
classes := []string{"tooltip"}
if placement != "" {
classes = append(classes, "tooltip-"+placement)
}
if color != "" {
classes = append(classes, "tooltip-"+color)
}
if open {
classes = append(classes, "tooltip-open")
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": strings.Join(classes, " "), "data-tip": text}, Children: []shared.Node{child}}
}
func TableWithVariants(headers []string, rows [][]shared.Node, zebra bool, pinRows bool, pinCols bool, size string) shared.Node {
classes := []string{"table"}
if zebra {
classes = append(classes, "table-zebra")
}
if pinRows {
classes = append(classes, "table-pin-rows")
}
if pinCols {
classes = append(classes, "table-pin-cols")
}
if size != "" {
classes = append(classes, "table-"+size)
}
headersNode := make([]shared.Node, 0, len(headers))
for _, h := range headers {
headersNode = append(headersNode, lowhtml.ElementNode{Tag: "th", Text: h})
}
tbodyRows := make([]shared.Node, 0, len(rows))
for _, row := range rows {
cells := make([]shared.Node, 0, len(row))
for _, cell := range row {
cells = append(cells, lowhtml.ElementNode{Tag: "td", Children: []shared.Node{cell}})
}
tbodyRows = append(tbodyRows, lowhtml.ElementNode{Tag: "tr", Children: cells})
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "overflow-x-auto"}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "table", Attrs: map[string]string{"class": strings.Join(classes, " ")}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "thead", Children: []shared.Node{lowhtml.ElementNode{Tag: "tr", Children: headersNode}}},
lowhtml.ElementNode{Tag: "tbody", Children: tbodyRows},
}},
}}
}
func ModalWithPlacement(props shared.ModalProps, placement string) shared.Node {
attrs := map[string]string{"class": "modal"}
if props.Open {
attrs["open"] = "open"
}
if placement != "" {
attrs["class"] += " modal-" + placement
}
return node("dialog", attrs,
node("div", map[string]string{"class": "modal-box"},
textNode("h3", map[string]string{"class": "font-bold text-lg"}, props.Title),
props.Body,
node("div", map[string]string{"class": "modal-action"}, props.Actions),
),
node("form", map[string]string{"method": "dialog", "class": "modal-backdrop"}, textNode("button", nil, "close")),
)
}
func TabsWithVariants(items []shared.TabsItem, style string, placement string, size string, className string) shared.Node {
classes := []string{"tabs"}
if style != "" {
classes = append(classes, "tabs-"+style)
}
if placement != "" {
classes = append(classes, "tabs-"+placement)
}
if size != "" {
classes = append(classes, "tabs-"+size)
}
if className != "" {
classes = append(classes, className)
}
tabNodes := make([]shared.Node, 0, len(items))
for _, item := range items {
attrs := map[string]string{"class": "tab", "role": "tab", "type": "button", "aria-selected": "false"}
if item.Active {
attrs["class"] += " tab-active"
attrs["aria-selected"] = "true"
}
if item.Disabled {
attrs["class"] += " tab-disabled"
attrs["disabled"] = "disabled"
}
tabNodes = append(tabNodes, textNode("button", attrs, item.Label))
}
return node("div", map[string]string{"class": strings.Join(classes, " "), "role": "tablist"}, tabNodes...)
}
func StepsWithVariants(items []shared.Node, direction string, color string, className string) shared.Node {
classes := []string{"steps"}
if direction != "" {
classes = append(classes, "steps-"+direction)
}
if className != "" {
classes = append(classes, className)
}
if color != "" {
items = withStepColor(items, "step-"+color)
}
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": strings.Join(classes, " ")}, Children: items}
}
func withStepColor(items []shared.Node, colorClass string) []shared.Node {
colored := make([]shared.Node, 0, len(items))
for _, item := range items {
switch n := item.(type) {
case lowhtml.ElementNode:
if n.Tag == "li" && hasClass(n.Attrs["class"], "step") {
n.Attrs = cloneAttrs(n.Attrs)
n.Attrs["class"] = appendClass(n.Attrs["class"], colorClass)
}
colored = append(colored, n)
default:
colored = append(colored, item)
}
}
return colored
}
func hasClass(className, target string) bool {
for _, class := range strings.Fields(className) {
if class == target {
return true
}
}
return false
}
func cloneAttrs(attrs map[string]string) map[string]string {
cloned := make(map[string]string, len(attrs)+1)
for key, value := range attrs {
cloned[key] = value
}
return cloned
}
func appendClass(className string, parts ...string) string {
classes := strings.Fields(className)
seen := make(map[string]bool, len(classes)+len(parts))
for _, class := range classes {
seen[class] = true
}
for _, part := range parts {
if part != "" && !seen[part] {
classes = append(classes, part)
seen[part] = true
}
}
return strings.Join(classes, " ")
}
func TimelineWithDirection(items []shared.Node, direction string, compact bool, snapIcon bool, className string) shared.Node {
classes := []string{"timeline"}
if direction != "" {
classes = append(classes, "timeline-"+direction)
}
if compact {
classes = append(classes, "timeline-compact")
}
if snapIcon {
classes = append(classes, "timeline-snap-icon")
}
if className != "" {
classes = append(classes, className)
}
return lowhtml.ElementNode{Tag: "ul", Attrs: map[string]string{"class": strings.Join(classes, " ")}, Children: items}
}
func LoadingWithVariants(kind string, size string) shared.Node {
classes := []string{"loading"}
if kind == "" {
kind = "spinner"
}
classes = append(classes, "loading-"+kind)
if size != "" {
classes = append(classes, "loading-"+size)
}
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": strings.Join(classes, " ")}}
}
func StatusWithVariants(color string, size string) shared.Node {
classes := []string{"status"}
if color != "" {
classes = append(classes, "status-"+color)
}
if size != "" {
classes = append(classes, "status-"+size)
}
return lowhtml.ElementNode{Tag: "span", Attrs: map[string]string{"class": strings.Join(classes, " ")}}
}
func ToggleWithVariants(name string, checked bool, color string, size string) shared.Node {
className := "toggle"
if color != "" {
className += " toggle-" + color
}
if size != "" {
className += " toggle-" + size
}
attrs := map[string]string{"type": "checkbox", "name": name, "class": className}
if checked {
attrs["checked"] = "checked"
}
return lowhtml.ElementNode{Tag: "input", Attrs: attrs}
}
func SwapWithVariants(onNode, offNode shared.Node, active bool, rotate bool, flip bool) shared.Node {
className := "swap"
if active {
className += " swap-active"
}
if rotate {
className += " swap-rotate"
}
if flip {
className += " swap-flip"
}
return lowhtml.ElementNode{Tag: "label", Attrs: map[string]string{"class": className}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "input", Attrs: map[string]string{"type": "checkbox"}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "swap-on"}, Children: []shared.Node{onNode}},
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": "swap-off"}, Children: []shared.Node{offNode}},
}}
}
func JoinWithDirection(direction string, children ...shared.Node) shared.Node {
className := "join"
if direction != "" {
className += " join-" + direction
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": className}, Children: children}
}
func DropdownWithPlacement(trigger, menu shared.Node, placement string) shared.Node {
className := "dropdown"
if placement != "" {
className += " dropdown-" + placement
}
return lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"class": className}, Children: []shared.Node{
lowhtml.ElementNode{Tag: "div", Attrs: map[string]string{"tabindex": "0", "role": "button"}, Children: []shared.Node{trigger}},
dropdownContent(menu),
}}
}
package presets
import (
"github.com/YoshihideShirai/marionette/frontend/assets"
"github.com/YoshihideShirai/marionette/frontend/twailwindcss"
)
func FrameworkStylesheetAssets() []assets.AssetName {
return []assets.AssetName{assets.DaisyUI}
}
func FrameworkScriptAssets() []assets.AssetName {
return twailwindcss.FrameworkScriptAssets()
}
func FrameworkStylesheets() []string {
return resolveStylesheets(assets.DefaultProvider, FrameworkStylesheetAssets())
}
func FrameworkScripts() []string {
return resolveScripts(assets.DefaultProvider, FrameworkScriptAssets())
}
func resolveStylesheets(provider assets.AssetProvider, names []assets.AssetName) []string {
out := make([]string, 0, len(names))
for _, name := range names {
if url, ok := provider.StylesheetURL(name); ok && url != "" {
out = append(out, url)
}
}
return out
}
func resolveScripts(provider assets.AssetProvider, names []assets.AssetName) []string {
out := make([]string, 0, len(names))
for _, name := range names {
if url, ok := provider.ScriptURL(name); ok && url != "" {
out = append(out, url)
}
}
return out
}
package frontend
import daisy "github.com/YoshihideShirai/marionette/frontend/daisyui"
func TextNode(text string) Node { return daisy.TextNode(text) }
func PrimaryButton(label string, props ComponentProps) Node {
return daisy.PrimaryButton(label, props)
}
func SecondaryButton(label string, props ComponentProps) Node {
return daisy.SecondaryButton(label, props)
}
func GhostButton(label string, props ComponentProps) Node { return daisy.GhostButton(label, props) }
func Avatar(src, alt, class string) Node { return daisy.Avatar(src, alt, class) }
func Navbar(start, center, end Node) Node { return daisy.Navbar(start, center, end) }
func Hero(title, description string, actions ...Node) Node {
return daisy.Hero(title, description, actions...)
}
func Menu(items ...Node) Node { return daisy.Menu(items...) }
func Footer(children ...Node) Node { return daisy.Footer(children...) }
func Drawer(id string, side, content Node) Node { return daisy.Drawer(id, side, content) }
func Stat(title, value, desc string) Node { return daisy.Stat(title, value, desc) }
func Steps(items ...Node) Node { return daisy.Steps(items...) }
func Step(label string, active bool) Node { return daisy.Step(label, active) }
func Timeline(items ...Node) Node { return daisy.Timeline(items...) }
func TimelineItem(startLabel, endLabel string, content Node) Node {
return daisy.TimelineItem(startLabel, endLabel, content)
}
func Collapse(title string, content Node, open bool) Node {
return daisy.Collapse(title, content, open)
}
func MockupWindow(title string, content Node) Node { return daisy.MockupWindow(title, content) }
func Kbd(text string) Node { return daisy.Kbd(text) }
func Code(text string) Node { return daisy.Code(text) }
func Indicator(item, target Node) Node { return daisy.Indicator(item, target) }
func Dropdown(trigger, menu Node) Node { return daisy.Dropdown(trigger, menu) }
func Tooltip(text string, child Node) Node { return daisy.Tooltip(text, child) }
func Loading(sizeClass string) Node { return daisy.Loading(sizeClass) }
func RadialProgress(value int, sizeClass string) Node { return daisy.RadialProgress(value, sizeClass) }
func Rating(name string, max int, checked int) Node { return daisy.Rating(name, max, checked) }
func Range(name string, value int, min int, max int) Node { return daisy.Range(name, value, min, max) }
func Toggle(name string, checked bool) Node { return daisy.Toggle(name, checked) }
func ToggleVariant(name string, checked bool, variant string) Node {
return daisy.ToggleVariant(name, checked, variant)
}
func ToggleWithIcons(name string, checked bool, className string) Node {
return daisy.ToggleWithIcons(name, checked, className)
}
func Join(children ...Node) Node { return daisy.Join(children...) }
func Mask(shapeClass string, child Node) Node { return daisy.Mask(shapeClass, child) }
func Carousel(items ...Node) Node { return daisy.Carousel(items...) }
func CarouselItem(id string, child Node) Node { return daisy.CarouselItem(id, child) }
func ChatBubble(content Node, end bool) Node { return daisy.ChatBubble(content, end) }
func ThemeController(options ...Node) Node { return daisy.ThemeController(options...) }
func ThemeControllerOption(theme string, checked bool, className string) Node {
return daisy.ThemeControllerOption(theme, checked, className)
}
func Countdown(value int) Node { return daisy.Countdown(value) }
func Status(colorClass string) Node { return daisy.Status(colorClass) }
func Dock(items ...Node) Node { return daisy.Dock(items...) }
func Fieldset(legend string, fields ...Node) Node { return daisy.Fieldset(legend, fields...) }
func Label(text string) Node { return daisy.Label(text) }
func Validator(message string) Node { return daisy.Validator(message) }
func BrowserMockup(content Node) Node { return daisy.BrowserMockup(content) }
func PhoneMockup(content Node) Node { return daisy.PhoneMockup(content) }
func CodeMockup(lines ...string) Node { return daisy.CodeMockup(lines...) }
func Calendar(content Node) Node { return daisy.Calendar(content) }
func Swap(onNode, offNode Node, active bool) Node { return daisy.Swap(onNode, offNode, active) }
func Filter(items ...Node) Node { return daisy.Filter(items...) }
func Diff(before, after Node) Node { return daisy.Diff(before, after) }
func List(items ...Node) Node { return daisy.List(items...) }
func Accordion(title string, content Node, open bool) Node {
return daisy.Accordion(title, content, open)
}
func FAB(icon Node, label string) Node { return daisy.FAB(icon, label) }
func SpeedDial(trigger Node, items ...Node) Node { return daisy.SpeedDial(trigger, items...) }
func DockItem(child Node, active bool) Node { return daisy.DockItem(child, active) }
func FilterItem(label string, active bool) Node { return daisy.FilterItem(label, active) }
func CalendarGrid(days ...Node) Node { return daisy.CalendarGrid(days...) }
func TextRotate(words []string, animationClass string) Node {
return daisy.TextRotate(words, animationClass)
}
func Hover3DCard(content Node) Node { return daisy.Hover3DCard(content) }
func HoverGallery(items ...Node) Node { return daisy.HoverGallery(items...) }
func SelectWithVariants(name string, options []SelectOption, color, size, style string, props ComponentProps) Node {
return daisy.SelectWithVariants(name, options, color, size, style, props)
}
func ProgressWithVariant(value, max float64, label, color string, props ComponentProps) Node {
return daisy.ProgressWithVariant(value, max, label, color, props)
}
func BadgeWithVariant(label, color, size, style string, props ComponentProps) Node {
return daisy.BadgeWithVariant(label, color, size, style, props)
}
func RangeWithVariants(name string, value int, min int, max int, color string, size string) Node {
return daisy.RangeWithVariants(name, value, min, max, color, size)
}
func RatingWithVariants(name string, max int, checked int, size string, half bool, allowClear bool) Node {
return daisy.RatingWithVariants(name, max, checked, size, half, allowClear)
}
func ToastWithPlacement(children []Node, horizontal, vertical, className string) Node {
return daisy.ToastWithPlacement(children, horizontal, vertical, className)
}
func TooltipWithVariants(text string, child Node, placement string, color string, open bool) Node {
return daisy.TooltipWithVariants(text, child, placement, color, open)
}
func TableWithVariants(headers []string, rows [][]Node, zebra bool, pinRows bool, pinCols bool, size string) Node {
return daisy.TableWithVariants(headers, rows, zebra, pinRows, pinCols, size)
}
func ModalWithPlacement(props ModalProps, placement string) Node {
return daisy.ModalWithPlacement(props, placement)
}
func StepsWithVariants(items []Node, direction string, color string, className string) Node {
return daisy.StepsWithVariants(items, direction, color, className)
}
func TimelineWithDirection(items []Node, direction string, compact bool, snapIcon bool, className string) Node {
return daisy.TimelineWithDirection(items, direction, compact, snapIcon, className)
}
func LoadingWithVariants(kind string, size string) Node { return daisy.LoadingWithVariants(kind, size) }
func StatusWithVariants(color string, size string) Node { return daisy.StatusWithVariants(color, size) }
func ToggleWithVariants(name string, checked bool, color string, size string) Node {
return daisy.ToggleWithVariants(name, checked, color, size)
}
func SwapWithVariants(onNode, offNode Node, active bool, rotate bool, flip bool) Node {
return daisy.SwapWithVariants(onNode, offNode, active, rotate, flip)
}
func JoinWithDirection(direction string, children ...Node) Node {
return daisy.JoinWithDirection(direction, children...)
}
func DropdownWithPlacement(trigger, menu Node, placement string) Node {
return daisy.DropdownWithPlacement(trigger, menu, placement)
}
// Package dashwind provides reusable DashWind-inspired admin shell components.
package dashwind
import (
"sort"
"strings"
mf "github.com/YoshihideShirai/marionette/frontend"
daisy "github.com/YoshihideShirai/marionette/frontend/daisyui"
)
// DefaultMainTargetID is the default region id used by ShellContent for htmx fragment swaps.
const DefaultMainTargetID = "dashwind-main"
// DefaultDrawerID is the safe default drawer toggle id used by Shell.
const DefaultDrawerID = "dashwind-drawer"
// DefaultCSS contains small layout tweaks used by the DashWind shell.
const DefaultCSS = `
#marionette-root { width: 100%; max-width: none; padding: 0; }
#marionette-root > * { animation: none; }
.dashwind-shell .drawer-side .menu a.active { background: var(--color-primary); color: var(--color-primary-content); font-weight: 700; }
.dashwind-shell .card, .dashwind-shell .stats { border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent); }
.dashwind-shell .stat-value { font-size: clamp(1.75rem, 2vw, 2.25rem); }
`
// Brand describes the shell brand block rendered at the top of the sidebar.
type Brand struct {
Title string
Subtitle string
Mark string
Href string
Class string
}
// Navigation describes a set of sidebar menu groups.
type Navigation []NavGroup
// NavItem describes a single sidebar link.
type NavItem struct {
Path string
Label string
Icon string
IconNode mf.Node
Badge string
Disabled bool
External bool
Children []NavItem
// Deprecated: use Path.
Href string
// Deprecated: active state is normally derived by RenderNavigation from the current path.
Active bool
// Class appends classes to the rendered anchor.
Class string
}
// NavGroup describes a labeled sidebar menu section.
type NavGroup struct {
Label string
Items []NavItem
Class string
}
// UserMenu configures the user affordance rendered in the topbar action area.
type UserMenu struct {
Name string
Email string
Initials string
AvatarURL string
Items []NavItem
Actions []mf.Node
Class string
AvatarClass string
MenuClass string
}
// ShellProps configures the DashWind application shell.
type ShellProps struct {
ID string
DrawerID string
MainTargetID string
Brand Brand
CurrentPath string
Navigation Navigation
User UserMenu
Actions []mf.Node
SearchPlaceholder string
Class string
// Optional class overrides. Defaults match the DashWind demo shell and are safe for responsive layouts.
DrawerClass string
ContentClass string
SideClass string
OverlayClass string
NavbarClass string
MainClass string
SidebarClass string
// Deprecated: use Brand.Title, Brand.Subtitle, Brand.Mark, Navigation, User.Initials, Actions, and ShellContent/body instead.
CurrentTitle string
BrandTitle string
BrandSubtitle string
BrandMark string
NavGroups Navigation
Content mf.Node
UserInitials string
SidebarFooter mf.Node
TopbarActions []mf.Node
}
// Shell renders a responsive dashboard shell with a drawer sidebar and sticky topbar.
func Shell(props ShellProps, body mf.Node) mf.Node {
props = normalizeShellProps(props)
if body == nil {
body = props.Content
}
id := defaultString(props.ID, "dashwind-app")
drawerID := defaultString(props.DrawerID, DefaultDrawerID)
content := mf.DivProps(mf.ElementProps{Class: shellContentClass(props)}, topbar(props, drawerID), ShellContentWithClass(props.MainTargetID, props.MainClass, body))
return mf.Region(mf.RegionProps{ID: id}, daisy.DrawerWithProps(daisy.DrawerProps{
ID: drawerID,
Class: strings.TrimSpace("lg:drawer-open dashwind-shell " + props.DrawerClass + " " + props.Class),
ContentClass: defaultString(props.ContentClass, "flex min-h-screen flex-col bg-base-200"),
SideClass: defaultString(props.SideClass, "z-40"),
OverlayClass: props.OverlayClass,
Content: content,
Side: sidebar(props),
}))
}
// ShellContent wraps page content in the htmx-friendly main region used by Shell.
func ShellContent(id string, body mf.Node) mf.Node {
return ShellContentWithClass(id, "", body)
}
// ShellContentWithClass wraps page content with an optional class override for Shell's main region.
func ShellContentWithClass(id, className string, body mf.Node) mf.Node {
return mf.Region(mf.RegionProps{ID: defaultString(id, DefaultMainTargetID), Props: mf.ComponentProps{Class: defaultString(className, "flex-1 p-4 md:p-6 lg:p-8 space-y-6")}}, body)
}
// PageHeaderProps configures a page title, description, and optional action node.
type PageHeaderProps struct {
Title string
Description string
Actions mf.Node
Class string
}
// PageHeader renders a responsive title row for dashboard pages.
func PageHeader(props PageHeaderProps) mf.Node {
children := []mf.Node{div("", mf.H1Props(mf.ElementProps{Class: "text-3xl font-bold"}, mf.Text(props.Title)), paragraph("mt-1 text-base-content/60", props.Description))}
if props.Actions != nil {
children = append(children, props.Actions)
}
return div(strings.TrimSpace("flex flex-col gap-4 md:flex-row md:items-center md:justify-between "+props.Class), children...)
}
// CardPanelProps configures a DashWind card surface.
type CardPanelProps struct {
Title string
Description string
Class string
BodyClass string
Actions mf.Node
}
// CardPanel renders a shadowed DaisyUI card panel.
func CardPanel(props CardPanelProps, children ...mf.Node) mf.Node {
className := strings.TrimSpace("bg-base-100 shadow " + props.Class)
return daisy.CardPanel(daisy.CardPanelProps{Title: props.Title, Description: props.Description, Class: className, BodyClass: props.BodyClass, Actions: props.Actions}, children...)
}
// Field describes a single input row inside DashWind form presets such as AuthCard and SettingsSection.
type Field struct {
Name string
Label string
Type string
Value string
Required bool
Help string
Error string
}
// AuthCardProps configures a DashWind authentication card preset.
type AuthCardProps struct {
Title string
Description string
Action string
Fields []Field
SubmitLabel string
Footer mf.Node
Class string
FormClass string
}
// AuthCard renders a ready-made two-column authentication surface with labeled fields and a submit button.
func AuthCard(props AuthCardProps) mf.Node {
fieldNodes := make([]mf.Node, 0, len(props.Fields)+2)
for _, field := range props.Fields {
fieldNodes = append(fieldNodes, renderPresetField(field))
}
fieldNodes = append(fieldNodes, daisy.ButtonWithAttrs(defaultString(props.SubmitLabel, props.Title), mf.ComponentProps{Class: "mt-2 w-full btn-primary"}, map[string]string{"type": "submit"}))
if props.Footer != nil {
fieldNodes = append(fieldNodes, div("text-center mt-4 text-sm", props.Footer))
}
formAttrs := mf.Attrs{"class": strings.TrimSpace("space-y-4 " + props.FormClass)}
if strings.TrimSpace(props.Action) != "" {
formAttrs["action"] = props.Action
formAttrs["method"] = "post"
}
return div(strings.TrimSpace("card mx-auto w-full max-w-5xl shadow-xl bg-base-100 "+props.Class),
div("grid grid-cols-1 rounded-xl md:grid-cols-2",
div("rounded-l-xl bg-primary p-10 text-primary-content", mf.H2Props(mf.ElementProps{Class: "text-3xl font-bold"}, mf.Text("DashWind")), paragraph("mt-4 opacity-80", "Build admin dashboards with DaisyUI, htmx fragments, and Go state.")),
div("py-16 px-10", mf.H2Props(mf.ElementProps{Class: "mb-2 text-center text-2xl font-semibold"}, mf.Text(props.Title)), paragraph("mb-6 text-center text-sm text-base-content/60", props.Description), mf.Element("form", mf.ElementProps{Attrs: formAttrs}, fieldNodes...)),
),
)
}
// SettingsSectionProps configures a DashWind settings form section preset.
type SettingsSectionProps struct {
Title string
Description string
Action string
Fields []Field
SubmitLabel string
Footer mf.Node
Actions mf.Node
Class string
BodyClass string
FormClass string
}
// SettingsSection renders a card-style settings form with a header, fields, and optional submit/footer content.
func SettingsSection(props SettingsSectionProps) mf.Node {
fieldNodes := make([]mf.Node, 0, len(props.Fields)+2)
for _, field := range props.Fields {
fieldNodes = append(fieldNodes, renderPresetField(field))
}
if strings.TrimSpace(props.SubmitLabel) != "" {
fieldNodes = append(fieldNodes, div("pt-2 text-right", daisy.ButtonWithAttrs(props.SubmitLabel, mf.ComponentProps{Class: "btn-primary btn-sm"}, map[string]string{"type": "submit"})))
}
if props.Footer != nil {
fieldNodes = append(fieldNodes, div("pt-2 text-sm text-base-content/60", props.Footer))
}
formAttrs := mf.Attrs{"class": strings.TrimSpace("space-y-4 " + props.FormClass)}
if strings.TrimSpace(props.Action) != "" {
formAttrs["action"] = props.Action
formAttrs["method"] = "post"
}
return CardPanel(CardPanelProps{Title: props.Title, Description: props.Description, Class: props.Class, BodyClass: props.BodyClass, Actions: props.Actions}, mf.Element("form", mf.ElementProps{Attrs: formAttrs}, fieldNodes...))
}
func renderPresetField(field Field) mf.Node {
name := strings.TrimSpace(field.Name)
if name == "" {
name = slugifyFieldName(field.Label)
}
id := "dashwind-field-" + name
inputType := defaultString(field.Type, "text")
inputClass := "input w-full"
if strings.TrimSpace(field.Error) != "" {
inputClass += " input-error"
}
attrs := mf.Attrs{"id": id, "name": name, "type": inputType, "value": field.Value, "class": inputClass}
if field.Required {
attrs["required"] = "required"
attrs["aria-required"] = "true"
}
children := []mf.Node{mf.LabelElementProps(mf.ElementProps{Class: "label pb-1", Attrs: mf.Attrs{"for": id}}, mf.Text(field.Label))}
children = append(children, mf.InputElement(mf.ElementProps{Attrs: attrs}))
if strings.TrimSpace(field.Help) != "" {
children = append(children, paragraph("mt-1 text-xs text-base-content/60", field.Help))
}
if strings.TrimSpace(field.Error) != "" {
children = append(children, paragraph("mt-1 text-xs font-medium text-error", field.Error))
}
return div("w-full space-y-1", children...)
}
func slugifyFieldName(label string) string {
return strings.Trim(strings.ToLower(strings.ReplaceAll(strings.TrimSpace(label), " ", "-")), "-")
}
// Tone describes the semantic color applied to dashboard metric trends.
type Tone string
const (
// ToneSuccess marks positive KPI movement.
ToneSuccess Tone = "success"
// ToneWarning marks KPI movement that needs attention.
ToneWarning Tone = "warning"
// ToneError marks negative KPI movement.
ToneError Tone = "error"
// ToneNeutral marks informational or unchanged KPI movement.
ToneNeutral Tone = "neutral"
)
// Metric describes a KPI tile rendered by MetricGrid or MetricCard.
type Metric struct {
Title string
Value string
Description string
Trend string
TrendTone Tone
Icon mf.Node
Href string
}
// MetricGridProps configures a responsive grid of metric cards.
type MetricGridProps struct {
Items []Metric
Class string
}
// MetricGrid renders KPI metric cards in a responsive grid.
func MetricGrid(props MetricGridProps) mf.Node {
cards := make([]mf.Node, 0, len(props.Items))
for _, item := range props.Items {
cards = append(cards, MetricCard(item))
}
return div(defaultString(props.Class, "grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4"), cards...)
}
// MetricCard renders a single DashWind KPI card.
func MetricCard(metric Metric) mf.Node {
children := []mf.Node{
div("flex items-start justify-between gap-4",
div("space-y-1", paragraph("text-sm font-medium text-base-content/60", metric.Title), div("text-3xl font-bold tracking-tight text-primary", mf.Text(metric.Value))),
metricIcon(metric.Icon),
),
}
if strings.TrimSpace(metric.Description) != "" {
children = append(children, paragraph("text-sm text-base-content/60", metric.Description))
}
if strings.TrimSpace(metric.Trend) != "" {
children = append(children, paragraph(strings.TrimSpace("text-sm font-medium "+toneTextClass(metric.TrendTone)), metric.Trend))
}
className := "card bg-base-100 shadow transition hover:-translate-y-0.5 hover:shadow-md"
body := mf.DivProps(mf.ElementProps{Class: "card-body gap-3"}, children...)
if strings.TrimSpace(metric.Href) == "" {
return mf.DivProps(mf.ElementProps{Class: className}, body)
}
return mf.AnchorProps(mf.ElementProps{Class: strings.TrimSpace(className + " block no-underline text-base-content"), Attrs: mf.Attrs{"href": metric.Href}}, body)
}
// Stat describes a single metric item inside StatsGrid.
type Stat struct {
Title string
Value string
Description string
Figure mf.Node
Class string
TitleClass string
ValueClass string
DescriptionClass string
FigureClass string
}
// StatsGridProps configures a grid of stat cards.
type StatsGridProps struct {
Items []Stat
Class string
}
// StatsGrid renders each stat as an individual card, matching DashWind dashboard tiles.
// Deprecated: use MetricGrid with Metric items.
func StatsGrid(props StatsGridProps) mf.Node {
cards := make([]mf.Node, 0, len(props.Items))
for _, item := range props.Items {
cards = append(cards, daisy.StatsWithProps(daisy.StatsProps{Class: "shadow bg-base-100"}, daisy.StatItem(daisy.StatProps{
Title: item.Title, Value: item.Value, Description: item.Description, Figure: item.Figure, Class: item.Class,
TitleClass: item.TitleClass, ValueClass: defaultString(item.ValueClass, "text-primary"), DescriptionClass: item.DescriptionClass, FigureClass: defaultString(item.FigureClass, "text-primary text-3xl"),
})))
}
return div(defaultString(props.Class, "grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4"), cards...)
}
// Action describes a reusable button, link, or htmx form action for DashWind resource pages.
type Action struct {
Label string
Href string
Action string
Method string
Target string
Swap string
Class string
Attrs mf.Attrs
Fields map[string]string
Confirm string
Disabled bool
Content mf.Node
}
// ResourcePageProps configures a conventional list/resource page with a title, toolbar, table, and empty state.
type ResourcePageProps[T any] struct {
Title string
Description string
Rows []T
Columns []Column[T]
PrimaryAction Action
Filters []mf.Node
Search mf.Node
EmptyState mf.EmptyStateProps
RowActions func(T) []Action
}
// ResourcePage renders a high-level DashWind resource page from reusable page, table, empty-state, and action primitives.
func ResourcePage[T any](props ResourcePageProps[T]) mf.Node {
columns := append([]Column[T](nil), props.Columns...)
if props.RowActions != nil {
columns = append(columns, Column[T]{
HeaderClass: "w-0",
Class: "text-right",
Cell: func(row T) mf.Node {
return renderActions(props.RowActions(row), "justify-end")
},
})
}
children := []mf.Node{PageHeader(PageHeaderProps{Title: props.Title, Description: props.Description, Actions: renderPrimaryAction(props.PrimaryAction)})}
if toolbar := resourceToolbar(props.Search, props.Filters); toolbar != nil {
children = append(children, toolbar)
}
if len(props.Rows) == 0 {
children = append(children, resourceEmptyState(props.EmptyState))
} else {
children = append(children, DataTable(DataTableProps[T]{Columns: columns, Rows: props.Rows}))
}
return div("space-y-6", children...)
}
func renderPrimaryAction(action Action) mf.Node {
if actionIsZero(action) {
return nil
}
return renderAction(action)
}
func renderActions(actions []Action, className string) mf.Node {
nodes := make([]mf.Node, 0, len(actions))
for _, action := range actions {
if actionIsZero(action) {
continue
}
nodes = append(nodes, renderAction(action))
}
if len(nodes) == 0 {
return mf.Raw("")
}
return daisy.Actions(mf.ActionsProps{Props: mf.ComponentProps{Class: className}}, nodes...)
}
func renderAction(action Action) mf.Node {
content := action.Content
if content == nil {
content = mf.Text(action.Label)
}
attrs := copyAttrs(action.Attrs)
if action.Confirm != "" {
attrs["hx-confirm"] = action.Confirm
}
if action.Href != "" {
attrs["href"] = action.Href
return mf.AnchorProps(mf.ElementProps{Class: actionClass(action), Attrs: attrs}, content)
}
if action.Action != "" {
fields := make([]mf.Node, 0, len(action.Fields)+1)
fieldNames := make([]string, 0, len(action.Fields))
for name := range action.Fields {
fieldNames = append(fieldNames, name)
}
sort.Strings(fieldNames)
for _, name := range fieldNames {
fields = append(fields, daisy.HiddenField(name, action.Fields[name]))
}
buttonAttrs := copyAttrs(attrs)
buttonAttrs["type"] = "submit"
fields = append(fields, daisy.ButtonContentWithAttrs(mf.ComponentProps{Class: actionClassWithoutButtonPrefix(action), Disabled: action.Disabled}, buttonAttrs, content))
return daisy.ActionFormWithOptions(daisy.ActionFormOptions{Action: action.Action, Method: action.Method, Target: action.Target, Swap: action.Swap, Class: "inline-flex"}, fields...)
}
attrs["type"] = defaultString(attrs["type"], "button")
return daisy.ButtonContentWithAttrs(mf.ComponentProps{Class: actionClassWithoutButtonPrefix(action), Disabled: action.Disabled}, attrs, content)
}
func resourceToolbar(search mf.Node, filters []mf.Node) mf.Node {
if search == nil && len(filters) == 0 {
return nil
}
children := []mf.Node{}
if search != nil {
children = append(children, div("w-full md:max-w-sm", search))
}
if len(filters) > 0 {
children = append(children, div("flex flex-wrap items-center gap-2", filters...))
}
return div("flex flex-col gap-3 md:flex-row md:items-center md:justify-between", children...)
}
func resourceEmptyState(props mf.EmptyStateProps) mf.Node {
if props.Title == "" {
props.Title = "No records found"
}
if props.Description == "" {
props.Description = "Create a record or adjust your filters to see data here."
}
return daisy.EmptyState(props)
}
func actionClass(action Action) string {
return strings.TrimSpace("btn " + actionClassWithoutButtonPrefix(action))
}
func actionClassWithoutButtonPrefix(action Action) string {
return strings.TrimSpace(defaultString(action.Class, "btn-sm"))
}
func actionIsZero(action Action) bool {
return action.Label == "" && action.Href == "" && action.Action == "" && action.Content == nil
}
func copyAttrs(attrs mf.Attrs) mf.Attrs {
copied := mf.Attrs{}
for key, value := range attrs {
copied[key] = value
}
return copied
}
// Column describes a high-level DashWind data table column for a row of type T.
type Column[T any] struct {
Header string
Cell func(T) mf.Node
Class string
HeaderClass string
Sortable bool
}
// DataTableProps configures a responsive DashWind data table.
type DataTableProps[T any] struct {
Columns []Column[T]
Rows []T
Empty mf.Node
Zebra bool
Compact bool
Actions mf.Node
WrapperClass string
}
// DataTable renders a responsive high-level DashWind table backed by DaisyUI's table primitive.
func DataTable[T any](props DataTableProps[T]) mf.Node {
headers := make([]string, 0, len(props.Columns))
headerClasses := make([]string, 0, len(props.Columns))
headerSortables := make([]bool, 0, len(props.Columns))
for _, column := range props.Columns {
headers = append(headers, column.Header)
headerClasses = append(headerClasses, column.HeaderClass)
headerSortables = append(headerSortables, column.Sortable)
}
rows := make([][]mf.Node, 0, len(props.Rows))
cellClasses := make([][]string, 0, len(props.Rows))
for _, row := range props.Rows {
cells := make([]mf.Node, 0, len(props.Columns))
classes := make([]string, 0, len(props.Columns))
for _, column := range props.Columns {
cell := mf.Node(mf.Text(""))
if column.Cell != nil {
cell = column.Cell(row)
}
cells = append(cells, cell)
classes = append(classes, column.Class)
}
rows = append(rows, cells)
cellClasses = append(cellClasses, classes)
}
className := ""
if props.Zebra {
className = strings.TrimSpace(className + " table-zebra")
}
if props.Compact {
className = strings.TrimSpace(className + " table-sm")
}
table := daisy.TableWithProps(daisy.TableProps{
Headers: headers,
Rows: rows,
Class: className,
WrapperClass: props.WrapperClass,
HeaderClasses: headerClasses,
HeaderSortables: headerSortables,
CellClasses: cellClasses,
Empty: props.Empty,
EmptyColSpan: len(props.Columns),
})
if props.Actions == nil {
return table
}
return div("space-y-4", div("flex justify-end", props.Actions), table)
}
func normalizeShellProps(props ShellProps) ShellProps {
if props.Brand.Title == "" {
props.Brand.Title = props.BrandTitle
}
if props.Brand.Subtitle == "" {
props.Brand.Subtitle = props.BrandSubtitle
}
if props.Brand.Mark == "" {
props.Brand.Mark = props.BrandMark
}
if len(props.Navigation) == 0 {
props.Navigation = props.NavGroups
}
if props.User.Initials == "" {
props.User.Initials = props.UserInitials
}
if len(props.Actions) == 0 {
props.Actions = props.TopbarActions
}
if strings.TrimSpace(props.CurrentPath) == "" && strings.TrimSpace(props.CurrentTitle) != "" {
if item, ok := findNavItemByLabel(props.Navigation, props.CurrentTitle); ok {
props.CurrentPath = navItemPath(item)
}
}
return props
}
func topbar(props ShellProps, drawerID string) mf.Node {
actions := append([]mf.Node{}, props.Actions...)
if len(actions) == 0 {
actions = []mf.Node{
mf.ThemeToggleButton(mf.ComponentProps{Class: "btn-circle"}),
daisy.ButtonContentWithAttrs(mf.ComponentProps{Class: "btn-ghost btn-circle indicator"}, map[string]string{"type": "button", "aria-label": "notifications"}, span("indicator-item badge badge-primary badge-xs", ""), mf.Text("🔔")),
renderUserMenu(props.User),
}
}
return daisy.NavbarWithProps(daisy.NavbarProps{Class: defaultString(props.NavbarClass, "sticky top-0 z-30 border-b border-base-300 bg-base-100/90 backdrop-blur")},
div("flex-none lg:hidden", mf.LabelElementProps(mf.ElementProps{Class: "btn btn-square btn-ghost", Attrs: mf.Attrs{"for": drawerID, "aria-label": "open sidebar"}}, mf.Text("☰"))),
div("flex-1", mf.H1Props(mf.ElementProps{Class: "text-xl font-semibold"}, mf.Text(currentTitle(props)))),
div("hidden max-w-md flex-1 md:block", searchInput(defaultString(props.SearchPlaceholder, "Search DashWind"))),
div("flex-none gap-2", actions...),
)
}
func sidebar(props ShellProps) mf.Node {
footer := props.SidebarFooter
if footer == nil {
footer = div("mt-6 rounded-box bg-primary/10 p-4 text-sm", paragraph("font-semibold", "Marionette port"), paragraph("mt-1 opacity-70", "DashWind template patterns rebuilt as Go handlers and htmx fragments."))
}
brand := props.Brand
brand.Title = defaultString(brand.Title, "DashWind")
brand.Mark = defaultString(brand.Mark, "D")
panelChildren := []mf.Node{brandBlock(brand)}
panelChildren = append(panelChildren, RenderNavigation(props.Navigation, props.CurrentPath))
panelChildren = append(panelChildren, footer)
return div(defaultString(props.SidebarClass, "min-h-full w-80 bg-base-100 text-base-content shadow-xl"), div("p-5", panelChildren...))
}
func brandBlock(brand Brand) mf.Node {
mark := div("grid h-11 w-11 place-items-center rounded-2xl bg-primary text-xl font-black text-primary-content", mf.Text(brand.Mark))
text := div("", mf.H2Props(mf.ElementProps{Class: "text-lg font-bold"}, mf.Text(brand.Title)), paragraph("text-xs text-base-content/60", brand.Subtitle))
children := []mf.Node{mark, text}
className := strings.TrimSpace("mb-6 flex items-center gap-3 " + brand.Class)
if strings.TrimSpace(brand.Href) != "" {
return mf.AnchorProps(mf.ElementProps{Class: className, Attrs: mf.Attrs{"href": brand.Href}}, children...)
}
return div(className, children...)
}
// RenderNavigation renders DashWind sidebar navigation and marks the item whose Path matches currentPath active.
func RenderNavigation(nav Navigation, currentPath string) mf.Node {
menus := make([]mf.Node, 0, len(nav))
for _, group := range nav {
menus = append(menus, NavMenu(group, currentPath))
}
return mf.DivProps(mf.ElementProps{}, menus...)
}
// NavMenu renders a menu group and marks an item active by CurrentPath when Active is false.
func NavMenu(group NavGroup, currentPath string) mf.Node {
items := []mf.Node{}
if strings.TrimSpace(group.Label) != "" {
items = append(items, daisy.MenuTitle(group.Label))
}
for _, item := range group.Items {
items = append(items, renderNavItem(item, currentPath))
}
return daisy.MenuWithProps(daisy.MenuProps{Class: strings.TrimSpace("rounded-box gap-1 p-0 " + group.Class)}, items...)
}
func renderNavItem(item NavItem, currentPath string) mf.Node {
path := navItemPath(item)
active := item.Active || (path != "" && path == currentPath) || navItemHasActiveChild(item, currentPath)
className := strings.TrimSpace(item.Class)
if active {
className = strings.TrimSpace(className + " active")
}
if item.Disabled {
className = strings.TrimSpace(className + " disabled")
}
attrs := mf.Attrs{"class": className}
if item.Disabled {
attrs["aria-disabled"] = "true"
attrs["tabindex"] = "-1"
} else {
if path == "" {
path = "#"
}
attrs["href"] = path
if item.External {
attrs["target"] = "_blank"
attrs["rel"] = "noopener noreferrer"
}
}
children := make([]mf.Node, 0, 4)
if item.IconNode != nil {
children = append(children, item.IconNode)
} else if item.Icon != "" {
children = append(children, span("w-6 text-center", item.Icon))
}
children = append(children, span("flex-1", item.Label))
if len(item.Children) > 0 {
children = append(children, span("text-xs opacity-60", "▾"))
}
if item.Badge != "" {
children = append(children, span("badge badge-sm", item.Badge))
}
liChildren := []mf.Node{mf.Element("a", mf.ElementProps{Attrs: attrs}, children...)}
if len(item.Children) > 0 {
childItems := make([]mf.Node, 0, len(item.Children))
for _, child := range item.Children {
childItems = append(childItems, renderNavItem(child, currentPath))
}
liChildren = append(liChildren, mf.UlProps(mf.ElementProps{}, childItems...))
}
return mf.Li(liChildren...)
}
func navItemHasActiveChild(item NavItem, currentPath string) bool {
for _, child := range item.Children {
path := navItemPath(child)
if path != "" && path == currentPath {
return true
}
if navItemHasActiveChild(child, currentPath) {
return true
}
}
return false
}
func navItemPath(item NavItem) string {
if strings.TrimSpace(item.Path) != "" {
return item.Path
}
return item.Href
}
func renderUserMenu(user UserMenu) mf.Node {
initials := defaultString(user.Initials, "DW")
avatarClass := defaultString(user.AvatarClass, "bg-primary text-primary-content w-10 rounded-full")
avatar := daisy.AvatarPlaceholder(initials, "", avatarClass)
if strings.TrimSpace(user.AvatarURL) != "" {
avatar = mf.DivProps(mf.ElementProps{Class: "avatar"}, div(defaultString(user.AvatarClass, "w-10 rounded-full"), mf.Element("img", mf.ElementProps{Attrs: mf.Attrs{"src": user.AvatarURL, "alt": defaultString(user.Name, "User")}})))
}
menuItems := make([]mf.Node, 0, len(user.Items)+len(user.Actions)+1)
if user.Name != "" || user.Email != "" {
menuItems = append(menuItems, mf.Li(mf.Element("div", mf.ElementProps{Class: "flex flex-col gap-0"}, span("font-semibold", user.Name), span("text-xs opacity-60", user.Email))))
}
for _, item := range user.Items {
menuItems = append(menuItems, daisy.MenuLink(daisy.MenuLinkProps{Label: item.Label, Href: navItemPath(item), Icon: item.Icon, Active: item.Active, Class: item.Class}))
}
menuItems = append(menuItems, user.Actions...)
if len(menuItems) == 0 {
return avatar
}
return div(strings.TrimSpace("dropdown dropdown-end "+user.Class),
mf.Element("button", mf.ElementProps{Class: "btn btn-ghost btn-circle", Attrs: mf.Attrs{"type": "button", "aria-label": defaultString(user.Name, "user menu")}}, avatar),
daisy.MenuWithProps(daisy.MenuProps{Class: strings.TrimSpace("dropdown-content z-50 mt-3 w-56 rounded-box bg-base-100 p-2 shadow " + user.MenuClass)}, menuItems...),
)
}
func currentTitle(props ShellProps) string {
if props.CurrentTitle != "" {
return props.CurrentTitle
}
if item, ok := findActiveNavItem(props.Navigation, props.CurrentPath); ok {
return item.Label
}
return "Dashboard"
}
func findNavItemByLabel(nav Navigation, label string) (NavItem, bool) {
for _, group := range nav {
if item, ok := findNavItemInItems(group.Items, func(item NavItem) bool { return item.Label == label }); ok {
return item, true
}
}
return NavItem{}, false
}
func findActiveNavItem(nav Navigation, currentPath string) (NavItem, bool) {
for _, group := range nav {
if item, ok := findNavItemInItems(group.Items, func(item NavItem) bool {
path := navItemPath(item)
return item.Active || (path != "" && path == currentPath)
}); ok {
return item, true
}
}
return NavItem{}, false
}
func findNavItemInItems(items []NavItem, match func(NavItem) bool) (NavItem, bool) {
for _, item := range items {
if match(item) {
return item, true
}
if child, ok := findNavItemInItems(item.Children, match); ok {
return child, true
}
}
return NavItem{}, false
}
func shellContentClass(props ShellProps) string {
return defaultString(props.ContentClass, "flex min-h-screen flex-col bg-base-200")
}
func searchInput(placeholder string) mf.Node {
return mf.LabelElementProps(mf.ElementProps{Class: "input flex items-center gap-2"},
span("opacity-60", "⌕"),
mf.InputElement(mf.ElementProps{Class: "grow", Attrs: mf.Attrs{"type": "search", "placeholder": placeholder}}),
)
}
func metricIcon(icon mf.Node) mf.Node {
if icon == nil {
return mf.Raw("")
}
return div("grid h-12 w-12 shrink-0 place-items-center rounded-box bg-primary/10 text-2xl font-bold text-primary", icon)
}
func toneTextClass(tone Tone) string {
switch tone {
case ToneSuccess:
return "text-success"
case ToneWarning:
return "text-warning"
case ToneError:
return "text-error"
case ToneNeutral, "":
return "text-base-content/60"
default:
return "text-" + string(tone)
}
}
func div(className string, children ...mf.Node) mf.Node {
return mf.DivProps(mf.ElementProps{Class: className}, children...)
}
func paragraph(className, text string) mf.Node {
return mf.PProps(mf.ElementProps{Class: className}, mf.Text(text))
}
func span(className, text string) mf.Node {
return mf.SpanProps(mf.ElementProps{Class: className}, mf.Text(text))
}
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
package dashwind
import (
"encoding/json"
"strings"
"github.com/YoshihideShirai/marionette/backend"
mf "github.com/YoshihideShirai/marionette/frontend"
"github.com/YoshihideShirai/marionette/frontend/assets"
)
// Options configures DashWind assets registered by Use.
type Options struct {
// Theme sets the initial DaisyUI theme for pages that use DashWind.
// When empty, Marionette's default theme bootstrap remains in control.
Theme string
// CustomCSS adds trusted inline CSS after the DashWind default CSS.
CustomCSS string
// DisableDefaultCSS skips DefaultCSS registration when the app provides its own shell CSS.
DisableDefaultCSS bool
// AssetsBasePath rewrites the DaisyUI/Tailwind framework imports to a self-hosted base path.
// For example, "/assets/dashwind" resolves to "/assets/dashwind/daisyui.css" and
// "/assets/dashwind/tailwindcss-browser.js". Leave empty to use the DaisyUI style template CDN defaults.
AssetsBasePath string
}
// Use registers the DaisyUI style template, DashWind CSS, and optional DashWind settings on app.
func Use(app *backend.App, options Options) {
if app == nil {
return
}
app.UseStyleTemplate(styleTemplate())
if base := strings.TrimSpace(options.AssetsBasePath); base != "" {
app.UseAssets(assets.NewLocalAssetProvider(base))
}
if !options.DisableDefaultCSS {
app.AddStyle(DefaultCSS)
}
if css := strings.TrimSpace(options.CustomCSS); css != "" {
app.AddStyle(css)
}
if theme := strings.TrimSpace(options.Theme); theme != "" {
app.AddJavaScript(themeBootstrapJS(theme))
}
}
func styleTemplate() mf.StyleTemplate {
return mf.DaisyUITemplate
}
func themeBootstrapJS(theme string) string {
encoded, err := json.Marshal(theme)
if err != nil {
return ""
}
return `document.documentElement.setAttribute("data-theme", ` + string(encoded) + `);`
}
package frontend
import (
"cmp"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
rdf "github.com/rocketlaunchr/dataframe-go"
)
type DataFrameFilterOp string
const (
DataFrameFilterEq DataFrameFilterOp = "eq"
DataFrameFilterNotEq DataFrameFilterOp = "neq"
DataFrameFilterContains DataFrameFilterOp = "contains"
DataFrameFilterGT DataFrameFilterOp = "gt"
DataFrameFilterGTE DataFrameFilterOp = "gte"
DataFrameFilterLT DataFrameFilterOp = "lt"
DataFrameFilterLTE DataFrameFilterOp = "lte"
)
type DataFrameFilter struct {
Column string
Op DataFrameFilterOp
Value any
}
type DataFrameSort struct {
Column string
Desc bool
}
type DataFrameComputedColumn struct {
Name string
Compute func(row map[string]any) any
}
type DataFrameViewProps struct {
Filters []DataFrameFilter
Sort []DataFrameSort
Page int
PageSize int
ComputedColumns []DataFrameComputedColumn
}
type DataQueryState struct {
Filters []DataFrameFilter
}
type DataFrameChartSeries struct {
Column, Label, BackgroundColor, BorderColor string
Fill bool
Tension float64
}
type DataFrameChartProps struct {
Chart ChartProps
LabelColumn string
Series []DataFrameChartSeries
View DataFrameViewProps
}
func ApplyDataFrameView(df *rdf.DataFrame, view DataFrameViewProps) *rdf.DataFrame {
if df == nil {
return nil
}
columnNames := df.Names()
rows := make([]map[any]any, 0, df.NRows())
for i := 0; i < df.NRows(); i++ {
rows = append(rows, df.Row(i, true, rdf.SeriesName))
}
for _, cc := range view.ComputedColumns {
if cc.Compute == nil || strings.TrimSpace(cc.Name) == "" {
continue
}
name := strings.TrimSpace(cc.Name)
for _, row := range rows {
input := map[string]any{}
for k, v := range row {
if key, ok := k.(string); ok {
input[key] = v
}
}
row[name] = cc.Compute(input)
}
columnNames = append(columnNames, name)
}
if len(view.Filters) > 0 {
filtered := make([]map[any]any, 0, len(rows))
for _, row := range rows {
ok := true
for _, f := range view.Filters {
if !matchesFilter(rowValue(row, strings.TrimSpace(f.Column)), f) {
ok = false
break
}
}
if ok {
filtered = append(filtered, row)
}
}
rows = filtered
}
if len(view.Sort) > 0 {
slices.SortStableFunc(rows, func(a, b map[any]any) int {
for _, s := range view.Sort {
av, bv := rowValue(a, strings.TrimSpace(s.Column)), rowValue(b, strings.TrimSpace(s.Column))
c := compareAny(av, bv)
if c != 0 {
if s.Desc {
return -c
}
return c
}
}
return 0
})
}
if view.PageSize > 0 {
page := view.Page
if page < 1 {
page = 1
}
start := (page - 1) * view.PageSize
if start >= len(rows) {
rows = []map[any]any{}
} else {
end := min(start+view.PageSize, len(rows))
rows = rows[start:end]
}
}
series := make([]rdf.Series, 0, len(columnNames))
for _, name := range columnNames {
vals := make([]any, 0, len(rows))
for _, row := range rows {
vals = append(vals, rowValue(row, name))
}
series = append(series, rdf.NewSeriesMixed(name, nil, vals...))
}
return rdf.NewDataFrame(series...)
}
// UIDataFrame renders ...
func DataFrame(df *rdf.DataFrame, props TableProps) Node {
tableProps := props
if len(tableProps.SelectedFilters) == 0 {
tableProps.SelectedFilters = append([]DataFrameFilter(nil), tableProps.View.Filters...)
}
df = ApplyDataFrameView(df, tableProps.View)
if df == nil {
return Table(tableProps)
}
columnNames := df.Names()
if len(columnNames) > 0 {
cols := make([]TableColumn, 0, len(columnNames))
for _, name := range columnNames {
col := TableColumn{Label: strings.TrimSpace(name)}
for _, in := range props.Columns {
if strings.TrimSpace(in.SortKey) == strings.TrimSpace(name) || strings.TrimSpace(in.Label) == strings.TrimSpace(name) {
col.SortKey = in.SortKey
col.SortHref = in.SortHref
col.SortActive = in.SortActive
break
}
}
cols = append(cols, col)
}
tableProps.Columns = cols
}
rows := make([]TableComponentRow, 0, df.NRows())
for i := 0; i < df.NRows(); i++ {
rowData := df.Row(i, true, rdf.SeriesName)
cells := make([]Node, 0, len(columnNames))
for _, name := range columnNames {
switch v := rowData[name].(type) {
case nil:
cells = append(cells, textNode(""))
case Node:
cells = append(cells, v)
default:
cells = append(cells, textNode(fmt.Sprint(v)))
}
}
rows = append(rows, TableComponentRow{Cells: cells})
}
tableProps.Rows = rows
return Table(tableProps)
}
func DataFrameChart(df *rdf.DataFrame, props DataFrameChartProps) Node {
chartProps := props.Chart
if chartProps.QueryStateLabel == "" {
chartProps.QueryStateLabel = strings.TrimSpace(props.LabelColumn)
}
df = ApplyDataFrameView(df, props.View)
if df == nil {
return Chart(chartProps)
}
columnNames := df.Names()
if len(columnNames) == 0 {
return Chart(chartProps)
}
labelColumn := strings.TrimSpace(props.LabelColumn)
if labelColumn == "" {
labelColumn = columnNames[0]
}
series := props.Series
if len(series) == 0 {
for _, name := range columnNames {
if name == labelColumn {
continue
}
series = append(series, DataFrameChartSeries{Column: name, Label: name})
}
}
labels := make([]string, 0, df.NRows())
datasets := make([]ChartDataset, len(series))
for i, item := range series {
label := strings.TrimSpace(item.Label)
if label == "" {
label = strings.TrimSpace(item.Column)
}
datasets[i] = ChartDataset{Label: label, BackgroundColor: strings.TrimSpace(item.BackgroundColor), BorderColor: strings.TrimSpace(item.BorderColor), Fill: item.Fill, Tension: item.Tension, Data: make([]float64, 0, df.NRows())}
}
for row := 0; row < df.NRows(); row++ {
rowData := df.Row(row, true, rdf.SeriesName)
labels = append(labels, fmt.Sprint(rowData[labelColumn]))
for i, item := range series {
datasets[i].Data = append(datasets[i].Data, chartFloat(rowData[strings.TrimSpace(item.Column)]))
}
}
chartProps.Labels = labels
chartProps.Datasets = datasets
return Chart(chartProps)
}
func DataQueryStateFromView(view DataFrameViewProps) DataQueryState {
return DataQueryState{Filters: append([]DataFrameFilter(nil), view.Filters...)}
}
func (s DataQueryState) ToView(view DataFrameViewProps) DataFrameViewProps {
view.Filters = append([]DataFrameFilter(nil), s.Filters...)
return view
}
func (s DataQueryState) Encode(values url.Values) {
for _, f := range s.Filters {
column := strings.TrimSpace(f.Column)
if column == "" {
continue
}
op := strings.TrimSpace(string(f.Op))
if op == "" {
op = string(DataFrameFilterEq)
}
values.Add("df.filter", column+"|"+op+"|"+fmt.Sprint(f.Value))
}
}
func rowValue(row map[any]any, key string) any {
if value, ok := row[key]; ok {
return value
}
for k, value := range row {
if ks, ok := k.(string); ok && ks == key {
return value
}
}
return nil
}
func matchesFilter(value any, f DataFrameFilter) bool {
op := DataFrameFilterOp(strings.TrimSpace(string(f.Op)))
if op == "" {
op = DataFrameFilterEq
}
left, right := strings.TrimSpace(fmt.Sprint(value)), strings.TrimSpace(fmt.Sprint(f.Value))
switch op {
case DataFrameFilterEq:
return left == right
case DataFrameFilterNotEq:
return left != right
case DataFrameFilterContains:
return strings.Contains(strings.ToLower(left), strings.ToLower(right))
case DataFrameFilterGT, DataFrameFilterGTE, DataFrameFilterLT, DataFrameFilterLTE:
c := compareAny(value, f.Value)
if op == DataFrameFilterGT {
return c > 0
}
if op == DataFrameFilterGTE {
return c >= 0
}
if op == DataFrameFilterLT {
return c < 0
}
return c <= 0
default:
return left == right
}
}
func compareAny(a, b any) int {
af, aok := numericValue(a)
bf, bok := numericValue(b)
if aok && bok {
return cmp.Compare(af, bf)
}
return cmp.Compare(strings.TrimSpace(fmt.Sprint(a)), strings.TrimSpace(fmt.Sprint(b)))
}
func numericValue(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case int32:
return float64(n), true
case uint:
return float64(n), true
case uint64:
return float64(n), true
case uint32:
return float64(n), true
default:
f, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(v)), 64)
return f, err == nil
}
}
func chartFloat(value any) float64 { f, _ := numericValue(value); return f }
package frontend
import (
"fmt"
"strings"
daisy "github.com/YoshihideShirai/marionette/frontend/daisyui"
)
type FormRowProps struct {
ID string
Label string
Description string
Error string
Required bool
Control Node
}
type FieldErrorProps struct {
ID string
Message string
}
type TextFieldProps struct {
ID string
Name string
Value string
Placeholder string
Type string
Description string
Error string
Required bool
Disabled bool
ReadOnly bool
Ref string
}
type TextareaProps struct {
ID string
Name string
Value string
Placeholder string
Rows int
Description string
Error string
Required bool
Disabled bool
ReadOnly bool
Ref string
}
type SelectFieldProps struct {
ID string
Name string
Options []SelectOption
Description string
Error string
Required bool
Disabled bool
ReadOnly bool
Ref string
}
type CheckboxProps struct {
ID string
Name string
Value string
Checked bool
Label string
Description string
Error string
Disabled bool
ReadOnly bool
Ref string
}
type RadioOption struct {
Label string
Value string
}
type RadioGroupProps struct {
ID string
Name string
Value string
Options []RadioOption
Description string
Error string
Disabled bool
ReadOnly bool
Ref string
}
type SwitchProps struct {
ID string
Name string
Value string
Checked bool
Label string
Description string
Error string
Disabled bool
ReadOnly bool
Ref string
}
func FormRow(props FormRowProps) Node {
id := strings.TrimSpace(props.ID)
if id == "" {
return renderErrorNode{err: fmt.Errorf("form row id is required")}
}
if props.Control == nil {
return renderErrorNode{err: fmt.Errorf("form row control is required")}
}
descID := id + "-description"
errorID := id + "-error"
children := []Node{}
if strings.TrimSpace(props.Label) != "" {
labelChildren := []Node{element{Tag: "span", Attrs: map[string]string{"class": "text-sm font-medium"}, Text: props.Label}}
if props.Required {
labelChildren = append(labelChildren, element{Tag: "span", Attrs: map[string]string{"class": "text-error"}, Text: "*"})
}
children = append(children, element{Tag: "label", Attrs: map[string]string{"for": id, "class": "mb-1 inline-flex items-center gap-1"}, Children: labelChildren})
}
children = append(children, props.Control)
if strings.TrimSpace(props.Description) != "" {
children = append(children, element{Tag: "p", Attrs: map[string]string{"id": descID, "class": "text-xs text-base-content/70"}, Text: props.Description})
}
if strings.TrimSpace(props.Error) != "" {
children = append(children, FieldError(FieldErrorProps{ID: errorID, Message: props.Error}))
}
return element{Tag: "div", Attrs: map[string]string{"class": "ui-form-row flex flex-col gap-1.5"}, Children: children}
}
func FieldError(props FieldErrorProps) Node {
if strings.TrimSpace(props.Message) == "" {
return Raw("")
}
id := strings.TrimSpace(props.ID)
if id == "" {
return renderErrorNode{err: fmt.Errorf("field error id is required")}
}
return element{Tag: "p", Attrs: map[string]string{"id": id, "class": "ui-field-error text-xs font-medium text-error"}, Text: props.Message}
}
func TextField(props TextFieldProps) Node {
typeValue := strings.TrimSpace(props.Type)
if typeValue == "" {
typeValue = "text"
}
return element{Tag: "input", Attrs: inputControlAttrs(controlAttrConfig{
ID: props.ID,
Name: props.Name,
Value: props.Value,
Description: props.Description,
Error: props.Error,
Disabled: props.Disabled,
ReadOnly: props.ReadOnly,
Required: props.Required,
Ref: props.Ref,
Class: formInputClass(props.Error),
Type: typeValue,
Placeholder: props.Placeholder,
})}
}
func Textarea(first any, rest ...any) Node {
if name, ok := first.(string); ok {
if len(rest) != 2 {
return renderErrorNode{err: fmt.Errorf("textarea requires value and options")}
}
value, ok := rest[0].(string)
if !ok {
return renderErrorNode{err: fmt.Errorf("textarea value must be a string")}
}
options, ok := rest[1].(TextareaOptions)
if !ok {
return renderErrorNode{err: fmt.Errorf("textarea options must be TextareaOptions")}
}
return daisy.Textarea(name, value, options)
}
props, ok := first.(TextareaProps)
if !ok || len(rest) != 0 {
return renderErrorNode{err: fmt.Errorf("textarea requires TextareaProps or name, value, TextareaOptions")}
}
return textareaField(props)
}
func TextareaWithVariants(name, value string, options TextareaOptions, props TextareaVariantProps) Node {
options.Props = ComponentProps{
Class: props.Class,
Variant: string(props.Variant),
Size: string(props.Size),
Disabled: props.Disabled,
}
return Textarea(name, value, options)
}
func textareaField(props TextareaProps) Node {
attrs := inputControlAttrs(controlAttrConfig{
ID: props.ID,
Name: props.Name,
Value: "",
Description: props.Description,
Error: props.Error,
Disabled: props.Disabled,
ReadOnly: props.ReadOnly,
Required: props.Required,
Ref: props.Ref,
Class: formInputClass(props.Error) + " min-h-24",
Placeholder: props.Placeholder,
})
if props.Rows > 0 {
attrs["rows"] = fmt.Sprintf("%d", props.Rows)
}
return element{Tag: "textarea", Attrs: attrs, Text: props.Value}
}
func Select(first any, rest ...any) Node {
if name, ok := first.(string); ok {
if len(rest) != 2 {
return renderErrorNode{err: fmt.Errorf("select requires options and props")}
}
options, ok := rest[0].([]SelectOption)
if !ok {
return renderErrorNode{err: fmt.Errorf("select options must be []SelectOption")}
}
props, ok := rest[1].(ComponentProps)
if !ok {
return renderErrorNode{err: fmt.Errorf("select props must be ComponentProps")}
}
return daisy.Select(name, options, props)
}
props, ok := first.(SelectFieldProps)
if !ok || len(rest) != 0 {
return renderErrorNode{err: fmt.Errorf("select requires SelectFieldProps or name, []SelectOption, ComponentProps")}
}
return selectField(props)
}
func selectField(props SelectFieldProps) Node {
attrs := inputControlAttrs(controlAttrConfig{
ID: props.ID,
Name: props.Name,
Description: props.Description,
Error: props.Error,
Disabled: props.Disabled,
ReadOnly: props.ReadOnly,
Required: props.Required,
Ref: props.Ref,
Class: formSelectClass(props.Error),
})
children := make([]Node, 0, len(props.Options))
for _, opt := range props.Options {
o := element{Tag: "option", Attrs: map[string]string{"value": opt.Value}, Text: opt.Label}
if opt.Selected {
o.Attrs["selected"] = "selected"
}
children = append(children, o)
}
return element{Tag: "select", Attrs: attrs, Children: children}
}
func Checkbox(props any) Node {
switch p := props.(type) {
case CheckboxComponentProps:
return daisy.Checkbox(p)
case CheckboxProps:
return checkboxField(p)
default:
return renderErrorNode{err: fmt.Errorf("checkbox requires CheckboxProps or CheckboxComponentProps")}
}
}
func checkboxField(props CheckboxProps) Node {
attrs := checkableAttrs(props.ID, props.Name, props.Value, props.Description, props.Error, props.Ref, props.Disabled, props.ReadOnly, "checkbox")
if props.Checked {
attrs["checked"] = "checked"
}
control := element{Tag: "input", Attrs: attrs}
content := []Node{control, element{Tag: "span", Attrs: map[string]string{"class": "text-sm"}, Text: props.Label}}
return element{Tag: "label", Attrs: map[string]string{"for": strings.TrimSpace(props.ID), "class": "inline-flex items-center gap-2"}, Children: content}
}
func RadioGroup(props any) Node {
switch p := props.(type) {
case RadioGroupComponentProps:
return daisy.RadioGroup(p)
case RadioGroupProps:
return radioGroupField(p)
default:
return renderErrorNode{err: fmt.Errorf("radio group requires RadioGroupProps or RadioGroupComponentProps")}
}
}
func radioGroupField(props RadioGroupProps) Node {
groupID := strings.TrimSpace(props.ID)
if groupID == "" {
return renderErrorNode{err: fmt.Errorf("radio group id is required")}
}
items := make([]Node, 0, len(props.Options))
for i, opt := range props.Options {
itemID := fmt.Sprintf("%s-%d", groupID, i)
attrs := checkableAttrs(itemID, props.Name, opt.Value, props.Description, props.Error, props.Ref, props.Disabled, props.ReadOnly, "radio")
if props.Value == opt.Value {
attrs["checked"] = "checked"
}
items = append(items, element{Tag: "label", Attrs: map[string]string{"for": itemID, "class": "inline-flex items-center gap-2"}, Children: []Node{
element{Tag: "input", Attrs: attrs},
element{Tag: "span", Attrs: map[string]string{"class": "text-sm"}, Text: opt.Label},
}})
}
return element{Tag: "div", Attrs: map[string]string{"id": groupID, "class": "flex flex-wrap gap-4"}, Children: items}
}
func Switch(props any) Node {
switch p := props.(type) {
case SwitchComponentProps:
return daisy.Switch(p)
case SwitchProps:
return switchField(p)
default:
return renderErrorNode{err: fmt.Errorf("switch requires SwitchProps or SwitchComponentProps")}
}
}
func switchField(props SwitchProps) Node {
attrs := checkableAttrs(props.ID, props.Name, props.Value, props.Description, props.Error, props.Ref, props.Disabled, props.ReadOnly, "checkbox")
attrs["class"] = formSwitchClass(props.Error)
if props.Checked {
attrs["checked"] = "checked"
}
return element{Tag: "label", Attrs: map[string]string{"for": strings.TrimSpace(props.ID), "class": "inline-flex items-center gap-2"}, Children: []Node{
element{Tag: "input", Attrs: attrs},
element{Tag: "span", Attrs: map[string]string{"class": "text-sm"}, Text: props.Label},
}}
}
type controlAttrConfig struct {
ID string
Name string
Value string
Description string
Error string
Disabled bool
ReadOnly bool
Required bool
Ref string
Class string
Type string
Placeholder string
}
func inputControlAttrs(cfg controlAttrConfig) map[string]string {
id := strings.TrimSpace(cfg.ID)
if id == "" {
return map[string]string{"data-render-error": "id is required"}
}
attrs := map[string]string{
"id": id,
"name": strings.TrimSpace(cfg.Name),
"class": cfg.Class,
"aria-describedby": describedBy(id, cfg.Description, cfg.Error),
}
if cfg.Type != "" {
attrs["type"] = cfg.Type
}
if cfg.Value != "" {
attrs["value"] = cfg.Value
}
if cfg.Placeholder != "" {
attrs["placeholder"] = cfg.Placeholder
}
if cfg.Required {
attrs["required"] = "required"
}
if cfg.Disabled {
attrs["disabled"] = "disabled"
}
if cfg.ReadOnly {
attrs["readonly"] = "readonly"
}
if strings.TrimSpace(cfg.Error) != "" {
attrs["aria-invalid"] = "true"
}
if strings.TrimSpace(cfg.Ref) != "" {
attrs["data-ref"] = strings.TrimSpace(cfg.Ref)
}
return attrs
}
func checkableAttrs(id, name, value, description, errMsg, ref string, disabled, readOnly bool, typ string) map[string]string {
attrs := map[string]string{
"id": strings.TrimSpace(id),
"name": strings.TrimSpace(name),
"type": typ,
"class": formCheckableClass(errMsg),
"aria-describedby": describedBy(strings.TrimSpace(id), description, errMsg),
}
if strings.TrimSpace(value) != "" {
attrs["value"] = strings.TrimSpace(value)
}
if disabled {
attrs["disabled"] = "disabled"
}
if readOnly {
attrs["readonly"] = "readonly"
}
if strings.TrimSpace(errMsg) != "" {
attrs["aria-invalid"] = "true"
}
if strings.TrimSpace(ref) != "" {
attrs["data-ref"] = strings.TrimSpace(ref)
}
return attrs
}
func describedBy(id, description, errMsg string) string {
ids := []string{}
if strings.TrimSpace(description) != "" {
ids = append(ids, id+"-description")
}
if strings.TrimSpace(errMsg) != "" {
ids = append(ids, id+"-error")
}
return strings.Join(ids, " ")
}
func formInputClass(errMsg string) string {
base := "input w-full text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60 disabled:cursor-not-allowed read-only:bg-base-200"
if strings.TrimSpace(errMsg) != "" {
return base + " input-error"
}
return base
}
func formSelectClass(errMsg string) string {
base := "select w-full text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60 disabled:cursor-not-allowed"
if strings.TrimSpace(errMsg) != "" {
return base + " select-error"
}
return base
}
func formCheckableClass(errMsg string) string {
base := "checkbox focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60 disabled:cursor-not-allowed"
if strings.TrimSpace(errMsg) != "" {
return base + " border-error"
}
return base
}
func formSwitchClass(errMsg string) string {
base := "toggle focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60 disabled:cursor-not-allowed"
if strings.TrimSpace(errMsg) != "" {
return base + " border-error"
}
return base
}
// Package html provides low-level HTML/HTMX primitives and tag-building
// utilities used by higher-level frontend packages.
//
// This package is primarily an internal implementation layer. Most Marionette
// applications should depend on user-facing APIs from package frontend (and
// package frontend/daisyui when using daisyUI components) instead of consuming
// these primitives directly.
package html
import (
"bytes"
"fmt"
"html/template"
"regexp"
"sort"
)
// Node is a declarative UI element that can render itself as safe HTML.
type Node interface {
Render() (template.HTML, error)
}
// ElementNode is the rendered representation used by low-level element
// constructors.
type ElementNode struct {
Tag string
Attrs map[string]string
Children []Node
Text string
}
// Attrs defines HTML attributes for low-level element constructors.
type Attrs map[string]string
// ElementProps defines common HTML element attributes while keeping class and
// id easy to scan at call sites.
type ElementProps struct {
ID string
Class string
Attrs Attrs
}
// SidebarItem is the low-level representation for sidebar links used by
// primitive and sample navigation builders.
type SidebarItem struct {
Label string
Href string
Current bool
}
// Active returns a copy of the item marked as the current navigation entry.
func (i SidebarItem) Active() SidebarItem {
i.Current = true
return i
}
// Sidebar is a low-level sidebar node for samples and starter layouts.
type Sidebar struct {
Brand string
Title string
Items []SidebarItem
NoteTitle string
NoteText string
}
// NewSidebar creates a low-level sidebar node.
func NewSidebar(brand, title string, items ...SidebarItem) *Sidebar {
return &Sidebar{Brand: brand, Title: title, Items: items}
}
// SidebarLink creates a low-level sidebar link item.
func SidebarLink(label, href string) SidebarItem {
return SidebarItem{Label: label, Href: href}
}
// Note returns the sidebar with an optional note block configured.
func (s *Sidebar) Note(title, text string) *Sidebar {
s.NoteTitle = title
s.NoteText = text
return s
}
func (s *Sidebar) Render() (template.HTML, error) {
children := []Node{
ElementNode{
Tag: "div",
Attrs: map[string]string{"class": "mb-6"},
Children: []Node{
ElementNode{Tag: "div", Attrs: map[string]string{"class": "text-sm font-semibold uppercase tracking-wide text-base-content/50"}, Text: s.Brand},
ElementNode{Tag: "div", Attrs: map[string]string{"class": "text-lg font-bold"}, Text: s.Title},
},
},
s.renderNav(),
}
if s.NoteTitle != "" || s.NoteText != "" {
children = append(children, ElementNode{
Tag: "div",
Attrs: map[string]string{"class": "mt-6 rounded-box bg-base-200 p-3 text-sm text-base-content/70"},
Children: []Node{
ElementNode{Tag: "div", Attrs: map[string]string{"class": "font-medium text-base-content"}, Text: s.NoteTitle},
ElementNode{Tag: "div", Text: s.NoteText},
},
})
}
return ElementNode{
Tag: "aside",
Attrs: map[string]string{"class": "rounded-box border border-base-300 bg-base-100 p-4 shadow-sm lg:min-h-[calc(100vh-3rem)]"},
Children: children,
}.Render()
}
func (s *Sidebar) renderNav() Node {
items := make([]Node, 0, len(s.Items))
for _, item := range s.Items {
href := item.Href
if href == "" {
href = "#"
}
className := "btn btn-ghost justify-start text-base-content/70"
if item.Current {
className = "btn btn-primary justify-start"
}
items = append(items, ElementNode{Tag: "a", Attrs: map[string]string{"class": className, "href": href}, Text: item.Label})
}
return ElementNode{Tag: "nav", Attrs: map[string]string{"class": "flex flex-col gap-1"}, Children: items}
}
var tagPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9-]*$`)
func (e ElementNode) Render() (template.HTML, error) {
if !tagPattern.MatchString(e.Tag) {
return "", fmt.Errorf("invalid tag: %q", e.Tag)
}
children := make([]template.HTML, 0, len(e.Children))
for _, child := range e.Children {
if child == nil {
continue
}
r, err := child.Render()
if err != nil {
return "", err
}
children = append(children, r)
}
var b bytes.Buffer
b.WriteString("<")
b.WriteString(e.Tag)
keys := make([]string, 0, len(e.Attrs))
for k := range e.Attrs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(" ")
b.WriteString(template.HTMLEscapeString(k))
b.WriteString(`="`)
b.WriteString(template.HTMLEscapeString(e.Attrs[k]))
b.WriteString(`"`)
}
b.WriteString(">")
b.WriteString(template.HTMLEscapeString(e.Text))
b.WriteString(string(joinHTML(children)))
b.WriteString("</")
b.WriteString(e.Tag)
b.WriteString(">")
return template.HTML(b.String()), nil
}
// Raw allows trusted HTML snippets (e.g. full page shell).
type Raw string
func (r Raw) Render() (template.HTML, error) { return template.HTML(r), nil }
func Text(v string) Node {
return ElementNode{Tag: "span", Text: v}
}
func Element(tag string, props ElementProps, children ...Node) Node {
return ElementNode{Tag: tag, Attrs: elementAttrs(props), Children: children}
}
func Div(children ...Node) Node {
return DivProps(ElementProps{}, children...)
}
func DivID(id string, children ...Node) Node {
return DivProps(ElementProps{ID: id}, children...)
}
func DivClass(className string, children ...Node) Node {
return DivProps(ElementProps{Class: className}, children...)
}
func DivAttrs(attrs Attrs, children ...Node) Node {
return DivProps(ElementProps{Attrs: attrs}, children...)
}
func DivProps(props ElementProps, children ...Node) Node {
return Element("div", props, children...)
}
func Span(children ...Node) Node { return Element("span", ElementProps{}, children...) }
func SpanProps(props ElementProps, children ...Node) Node { return Element("span", props, children...) }
func P(children ...Node) Node { return Element("p", ElementProps{}, children...) }
func AnchorProps(props ElementProps, children ...Node) Node { return Element("a", props, children...) }
func AsideProps(props ElementProps, children ...Node) Node {
return Element("aside", props, children...)
}
func DescriptionListProps(props ElementProps, children ...Node) Node {
return Element("dl", props, children...)
}
func DescriptionTermProps(props ElementProps, children ...Node) Node {
return Element("dt", props, children...)
}
func DescriptionDetailsProps(props ElementProps, children ...Node) Node {
return Element("dd", props, children...)
}
func PProps(props ElementProps, children ...Node) Node { return Element("p", props, children...) }
func LabelElement(children ...Node) Node {
return LabelElementProps(ElementProps{}, children...)
}
func LabelElementProps(props ElementProps, children ...Node) Node {
return Element("label", props, children...)
}
func InputElement(props ElementProps) Node { return Element("input", props) }
func Ul(children ...Node) Node { return UlProps(ElementProps{}, children...) }
func UlProps(props ElementProps, children ...Node) Node { return Element("ul", props, children...) }
func Li(children ...Node) Node { return LiProps(ElementProps{}, children...) }
func LiProps(props ElementProps, children ...Node) Node { return Element("li", props, children...) }
func H1(children ...Node) Node { return Element("h1", ElementProps{}, children...) }
func H1Props(props ElementProps, children ...Node) Node { return Element("h1", props, children...) }
func H2(children ...Node) Node { return Element("h2", ElementProps{}, children...) }
func H2Props(props ElementProps, children ...Node) Node { return Element("h2", props, children...) }
func H3(children ...Node) Node { return Element("h3", ElementProps{}, children...) }
func H3Props(props ElementProps, children ...Node) Node { return Element("h3", props, children...) }
func H4(children ...Node) Node { return Element("h4", ElementProps{}, children...) }
func H4Props(props ElementProps, children ...Node) Node { return Element("h4", props, children...) }
func Column(children ...Node) Node {
return ElementNode{Tag: "div", Attrs: map[string]string{"class": "flex flex-col gap-3"}, Children: children}
}
func elementAttrs(props ElementProps) map[string]string {
attrs := make(map[string]string, len(props.Attrs)+2)
for key, value := range props.Attrs {
attrs[key] = value
}
if props.ID != "" {
attrs["id"] = props.ID
}
if props.Class != "" {
attrs["class"] = joinClass(attrs["class"], props.Class)
}
return attrs
}
func joinHTML(parts []template.HTML) template.HTML {
var b bytes.Buffer
for _, p := range parts {
b.WriteString(string(p))
}
return template.HTML(b.String())
}
func joinClass(parts ...string) string {
var b bytes.Buffer
for _, part := range parts {
if part == "" {
continue
}
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(part)
}
return b.String()
}
package html
import (
"html/template"
"strings"
)
// TableRowData stores primitive table cells.
type TableRowData struct {
Cells []Node
}
// Table is a low-level table node for small HTMX samples and starter UIs.
type Table struct {
Headers []string
Rows []TableRowData
}
// HTMXTable returns a primitive table node used by HTMX-focused examples.
func HTMXTable(headers []string, rows ...TableRowData) Node {
return Table{Headers: headers, Rows: rows}
}
// TableRow groups cell nodes for HTMXTable.
func TableRow(cells ...Node) TableRowData {
return TableRowData{Cells: cells}
}
func (t Table) Render() (template.HTML, error) {
headerCells := make([]Node, 0, len(t.Headers))
for _, header := range t.Headers {
headerCells = append(headerCells, ElementNode{Tag: "th", Text: header})
}
bodyRows := make([]Node, 0, len(t.Rows))
for _, row := range t.Rows {
cells := make([]Node, 0, len(row.Cells))
for _, cell := range row.Cells {
cells = append(cells, ElementNode{Tag: "td", Children: []Node{cell}})
}
bodyRows = append(bodyRows, ElementNode{Tag: "tr", Children: cells})
}
return ElementNode{
Tag: "table",
Attrs: map[string]string{"class": "table"},
Children: []Node{
ElementNode{
Tag: "thead",
Children: []Node{
ElementNode{Tag: "tr", Children: headerCells},
},
},
ElementNode{Tag: "tbody", Children: bodyRows},
},
}.Render()
}
// Form is a primitive HTMX form node.
type Form struct {
Action string
TargetQ string
Children []Node
}
// NewForm creates a primitive HTMX form that posts to action and targets #app by default.
func NewForm(action string, children ...Node) *Form {
return &Form{Action: action, TargetQ: "#app", Children: children}
}
// Target configures the HTMX target selector.
func (f *Form) Target(selector string) *Form {
f.TargetQ = selector
return f
}
func (f *Form) Render() (template.HTML, error) {
return ElementNode{
Tag: "form",
Attrs: map[string]string{
"class": "flex flex-col gap-3",
"hx-post": ActionPath(f.Action),
"hx-target": f.TargetQ,
"hx-swap": "outerHTML",
},
Children: f.Children,
}.Render()
}
// Input creates a primitive text input node.
func Input(name, value string, className ...string) Node {
return ElementNode{Tag: "input", Attrs: map[string]string{
"class": joinClass(append([]string{"input w-full"}, className...)...),
"name": name,
"type": "text",
"value": value,
}}
}
// FileUpload creates a primitive file input node.
func FileUpload(name string, required bool, className ...string) Node {
attrs := map[string]string{
"class": joinClass(append([]string{"input w-full"}, className...)...),
"name": name,
"type": "file",
"value": "",
}
if required {
attrs["required"] = "required"
}
return ElementNode{Tag: "input", Attrs: attrs}
}
// HiddenInput creates a primitive hidden input node.
func HiddenInput(name, value string) Node {
return ElementNode{Tag: "input", Attrs: map[string]string{"name": name, "type": "hidden", "value": value}}
}
// Submit creates a primitive submit button node.
func Submit(label string) Node {
return ElementNode{Tag: "button", Attrs: map[string]string{"class": "btn btn-primary w-fit", "type": "submit"}, Text: label}
}
// Button is a primitive HTMX button node.
type Button struct {
Label string
Action string
TargetQ string
}
// HTMXButton creates a primitive HTMX button that targets #app by default.
func HTMXButton(label string) *Button {
return &Button{Label: label, TargetQ: "#app"}
}
// OnClick configures the button POST action.
func (b *Button) OnClick(action string) *Button { return b.Post(action) }
// Post configures the button POST action.
func (b *Button) Post(action string) *Button {
b.Action = action
return b
}
// TargetSelector configures the button HTMX target selector.
func (b *Button) TargetSelector(selector string) *Button {
b.TargetQ = selector
return b
}
// Target configures the button HTMX target selector.
func (b *Button) Target(selector string) *Button { return b.TargetSelector(selector) }
func (b *Button) Render() (template.HTML, error) {
return ElementNode{Tag: "button", Attrs: map[string]string{
"class": "btn btn-primary w-fit",
"hx-post": ActionPath(b.Action),
"hx-target": b.TargetQ,
"hx-swap": "outerHTML",
}, Text: b.Label}.Render()
}
// ActionPath normalizes HTMX action paths by ensuring one leading slash.
func ActionPath(action string) string {
trimmed := strings.TrimSpace(action)
if strings.HasPrefix(trimmed, "/") {
return trimmed
}
return "/" + strings.TrimLeft(trimmed, "/")
}
package frontend
import (
"bytes"
"html/template"
"net/http"
"strings"
"github.com/YoshihideShirai/marionette/frontend/assets"
)
var shellTmpl = template.Must(template.New("shell").Parse(`<!doctype html>
<html lang="en" data-theme="corporate">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
{{range .FrameworkStylesheets}}<link href="{{.}}" rel="stylesheet" type="text/css" />
{{end}}{{range .FrameworkScripts}}<script src="{{.}}"></script>
{{end}}
<style>
:root {
--mrn-page-max-width: 80rem;
--mrn-page-padding: clamp(1rem, 2vw + 0.5rem, 2rem);
--mrn-focus-ring: 0 0 0 3px color-mix(in oklab, var(--color-primary) 28%, transparent);
}
html {
min-height: 100%;
background: var(--color-base-200);
}
body {
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, color-mix(in oklab, var(--color-primary) 16%, transparent), transparent 28rem),
radial-gradient(circle at bottom right, color-mix(in oklab, var(--color-secondary) 10%, transparent), transparent 24rem),
linear-gradient(180deg, var(--color-base-100) 0%, var(--color-base-200) 42%, var(--color-base-200) 100%);
color: var(--color-base-content);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
#marionette-root {
width: min(100%, var(--mrn-page-max-width));
padding: var(--mrn-page-padding);
}
#marionette-root > * {
animation: mrn-page-enter 160ms ease-out both;
}
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: none;
box-shadow: var(--mrn-focus-ring);
}
::selection {
background: color-mix(in oklab, var(--color-primary) 24%, transparent);
}
@keyframes mrn-page-enter {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
#marionette-root > * {
animation: none;
}
}
</style>
{{range .Stylesheets}}<link href="{{.}}" rel="stylesheet" type="text/css" />
{{end}}{{range .Styles}}<style>{{.}}</style>
{{end}}
{{range .Scripts}}<script src="{{.}}"></script>
{{end}}{{range .JavaScripts}}<script>{{.}}</script>
{{end}}
</head>
<body class="bg-base-200 min-h-screen">
<main id="marionette-root" class="container mx-auto p-6">{{.Content}}</main>
</body>
</html>`))
type shellOptions struct {
Title string
StyleTemplate StyleTemplate
FrameworkStylesheets []string
FrameworkScripts []string
Stylesheets []string
Styles []template.CSS
AssetProvider assets.AssetProvider
Scripts []string
JavaScripts []template.JS
DisableHTMX bool
EnableSSE bool
AssetPolicy assets.AssetPolicy
// DisableCharts keeps Chart.js out of pages that do not need charts.
// TODO: replace this opt-out with render-context feature tracking when Chart nodes can mark Chart.js as required.
DisableCharts bool
}
// ShellOptions configures the HTML document shell rendered by ShellWithOptions.
type ShellOptions = shellOptions
// Shell renders content inside the default Marionette HTML document shell.
func Shell(content template.HTML) (string, error) {
return shell(content)
}
func shell(content template.HTML) (string, error) {
return shellWithOptions(content, shellOptions{})
}
// ShellWithOptions renders content inside the Marionette HTML document shell.
func ShellWithOptions(content template.HTML, options ShellOptions) (string, error) {
return shellWithOptions(content, shellOptions(options))
}
func shellWithOptions(content template.HTML, options shellOptions) (string, error) {
title := strings.TrimSpace(options.Title)
if title == "" {
title = "Marionette"
}
provider := assetProvider(options.AssetProvider)
frameworkStylesheets, frameworkScripts := resolveFrameworkAssets(options, provider)
scripts := append(resolveFeatureScriptAssets(options, provider), options.Scripts...)
if err := validateShellAssetPolicy(options.AssetPolicy, frameworkStylesheets, frameworkScripts, options.Stylesheets, scripts); err != nil {
return "", err
}
javaScripts := []template.JS{template.JS(assets.ThemeBootstrapJS)}
if !options.DisableCharts {
javaScripts = append(javaScripts, template.JS(assets.ChartBootstrapJS))
}
if options.EnableSSE {
javaScripts = append(javaScripts, template.JS(assets.SSEBootstrapJS))
}
javaScripts = append(javaScripts, options.JavaScripts...)
view := struct {
Title string
Content template.HTML
FrameworkStylesheets []string
FrameworkScripts []string
Stylesheets []string
Styles []template.CSS
Scripts []string
JavaScripts []template.JS
}{
Title: title,
Content: content,
FrameworkStylesheets: frameworkStylesheets,
FrameworkScripts: frameworkScripts,
Stylesheets: options.Stylesheets,
Styles: options.Styles,
Scripts: scripts,
JavaScripts: javaScripts,
}
var out bytes.Buffer
if err := shellTmpl.Execute(&out, view); err != nil {
return "", err
}
return out.String(), nil
}
func validateShellAssetPolicy(policy assets.AssetPolicy, frameworkStylesheets, frameworkScripts, stylesheets, scripts []string) error {
checks := []struct {
kind string
urls []string
}{
{kind: "framework stylesheet", urls: frameworkStylesheets},
{kind: "framework script", urls: frameworkScripts},
{kind: "stylesheet", urls: stylesheets},
{kind: "script", urls: scripts},
}
for _, check := range checks {
for _, url := range check.urls {
if err := assets.ValidateURL(policy, check.kind, url); err != nil {
return err
}
}
}
return nil
}
func resolveFeatureScriptAssets(options shellOptions, provider assets.AssetProvider) []string {
names := make([]assets.AssetName, 0, 2)
if !options.DisableHTMX {
names = append(names, assets.HTMX)
}
if !options.DisableCharts {
names = append(names, assets.ChartJS)
}
return resolveScriptAssets(provider, names)
}
func resolveFrameworkAssets(options shellOptions, provider assets.AssetProvider) ([]string, []string) {
if len(options.FrameworkStylesheets) > 0 || len(options.FrameworkScripts) > 0 {
return append([]string(nil), options.FrameworkStylesheets...), append([]string(nil), options.FrameworkScripts...)
}
styleTemplate := options.StyleTemplate
if styleTemplate.Name == "" && len(styleTemplate.FrameworkStylesheets) == 0 && len(styleTemplate.FrameworkScripts) == 0 && len(styleTemplate.FrameworkStylesheetAssets) == 0 && len(styleTemplate.FrameworkScriptAssets) == 0 {
styleTemplate = DefaultStyleTemplate()
}
stylesheets := resolveStylesheetAssets(provider, styleTemplate.FrameworkStylesheetAssets)
if len(styleTemplate.FrameworkStylesheetAssets) == 0 {
stylesheets = append(stylesheets, styleTemplate.FrameworkStylesheets...)
}
scripts := resolveScriptAssets(provider, styleTemplate.FrameworkScriptAssets)
if len(styleTemplate.FrameworkScriptAssets) == 0 {
scripts = append(scripts, styleTemplate.FrameworkScripts...)
}
return stylesheets, scripts
}
func assetProvider(provider assets.AssetProvider) assets.AssetProvider {
if provider != nil {
return provider
}
return assets.DefaultProvider
}
func resolveStylesheetAssets(provider assets.AssetProvider, names []assets.AssetName) []string {
out := make([]string, 0, len(names))
for _, name := range names {
if url, ok := provider.StylesheetURL(name); ok && url != "" {
out = append(out, url)
}
}
return out
}
func resolveScriptAssets(provider assets.AssetProvider, names []assets.AssetName) []string {
out := make([]string, 0, len(names))
for _, name := range names {
if url, ok := provider.ScriptURL(name); ok && url != "" {
out = append(out, url)
}
}
return out
}
// WriteHTML writes an HTML response with Marionette's standard content type.
func WriteHTML(w http.ResponseWriter, body string) {
writeHTML(w, body)
}
func writeHTML(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(body))
}
package frontend
import (
"html/template"
"github.com/YoshihideShirai/marionette/frontend/assets"
)
type ShellAssets struct {
StyleTemplate StyleTemplate
FrameworkStylesheets []string
FrameworkScripts []string
Stylesheets []string
Styles []template.CSS
AssetProvider assets.AssetProvider
Scripts []string
JavaScripts []template.JS
DisableHTMX bool
DisableCharts bool
EnableSSE bool
}
func (a *ShellAssets) UseStyleTemplate(tpl StyleTemplate) {
a.StyleTemplate = StyleTemplate{
Name: tpl.Name,
FrameworkStylesheets: append([]string(nil), tpl.FrameworkStylesheets...),
FrameworkScripts: append([]string(nil), tpl.FrameworkScripts...),
FrameworkStylesheetAssets: append([]assets.AssetName(nil), tpl.FrameworkStylesheetAssets...),
FrameworkScriptAssets: append([]assets.AssetName(nil), tpl.FrameworkScriptAssets...),
}
a.FrameworkStylesheets = nil
a.FrameworkScripts = nil
}
func (a *ShellAssets) UseAssets(provider assets.AssetProvider) { a.AssetProvider = provider }
func (a *ShellAssets) AddStylesheet(href string) { a.Stylesheets = append(a.Stylesheets, href) }
func (a *ShellAssets) AddStyle(css template.CSS) { a.Styles = append(a.Styles, css) }
func (a *ShellAssets) AddScript(src string) { a.Scripts = append(a.Scripts, src) }
func (a *ShellAssets) AddJavaScript(js template.JS) {
a.JavaScripts = append(a.JavaScripts, js)
}
// EnableHTMX controls whether the default HTMX runtime is included in full-page shells.
func (a *ShellAssets) EnableHTMX(enable bool) { a.DisableHTMX = !enable }
// EnableCharts controls whether the default Chart.js runtime and chart bootstrap are included in full-page shells.
func (a *ShellAssets) EnableCharts(enable bool) { a.DisableCharts = !enable }
// EnableServerSentEvents controls whether the default SSE connector runtime is included in full-page shells.
func (a *ShellAssets) EnableServerSentEvents(enable bool) { a.EnableSSE = enable }
package frontend
import (
"fmt"
"strings"
)
// StreamTrigger renders a hidden htmx trigger for polling a server-side stream action.
func StreamTrigger(props StreamTriggerProps) Node {
action := strings.TrimSpace(props.Action)
if action == "" {
return renderErrorNode{err: fmt.Errorf("stream trigger action is required")}
}
target := strings.TrimSpace(props.Target)
if target == "" {
return renderErrorNode{err: fmt.Errorf("stream trigger target is required")}
}
swap := strings.TrimSpace(props.Swap)
if swap == "" {
swap = "outerHTML"
}
trigger := strings.TrimSpace(props.Trigger)
if trigger == "" {
delay := strings.TrimSpace(props.Delay)
if delay == "" {
delay = "350ms"
}
trigger = "load delay:" + delay
}
attrs := map[string]string{
"aria-hidden": "true",
"class": strings.TrimSpace("hidden " + props.Props.Class),
"hx-post": action,
"hx-trigger": trigger,
"hx-target": target,
"hx-swap": swap,
}
if strings.TrimSpace(props.ID) != "" {
attrs["id"] = strings.TrimSpace(props.ID)
}
return element{Tag: "div", Attrs: attrs}
}
package frontend
import (
"github.com/YoshihideShirai/marionette/frontend/assets"
daisyuipresets "github.com/YoshihideShirai/marionette/frontend/daisyui/presets"
"github.com/YoshihideShirai/marionette/frontend/twailwindcss"
)
type StyleTemplate struct {
Name string
FrameworkStylesheets []string
FrameworkScripts []string
// FrameworkStylesheetAssets and FrameworkScriptAssets name provider-resolved assets.
// FrameworkStylesheets/FrameworkScripts remain supported for direct URL compatibility.
FrameworkStylesheetAssets []assets.AssetName
FrameworkScriptAssets []assets.AssetName
}
var DaisyUITemplate = StyleTemplate{
Name: "daisyui",
FrameworkStylesheetAssets: daisyuipresets.FrameworkStylesheetAssets(),
FrameworkScriptAssets: daisyuipresets.FrameworkScriptAssets(),
FrameworkStylesheets: daisyuipresets.FrameworkStylesheets(),
FrameworkScripts: daisyuipresets.FrameworkScripts(),
}
var TailwindCSSTemplate = StyleTemplate{
Name: twailwindcss.TemplateName,
FrameworkStylesheetAssets: twailwindcss.FrameworkStylesheetAssets(),
FrameworkScriptAssets: twailwindcss.FrameworkScriptAssets(),
FrameworkStylesheets: twailwindcss.FrameworkStylesheets(),
FrameworkScripts: twailwindcss.FrameworkScripts(),
}
func DefaultStyleTemplate() StyleTemplate {
return DaisyUITemplate
}
func StyleTemplateByName(name string) (StyleTemplate, bool) {
switch name {
case "daisyui", "tailadmin":
return DaisyUITemplate, true
case twailwindcss.TemplateName:
return TailwindCSSTemplate, true
default:
return StyleTemplate{}, false
}
}
package twailwindcss
import "github.com/YoshihideShirai/marionette/frontend/assets"
const (
TemplateName = "tailwindcss"
BrowserURL = assets.TailwindBrowserURL
)
func FrameworkStylesheetAssets() []assets.AssetName {
return nil
}
func FrameworkScriptAssets() []assets.AssetName {
return []assets.AssetName{assets.TailwindCSSBrowser}
}
func FrameworkStylesheets() []string {
return nil
}
func FrameworkScripts() []string {
if url, ok := assets.DefaultProvider.ScriptURL(assets.TailwindCSSBrowser); ok && url != "" {
return []string{url}
}
return nil
}
package frontend
import (
"context"
"fmt"
"io"
lowhtml "github.com/YoshihideShirai/marionette/frontend/html"
shared "github.com/YoshihideShirai/marionette/frontend/shared"
dataframeimports "github.com/rocketlaunchr/dataframe-go/imports"
)
type FlashLevel string
const (
FlashSuccess FlashLevel = "success"
FlashError FlashLevel = "error"
FlashInfo FlashLevel = "info"
FlashWarn FlashLevel = "warn"
)
type FlashMessage struct {
Level FlashLevel `json:"level"`
Message string `json:"message"`
}
// Node is a declarative UI element that can render itself as safe HTML.
type Node = lowhtml.Node
type element = lowhtml.ElementNode
// Attrs defines HTML attributes for low-level element constructors.
type Attrs = lowhtml.Attrs
// ElementProps defines common HTML element attributes while keeping class and
// id easy to scan at call sites.
type ElementProps = lowhtml.ElementProps
// Raw allows trusted HTML snippets (e.g. full page shell).
type Raw = lowhtml.Raw
func textNode(v string) Node {
return lowhtml.Text(v)
}
func htmlElement(tag string, props ElementProps, children ...Node) Node {
return lowhtml.Element(tag, props, children...)
}
func Text(v string) Node { return lowhtml.Text(v) }
func Element(tag string, props ElementProps, children ...Node) Node {
return lowhtml.Element(tag, props, children...)
}
func Div(children ...Node) Node { return lowhtml.Div(children...) }
func DivProps(props ElementProps, children ...Node) Node {
return lowhtml.DivProps(props, children...)
}
func Span(children ...Node) Node { return lowhtml.Span(children...) }
func SpanProps(props ElementProps, children ...Node) Node {
return lowhtml.SpanProps(props, children...)
}
func P(children ...Node) Node { return lowhtml.P(children...) }
func AnchorProps(props ElementProps, children ...Node) Node {
return lowhtml.AnchorProps(props, children...)
}
func AsideProps(props ElementProps, children ...Node) Node {
return lowhtml.AsideProps(props, children...)
}
func DescriptionListProps(props ElementProps, children ...Node) Node {
return lowhtml.DescriptionListProps(props, children...)
}
func DescriptionTermProps(props ElementProps, children ...Node) Node {
return lowhtml.DescriptionTermProps(props, children...)
}
func DescriptionDetailsProps(props ElementProps, children ...Node) Node {
return lowhtml.DescriptionDetailsProps(props, children...)
}
func PProps(props ElementProps, children ...Node) Node { return lowhtml.PProps(props, children...) }
func LabelElement(children ...Node) Node { return lowhtml.LabelElement(children...) }
func LabelElementProps(props ElementProps, children ...Node) Node {
return lowhtml.LabelElementProps(props, children...)
}
func InputElement(props ElementProps) Node { return lowhtml.InputElement(props) }
func Ul(children ...Node) Node { return lowhtml.Ul(children...) }
func UlProps(props ElementProps, children ...Node) Node { return lowhtml.UlProps(props, children...) }
func Li(children ...Node) Node { return lowhtml.Li(children...) }
func LiProps(props ElementProps, children ...Node) Node { return lowhtml.LiProps(props, children...) }
func H1(children ...Node) Node { return lowhtml.H1(children...) }
func H1Props(props ElementProps, children ...Node) Node { return lowhtml.H1Props(props, children...) }
func H2(children ...Node) Node { return lowhtml.H2(children...) }
func H2Props(props ElementProps, children ...Node) Node { return lowhtml.H2Props(props, children...) }
func H3(children ...Node) Node { return lowhtml.H3(children...) }
func H3Props(props ElementProps, children ...Node) Node { return lowhtml.H3Props(props, children...) }
func H4(children ...Node) Node { return lowhtml.H4(children...) }
func H4Props(props ElementProps, children ...Node) Node { return lowhtml.H4Props(props, children...) }
type TableRowData = shared.TableRowData
func HTMXTable(headers []string, rows ...TableRowData) Node {
return lowhtml.HTMXTable(headers, rows...)
}
func TableRow(cells ...Node) TableRowData {
return lowhtml.TableRow(cells...)
}
func DataFrameFromCSV(r io.ReadSeeker, props TableProps, opts ...dataframeimports.CSVLoadOptions) (Node, error) {
if r == nil {
return nil, fmt.Errorf("csv reader is nil")
}
df, err := dataframeimports.LoadFromCSV(context.Background(), r, opts...)
if err != nil {
return nil, err
}
return DataFrame(df, props), nil
}
func DataFrameFromTSV(r io.ReadSeeker, props TableProps, opts ...dataframeimports.CSVLoadOptions) (Node, error) {
tsvOpts := make([]dataframeimports.CSVLoadOptions, len(opts))
copy(tsvOpts, opts)
if len(tsvOpts) == 0 {
tsvOpts = append(tsvOpts, dataframeimports.CSVLoadOptions{Comma: '\t'})
} else if tsvOpts[0].Comma == 0 {
tsvOpts[0].Comma = '\t'
}
return DataFrameFromCSV(r, props, tsvOpts...)
}
type SidebarItem = shared.SidebarItem
func Sidebar(brand, title string, items ...SidebarItem) *lowhtml.Sidebar {
return lowhtml.NewSidebar(brand, title, items...)
}
func SidebarLink(label, href string) SidebarItem {
return lowhtml.SidebarLink(label, href)
}
func Form(action string, children ...Node) *lowhtml.Form { return lowhtml.NewForm(action, children...) }
func Input(name, value string, props ...ComponentProps) Node {
if len(props) > 0 {
return lowhtml.Input(name, value, props[0].Class)
}
return lowhtml.Input(name, value)
}
func FileUpload(name string, required bool, props ...ComponentProps) Node {
if len(props) > 0 {
return lowhtml.FileUpload(name, required, props[0].Class)
}
return lowhtml.FileUpload(name, required)
}
func HiddenInput(name, value string) Node { return lowhtml.HiddenInput(name, value) }
func Submit(label string) Node { return lowhtml.Submit(label) }
func HTMXButton(label string) *lowhtml.Button { return lowhtml.HTMXButton(label) }
func actionPath(action string) string { return lowhtml.ActionPath(action) }
func FlashAlerts(flashes []FlashMessage) Node {
if len(flashes) == 0 {
return htmlElement("div", ElementProps{ID: "flash-alerts", Class: "hidden"})
}
children := make([]Node, 0, len(flashes))
for _, flash := range flashes {
children = append(children, htmlElement("div", ElementProps{Class: "alert " + flashLevelClass(flash.Level)}, textNode(flash.Message)))
}
return htmlElement("div", ElementProps{ID: "flash-alerts", Class: "space-y-2"}, children...)
}
func flashLevelClass(level FlashLevel) string {
switch level {
case FlashSuccess:
return "alert-success"
case FlashError:
return "alert-error"
case FlashWarn:
return "alert-warning"
default:
return "alert-info"
}
}
package componenttmpl
import (
"bytes"
"fmt"
"html/template"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
)
// Source owns component template path resolution, parsing, and caching.
// Callers keep one Source per package-level template root and render templates
// through Node so the template loader remains centralized in this package.
type Source struct {
dir string
once sync.Once
tmpl *template.Template
err error
}
// NewSource returns a cached component template source rooted at dir.
func NewSource(dir string) *Source {
return &Source{dir: dir}
}
// NewSourceFromCaller returns a cached template source by resolving pathParts
// relative to the file at runtime.Caller(skip).
func NewSourceFromCaller(skip int, pathParts ...string) (*Source, error) {
_, currentFile, _, ok := runtime.Caller(skip)
if !ok {
return nil, fmt.Errorf("failed to resolve component template path for %s", filepath.Join(pathParts...))
}
parts := append([]string{filepath.Dir(currentFile)}, pathParts...)
return NewSource(filepath.Join(parts...)), nil
}
// Load parses component templates from the source directory once and returns
// the cached parsed template set.
func (s *Source) Load() (*template.Template, error) {
if s == nil {
return nil, fmt.Errorf("component template source is nil")
}
s.once.Do(func() {
s.tmpl, s.err = Load(s.dir)
})
return s.tmpl, s.err
}
// Node executes a named template from Source.
type Node struct {
Source *Source
Name string
Data any
}
// Render executes the node template and returns rendered HTML.
func (n Node) Render() (template.HTML, error) {
tmpl, err := n.Source.Load()
if err != nil {
return "", err
}
var out bytes.Buffer
if err := tmpl.ExecuteTemplate(&out, n.Name, n.Data); err != nil {
return "", err
}
return template.HTML(out.String()), nil
}
// Load parses component templates from dir and returns parsed templates.
// Template names are expected to be referenced as "components/<basename>".
func Load(dir string) (*template.Template, error) {
patterns := []string{"*.tmpl", "*.html"}
files := make([]string, 0, 16)
checked := make([]string, 0, len(patterns))
for _, pattern := range patterns {
glob := filepath.Join(dir, pattern)
checked = append(checked, glob)
matched, err := filepath.Glob(glob)
if err != nil {
return nil, fmt.Errorf("failed to glob component templates (%s): %w", glob, err)
}
files = append(files, matched...)
}
if len(files) == 0 {
return nil, fmt.Errorf("no component templates found; checked paths: %s", strings.Join(checked, ", "))
}
sort.Strings(files)
tmpl, err := template.ParseFiles(files...)
if err != nil {
return nil, fmt.Errorf("failed to parse component templates from %s (template names must follow components/<basename>): %w", dir, err)
}
return tmpl, nil
}