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

@ -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>