Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
Luis Otavio de Colla Furquim avatar

Wings Authoring

  • 29 repo stars
  • Updated August 4, 2026
  • luisfurquim/wings

Skills that help AI assistants write correct WINGS apps: component structure, template syntax, i18n, skins, and the build. Pairs with the repo-root AGENTS.md.

About

wings-authoring is a Claude Code skill in the AI & Agent Building category. Skills that help AI assistants write correct WINGS apps: component structure, template syntax, i18n, skins, and the build. Pairs with the repo-root AGENTS.md.

  • wings-authoring
  • AI & Agent Building
  • AI-coding skill

Wings Authoring by the numbers

  • Data as of Aug 5, 2026 (Skillselion catalog sync)
/plugin marketplace add luisfurquim/wings
/plugin install wings-authoring@wings

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
repo stars29
Last updatedAugust 4, 2026
Repositoryluisfurquim/wings

What it does

Skills that help AI assistants write correct WINGS apps: component structure, template syntax, i18n, skins, and the build. Pairs with the repo-root AGENTS.md.

README.md

WINGS logo

WINGS

Web IN Go Sphere

Go Reference License: MPL 2.0 Go Report Card AGENTS.md Claude Code plugin

Live Demo — try it in your browser, no install needed. Or run locally: clone the repo, cd live-demo && bash build.sh && go run serve.go

or jump to the Quick Start / Full Example

Build reactive Web Components in pure Go — no JavaScript framework required.

WINGS compiles to WebAssembly and gives you custom HTML elements with automatic data binding, conditional rendering, array iteration, two-way form binding, hash-based routing, and parent-child communication — all authored in Go and running natively in the browser.

Why WINGS?

Pure Go Write components, state, and logic entirely in Go. Templates stay in plain HTML.
Reactive Change a value with Set() and the DOM updates automatically — no virtual DOM diffing overhead.
Lightweight Direct DOM manipulation via targeted refs. No framework runtime to download beyond your WASM binary.
Encapsulated Each component lives inside a Shadow DOM with scoped CSS — no style leaks, no naming collisions.
Two-Way Binding The & prefix syncs <input>, <select>, and <textarea> with your Go data map in both directions.
Hash Routing Built-in {{#}} binding and wings.GoTo() for SPA navigation without a router library.
Composable Nest components freely. Parent-to-child data flows via attributes; child-to-parent events flow via @ triggers.
Standard Web Uses native Custom Elements v1 and Shadow DOM — works alongside any existing page or framework.
Internationalized Build-time text extraction + runtime catalog lookup, with plural/gender flexion and locale-aware number/date/measure formatting.
Themeable Global --wings-* design tokens; compose multiple runtime skins (colours, geometry, depth, motion, atmosphere).
Testable in-web Wrap any widget in <w-test> to spy its events and seal a pass/fail; modules declare self-tests via Testable(), and <w-test-report> collects the whole page (auto + visual) into one JSON report.
Zero-toolchain dev A Docker dev container recompiles your wings.wasm and serves it on every save — iterate with no Go toolchain on the host, or run the same loop natively.

What's New

Release highlights — full history in CHANGELOG.md.

v0.16.14

  • AI authoring plugin now covers security — six app-flavored sec-* skills complete the wings-authoring kit. Validating them against a generated component caught and fixed a real runtime leak: native listeners wired with dom.AddEvent are now auto-released when a component disconnects.
  • Cross-tool reach — Cursor, Kiro, and GitHub Copilot rule files now re-point to AGENTS.md, so non-Claude assistants get the same guide.

v0.16.12

  • Continuous fuzzing of the parsers — native Go fuzz targets for the template/flex readers, codec, catalog aligner, signature verification, and number/format parsing, run via go run ./cmd/build fuzz. The first run already caught and fixed a currency-formatting bug.

v0.16.11

  • vulncheck build target — govulncheck over every module, native and GOOS=js; deps and toolchain bumped to clear every finding it reported.

Table of Contents


Migrating from wprana

WINGS was previously published as wprana. The import path is now github.com/luisfurquim/wings and the package identifier is wings.

New code imports it directly:

import "github.com/luisfurquim/wings"

// wings.Register(...), wings.Main(), wings.GoTo(...), etc.

Already have code that calls wprana.Foo(...)? You don't have to touch every call site. Point your module at the new path and alias the import back to wprana:

go get github.com/luisfurquim/wings
import wprana "github.com/luisfurquim/wings"

// every existing wprana.Foo(...) call keeps compiling unchanged

Only the import line changes — the rest of your code stays as-is. You can migrate to the wings. name gradually, or not at all.


Quick Start

WASM binaries cannot be loaded from file:// URLs. The snippet below builds a hello-world component, copies the required runtime files, and starts a tiny Go server so you can open the page in a browser.

Windows users: the tutorial snippets below assume a POSIX shell — run them under WSL (recommended — official Microsoft path, ships with Windows 10/11) or Git Bash (bundled with Git for Windows). Native cmd.exe / PowerShell will not execute them directly.

The project's own build, however, is pure Go and cross-platform: run go run ./cmd/build <target> (targets: lib, example, live-demo, wlate, all, vulncheck) — no sed, openssl, or python3 needed. The build.sh files in each directory are thin wrappers around it, kept for habit.

vulncheck runs govulncheck over every module in the repo, twice each: a native call-graph pass and a GOOS=js package-level pass (js-only packages are invisible to the native pass). It needs network access for the vulnerability DB, so it is not part of all — run it before each release; any finding fails it.

# 1. Create the project
mkdir hello-wings && cd hello-wings
go mod init hello-wings
go get github.com/luisfurquim/wings

# 2. Copy the JS helpers from the wings module
WPRANA=$(go list -m -f '{{.Dir}}' github.com/luisfurquim/wings)
mkdir -p static
cp "$WPRANA/prana_helper.js" static/
# Go 1.24+: lib/wasm/   Go ≤1.23: misc/wasm/
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" static/ 2>/dev/null || \
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" static/

Create static/index.html:

<!DOCTYPE html>
<html>
<head>
   <script src="prana_helper.js"></script>
   <script src="wasm_exec.js"></script>
   <script>
      const go = new Go();
      WebAssembly
         .instantiateStreaming(fetch("wings.wasm"), go.importObject)
         .then(r => go.run(r.instance))
         .catch(console.error);
   </script>
</head>
<body>
   <hello-world></hello-world>
</body>
</html>

Create mod/hello/hello.go:

//go:build js && wasm

package hello

import (
    _ "embed"
    "github.com/luisfurquim/wings"
)

//go:embed hello.html
var htmlContent string

type Hello struct{}

func init() {
    wings.Register("hello-world", htmlContent, "",
        func() wings.PranaMod { return &Hello{} })
}

func (h *Hello) InitData() map[string]any {
    return map[string]any{"greeting": "Hello from Go + WASM!"}
}

func (h *Hello) Render(_ *wings.PranaObj) {}

Create mod/hello/hello.html:

<h1>{{greeting}}</h1>

Create main.go:

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"
    _ "hello-wings/mod/hello"
)

func main() { wings.Main() }

Build and serve:

# 3. Build the WASM binary
GOOS=js GOARCH=wasm go build -o static/wings.wasm .

# 4. Start a minimal dev server (paste into serve.go, then run it)
cat > serve.go.tmp <<'GOFILE'
//go:build ignore

package main

import (
    "fmt"
    "net/http"
)

func main() {
    fs := http.FileServer(http.Dir("static"))
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path[len(r.URL.Path)-5:] == ".wasm" {
            w.Header().Set("Content-Type", "application/wasm")
        }
        fs.ServeHTTP(w, r)
    })
    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}
GOFILE
go run serve.go.tmp

Open http://localhost:8080 and you should see "Hello from Go + WASM!".

Dev Container (rebuild-on-save)

For an iterative loop without rebuilding by hand — or without a Go toolchain on your machine at all — WINGS ships a Docker dev environment under dev/docker/. Copy three files into your app's root (Dockerfile, docker-compose.yml, and .env.example.env), then:

docker compose up

Your source stays on the host (bind-mounted), and on every save the container re-lints, recompiles wings.wasm, and serves it on http://localhost:8080 — reload the browser to see the change. The module cache persists across runs.

Under the hood this is the build orchestrator's dev mode, which also runs natively (Go installed, no Docker):

# from your app's root
go run github.com/luisfurquim/wings/cmd/build@latest dev

It is configured entirely through WINGS_* environment variables:

Variable Default Purpose
WINGS_PORT 8080 Dev server port.
WINGS_WEBROOT . Dir with index.html; wings.wasm + JS helpers are written here.
WINGS_MAIN . Main package compiled to wasm (relative to the app root).
WINGS_HTTPD (empty) Custom server command; empty uses the built-in static server.
WINGS_DEFLANG (empty) If set (e.g. pt-BR), runs gen_i18n each build.
WINGS_GENI18N_ARGS (empty) Extra gen_i18n flags.
WINGS_BUILD_TAGS (empty) Extra -tags for go build.
WINGS_WATCH_EXT go,html,css,json File extensions that trigger a rebuild.
WINGS_WATCH_MODE auto auto rebuilds on every save; on-demand only logs changes and rebuilds when you touch REBUILD at the app root.
WINGS_DEBOUNCE_MS 200 Coalesce window (ms) for bursts of saves.

If your app needs a real backend instead of the built-in static server, set WINGS_HTTPD to the command that starts it (e.g. WINGS_HTTPD=go run ./server); build-and-watch keep running alongside it.

When WINGS_DEFLANG is set, the loop also runs gen_i18n on each build (and publishes the signed catalogs into the webroot), and two optional passes can be enabled: WINGS_AUTO_FLEX (fill inflections from baked Unitex dictionaries — list locales in WINGS_DICT_LANGS) and WINGS_AUTO_TRANSLATE (machine/LLM pre-fill, configured via WINGS_TR_*).

To see the loop drive a real i18n-enabled app, run the bundled live-demo in the container with only .env flags (copy its live-demo/ and docs/ folders, no code changes). See dev/docker/README.md for that recipe and the full variable reference.

AI-Assisted Development

If you build WINGS apps with an AI coding assistant, this repo ships a context kit so the assistant writes correct WINGS code instead of guessing from a long README — including the most common mistake (camelCase binding names silently becoming no-ops; WINGS lowercases attribute names, so use snake_case).

  • Any assistant (Codex, Cursor, Copilot, Gemini CLI, Windsurf, Aider, …): the repo-root AGENTS.md is read automatically by tools that support the open AGENTS.md standard. It carries the mental model, the must-know gotchas, and the template-syntax quick reference.
  • Tools with their own rules format: thin wrappers re-point Cursor (.cursor/rules/), Kiro (.kiro/steering/), and GitHub Copilot (.github/copilot-instructions.md) back to AGENTS.md, so each picks it up through its native always-on mechanism — no content is duplicated.
  • Claude Code: install the wings-authoring plugin for deeper, task-triggered skills. The wings repository is itself the marketplace — no public store:
    /plugin marketplace add luisfurquim/wings
    /plugin install wings-authoring@wings
    

The plugin ships skills for components, i18n, skins, built-in widgets, and the build (wings-component, wings-i18n, wings-skins, wings-widgets, wings-build), plus app-flavored security skills that re-aim WINGS's own hardening practices at the app you write (sec-wasm-go, sec-hostile-input, sec-fail-operational, sec-supply-chain, sec-minimal-trusted-code, sec-fuzzing).

Project Setup

A WINGS project has the following structure:

myapp/
├── go.mod
├── main.go                 # WASM entry point
├── mod/
│   └── mywidget/
│       ├── mywidget.go     # Module logic (implements PranaMod)
│       ├── mywidget.html   # Template with binding syntax
│       └── mywidget.css    # Component styles
└── static/
    ├── index.html          # HTML page
    ├── prana_helper.js     # wings JS bridge (from wings package)
    └── wasm_exec.js        # Go WASM runtime

HTML Page

The load order is critical. prana_helper.js must come before wasm_exec.js, and both must come before the WASM binary:

<!DOCTYPE html>
<html>
<head>
   <!-- 1. wings JS bridge (defines window._pranaDef) -->
   <script src="prana_helper.js"></script>

   <!-- 2. Go WASM runtime -->
   <script src="wasm_exec.js"></script>

   <!-- 3. Load and run the WASM binary -->
   <script>
      const go = new Go();
      WebAssembly
         .instantiateStreaming(fetch("wings.wasm"), go.importObject)
         .then(result => go.run(result.instance))
         .catch(err => console.error(err));
   </script>
</head>
<body>
   <my-widget title="Demo"></my-widget>
</body>
</html>

Entry Point (main.go)

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"

    // Side-effect imports: each init() registers a module via wings.Register()
    _ "myapp/mod/mywidget"
)

func main() {
    wings.Main() // Defines all custom elements and blocks forever
}

Creating a Module

Each module implements the PranaMod interface:

type PranaMod interface {
    InitData() map[string]any    // Returns initial component state
    Render(obj *PranaObj)        // Called after connection to DOM
}

Example Module

mod/counter/counter.go

//go:build js && wasm

package counter

import (
    _ "embed"
    "github.com/luisfurquim/wings"
    "github.com/luisfurquim/wings/timer"
)

//go:embed counter.html
var htmlContent string

//go:embed counter.css
var cssContent string

type Counter struct{}

func init() {
    wings.Register(
        "my-counter",       // custom element tag name
        htmlContent,         // embedded HTML template
        cssContent,          // embedded CSS
        func() wings.PranaMod { return &Counter{} },
        "title",             // observed attributes
    )
}

func (c *Counter) InitData() map[string]any {
    return map[string]any{
        "title": "Counter",
        "count": 0,
    }
}

func (c *Counter) Render(obj *wings.PranaObj) {
    // Set up a ticker that increments count every second
    go func() {
        tk := timer.NewTicker(1000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count", n)
        }
    }()
}

mod/counter/counter.html

<div class="counter">
   <h2>{{title}}</h2>
   <p>Count: <span>{{count}}</span></p>
</div>

Template Syntax

Expression Binding

Use {{expression}} to bind data values to the DOM. Expressions are automatically updated when the data changes.

<!-- Simple variable -->
<span>{{title}}</span>

<!-- Nested field access -->
<span>{{user.name}}</span>

<!-- Array access with literal index -->
<span>{{items[0]}}</span>

<!-- Array access with variable index -->
<span>{{items[i].label}}</span>

<!-- Attributes -->
<img src="{{avatar_url}}" alt="{{username}}" />

Hash Fragment Binding

