Skip to content

hupeh/gohtml

Repository files navigation

GoHTML

Go Reference Go Report Card License: MIT

一个强大且易用的 Go HTML 模板引擎,基于标准库 html/template,支持模板继承、热重载和丰富的内置函数。

特性

  • 🎯 模板继承 - 使用 {{ extends "parent.gohtml" }} 实现模板继承
  • 🔥 热重载 - 开发环境支持模板热重载,无需重启
  • 📦 embed.FS 支持 - 生产环境可将模板编译进二进制
  • 🧩 丰富的内置函数 - 字符串处理、日期格式化、逻辑判断等 20+ 函数
  • 🌐 HTTP 中间件 - 内置 HTTP 中间件,轻松集成到 Web 应用
  • 🎨 全局数据注入 - 支持在 context 中注入全局模板数据
  • 🔒 类型安全 - 基于标准库 html/template,自动转义防止 XSS
  • 0️⃣ 零依赖 - 仅依赖 Go 标准库

安装

go get github.com/hupeh/gohtml

快速开始

基础示例

创建模板文件 templates/index.gohtml:

<!DOCTYPE html>
<html>
<head>
    <title>{{ .Title }}</title>
</head>
<body>
    <h1>{{ .Message }}</h1>
    <p>当前时间: {{ now | datetimeFormat }}</p>
</body>
</html>

Go 代码:

package main

import (
    "fmt"
    "github.com/hupeh/gohtml"
)

func main() {
    // 创建引擎
    engine := gohtml.New()
    
    // 从目录加载模板
    err := engine.JoinDir("./templates")
    if err != nil {
        panic(err)
    }
    
    // 渲染模板
    result, err := engine.Render("index", map[string]any{
        "Title":   "欢迎",
        "Message": "Hello, GoHTML!",
    })
    if err != nil {
        panic(err)
    }
    
    fmt.Println(string(result))
}

模板继承

父模板 templates/layout.gohtml:

<!DOCTYPE html>
<html>
<head>
    <title>{{ block "title" . }}默认标题{{ end }}</title>
    <style>
        {{ block "style" . }}{{ end }}
    </style>
</head>
<body>
    <header>
        <h1>我的网站</h1>
    </header>
    
    <main>
        {{ block "content" . }}{{ end }}
    </main>
    
    <footer>
        <p>&copy; 2024 我的网站</p>
    </footer>
</body>
</html>

子模板 templates/home.gohtml:

{{ extends "layout.gohtml" }}

{{ define "title" }}首页 - 我的网站{{ end }}

{{ define "style" }}
body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 20px;
}
{{ end }}

{{ define "content" }}
<h2>欢迎来到首页</h2>
<p>用户: {{ .Username }}</p>
<p>今天是 {{ now | dateFormat }}</p>
{{ end }}

Go 代码:

engine := gohtml.New()
engine.JoinDir("./templates")

result, _ := engine.Render("home", map[string]any{
    "Username": "张三",
})

使用 embed.FS (生产环境)

package main

import (
    "embed"
    "github.com/hupeh/gohtml"
)

//go:embed templates/*.gohtml
var templateFS embed.FS

func main() {
    engine := gohtml.New()
    
    // 从嵌入的文件系统加载模板
    err := engine.JoinFS(templateFS)
    if err != nil {
        panic(err)
    }
    
    result, _ := engine.Render("index", map[string]any{
        "Title": "生产环境",
    })
}

HTTP 中间件集成

package main

import (
    "net/http"
    "github.com/hupeh/gohtml"
)

func main() {
    engine := gohtml.New()
    engine.JoinDir("./templates")
    
    mux := http.NewServeMux()
    
    // 使用中间件注入引擎和全局数据
    handler := gohtml.Middleware(engine, map[string]any{
        "SiteName": "我的网站",
        "Version":  "1.0.0",
    })(mux)
    
    // 路由处理 - 方式 1:使用 Render 函数(推荐)
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // Render 函数会自动从 context 获取引擎和全局数据
        result, err := gohtml.Render(r.Context(), "index", map[string]any{
            "Title":   "首页",
            "Content": "欢迎访问",
        })
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        w.Write(result)
    })
    
    // 路由处理 - 方式 2:手动获取引擎
    mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
        eng, _ := gohtml.FromContext(r.Context())
        data := gohtml.GetData(r.Context())
        data["Title"] = "关于我们"
        
        result, _ := eng.Render("about", data)
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        w.Write(result)
    })
    
    http.ListenAndServe(":8080", handler)
}

