via

You are reading the docs for v0.8. Read the latest (v0.10)

h helpers

Package h writes markup as Go calls: one function per element, one per attribute, all returning h.H. Values are escaped when the view renders, URLs are scheme-checked, and the Data* helpers spell Datastar's attributes. Every name is listed in the reference.

On this page

#Nodes

Go
func Card(title string, n int) h.H {
	return h.Section(h.Class("card"),
		h.H2(h.Str(title)),
		h.P(h.Str(n), h.Str(" new")),
		h.ID("inbox"),
	)
}

var Inbox = Card("Inbox", 3)
Rendered HTML
<section class="card" id="inbox">
  <h2>Inbox</h2>
  <p>3 new</p>
</section>

An element takes a variadic list of h.H, and an h.Attr is an h.H, so attributes and children share one call in any order. The renderer moves attributes into the opening tag in the order given: h.ID above comes after the children and still lands in the tag.

h.Str is the only text node. It takes a string or any integer or float type, so a count needs no strconv. It does not take bool, time.Time or a fmt.Stringer: format those in Go and pass the string. A nil h.H renders as nothing. A tag h has no function for, such as a custom element or an SVG child, goes through h.El, which panics on an invalid tag name.

#Attributes

Go
func Qty(max int, sold bool) h.H {
	return h.Input(
		h.Type("number"), h.Name("qty"),
		h.Min(1), h.Max(max),
		h.Value(1),
		h.Disabled(sold),
		h.Required(true),
		h.Class("qty", ""),
		h.Aria("label", "Quantity"),
		h.RawAttr("list", "sizes"),
	)
}

var InStock = Qty(5, false)
Rendered HTML
<input type="number" name="qty"
  min="1" max="5" value="1"
  required
  class="qty"
  aria-label="Quantity"
  list="sizes">

Each attribute is a function with its name spelled once, so a misspelled one does not compile. Boolean attributes take a bool and render a bare name when true and nothing when false: disabled="false" still disables a control, so h.Disabled takes the Go condition, not a string. h.Min, h.Max, h.Step, h.Value and h.Width take any number; the counts, such as h.Rows and h.MaxLength, take an int.

h.Class joins its arguments with spaces and drops empty ones, so a conditional class is an empty string, and with none left it renders no attribute at all. h.Aria("label", …) is aria-label. h.RawAttr writes an attribute h has no function for.

#Escaping

Go
func Comment(who, text string) h.H {
	return h.P(h.Title(who), h.Str(text))
}

var Hostile = Comment(
	`"><b>`,
	"<script>alert(1)</script>",
)
Rendered HTML
<p title="&#34;&gt;&lt;b&gt;">
  &lt;script&gt;alert(1)&lt;/script&gt;
</p>

Text and every attribute value are HTML-escaped at render, with no opt-out. h has no raw-HTML constructor, and h.H is sealed, so no package outside via can add a node type. A string from a request or a database is safe in any h.Str or attribute value. Attribute and tag names are validated instead, and an invalid one panics: a name is written by you, not read from a request.

Markup you already hold as a string, such as rendered Markdown, has no way into a view. Build it with h calls instead.

#URL attributes

Go
func Links() h.H {
	a := func(u, s string) h.H {
		return h.A(h.Href(u), h.Str(s))
	}
	return h.Nav(
		a("/docs?q=a&b", "rel"),
		a("https://go.dev", "https"),
		a("javascript:alert(1)", "js"),
		a("//evil.example", "//host"),
		a("mailto:ada@x.org", "mailto"),
		a("data:text/html,x", "data"),
	)
}
Rendered HTML
<nav>
  <a href="/docs?q=a&amp;b">rel</a>
  <a href="https://go.dev">https</a>
  <a href="#">js</a>
  <a href="#">//host</a>
  <a href="mailto:ada@x.org">mailto</a>
  <a href="#">data</a>
</nav>

h.Href, h.Src and h.Action admit relative URLs, http: and https:, and h.Href also admits mailto: and tel:. Any other scheme, a protocol-relative //host or \\host, and an empty string become #, with a warning in the log; the page still renders. Tabs and newlines are stripped and leading spaces and case ignored first, as a browser would, so a padded JavaScript: is refused too. h.RawAttr runs the same check on URL-bearing names, and via.Ctx.Redirect shares the policy.

#Datastar attributes

#One attribute, three spellings

The buttons write the same attribute three ways. Datastar splits a key at the first colon into plugin and argument, so data-attr-disabled names a plugin called attr-disabled, which does not exist. Datastar ignores it with no console error.

Try this

  • Clear the input: the first two buttons disable, the third stays enabled.
  • Type a letter and watch the Wire pane: _colon__draft changes and no request appears.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"github.com/go-via/via"
	"github.com/go-via/via/h"
)