The special reference {{#}} resolves to the current URL hash fragment (i.e. the portion of window.location.hash after the # sign).

<!-- If URL is https://example.com/app#settings, displays "settings" -->
<span>Current view: {{#}}</span>

<!-- Conditional rendering based on hash -->
<div ?#="home">Home content here</div>
<div ?#="settings">Settings panel</div>

wings automatically monitors window.location.hash and triggers a sync on all live component instances whenever the hash changes, so {{#}} references are always up to date.

To change the hash programmatically from Go:

wings.GoTo("settings")   // sets window.location.hash = "settings"

This fires the browser's hashchange event, which in turn updates every {{#}} binding across all components.

Conditional Rendering

Use the ? prefix on an attribute to conditionally show or hide an element. Seven forms are supported:

Boolean (truthiness)
<!-- Shows only when show_details is truthy -->
<div ?show_details>
    <p>Details: {{details}}</p>
</div>

<!-- Shows only when items array has elements -->
<div ?items.length>
    <p>There are items!</p>
</div>
Negated Boolean (?!var)

Shows the element only when the variable is falsy (the logical negation of the truthiness check):

<!-- Shows only when is_loading is falsy -->
<div ?!is_loading>
    <p>Content has loaded.</p>
</div>

<!-- Shows only when items array is empty -->
<div ?!items.length>
    <p>No items found.</p>
</div>
Equality (?var="value")

Shows the element only when the variable's string representation equals the given value:

<!-- Shows only when user_type is "A" (Author) -->
<div ?user_type="A">
    <p>Author-specific content</p>
</div>

<!-- Shows only when status is "active" -->
<span ?status="active" class="badge">Active</span>
Inequality (?var!="value")

Shows the element only when the variable's string representation does not equal the given value:

<!-- Shows for any user_type except "R" (Reader) -->
<div ?user_type!="R">
    <p>Extra profile fields</p>
</div>

<!-- Hides when status is "deleted" -->
<div ?status!="deleted">
    <p>This record is visible</p>
</div>
Prefix (?var^="value")

Shows the element only when the variable's string representation starts with the given value:

<!-- Shows only when url starts with "https" -->
<div ?url^="https">
    <p>Secure connection</p>
</div>
Suffix (?var$="value")

Shows the element only when the variable's string representation ends with the given value:

<!-- Shows only when filename ends with ".pdf" -->
<div ?filename$=".pdf">
    <p>PDF document</p>
</div>
Contains (?var*="value")

Shows the element only when the variable's string representation contains the given value as a substring:

<!-- Shows only when tags contain "urgent" -->
<div ?tags*="urgent">
    <p class="alert">Urgent item</p>
</div>
How operators map to HTML attributes

The browser parses these forms naturally — no special escaping is needed:

Template syntax HTML attr name HTML attr value Operator
?cond ?cond (empty) truthy
?!cond ?!cond (empty) negated truthy
?cond="abc" ?cond abc equality
?cond!="abc" ?cond! abc inequality
?cond^="abc" ?cond^ abc starts with
?cond$="abc" ?cond$ abc ends with
?cond*="abc" ?cond* abc contains

Note: Comparison operators < and > are not supported because they conflict with HTML tag syntax.

Behavior

When the condition is falsy (or the comparison fails), the element is replaced by a comment node. When the condition becomes truthy (or the comparison succeeds), the original element is restored.

Truthiness rules (for the boolean form, same as JavaScript):

  • Falsy: nil, false, 0, "", empty []any{}
  • Truthy: everything else

Comparison rules (for equality/inequality forms):

  • The variable value is converted to its string representation via fmt.Sprintf("%v", value) before comparing with the attribute value.
  • This means numeric values work as expected: ?count="0" matches when count is 0 (int) or "0" (string).

Array Iteration

Use the * prefix to repeat an element for each item in an array.

Single-element iteration (*array:index)

Creates a <span> wrapper around the repeated elements:

<ul>
   <li *items:i>{{items[i].label}}</li>
</ul>

With data:

"items": []any{
    map[string]any{"label": "Alpha"},
    map[string]any{"label": "Beta"},
    map[string]any{"label": "Gamma"},
}

Produces:

<ul>
   <span>
      <li>Alpha</li>
      <li>Beta</li>
      <li>Gamma</li>
   </span>
</ul>
Container iteration (**array:index)

The parent element itself becomes the container (no extra wrapper):

<!--
   Important! With **, the iterator attribute goes on the CONTAINER element
   (here <ul>), not on the repeated child. The first child element (<li>)
   becomes the template that is cloned for each array item.
-->
<ul **items:i>
   <li>{{items[i].label}}</li>
</ul>

Produces:

<ul>
   <li>Alpha</li>
   <li>Beta</li>
   <li>Gamma</li>
</ul>

The index variable (:i) is available in the template. You can use {{i}} to access the current iteration index.

Two-Way Binding

Use the & prefix on an attribute to establish a two-way binding between a form input and a data variable. Changes to the input update the data, and changes to the data update the input.

<input &value="{{username}}" type="text" placeholder="Username" />
<input &value="{{password}}" type="password" />
<select &value="{{selected}}">
    <option value="a">Option A</option>
    <option value="b">Option B</option>
</select>
<textarea &value="{{bio}}"></textarea>

Two-way binding only works with:

  • <input>
  • <select>
  • <textarea>

And requires a pure reference (single {{variable}}), not mixed text like "prefix {{variable}}".

Events (Child to Parent)

Use the @ prefix in the parent's template to bind a child component event to a handler function defined in the parent's data. The child fires the event using obj.Trigger("event_name").

<!-- Parent template: @event_name="handler_name" -->
<my-login @login="on_login" @logout="on_logout"></my-login>

The naming works like this:

  • @login is the event name — this is what the child passes to Trigger
  • "on_login" is the handler function name — looked up in the parent's data map

The child fires the event using only the name without the @ prefix:

// In the CHILD's Render:
obj.Trigger("login")       // matches @login in parent's template
obj.Trigger("logout")      // matches @logout in parent's template

The handler must be a func(...any) (or wings.TriggerHandler) in the parent's data map.

Important: In InitData, the obj parameter is not yet available — it only becomes available in Render. If your handler needs obj (which is almost always the case), use wings.TriggerHandler(nil) as a placeholder in InitData, then set the real handler in Render:

func (app *App) InitData() map[string]any {
    return map[string]any{
        // Placeholder — obj is not available here
        "on_login":  wings.TriggerHandler(nil),
        "on_logout": wings.TriggerHandler(nil),
    }
}

func (app *App) Render(obj *wings.PranaObj) {
    // Now obj is available — define the real handlers
    obj.This.Set("on_login", func(args ...any) {
        obj.This.Set("is_logged", true)
        obj.This.Set("is_anonymous", false)
    })
    obj.This.Set("on_logout", func(args ...any) {
        obj.This.Set("is_logged", false)
        obj.This.Set("is_anonymous", true)
    })
}

You can pass arguments from the child to the parent handler:

obj.Trigger("login", username, token)

Note: @ event attributes are read directly from the DOM at trigger time, so they do not need to be listed in the child's observed attributes. Only attributes whose values change at runtime (like & bindings) need to be observed.

See Parent-Child Communication for a complete example.

Catch-all event channels (@all / @else)

A named binding like @login only routes the login event. Two wildcard channels let a parent observe events without naming each one:

  • @all — the spy. It fires on every event the child triggers, including events that also have a named @handler. It routes last, after the primary handler, so by the time @all runs the DOM and state already reflect the event's effect — ideal for assertions and logging.
  • @else — the rest. It fires only for events that have no named @handler. For any given event, @<event> and @else never both fire (@else means "there was no specific handler").

Because one catch-all serves many events, its handler has a different signature — func(name string, params ...any) — receiving the event name typed as its first argument (a spy needs to know which event fired). A named handler keeps the plain func(...any).

<my-widget
    @save="onSave"          <!-- specific handler -->
    @else="onOther"         <!-- fires for everything except save -->
    @all="onAny">           <!-- fires for everything, including save, last -->
</my-widget>
obj.This.Set("onSave", func(args ...any) { /* args = save's args */ })
obj.This.Set("onOther", func(name string, params ...any) {
    // name = the event name, params = the event's own args
})
obj.This.Set("onAny", func(name string, params ...any) {
    // fires for every event, after the specific/else handler
})

These power the <w-test> harness, which stamps @all on the widget it wraps to capture its whole event stream. Like other @ bindings they are read from the DOM at fire time and need no observed-attribute declaration.

Reactive Data API

The ReactiveData type wraps a map[string]any and triggers automatic DOM synchronization on every mutation.

func (c *MyComponent) Render(obj *wings.PranaObj) {
    // Set a value (triggers DOM sync)
    obj.This.Set("title", "New Title")

    // Get a value
    title := obj.This.Get("title").(string)

    // Delete a key
    obj.This.Delete("old_field")

    // Append to an array
    obj.This.Append("items", map[string]any{"label": "New Item"})

    // Set array element at index
    obj.This.SetAt("items", 0, map[string]any{"label": "Updated"})

    // Delete array element at index
    obj.This.DeleteAt("items", 2)

    // Access the raw map directly (no automatic sync)
    obj.This.M["key"] = "value"
    // Then trigger sync manually:
    obj.This.Sync()
}

Navigation — Hash Fragment

WINGS exposes a package-level function to change the URL hash fragment:

// Navigate to a new view
wings.GoTo("settings")  // -> window.location.hash = "#settings"

// Clear the hash
wings.GoTo("")          // -> window.location.hash = ""

All {{#}} bindings and ?#="value" conditionals update automatically.

How DOM Updates Work

WINGS does not use a Virtual DOM. Instead it relies on direct, targeted DOM manipulation guided by a compile-time reference map.

Reference Extraction

When a component is first connected, WINGS walks the HTML template once and builds a DOMRefNode tree — a lightweight map that records, for every DOM node, which data keys appear in its text content and attributes. This map is stored alongside the component's reactive state and never rebuilt.

Synchronization Cycle

Every mutation through the ReactiveData API (Set, Delete, Append, DeleteAt, SetAt, or Sync) increments a global epoch counter and kicks off a synchronization pass:

  1. Epoch guard — each component tracks the last epoch it was synced at. If the current epoch matches, the sync is skipped, breaking circular propagation between parent and child components.

  2. Tree walk — the engine walks the DOMRefNode tree in parallel with the live DOM tree. For each node it:

    • Text nodes: resolves all {{expression}} segments against the current data context and writes the result to node.data (or element.value for <textarea>).
    • Attributes: resolves each bound attribute's segments and calls setAttribute only when the new value differs from the current one.
    • Conditionals (?): evaluates the condition. If false, the element is replaced by a comment placeholder; if true and currently hidden, the original element is restored from the stored reference.
    • Arrays (* / **): compares the current array length to the number of child nodes. Adds clones of the template for new items, removes excess nodes for deleted items, and recursively syncs each child with its corresponding array element.
    • Two-way bindings (&): updates the input's value property and keeps the stored context pointer current so the onchange handler always writes back to the correct data key.
  3. Propagation — after the local sync, observed attributes on child custom elements are updated via setAttribute, which triggers their own attributeChangedCallback and a downstream sync (subject to the same epoch guard).

Why Not a Virtual DOM?

A virtual DOM diffs an entire tree snapshot to compute the minimum set of mutations. WINGS skips the diff entirely: the reference map already knows which DOM nodes depend on which data keys, so it can jump directly to the affected nodes. This makes updates O(bindings) rather than O(tree size), with no garbage from disposable tree snapshots — an important property in a WASM environment where GC pauses are more noticeable.

Helper Packages

Helper functions are organized into subpackages so applications only import what they actually use, keeping the WASM binary lean.

wings/dom — Events and Queries

import "github.com/luisfurquim/wings/dom"

Register DOM event listeners with automatic preventDefault and stopPropagation support:

func (c *MyComponent) Render(obj *wings.PranaObj) {
    forms := dom.Query(obj.Dom, "form")
    if len(forms) > 0 {
        // Register submit handler with preventDefault
        handlerID := dom.AddEvent(forms[0], "submit",
            func(this js.Value, args []js.Value) any {
                username := obj.This.Get("username").(string)
                password := obj.This.Get("password").(string)
                // ... handle login
                return nil
            },
            true,  // preventDefault
            false, // stopPropagation
        )

        // Later, to remove the handler:
        // dom.RmEvent(handlerID)
    }
}

API:

func dom.AddEvent(el js.Value, eventName string,
    handler func(this js.Value, args []js.Value) any,
    preventDefault, stopPropagation bool) int64

func dom.RmEvent(id int64)

func dom.Query(el js.Value, selector string) []js.Value

wings/timer — Timers

import "github.com/luisfurquim/wings/timer"

// Sleep blocks the current goroutine for ms milliseconds,
// yielding control to the JS event loop.
timer.Sleep(2000)

// NewTicker sends on Tick channel every ms milliseconds.
// Call Stop() to release resources.
tk := timer.NewTicker(1000)
defer tk.Stop()
for range tk.Tick {
    // called every second
}

// SetTimeout schedules fn after delay ms.
// Returns a channel that closes on completion.
done := timer.SetTimeout(func() {
    fmt.Println("fired!")
}, 5000)
<-done // wait for it

// SetInterval schedules fn every interval ms.
// Returns a cancel function.
cancel := timer.SetInterval(func() {
    fmt.Println("tick")
}, 1000)
// later:
cancel()

wings/location — Browser Location

import "github.com/luisfurquim/wings/location"

// Get window.location.href as *url.URL
loc, err := location.Get()

// Get top.location.href as *url.URL (useful inside iframes)
topLoc, err := location.GetTop()

wings.KeyStorage — Storage Interface

The wings.KeyStorage interface defines a backend-agnostic key-value storage API. It accepts arbitrary Go values and relies on an Encoder/Decoder pair for serialization. Any storage backend (localStorage, OPFS, IndexedDB, etc.) can implement this interface:

type KeyStorage interface {
    Set(key string, val any) error
    Get(key string, outval any) error
    Del(key string) error
    Exists(key string) (bool, int64, error)
}

Modules that need persistent storage should accept a wings.KeyStorage instead of a concrete type. This way the application's main() decides which backend to use:

// In a module package:
var Store wings.KeyStorage

// In main():
import "github.com/luisfurquim/wings/localstorage"
import "github.com/luisfurquim/wings/opfs"

// Option A: localStorage backend
myModule.Store = localstorage.NewKV(nil, nil)

// Option B: OPFS backend (recommended for larger/sensitive data)
myModule.Store = opfs.New(nil, nil)

wings/localstorage — LocalStorage

import "github.com/luisfurquim/wings/localstorage"

Access browser localStorage with pluggable serialization.

Encoder / Decoder

Implement these interfaces to choose your encoding strategy (JSON, Gob+base64, etc.):

type Encoder interface {
    Encode(inpval any) string
}

type Decoder interface {
    Decode(buf string, outval any) error
}

If you pass nil for either parameter, a built-in default codec is used. It handles common Go types out of the box:

Type Encode Decode
string passthrough passthrough
[]byte string(v) []byte(s)
bool "true" / "false" strconv.ParseBool
int, int8--int64 strconv.FormatInt strconv.ParseInt
uint, uint8--uint64 strconv.FormatUint strconv.ParseUint
float32, float64 strconv.FormatFloat strconv.ParseFloat
KV — Recommended API (implements wings.KeyStorage)

KV is the recommended way to use localStorage. It implements wings.KeyStorage:

// Create with default codec (handles string, int, float, bool, etc.)
kv := localstorage.NewKV(nil, nil)

// Or with a custom encoder/decoder
kv := localstorage.NewKV(myEncoder, myDecoder)

// Store a value
err := kv.Set("username", "Ana")

// Retrieve a value (outval must be a pointer)
var name string
err := kv.Get("username", &name)
if errors.Is(err, localstorage.ErrKeyNotFound) {
    // key does not exist
}

// Check existence and get stored string length
exists, size, err := kv.Exists("username")

// Remove a key
err := kv.Del("username")
LS — Legacy API

LS provides the original API with pluggable Encoder/Decoder. It does not implement wings.KeyStorage (its Set and Del methods do not return errors). New code should use KV instead.

ls := localstorage.New(myEncoder, myDecoder)

// Or with the default codec
ls := localstorage.New(nil, nil)

ls.Set("user", map[string]any{"name": "Ana", "age": 30})

var user map[string]any
err := ls.Get("user", &user)

ls.Del("user")

// Iteration helpers (not available on KV)
n := ls.Len()
name, ok := ls.Key(0)
ls.Clear()

wings/opfs — Origin Private File System

import "github.com/luisfurquim/wings/opfs"

Access the browser's Origin Private File System directly from Go WASM. Files are stored in a sandboxed, origin-scoped filesystem that is invisible to the user and not subject to the same storage limits as localStorage.

opfs.Store implements wings.KeyStorage and uses the same Encoder/Decoder pattern as localstorage.KV. If nil is passed for either parameter, the built-in default codec is used (same type table as localstorage).

// Create with default codec
store := opfs.New(nil, nil)

// Store a value
err := store.Set("my-key", "hello world")

// Retrieve a value (outval must be a pointer)
var val string
err := store.Get("my-key", &val)
if errors.Is(err, opfs.ErrNotFound) {
    // key does not exist
}

// Check existence and get stored size in bytes
exists, size, err := store.Exists("my-key")

// Remove a key (no error if it does not exist)
err := store.Del("my-key")

The store accesses OPFS via the asynchronous File System API (navigator.storage.getDirectory()), called directly through syscall/js. No Service Worker is required.

JavaScript Interop (core)

These functions remain in the core wings package:

// Access the global window object
window := wings.JSGlobal()

// Create a persistent JS callback (must call Release() when done)
fn := wings.JSFunc(func(this js.Value, args []js.Value) any {
    // handle callback
    return nil
})
defer fn.Release()

// Create a one-shot JS callback (auto-releases after first call)
fn := wings.JSFuncOnce(func() {
    // handle callback
})

Customizable Widgets

Modules that implement only PranaMod have fixed CSS. Modules that also implement Customizable allow consuming applications to replace parts of their CSS at runtime — for example, changing the color scheme without touching the layout rules.

Customizable Interface

// CSSPart is a named section of a component's CSS.
type CSSPart struct {
    Name    string
    Content string
}

// Customizable extends PranaMod with CSS customization.
type Customizable interface {
    PranaMod
    ListCSS() []CSSPart
    ReplaceCSS(key string, content string)
}
  • ListCSS() returns the CSS parts in order. The order matters: for example, a "Vars" part defining CSS custom properties must come before a "Design" part that uses var() references.
  • ReplaceCSS(key, content) replaces the named part and updates all live instances immediately via wings.Update().

wings.Update — Dynamic CSS

wings.Update(tagName string, cssContent string)

Replaces the CSS of a registered custom element and updates the <style> tag in the Shadow DOM of every live instance. Called automatically by ReplaceCSS; can also be called directly for full CSS replacement.

Skins — Theming with --wings-* Tokens

A skin is a CSS payload — a block of --wings-* custom-property definitions at :root — paired with a SkinCategory bitmask declaring which design dimensions it touches. Widgets read every token through a var(--wings-X, fallback) reference, so they look correct with no skin active and restyle globally the moment one is applied. There is no per-widget theming step.

import (
    "github.com/luisfurquim/wings"
    _ "github.com/luisfurquim/wings/skins/light" // blank import → init() registers it
    _ "github.com/luisfurquim/wings/skins/glass"
)

func main() {
    if err := wings.ApplySkin("light"); err != nil { /* … */ }
    _ = wings.ApplySkin("glass") // composes on top of light
    wings.Main()
}

Each active skin owns a <style id="wings-skin-NAME" data-wings-skin="NAME"> in document.head; DOM order is activation order, so later skins cascade over earlier ones.

Multi-skin composition

Several skins can be active at once provided their category bitmasks are disjoint (their AND is zero). This lets a complete theme (colours + geometry + depth + …) compose with a focused skin that touches one orthogonal dimension (e.g. glass, which is Atmosphere-only). Activating a skin whose categories overlap an already-active skin returns a *SkinConflictError instead of silently double-setting a token.

There are nine categories (SkinCategory, a uint64 bitmask):

Category What it owns
CategoryIdentity colours, surfaces, text, primary/secondary, borders, button colours, shadow colour
CategoryGeometry corner-radius scale, border width/style
CategoryDepth shadow shapes (offset/blur/spread); colour comes from Identity
CategoryMotion transition durations/easing, hover-lift, active-scale
CategoryInteraction focus-ring (chromatic feedback)
CategoryTypography font family/size/weight (reserved — no built-ins yet)
CategorySpacing padding / gap density
CategoryLighting gradients, glows, gradient-shadow
CategoryAtmosphere glass opacity, surface blur/saturate

The split is principled: a token whose value is a colour belongs to a chromatic category (Identity / Lighting / Interaction); a token whose value is a metric (px, ms, ratio, transform) belongs to a structural/temporal one (Geometry / Spacing / Depth / Motion). Shadows are split deliberately — the shape is Depth, the colour rgba is Identity (--wings-shadow-color-*), and Depth skins compose the final --wings-shadow-* that widgets read.

Convenience masks bundle the bits a typical skin of each kind declares: IdentitySkinCategories (Identity|Lighting|Interaction), GeometrySkinCategories (Geometry|Spacing), DepthSkinCategories, MotionSkinCategories.

Built-in skins (18)

Kind (mask) Skins Mutually exclusive?
Identity (theme) light dark autumn darkblueberry darkforest lightblueberry mushroom vividforest yes — pick one
Geometry sharp classic soft yes — pick one
Depth flat lifted floating yes — pick one
Motion gentle calm brisk yes — pick one
Atmosphere glass composes with anything

A complete look is one from each column, e.g. the live-demo default is light + classic + lifted + calm (optionally + glass). Import each skin you intend to use (blank import) so its init() registers it.

Registering your own skin

//go:embed skin.css
var css string

func init() {
    // Declare exactly the dimensions your CSS defines, so composition and
    // conflict detection work. A chromatic theme uses the convenience mask:
    wings.RegisterSkin("midnight", wings.IdentitySkinCategories, css)
    // A focused skin declares a single bit:
    // wings.RegisterSkin("rounded", wings.GeometrySkinCategories, css)
}

The token contract every skin fills lives in skins/tokens.md, organised one section per category. Define the tokens your declared categories own; widgets supply literal fallbacks for everything else.

Private categories (your own design dimensions)

SkinCategory is a uint64, and its bit space is partitioned like IANA address space. The low bits are framework-owned built-ins (9 today) and grow upward; the high 16 bits (48–63) are permanently reserved for you. A built-in will never be assigned a bit in that range, so a private category you mint today keeps working across WINGS upgrades. Mint one with UserCategory, give it a display name (optional, shown by <skin-switcher>), then use it like any built-in category — it participates in the same disjoint-mask conflict detection:

// A private "Brand" dimension, orthogonal to every built-in category.
var CategoryBrand wings.SkinCategory

func init() {
    b, err := wings.UserCategory(0) // n in [0,16)
    if err != nil {
        log.Fatal(err) // your call — the library returns the error, never panics
    }
    CategoryBrand = b
    _ = wings.RegisterCategoryName(CategoryBrand, "Brand")
    wings.RegisterSkin("acme", CategoryBrand, acmeCSS)
}

An acme skin declared this way composes freely with light + classic + … and only conflicts with another skin that also claims CategoryBrand.

Skin API

Function Purpose
RegisterSkin(name, categories, css) Register a skin (call from init()).
ApplySkin(name) error Activate. Idempotent; returns *SkinNotRegisteredError or *SkinConflictError.
DeactivateSkin(name) error Remove one active skin.
ClearSkins() Deactivate all.
ActiveSkins() []string / ActiveSkin() string All active (activation order) / most-recent.
ActiveCategories() SkinCategory OR of every active skin's mask.
ConflictsWith(categories) []string Active skins that would block categories.
ListSkins() []string / ListSkinInfos() []SkinInfo Registered names / names+categories.
SkinCategoriesOf(name) (SkinCategory, bool) A skin's declared mask.
UserCategory(n) (SkinCategory, error) Mint the n-th private category bit (n in [0,16)); reserved from built-ins.
RegisterCategoryName(bit, name) error Name a private category so it shows in Names()/String()/<skin-switcher>.
CategoryUserMask Mask of all 16 private bits — c & CategoryUserMask != 0 tests for any private category.
OnSkinChange(fn func()) Hook fired after every apply/deactivate/clear (used by <skin-switcher> to stay in sync).

The <skin-switcher> built-in widget exposes all of this as UI — see

Function Purpose
RegisterSkin(name, categories, css) Register a skin (call from init()).
ApplySkin(name) error Activate. Idempotent; returns *SkinNotRegisteredError or *SkinConflictError.
DeactivateSkin(name) error Remove one active skin.
ClearSkins() Deactivate all.
ActiveSkins() []string / ActiveSkin() string All active (activation order) / most-recent.
ActiveCategories() SkinCategory OR of every active skin's mask.
ConflictsWith(categories) []string Active skins that would block categories.
ListSkins() []string / ListSkinInfos() []SkinInfo Registered names / names+categories.
SkinCategoriesOf(name) (SkinCategory, bool) A skin's declared mask.
OnSkinChange(fn func()) Hook fired after every apply/deactivate/clear (used by <skin-switcher> to stay in sync).

The <skin-switcher> built-in widget exposes all of this as UI — see Built-in Widgets below.

Built-in Widgets

wings/widget/combobox — Multi-select Combobox

import _ "github.com/luisfurquim/wings/widget/combobox"

A multi-select combobox with type-ahead filtering, tag display, and keyboard support.

<w-combobox
    options='["Alpha","Beta","Gamma"]'
    placeholder="Type to filter..."
    mode="multi"
    @notinlist="on_notinlist"
    @change="on_change">
</w-combobox>

Attributes:

Attribute Description
options JSON array of strings or [{"label":"...","value":"..."},...] objects
placeholder Input placeholder text (default: "Type to filter...")
mode "multi" (default — tag-based multi-select) or "single" (replaces previous selection, hides tag display, shows label in the input)
value Pre-selected value. In single mode this attribute is authoritative: if it disagrees with the current selection (e.g. the parent reverts it after the user cancels a confirmation dialog), the visible selection re-syncs to match. The re-sync is silent — @change does NOT fire — so a controlled parent can safely roll back without re-entering its own change handler. In multi mode value only seeds the initial selection.

Events (via @):

Event Args Description
@notinlist typed string Enter pressed with text not matching any option
@change []any of selected items Selection changed by user action. Programmatic re-sync via value does not fire this.

Theming. The combobox reads its colours and metrics from the shared --wings-* skin tokens--wings-input-* (field), --wings-list-box-* / --wings-list-item-* (dropdown), --wings-tiny-element-* (tags), --wings-remover-* (tag remove button), plus --wings-radius-*, --wings-shadow-md and --wings-transition-fast. Apply a skin and the combobox follows; no per-widget theming needed. It also opts into the glass skin via --wings-surface-blur/--wings-surface-saturate on the dropdown panel.

The "No results" label is rendered via CSS content: attr(data-i18n), so it is translatable through the normal i18n pipeline.

Per-instance overrides. The widget still implements Customizable with "Vars" and "Design" parts, so a single instance can override CSS without a skin via cb.ReplaceCSS("Vars", …) (see Customizable Widgets).

wings/widget/tabs — Tabbed Container (w-tabs / w-tabbutton / w-tab)

import (
    _ "github.com/luisfurquim/wings/widget/tabs"
    _ "github.com/luisfurquim/wings/widget/tabbutton"
    _ "github.com/luisfurquim/wings/widget/tab"
)

w-tabs is a controlled container: the single source of truth for which panel is visible is the host's active attribute (a w-tab tid, or a positional index). Panels are w-tab children; the w-tabbutton buttons are optional sugar.

Shape 1 — with buttons (simple tabs). Co-locate each button with its panel as direct children (button, panel, button, panel). w-tabs routes the buttons into a strip and a click sets active for you:

<w-tabs mode="panel">
  <w-tabbutton active>Overview</w-tabbutton>
  <w-tab><h2>Overview</h2>…</w-tab>
  <w-tabbutton>Details</w-tabbutton>
  <w-tab>…</w-tab>
</w-tabs>

Shape 2 — headless (controlled). Provide no w-tabbutton. w-tabs adds no chrome and renders its content transparently (passthrough), so your own layout reaches the panels; drive selection by binding active:

<w-tabs mode="detached" active="{{current}}">
  <header>…your own buttons set `current`…</header>
  <w-tab tid="a">…</w-tab>
  <w-tab tid="b">…</w-tab>
</w-tabs>

Modes (mode attribute on w-tabs):

Mode Layout
panel (default) button strip on top (scrolls on overflow), panel below
detached same shape, chip-like free-floating buttons, transparent panels
menu button column on the left, panel on the right
accordion each button is moved into its panel to become the native <summary>; a w-tab marked active starts open, then open/close is the platform's job

Attributes. On w-tabs: mode, active. On w-tabbutton / w-tab: tid (optional identifier for deep-link/programmatic selection — pairing is by DOM adjacency, so it's not required), active (boolean; you may set it on the initial markup to choose the starting tab), and mode (managed — stamped by w-tabs; don't write it yourself).

Event to parent. @change fires after a user-driven (click/keyboard) activation; args[0] is the selected tid (or positional index as a string). It does not fire at init or for programmatic active changes.

Panels keep their DOM across switches (hidden via CSS, not destroyed), so input values, scroll position and embedded widgets survive. w-tabbutton is a deliberately stateless, CSS-only leaf — that's what makes the accordion "move" safe. For one-button-to-one-content disclosure, native <details> is often simpler than accordion.

wings/widget/navbar — Record Navigation Toolbar (w-navbar)

import _ "github.com/luisfurquim/wings/widget/navbar"

A record-navigation toolbar — first / prev-many / prev / position input / next / next-many / last — that forwards actions to the parent as triggers. It keeps no internal state: current position and total are owned by the parent through bound fields.

<w-navbar
    nav_input="{{cur_record}}"
    total_count="{{record_count}}"
    @first="goFirst"     @prevmany="goPrevPage"
    @prev="goPrev"       @next="goNext"
    @nextmany="goNextPage" @last="goLast"
    @change="onSeek">
</w-navbar>

The position input is two-way bound, so typing updates nav_input; @change then fires with the new value (args[0]), and Enter fires it immediately without waiting for blur. w-navbar implements Customizable ("Vars" / "Design").

wings/widget/skinswitcher — Skin Picker (<skin-switcher>)

import _ "github.com/luisfurquim/wings/widget/skinswitcher"

A drop-in UI for the skin system: one checkbox per registered skin (so the user can stack a focused skin like glass on top of a complete theme). It detects conflicts via the category bitmasks and, on selecting a conflicting skin, auto-replaces the colliding active skin rather than blocking. It registers an OnSkinChange hook so its UI stays in sync with programmatic ApplySkin / DeactivateSkin calls. Just drop <skin-switcher> into your markup.

wings/widget/test — In-web Test Harness (w-test)

import _ "github.com/luisfurquim/wings/widget/test"

Wrap any prana widget (the subject) in <w-test> to get an in-web integration test: it stamps the @all spy channel on the subject to capture every event it fires, renders a live event log, and shows a ⏳/✅/❌ seal. This complements the testing tripod — unit tests for pure logic, <w-test> for DOM integration, the human eye for aesthetics — and the tests are visible to everyone, right next to the thing they exercise.

<w-test title="Counter fires changed"
        expect="Typing a new count should fire the changed event"
        check="countChanged">
    <count-input value="{{count}}"></count-input>
</w-test>

The assertion is a Go function registered by name (mirroring RegisterSkin), run on mount, after every captured event, and on the Re-run button:

wings.RegisterCheck("countChanged", func(ctx wings.CheckCtx) (bool, string) {
    for _, e := range ctx.Events {
        if e.Name == "changed" {
            return true, "saw changed"
        }
    }
    return false, "no changed event yet"
})

A CheckFunc receives a CheckCtx{Subject, Dom, Events} — the wrapped element, a node to query, and the ordered event log — and returns (pass bool, detail string). With no check= attribute the test is manual: the seal is a human-toggled ✅/❌ for purely visual checks (e.g. skin aesthetics). The host carries data-wtest-state="pending|pass|fail", so a later headless runner (Playwright) can scrape seals without re-deriving them. The widget's own chrome ("Events", "Re-run") is in English.

wings/widget/testreport — Module Self-tests (Testable() + w-test-report)

import _ "github.com/luisfurquim/wings/widget/testreport"

Instead of (or alongside) hand-wiring <w-test> cards, a module can declare its own integration tests by implementing the optional Testabler interface:

type Testabler interface {
    Testable() map[string]wings.CheckFunc
}

The runtime discovers them per live instance: when an element whose module implements Testabler mounts, its checks are registered against that element; they are dropped when it disconnects. Each check sees a CheckCtx whose Subject/Dom are the live element (Events is empty — event-stream assertions belong in a <w-test> wrapper, which spies via @all).

Declare Testable() in a file gated by the wings_test build tag so the tests compile only into test builds, never production:

//go:build js && wasm && wings_test

func (w *CountInput) Testable() map[string]wings.CheckFunc {
    return map[string]wings.CheckFunc{
        "renders-number-input": func(ctx wings.CheckCtx) (bool, string) {
            inputs := dom.Query(ctx.Subject.Get("shadowRoot"), "#ci-inp")
            if len(inputs) == 0 {
                return false, "no #ci-inp rendered"
            }
            return true, "number input present"
        },
    }
}

Drop <w-test-report> into your markup; its button collects the whole page's test result (wings.RunReport()) — every <w-test> card including the human-judged visual ones, in whatever state the tester left them, plus every Testable() check — shows it as JSON, and fires a report event carrying that JSON:

<w-test-report @report="on_report"></w-test-report>

Each entry is {kind, label, state, detail}, where kind is "w-test" or "testable" and state is "pass", "fail", or "pending". So a tester runs the whole page, judges the visual cards by eye, and delivers one report of what passed and what failed with a single click — no hand-written "these 100 of 500 failed".

Transport is your call. WINGS only produces the report — sending it to a server, writing it to a file, or diffing it in CI is the app's decision, handled in your @report handler. WINGS implements none of that.

To test everything, don't ask the framework to enumerate widgets — that is a composition concern. Build a throwaway test app that imports and mounts all your modules flat (no app hierarchy), compile it with -tags wings_test, and open it only in your dev/CI pipeline — never publish it.

Internationalization (i18n)

WINGS ships with an end-to-end i18n pipeline: a build-time extractor that rewrites HTML templates with stable numeric indices, a per-language catalog loaded at runtime by a zero-config package, a GUI editor for translators, and (optional) morphological dictionaries used to pre-fill plural/gender flexions from a Unitex DELAF source.

Pipeline Overview

            ┌────────────────────────────┐
            │ *.html (mod/**)            │  ── templates with natural text
            └────────────┬───────────────┘     (text nodes + translatable
                         │                      attributes like title, alt,
                         │   cmd/gen_i18n       placeholder, aria-label) +
                         ▼                      flex blocks {{@g %c ~w ...}}
            ┌────────────────────────────────────────────────┐
            │ *.i18n.html                                    │
            │ i18n/<deflang>.json                            │  ── text catalog
            │ i18n/<deflang>.inflections.json  (if any flex) │  ── gender×CLDR
            └────────────┬───────────────────────────────────┘
                         │
              translator ▼ (helpers/wlate GUI — Texto + Inflexões tabs)
            ┌────────────────────────────────────────────────┐
            │ i18n/<lang>.json + <lang>.inflections.json     │
            └────────────┬───────────────────────────────────┘
                         │   runtime
                         ▼
            ┌────────────────────────────┐
            │ wi18n (WASM, side-effect)  │  ── fetches both JSONs in parallel,
            │ wings.Printer   = lookup  │     installs Printer (text index →
            │ wings.SynPrinter = flex   │     string) and SynPrinter (flex
            └────────────────────────────┘     block → locale-correct form)

   ┌──────────────────────────────────────────────────────────────┐
   │ Optional: cmd/dictbuild self-fetches the Unitex DELAF for a  │
   │ locale (gh:UnitexGramLab/unitex-lingua + auto-built          │
   │ UnitexToolLogger from gh:UnitexGramLab/unitex-core) and      │
   │ emits <lang>.db (gob) used by gen_i18n to pre-fill flexions. │
   │ cmd/dictlookup is a CLI inspector for that .db.              │
   └──────────────────────────────────────────────────────────────┘

The runtime side is intentionally tiny: a TextNode whose content is a decimal number gets replaced by table[n] when the catalog is loaded, and left untouched otherwise — so dynamic text produced via {{expression}} passes through unchanged. The same lookup applies to values of the attributes listed in wings.TranslatableAttrs (default: title, placeholder, alt, aria-label, data-i18n).

For plurals and gender agreement — cases where a single translation string cannot reflect the target locale's grammar — WINGS ships a parallel pipeline keyed on inline flex sigils (@var/%var/~word/#N). See Flexion — Plurals & Gender (SynPrinter) below for the full syntax and catalog format.

wings/wi18n — Runtime Lookup

import _ "github.com/luisfurquim/wings/wi18n" — side-effect import only.

On init(), wi18n:

  1. Detects the browser language from navigator.languages[0], falling back to navigator.language, then en-US.
  2. Sets <html lang="…"> accordingly.
  3. Registers itself on wings.InitWG so wings.Main() waits for the catalog to load before defining custom elements.
  4. Fetches <BasePath><lang>.json (with fallback chain: full tag → base language → en-US) relative to the current page. BasePath defaults to i18n/; apps that ship their own catalog next to the project's catalog call wi18n.SetBasePath("<dir>/") from an init() that runs after wi18n's (see the wlate self-i18n setup below for a concrete example).
  5. Decodes the JSON as a []wi18n.Entry array (see schema below); builds the lookup table from each entry's content field.
  6. Replaces wings.Printer with a function that parses the TextNode content (or attribute value, when the attribute is in wings.TranslatableAttrs) as a decimal index and returns table[idx]. Entries whose content is empty fall back to rendering the raw index — a deliberate visual signal for missing translations.

If no catalog can be loaded, wings.Printer stays as the default ByPass and TextNodes render their raw numeric indices.

The bundle loaded at init is also stashed in an in-memory cache keyed by the requested tag, so the first call to wi18n.SetLang(<initial-lang>) later in the session is a cache hit (no re-fetch). See Runtime Locale Switching (SetLang) below.

// Usage: import for side effects, that's it.
import (
    "github.com/luisfurquim/wings"
    _ "github.com/luisfurquim/wings/wi18n"
    _ "myapp/mod/mywidget"
)

func main() { wings.Main() }

// Optional: read the selected language tag
lang := wi18n.Lang()

Catalog schema. The catalog is split across two parallel files: a browser-side data file and a server-side metadata file.

i18n/<lang>.json — shipped to the browser:

[
  { "content": "Dashboard", "revised": true },
  { "content": "", "revised": false, "source": "llm:gemma4" }
]

i18n/<lang>.meta.json — server-only, same length, parallel-indexed:

[
  { "context": "mywidget/mywidget.html:7:42",
    "ctxdetail": "a@mywidget/mywidget.html:7:42<br/>h2@mywidget/mywidget.html:65:17" },
  { "context": "mywidget/mywidget.html:9:5",
    "ctxdetail": "p@mywidget/mywidget.html:9:5" }
]
  • content — source string for the default language; translation for every other language. Empty means "not translated yet".
  • revised — translator-maintained flag, flipped in the wlate GUI once a human has reviewed the entry. Preserved across gen_i18n runs when the underlying source string has not changed.
  • source — optional provenance tag, set automatically when the entry was pre-filled (e.g. "llm:gemma4", "dict:unitex-lingua"). Displayed as a badge in wlate so translators know which entries need human review.
  • context (meta) — first occurrence as <path>:<line>:<col> (forward slashes on every OS).
  • ctxdetail (meta) — every occurrence joined by <br/>, each formatted as <tag>@<path>:<line>:<col>. For attribute values the tag is written as <element>[<attr>] (e.g., button[title]).

Runtime Locale Switching (SetLang)

Once the page has loaded, applications can switch the active locale on the fly without reloading the page or rebuilding the DOM. Form input, list contents, scroll position and component state survive the switch — only the bindings driven by Printer / SynPrinter / FmtPrinter are refreshed in place.

import "github.com/luisfurquim/wings/wi18n"

// from a click handler, language picker, etc.
wi18n.SetLang("en-US", func(err error) {
    if err != nil {
        // No catalog available for "en-US" or any of its fallbacks.
        return
    }
    // Locale switch is complete; wings.Locale is now "en-US"
    // and every visible custom element has been re-translated.
})

SetLang(tag, done) always runs in a goroutine and returns immediately; this is mandatory because the catalog fetch goes through the JS event loop, and a synchronous wait inside an event handler would deadlock the very fetch().then callback we are awaiting. The optional done callback fires (also from the goroutine) once the switch completes or fails. Pass nil if you do not need notification.

How it works:

  • The first call to a given tag fetches <BasePath><lang>.json (and <lang>.inflections.json, if any), parses it, and caches the resulting bundle keyed by the requested tag. Subsequent calls to the same tag reuse the cache, so toggling between languages does not re-fetch.
  • The active text and inflection tables are atomically swapped, then wings.Locale and <html lang="…"> are updated.
  • Every live custom-element instance is then walked: each text node and attribute that the constructor stashed (via _wi18nSrc / _wi18nAttr_* JS expandos on the node) is re-translated, the new binding string is re-parsed, and the affected PranaState is re-synced. Existing *items:i clones, conditional state, two-way bindings and timer goroutines are preserved.

Limitation — placeholder set, not order. Translations may freely reorder {{...}} placeholders to match the target language's word order. A mixed text node (words and placeholders) is indexed whole, with its {{...}} inline, so the catalog string for

pt-BR:  O usuário {{nome}} comprou {{produto}}

is translated as a single unit and the placeholders can move anywhere:

ja:     {{produto}} を {{nome}} が購入しました

At construction and on every SetLang, the runtime sets the node to Printer(index) (the translated string) and then re-parses it for bindings, so any permutation of the same placeholders binds correctly — word-order differences (SVO → SOV, etc.) are fully supported.

What the SetLang walker does not do is create or destroy DOMRefNodes on the fly: if a translation adds a {{...}} placeholder that was absent from the source-language string, or removes one, that delta may render as raw text. In short: keep the set of placeholders identical across locales; their order is free.

Performance caches. Two memoisation layers sit underneath the runtime so the per-switch cost stays low even for large pages:

  • Intl instance cache (wings/wi18n/intl_cache.go). Intl.NumberFormat / Intl.DateTimeFormat instances are expensive to construct (each one parses CLDR locale data behind the syscall/js boundary) and essentially free to call. They are cached keyed by (locale, options) and reused across SetLang calls — so toggling back to a previous locale always hits warm formatters.
  • Parsed-template cache (wings/parse_cache.go). The re-bind walker memoises expr.ParseText by its input string. Many instances of the same custom element produce identical translated strings, so the parse cost is paid once per unique (locale, source string) pair instead of once per node.

Neither cache is invalidated on SetLang: both are keyed on values that stay stable for the lifetime of the catalog.

Worked example. The live-demo/ directory at the repository root ships a single tabbed application that exercises every feature described in this README — including a locale switcher in the header that demonstrates SetLang simultaneously refreshing the basics, flex, fmt and i18n tabs across pt-BR / en-US / es-AR.

cmd/gen_i18n — Build-time Extractor

go run github.com/luisfurquim/wings/cmd/gen_i18n \
    --path ./mod \
    --deflang pt-BR

What it does:

  • Walks the directory tree, processes every *.html (skips files already ending in .i18n.html).
  • For each HTML file, parses the DOM, extracts the natural text from every TextNode and the value of each element attribute whose name is in the translatable attribute set (see flags below), inserts the string into a shared trie keyed by an octal hash of the text, and replaces the node content (or attribute value) with the trie's decimal index.
  • Leaves runtime placeholders verbatim. A text node (or attribute value) that is only {{…}} bindings plus whitespace — e.g. {{count}}, {{%price}}, {{%dist:km}} — carries no words to translate, so it is left untouched and never assigned a catalog index. This keeps the catalog clean and prevents non-deflang locales from rendering a bare index where the binding should be. (Flex blocks such as {{@g %c …}} are the deliberate exception: they are extracted — see Flex-block extraction below.)
  • Skips any element (and its subtree) marked translate="no" — see Opting out below.
  • Writes <file>.i18n.html next to each source template. Embed these at compile time via //go:embed instead of the original HTML. (Note: the parser emits a wrapping <html><head></head><body>…</body></html> — this is harmless; the framework sets the template via <template>.innerHTML, which dissolves those wrappers and keeps the fragment.)
  • Writes two parallel files per language: i18n/<lang>.json (browser bundle — content, revised, optional source) and i18n/<lang>.meta.json (server-only — context, ctxdetail). The deflang .json has content set to the source string for every entry; other <lang>.json files are remapped in place across runs by the change-detection pass below.
  • Preserves translations across source edits. Each run aligns the new source order against the committed deflang catalog (no side-car database — the JSON is the record). A source string still present is matched exactly regardless of where it moved, so its translation and revised flag carry over verbatim; a string that was edited is matched to its closest previous version (Levenshtein similarity, scoped between the surrounding unchanged anchors) and its old translation is reused but flagged revised=false for re-review; genuinely new strings start empty and disappeared ones are dropped.
  • Validates --deflang as a BCP 47 tag via golang.org/x/text/language (falls back to en-US on invalid input).
  • If legacy <lang>.csv files exist without a corresponding <lang>.json, they are converted once (the legacy ! marker, if present, is stripped). This is a one-shot migration; CSV is no longer the on-disk format.

Translatable-attribute flags. Attributes like title, placeholder, alt, aria-label, and data-i18n carry user-visible text just like text nodes do. gen_i18n extracts the values of the following attributes by default:

title, placeholder, alt, aria-label, data-i18n

CSS content: via data-i18n. CSS pseudo-elements (::before / ::after) can render translatable text without any CSS-level i18n machinery. Declare the CSS once:

.breadcrumb::before { content: attr(data-i18n); }

Then put the source text on the element as data-i18n:

<span class="breadcrumb" data-i18n="Início"></span>

gen_i18n assigns a catalog index to the attribute value exactly as it does for text nodes; the runtime Printer translates it at construction time and again on every SetLang() call; the browser picks it up through the standard CSS attr() function. No CSS parsing, no custom properties, no extra runtime code.

Three flags tune this set:

Flag Effect
--attrs <list> Replaces the default list entirely. Comma-separated, case-insensitive.
--add-attrs <list> Appends to the active list (default, or whatever --attrs produced).
--no-attrs <list> Removes from the active list after additions, so it can also drop defaults.

In ctxdetail, attribute occurrences are distinguished from text-node occurrences by the tag format: button[title]@path:line:col vs. button@path:line:col.

Opting out (translate="no")

gen_i18n honors the standard HTML translate attribute. An element marked translate="no" — and everything inside it — is excluded from extraction (both text nodes and translatable attributes); a nested translate="yes" re-enables it. The attribute is build-time only: the wi18n runtime ignores it and still substitutes any numeric index it finds, so a node can hold a literal index under translate="no" and still render live.

Use it for content that must stay verbatim regardless of locale:

<!-- Language autonyms never translate (each shows in its own language) -->
<select translate="no">
  <option value="pt-BR">Português</option>
  <option value="en-US">English</option>
  <option value="es-AR">Español</option>
</select>

<!-- Verbatim prose (e.g. an English API demo) kept out of the catalog -->
<div class="api-notes" translate="no"> … </div>

The combination — build-time skip + runtime substitution — also enables a self-referential demo: a table whose cells contain literal catalog indices under translate="no" won't be re-indexed by gen_i18n, yet the runtime renders each index live (the live-demo's "Tradução" tab does exactly this).

Runtime mirror. At runtime, wings.TranslatableAttrs controls which attributes the engine passes through Printer. Its default matches the default gen_i18n set, and must mirror whatever flags produced the catalog, otherwise attribute values render as raw decimal indices:

// Option 1: assign a fresh slice (full override)
wings.TranslatableAttrs = []string{"title", "placeholder"}

// Option 2: incremental — safe with other packages that may also tweak it
wings.AddTranslatableAttrs("data-tip", "aria-placeholder")
wings.RemoveTranslatableAttrs("title")

The helpers are case-insensitive, trim whitespace, and skip duplicates. Both the assignment and the helper calls must run before wings.Main() finishes initialization (an init() in package main is the canonical spot).

Flex-block extraction. In the same pass, gen_i18n scans every {{...}} binding for flex sigils (@/%/~/#N — see next section). Each distinct block is assigned a numeric rule index, rewritten to its canonical {{@g %c ~word #N}} form in the .i18n.html, and emitted as a row in i18n/<deflang>.inflections.json (with a parallel .inflections.meta.json holding context/ctxdetail). Translator-maintained <lang>.inflections.json files are remapped in place across runs, same as the text catalog.

Auto-fill flags.

Flag Effect
--auto-flex Consult per-language dictionaries (.db files built by cmd/dictbuild) to auto-fill empty inflection cells. Output is tagged source: "dict:unitex-lingua" and flagged for human review.
--dict-dir <dir> Directory holding <lang>.db files. Default: cmd/gen_i18n/dicts under the WINGS module.
--auto-translate Use the LLM/MT backend configured in gen_i18n.json to pre-fill text and flex entries that the dictionary pass could not fill. All LLM output is tagged with the model name and flagged revised: false for human review.

Degenerate-deflang lint. When the deflang has no gender axis (e.g. en-US) but at least one target locale does (e.g. pt-BR), any flex block missing @var would collapse every row into a single gender column — leaving the gendered locale's translator with no way to supply masculine/feminine forms. gen_i18n emits a lint: warning on stderr pointing at the first occurrence of each such block, so the webdev can add the @<var> sigil before shipping.

Flexion — Plurals & Gender (SynPrinter)

Text catalogs work when one source string maps to exactly one translated string. They break down for grammars that inflect: English "1 student / 2 students" has two forms, Portuguese "1 aluno aprovado / 2 alunos aprovados / 1 aluna aprovada / 2 alunas aprovadas" has four, and Arabic has six CLDR plural categories per gender. WINGS's flex pipeline handles this by making the grammar-shaping variables visible to the runtime via inline sigils.

The sigils are selectors, not just morphology. @var selects a row by gender — exactly what Fluent expresses as { $gender -> [male] … [female] … *[other] … } — and %var selects the CLDR plural category for the count. The per-cell table (m.one, f.other, …) is the variant set those selectors choose from, so contextual selection by gender and number is a first-class feature, not a side effect. What WINGS adds on top is optional morphological auto-fill: gen_i18n --auto-flex pre-populates those cells from Unitex DELAF dictionaries (see cmd/dictbuild), so a translator reviews suggestions instead of hand-writing every gender×number form.

Template syntax. Inside a {{...}} binding, four sigils signal a flex block (as opposed to a plain reference):

Sigil Role Example
@var Gender axis — value is the row key (e.g. "m", "f"). Not emitted into the rendered string. {{@genero ...}}
%var Count axis — value is the integer count. Emitted at its position in the rule ({n} placeholder inside cells). {{%qt ...}}
~word Flex marker — a lemma that will be inflected by the translator. Consumed at build time only (gen_i18n uses it to suggest default forms from <lang>.db). {{... ~aluno ~aprovado}}
#N Rule index — injected by gen_i18n during rewriting; the webdev does not write this by hand. {{@genero %qt ~aluno #42}}

Path-based variables. Both @var and %var accept full reference paths — useful when the axis lives inside a struct or array element:

<!-- Gender from a user record, count from the current cart line -->
<p>{{ @user.gender %cart[idx].qty ~aluno aprovado }}</p>

The resolver falls back to the cheap single-level lookup for bare names (@genero, %qt) and routes path-bearing sigils through wings.Solve against the live data context.

Order matters inside the block. %var emits the count value where it appears in the rule, so placement controls the output. For "os 10 alunos" write ~o %qt ~aluno — placing %qt between the article and the noun. A verb that agrees with number must carry its own ~ (e.g. ~ganhou); omitting it leaves a singular verb glued to a plural subject.

Catalog schema. Like the text catalog, the inflections catalog is split across two parallel files.

i18n/<lang>.inflections.json — shipped to the browser:

[
  {
    "label":   "o {n} aluno aprovado ganhou uma bolsa",
    "revised": false,
    "cells": {
      "m.one":   "o {n} aluno aprovado ganhou uma bolsa",
      "m.other": "os {n} alunos aprovados ganharam uma bolsa",
      "f.one":   "a {n} aluna aprovada ganhou uma bolsa",
      "f.other": "as {n} alunas aprovadas ganharam uma bolsa"
    },
    "sources": {
      "m.one": "dict:unitex-lingua",
      "f.one": "llm:gemma4"
    }
  }
]

i18n/<lang>.inflections.meta.json — server-only, parallel-indexed:

[
  {
    "context":   "pages/result.html:12:8",
    "ctxdetail": "caption@pages/result.html:12:8"
  }
]
  • label — translator-facing stem (the non-sigil tokens from the original block), used as a visible placeholder when no matching cell is found.
  • cells — map keyed by <gender>.<cldr-category>. CLDR categories vary per locale (zero, one, two, few, many, other). Locales without a gender axis use a single empty-string gender key (.one, .other). {n} inside a cell is replaced by the numeric count at render time.
  • revised — same semantics as the text catalog flag.
  • sources — optional per-cell provenance map (same tag format as source in the text catalog). Omitted when empty.
  • context / ctxdetail (meta) — same format as the text .meta.json.

Runtime behavior. When wi18n is imported, it fetches <lang>.inflections.json in parallel with the main text catalog and installs wings.SynPrinter — a second printer hook invoked by the syncer on every flex block. SynPrinter resolves the gender and count variables from the live data context, computes the CLDR plural category for the current locale, and looks up cells["<gender>.<cat>"].

The fallback chain handles sparse catalogs:

  1. Explicit <gender>.zero wins when count is exactly 0 (useful in pt-BR where CLDR folds 0 into one).
  2. Empty zero cell → try <gender>.one.
  3. Any other empty cell → try <gender>.other.
  4. Still empty → render the rule Label (the translator-facing stem) as a visible placeholder, rather than blank.

Without wi18n loaded, the default wings.NoFlexSynPrinter renders the rule index as #N — missing inflection support stays obvious on the page instead of silently dropping content.

Programmable Flex — Custom Inflection Engines (CustomFlex)

The built-in @/% selectors plus dictionary auto-fill cover gender and number out of the box, zero-config. When an app needs inflection the catalog can't express — a remote morphology service, a domain-specific declension, a language the dictionaries don't cover — it can supply its own engine. The principle: WINGS shapes flexion at build time and integrates it at runtime; implementing the inflection becomes the app's responsibility. With no engine available the raw lemma is emitted (visible, not a silent drop) — by design.

Additional sigils. Three sigils extend the block grammar for custom engines:

Sigil Role
*var A CustomFlex engine — a candidate to inflect the block's lemmas, elected by Priority(). May also act as a selector.
$var A dynamic value emitted verbatim (not inflected) — e.g. a proper name interleaved in the sentence. Also exposed to the engine as a selector.
~$var A dynamic value to be inflected — a ~word whose text arrives at runtime.

~ means "inflect this", $ means "dynamic value", * means "engine" — they're orthogonal (~$var = ~ + $var). The engine is always * (a $ value is pure data and can't be a motor). Blocks must not be nested.

The contract (core wings package):

type CustomFlex interface {
    // Flex returns the inflected form of word given the context. Selector
    // order is not guaranteed — identify by Name/Sigil, never by position.
    Flex(word string, selectors ...FlexSelector) (string, error)
    String() string // text this token emits at its position ("" = selector only)
}
type Prioritized interface { Priority() uint } // optional; absent ⇒ 0
type FlexSelector struct { Sigil byte; Name string; Value any }

Engine election (one per block). Candidates are the *var / ~$var values that implement CustomFlex; the highest Priority() wins and the others become selectors passed to it. @gender / %count are selectors, never candidates. The built-in catalog is the implicit engine, used only when there is no custom candidate — so the zero-config path is unchanged. A tie between user candidates is an error: it logs and falls back to emitting the raw lemmas.

Build-time split. gen_i18n keys the catalog format on the sigils it sees. A block with only @/%/~word keeps the per-cell phrase catalog above. A block carrying */~$ is split into locale-invariant control metadata (the @gender / %count bindings, the *engine, the #N index) and a per-locale content template (literal text + $var/~$var/~word + the count position) that translators reorder freely — whitespace is preserved token-by-token, so a space-free script like Japanese composes correctly.

Async engines (the critical pattern). Flex is synchronous and runs inside the render epoch — it must not block on fetch (the same deadlock class as SetLang). For a remote source: return the cached form; on a miss return a placeholder (the raw value) and start the request in a goroutine; when it resolves, populate the cache and re-trigger the render (obj.This.Set). The live demo's RemoteFlexer demonstrates this end to end.

Failure modes — all visible, never silent: a tie between user engines, a ~$var left to the catalog-default engine (which has no runtime form for it), or a Flex that returns an error each emit the raw value and log. gen_i18n emits a soft lint: note when it sees ~$ without a * in a block.

By example — built-in vs custom

The live demo runs both kinds of block side by side. Comparing them shows where each one keeps its inflection knowledge.

Built-in (catalog-backed). Gender and number, looked up from a table:

<p>{{ @gender %qt #0 }}</p>
// The component supplies only the *values* the selectors read:
func (w *Demo) InitData() map[string]any {
    return map[string]any{"gender": "f", "qt": 2}
}

There is no inflection code. A translator fills the per-cell table (m.one, m.other, f.one, f.other, …) in <lang>.inflections.json — optionally pre-filled by gen_i18n --auto-flex from dictionaries — and at runtime WINGS picks the cell by gender + CLDR plural category. The forms exist at build time; runtime is an indexed lookup.

Custom (engine-backed). A lemma chosen at runtime, inflected by your code:

<p>{{ %qt *flexer ~$produto }}</p>
// A minimal synchronous engine: pluralise Portuguese by appending "s".
type PtPluralizer struct{}

func (PtPluralizer) String() string { return "" } // contributes no text of its own

func (PtPluralizer) Flex(word string, sel ...wings.FlexSelector) (string, error) {
    n := 1
    for _, s := range sel {
        if s.Sigil == '%' { // the count axis, arriving from %qt
            if v, ok := s.Value.(int); ok {
                n = v
            }
        }
    }
    if n == 1 {
        return word, nil
    }
    return word + "s", nil // toy rule — a real engine would handle irregulars
}

// Optionally implement Priority() uint to outrank other engines / the catalog.

func (w *Demo) InitData() map[string]any {
    return map[string]any{
        "qt":      1,
        "produto": "maçã",         // dynamic: e.g. bound to a <select>
        "flexer":  PtPluralizer{},  // the *flexer engine
    }
}

Here ~$produto is a lemma whose text is dynamic (the user picks it from a dropdown), so it cannot live in a build-time catalog; *flexer inflects it at runtime, and %qt is handed to the engine as a selector.

Built-in (@/% + ~word) Custom (*engine + ~$var)
Who provides the forms translator (catalog cells) + optional dict auto-fill app developer (engine code)
When build time → indexed lookup at runtime runtime
Best for fixed phrases, known lemmas, gender/number dynamic lemmas, remote services, rules a table can't hold
When absent renders the Label stem renders the raw value

The engine above is synchronous. For a backend-driven engine — where Flex cannot block on the network — see the demo's RemoteFlexer: it returns the raw word as a placeholder on a cache miss, fetches in a goroutine, and re-runs the render (obj.This.Set) once the form arrives.

Arbitrary selection (coming from Fluent)

Selection in a flex block is not limited to gender and number. Every participant is handed to the engine as a FlexSelector, so an engine can branch on an arbitrary key — the role Fluent gives an arbitrary selector:

# Fluent
shortcut = { $platform ->
    [macos]  ⌘ + C
   *[other]  Ctrl + C
}

In WINGS that selection lives in a CustomFlex engine. The variants can be a plain JSON table — shaped like the Fluent selector above, though an engine is free to store its data however it likes:

{ "copy": { "macos": "⌘ + C", "*": "Ctrl + C" } }
type Shortcuts struct{ table map[string]map[string]string }

func (s Shortcuts) String() string { return "" }
func (s Shortcuts) Priority() uint  { return 10 }

func (s Shortcuts) Flex(word string, sel ...wings.FlexSelector) (string, error) {
    platform := "*"
    for _, sl := range sel {
        if sl.Sigil == '$' && sl.Name == "platform" { // the $platform selector
            if p, ok := sl.Value.(string); ok && p != "" {
                platform = p
            }
        }
    }
    if v, ok := s.table[word][platform]; ok {
        return v, nil
    }
    return s.table[word]["*"], nil
}
<p>{{ *help Copy on $platform uses ~$action }}</p>

With "platform": "macos", "action": "copy", "help": myShortcuts this renders "Copy on macos uses ⌘ + C". Note that $platform plays two roles at once (like %count): it is emitted in the sentence and handed to the engine as a selector. The surrounding words remain ordinary catalog i18n (translated per locale) while the engine resolves only the variant — so selection composes with translation. And because the same engine inflects ~word/~$var, WINGS unifies selecting a variant with generating one, where Fluent selects among pre-authored variants only. See the live demo's PlatformHelper.

Message reuse (=name / #name)

The last piece Fluent has that a per-block syntax otherwise lacks is one message referencing another — define a phrase once, reuse it in many places so the translator edits a single entry:

# Fluent
-product = { $count } apples
cart    = In your cart: { -product }
receipt = Payment for { -product } confirmed

WINGS expresses this with two sigils that reuse the # you already know:

  • =name defines: it names this block's message. The block still renders in place — naming is additive.
  • #name uses: it renders the named message here. This unifies #: #42 is "the message at index 42" (the literal index gen_i18n injects), and #name is "the message at the index bound to name" — literal vs. variable, the same distinction the rest of WINGS draws.
<!-- define once (and render it here) -->
<p>{{ =cart %count *flexer ~$product }}</p>

<!-- reuse anywhere -->
<p>In your cart: {{ #cart }}.</p>
<p>Payment for {{ #cart }} confirmed.</p>

Names are global per catalog: a #name in any file resolves a =name in any other file, in any order. Resolution happens entirely at build timegen_i18n rewrites #name to the definer's #N and emits a plain runtime block, so the browser sees nothing new and reuse works for both catalog (gender×number) and programmable messages.

A reuse site inherits the definer's control axes and may override them per slot: an @gender or %count declared at the site wins (otherwise the definer's is inherited), and declaring any *engine replaces the inherited engines wholesale. The message content (~word/$var/~$var) always comes from the definition — so each reference resolves its variables in the context where it appears, which is strictly more than Fluent (whose message references are static; only its terms take arguments):

<!-- inherits *flexer and %count from =cart -->
<p>{{ #cart }}</p>

<!-- same message, but counted by a different variable at this site -->
<p>{{ #cart %backorder }}</p>

A literal = glued to a word (price=value) would otherwise read as =value; write == to escape it. A #name with no matching =name is left verbatim and logged — a visible, fixable degradation rather than a silent blank. See the live demo's reuse section in flextab.html.

Locale-Aware Formatting (FmtPrinter)

Text catalogs translate strings and flex blocks resolve plurals/gender. A third axis — values — needs locale-aware treatment too: numbers use different decimal and grouping separators across locales, currencies have their own symbols and fractional digits, dates span a zoo of formats. The FmtPrinter pipeline handles all of these through the same sigil already used for the count axis of flex blocks: %var.

Template syntax. When a {{...}} binding contains exactly one %var (optionally followed by a path tail), it is a format block — the value is resolved from the data context and handed to wings.FmtPrinter, which picks the rendering based on the Go type of the value:

<!-- Plain numeric value (locale separators) -->
<p>Total: {{%count}} unidades</p>

<!-- Nested path -->
<p>Saldo: {{%user.balance}}</p>

<!-- Array element — identical semantics to a plain reference path -->
<td>{{%invoices[idx].total}}</td>

A %var that shares a {{...}} with any of @var, ~word, #N, or other literal tokens is interpreted as a flex block count axis instead (see the previous section) — the lone-%var rule is the only ambiguity cleared at parse time, and it falls on the common case.

Type-directed rendering. wi18n's FmtPrinter dispatches on the value's Go type in this order:

Type Output
nil empty string
int, int64, uint, uint64, etc. Intl.NumberFormat integer (grouping per locale)
float32, float64 Intl.NumberFormat default precision
time.Time Intl.DateTimeFormat (epoch ms bridge)
js.Value holding a JS Date Intl.DateTimeFormat direct
anything implementing wi18n.Numerical v.Format(locale, formatName)(string, error); error stops rendering
anything else fmt.Sprint fallback

wi18n uses the browser's Intl API rather than bundling locale tables (ICU/CLDR) inside the WASM binary — keeps the artifact small and honors the browser's tz database for dates.

Numerical interface — customization without registries. Any Go type that implements the following interface is treated as a first-class formattable value:

type Numerical interface {
    Format(locale, formatName string) (string, error)
}

There is no registration step; satisfying the interface is the registration. formatName comes from the %var:formatName template syntax (empty string when the template uses bare %var).

Returning a non-nil error stops rendering for that binding: FmtPrinter discards the returned string, logs the error with locale/formatName context, and emits an empty string. Returning ("", nil) produces an empty string without triggering the error path. The implementation may log domain-level detail before returning the error; FmtPrinter adds the locale/formatName context on top.

wi18n.Currency — built-in example of Numerical.

type Currency struct {
    Amount int64  // smallest unit (centavos, cents, yen-units)
    Code   string // ISO 4217 (BRL, USD, JPY, BHD)
}

Currency implements Numerical. The amount is stored as a signed int64 in the currency's minor unit — no float rounding surprises on financial data — and the ISO code travels with the value so multi-currency templates work by iterating a []Currency naturally:

// mod/invoice/invoice.go
data := map[string]any{
    "lines": []wi18n.Currency{
        {Amount: 123450, Code: "BRL"}, // R$ 1.234,50 in pt-BR
        {Amount: 9999,   Code: "USD"}, // $99.99 in en-US
    },
}
<!-- mod/invoice/invoice.html -->
<tr *lines:i><td>{{%lines[i]}}</td></tr>

Applications with a single fixed currency typically wrap Currency in a helper:

func BRL(n int64) wi18n.Currency { return wi18n.Currency{Amount: n, Code: "BRL"} }

Or define their own domain type implementing Numerical and delegating to Currency internally — that is exactly the pattern the built-in physical measure packages follow (see Physical Measure Packages).

An ISO 4217 table (embedded in wi18n/currency_iso4217.go) decides the number of fractional digits — most currencies use 2, with documented exceptions for zero-decimal (JPY, KRW, VND, …), three-decimal (BHD, KWD, …), and four-decimal (CLF, UYW) cases.

Fallback behaviour. Without wi18n imported, wings.FmtPrinter stays as the default NoFmtFmtPrinter, which renders values via fmt.Sprint — locale-incorrect but never blank. When wi18n is loaded but the browser's Intl rejects a locale/currency combination (or the environment has no Intl at all), each formatter falls back to a locale-agnostic rendering (strconv, plain decimal point, RFC 3339 for dates) so the page stays readable.

Named formats (%var:formatName). The :formatName suffix is interpreted differently depending on the value type:

  • Numerical types (measure packages, Currency, custom types): formatName is passed as the second argument to Format; the implementation decides what to do with it — measure packages treat it as a unit override ({{%dist:km}}), Currency ignores it.
  • Native scalars (int, float64, etc.) and time.Time / JS Date: fmtPrinter looks up formatName in the current locale's <lang>.fmt.json "named" section and, if found, renders through the corresponding Intl.NumberFormat or Intl.DateTimeFormat ({{%count:compact}}). If the name is not configured, rendering falls back to the default locale-aware behavior and formatName is silently ignored.

The suffix is optional: bare {{%dist}} passes an empty string.

Per-locale <lang>.fmt.json. A file placed next to the text catalog (e.g. i18n/en-US.fmt.json) carries two optional entry shapes loaded automatically by wi18n when the locale is switched:

{
  "km":      { "decimals": 2 },
  "mi":      { "decimals": 3 },
  "compact": { "type": "number", "notation": "compact" },
  "short":   { "type": "date",   "dateStyle": "short"  }
}

Entries with a "type" field are named scalar formats: every key except "type" is forwarded verbatim as an Intl option object. "type": "number" builds an Intl.NumberFormat; "type": "date" builds an Intl.DateTimeFormat. The resulting formatter is cached per (locale, name) and reused across renders.

Entries without "type" are unit-precision overrides ("decimals" key): wi18n.UnitDecimals(locale, unit) returns the value, and measure packages consult it before their built-in default. wi18n.NamedFmt(locale, name) exposes named scalar entries to callers that need the raw Intl options.

wi18n.SetConfig — app-level unit overrides. Call wi18n.SetConfig(jsonBytes) at startup (e.g. from an embedded wings.json) to set per-locale default units for any quantity:

{ "measures": { "pt-BR": { "length": "km" }, "en-US": { "length": "mi" } } }

wi18n.MeasureDefault(quantity, locale) returns the configured unit, or ("", false) when none is set. Measure packages consult this before falling back to their built-in locale heuristics.

Physical Measure Packages

Eight packages under wi18n/ model common physical quantities. Each stores the value in a canonical SI unit and implements wi18n.Numerical (the //go:build js && wasm Format method is in a separate file so the pure-Go math is testable on the host):

Package Type Canonical Unit names
wi18n/length Length{Meters float64} m m km cm mm mi ft yd in nmi league
wi18n/temperature Temperature{Kelvin float64} K k/kelvin c/celsius f/fahrenheit r/rankine
wi18n/speed Speed{MetersPerSecond float64} m/s ms kmh mph kn fps
wi18n/volume Volume{Liters float64} L L mL dL m3 floz pt qt gal galimp
wi18n/weight Weight{Kilograms float64} kg kg g mg t lb oz st
wi18n/area Area{SquareMeters float64} m2 km2 cm2 mm2 ha mi2 ft2 yd2 in2 ac
wi18n/fueleconomy FuelEconomy{LitersPer100km float64} L/100 km l100km mpg mpgimp kml
wi18n/cooking Volume{Liters float64} / Weight{Kilograms float64} L / kg vol: L mL cup tbsp tsp floz · wt: kg g lb oz

Template usage. The data key holds a value of the measure type; the template uses %var (locale default) or %var:unit (explicit unit):

// mod/trip/trip.go
func (t *Trip) InitData() map[string]any {
    return map[string]any{
        "dist": length.Length{Meters: 42195},
        "temp": temperature.Temperature{Kelvin: 310.15},
    }
}
<!-- locale default -->
<td>{{%dist}}</td>

<!-- explicit unit override -->
<td>{{%dist:km}}</td>
<td>{{%dist:mi}}</td>
<td>{{%temp:f}}</td>

Locale defaults. Each package's DefaultUnit(locale) applies common regional conventions: lengthmi for en-US/en-GB/en-LR/my, temperaturef for en-US/en-BS/en-BZ/en-KY, speedmph for the same imperial group, volumegal for en-US, weightlb for en-US, fueleconomympg for en-US and mpgimp for en-GB. All other locales get SI/metric defaults.

Unit name constraints. Unit identifiers contain only ASCII letters and digits — no / or - — so they are safe as %var:unit tokens in templates (kmh not km/h, floz not fl-oz, ms not m/s).

fueleconomy inverse units. mpg, mpgimp, and kml are inversely proportional to LitersPer100km. Format and Convert return a non-nil error when LitersPer100km ≤ 0 for these units (avoids +Inf).

cmd/dictbuild & cmd/dictlookup — Flexion Dictionaries

These two tools convert a Unitex/GramLab DELAF .dic (UTF-16 text) into a compact Go-native lookup structure used to pre-fill plural/gender flexions during the build.

dictbuild has two invocation modes:

  • dictbuild -lang <tag> — fetch mode. Downloads the compiled .bin/.inf for the requested locale from UnitexGramLab/unitex-lingua, shallow-clones UnitexGramLab/unitex-core at a pinned tag (currently v3.3), builds UnitexToolLogger from source with make UNITEXTOOLLOGGERONLY=yes 64BITS=yes, expands the compiled dictionary back to UTF-16 text via Uncompress, and parses the result. Persistent state (cloned tools, compiled binary, cached .bin/.inf) lives under -state-dir (defaults to $XDG_CACHE_HOME/wings/dictbuild). Pass -tool /path/to/UnitexToolLogger to skip the auto-build if you already have the binary. Output: <tag>.db in -out (defaults to .). Linux/macOS only — Windows requires a manual MSVC build.
  • dictbuild [-out <dir>] <input.dic> <tag> — legacy mode for users who already have a UTF-16 DELAF on hand (e.g. produced by Unitex/GramLab desktop or pulled from an internal mirror).

The auto-fetch table covers every language directory in unitex-lingua that ships at least one usable DELAF .bin/.inf pair: de, el, en, es, fi, fr, grc, it, la, mg, nn, no, oge, pl, pt-BR, pt-PT, ru, sr-Cyrl, sr-Latn, th, zh. For each locale the most general/inflected dictionary upstream provides was chosen; some entries are samples or partial dictionaries (el's "30percent", nn's "Dela-sample", grc's demo) because that is all upstream ships. ar (separate noun and verb DELAFs) and ko (empty upstream Dela/) are intentionally not listed. The dictbuild parser is PT-centric in its verb-class aliasing (see aliasHomograph), so <lang>.db for non-Portuguese locales is structurally correct but may need language-specific tweaks on the gen_i18n side to interpret verbal classes.

In both modes the tool applies DELAF-specific filters:

  • +Pr (proper names) → dropped
  • +PRO (enclitic pronoun) → dropped
  • Imperative forms (code Y*) → dropped
  • Finite 1st/2nd person verbal forms → dropped (only 3rd person, infinitive, gerund, and participle are needed for count/gender agreement in templates)

Each entry is decomposed into three axes — Class (tense/mood stem), Genre (m/f/n/empty), Count (s/p/empty). The resulting shape:

type Dict struct {
    Lemmas    map[string]*Lemma   // canonical form → grouped inflections
    FormIndex map[string][]FormRef // surface form → refs back into Lemmas
}
type FormRef struct { Lemma, Class, Genre, Count string }
type Lemma   struct {
    Category string                // "N", "V", "A", "ADV", ...
    Forms    map[string]Inflect    // key = Class+Genre+Count, e.g. "ms", "fp"
}
type Inflect struct { DiffPos int; Suffix string } // DiffPos in runes

dictlookup <file.db> <word> is a human-readable inspector. It prints every FormIndex hit for the queried word, resolves each hit to its parent Lemma, and reconstructs the surface form of every kept inflection so the output needs no post-processing.

$ dictlookup pt-BR.db passou
loaded pt-BR.db: 128430 lemmas, 984712 form entries

FormIndex["passou"] → 1 reference(s):
  [0] Lemma="passar" Class="J" Genre="" Count="s"
      passar (V)
        W:    passar       (infinitive)
        G:    passando     (gerund)
        K:ms  passado      ...
        ...

helpers/wlate — Translation Editor GUI

helpers/wlate/ is a wings-built WASM app designed for translators to review and edit catalogs side-by-side with a reference language.

Features (implemented):

  • Two-panel layout: reference language (read-only) vs. target language (editable), with per-entry "revised" toggle (bordeaux left border = not yet reviewed; gray = reviewed).

  • Two tabs: Texto (plain strings) and Inflexões (gender × CLDR plural category grid, using CSS Grid with display: contents on iteration wrappers).

  • Keyboard shortcuts for navigation and toggling revised state (all rebindable via wings.json).

  • Filter toggle: show only unrevised entries.

  • Unsaved-changes guard (in-app dialog + beforeunload).

  • On-save file creation: if the target catalog doesn't exist, wlate creates it mirroring the reference structure with empty content and revised: false.

  • JSON schemas:

    // i18n/<lang>.json — browser bundle (content + provenance)
    [{"content": "...", "revised": false, "source": "llm:gemma4"}]
    
    // i18n/<lang>.meta.json — server-only, parallel-indexed
    [{"context": "pages/result.html:12:8",
      "ctxdetail": "th@pages/result.html:12:8<br/>button[title]@pages/result.html:40:17"}]
    
    // i18n/<lang>.inflections.json
    // Sigil order matters: %qt emits the number where it appears, so place
    // it AFTER ~o and BEFORE ~aluno for "os 10 alunos" (not "10 os alunos").
    // Verbs that conjugate with number (~ganhou/ganharam) need the ~ too —
    // missing a ~ on a number-agreeing verb causes "10 alunos ganhou" bugs.
    [{"label": "o {n} aluno aprovado ganhou uma bolsa", "revised": false,
      "cells": {"m.one": "...", "f.other": "..."},
      "sources": {"m.one": "dict:unitex-lingua"}}]
    
    // i18n/<lang>.inflections.meta.json — server-only, parallel-indexed
    [{"context": "...", "ctxdetail": "caption"}]
    

Collaboration & review come from your VCS. Translation and inflection catalogs are plain files in your repo tree, so branch / PR / merge / blame / review apply to them exactly as to code — no separate TMS to provision or sync. A translator works on a branch, opens a pull request, and reviewers diff the JSON like any other change. wlate adds the editing ergonomics on top: per-entry approval (revised), per-cell provenance (translator email when OAuth2 is configured), and integrated MT/LLM pre-fill (--auto-translate). The design premise is deliberate — a team that versions its code already has the workflow translations need, and a team that doesn't version its code won't want a heavier localization pipeline either.

Self-i18n. wlate itself is built as a translatable WINGS app — the editor eats its own dog food. Its templates live under helpers/wlate/mod/wlate/, build.sh runs gen_i18n against that tree before compiling the WASM, and the resulting catalogs are published to dist/wlate-i18n/ so they don't collide with the /i18n/* route used for the project being translated. On startup, main.go calls wi18n.SetBasePath("wlate-i18n/") to point the runtime fetch at that directory. Source language is pt-BR; en-US ships fully translated, es-CO ships as a pt-BR copy with revised=false for human review.

Build and run:

cd helpers/wlate
bash build.sh                 # runs gen_i18n, copies catalogs, builds WASM
go run serve.go <project-dir> # starts the mini-server (see below)
server.conf — mini-server configuration

serve.go is both a dev server and a production-capable mini-server. If a file named server.conf sits next to the executable, it is parsed as key=value lines (comments starting with # and blank lines allowed; values may be single- or double-quoted). Without server.conf, the server keeps its original defaults (listens on :8080, plain HTTP, no auth).

Basic keys:

Key Effect
cert=<name> Enables TLS. Loads <name>.crt (or <name>.pem) from the executable's directory; if the file doesn't contain the private key, also loads <name>.key. Fatal on any load error.
listen=<address> Address passed verbatim to net.Listen("tcp", …). Fatal on bind error.
root=<path> Overrides the positional <project-dir> argument.

OAuth2 / OIDC keys (all required together when any is set):

Key Effect
oauth2_issuer=<url> OIDC issuer URL. Discovery via /.well-known/openid-configuration; the provider must expose userinfo_endpoint.
oauth2_client_id=<id> Client ID registered with the provider.
oauth2_client_secret=<s> Client secret.
oauth2_redirect_url=<url> Absolute callback URL; typically https://<host>/oauth2/callback.
oauth2_allowed=<path> Optional. Path to a text file listing one allowed e-mail per line (blank lines and # comments ignored). If omitted, any authenticated user is allowed.

When OAuth2 is configured, every non-/oauth2/* request is gated: GET requests without a valid session cookie redirect to the provider's authorization endpoint; other methods return 401. The callback exchanges the code for an access token, fetches the e-mail from userinfo, checks the allowlist, and issues a wlate_session cookie (HttpOnly, SameSite=Lax, Secure when served over TLS, 12 h TTL). Sessions are stored in-memory. GET /oauth2/logout invalidates the session cookie.

Example server.conf:

# Bind and TLS
listen=:8443
cert=wlate

# Project root (overrides CLI arg)
root=/var/lib/wlate/mytranslations

# Gated access via Google OIDC
oauth2_issuer=https://accounts.google.com
oauth2_client_id=1234567890-abc.apps.googleusercontent.com
oauth2_client_secret=GOCSPX-…
oauth2_redirect_url=https://wlate.example.com/oauth2/callback
oauth2_allowed=/etc/wlate/allowed.txt
# /etc/wlate/allowed.txt — one e-mail per line
translator1@example.com
translator2@example.com

OAuth2 support uses only the Go standard library (no go-oidc / x/oauth2 dependencies).

Component Lifecycle

  1. Registration (init()): Module calls wings.Register() to register the custom element tag, template, CSS, factory function, and observed attributes.

  2. Construction (automatic): When the browser encounters the custom element tag, the constructor creates a shadow root, injects CSS, parses the template, calls InitData() for initial state, and sets up data bindings.

  3. Connection (automatic): When the element is inserted into the DOM, connectedCallback fires and sets the ready flag.

  4. Render (automatic): Once connected, Render(obj) is called with the PranaObj containing:

    • obj.This*ReactiveData for reading/writing state
    • obj.Domjs.Value of the container SPAN in the shadow root
    • obj.Elementjs.Value of the custom element itself
    • obj.Trigger — function to dispatch events to the parent component
  5. Attribute Changes (automatic): When an observed attribute changes, the new value is copied into the data map and a sync is triggered.

  6. Disconnection (automatic): When the element is removed from the DOM, event handlers and bindings are cleaned up.

Parent-Child Communication

Passing Data Down (Attributes)

The parent passes data to children via attributes with {{expression}} bindings:

<!-- Parent template -->
<my-child
    title="{{page_title}}"
    is_logged="{{is_logged}}"
    is_anonymous="{{is_anonymous}}"
></my-child>

Data flows one-way: parent to child. When the parent's data changes, the child's attributes are updated automatically. To communicate from child back to parent, use Triggers.

Dispatching Events Up (Trigger)

Children fire named events that invoke handler functions in the parent. The @ attribute in the parent's template maps event names to handler names:

Parent template (app.html):

<!-- @login maps event "login" to handler function "on_login" -->
<my-login @login="on_login"></my-login>

Parent code (app.go):

func (app *App) InitData() map[string]any {
    return map[string]any{
        // Placeholder — obj not available yet
        "on_login": wings.TriggerHandler(nil),
    }
}

func (app *App) Render(obj *wings.PranaObj) {
    // Real handler with obj in scope
    obj.This.Set("on_login", func(args ...any) {
        obj.This.Set("is_logged", true)
    })
}

Child Render (login.go):

func (lgn *Login) Render(obj *wings.PranaObj) {
    // Trigger uses the event name (without @), not the handler name
    obj.Trigger("login", username)  // matches @login in parent template
}

The flow is: obj.Trigger("login") -> looks up @login attribute on the child element -> finds "on_login" -> resolves on_login in the parent's data map -> calls the function.

Syntax Quick-Reference

Prefix Name Example Description
{{ }} Binding {{user.name}} Display a data value. Updates automatically on change.
{{#}} Hash {{#}} Current URL hash fragment. Updates on hashchange.
? Conditional ?is_admin Show/hide element based on truthiness.
?! Negated ?!is_loading Show element only when value is falsy.
?="val" Equality ?status="active" Show element only when value equals "val".
?!="val" Inequality ?status!="deleted" Show element only when value does not equal "val".
?^="val" Prefix ?url^="https" Show element only when value starts with "val".
?$="val" Suffix ?name$=".pdf" Show element only when value ends with "val".
?*="val" Contains ?tags*="urgent" Show element only when value contains "val".
* Iteration *items:i Repeat element for each array item (wrapped in <span>).
** Iteration (no wrap) **items:i Repeat first child for each item (container stays).
& Two-way &value="{{val}}" Sync <input> / <select> / <textarea> with data.
@ Event @click="on_save" Dispatch child event to parent handler function.
@var Flex gender (i18n) {{@genero ~o %qt ~aluno}} Gender axis in a flexion block. Value keys the <gender>.<category> row.
%var Flex count (i18n) {{%qt ~aluno}} Count axis in a flexion block. Emitted at its position; drives CLDR plural category.
%var (lone) Format (i18n) {{%preco}} Locale-aware formatting. Type-directed: ints/floats, time.Time, wi18n.Currency, or any Numerical.
~word Flex stem (i18n) {{~aluno ~aprovado}} Build-time marker for a word the translator will inflect. Consumed by gen_i18n.
#N Flex rule index (i18n) {{@genero %qt #42}} Auto-assigned by gen_i18n when rewriting .i18n.html. Webdev never writes this.

Important Notes

Attribute Names Are Lowercased

HTML attribute names are always converted to lowercase by the browser. This means template variables used in attributes (?condition, &attr, @event) must use lowercase names only. Use snake_case for multi-word identifiers. The ! suffix used for inequality (?var!="val") is preserved by the browser since only letters are lowercased:

// CORRECT
"is_logged":    false,
"is_anonymous": true,

// WRONG - will not match because browser lowercases attributes
"isLogged":    false,
"isAnonymous": true,
<!-- CORRECT -->
<my-child ?is_logged is_anonymous="{{is_anonymous}}"></my-child>

<!-- WRONG -->
<my-child ?isLogged isAnonymous="{{isAnonymous}}"></my-child>

Note: variables used only in text content ({{camelCase}}) are not affected by this restriction, since they are parsed from text nodes, not attributes. However, for consistency, snake_case is recommended everywhere.

The build orchestrator catches this for you: go run ./cmd/build <target> scans every template and fails the build if a binding name (?cond, *arr, **arr, &attr) contains an uppercase letter, with the offending file:line — so a camelCase binding can never ship as a silent no-op.

Template Root Element

If your template has multiple top-level elements, WINGS automatically wraps them in a <span>. For predictable styling, consider using a single root element:

<!-- Multiple roots (auto-wrapped in span) -->
<header>...</header>
<main>...</main>

<!-- Single root (no wrapper needed) -->
<div>
    <header>...</header>
    <main>...</main>
</div>

Full Example

main.go

//go:build js && wasm

package main

import (
    "github.com/luisfurquim/wings"
    _ "myapp/mod/mywidget"
)

func main() {
    wings.Main()
}

mod/mywidget/mywidget.go

//go:build js && wasm

package mywidget

import (
    _ "embed"
    "syscall/js"

    "github.com/luisfurquim/wings"
    "github.com/luisfurquim/wings/dom"
    "github.com/luisfurquim/wings/timer"
)

//go:embed mywidget.html
var htmlContent string

//go:embed mywidget.css
var cssContent string

type MyWidget struct{}

func init() {
    wings.Register(
        "my-widget",
        htmlContent,
        cssContent,
        func() wings.PranaMod { return &MyWidget{} },
        "title",
    )
}

func (w *MyWidget) InitData() map[string]any {
    return map[string]any{
        "title":      "wings live demo",
        "count":      0,
        "count2":     0,
        "items":      []any{},
        "show_extra": false,
        "extra":      "This is extra content toggled by a boolean conditional.",
        "input_val":  "",
        "mode":       "edit",
    }
}

func (w *MyWidget) Render(obj *wings.PranaObj) {
    // Default to #list page if no hash fragment
    if js.Global().Get("location").Get("hash").String() == "" {
        wings.GoTo("list")
    }

    // Populate items
    obj.This.Set("items", []any{
        map[string]any{"label": "Alpha"},
        map[string]any{"label": "Beta"},
        map[string]any{"label": "Gamma"},
    })

    // Keep input_val in sync on every keystroke
    inputs := dom.Query(obj.Dom, "input[type=\"text\"]")
    if len(inputs) > 0 {
        dom.AddEvent(inputs[0], "input",
            func(this js.Value, args []js.Value) any {
                obj.This.Set("input_val", inputs[0].Get("value").String())
                return nil
            }, false, false)
    }

    // Form submit: add item
    forms := dom.Query(obj.Dom, "form")
    if len(forms) > 0 {
        dom.AddEvent(forms[0], "submit",
            func(this js.Value, args []js.Value) any {
                val := obj.This.Get("input_val").(string)
                if val != "" {
                    obj.This.Append("items", map[string]any{"label": val})
                    obj.This.Set("input_val", "")
                }
                return nil
            }, true, false)
    }

    // Toggle mode button
    toggleBtns := dom.Query(obj.Dom, "#btn-toggle-mode")
    if len(toggleBtns) > 0 {
        dom.AddEvent(toggleBtns[0], "click",
            func(this js.Value, args []js.Value) any {
                mode := obj.This.Get("mode").(string)
                if mode == "edit" {
                    obj.This.Set("mode", "readonly")
                } else {
                    obj.This.Set("mode", "edit")
                }
                return nil
            }, false, false)
    }

    // Toggle extra button
    extraBtns := dom.Query(obj.Dom, "#btn-toggle-extra")
    if len(extraBtns) > 0 {
        dom.AddEvent(extraBtns[0], "click",
            func(this js.Value, args []js.Value) any {
                show := obj.This.Get("show_extra").(bool)
                obj.This.Set("show_extra", !show)
                return nil
            }, false, false)
    }

    // Navigation links
    navList := dom.Query(obj.Dom, "#nav-list")
    if len(navList) > 0 {
        dom.AddEvent(navList[0], "click",
            func(this js.Value, args []js.Value) any {
                wings.GoTo("list")
                return nil
            }, true, false)
    }
    navDash := dom.Query(obj.Dom, "#nav-dash")
    if len(navDash) > 0 {
        dom.AddEvent(navDash[0], "click",
            func(this js.Value, args []js.Value) any {
                wings.GoTo("dashboard")
                return nil
            }, true, false)
    }

    // Page 1 counter: every 2 seconds
    go func() {
        tk := timer.NewTicker(2000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count", n)
        }
    }()

    // Page 2 counter: every 5 seconds
    go func() {
        tk := timer.NewTicker(5000)
        defer tk.Stop()
        n := 0
        for range tk.Tick {
            n++
            obj.This.Set("count2", n)
        }
    }()
}

mod/mywidget/mywidget.html

<div class="widget">
   <h1>{{title}}</h1>

   <!-- Navigation -->
   <nav>
      <a id="nav-list" href="#list">List</a>
      <a id="nav-dash" href="#dashboard">Dashboard</a>
   </nav>

   <!-- Toolbar -->
   <div class="toolbar">
      <button id="btn-toggle-mode">Toggle Mode</button>
      <button id="btn-toggle-extra">Toggle Extra</button>
      <span class="mode-badge">Mode: <strong>{{mode}}</strong></span>
   </div>

   <!-- Page 1: List -->
   <div ?#="list" class="page page-list">
      <h2>Item List</h2>
      <p>Auto-counter (every 2s): <span class="counter">{{count}}</span></p>

      <!-- Boolean conditional -->
      <div ?show_extra class="extra-box">
         <p>{{extra}}</p>
      </div>

      <!-- Negated boolean conditional -->
      <div ?!show_extra class="extra-hint">
         <p>Click "Toggle Extra" to reveal extra content.</p>
      </div>

      <!-- Equality conditional: only in edit mode -->
      <div ?mode="edit">
         <form>
            <input &value="{{input_val}}" type="text" placeholder="Add item..." />
            <input type="submit" value="Add" />
         </form>
      </div>

      <!-- Inequality conditional: hidden when readonly -->
      <div ?mode!="readonly" class="edit-hint">
         <p>Form is visible because mode != "readonly".</p>
      </div>

      <!-- Prefix conditional -->
      <div ?mode^="ed" class="cond-demo">
         <p>Prefix match: mode starts with "ed"</p>
      </div>

      <!-- Suffix conditional -->
      <div ?mode$="it" class="cond-demo">
         <p>Suffix match: mode ends with "it"</p>
      </div>

      <!-- Contains conditional -->
      <div ?mode*="d" class="cond-demo">
         <p>Contains match: mode contains "d"</p>
      </div>

      <ul>
         <li *items:i>{{items[i].label}}</li>
      </ul>
   </div>

   <!-- Page 2: Dashboard -->
   <div ?#="dashboard" class="page page-dash">
      <h2>Dashboard</h2>
      <p>Slow counter (every 5s): <span class="counter counter-lg">{{count2}}</span></p>

      <div class="dash-grid">
         <div class="dash-card">
            <h3>Items</h3>
            <ul>
               <li *items:i>{{items[i].label}}</li>
            </ul>
         </div>
         <div class="dash-card">
            <h3>Status</h3>
            <p>Mode: <strong>{{mode}}</strong></p>
            <p>Extra visible: <strong>{{show_extra}}</strong></p>
            <p>Fast counter: <strong>{{count}}</strong></p>
         </div>
      </div>

      <!-- Boolean conditional on dashboard too -->
      <div ?show_extra class="extra-box">
         <p>{{extra}}</p>
      </div>
   </div>
</div>

mod/mywidget/mywidget.css

.widget {
   max-width: 600px;
   margin: 24px auto;
   padding: 24px;
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
   color: #1a1a2e;
   background: #fff;
   border-radius: 12px;
   box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
h1 { margin: 0 0 12px; font-size: 1.4em; color: #16213e; }
nav { display: flex; gap: 8px; margin-bottom: 16px; border-bottom: 2px solid #e8e8e8; padding-bottom: 12px; }
nav a { padding: 6px 16px; border-radius: 6px; text-decoration: none; color: #0f3460; font-weight: 600; background: #e8f0fe; cursor: pointer; }
nav a:hover { background: #c5d9f7; }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
.toolbar button { padding: 6px 14px; border: 1px solid #ccc; border-radius: 6px; background: #f4f4f9; cursor: pointer; }
.toolbar button:hover { background: #dde; }
.mode-badge { font-size: 0.85em; color: #555; }
h2 { margin: 0 0 12px; font-size: 1.15em; color: #0f3460; }
.counter { display: inline-block; background: #0f3460; color: #fff; padding: 2px 10px; border-radius: 4px; font-weight: 700; }
.counter-lg { font-size: 2em; padding: 8px 20px; border-radius: 8px; }
.extra-box { background: #e8f5e9; border-left: 4px solid #4caf50; padding: 8px 12px; border-radius: 4px; margin: 10px 0; }
.extra-hint { background: #fff3e0; border-left: 4px solid #ff9800; padding: 8px 12px; border-radius: 4px; margin: 10px 0; font-style: italic; }
.cond-demo { background: #ede7f6; border-left: 4px solid #7e57c2; padding: 6px 12px; border-radius: 4px; margin: 6px 0; font-size: 0.9em; }
form { display: flex; gap: 8px; margin: 12px 0; }
input[type="text"] { flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 6px; }
input[type="submit"] { padding: 6px 16px; background: #0f3460; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; }
ul { list-style: none; padding: 0; }
li { padding: 6px 10px; background: #f9f9fb; border-radius: 4px; margin-bottom: 4px; border: 1px solid #eee; }
.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
.dash-card { background: #f4f4f9; border-radius: 8px; padding: 12px; border: 1px solid #e0e0e0; }
.dash-card h3 { margin: 0 0 8px; font-size: 1em; color: #16213e; }

Security

Shadow DOM isolation

Every custom element registered with Register uses an open shadow root (mode: "open") by default. This means any same-page script can access the component's internal DOM via element.shadowRoot.

Relevant advisories: CVE-2019-11730 (Firefox same-origin bypass via shadow DOM adoption), GHSA-wh77-3x4m-4q9g (Lit SSR shadow DOM template injection).

If you need stronger DOM isolation, use RegisterWithOpts and set ComponentOpts.Closed = true:

wings.RegisterWithOpts("my-widget", html, css,
    wings.ComponentOpts{Closed: true},
    func() wings.PranaMod { return &MyWidget{} },
)

With Closed: true the shadow root's mode is "closed" — external scripts get null from element.shadowRoot. The WINGS runtime itself accesses the shadow root via the reference stored at construction time, so all internal features (CSS injection, Update(), data binding) continue to work normally.

Trade-offs of closed mode:

  • Testing tools that query the shadow root via element.shadowRoot will break and must be refactored to use events, attributes, or test IDs.
  • DevTools in some browsers show closed shadow roots with limited visibility.
  • Closed mode is not an absolute security barrier — slotted content, CSS custom properties, and custom events still cross shadow boundaries.

innerHTML invariant

domCreateTemplate(html) sets template.innerHTML internally. The html argument must be a compile-time constant (typically a //go:embed string). Never pass runtime, user-supplied, or server-fetched content to this function — innerHTML is a DOM XSS sink.

WASM memory isolation

wasm_exec.js by default stores the Go runtime object as window.go, which exposes the WASM linear memory buffer to any same-origin script. The index.html templates in this repository wrap the instantiation in an async IIFE so go never reaches window:

(async () => {
    const go = new Go();
    const r = await WebAssembly.instantiateStreaming(fetch("wings.wasm"), go.importObject);
    go.run(r.instance);
})().catch(console.error);

Copy this pattern in your own index.html.

Script integrity (SRI)

build.sh injects integrity="sha384-..." attributes on the <script> tags for prana_helper.js and wasm_exec.js after every build. This prevents a compromised CDN or build pipeline from silently substituting modified helpers.

Catalog signing (ed25519)

gen_i18n can sign every .json catalog with an ed25519 key. The WASM app embeds the matching public key and the wi18n loader verifies each catalog before using it.

Generate a keypair (run once per project):

gen_i18n -genkey -sign-key-password <strong-password>

This writes gen_i18n.ed25519.key (encrypted private key) and gen_i18n.ed25519.pub (public key for embedding).

Sign catalogs on every run:

gen_i18n --path . --deflang en-US \
         -sign-key gen_i18n.ed25519.key \
         -sign-key-password <password>

A .json.sig sidecar is written next to each published catalog: the main <lang>.json, every emitted <lang>.inflections.json, and any hand-authored <lang>.fmt.json found in the i18n directory.

Verify in your WASM app:

//go:embed gen_i18n.ed25519.pub
var catalogPubKey []byte

func main() {
    if err := wi18n.SetCatalogPublicKey(catalogPubKey); err != nil {
        log.Fatal(err)
    }
    wings.Main()
}

Enforcement is opt-in and, once on, strict. With no public key configured, verification is skipped and no .sig sidecar is even fetched (backward compatible). Once a key is configured, every catalog the app loads MUST carry a valid .sig: a missing sidecar is treated as tampering and the catalog is rejected — the loader never falls through to an unsigned, possibly forged catalog. This covers the main <lang>.json and the optional <lang>.inflections.json / <lang>.fmt.json: an optional file that does not exist is fine (nothing to verify), but one that loads must verify.

A rejection surfaces as *wi18n.CatalogSignatureError in the SetLang error callback, so the app can tell tampering apart from ordinary load failures (missing catalog, parse error):

wi18n.SetLang(next, func(err error) {
    var sigErr *wi18n.CatalogSignatureError
    if errors.As(err, &sigErr) {
        // signature policy failure — warn loudly
    }
})

The live-demo's locale-switcher shows the recommended wiring: on error it logs, reverts its <select> to the still-active wings.Locale, and fires an @error trigger so the hosting app can react declaratively (<locale-switcher @error="fnLocaleError"> opening a <w-dialog>).

Third-party data

cmd/dictbuild can download linguistic dictionaries from the UnitexGramLab/unitex-lingua repository. These dictionaries are distributed under the Lesser General Public License for Linguistic Resources (LGPLLR). They are not stored in this repository — they are fetched on demand by dictbuild -lang <tag> and cached locally on the developer's machine. WINGS's source code is not affected by the LGPLLR.

Copyright and per-language modification notices are in cmd/gen_i18n/dicts/NOTICE.md.

If you use gen_i18n -auto-plurals: the generated *.inflections.json files are derivative works of the LGPLLR-licensed dictionaries. Before deploying them on your site, copy NOTICE-TEMPLATE.md into your project's NOTICE file and fill in the URL where your i18n/ directory is accessible. Users who fill plural forms by hand are not affected.

Logo & credits

The WINGS logo (assets/logo.png) is a winged adaptation of the Go gopher. The Go gopher was designed by Renée French and is licensed under the Creative Commons Attribution 3.0 license. This derivative keeps that attribution.

The illustration was produced with AI assistance and is provided for use as the WINGS project mascot only. It is a separate asset from the source code: it is not covered by the project's MPL 2.0 license, and (as an AI-generated work) no independent copyright is claimed over it.

"Go" and the Go gopher are associated with the Go project / Google; WINGS is an independent project and is not affiliated with or endorsed by them.

License

This project is licensed under the Mozilla Public License 2.0.

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.