内置函数完整列表

GoHTML 提供了丰富的内置函数,包括 Go 标准库的函数和自定义扩展函数。

📋 函数来源说明

  • 🔵 标准库 - 来自 Go html/template 标准库,行为保持不变
  • 🟢 覆盖 - 覆盖了标准库的同名函数,行为已修改
  • 🟣 扩展 - GoHTML 新增的自定义函数

⚠️ 重要说明:自动转义与类型转换函数

GoHTML 基于 html/template,默认会自动转义所有输出以防止 XSS 攻击。

<!-- 自动转义示例 -->
{{ .Content }}
<!-- 如果 Content = "<script>alert('xss')</script>" -->
<!-- 输出: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt; -->
<!-- 浏览器显示为纯文本,不执行脚本 -->

当你需要渲染可信的 HTML 内容时(如富文本编辑器的输出),使用类型转换函数跳过转义:

<!-- 跳过转义示例 -->
{{ html .Content }}
<!-- 如果 Content = "<h2>标题</h2><p>段落</p>" -->
<!-- 输出: <h2>标题</h2><p>段落</p> -->
<!-- 浏览器正确渲染 HTML -->

安全提示

  • ✅ 对用户输入的内容:使用 {{ .UserInput }}(自动转义)
  • ✅ 对可信的 HTML 内容:使用 {{ html .TrustedContent }}(跳过转义)
  • ⚠️ 永远不要对未经验证的用户输入使用 html 函数,这会导致 XSS 漏洞

类型转换函数

用于标记可信内容,跳过自动转义:

函数 来源 说明 示例
html 🟢 覆盖 标记为安全的 HTML(标准库是转义,这里是跳过转义) {{ html "<b>粗体</b>" }}
css 🟣 扩展 标记为安全的 CSS {{ css "color: red;" }}
js 🟢 覆盖 标记为安全的 JavaScript(标准库是转义,这里是跳过转义) {{ js "alert('hi')" }}
url 🟣 扩展 标记为安全的 URL {{ url "https://example.com" }}
attr 🟣 扩展 标记为安全的 HTML 属性 {{ attr "data-id='123'" }}

字符串处理函数

函数 来源 说明 示例
upper 🟣 扩展 转换为大写 {{ "hello" | upper }} → HELLO
lower 🟣 扩展 转换为小写 {{ "WORLD" | lower }} → world
trim 🟣 扩展 去除两端空白 {{ " text " | trim }} → text
trimPrefix 🟣 扩展 去除前缀 {{ trimPrefix "Hello" "He" }} → llo
trimSuffix 🟣 扩展 去除后缀 {{ trimSuffix "Hello" "lo" }} → Hel
replace 🟣 扩展 替换字符串 {{ replace "foo bar" "bar" "baz" -1 }}
split 🟣 扩展 分割字符串 {{ split "a,b,c" "," }} → [a b c]
join 🟣 扩展 连接字符串数组 {{ join .array ", " }}
contains 🟣 扩展 检查是否包含 {{ contains "hello" "ll" }} → true
hasPrefix 🟣 扩展 检查前缀 {{ hasPrefix "hello" "he" }} → true
hasSuffix 🟣 扩展 检查后缀 {{ hasSuffix "hello" "lo" }} → true
print 🔵 标准库 格式化输出(同 fmt.Sprint) {{ print "a" "b" }} → ab
printf 🔵 标准库 格式化输出(同 fmt.Sprintf) {{ printf "%s-%d" "id" 123 }} → id-123
println 🔵 标准库 格式化输出带换行(同 fmt.Sprintln) {{ println "hello" }}
len 🔵 标准库 返回长度 {{ len .Array }}, {{ len .String }}
index 🔵 标准库 访问数组/切片/映射元素 {{ index .Array 0 }}, {{ index .Map "key" }}
slice 🔵 标准库 切片操作 {{ slice .Array 1 3 }}

日期时间函数

