1
0
Fork 0
forked from goffee/cup

Custom html/template FuncMap extensions that bring Liquid-like expressiveness to Go server-side templates. Add template demo.

This commit is contained in:
Zeni Kim 2026-05-02 03:03:36 -05:00
parent 7bf28afdbf
commit a7310b24d5
5 changed files with 610 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import (
"math/rand/v2"
"os"
"strconv"
"time"
"git.smarteching.com/goffee/core"
"git.smarteching.com/goffee/core/template/components"
@ -861,3 +862,133 @@ func getPaginatedPageViews(values [][]components.ContentTableTD, limit int, offs
return values[offset:end] // Slice the array
}
// Custom Templates functions
func TemplatesFunctions(c *core.Context) *core.Response {
// check if template engine is enabled
TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE")
if TemplateEnableStr == "" {
TemplateEnableStr = "false"
}
TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr)
if TemplateEnable {
tmplData := SamplePageData()
return c.Response.Template("custom_templates_functions.html", tmplData)
} else {
message := "{\"message\": \"Error, template not enabled\"}"
return c.Response.Json(message)
}
}
// Author represents the writer of an article to show the custom functions
type Author struct {
Name string
Avatar string
Bio string
}
// Article represents a blog post or news entry used to test template helpers to show the custom functions
type Article struct {
Title string
Slug string
Excerpt string
Body string
Tags []string
PublishedAt time.Time
UpdatedAt time.Time
Author Author
Views int
Price float64
Featured bool
Subtitle string // intentionally left empty on some entries to test coalesce/defaultVal
}
// PageData is the top-level context passed to the HTML template to show the custom functions
type PageData struct {
SiteTitle string
Articles []Article
}
// SamplePageData returns a populated PageData ready to be rendered by the template.
func SamplePageData() PageData {
return PageData{
SiteTitle: "go/template lab",
Articles: []Article{
{
Title: "getting started with go templates",
Slug: "getting-started-go-templates",
Excerpt: "Go's html/template package is both powerful and safe by default. In this article we explore how to extend it with custom FuncMap helpers that bring it closer to the expressiveness of Liquid or Twig, without sacrificing any of the security guarantees.",
Tags: []string{"go", "templates", "web", "backend"},
PublishedAt: time.Now().Add(-3 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * 24 * time.Hour),
Views: 14200,
Price: 0,
Featured: true,
Subtitle: "",
Author: Author{
Name: "marina voss",
Avatar: "MV",
Bio: "Senior backend engineer focused on developer tooling and observability.",
},
},
{
Title: "building a blog engine in go",
Slug: "blog-engine-go",
Excerpt: "We walk through building a minimal but complete blog engine using only the Go standard library: routing with net/http, persistence with database/sql, and rendering with html/template.",
Tags: []string{"go", "blog", "sqlite"},
PublishedAt: time.Now().Add(-10 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-10 * 24 * time.Hour),
Views: 8750,
Price: 9.99,
Featured: false,
Subtitle: "A zero-dependency approach",
Author: Author{
Name: "rafael okonkwo",
Avatar: "RO",
Bio: "Full-stack engineer and open-source contributor. Writes about Go and distributed systems.",
},
},
{
Title: "concurrency patterns you should know",
Slug: "concurrency-patterns-go",
Excerpt: "Goroutines are cheap, but misusing them is expensive. This deep-dive covers fan-out/fan-in, pipelines, semaphores, and error group patterns with real production examples.",
Tags: []string{"go", "concurrency", "goroutines", "advanced"},
PublishedAt: time.Now().Add(-45 * 24 * time.Hour),
UpdatedAt: time.Now().Add(-40 * 24 * time.Hour),
Views: 31400,
Price: 14.99,
Featured: true,
Subtitle: "",
Author: Author{
Name: "selin çelik",
Avatar: "SÇ",
Bio: "Systems programmer. Previously at Cloudflare. Loves writing about the internals of things.",
},
},
{
Title: "understanding go interfaces",
Slug: "understanding-go-interfaces",
Excerpt: "Interfaces in Go are implicit and structural, which makes them both elegant and occasionally surprising. We look at how the runtime dispatches method calls and how to design composable interfaces.",
Tags: []string{"go", "interfaces", "design"},
PublishedAt: time.Now().Add(-2 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
Views: 420,
Price: 0,
Featured: false,
Subtitle: "Implicit, structural, and powerful",
Author: Author{
Name: "marina voss",
Avatar: "MV",
Bio: "Senior backend engineer focused on developer tooling and observability.",
},
},
},
}
}

2
go.mod
View file

@ -21,9 +21,11 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
git.smarteching.com/zeni/go-chart/v2 v2.1.4 // indirect
git.smarteching.com/zeni/go-charts/v2 v2.6.11 // indirect
github.com/SparkPost/gosparkpost v0.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect

4
go.sum
View file

@ -4,6 +4,8 @@ git.smarteching.com/goffee/core v1.9.6 h1:GY1EXqbmBEWZAVrl3q22Izb6aXhQzFVQBv2hWh
git.smarteching.com/goffee/core v1.9.6/go.mod h1:ifiBgTOR4zCMzdGsabNrEO792EHny2o149NGe3TSlms=
git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q=
git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ=
git.smarteching.com/zeni/go-charts/v2 v2.6.11 h1:9udzlv3uxGXszpplfkL5IaTUrgkNj++KwhbaN1vVEqI=
git.smarteching.com/zeni/go-charts/v2 v2.6.11/go.mod h1:3OpRPSXg7Qx4zcgsmwsC9ZFB9/wAkGSbnXf1wIbHYCg=
github.com/SparkPost/gosparkpost v0.2.0 h1:yzhHQT7cE+rqzd5tANNC74j+2x3lrPznqPJrxC1yR8s=
github.com/SparkPost/gosparkpost v0.2.0/go.mod h1:S9WKcGeou7cbPpx0kTIgo8Q69WZvUmVeVzbD+djalJ4=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@ -21,6 +23,8 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=

View file

@ -60,4 +60,6 @@ func registerRoutes() {
controller.Get("/appsession", controllers.AppSession)
controller.Post("/appsession", controllers.AppSession)
controller.Get("/templatesfunc", controllers.TemplatesFunctions)
}

View file

@ -0,0 +1,471 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.SiteTitle}} — template lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0f;
--surface: #141416;
--border: #222226;
--muted: #3a3a40;
--subtle: #6b6b75;
--body: #c8c8d0;
--heading: #f0f0f4;
--accent: #e8c547;
--accent2: #5b8cff;
--danger: #ff6b6b;
--mono: 'JetBrains Mono', monospace;
--serif: 'Playfair Display', Georgia, serif;
--sans: 'DM Sans', sans-serif;
}
html { background: var(--bg); color: var(--body); font-family: var(--sans); font-size: 16px; line-height: 1.6; }
body { max-width: 1100px; margin: 0 auto; padding: 0 2rem 6rem; }
/* ── Header ── */
header {
border-bottom: 1px solid var(--border);
padding: 3rem 0 2rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 1rem;
}
.site-title {
font-family: var(--mono);
font-size: .75rem;
letter-spacing: .2em;
text-transform: uppercase;
color: var(--accent);
}
.site-headline {
font-family: var(--serif);
font-size: clamp(2rem, 5vw, 3.5rem);
color: var(--heading);
line-height: 1.1;
margin-top: .4rem;
}
.site-headline em { color: var(--accent); font-style: italic; }
.helper-count {
font-family: var(--mono);
font-size: .7rem;
color: var(--subtle);
text-align: right;
white-space: nowrap;
}
/* ── Section label ── */
.section-label {
display: flex;
align-items: center;
gap: 1rem;
margin: 4rem 0 1.5rem;
}
.section-label span {
font-family: var(--mono);
font-size: .65rem;
letter-spacing: .15em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Helper badge ── */
.badge {
display: inline-block;
font-family: var(--mono);
font-size: .6rem;
letter-spacing: .08em;
padding: .15em .5em;
border-radius: 3px;
border: 1px solid var(--muted);
color: var(--subtle);
vertical-align: middle;
margin-left: .4rem;
}
.badge-accent { border-color: var(--accent); color: var(--accent); }
.badge-blue { border-color: var(--accent2); color: var(--accent2); }
/* ── Demo block ── */
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.demo-cell {
background: var(--surface);
padding: 1.5rem;
}
.demo-cell h3 {
font-family: var(--mono);
font-size: .7rem;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: .75rem;
}
.demo-cell .result {
font-size: 1rem;
color: var(--heading);
}
.demo-cell .note {
margin-top: .5rem;
font-size: .75rem;
color: var(--subtle);
font-family: var(--mono);
}
/* ── Article cards ── */
.article-list { display: flex; flex-direction: column; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.article-card { background: var(--surface); padding: 2rem; display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: start; transition: background .15s; }
.article-card:hover { background: #18181c; }
.card-meta { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; margin-bottom: .75rem; }
.tag {
font-family: var(--mono);
font-size: .6rem;
letter-spacing: .08em;
text-transform: uppercase;
padding: .2em .55em;
background: var(--muted);
border-radius: 3px;
color: var(--body);
}
.tag-featured { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
.card-title { font-family: var(--serif); font-size: 1.35rem; color: var(--heading); line-height: 1.25; margin-bottom: .5rem; }
.card-subtitle { font-size: .85rem; color: var(--accent2); margin-bottom: .6rem; font-style: italic; }
.card-excerpt { font-size: .875rem; color: var(--body); line-height: 1.65; }
.card-author { display: flex; align-items: center; gap: .6rem; margin-top: 1.25rem; }
.avatar {
width: 32px; height: 32px; border-radius: 50%;
background: var(--muted);
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-size: .55rem; color: var(--accent);
flex-shrink: 0;
}
.author-name { font-size: .8rem; color: var(--heading); }
.author-date { font-size: .72rem; color: var(--subtle); font-family: var(--mono); }
.card-aside { text-align: right; }
.views { font-family: var(--mono); font-size: .75rem; color: var(--subtle); white-space: nowrap; }
.views strong { color: var(--body); display: block; font-size: 1rem; }
.price-badge {
display: inline-block; margin-top: .75rem;
font-family: var(--mono); font-size: .7rem;
padding: .3em .7em; border-radius: 4px;
background: color-mix(in srgb, var(--accent2) 15%, transparent);
color: var(--accent2); border: 1px solid var(--accent2);
}
.price-free {
background: color-mix(in srgb, #4caf50 12%, transparent);
color: #6fcf97; border-color: #4caf50;
}
/* ── Logic demo table ── */
.logic-table { width: 100%; border-collapse: collapse; font-size: .85rem; }
.logic-table th {
font-family: var(--mono); font-size: .65rem; letter-spacing: .1em; text-transform: uppercase;
color: var(--subtle); text-align: left; padding: .6rem 1rem;
border-bottom: 1px solid var(--border);
}
.logic-table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); color: var(--body); }
.logic-table tr:last-child td { border-bottom: none; }
.logic-table tr:hover td { background: var(--surface); }
.val { font-family: var(--mono); font-size: .8rem; color: var(--accent); }
/* ── Footer ── */
footer {
margin-top: 5rem; padding-top: 2rem; border-top: 1px solid var(--border);
font-family: var(--mono); font-size: .65rem; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: .5rem;
}
</style>
</head>
<body>
<!-- ════════════════════════════════════════════
HEADER
════════════════════════════════════════════ -->
<header>
<div>
<p class="site-title">{{.SiteTitle}}</p>
<h1 class="site-headline">Template <em>helpers</em><br>in action</h1>
</div>
<p class="helper-count">22 helpers registered<br>across 6 groups</p>
</header>
<!-- ════════════════════════════════════════════
STRING HELPERS
════════════════════════════════════════════ -->
<div class="section-label"><span>String helpers</span></div>
{{$first := first .Articles}}
<div class="demo-grid">
<div class="demo-cell">
<h3>capitalize <span class="badge">capitalize</span></h3>
<p class="result">{{capitalize $first.Title}}</p>
<p class="note">input: "{{$first.Title}}"</p>
</div>
<div class="demo-cell">
<h3>truncate <span class="badge">truncate</span></h3>
<p class="result">{{$first.Excerpt | truncate 80}}</p>
<p class="note">capped at 80 chars</p>
</div>
<div class="demo-cell">
<h3>prepend <span class="badge">prepend</span></h3>
<p class="result">{{prepend $first.Slug "/articles/"}}</p>
<p class="note">prepended "/articles/" to slug</p>
</div>
<div class="demo-cell">
<h3>strAppend <span class="badge">strAppend</span></h3>
<p class="result">{{strAppend $first.Slug ".html"}}</p>
<p class="note">appended ".html" to slug</p>
</div>
<div class="demo-cell">
<h3>split → join <span class="badge">split</span> <span class="badge">join</span></h3>
{{$parts := split "go,templates,funcmap" ","}}
<p class="result">{{join $parts " · "}}</p>
<p class="note">split on "," then joined with " · "</p>
</div>
</div>
<!-- ════════════════════════════════════════════
NUMBER & DATE HELPERS
════════════════════════════════════════════ -->
<div class="section-label"><span>Number &amp; Date helpers</span></div>
<div class="demo-grid">
<div class="demo-cell">
<h3>fmtNumber — int <span class="badge">fmtNumber</span></h3>
<p class="result">{{fmtNumber $first.Views}}</p>
<p class="note">raw value: {{$first.Views}}</p>
</div>
{{$paid := index .Articles 2}}
<div class="demo-cell">
<h3>fmtNumber — float <span class="badge">fmtNumber</span></h3>
<p class="result">$ {{fmtNumber $paid.Price}}</p>
<p class="note">raw value: {{$paid.Price}}</p>
</div>
<div class="demo-cell">
<h3>fmtDate "short" <span class="badge">fmtDate</span></h3>
<p class="result">{{fmtDate $first.PublishedAt "short"}}</p>
<p class="note">layout: "02 Jan 2006"</p>
</div>
<div class="demo-cell">
<h3>fmtDate "long" <span class="badge">fmtDate</span></h3>
<p class="result">{{fmtDate $first.PublishedAt "long"}}</p>
<p class="note">layout: "02 January 2006"</p>
</div>
<div class="demo-cell">
<h3>fmtDate "iso" <span class="badge">fmtDate</span></h3>
<p class="result">{{fmtDate $first.PublishedAt "iso"}}</p>
<p class="note">layout: "2006-01-02"</p>
</div>
<div class="demo-cell">
<h3>timeAgo <span class="badge">timeAgo</span></h3>
{{range .Articles}}
<p class="result" style="margin-bottom:.3rem">{{timeAgo .PublishedAt}} <span class="note" style="display:inline">— {{fmtDate .PublishedAt "short"}}</span></p>
{{end}}
</div>
</div>
<!-- ════════════════════════════════════════════
COLLECTION HELPERS
════════════════════════════════════════════ -->
<div class="section-label"><span>Collection helpers</span></div>
<div class="demo-grid">
<div class="demo-cell">
<h3>first <span class="badge">first</span></h3>
{{with first .Articles}}
<p class="result">{{capitalize .Title}}</p>
<p class="note">first article in the list</p>
{{end}}
</div>
<div class="demo-cell">
<h3>last <span class="badge">last</span></h3>
{{with last .Articles}}
<p class="result">{{capitalize .Title}}</p>
<p class="note">last article in the list</p>
{{end}}
</div>
<div class="demo-cell">
<h3>sliceOf 02 <span class="badge">sliceOf</span></h3>
{{range sliceOf .Articles 0 2}}
<p class="result" style="margin-bottom:.25rem">— {{capitalize .Title}}</p>
{{end}}
<p class="note">only first 2 of {{len .Articles}} articles</p>
</div>
<div class="demo-cell">
<h3>contains — slice <span class="badge">contains</span></h3>
{{if contains $first.Tags "go"}}
<p class="result" style="color:var(--accent)">✓ has tag "go"</p>
{{else}}
<p class="result" style="color:var(--danger)">✗ missing tag "go"</p>
{{end}}
<p class="note">tags: {{join $first.Tags ", "}}</p>
</div>
<div class="demo-cell">
<h3>contains — string <span class="badge">contains</span></h3>
{{if contains $first.Excerpt "FuncMap"}}
<p class="result" style="color:var(--accent)">✓ excerpt mentions "FuncMap"</p>
{{else}}
<p class="result" style="color:var(--danger)">✗ not found</p>
{{end}}
<p class="note">substring search on .Excerpt</p>
</div>
<div class="demo-cell">
<h3>join <span class="badge">join</span></h3>
<p class="result">{{join $first.Tags " / "}}</p>
<p class="note">tags joined with " / "</p>
</div>
</div>
<!-- ════════════════════════════════════════════
LOGIC HELPERS
════════════════════════════════════════════ -->
<div class="section-label"><span>Logic helpers</span></div>
<table class="logic-table">
<thead>
<tr>
<th>Helper</th>
<th>Article</th>
<th>Input</th>
<th>Output</th>
</tr>
</thead>
<tbody>
{{range .Articles}}
<tr>
<td><span class="badge badge-accent">defaultVal</span></td>
<td>{{.Title | capitalize | truncate 30}}</td>
<td class="val">.Subtitle ({{if .Subtitle}}"{{.Subtitle}}"{{else}}empty{{end}})</td>
<td class="val">{{defaultVal "No subtitle" .Subtitle}}</td>
</tr>
<tr>
<td><span class="badge badge-blue">ternary</span></td>
<td>{{capitalize .Title | truncate 30}}</td>
<td class="val">.Featured = {{.Featured}}</td>
<td class="val">{{ternary "⭐ featured" "regular" .Featured}}</td>
</tr>
<tr>
<td><span class="badge badge-accent">coalesce</span></td>
<td>{{capitalize .Title | truncate 30}}</td>
<td class="val">.Subtitle → .Title</td>
<td class="val">{{coalesce .Subtitle .Title}}</td>
</tr>
{{end}}
</tbody>
</table>
<!-- ════════════════════════════════════════════
FULL ARTICLE CARDS (all helpers combined)
════════════════════════════════════════════ -->
<div class="section-label"><span>Full article cards — all helpers combined</span></div>
<div class="article-list">
{{range .Articles}}
<div class="article-card">
<div>
<div class="card-meta">
{{if .Featured}}<span class="tag tag-featured">featured</span>{{end}}
{{range .Tags}}<span class="tag">{{.}}</span>{{end}}
</div>
<h2 class="card-title">{{capitalize .Title}}</h2>
{{with coalesce .Subtitle ""}}
<p class="card-subtitle">{{.}}</p>
{{end}}
<p class="card-excerpt">{{.Excerpt | truncate 160}}</p>
<a style="font-family:var(--mono);font-size:.7rem;color:var(--accent2);text-decoration:none;margin-top:.75rem;display:inline-block;"
href="{{prepend .Slug "/articles/"}}">
{{prepend .Slug "/articles/"}} →
</a>
<div class="card-author">
<div class="avatar">{{.Author.Avatar}}</div>
<div>
<div class="author-name">{{capitalize .Author.Name}}</div>
<div class="author-date">
{{fmtDate .PublishedAt "short"}} · {{timeAgo .PublishedAt}}
</div>
</div>
</div>
</div>
<div class="card-aside">
<div class="views">
<strong>{{fmtNumber .Views}}</strong>
views
</div>
{{if gt .Price 0.0}}
<span class="price-badge">$ {{fmtNumber .Price}}</span>
{{else}}
<span class="price-badge price-free">free</span>
{{end}}
</div>
</div>
{{end}}
</div>
<!-- ════════════════════════════════════════════
FOOTER
════════════════════════════════════════════ -->
<footer>
<span>{{.SiteTitle}} / template sandbox</span>
<span>{{len .Articles}} articles · {{fmtDate (first .Articles).PublishedAt "iso"}} → {{fmtDate (last .Articles).PublishedAt "iso"}}</span>
</footer>
</body>
</html>