// HColon writes one Datastar attribute three ways. The first two render
// data-attr:disabled and work; the third renders data-attr-disabled, which
// Datastar reads as a plugin named "attr-disabled" that does not exist, and
// ignores without a console error.
type HColon struct {
	Draft via.SignalCS[string]
}

func (c *HColon) View() h.H {
	empty := c.Draft.Ref().Eq("")
	return h.Div(h.Class("row"),
		h.Input(c.Draft.Bind(), h.Placeholder("Type to enable")),
		h.Button(h.Type("button"), h.DataAttr("disabled", empty), h.Str("DataAttr")),
		h.Button(h.Type("button"), h.Data("attr:disabled", string(empty)), h.Str(`Data("attr:…")`)),
		h.Button(h.Type("button"), h.Data("attr-disabled", string(empty)), h.Str(`Data("attr-…")`)),
	)
}
rendered HTML
<button type="button"
  data-attr:disabled="($_colon__draft === &#34;&#34;)">DataAttr</button>
<button type="button"
  data-attr:disabled="($_colon__draft === &#34;&#34;)">Data(&#34;attr:…&#34;)</button>
<button type="button"
  data-attr-disabled="($_colon__draft === &#34;&#34;)">Data(&#34;attr-…&#34;)</button>

Each typed helper writes one plugin's key, so the colon is spelled for you: h.DataShow, h.DataText, h.DataClass, h.DataAttr, h.DataStyle, h.DataOn, h.DataEffect, h.DataComputed, h.DataIndicator, h.DataRef and h.DataIgnoreMorph. They take an expr.Expr or any string type; the reference table lists the attribute each one writes.

h.Data covers what they miss, such as a modifier suffix (h.Data("on:keydown__debounce.300ms", …)) or a newer plugin, and there the colon is yours to get right. A test that reads the HTML cannot catch the hyphen: the attribute renders, and only the browser ignores it.

There is no h.DataBind or h.DataSignals: via writes those from signal fields (via.Signal.Bind and the root's data-signals). h.DataIndicator and h.DataRef take a signal's Ref and drop the $. h.DataIgnoreMorph only works when the old and new node both carry it; put it on a container some JavaScript owns, such as a chart or a map.

#Composition with via

Conditionals, lists and child compositions are in the root package, not in h: via.When, via.Each and via.Child. A child has state and a lifecycle, which h knows nothing about, and When and Each keep the view one expression. Since a nil node renders nothing, a Go if that leaves a variable nil works too. Compositions covers all three.

#Pairing with expr

expr.go
type Invite struct {
	Query via.Signal[string]
	URL   string
}

func (p *Invite) View() h.H {
	q := p.Query.Ref()
	copyURL := expr.CopyToClipboard(expr.Val(p.URL))
	return h.Div(
		h.Input(p.Query.Bind()),
		h.P(h.DataShow(q.Ne("")),
			h.Str("Searching for "), p.Query.Display()),
		h.Button(h.DataOn("click", copyURL), h.Str("Copy invite link")),
		h.Pre(h.Code(h.Str("go get github.com/go-via/via"))),
		h.Button(h.DataOn("click", expr.CopyTextOf("code"),
			expr.Class("copied", true)), h.Str("Copy")),
	)
}
rendered HTML
<div id="root" data-signals='{"query":""}'>
  <div>
    <input data-bind="query">
    <p data-show="($query !== &#34;&#34;)">
      Searching for <span data-text="$query"></span>
    </p>
    <button data-on:click="navigator.clipboard.writeText(&#34;https://example.com/join?ref=\u0040ada&#34;).catch(() =&gt; {})">Copy invite link</button>
    <pre><code>go get github.com/go-via/via</code></pre>
    <button data-on:click="navigator.clipboard.writeText(el.parentElement?.querySelector(&#34;code&#34;)?.textContent ?? &#34;&#34;).catch(() =&gt; {});
      el.classList.toggle(&#34;copied&#34;, true)">Copy</button>
  </div>
</div>

via.Signal.Ref returns the signal as an expr.Expr, and its methods build on it: q.Ne("") renders ($query !== ""). expr.Val encodes a Go value as a JavaScript literal and escapes @, so the @ada in this invite URL cannot turn into a Datastar action.

expr.CopyToClipboard writes an expression's value to the clipboard. expr.CopyTextOf copies the text of an element beside the handler, read at click time. expr.Class toggles a class on the handler element, for feedback that needs no signal. h.DataOn joins several statements with a semicolon. The rest of the vocabulary is on Signals.

#Tags via owns

h has no function for html, head, body, script, style, title, base, meta, link, template, slot or data. via writes the document shell, and every script and stylesheet must be admitted by the CSP, which via builds once per mount from via.WithHead and the page's Meta.Assets. Declare scripts and styles in via.Assets, and the title and description in via.Meta.

via.Head's Raw field takes other head markup, such as icons or a viewport meta, and panics at startup if it contains <script or <style: via never parses Raw, so the CSP could not admit them and the browser would block them silently.