函数 来源 说明 示例
now 🟣 扩展 获取当前时间 {{ now }}
formatTime 🟣 扩展 自定义格式化 {{ formatTime .time "2006-01-02" }}
dateFormat 🟣 扩展 格式化为日期 {{ now | dateFormat }} → 2024-01-13
datetimeFormat 🟣 扩展 格式化为日期时间 {{ now | datetimeFormat }} → 2024-01-13 15:04:05
rfc3339 🟣 扩展 RFC3339 格式化 {{ now | rfc3339 }} → 2024-01-13T15:04:05Z07:00

逻辑与比较函数

函数 来源 说明 示例
not 🔵 标准库 逻辑非 {{ if not .IsEmpty }}有内容{{ end }}
and 🔵 标准库 逻辑与 {{ if and .A .B }}都为真{{ end }}
or 🔵 标准库 逻辑或 {{ if or .A .B }}至少一个为真{{ end }}
eq 🔵 标准库 等于 (==) {{ if eq .Status "active" }}激活{{ end }}
ne 🔵 标准库 不等于 (!=) {{ if ne .Count 0 }}非零{{ end }}
lt 🔵 标准库 小于 (<) {{ if lt .Age 18 }}未成年{{ end }}
le 🔵 标准库 小于等于 (<=) {{ if le .Score 60 }}不及格{{ end }}
gt 🔵 标准库 大于 (>) {{ if gt .Price 100 }}贵{{ end }}
ge 🔵 标准库 大于等于 (>=) {{ if ge .Age 18 }}成年{{ end }}
default 🟣 扩展 提供默认值 {{ default "未知" .Name }}

其他函数

函数 来源 说明 示例
urlquery 🔵 标准库 URL 查询参数转义 {{ urlquery "a b" }} → a+b
call 🔵 标准库 调用函数 {{ call .Method .Arg }}

完整示例

博客应用示例

