RHTML Templates
Runa's enhanced HTML template syntax built on html/template
view/rhtml is Runa’s enhanced HTML renderer. It still uses Go’s standard html/template, so HTML escaping is enabled by default, while adding layouts, blocks, includes, conditions, loops, and custom r: tags
It is useful for admin pages, server-rendered pages, emails, CMS pages, and any feature that renders Go data directly into HTML
Install
go get github.com/duxweb/runa/view
RHTML is a subpackage in the view module:
import "github.com/duxweb/runa/view/rhtml"
Connect through view.Provider
To use ctx.Render(...) in HTTP handlers, install view.Provider() and register a named renderer:
app.Install(view.Provider())
func (appModule) Register(ctx context.Context, app provider.Context) error {
views := provider.MustInvoke[*view.Registry](app)
return views.Register(ctx, "web", rhtml.New(
view.Dir("views", "**/*.html").Reload(true),
))
}
Render in a handler:
route.Default().Get("/", func(ctx *route.Context) error {
return ctx.Render("pages/home", map[string]any{
"Title": "Runa",
}, "web")
})
If you register only one view domain, you can omit the final "web" argument.
Standalone usage
renderer := rhtml.New(view.Dir("views", "**/*.html"))
var body bytes.Buffer
err := renderer.Render(simpleContext{ctx: context.Background()}, &body, "pages/home", map[string]any{
"Title": "Runa",
})
The context value only needs to implement Context() context.Context
Configuration
view/rhtml does not read TOML config by itself. Template directories, hot reload, and embedded files are configured through template sources passed to rhtml.New(...).
Common patterns:
- Use
view.Dir("views", "**/*.html").Reload(true)in development. - Use
view.Embed(...)or disableReload(true)in production. - Register multiple renderers with
view.Registry.Register(ctx, name, renderer)when different view domains need different sources or functions.
Recommended layout
views/
layouts/
base.html
admin.html
partials/
nav.html
pagination.html
pages/
home.html
users/index.html
Template names can include or omit the extension:
ctx.Render("pages/home", data)
ctx.Render("pages/home.html", data)
Layouts and blocks
views/layouts/admin.html:
<html>
<head>
<title>{{ .root.Title }}</title>
</head>
<body>
<aside><r:block name="sidebar">Default menu</r:block></aside>
<main><r:block name="content"></r:block></main>
</body>
</html>
views/pages/home.html:
<r:layout name="layouts/admin">
<r:section name="sidebar">User menu</r:section>
<r:section name="content">
<h1>{{ .root.Title }}</h1>
</r:section>
</r:layout>
Rules:
r:layoutmust wrap the whole page templater:sectioncan only appear insider:layout- Duplicate section names are rejected
r:blockcan provide fallback content
Nested layouts
Layouts can extend another layout. This works well for base → admin → page structures:
<!-- layouts/base.html -->
<html>
<body><r:block name="body"></r:block></body>
</html>
<!-- layouts/admin.html -->
<r:layout name="layouts/base">
<r:section name="body">
<aside><r:block name="sidebar">Menu</r:block></aside>
<main><r:block name="content"></r:block></main>
</r:section>
</r:layout>
Include partials
<r:include name="partials/nav" label="Main" title=".Title" />
r:include is self-closing. Attribute values starting with . are read from current data. Other values are passed as literals
Included templates only see explicit data and .root by default, so local variables do not leak between templates
Conditions
<r:if cond=".User">
<p>{{ .User.Name }}</p>
<r:else>
<p>Guest</p>
</r:if>
cond is evaluated from template data. Empty strings, false, nil, and empty collections are treated as false.
Loops
<r:for value=".Items" as="item">
<span>{{ .item.Name }}</span>
</r:for>
as must be a valid Go identifier. Nested loops can still access outer variables
<r:for value=".Groups" as="group">
<h2>{{ .group.Name }}</h2>
<r:for value="group.Items" as="item">
<span>{{ .item }}</span>
</r:for>
</r:for>
Custom r: tags
Custom tags are useful for encapsulating “load data, then render a template block” patterns such as article lists, menus, categories, or recommended content.
renderer := rhtml.New(view.Dir("views", "**/*.html"))
renderer.Tag("articles", func(ctx context.Context, props rhtml.Props) (any, error) {
status := props.Int("status")
_ = status
return []Article{{Title: "Hello"}}, nil
})
Self-closing form:
<r:articles status="1" as="articles" />
<r:for value=".articles" as="item">
<h2>{{ .item.Title }}</h2>
</r:for>
Block form:
<r:articles status="1" as="item">
<h2>{{ .item.Title }}</h2>
</r:articles>
When the tag returns a slice, the block renders once per item. When it returns one object, the block renders once
Template functions
renderer.Func("upper", strings.ToUpper)
renderer.Funcs(template.FuncMap{
"asset": func(path string) string { return "/assets/" + path },
})
Use them in templates:
<h1>{{ upper .root.Title }}</h1>
<link rel="stylesheet" href="{{ asset "app.css" }}">
Hot reload
renderer := rhtml.New(
view.Dir("views", "**/*.html").Reload(true),
)
Reload(true) checks file count, size, and modification time before each render. It is useful in development. Keep it disabled in production unless runtime template reloads are required.
Embed templates
//go:embed views/**/*.html
var viewsFS embed.FS
renderer := rhtml.New(
view.Embed(viewsFS, "views", "**/*.html"),
)
You can also configure a development directory override:
source := view.Embed(viewsFS, "views", "**/*.html").Dev(
view.Dir("views", "**/*.html").Reload(true),
)
renderer := rhtml.New(source.UseDev())
Scope rules
.rootalways points to the original render datar:include,r:block,r:section, and custom tags create local data scopes- Data not explicitly passed does not enter includes
- Rendering still uses
html/template, so HTML escaping is enabled by default
Common mistakes
- Content outside
r:layoutis rejected when a page uses a layout r:includemust be self-closingr:sectioncan only be used insider:layout- Custom tag names cannot use reserved names such as
layout,section,block,include,if, orfor route renderer is not configuredmeansview.Provider()or route service registration is missing
API quick reference
| API | Description |
|---|---|
rhtml.New(sources...) |
Create an enhanced renderer |
renderer.Func(name, fn) |
Register one template function |
renderer.Funcs(funcMap) |
Register template functions in bulk |
renderer.Tag(name, handler) |
Register a custom r: tag |
view.Dir(root, patterns...) |
Use a local directory as source |
view.Embed(fs, root, patterns...) |
Use embed.FS as source |
source.Reload(true) |
Enable reload checks |
props.Get(name) |
Read a raw attribute |
props.String(name) |
Read a string attribute |
props.Int(name) |
Read an int attribute |
props.Bool(name) |
Read a bool attribute |
props.Root() |
Read original render data |
props.Map() |
Read cloned props |