Xun
Web framework built on Go's built-in html/template and net/http package’s router. It is designed to be lightweight, fast, and easy to use while providing a simple and intuitive API for building web applications with advanced features such as middleware, routing, and template rendering
About Xun
This large README is shown as a plain-text preview. Read the full README at the source
# Xun
Xun is a web framework built on Go's built-in html/template and net/http package’s router. It is designed to be lightweight, fast, and easy to use. Xun provides a simple and intuitive API for building web applications, while also offering advanced features such as middleware, routing, and template rendering.
Xun [ʃʊn] (pronounced 'shoon'), derived from the Chinese character 迅, signifies being lightweight and fast.
[](https://github.com/yaitoo/xun/actions/workflows/tests.yml)
[](https://codecov.io/gh/yaitoo/xun)
[](https://goreportcard.com/report/github.com/yaitoo/xun)
[](https://pkg.go.dev/github.com/yaitoo/xun)
[](https://github.com/yaitoo/xun/releases)
[](LICENSE)
[](https://github.com/yaitoo/xun/compare)
## Features
- Works with Go's built-in `net/http.ServeMux` router that was introduced in 1.22. [Routing Enhancements for Go 1.22](https://go.dev/blog/routing-enhancements).
- Works with Go's built-in `html/template`. It is built-in support for Server-Side Rendering (SSR).
- Built-in response compression support for `gzip` and `deflate`.
- Built-in Form and Validate feature with i18n support.
- Built-in `AutoTLS` feature. It automatic SSL certificate issuance and renewal through Let's Encrypt and other ACME-based CAs
- Support Page Router in `StaticViewEngine` and `HtmlViewEngine`.
- Support multiple viewers by ViewEngines: `StaticViewEngine`, `JsonViewEngine` and `HtmlViewEngine`. You can feel free to add custom view engine, eg `XmlViewEngine`.
- Support to reload changed static files automatically in development environment.
- Built-in Content engine that renders `.md` files in `content/` as pages, deriving `Title` from the first `# H1` and `Description` from the first blockquote or top-level paragraph — no frontmatter, no separate template pipeline.
## Getting Started
> See full source code on [xun-examples](https://github.com/yaitoo/xun-examples)
### Install Xun
- install latest commit from `main` branch
```
go get github.com/yaitoo/xun@main
```
- install latest release
```
go get github.com/yaitoo/xun@latest
```
### Project structure
`Xun` has some specified directories that is used to organize code, routing and static assets.
- `public`: Static assets to be served.
- `components` A partial view that is shared between layouts/pages/views.
- `views`: An internal page view that can be referenced in `context.View` to render different UI for current routing.
- `layouts`: A layout is shared between multiple pages/views
- `pages`: A public page view that will create public page routing automatically.
- `text`: An internal text view that can be referenced in `context.View` to render with a data model.
- `content`: A Markdown-driven page tree. Every `.md` file auto-registers as `GET /<slug>`; paired `.tpl` files act as the bubble-up template (never registered as a route) and use the same `layouts/` directory as everything else. `.html` files in `content/` are independent page routes.
**NOTE: All html files(component,layout, view and page) will be parsed by [html/template](https://pkg.go.dev/html/template). You can feel free to use all built-in [Actions,Pipelines and Functions](https://pkg.go.dev/text/template), and your custom functions that is registered in `HtmlViewEngine`.**
### Layouts and Pages
`Xun` uses file-system based routing, meaning you can use folders and files to define routes. This section will guide you through how to create layouts and pages, and link between them.
#### Creating a page
A page is UI that is rendered on a specific route. To create a page, add a page file(.html) inside the `pages` directory. For example, to create an index page (`/`):
```
└── app
└── pages
└── index.html
```
> index.html
``` html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Xun-Admin</title>
</head>
<body>
<div id="app">hello world</div>
</body>
</html>
```
#### Creating a layout
A layout is UI that is shared between multiple pages/views.
You can create a layout(.html) file inside the `layouts` directory.
```
└── app
├── layouts
│ └── home.html
└── pages
└── index.html
```
> layouts/home.html
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Xun-Admin</title>
</head>
<body>
{{ block "content" .}} {{ end }}
</body>
</html>
```
> pages/index.html
```html
<!--layout:home-->
{{ define "content" }}
<div id="app">hello world</div>
{{ end }}
```
### Static assets
You can store static files, like images, fonts, js and css, under a directory called `public` in the root directory. Files inside public can then be referenced by your code starting from the base URL (/).
**NOTE: `public/index.html` will be exposed by `/` instead of `/index.html`.**
#### Creating a component
A component is a partial view that is shared between multiple layouts/pages/views.
```
└── app
├── components
│ └── assets.html
├── layouts
│ └── home.html
├── pages
│ └── index.html
└── public
├── app.js
└── skin.css
```
> components/assets.html
```html
<link rel="stylesheet" href="/skin.css">
<script type="text/javascript" src="/app.js"></script>
```
> layouts/home.html
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Xun-Admin</title>
{{ block "components/assets" . }} {{ end }}
</head>
<body>
{{ block "content" .}} {{ end }}
</body>
</html>
```
### Text View
A text view is UI that is referenced in `context.View` to render the view with a data model.
**NOTE: Text files are parsed using the `text/template` package. This is different from the `html/template` package used in `pages/layouts/views/components`. While `text/template` is designed for generating textual output based on data, it does not automatically secure HTML output against certain attacks. Therefore, please ensure your output is safe to prevent code injection.**
#### Creating a text view
```
└── app
├── components
│ └── assets.html
├── layouts
│ └── home.html
├── pages
│ └── index.html
└── public
│ ├── app.js
│ └── skin.css
└── text
├── sitemap.xml
```
#### Render the view with a data model
```go
app.Get("/sitemap.xml", func(c *xun.Context) error {
return c.View(Sitemap{
LastMod: time.Now(),
}, "text/sitemap.xml") // use `text/sitemap.xml` as current Viewer to render
})
```
### Sitemap for blogs
Drop a `public/sitemap.xml` template next to your other static assets and the
framework auto-registers `GET /sitemap.xml` to render it with the URLs derived
from your content engine. No `text/sitemap.xml` indirection, no extra handler.
```
└── app
└── public
└── sitemap.xml
```
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{ range .Data -}}
<url>
<loc>https://example.com{{ .Loc }}</loc>
{{ if .LastMod }}<lastmod>{{ .LastMod }}</lastmod>{{ end }}
</url>
{{ end -}}
</urlset>
```
`.Data` is `[]xun.SitemapURL{Loc, LastMod}` projected from `app.contentViews`
only. The scope is intentionally narrow:
| Source | `Loc` | `LastMod` |
|--------|-------|-----------|
| `content/*.md` (with bubble-up template) | e.g. `content/post.md` → `/content/post` | file mtime (RFC3339) |
| `content/<dir>/index.md` | e.g. `content/blog/index.md` → `/blog/` (canonical trailing slash) | file mtime (RFC3339) |
Out of scope (by design — see `content_sitemap.go` doc comments for the
rationale):
- `pages/*.html` and `public/*.html` — user-authored, no mtime metadata
in the framework; add them to the sitemap by hand if needed.
- `app.Get` routes — classified as user handlers, not pages.
- Static assets in `public/` (CSS, JS, images, fonts, feeds).
- `.md` files without a bubble-up template (orphan entries — no route
to point crawlers at).
`Loc` is a URL path only (e.g. `/content/post`) — the framework does
not know which scheme + host your site is deployed under, so the
template prepends the host itself. Guard `LastMod` with
`{{ if .LastMod }}` (always safe; content views always carry a mtime
unless the source file lacks one).
#### Filtering
There is no `WithSitemap` option. Filtering happens in your `sitemap.xml`
template: drop entries by whatever signal is convenient in `Loc` /
`LastMod`, or write a custom handler that inspects `*ContentView` directly
via `app.contentViews`.
#### Taking over the route
If you call `app.Get("/sitemap.xml", h)` first, the framework's auto-handler
steps aside — your handler wins. The `TextViewer` is still exposed as
`app.viewers["sitemap.xml"]`, so you can reuse it:
```go
app.Get("/sitemap.xml", func(c *xun.Context) error {
return c.View(c.App.SitemapURLs(), "sitemap.xml")
})
```
Or write your own rendering entirely and ignore the viewer — the file is yours.
> curl --header "Accept: application/xml, text/xml,text/plain, */*" -v http://127.0.0.1/sitemap.xml
```bash
* Trying 127.0.0.1:80...
* Connected to 127.0.0.1 (127.0.0.1) port 80
> GET /sitemap.xml HTTP/1.1
> Host: 127.0.0.1
> User-Agent: curl/8.7.1
> Accept: application/xml, text/xml,text/plain, */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Wed, 15 Jan 2025 11:51:56 GMT
< Content-Length: 277
< Content-Type: text/xml; charset=utf-8
<
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://github.com/yaitoo/xun</loc>
<lastmod>2025-01-15T19:51:56+08:00</lastmod>
<changefreq>hourly</changefreq>
<priority>1.0</priority>
</url>
* Connection #0 to host 127.0.0.1 left intact
</urlset>%
```
### Content
The Content engine turns Markdown files into pages without introducing a new `Viewer` or a second template engine. Drop a `.md` file into `content/` and it auto-registers as `GET /<slug>`; drop a `.tpl` next to it (or any ancestor `index.tpl`) to control how the rendered Markdown is wrapped.
`.html` and `.tpl` inside `content/` have distinct roles:
- `.tpl` — bubble-up template only. Loaded into the template graph; never registered as a route.
- `.html` — page route only. Registered as a route; never consulted during bubble-up.
This is what lets a directory such as `content/blog/` hold both `index.tpl` (wrapping template) and `index.md` (real Markdown page) without conflict.
#### Directory layout
```
└── app
├── layouts
│ └── site.html
└── content
├── hello.md ← GET /hello
├── hello.tpl ← optional, dedicated template for /hello
└── 2026
├── index.tpl ← bubble-up target (not a route)
├── index.md ← optional, directory-level page: GET /2026/
└── deeper.md ← GET /2026/deeper
```
For each `.md` file, the engine looks for a bubble-up template in this order:
1. `content/<slug>.tpl`
2. `content/<dir>/index.tpl`
3. any ancestor `index.tpl`
4. the root `index.tpl`
The first match is the template; it uses the same `<!--layout:NAME-->` mechanism as every other page, so layouts are shared across content and non-content pages.
#### Writing a Markdown file
Markdown files don't need any frontmatter. `Title` is taken from the first `# H1` and `Description` from the first blockquote (preferred) or top-level paragraph. Everything else comes from the filesystem path and `mtime`.
> content/hello.md
```markdown
# Hello, Xun
> A one-line pitch for the page.
This is the body. It supports standard [GitHub Flavored
Markdown](https://github.github.com/gfm/) out of the box — tables,
task lists, fenced code, autolinks.
```
#### Rendering the page in a template
A request to `/hello` lands on `content/hello.tpl` (or whichever template the bubble-up rule picked). Inside the template, the parsed Markdown is exposed as `.Content`:
> content/hello.tpl
```html
<!--layout:site-->
{{ define "content" }}
<article>
<h1>{{ .Content.Title }}</h1>
{{ if .Content.Description }}
<p class="lead">{{ .Content.Description }}</p>
{{ end }}
<div>{{ .Content.Body }}</div>
</article>
{{ end }}
```
`Content` is `nil` on routes that don't come from a `.md` file, so existing templates are unaffected. The full shape:
```go
type ContentView struct {
Path string // "content/2026/deeper.md"
Slug string // "2026/deeper"
Title string // First # H1; empty if none
Description string // First blockquote (preferred) or top-level paragraph
Date time.Time // File mtime
Body template.HTML // Rendered Markdown
}
```
#### Configuring the engine
`content/` is enabled by default. The relevant options are:
- `xun.WithContent(dirs ...string)` — register one or more content directories (default `["content"]`). Passing `""` disables Markdown loading. Each directory doubles as a route prefix, so `WithContent("blog", "docs")` produces independent `/blog/...` and `/docs/...` trees.
- `xun.WithContentRenderer(fn)` — replace the default Markdown renderer. Use this to plug in syntax highlighting, AST transforms, or a fully-configured `goldmark` instance.
Custom key/value metadata (image, author, OG tags, etc.) is read from a sibling `.yaml` next to each `.md`. See `docs/content.md` §1.4 for the full convention.
#### A minimal example
```go
app := xun.New(
xun.WithFsys(os.DirFS("app")),
xun.WithContent("content"), // optional — "content" is the default
)
app.Start()
defer app.Close()
http.ListenAndServe(":80", http.DefaultServeMux)
```
With `content/hello.md` and `content/hello.tpl` in place, `curl http://localhost/hello` returns the rendered page.
## Building your application
### Canonical application shape
The standard layout of a `main.go` is a six-step lifecycle — load config, open resources, build the app, register routes, start the app, run listeners. Keeping the steps in this shape makes the program easy to extend and easy to reason about.
```go
// 1. Load config (viper, env, flags — not xun-specific but done first)
err := loadConfig()
// 2. Open databases / caches
db, err := setupDB(ctx)
// 3. Build the xun.App with options
mux := http.NewServeMux()
app := xun.New(
xun.WithMux(mux),
xun.WithFsys(getFsys()),
xun.WithWatch(), // dev only
xun.WithHandlerViewers(&xun.JsonViewer{}),
xun.WithInterceptor(htmx.New()),
xun.WithBuildAssetURL(func(p string) bool {
return strings.HasPrefix(p, "/assets/")
}),
)
// 4. Register xun-managed routes (go through middleware pipeline)
setupRoutes(app)
// 5. Start the app (non-blocking — registers handlers on the mux)
app.Start()
defer app.Close()
// 6. Run listeners (blocking)
http.ListenAndServe(":80", mux)
```
| Option | Purpose | When to use it |
|---|---|---|
| `WithMux` | Inject your own `*http.ServeMux` | When you need a stdlib mux (e.g. for `ListenAndServeTLS`) |
| `WithFsys` | Templates + assets `fs.FS` | Always — points to `app/` in dev, `//go:embed` in prod |
| `WithWatch` | Live-reload templates | **Dev only**; thread-unsafe |
| `WithHandlerViewers` | Alternate content renderers | Add `JsonViewer` for API responses alongside `HtmlViewer` |
| `WithInterceptor` | Cross-cutting wrappers | `htmx.New()` (request/response helpers), custom auth, etc. |
| `WithBuildAssetURL` | Asset URL strategy | Return `true` to route through `{{asset "..."}}` for cache-bust hashing |
#### Dev / prod twin fs.FS
The canonical pattern keeps templates and assets addressable in both environments:
```go
//go:embed app/components app/layouts app/pages app/views app/public
var fsys embed.FS
func getFsys() fs.FS {
if fi, err := os.Stat("./app"); err == nil && fi.IsDir() {
return os.DirFS("./app") // dev: live files on disk
}
sub, _ := fs.Sub(fsys, "app") // prod: bundled binary
return sub
}
```
In production the binary has no on-disk dependencies; in development `WithWatch` reloads templates and assets as you save them.
### Routing
#### Route Handler
Page Router only serve static content from html files. We have to define router handler in go to process request and bind data to the template file via `HtmlViewer`.
> pages/index.html
```html
<!--layout:home-->
{{ define "content" }}
<div id="app">hello {{.Data.Name}}</div>
{{ end }}
```
> main.go
```go
app.Get("/{$}", func(c *xun.Context) error {
return c.View(map[string]string{
"Name": "go-xun",
})
})
```
**NOTE: An `/index.html` always be registered as `/{$}` in routing table. See more detail on [Routing Enhancements for Go 1.22](https://go.dev/blog/routing-enhancements).**
> There is one last bit of syntax. As we showed above, patterns ending in a slash, like /posts/, match all paths beginning with that string. To match only the path with the trailing slash, you can write /posts/{$}. That will match /posts/ but not /posts or /posts/234.
#### Dynamic Routes
When you don't know the exact segment names ahead of time and want to create routes from dynamic data, you can use Dynamic Segments that are filled in at request time. `{var}` can be used in folder name and file name as same as router handler in `http.ServeMux`.
For examples, below patterns will be generated automatically, and registered in routing table.
- `/user/{id}.html` generates pattern `/user/{id}`
- `/{id}/user.html` generates pattern `/{id}/user`
```
├── app
│ ├── components
│ │ └── assets.html
│ ├── layouts
│ │ └── home.html
│ ├── pages
│ │ ├── index.html
│ │ └── user
│ │ └── {id}.html
│ └── public
│ ├── app.js
│ └── skin.css
├── go.mod
├── go.sum
└── main.go
```
> pages/user/{id}.html
```html
<!--layout:home-->
{{ define "content" }}
<div id="app">hello {{.Data.Name}}</div>
{{ end }}
```
> main.go
```go
app.Get("/user/{id}", func(c *xun.Context) error {
id := c.Request.PathValue("id")
user := getUserById(id)
return c.View(user)
})
```
#### PageRoute with path parameters
A page file with a `{var}` segment auto-registers a PageRoute whose pattern matches Go 1.22's `http.ServeMux` syntax. To supply the model, override the same pattern with `app.Get`:
> pages/user/{id}.html
```html
<!--layout:home-->
{{ define "content" }}
<h1>User #{{.Data.ID}}</h1>
<p>{{.Data.Name}} ({{.Data.Email}})</p>
{{ end }}
```
> main.go
```go
app.Get("/user/{id}", func(c *xun.Context) error {
id, _ := strconv.ParseInt(c.Request.PathValue("id"), 10, 64)
var u User
db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Name, &u.Email)
return c.View(u) // ← .Data = u
})
```
> **Note:** `{id}` (Go 1.22 brace syntax) is the only form xun understands. The older `:id` colon-style does **not** work.
#### The `{$}` root pattern
`pages/index.html` auto-registers `GET /{$}`, not `GET /` (xun appends `{$}` to patterns that end in `/` so they only match with the trailing slash). To supply data to the root page you must register against the same pattern:
```go
// Right — matches the auto-registered PageRoute
app.Get("/{$}", handleLanding)
// Wrong — different pattern, will not be called for /
// app.Get("/", handleLanding)
```
### Multiple ViewersFrequently Asked Questions
What is Xun?
Xun is a Web Frameworks library for the Go programming language. Web framework built on Go's built-in html/template and net/http package’s router. It is designed to be lightweight, fast, and easy to use while providing a simple and intuitive API for building web applications with advanced features such as middleware, routing, and template rendering
How do I install Xun?
Install Xun with the Go module system using `go get yaitoo/xun`. Check the repository for the current installation instructions.
What category does Xun belong to?
Xun is listed under Web Frameworks, specifically Web Frameworks.