go-pages
is a server-side HTML component rendering engine designed to bring the ease and
flexibility of Web component development to Go projects.
It consists of a template engine for rendering HTML components (.chtml
files) and a file-based
router for serving components based on URL paths.
Goals
- Template engine supports composable and reusable components.
- Template language takes cues from VueJS and AlpineJS, incorporating
for/if
directives within HTML tags. - No JavaScript required.
- No code generation required, though a Go code generator can be developed for improved performance.
- Implements file-based routing akin to NuxtJS.
- Single-file components (combine HTML, CSS, and JS in a single file).
- Not tied to Go ecosystem, allowing components to be reused in non-Go projects.
- Plays nicely with HTMX and AlpineJS.
- Template fragments for partial rendering of components.
- Automatic browser refresh during development.
- Small API surface (both on the template language and the Go API) for quick starts.
- Ability to embed assets into a single binary.
Why not html/template
, jet
, pongo2
, or others?
Templates are harder to compose and reuse across projects compared to more modular components approach.
Why not templ or gomponents?
These options lock you into the Go ecosystem, limiting component reuse in non-Go projects.
go get -u github.com/dpotapov/go-pages
-
Create a directory for your pages and components. For example,
./pages
. -
Create a file in the
./pages
directory. For example,./pages/index.chtml
with the following content:<h1>Hello World</h1>
-
Create a Go program to serve the pages.
package main import ( "net/http" "os" "github.com/dpotapov/go-pages" ) func main() { ph := &pages.Handler{ FileSystem: os.DirFS("./pages"), } http.ListenAndServe(":8080", ph) }
-
Run the program and navigate to
http://localhost:8080
. You should see the text "Hello World". -
Create another file in the
./pages
directory. For example,./pages/about.chtml
with the following content:<h1>About page</h1>
-
Navigate to
http://localhost:8080/about
. You should see the text "About page". No need to restart the server.
Check out the example directory for a more complete example.
Components are defined in .chtml
files in HTML5-like syntax.
On-top of a standard HTML, go-pages
adds the following elements and attributes prefixed
with c:
namespace:
-
<c:NAME>...</c:NAME>
imports a component by name. The body of the element is passed to the component as an argument and can be interpolated with${_}
syntax. Any attributes on the element are passed to the component as arguments as well. Typically, the component is a.chtml
file, but it can also be a virtual component defined in Go code. -
<c:attr name="ATTR_NAME">VALUE</c:attr>
— builtin that applies the attributeATTR_NAME
with valueVALUE
to the parent element/component. It:- must be nested inside an element or component (top-level usage is ignored)
- supports string interpolation in
VALUE
- has no side effects beyond setting the attribute (no env/scope mutation) Examples:
<a><c:attr name="href">${link}</c:attr>Open</a> <!-- Renders: <a href="/path">Open</a> --> <c:MyButton> <c:attr name="variant">primary</c:attr> Click me </c:MyButton>
-
c:if
,c:else-if
,c:else
attribute for conditional rendering. -
c:for
attribute for iterating over a slice or a map.
All c:
elements and attributes are removed from the final HTML output.
<c>...</c>
is a control-only container that does not render its own tag. It can:
- passthrough children as-is:
<c><p>Hello</p></c> <!-- Renders: <p>Hello</p> -->
- bind rendered content to a variable and suppress output via
var
:<c var="paragraph"><p>Hello</p></c> <div>${paragraph}</div> <!-- Renders: <div><p>Hello</p></div> -->
- bind with automatic type casting:
<c var="myvar number">123</c> <c var="myobj { name: string, age: number }">${ {name: "John", age: 30} }</c> <p>${myobj.name} is ${myobj.age} years old</p> <!-- Content is automatically converted to the specified type -->
- loop with
for
:<c for="i in items"><li>${i}</li></c>
- conditionally render with
if
/else-if
/else
:<c if="cond">A</c><c else-if="other">B</c><c else>C</c>
Type Casting Support
The var
attribute supports optional type casting using the syntax var="name type"
:
- Basic types:
string
,number
,bool
,html
,any
- Arrays:
[type]
(e.g.,[string]
,[number]
) - Objects:
{field: type, ...}
(e.g.,{name: string, age: number}
) - Uniform objects:
{ _: type }
(e.g.,{ _: string }
means any keys with string values) - Nested structures:
{items: [{name: string}]}
Uniform objects are useful when the set of keys is not known ahead of time but all values share the same type. For example:
<c var="labels { _: string }">${ {a: "Alpha", b: "Beta"} }</c>
<p>${labels.a} / ${labels.b}</p>
If the content cannot be converted to the specified type, a runtime error is thrown with a descriptive message.
Constraints and notes:
- Do not mix
for
withif
/else-if
/else
on the same<c>
. - Do not use
c:*
directives on<c>
; use plain attributesif
,else-if
,else
,for
. <c>
with no children produces no output.
Kebab-case conversion
The go-pages
library does not enforce a style for naming components and arguments, you may
choose between CamelCase/camelCase or kebab-style, single word or multiple words. Just don't use
underscores as they feel wrong in HTML and URL paths.
You may want to use kebab-case for components, that represent pages (and become part of the URL that visible to the user). When referencing a component or an argument in an expression, all dashes will be replaced with underscores.
Example:
<!--
kebab-style - preferred for URLs and native to HTML.
The go-pages engine will automatically replace dashes to underscore_case to make easier to use
in expressions. E.g. some-arg-1 becomes ${some_arg_1}.
-->
<c:my-component some-arg-1="...">
...
</c:my-component>
<!--
CamelCase for component names and camelCase for attributes.
This style is easier to use in expressions. E.g. someArg1 can be referenced as ${someArg1}.
-->
<c:MyComponent someArg1="...">
...
</c:MyComponent>
Expressions
Currently, go-pages
uses the https://github.com/expr-lang/expr
library for evaluating
expressions. Refer https://expr-lang.org/ for the syntax.
String Interpolation
String interpolation is supported using the ${ ... }
syntax. For example:
<a href="${link}">Hello, ${name}!</a>
All string attributes and text nodes are interpolated.
Template fragments allow rendering just a portion of a component's HTML. This is particularly useful for HTMX-based applications where you want to update only specific parts of a page.
To define a fragment in your template, simply add an id
attribute to the HTML element that you want to use as a fragment:
<div id="user-profile">
<h2>${user.name}</h2>
<p>${user.bio}</p>
</div>
<div id="user-stats">
<h3>Stats</h3>
<ul>
<li>Posts: ${user.posts_count}</li>
<li>Followers: ${user.followers_count}</li>
</ul>
</div>
To render only a specific fragment, you can use the FragmentSelector
option in the Handler
:
h := &pages.Handler{
FileSystem: os.DirFS("./pages"),
FragmentSelector: func(r *http.Request) string {
return r.URL.Query().Get("fragment")
},
}
This example renders only the fragment specified in the fragment
query parameter. For example, requesting /user?fragment=user-stats
would render only the user-stats
div.
For HTMX applications, you can use the built-in HTMXFragmentSelector
function to automatically handle the HX-Target
header:
h := &pages.Handler{
FileSystem: os.DirFS("./pages"),
FragmentSelector: pages.HTMXFragmentSelector,
}
With this setup, HTMX requests using hx-target
will automatically render only the targeted fragment:
<button hx-get="/user" hx-target="#user-stats">Refresh Stats</button>
When this button is clicked, only the user-stats
fragment will be rendered and returned to the browser, which HTMX will then use to update just that part of the page.
go-pages
handler implements a file-based routing system. The path from the request URL is used to
find the corresponding .chtml
file.
For example, if the request URL is /about
, the handler will look for the about.chtml
file in the
directory. The handler will also look for an index.chtml
file if the request URL ends with /
.
If the request URL points to a static file (e.g. /css/style.css
), the handler will serve the
file if it exists in the file system.
Dynamic routes
Directories or component files can be prefixed with an underscore to indicate a dynamic route.
Double underscore __
in the component filename is used to indicate a catch-all route.
Examples:
/_user
/index.chtml -> matches URL: /joe/
/profile.chtml -> matches URL: /joe/profile
/posts
/index.chtml -> matches URL: /posts/
/_slug.chtml -> matches URL: /posts/hello-world
/__path.chtml -> matches URL: /anything/else
The router will pass the dynamic part of the URL as an argument to the component. For example, the
/posts/_slug.chtml
component will receive the slug
argument with the value hello-world
.
Catch-all component in the root directory of the file system can be used to implement a 404 page.
Each top-level page component receives a pages.RequestArg
object as an request
argument.
Here is an example of the data it contains:
# HTTP method, string: GET, POST, etc.
method: "GET"
# Full URL
url: "http://localhost:8080/posts/hello-world?foo=bar&foo=baz"
# URL scheme
scheme: "http"
# Hostname part of the URL
host: "localhost"
# Port part of the URL
port: "8080"
# Path part of the URL
path: "/posts/hello-world"
# URL Query parameters, represented as a map of string slices.
query:
foo: ["bar", "baz"]
# Remote address of the client, string: "IPv4:PORT" or "IPv6:PORT"
remote_addr: "127.0.0.1:12345"
# HTTP headers, represented as a map of string slices.
headers:
Content-Type: ["application/json"]
Cookie: ["session_id=1234567890", "user_id=123"]
# Body is available only when the content type is either application/json or
# application/x-www-form-urlencoded and the body size is less than xxx MB.
# TODO: define the size limit.
body:
foo: "bar"
bar: "baz"
# raw_body is only available when the content type is not application/json or
# application/x-www-form-urlencoded, or body size exceeds xxx MB limit.
# It is meant to be passed to custom components with Go renderers.
raw_body: nil