ErrorBoundary wraps a child component and renders a fallback UI when the
child panics during Render or Mount. Once a panic occurs, the fallback UI is
displayed for subsequent renders.
{
return "<root data-component-id=\"" + e.Child.GetID() + "\">" + e.Fallback + "</root>"
}
Render renders the child component, returning the fallback HTML if the child
panics or if a previous panic was recorded.
{
if e.err != nil {
return e.fallbackHTML()
}
defer func() {
if r := recover(); r != nil {
e.err = r
out = e.fallbackHTML()
}
}()
return e.Child.Render()
}
Mount mounts the child component, updating the DOM with the fallback HTML if
the child panics during mounting.
{
if e.err != nil {
return
}
defer func() {
if r := recover(); r != nil {
e.err = r
dom.UpdateDOM(e.Child.GetID(), e.Fallback)
}
}()
e.Child.Mount()
}
Unmount delegates to the child component's Unmount method.
{ e.Child.Unmount() }
OnMount is a no-op for ErrorBoundary.
{}
OnUnmount is a no-op for ErrorBoundary.
{}
GetName returns the name of the component.
{ return "ErrorBoundary" }
GetID returns the wrapped child's ID.
{ return e.Child.GetID() }
SetSlots delegates slot assignment to the child component.
{
if e.Child != nil {
e.Child.SetSlots(slots)
}
}
NewErrorBoundary creates a new ErrorBoundary around the provided child
component. If the child panics during Render or Mount, the provided fallback
HTML will be rendered instead.
{
return &ErrorBoundary{Child: child, Fallback: fallback}
}
ComponentStats is a stub for non-wasm builds.
ComponentTimelineEntry is a stub for non-wasm builds.
Plugin defines interface for plugins to register hooks on the App. Plugins can
provide a build step which is executed by the CLI before the application is
run and may also attach runtime hooks through Install.
Named plugins expose a unique identifier used for deduplication.
Implementing this interface is optional.
Requires allows plugins to declare mandatory dependencies.
Implementing this interface is optional.
Optional allows plugins to declare optional dependencies.
Implementing this interface is optional.
PreBuilder allows plugins to execute logic before the CLI build step.
Implementing this interface is optional.
PostBuilder allows plugins to execute logic after the CLI build step.
Implementing this interface is optional.
Uninstaller allows plugins to clean up previously registered hooks.
Implementing this interface is optional.
App maintains registered hooks and exposes helper methods for plugins
to attach to framework events.
RegisterRouter adds a router navigation hook.
{
a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore adds a store mutation hook.
{
a.storeHooks = append(a.storeHooks, fn)
}
RegisterTemplate adds a template render hook.
{
a.templateHooks = append(a.templateHooks, fn)
}
RegisterLifecycle adds hooks for component mount and unmount.
{
if mount != nil {
a.mountHooks = append(a.mountHooks, mount)
}
if unmount != nil {
a.unmountHooks = append(a.unmountHooks, unmount)
}
}
RegisterRTMLVar registers a value that can be referenced from RTML as
{plugin:NAME.VAR}.
{
if a.pluginVars == nil {
a.pluginVars = make(map[string]map[string]any)
}
if _, ok := a.pluginVars[plugin]; !ok {
a.pluginVars[plugin] = make(map[string]any)
}
a.pluginVars[plugin][name] = val
}
HasPlugin reports whether a plugin with the given name is installed.
{
if a.plugins == nil {
return false
}
_, ok := a.plugins[name]
return ok
}
{}
{}
{}
{}
{}
{ return false }
newApp creates an App with initialized hook storage.
{
return &App{hooks: &hooks{}, pluginVars: make(map[string]map[string]any), plugins: make(map[string]Plugin)}
}
getRTMLVar retrieves a registered plugin variable.
{
if app.pluginVars == nil {
return nil, false
}
if vars, ok := app.pluginVars[plugin]; ok {
v, ok := vars[name]
return v, ok
}
return nil, false
}
RegisterPluginVar is a convenience wrapper for plugins to expose variables.
{
app.RegisterRTMLVar(plugin, name, val)
}
RegisterPlugin registers a plugin and allows it to add hooks. If the plugin
implements Named and has already been installed, it is skipped.
{
if n, ok := p.(Named); ok {
if app.HasPlugin(n.Name()) {
return
}
if app.plugins == nil {
app.plugins = make(map[string]Plugin)
}
app.plugins[n.Name()] = p
}
if r, ok := p.(Requires); ok {
for _, dep := range r.Requires() {
if dn, ok := dep.(Named); ok {
if app.HasPlugin(dn.Name()) {
continue
}
}
RegisterPlugin(dep)
}
}
if o, ok := p.(Optional); ok {
for _, dep := range o.Optional() {
if dn, ok := dep.(Named); ok {
if app.HasPlugin(dn.Name()) {
continue
}
}
RegisterPlugin(dep)
}
}
p.Install(app)
}
TriggerRouter invokes router hooks with the given path.
{
for _, h := range app.routerHooks {
h(path)
}
}
TriggerStore invokes store hooks for a mutation.
{
for _, h := range app.storeHooks {
h(module, store, key, value)
}
}
TriggerTemplate invokes template hooks with rendered HTML for a component.
{
for _, h := range app.templateHooks {
h(componentID, html)
}
}
TriggerMount invokes mount lifecycle hooks.
{
for _, h := range app.mountHooks {
h(c)
}
}
TriggerUnmount invokes unmount lifecycle hooks.
{
for _, h := range app.unmountHooks {
h(c)
}
}
{
state.StoreHook = TriggerStore
dom.TemplateHook = TriggerTemplate
}
App is a stub holder for callbacks.
RegisterRouter adds a router navigation hook.
{
a.routerHooks = append(a.routerHooks, fn)
}
RegisterStore adds a store mutation hook.
{
a.storeHooks = append(a.storeHooks, fn)
}
RegisterTemplate adds a template render hook.
{
a.templateHooks = append(a.templateHooks, fn)
}
RegisterLifecycle adds hooks for component mount and unmount.
{
if mount != nil {
a.mountHooks = append(a.mountHooks, mount)
}
if unmount != nil {
a.unmountHooks = append(a.unmountHooks, unmount)
}
}
RegisterRTMLVar registers a value that can be referenced from RTML as
{plugin:NAME.VAR}.
{
if a.pluginVars == nil {
a.pluginVars = make(map[string]map[string]any)
}
if _, ok := a.pluginVars[plugin]; !ok {
a.pluginVars[plugin] = make(map[string]any)
}
a.pluginVars[plugin][name] = val
}
HasPlugin reports whether a plugin with the given name is installed.
{
if a.plugins == nil {
return false
}
_, ok := a.plugins[name]
return ok
}
{}
{}
{}
{}
{}
{ return false }
{}
{}
{}
{}
{}
{}
{}
{ return "" }
{}
{}
{}
{}
{ return "noop" }
{ return "noop" }
{}
{
// reset registry
ComponentRegistry = map[string]func() Component{}
if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err != nil {
t.Fatalf("unexpected error registering component: %v", err)
}
if err := RegisterComponent("dup", func() Component { return noopComponent{} }); err == nil {
t.Fatalf("expected error on duplicate registration")
}
}
{
ComponentRegistry = map[string]func() Component{}
MustRegisterComponent("dup", func() Component { return noopComponent{} })
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic on duplicate registration")
}
}()
MustRegisterComponent("dup", func() Component { return noopComponent{} })
}
{
devWatcherOnce.Do(func() {
if js.Get("EventSource").Type() != js.TypeFunction {
log.Printf("rfw: EventSource not available, auto reload disabled")
return
}
devMsgHandler = js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) == 0 {
return nil
}
data := args[0].Get("data").String()
devHandleDevMessage(data)
return nil
})
devErrHandler = js.FuncOf(func(this js.Value, args []js.Value) any {
params := make([]any, 0, len(args)+1)
params = append(params, "rfw dev watcher error")
for _, a := range args {
params = append(params, a)
}
js.Console().Call("warn", params...)
return nil
})
devEventSource = js.Get("EventSource").New("/__rfw/hmr")
if !devEventSource.Truthy() {
return
}
if devMsgHandler.Truthy() {
devEventSource.Set("onmessage", devMsgHandler.Value)
devEventSource.Call("addEventListener", "message", devMsgHandler.Value)
}
if devErrHandler.Truthy() {
devEventSource.Set("onerror", devErrHandler.Value)
devEventSource.Call("addEventListener", "error", devErrHandler.Value)
}
})
}
{
var msg devPayload
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
log.Printf("rfw: failed to parse dev message: %v", err)
return
}
switch msg.Type {
case "reload":
js.Location().Call("reload")
case "rtml":
devApplyTemplateUpdate(msg.Component, msg.Markup)
}
}
{
if !DevMode || name == "" || markup == "" {
return
}
devMu.Lock()
devTemplateOverrides[name] = markup
components := make([]*HTMLComponent, 0, len(devComponents[name]))
for _, cmp := range devComponents[name] {
components = append(components, cmp)
}
devMu.Unlock()
dom.OverrideBindings(name, markup)
for _, cmp := range components {
cmp.Template = markup
cmp.TemplateFS = []byte(markup)
cmp.cache = nil
cmp.lastCacheKey = ""
dom.RegisterBindings(cmp.ID, cmp.Name, markup)
html := cmp.Render()
dom.UpdateDOM(cmp.ID, html)
}
}
{
if !DevMode {
return template
}
devMu.RLock()
override, ok := devTemplateOverrides[c.Name]
devMu.RUnlock()
if !ok || override == "" {
return template
}
dom.OverrideBindings(c.Name, override)
c.TemplateFS = []byte(override)
return override
}
{
if !DevMode {
return
}
devMu.Lock()
defer devMu.Unlock()
bucket, ok := devComponents[c.Name]
if !ok {
bucket = make(map[string]*HTMLComponent)
devComponents[c.Name] = bucket
}
bucket[c.ID] = c
}
{
if !DevMode {
return
}
devMu.Lock()
defer devMu.Unlock()
if bucket, ok := devComponents[c.Name]; ok {
delete(bucket, c.ID)
if len(bucket) == 0 {
delete(devComponents, c.Name)
}
}
}
{ return nil }
{ p.installed++ }
{ return "named-test" }
{
app = newApp()
p1 := &namedTestPlugin{}
RegisterPlugin(p1)
if p1.installed != 1 {
t.Fatalf("expected first plugin to install once, got %d", p1.installed)
}
p2 := &namedTestPlugin{}
RegisterPlugin(p2)
if p2.installed != 0 {
t.Fatalf("expected second plugin not to install, got %d", p2.installed)
}
if !app.HasPlugin("named-test") {
t.Fatalf("expected HasPlugin to return true")
}
}
{ return nil }
{ p.installed++ }
{ return "dep" }
{ return nil }
{}
{ return "requires" }
{ return []Plugin{p.dep} }
{
app = newApp()
dep := &depPlugin{}
req := &requiresPlugin{dep: dep}
RegisterPlugin(req)
if dep.installed != 1 {
t.Fatalf("expected dependency to install, got %d", dep.installed)
}
if !app.HasPlugin("dep") || !app.HasPlugin("requires") {
t.Fatalf("expected both plugins to be registered")
}
}
{ return nil }
{}
{ return "optional" }
{
if !p.enable {
return nil
}
return []Plugin{p.dep}
}
{
app = newApp()
dep := &depPlugin{}
opt := &optionalPlugin{dep: dep, enable: true}
RegisterPlugin(opt)
if dep.installed != 1 {
t.Fatalf("expected optional dependency to install")
}
app = newApp()
dep2 := &depPlugin{}
opt2 := &optionalPlugin{dep: dep2, enable: false}
RegisterPlugin(opt2)
if dep2.installed != 0 {
t.Fatalf("expected disabled optional dependency not to install")
}
}
Tests complex conditional scenarios including @else-if and nested blocks.
{
c := &HTMLComponent{Props: map[string]any{"val": "2"}, conditionContents: make(map[string]ConditionContent)}
template := `
@if:prop:val=="1"
One
@else-if:prop:val=="2"
Two
@else
Other
@endif`
out := replaceConditionals(template, c)
if strings.Contains(out, "One") || strings.Contains(out, "Other") {
t.Fatalf("unexpected branches rendered: %s", out)
}
if !strings.Contains(out, "Two") {
t.Fatalf("expected 'Two' branch, got %s", out)
}
}
{
props := map[string]any{"outer": "yes", "inner": "maybe"}
c := &HTMLComponent{Props: props, conditionContents: make(map[string]ConditionContent)}
template := `
@if:prop:outer=="yes"
Start
@if:prop:inner=="yes"
InnerYes
@else-if:prop:inner=="maybe"
InnerMaybe
@else
InnerNo
@endif
@else
OuterNo
@endif`
out := replaceConditionals(template, c)
if !strings.Contains(out, "Start") || !strings.Contains(out, "InnerMaybe") {
t.Fatalf("nested conditions not rendered as expected: %s", out)
}
if strings.Contains(out, "InnerYes") || strings.Contains(out, "InnerNo") || strings.Contains(out, "OuterNo") {
t.Fatalf("unexpected branches present: %s", out)
}
}
Tests constructor decorators for refs and keyed lists.
{
tpl := `<div [header] class="box"></div>`
out := replaceConstructors(tpl)
if out != `<div class="box" data-ref="header"></div>` {
t.Fatalf("unexpected constructor replacement: %s", out)
}
tpl = `<li [key {item.ID}]></li>`
out = replaceConstructors(tpl)
if out != `<li data-key="{item.ID}"></li>` {
t.Fatalf("expected data-key constructor, got %s", out)
}
}
Tests plugin placeholders for variables, commands and constructors.
{
RegisterPluginVar("soccer", "team", "lions")
tpl := `<div @plugin:soccer.init>{plugin:soccer.team}</div>`
out := replacePluginPlaceholders(tpl)
if !strings.Contains(out, "lions") {
t.Fatalf("plugin variable not replaced: %s", out)
}
if !strings.Contains(out, `data-plugin-cmd="soccer.init"`) {
t.Fatalf("plugin command not replaced: %s", out)
}
tpl = `<span [plugin:soccer.badge]></span>`
out = replaceConstructors(tpl)
if !strings.Contains(out, `data-plugin="soccer.badge"`) {
t.Fatalf("plugin constructor not replaced: %s", out)
}
}
SetDevMode toggles development mode features.
{
DevMode = enabled
if enabled {
startDevTemplateWatcher()
}
}
RegisterComponent registers a component constructor under the provided name.
When a template references the name via `rt-is`, the constructor will be
invoked to create a new component instance at render time. It returns an
error if a component with the same name is already registered and logs a
warning.
{
componentRegistryMu.Lock()
defer componentRegistryMu.Unlock()
if _, exists := ComponentRegistry[name]; exists {
Log().Warn("component %s already registered", name)
return fmt.Errorf("component %s already registered", name)
}
ComponentRegistry[name] = constructor
return nil
}
LoadComponent retrieves a component by name using the registry. If no
component is registered under that name, nil is returned.
{
componentRegistryMu.RLock()
ctor, ok := ComponentRegistry[name]
componentRegistryMu.RUnlock()
if ok {
return ctor()
}
return nil
}
NewComponent creates an HTMLComponent initialized with the provided
template and props. It sets itself as the underlying component and
performs initialization with the default store.
{
c := NewHTMLComponent(name, templateFS, props)
c.SetComponent(c)
c.Init(nil)
return c
}
NewComponentWith creates an HTMLComponent and binds it to the given
component implementation. This is useful when embedding HTMLComponent
inside another struct to override lifecycle hooks.
{
c := NewHTMLComponent(name, templateFS, props)
if any(self) != nil {
c.SetComponent(self)
} else {
c.SetComponent(c)
}
c.Init(nil)
return c
}
Stats returns zeroed metrics on non-wasm builds.
{ return ComponentStats{} }
{
template, err := LoadComponentTemplate(c.TemplateFS)
if err != nil {
panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
}
template = devOverrideTemplate(c, template)
c.Template = template
dom.RegisterBindings(c.ID, c.Name, template)
devRegisterComponent(c)
if store != nil {
c.Store = store
} else {
c.Store = state.GlobalStoreManager.GetStore("app", "default")
if c.Store == nil {
panic(fmt.Sprintf("No store provided and no default store found for component %s", c.Name))
}
}
}
{
start := time.Now()
defer c.recordRender(time.Since(start))
key := c.cacheKey()
if c.cache != nil {
if val, ok := c.cache[key]; ok {
renderedTemplate = val
return
}
if c.lastCacheKey != "" && c.lastCacheKey != key {
delete(c.cache, c.lastCacheKey)
}
} else {
c.cache = make(map[string]string)
}
defer func() {
if r := recover(); r != nil {
jsStack := js.Error().New().Get("stack").String()
goStack := string(debug.Stack())
panic(fmt.Sprintf("%v\nGo stack:\n%s\nJS stack:\n%s", r, goStack, jsStack))
}
}()
c.unsubscribes.Run()
renderedTemplate = c.Template
renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)
// Extract slot contents destined for child components
renderedTemplate = extractSlotContents(renderedTemplate, c)
// Replace this component's slot placeholders with provided content or fallbacks
renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)
for key, value := range c.Props {
placeholder := fmt.Sprintf("{{%s}}", key)
renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, fmt.Sprintf("%v", value))
}
// Register @include directives that supply inline props
renderedTemplate = replaceComponentIncludes(renderedTemplate, c)
// Handle @include:componentName syntax for dependencies
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle @for loops and legacy @foreach syntax
renderedTemplate = replaceForPlaceholders(renderedTemplate, c)
renderedTemplate = replaceForeachPlaceholders(renderedTemplate, c)
// Handle @store:module.storeName.varName syntax.
// Append :w for writable inputs; read-only inputs omit the suffix (:r is not supported).
renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
// Handle @signal:name syntax for local signals
renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
// Handle @prop:propName syntax for props
renderedTemplate = replacePropPlaceholders(renderedTemplate, c)
// Handle plugin variable and command placeholders
renderedTemplate = replacePluginPlaceholders(renderedTemplate)
// Handle host variable and command placeholders
if c.HostComponent != "" {
renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
}
// Handle @if:condition syntax for conditional rendering
renderedTemplate = replaceConditionals(renderedTemplate, c)
// Handle @on:event:handler and @event:handler syntax for event binding
renderedTemplate = replaceEventHandlers(renderedTemplate)
// Handle rt-is="ComponentName" for dynamic component loading
renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)
// Render any components introduced via rt-is placeholders
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle constructor decorators like [ref] and [key expr]
renderedTemplate = replaceConstructors(renderedTemplate)
if c.HostComponent != "" {
hostclient.RegisterComponent(c.ID, c.HostComponent, c.hostVars)
}
renderedTemplate = minifyInline(renderedTemplate)
c.cache[key] = renderedTemplate
c.lastCacheKey = key
return renderedTemplate
}
{
if c == nil {
return
}
c.metricsMu.Lock()
c.renderCount++
c.totalRender += duration
c.lastRender = duration
c.appendTimelineLocked(ComponentTimelineEntry{
Kind: "render",
Timestamp: time.Now(),
Duration: duration,
})
c.metricsMu.Unlock()
}
{
if entry.Kind == "" {
return
}
if c.timeline == nil {
c.timeline = make([]ComponentTimelineEntry, 0, 8)
}
c.timeline = append(c.timeline, entry)
if len(c.timeline) > componentTimelineLimit {
c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
}
}
Stats returns a snapshot of the component's render metrics.
{
c.metricsMu.Lock()
defer c.metricsMu.Unlock()
stats := ComponentStats{
RenderCount: c.renderCount,
TotalRender: c.totalRender,
LastRender: c.lastRender,
}
if c.renderCount > 0 {
stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
}
if len(c.timeline) > 0 {
stats.Timeline = append(stats.Timeline, c.timeline...)
}
return stats
}
{
if c.Dependencies == nil {
c.Dependencies = make(map[string]Component)
}
if depComp, ok := dep.(*HTMLComponent); ok {
depComp.Init(c.Store)
depComp.parent = c
}
c.Dependencies[placeholderName] = dep
}
{
devUnregisterComponent(c)
if c.component != nil {
c.component.OnUnmount()
}
dom.RemoveEventListeners(c.ID)
dom.RemoveComponentSignals(c.ID)
log.Printf("Unsubscribing %s from all stores", c.Name)
c.unsubscribes.Run()
for _, dep := range c.Dependencies {
dep.Unmount()
}
}
{
for _, dep := range c.Dependencies {
dep.Mount()
}
if c.component != nil {
c.component.OnMount()
}
}
{
return c.Name
}
{
return c.ID
}
GetRef returns the DOM element annotated with a matching constructor
decorator. It searches within this component's root element using the
data-ref attribute injected during template rendering.
{
doc := dom.Doc()
var root dom.Element
if c.ID == "" {
root = doc.ByID("app")
} else {
root = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if root.IsNull() || root.IsUndefined() {
return dom.Element{}
}
return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
{
if c.onMount != nil {
c.onMount(c)
}
}
{
if c.onUnmount != nil {
c.onUnmount(c)
}
}
{
c.onMount = fn
}
{
c.onUnmount = fn
}
{
c.onMount = onMount
c.onUnmount = onUnmount
return c
}
{
c.component = component
}
{
if c.Slots == nil {
c.Slots = make(map[string]any)
}
for k, v := range slots {
c.Slots[k] = v
}
}
Provide stores a value on this component so that descendants can
retrieve it with Inject. It creates the map on first use.
{
if c.provides == nil {
c.provides = make(map[string]any)
}
c.provides[key] = val
}
Inject searches for a provided value starting from this component and
walking up the parent chain. It returns the value as `any` and whether it
was found. Callers can type-assert the result.
{
if c.provides != nil {
if v, ok := c.provides[key]; ok {
return v, true
}
}
if c.parent != nil {
return c.parent.Inject(key)
}
return nil, false
}
{
if c.Props == nil {
c.Props = make(map[string]any)
}
for k, v := range params {
c.Props[k] = v
}
}
AddHostComponent links this HTML component to a server-side HostComponent
by name. When running in SSC mode, messages from the wasm runtime will be
routed to the corresponding host component on the server.
{
c.HostComponent = name
}
{
hasher := sha1.New()
hasher.Write([]byte(serializeProps(c.Props)))
if len(c.Dependencies) > 0 {
deps := make([]string, 0, len(c.Dependencies))
for name, dep := range c.Dependencies {
deps = append(deps, name+dep.GetID())
}
sort.Strings(deps)
for _, d := range deps {
hasher.Write([]byte(d))
}
}
return hex.EncodeToString(hasher.Sum(nil))
}
{}
{}
{ return template }
{}
{}
Logger defines logging interface used by the framework.
{ state.SetLogger(logger) }
SetLogger allows applications to replace the default logger.
{
if l != nil {
logger = l
state.SetLogger(l)
}
}
Log returns the active logger implementation.
{ return logger }
defaultLogger is the fallback logger using the standard log package.
{ log.Printf("DEBUG: "+format, v...) }
{ log.Printf("INFO: "+format, v...) }
{ log.Printf("WARN: "+format, v...) }
{ log.Printf("ERROR: "+format, v...) }
{
state.NewStore("default", state.WithModule("app"))
parentTpl := []byte("<root></root>")
childTpl := []byte("<root></root>")
parent := NewComponent("Parent", parentTpl, nil)
child := NewComponent("Child", childTpl, nil)
parent.Provide("answer", 42)
parent.AddDependency("child", child)
v, ok := Inject[int](child, "answer")
if !ok || v != 42 {
t.Fatalf("expected injected 42, got %v", v)
}
}
AST structures for template parsing
{ return t.Text }
Render evaluates the conditional branches and renders the appropriate content.
{
var conditions []string
for _, br := range cn.Branches {
conditions = append(conditions, br.Condition)
}
conditionID := fmt.Sprintf("cond-%x", sha1.Sum([]byte(strings.Join(conditions, "|"))))
var content ConditionContent
var chosen string
for _, br := range cn.Branches {
var sb strings.Builder
for _, n := range br.Nodes {
sb.WriteString(n.Render(c))
}
branchContent := sb.String()
content.Branches = append(content.Branches, ConditionalBranchContent{Condition: br.Condition, Content: branchContent})
if br.Condition != "" {
result, dependencies := evaluateCondition(br.Condition, c)
for _, dep := range dependencies {
if dep.signal != "" {
if prop, ok := c.Props[dep.signal]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
dom.RegisterSignal(c.ID, dep.signal, sig)
unsub := state.Effect(func() func() {
sig.Read()
updateConditionBindings(c, conditionID)
return nil
})
c.unsubscribes.Add(unsub)
}
}
continue
}
module, storeName, key := dep.module, dep.storeName, dep.key
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
unsubscribe := store.OnChange(key, func(newValue any) {
updateConditionBindings(c, conditionID)
})
c.unsubscribes.Add(unsubscribe)
}
}
if chosen == "" && result {
chosen = branchContent
}
} else if chosen == "" {
chosen = branchContent
}
}
c.conditionContents[conditionID] = content
return fmt.Sprintf(`<div data-condition="%s">%s</div>`, conditionID, chosen)
}
ConditionContent stores rendered content for each branch of a conditional block
{
includeRegex := regexp.MustCompile(`@include:([\w-]+)`)
return includeRegex.ReplaceAllStringFunc(renderedTemplate, func(match string) string {
name := includeRegex.FindStringSubmatch(match)[1]
if dep, ok := c.Dependencies[name]; ok {
return dep.Render()
}
if DevMode {
Log().Warn("component %s missing dependency '%s'", c.Name, name)
}
return match
})
}
replaceComponentIncludes scans for @include directives that supply inline
props using the syntax @include:Component:{key:"value"}. Matching includes
are replaced with standard @include placeholders after instantiating the
component and registering it as a dependency.
{
idx := 0
// Handle includes that may be wrapped in <p> tags produced by Markdown
// renderers as well as bare @include directives.
patterns := []string{
`<p>@include:([\w-]+):\{([^}]*)\}</p>`,
`@include:([\w-]+):\{([^}]*)\}`,
}
for _, pat := range patterns {
re := regexp.MustCompile(pat)
template = re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
name := parts[1]
propStr := html.UnescapeString(parts[2])
comp := LoadComponent(name)
if comp == nil {
if DevMode {
Log().Warn("include referenced unknown component '%s'", name)
}
return match
}
props := map[string]any{}
propRe := regexp.MustCompile(`(\w+):"([^"]*)"`)
for _, m := range propRe.FindAllStringSubmatch(propStr, -1) {
props[m[1]] = m[2]
}
if hc, ok := comp.(*HTMLComponent); ok {
hc.Props = props
hc.ID = generateComponentID(hc.Name, hc.Props)
}
placeholder := fmt.Sprintf("inc-%s-%d", name, idx)
idx++
c.AddDependency(placeholder, comp)
return "@include:" + placeholder
})
}
return template
}
{
slotRegex := regexp.MustCompile(`@slot:(\w+)(?:\.(\w+))?([\s\S]*?)@endslot`)
return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := slotRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
depName := parts[1]
slotName := parts[2]
if slotName == "" {
slotName = "default"
}
content := parts[3]
if dep, ok := c.Dependencies[depName]; ok {
dep.SetSlots(map[string]any{slotName: content})
return ""
}
if DevMode {
Log().Warn("component %s missing dependency '%s' for slot '%s'", c.Name, depName, slotName)
}
return match
})
}
{
slotRegex := regexp.MustCompile(`@slot(?::(\w+))?([\s\S]*?)@endslot`)
idx := 0
return slotRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := slotRegex.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
slotName := parts[1]
if slotName == "" {
slotName = "default"
}
fallback := parts[2]
if content, ok := c.Slots[slotName]; ok {
switch v := content.(type) {
case string:
return v
case Component:
placeholder := fmt.Sprintf("slot-%s-%d", slotName, idx)
idx++
c.AddDependency(placeholder, v)
return fmt.Sprintf("@include:%s", placeholder)
default:
return fallback
}
}
return fallback
})
}
{
storeRegex := regexp.MustCompile(`@store:(\w+)\.(\w+)\.(\w+)(:w)?`)
return storeRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := storeRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
module := parts[1]
storeName := parts[2]
key := parts[3]
isWriteable := len(parts) == 5 && parts[4] == ":w"
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
value := store.Get(key)
if value == nil {
value = ""
}
unsubscribe := store.OnChange(key, func(newValue any) {
updateStoreBindings(c, module, storeName, key, newValue)
})
c.unsubscribes.Add(unsubscribe)
if isWriteable {
return match
} else {
return fmt.Sprintf(`<span data-store="%s.%s.%s">%v</span>`, module, storeName, key, value)
}
}
if DevMode {
Log().Warn("store %s.%s not found for key '%s' in component %s", module, storeName, key, c.Name)
}
return match
})
}
{
sigRegex := regexp.MustCompile(`@signal:(\w+)(:w)?`)
return sigRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := sigRegex.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
name := parts[1]
isWriteable := len(parts) == 3 && parts[2] == ":w"
if prop, ok := c.Props[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
dom.RegisterSignal(c.ID, name, sig)
val := sig.Read()
unsub := state.Effect(func() func() {
v := sig.Read()
updateSignalBindings(c, name, v)
return nil
})
c.unsubscribes.Add(unsub)
if isWriteable {
return match
}
return fmt.Sprintf(`<span data-signal="%s">%v</span>`, name, val)
}
}
if DevMode {
Log().Warn("signal '%s' not found in component %s", name, c.Name)
}
return match
})
}
{
propRegex := regexp.MustCompile(`@prop:(\w+)`)
idx := 0
return propRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := propRegex.FindStringSubmatch(match)
if len(parts) != 2 {
return match
}
propName := parts[1]
if value, exists := c.Props[propName]; exists {
switch v := value.(type) {
case Component:
placeholder := fmt.Sprintf("prop-%s-%d", propName, idx)
idx++
c.AddDependency(placeholder, v)
return fmt.Sprintf("@include:%s", placeholder)
default:
return fmt.Sprintf("%v", v)
}
}
if DevMode {
Log().Warn("component %s missing prop '%s'", c.Name, propName)
}
return match
})
}
{
varRegex := regexp.MustCompile(`\{plugin:(\w+)\.(\w+)\}`)
template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := varRegex.FindStringSubmatch(match)
if len(parts) != 3 {
return match
}
plug, name := parts[1], parts[2]
if v, ok := getRTMLVar(plug, name); ok {
return fmt.Sprintf("%v", v)
}
if DevMode {
Log().Warn("plugin variable %s.%s not found", plug, name)
}
return match
})
cmdRegex := regexp.MustCompile(`@plugin:(\w+)\.(\w+)([\s>/])`)
template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := cmdRegex.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
plug, name, suffix := parts[1], parts[2], parts[3]
return fmt.Sprintf(`data-plugin-cmd="%s.%s"%s`, plug, name, suffix)
})
return template
}
{
varRegex := regexp.MustCompile(`\{h:(\w+)\}`)
template = varRegex.ReplaceAllStringFunc(template, func(match string) string {
name := varRegex.FindStringSubmatch(match)[1]
c.hostVars = append(c.hostVars, name)
expectedVal := ""
if c.Props != nil {
if v, ok := c.Props[name]; ok {
expectedVal = fmt.Sprintf("%v", v)
} else if v, ok := c.Props["h:"+name]; ok {
expectedVal = fmt.Sprintf("%v", v)
}
}
hash := sha1.Sum([]byte(expectedVal))
expectedAttr := fmt.Sprintf("sha1:%x", hash)
return fmt.Sprintf(`<span data-host-var="%s" data-host-expected="%s">%s</span>`,
name, expectedAttr, html.EscapeString(expectedVal))
})
cmdRegex := regexp.MustCompile(`@h:(\w+)`)
template = cmdRegex.ReplaceAllStringFunc(template, func(match string) string {
name := cmdRegex.FindStringSubmatch(match)[1]
c.hostCmds = append(c.hostCmds, name)
return fmt.Sprintf(`data-host-cmd="%s"`, name)
})
return template
}
{
// Match event directives ensuring they are terminated by whitespace,
// a self-closing slash or the end of the tag. The terminating
// character is captured so it can be preserved in the replacement.
eventRegex := regexp.MustCompile(`@(on:)?(\w+(?:\.\w+)*):(\w+)([\s>/])`)
return eventRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := eventRegex.FindStringSubmatch(match)
if len(parts) != 5 {
return match
}
fullEvent := parts[2]
handler := parts[3]
suffix := parts[4]
eventParts := strings.Split(fullEvent, ".")
event := eventParts[0]
modifiers := []string{}
if len(eventParts) > 1 {
modifiers = eventParts[1:]
}
attr := fmt.Sprintf("data-on-%s=\"%s\"", event, handler)
if len(modifiers) > 0 {
attr += fmt.Sprintf(" data-on-%s-modifiers=\"%s\"", event, strings.Join(modifiers, ","))
}
return attr + suffix
})
}
replaceRtIsAttributes scans the template for elements decorated with the
`rt-is` attribute. The attribute's value identifies a component registered in
the ComponentRegistry. Matching elements are replaced with an @include
placeholder so standard include processing can render the referenced
component and manage its lifecycle.
{
re := regexp.MustCompile(`<([a-zA-Z0-9]+)([^>]*)rt-is="([^"]+)"[^>]*/?>`)
idx := 0
return re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
name := parts[3]
comp := LoadComponent(name)
if comp == nil {
if DevMode {
Log().Warn("rt-is referenced unknown component '%s'", name)
}
return match
}
placeholder := fmt.Sprintf("rtis-%s-%d", name, idx)
idx++
c.AddDependency(placeholder, comp)
return fmt.Sprintf("@include:%s", placeholder)
})
}
parseTemplate parses the template string into an AST of nodes.
{
lines := strings.Split(template, "\n")
idx := 0
return parseBlock(lines, &idx)
}
{
var nodes []Node
for *idx < len(lines) {
line := lines[*idx]
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "@if:"):
cond := trimmed
*idx++
n, err := parseConditional(lines, idx, cond)
if err != nil {
return nil, err
}
nodes = append(nodes, n)
case strings.HasPrefix(trimmed, "@else-if:"), trimmed == "@else", trimmed == "@endif":
return nodes, nil
default:
nodes = append(nodes, &TextNode{Text: line + "\n"})
*idx++
}
}
return nodes, nil
}
{
node := &ConditionalNode{}
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: firstCond, Nodes: children})
for *idx < len(lines) {
trimmed := strings.TrimSpace(lines[*idx])
switch {
case strings.HasPrefix(trimmed, "@else-if:"):
cond := trimmed
*idx++
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: cond, Nodes: children})
case trimmed == "@else":
*idx++
children, err := parseBlock(lines, idx)
if err != nil {
return nil, err
}
node.Branches = append(node.Branches, ConditionalBranch{Condition: "", Nodes: children})
case trimmed == "@endif":
*idx++
return node, nil
default:
*idx++
}
}
return node, nil
}
replaceConditionals parses conditionals using the AST and renders them.
{
nodes, err := parseTemplate(template)
if err != nil {
return template
}
var sb strings.Builder
for _, n := range nodes {
sb.WriteString(n.Render(c))
}
return sb.String()
}
{
Log().Debug("Evaluating condition: '%s'", condition)
conditionParts := strings.Split(condition, "==")
if len(conditionParts) != 2 {
Log().Debug("Condition format is invalid. Expected '=='.")
return false, nil
}
leftSide := strings.TrimSpace(conditionParts[0])
leftSide = strings.Replace(leftSide, "@if:", "", 1)
leftSide = strings.Replace(leftSide, "@else-if:", "", 1)
expectedValue := strings.ReplaceAll(conditionParts[1], `"`, "")
expectedValue = strings.TrimSpace(expectedValue)
Log().Debug("Left side: '%s', Expected value: '%s'", leftSide, expectedValue)
dependencies := []ConditionDependency{}
if strings.HasPrefix(leftSide, "store:") {
storeParts := strings.Split(strings.TrimPrefix(leftSide, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
Log().Debug("Dependency detected: Module '%s', Store '%s', Key '%s'", module, storeName, key)
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
dependencies = append(dependencies, ConditionDependency{module: module, storeName: storeName, key: key})
actualValue := fmt.Sprintf("%v", store.Get(key))
Log().Debug("Actual value from store: '%s'", actualValue)
return actualValue == expectedValue, dependencies
} else {
Log().Debug("Store '%s' in module '%s' not found.", storeName, module)
}
} else {
Log().Debug("Store parts length is not 3.")
}
}
if strings.HasPrefix(leftSide, "signal:") {
sigName := strings.TrimPrefix(leftSide, "signal:")
if prop, ok := c.Props[sigName]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
dependencies = append(dependencies, ConditionDependency{signal: sigName})
actualValue := fmt.Sprintf("%v", sig.Read())
return actualValue == expectedValue, dependencies
}
}
}
if strings.HasPrefix(leftSide, "prop:") {
propName := strings.TrimPrefix(leftSide, "prop:")
if value, exists := c.Props[propName]; exists {
actualValue := fmt.Sprintf("%v", value)
Log().Debug("Actual value from props: '%s'", actualValue)
return actualValue == expectedValue, dependencies
}
}
Log().Debug("No dependencies detected.")
return false, dependencies
}
{
doc := dom.Doc()
var element dom.Element
if c.ID == "" {
element = doc.ByID("app")
} else {
element = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-store="%s.%s.%s"]`, module, storeName, key)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
node.Set("innerHTML", fmt.Sprintf("%v", newValue))
}
placeholder := fmt.Sprintf("@store:%s.%s.%s:w", module, storeName, key)
// Update value-based inputs and selects
inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
inputs := element.Call("querySelectorAll", inputSelector)
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
input.Set("value", fmt.Sprintf("%v", newValue))
}
// Update checkboxes bound via checked attribute
checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
checks := element.Call("querySelectorAll", checkedSelector)
for i := 0; i < checks.Length(); i++ {
chk := checks.Index(i)
switch v := newValue.(type) {
case bool:
chk.Set("checked", v)
case string:
chk.Set("checked", strings.ToLower(v) == "true")
default:
chk.Set("checked", newValue != nil)
}
}
// Update textareas where placeholder is in content
textareas := element.Call("querySelectorAll", "textarea")
for i := 0; i < textareas.Length(); i++ {
ta := textareas.Index(i)
if ta.Get("value").String() == placeholder {
ta.Set("value", fmt.Sprintf("%v", newValue))
}
}
updateConditionsForStoreVariable(c, module, storeName, key)
}
{
doc := dom.Doc()
var element dom.Element
if c.ID == "" {
element = doc.ByID("app")
} else {
element = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-signal="%s"]`, name)
nodes := element.Call("querySelectorAll", selector)
for i := 0; i < nodes.Length(); i++ {
node := nodes.Index(i)
node.Set("innerHTML", fmt.Sprintf("%v", newValue))
}
placeholder := fmt.Sprintf("@signal:%s:w", name)
// Update value-based inputs and selects
inputSelector := fmt.Sprintf(`input[value="%s"], select[value="%s"]`, placeholder, placeholder)
inputs := element.Call("querySelectorAll", inputSelector)
for i := 0; i < inputs.Length(); i++ {
input := inputs.Index(i)
input.Set("value", fmt.Sprintf("%v", newValue))
}
// Update checkboxes
checkedSelector := fmt.Sprintf(`input[checked="%s"]`, placeholder)
checks := element.Call("querySelectorAll", checkedSelector)
for i := 0; i < checks.Length(); i++ {
chk := checks.Index(i)
switch v := newValue.(type) {
case bool:
chk.Set("checked", v)
case string:
chk.Set("checked", strings.ToLower(v) == "true")
default:
chk.Set("checked", newValue != nil)
}
}
// Update textareas with placeholder in content
textareas := element.Call("querySelectorAll", "textarea")
for i := 0; i < textareas.Length(); i++ {
ta := textareas.Index(i)
if ta.Get("value").String() == placeholder {
ta.Set("value", fmt.Sprintf("%v", newValue))
}
}
}
{
tagRegex := regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9-]*)`)
loc := tagRegex.FindStringSubmatchIndex(content)
if loc == nil {
return content
}
return content[:loc[1]] + fmt.Sprintf(` data-key="%v"`, key) + content[loc[1]:]
}
replaceConstructors scans for inline constructor tokens inside an element's
start tag and injects the corresponding data attribute. Supported
constructors:
[name] -> data-ref="name"
[key expr] -> data-key="expr"
Only a single constructor per element is handled.
{
re := regexp.MustCompile(`<([a-zA-Z][\w-]*)([^>]*?)\s\[([^\] ]+)(?:\s+([^\]]+))?\]([^>]*)>`)
return re.ReplaceAllStringFunc(template, func(match string) string {
parts := re.FindStringSubmatch(match)
if len(parts) < 6 {
return match
}
tag := parts[1]
before := parts[2]
name := parts[3]
param := parts[4]
after := parts[5]
attr := ""
if name == "key" && param != "" {
attr = fmt.Sprintf(` data-key="%s"`, param)
} else if strings.HasPrefix(name, "plugin:") {
attr = fmt.Sprintf(` data-plugin="%s"`, strings.TrimPrefix(name, "plugin:"))
} else {
attr = fmt.Sprintf(` data-ref="%s"`, name)
}
return fmt.Sprintf("<%s%s%s%s>", tag, before, attr, after)
})
}
{
if n, err := strconv.Atoi(expr); err == nil {
return n, nil
}
if strings.HasPrefix(expr, "store:") {
parts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(parts) == 3 {
module, storeName, key := parts[0], parts[1], parts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
if val := store.Get(key); val != nil {
unsubscribe := store.OnChange(key, func(newValue any) {
dom.UpdateDOM(c.ID, c.Render())
})
c.unsubscribes.Add(unsubscribe)
switch v := val.(type) {
case int:
return v, nil
case float64:
return int(v), nil
case string:
return strconv.Atoi(v)
}
}
}
}
}
if val, ok := c.Props[expr]; ok {
switch v := val.(type) {
case int:
return v, nil
case float64:
return int(v), nil
case string:
return strconv.Atoi(v)
}
}
return 0, fmt.Errorf("invalid number")
}
{
forRegex := regexp.MustCompile(`@for:(\w+(?:,\w+)?)\s+in\s+(\S+)([\s\S]*?)@endfor`)
return forRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := forRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
varsPart := parts[1]
expr := parts[2]
loopContent := parts[3]
aliases := strings.Split(varsPart, ",")
for i := range aliases {
aliases[i] = strings.TrimSpace(aliases[i])
}
if strings.Contains(expr, "..") {
rangeParts := strings.Split(expr, "..")
if len(rangeParts) != 2 {
return match
}
start, err := resolveNumber(rangeParts[0], c)
if err != nil {
return match
}
end, err := resolveNumber(rangeParts[1], c)
if err != nil {
return match
}
var result strings.Builder
for i := start; i <= end; i++ {
iter := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", aliases[0]), fmt.Sprintf("%d", i))
iter = insertDataKey(iter, i)
result.WriteString(iter)
}
return result.String()
}
var collection any
if strings.HasPrefix(expr, "store:") {
storeParts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
collection = store.Get(key)
unsubscribe := store.OnChange(key, func(newValue any) {
dom.UpdateDOM(c.ID, c.Render())
})
c.unsubscribes.Add(unsubscribe)
} else {
return match
}
} else {
return match
}
} else if val, ok := c.Props[expr]; ok {
collection = val
} else {
return match
}
switch col := collection.(type) {
case []any:
var result strings.Builder
alias := aliases[0]
for idx, item := range col {
iterContent := loopContent
if itemMap, ok := item.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\.(\w+)`, alias))
iterContent = fieldRegex.ReplaceAllStringFunc(iterContent, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := itemMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("%v", item))
}
iterContent = insertDataKey(iterContent, idx)
result.WriteString(iterContent)
}
return result.String()
case map[string]any:
keyAlias := aliases[0]
valAlias := keyAlias
if len(aliases) > 1 {
valAlias = aliases[1]
}
keys := make([]string, 0, len(col))
for k := range col {
keys = append(keys, k)
}
sort.Strings(keys)
var result strings.Builder
for _, k := range keys {
v := col[k]
iterContent := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", keyAlias), k)
if len(aliases) > 1 {
if vMap, ok := v.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\.(\w+)`, valAlias))
iterContent = fieldRegex.ReplaceAllStringFunc(iterContent, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := vMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), fmt.Sprintf("%v", v))
}
}
iterContent = insertDataKey(iterContent, k)
result.WriteString(iterContent)
}
return result.String()
default:
return match
}
})
}
{
foreachRegex := regexp.MustCompile(`@foreach:(\S+)\s+as\s+(\w+)([\s\S]*?)@endforeach`)
return foreachRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := foreachRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
expr := parts[1]
alias := parts[2]
content := parts[3]
foreachID := fmt.Sprintf("foreach-%x", sha1.Sum([]byte(match)))
c.foreachContents[foreachID] = ForeachConfig{Expr: expr, ItemAlias: alias, Content: content}
if strings.HasPrefix(expr, "store:") {
storeParts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
unsubscribe := store.OnChange(key, func(newValue any) {
updateForeachBindings(c, foreachID)
})
c.unsubscribes.Add(unsubscribe)
}
}
} else {
name := strings.TrimPrefix(expr, "signal:")
if prop, ok := c.Props[name]; ok {
if sig, ok := prop.(interface{ Read() any }); ok {
dom.RegisterSignal(c.ID, name, sig)
unsub := state.Effect(func() func() {
sig.Read()
updateForeachBindings(c, foreachID)
return nil
})
c.unsubscribes.Add(unsub)
}
}
}
rendered := renderForeachLoop(c, expr, alias, content)
return fmt.Sprintf(`<div data-foreach="%s">%s</div>`, foreachID, rendered)
})
}
{
var collection any
if strings.HasPrefix(expr, "store:") {
parts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(parts) == 3 {
module, storeName, key := parts[0], parts[1], parts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
collection = store.Get(key)
}
}
} else {
name := strings.TrimPrefix(expr, "signal:")
if val, ok := c.Props[name]; ok {
if sig, ok := val.(interface{ Read() any }); ok {
collection = sig.Read()
} else {
collection = val
}
}
}
val := reflect.ValueOf(collection)
if !val.IsValid() {
return ""
}
switch val.Kind() {
case reflect.Slice, reflect.Array:
var result strings.Builder
for i := 0; i < val.Len(); i++ {
item := val.Index(i).Interface()
iter := content
if itemMap, ok := item.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf(`@%s\\.(\\w+)`, alias))
iter = fieldRegex.ReplaceAllStringFunc(iter, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := itemMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
propFieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\\.(\\w+)`, alias))
iter = propFieldRegex.ReplaceAllStringFunc(iter, func(fieldMatch string) string {
fieldParts := propFieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := itemMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
}
iter = strings.ReplaceAll(iter, fmt.Sprintf("@%s", alias), fmt.Sprintf("%v", item))
iter = strings.ReplaceAll(iter, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("%v", item))
result.WriteString(iter)
}
return result.String()
case reflect.Map:
if val.Type().Key().Kind() != reflect.String {
return ""
}
var result strings.Builder
keys := val.MapKeys()
sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() })
for _, k := range keys {
v := val.MapIndex(k).Interface()
iter := content
if vMap, ok := v.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf(`@%s\\.(\\w+)`, alias))
iter = fieldRegex.ReplaceAllStringFunc(iter, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := vMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
propFieldRegex := regexp.MustCompile(fmt.Sprintf(`@prop:%s\\.(\\w+)`, alias))
iter = propFieldRegex.ReplaceAllStringFunc(iter, func(fieldMatch string) string {
fieldParts := propFieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := vMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
}
iter = strings.ReplaceAll(iter, fmt.Sprintf("@%s", alias), fmt.Sprintf("%v", v))
iter = strings.ReplaceAll(iter, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("%v", v))
result.WriteString(iter)
}
return result.String()
default:
return ""
}
}
{
doc := dom.Doc()
var element dom.Element
if c.ID == "" {
element = doc.ByID("app")
} else {
element = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-foreach="%s"]`, foreachID)
node := element.Call("querySelector", selector)
if node.IsNull() || node.IsUndefined() {
return
}
cfg := c.foreachContents[foreachID]
newContent := renderForeachLoop(c, cfg.Expr, cfg.ItemAlias, cfg.Content)
node.Set("innerHTML", newContent)
dom.BindStoreInputsForComponent(c.ID, node)
dom.BindSignalInputs(c.ID, node)
}
{
doc := dom.Doc()
var element dom.Element
if c.ID == "" {
element = doc.ByID("app")
} else {
element = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if element.IsNull() || element.IsUndefined() {
return
}
selector := fmt.Sprintf(`[data-condition="%s"]`, conditionID)
node := element.Call("querySelector", selector)
if node.IsNull() || node.IsUndefined() {
return
}
conditionContent := c.conditionContents[conditionID]
var newContent string
for _, br := range conditionContent.Branches {
if br.Condition == "" {
if newContent == "" {
newContent = br.Content
}
continue
}
result, _ := evaluateCondition(br.Condition, c)
if result {
newContent = br.Content
break
}
}
node.Set("innerHTML", newContent)
dom.BindStoreInputsForComponent(c.ID, node)
dom.BindSignalInputs(c.ID, node)
}
{
for conditionID, content := range c.conditionContents {
for _, br := range content.Branches {
if br.Condition == "" {
continue
}
dependencies, _ := getConditionDependencies(br.Condition)
for _, dep := range dependencies {
if dep.module == module && dep.storeName == storeName && dep.key == key {
updateConditionBindings(c, conditionID)
break
}
}
}
}
}
{
conditionParts := strings.Split(condition, "==")
if len(conditionParts) != 2 {
return nil, fmt.Errorf("Invalid condition format")
}
leftSide := strings.TrimSpace(conditionParts[0])
leftSide = strings.Replace(leftSide, "@if:", "", 1)
leftSide = strings.Replace(leftSide, "@else-if:", "", 1)
dependencies := []ConditionDependency{}
if strings.HasPrefix(leftSide, "store:") {
storeParts := strings.Split(strings.TrimPrefix(leftSide, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
dependencies = append(dependencies, ConditionDependency{module: module, storeName: storeName, key: key})
}
}
return dependencies, nil
}
{
forRegex := regexp.MustCompile(`@for:(\w+(?:,\w+)?)\s+in\s+(\S+)([\s\S]*?)@endfor`)
return forRegex.ReplaceAllStringFunc(template, func(match string) string {
parts := forRegex.FindStringSubmatch(match)
if len(parts) < 4 {
return match
}
varsPart := parts[1]
expr := parts[2]
loopContent := parts[3]
aliases := strings.Split(varsPart, ",")
for i := range aliases {
aliases[i] = strings.TrimSpace(aliases[i])
}
if strings.Contains(expr, "..") {
rangeParts := strings.Split(expr, "..")
if len(rangeParts) != 2 {
return match
}
start, err := resolveNumber(rangeParts[0], c)
if err != nil {
return match
}
end, err := resolveNumber(rangeParts[1], c)
if err != nil {
return match
}
var result strings.Builder
for i := start; i <= end; i++ {
iter := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", aliases[0]), fmt.Sprintf("%d", i))
iter = insertDataKey(iter, i)
result.WriteString(iter)
}
return result.String()
}
var collection any
if strings.HasPrefix(expr, "store:") {
storeParts := strings.Split(strings.TrimPrefix(expr, "store:"), ".")
if len(storeParts) == 3 {
module, storeName, key := storeParts[0], storeParts[1], storeParts[2]
store := state.GlobalStoreManager.GetStore(module, storeName)
if store != nil {
collection = store.Get(key)
unsubscribe := store.OnChange(key, func(newValue any) {
dom.UpdateDOM(c.ID, c.Render())
})
c.unsubscribes.Add(unsubscribe)
} else {
return match
}
} else {
return match
}
} else if val, ok := c.Props[expr]; ok {
collection = val
} else {
return match
}
switch col := collection.(type) {
case []Component:
tmp := make([]any, len(col))
for i, v := range col {
tmp[i] = v
}
collection = tmp
case []*HTMLComponent:
tmp := make([]any, len(col))
for i, v := range col {
tmp[i] = v
}
collection = tmp
case map[string]Component:
tmp := make(map[string]any, len(col))
for k, v := range col {
tmp[k] = v
}
collection = tmp
case map[string]*HTMLComponent:
tmp := make(map[string]any, len(col))
for k, v := range col {
tmp[k] = v
}
collection = tmp
}
switch col := collection.(type) {
case []any:
var result strings.Builder
alias := aliases[0]
for idx, item := range col {
iterContent := loopContent
if comp, ok := item.(Component); ok {
placeholder := fmt.Sprintf("for-%s-%d", alias, idx)
c.AddDependency(placeholder, comp)
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("@include:%s", placeholder))
} else if itemMap, ok := item.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf("@prop:%s\\.(\\w+)", alias))
iterContent = fieldRegex.ReplaceAllStringFunc(iterContent, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := itemMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", alias), fmt.Sprintf("%v", item))
}
iterContent = insertDataKey(iterContent, idx)
result.WriteString(iterContent)
}
return result.String()
case map[string]any:
keyAlias := aliases[0]
valAlias := keyAlias
if len(aliases) > 1 {
valAlias = aliases[1]
}
keys := make([]string, 0, len(col))
for k := range col {
keys = append(keys, k)
}
sort.Strings(keys)
var result strings.Builder
for idx, k := range keys {
v := col[k]
iterContent := strings.ReplaceAll(loopContent, fmt.Sprintf("@prop:%s", keyAlias), k)
if len(aliases) > 1 {
if vMap, ok := v.(map[string]any); ok {
fieldRegex := regexp.MustCompile(fmt.Sprintf("@prop:%s\\.(\\w+)", valAlias))
iterContent = fieldRegex.ReplaceAllStringFunc(iterContent, func(fieldMatch string) string {
fieldParts := fieldRegex.FindStringSubmatch(fieldMatch)
if len(fieldParts) == 2 {
if fieldValue, exists := vMap[fieldParts[1]]; exists {
return fmt.Sprintf("%v", fieldValue)
}
}
return fieldMatch
})
} else if comp, ok := v.(Component); ok {
placeholder := fmt.Sprintf("for-%s-%d", valAlias, idx)
c.AddDependency(placeholder, comp)
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), fmt.Sprintf("@include:%s", placeholder))
} else {
iterContent = strings.ReplaceAll(iterContent, fmt.Sprintf("@prop:%s", valAlias), fmt.Sprintf("%v", v))
}
}
iterContent = insertDataKey(iterContent, k)
result.WriteString(iterContent)
}
return result.String()
default:
return match
}
})
}
{
childTpl := []byte("<root>@slot:avatar<div>default</div>@endslot</root>")
parentTpl := []byte("<root>@slot:child.avatar<img src=\"pic.png\"/>@endslot@include:child</root>")
store := state.NewStore("test")
parent := NewHTMLComponent("Parent", parentTpl, nil)
parent.Init(store)
child := NewHTMLComponent("Child", childTpl, nil)
// child Init will be called via AddDependency
parent.AddDependency("child", child)
html := parent.Render()
if strings.Contains(html, ".avatar") {
t.Fatalf("slot placeholder not removed: %s", html)
}
if !strings.Contains(html, "pic.png") {
t.Fatalf("slot content not injected: %s", html)
}
}
{
childTpl := []byte("<root>@slot:avatar<div>fallback-avatar</div>@endslot<div>@slot<p>fallback-details</p>@endslot</div></root>")
parentTpl := []byte("<root>@slot:card.avatar<img/>@endslot@slot:card<p>details</p>@endslot@include:card@include:cardFallback</root>")
store := state.NewStore("test2")
parent := NewHTMLComponent("Parent2", parentTpl, nil)
parent.Init(store)
card := NewHTMLComponent("Child", childTpl, nil)
fallback := NewHTMLComponent("Child", childTpl, nil)
parent.AddDependency("card", card)
parent.AddDependency("cardFallback", fallback)
html := parent.Render()
if strings.Count(html, "<img/>") != 1 {
t.Fatalf("expected one image only: %s", html)
}
if !strings.Contains(html, "fallback-avatar") || !strings.Contains(html, "fallback-details") {
t.Fatalf("fallback content missing: %s", html)
}
}
Suspense renders a fallback while the render function returns http.ErrPending.
Render executes the render function and shows the fallback until it resolves.
{
if s.render == nil {
return s.fallback
}
content, err := s.render()
if err != nil {
if errors.Is(err, http.ErrPending) {
return s.fallback
}
return err.Error()
}
return content
}
Mount is a no-op for Suspense.
{}
Unmount is a no-op for Suspense.
{}
OnMount is a no-op for Suspense.
{}
OnUnmount is a no-op for Suspense.
{}
GetName returns the component name.
{ return "Suspense" }
GetID returns an empty ID for Suspense.
{ return "" }
SetSlots is a no-op since Suspense does not use slots.
{}
NewSuspense creates a Suspense component with the given render function and fallback HTML.
{
return &Suspense{render: render, fallback: fallback}
}
{
componentRegistryMu.Lock()
ComponentRegistry = map[string]func() Component{}
componentRegistryMu.Unlock()
const n = 100
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func(i int) {
defer wg.Done()
name := fmt.Sprintf("comp-%d", i)
if err := RegisterComponent(name, func() Component { return noopComponent{} }); err != nil {
t.Errorf("register %s: %v", name, err)
}
if c := LoadComponent(name); c == nil {
t.Errorf("load %s: got nil", name)
}
}(i)
}
wg.Wait()
}
{}
{}
{ return template }
{}
{}
{ panic("boom") }
{}
{}
{}
{}
{ return "panic" }
{ return "panic" }
{}
{
eb := NewErrorBoundary(&panicComponent{}, "<div>fb</div>")
html := eb.Render()
expected := "<root data-component-id=\"panic\"><div>fb</div></root>"
if html != expected {
t.Fatalf("expected %s, got %s", expected, html)
}
}
{
state.NewStore("default", state.WithModule("app"))
childTpl1 := []byte("<root><p>first</p></root>")
childTpl2 := []byte("<root><p>second</p></root>")
child1 := NewComponent("Child1", childTpl1, nil)
child2 := NewComponent("Child2", childTpl2, nil)
parentTpl := []byte("<root>@for:item in items @prop:item @endfor</root>")
parent := NewComponent("Parent", parentTpl, map[string]any{"items": []Component{child1, child2}})
html := parent.Render()
if !strings.Contains(html, "first") || !strings.Contains(html, "second") {
t.Fatalf("expected child components rendered: %s", html)
}
}
{
state.NewStore("default", state.WithModule("app"))
items := []any{
map[string]any{"name": "Mario", "age": 30},
map[string]any{"name": "Luigi", "age": 25},
}
parentTpl := []byte("<root>@for:item in items <p><b>Name:</b> @prop:item.name <b>Age:</b> @prop:item.age</p> @endfor</root>")
parent := NewComponent("Parent", parentTpl, map[string]any{"items": items})
html := parent.Render()
if !strings.Contains(html, "Mario") || !strings.Contains(html, "Luigi") {
t.Fatalf("expected names rendered: %s", html)
}
if strings.Contains(html, "@prop:item.name") || strings.Contains(html, "@prop:item.age") {
t.Fatalf("placeholders not replaced: %s", html)
}
}
{ u.funcs = append(u.funcs, fn) }
{
for _, fn := range u.funcs {
fn()
}
u.funcs = nil
}
Stats returns zeroed metrics on non-wasm builds.
{ return ComponentStats{} }
{
template, err := LoadComponentTemplate(c.TemplateFS)
if err != nil {
panic(fmt.Sprintf("Error loading template for component %s: %v", c.Name, err))
}
template = devOverrideTemplate(c, template)
c.Template = template
dom.RegisterBindings(c.ID, c.Name, template)
devRegisterComponent(c)
if store != nil {
c.Store = store
} else {
c.Store = state.GlobalStoreManager.GetStore("app", "default")
if c.Store == nil {
panic(fmt.Sprintf("No store provided and no default store found for component %s", c.Name))
}
}
}
{
start := time.Now()
defer c.recordRender(time.Since(start))
key := c.cacheKey()
if c.cache != nil {
if val, ok := c.cache[key]; ok {
renderedTemplate = val
return
}
if c.lastCacheKey != "" && c.lastCacheKey != key {
delete(c.cache, c.lastCacheKey)
}
} else {
c.cache = make(map[string]string)
}
defer func() {
if r := recover(); r != nil {
jsStack := js.Error().New().Get("stack").String()
goStack := string(debug.Stack())
panic(fmt.Sprintf("%v\nGo stack:\n%s\nJS stack:\n%s", r, goStack, jsStack))
}
}()
c.unsubscribes.Run()
renderedTemplate = c.Template
renderedTemplate = strings.Replace(renderedTemplate, "<root", fmt.Sprintf("<root data-component-id=\"%s\"", c.ID), 1)
// Extract slot contents destined for child components
renderedTemplate = extractSlotContents(renderedTemplate, c)
// Replace this component's slot placeholders with provided content or fallbacks
renderedTemplate = replaceSlotPlaceholders(renderedTemplate, c)
for key, value := range c.Props {
placeholder := fmt.Sprintf("{{%s}}", key)
renderedTemplate = strings.ReplaceAll(renderedTemplate, placeholder, fmt.Sprintf("%v", value))
}
// Register @include directives that supply inline props
renderedTemplate = replaceComponentIncludes(renderedTemplate, c)
// Handle @include:componentName syntax for dependencies
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle @for loops and legacy @foreach syntax
renderedTemplate = replaceForPlaceholders(renderedTemplate, c)
renderedTemplate = replaceForeachPlaceholders(renderedTemplate, c)
// Handle @store:module.storeName.varName syntax.
// Append :w for writable inputs; read-only inputs omit the suffix (:r is not supported).
renderedTemplate = replaceStorePlaceholders(renderedTemplate, c)
// Handle @signal:name syntax for local signals
renderedTemplate = replaceSignalPlaceholders(renderedTemplate, c)
// Handle @prop:propName syntax for props
renderedTemplate = replacePropPlaceholders(renderedTemplate, c)
// Handle plugin variable and command placeholders
renderedTemplate = replacePluginPlaceholders(renderedTemplate)
// Handle host variable and command placeholders
if c.HostComponent != "" {
renderedTemplate = replaceHostPlaceholders(renderedTemplate, c)
}
// Handle @if:condition syntax for conditional rendering
renderedTemplate = replaceConditionals(renderedTemplate, c)
// Handle @on:event:handler and @event:handler syntax for event binding
renderedTemplate = replaceEventHandlers(renderedTemplate)
// Handle rt-is="ComponentName" for dynamic component loading
renderedTemplate = replaceRtIsAttributes(renderedTemplate, c)
// Render any components introduced via rt-is placeholders
renderedTemplate = replaceIncludePlaceholders(c, renderedTemplate)
// Handle constructor decorators like [ref] and [key expr]
renderedTemplate = replaceConstructors(renderedTemplate)
if c.HostComponent != "" {
hostclient.RegisterComponent(c.ID, c.HostComponent, c.hostVars)
}
renderedTemplate = minifyInline(renderedTemplate)
c.cache[key] = renderedTemplate
c.lastCacheKey = key
return renderedTemplate
}
{
if c == nil {
return
}
c.metricsMu.Lock()
c.renderCount++
c.totalRender += duration
c.lastRender = duration
c.appendTimelineLocked(ComponentTimelineEntry{
Kind: "render",
Timestamp: time.Now(),
Duration: duration,
})
c.metricsMu.Unlock()
}
{
if entry.Kind == "" {
return
}
if c.timeline == nil {
c.timeline = make([]ComponentTimelineEntry, 0, 8)
}
c.timeline = append(c.timeline, entry)
if len(c.timeline) > componentTimelineLimit {
c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
}
}
Stats returns a snapshot of the component's render metrics.
{
c.metricsMu.Lock()
defer c.metricsMu.Unlock()
stats := ComponentStats{
RenderCount: c.renderCount,
TotalRender: c.totalRender,
LastRender: c.lastRender,
}
if c.renderCount > 0 {
stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
}
if len(c.timeline) > 0 {
stats.Timeline = append(stats.Timeline, c.timeline...)
}
return stats
}
{
if c.Dependencies == nil {
c.Dependencies = make(map[string]Component)
}
if depComp, ok := dep.(*HTMLComponent); ok {
depComp.Init(c.Store)
depComp.parent = c
}
c.Dependencies[placeholderName] = dep
}
{
devUnregisterComponent(c)
if c.component != nil {
c.component.OnUnmount()
}
dom.RemoveEventListeners(c.ID)
dom.RemoveComponentSignals(c.ID)
log.Printf("Unsubscribing %s from all stores", c.Name)
c.unsubscribes.Run()
for _, dep := range c.Dependencies {
dep.Unmount()
}
}
{
for _, dep := range c.Dependencies {
dep.Mount()
}
if c.component != nil {
c.component.OnMount()
}
}
{
return c.Name
}
{
return c.ID
}
GetRef returns the DOM element annotated with a matching constructor
decorator. It searches within this component's root element using the
data-ref attribute injected during template rendering.
{
doc := dom.Doc()
var root dom.Element
if c.ID == "" {
root = doc.ByID("app")
} else {
root = doc.Query(fmt.Sprintf("[data-component-id='%s']", c.ID))
}
if root.IsNull() || root.IsUndefined() {
return dom.Element{}
}
return root.Query(fmt.Sprintf(`[data-ref="%s"]`, name))
}
{
if c.onMount != nil {
c.onMount(c)
}
}
{
if c.onUnmount != nil {
c.onUnmount(c)
}
}
{
c.onMount = fn
}
{
c.onUnmount = fn
}
{
c.onMount = onMount
c.onUnmount = onUnmount
return c
}
{
c.component = component
}
{
if c.Slots == nil {
c.Slots = make(map[string]any)
}
for k, v := range slots {
c.Slots[k] = v
}
}
Provide stores a value on this component so that descendants can
retrieve it with Inject. It creates the map on first use.
{
if c.provides == nil {
c.provides = make(map[string]any)
}
c.provides[key] = val
}
Inject searches for a provided value starting from this component and
walking up the parent chain. It returns the value as `any` and whether it
was found. Callers can type-assert the result.
{
if c.provides != nil {
if v, ok := c.provides[key]; ok {
return v, true
}
}
if c.parent != nil {
return c.parent.Inject(key)
}
return nil, false
}
{
if c.Props == nil {
c.Props = make(map[string]any)
}
for k, v := range params {
c.Props[k] = v
}
}
AddHostComponent links this HTML component to a server-side HostComponent
by name. When running in SSC mode, messages from the wasm runtime will be
routed to the corresponding host component on the server.
{
c.HostComponent = name
}
{
hasher := sha1.New()
hasher.Write([]byte(serializeProps(c.Props)))
if len(c.Dependencies) > 0 {
deps := make([]string, 0, len(c.Dependencies))
for name, dep := range c.Dependencies {
deps = append(deps, name+dep.GetID())
}
sort.Strings(deps)
for _, d := range deps {
hasher.Write([]byte(d))
}
}
return hex.EncodeToString(hasher.Sum(nil))
}
ComponentStats contains aggregated render metrics for an HTML component.
ComponentTimelineEntry represents a point-in-time event collected for diagnostics.
{
id := generateComponentID(name, props)
c := &HTMLComponent{
ID: id,
Name: name,
TemplateFS: templateFs,
Dependencies: make(map[string]Component),
Props: props,
Slots: make(map[string]any),
conditionContents: make(map[string]ConditionContent),
foreachContents: make(map[string]ForeachConfig),
}
// Attempt automatic cleanup when component is garbage collected.
runtime.SetFinalizer(c, func(hc *HTMLComponent) { hc.Unmount() })
return c
}
{
inlineMinifierOnce.Do(func() {
inlineMinifier = minify.New()
inlineMinifier.AddFunc("text/javascript", tdJs.Minify)
inlineMinifier.AddFunc("text/css", css.Minify)
})
return inlineRe.ReplaceAllStringFunc(src, func(match string) string {
m := inlineRe.FindStringSubmatch(match)
tag, attrs, code := m[1], m[2], m[3]
media := "text/javascript"
if tag == "style" {
media = "text/css"
}
out, err := inlineMinifier.String(media, code)
if err != nil {
return match
}
return fmt.Sprintf("<%s%s>%s</%s>", tag, attrs, strings.TrimSpace(out), tag)
})
}
InjectTyped is a helper that performs a typed injection using generics.
It calls c.Inject and attempts to cast the value to T.
{
v, ok := c.Inject(key)
if !ok {
var zero T
return zero, false
}
t, ok := v.(T)
return t, ok
}
{
hasher := sha1.New()
hasher.Write([]byte(name))
propsString := serializeProps(props)
hasher.Write([]byte(propsString))
return hex.EncodeToString(hasher.Sum(nil))
}
{
if props == nil {
return ""
}
var sb strings.Builder
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := props[k]
sb.WriteString(fmt.Sprintf("%s=%v;", k, v))
}
return sb.String()
}
{
template := string(templateFs)
if template == "" {
return "", fmt.Errorf("template is empty")
}
return template, nil
}
RegisterComponent registers a component constructor for lookup by name. It
returns an error if a component with the same name has already been
registered and logs a warning.
{
componentRegistryMu.Lock()
defer componentRegistryMu.Unlock()
if _, exists := ComponentRegistry[name]; exists {
Log().Warn("component %s already registered", name)
return fmt.Errorf("component %s already registered", name)
}
ComponentRegistry[name] = constructor
return nil
}
LoadComponent retrieves a component constructor by name. If no component is
registered under that name, nil is returned.
{
componentRegistryMu.RLock()
ctor, ok := ComponentRegistry[name]
componentRegistryMu.RUnlock()
if ok {
return ctor()
}
return nil
}
MustRegisterComponent registers a component constructor under the provided name
and panics if the component is already registered.
{
if err := RegisterComponent(name, ctor); err != nil {
panic(err)
}
}
import "github.com/rfwlab/rfw/v1/dom"
import "time"
import "encoding/json"
import "github.com/rfwlab/rfw/v1/dom"
import "github.com/rfwlab/rfw/v1/state"
import "encoding/json"
import "testing"
import "encoding/json"
import "log"
import "sync"
import "github.com/rfwlab/rfw/v1/dom"
import "github.com/rfwlab/rfw/v1/js"
js
import "encoding/json"
import "testing"
import "strings"
import "testing"
import "fmt"
import "sync"
import "log"
import "github.com/rfwlab/rfw/v1/state"
import "testing"
import "github.com/rfwlab/rfw/v1/state"
import "crypto/sha1"
import "fmt"
import "html"
import "reflect"
import "regexp"
import "sort"
import "strconv"
import "strings"
import "github.com/rfwlab/rfw/v1/dom"
import "github.com/rfwlab/rfw/v1/state"
import "fmt"
import "regexp"
import "sort"
import "strings"
import "github.com/rfwlab/rfw/v1/dom"
import "github.com/rfwlab/rfw/v1/state"
import "strings"
import "testing"
import "github.com/rfwlab/rfw/v1/state"
import "errors"
import "github.com/rfwlab/rfw/v1/http"
http
import "fmt"
import "sync"
import "testing"
import "testing"
import "strings"
import "testing"
import "github.com/rfwlab/rfw/v1/state"
import "crypto/sha1"
import "encoding/hex"
import "fmt"
import "log"
import "regexp"
import "runtime"
import "runtime/debug"
import "sort"
import "strings"
import "sync"
import "time"
import "github.com/rfwlab/rfw/v1/dom"
import "github.com/rfwlab/rfw/v1/hostclient"
hostclient
import "github.com/rfwlab/rfw/v1/js"
js
import "github.com/rfwlab/rfw/v1/state"
import "github.com/tdewolff/minify/v2"
import "github.com/tdewolff/minify/v2/css"
import "github.com/tdewolff/minify/v2/js"
tdJs
import "fmt"
import "fmt"
import "sync"