布局模板 templates/layout.gohtml:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ block "title" . }}{{ .SiteName }}{{ end }}</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: Arial, sans-serif; line-height: 1.6; }
        header { background: #333; color: #fff; padding: 1rem; }
        nav a { color: #fff; margin-right: 1rem; text-decoration: none; }
        main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
        footer { background: #f4f4f4; padding: 1rem; text-align: center; margin-top: 2rem; }
        {{ block "style" . }}{{ end }}
    </style>
</head>
<body>
    <header>
        <h1>{{ .SiteName }}</h1>
        <nav>
            <a href="/">首页</a>
            <a href="/about">关于</a>
            <a href="/contact">联系</a>
        </nav>
    </header>
    
    <main>
        {{ block "content" . }}{{ end }}
    </main>
    
    <footer>
        <p>&copy; {{ now | formatTime "2006" }} {{ .SiteName }} | 版本 {{ .Version }}</p>
    </footer>
</body>
</html>

文章列表 templates/posts.gohtml:

{{ extends "layout.gohtml" }}

{{ define "title" }}文章列表 - {{ .SiteName }}{{ end }}

{{ define "content" }}
<h2>最新文章</h2>

{{ if .Posts }}
    {{ range .Posts }}
    <article style="border-bottom: 1px solid #eee; padding: 1rem 0;">
        <h3><a href="/posts/{{ .ID }}">{{ .Title }}</a></h3>
        <p>{{ .Summary }}</p>
        <small>
            作者: {{ .Author | upper }} | 
            发布时间: {{ .PublishedAt | datetimeFormat }}
        </small>
    </article>
    {{ end }}
{{ else }}
    <p>暂无文章</p>
{{ end }}
{{ end }}

文章详情 templates/post.gohtml:

{{ extends "layout.gohtml" }}

{{ define "title" }}{{ .Post.Title }} - {{ .SiteName }}{{ end }}

{{ define "content" }}
<article>
    <h2>{{ .Post.Title }}</h2>
    <p>
        <small>
            作者: {{ .Post.Author }} | 
            发布: {{ .Post.PublishedAt | datetimeFormat }} |
            标签: {{ join .Post.Tags ", " }}
        </small>
    </p>
    
    <div style="margin-top: 2rem;">
        {{ html .Post.Content }}
    </div>
    
    {{ if .Post.UpdatedAt }}
    <p style="margin-top: 2rem; color: #666;">
        最后更新: {{ .Post.UpdatedAt | datetimeFormat }}
    </p>
    {{ end }}
</article>

<section style="margin-top: 3rem;">
    <h3>相关文章</h3>
    <ul>
        {{ range .RelatedPosts }}
        <li><a href="/posts/{{ .ID }}">{{ .Title }}</a></li>
        {{ else }}
        <li>暂无相关文章</li>
        {{ end }}
    </ul>
</section>
{{ end }}

API 文档

Engine

type Engine struct {
    // 私有字段
}

// 创建新的引擎实例
func New() *Engine

// 从 fs.FS 加载模板(支持 embed.FS)
func (e *Engine) JoinFS(fsys fs.FS) error

// 从目录加载模板(支持热重载)
func (e *Engine) JoinDir(dir string) error

// 渲染模板,返回字节数组
func (e *Engine) Render(name string, data any) ([]byte, error)

Template

type Template struct {
    // 私有字段
}

// 创建新的模板实例
func NewTemplate() *Template

// 设置模板分隔符
func (x *Template) Delims(left, right string) *Template

// 注册自定义函数
func (x *Template) Funcs(funcMap template.FuncMap) *Template

// 查找模板
func (x *Template) Lookup(name string) *template.Template

// 执行模板
func (x *Template) ExecuteTemplate(wr io.Writer, name string, data any) error

// 从目录加载模板
func (x *Template) ParseDir(root string, extensions []string) error

// 从 fs.FS 加载模板
func (x *Template) ParseFS(root fs.FS, extensions []string) error

Context 函数

// 将引擎存入 context
func WithEngine(ctx context.Context, engine *Engine) context.Context

// 从 context 获取引擎
func FromContext(ctx context.Context) (*Engine, bool)

// 存入单个数据
func WithDatum(ctx context.Context, key string, value any) context.Context

// 存入多个数据
func WithData(ctx context.Context, dataMap map[string]any) context.Context

// 获取所有数据
func GetData(ctx context.Context) map[string]any

// 从 context 中获取引擎并渲染模板(自动合并全局数据)
func Render(ctx context.Context, name string, data map[string]any) ([]byte, error)

HTTP 中间件

// 创建 HTTP 中间件
func Middleware(engine *Engine, globals ...map[string]any) func(next http.Handler) http.Handler

高级用法

自定义函数

engine := gohtml.New()

// 注册自定义函数(需在加载模板前)
tmpl := gohtml.NewTemplate()
tmpl.Funcs(template.FuncMap{
    "currency": func(amount float64) string {
        return fmt.Sprintf("¥%.2f", amount)
    },
    "truncate": func(s string, length int) string {
        if len(s) <= length {
            return s
        }
        return s[:length] + "..."
    },
})

// 使用自定义模板
engine.t = tmpl
engine.JoinDir("./templates")

在模板中使用:

<p>价格: {{ currency 99.99 }}</p>
<p>摘要: {{ truncate .Content 100 }}</p>

自定义分隔符

tmpl := gohtml.NewTemplate()
tmpl.Delims("[[", "]]")  // 使用 [[ ]] 替代 {{ }}

嵌套模板

<!-- components/button.gohtml -->
{{ define "button" }}
<button class="{{ .Class }}" type="{{ default "button" .Type }}">
    {{ .Text }}
</button>
{{ end }}

<!-- page.gohtml -->
{{ template "button" dict "Class" "primary" "Text" "提交" "Type" "submit" }}

性能优化

生产环境最佳实践

  1. 使用 embed.FS: 将模板编译进二进制,避免磁盘 I/O
  2. 模板缓存: Engine 会自动缓存已解析的模板
  3. 避免频繁重载: 生产环境不要使用 JoinDir,优先使用 JoinFS
//go:build !dev

package main

import (
    "embed"
    "github.com/hupeh/gohtml"
)

//go:embed templates/*.gohtml
var templateFS embed.FS

func newEngine() *gohtml.Engine {
    engine := gohtml.New()
    engine.JoinFS(templateFS)
    return engine
}

开发环境热重载

//go:build dev

package main

import "github.com/hupeh/gohtml"

func newEngine() *gohtml.Engine {
    engine := gohtml.New()
    engine.JoinDir("./templates")
    return engine
}

构建命令:

# 开发环境
go build -tags dev

# 生产环境
go build

测试

运行测试:

go test ./...

运行带覆盖率的测试:

go test -cover ./...

许可证

MIT License - 详见 LICENSE

贡献

欢迎提交 Issue 和 Pull Request!

作者

hupeh

致谢

本项目基于 Go 标准库 html/template 构建,灵感来自 Django 和 Jinja2 模板引擎。

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages