// Package tmpl renders the final HTML pages. Every page is one
// self-contained file: inline CSS, JSON-LD, meta tags. No JavaScript
// runtime — only a speculation-rules hint, which browsers ignore
// unless they support it.
package tmpl
import (
"fmt"
"html"
htmltemplate "html/template"
"strings"
"time"
"mfblog/internal/config"
)
// Page is everything the template needs to render one page.
type Page struct {
Site config.Config
Title string
Desc string
Canonical string
OGType string
JSONLD htmltemplate.JS
Preload string
Body htmltemplate.HTML
}
var (
pagetmpl = htmltemplate.Must(htmltemplate.New("page").Parse(pageHTML))
)
// Render renders the full HTML document for one page.
func Render(p Page) (string, error) {
data := map[string]any{
"Site": p.Site,
"Title": p.Title,
"Desc": p.Desc,
"Canonical": p.Canonical,
"OGType": p.OGType,
"JSONLD": p.JSONLD,
"Preload": p.Preload,
"Body": p.Body,
"CSS": htmltemplate.CSS(css),
"SpeculationRules": htmltemplate.HTML(specRules),
"Year": time.Now().Year(),
"FooterLinks": footerLinksHTML(p.Site.FooterLinks, p.Site.BasePath),
}
var buf strings.Builder
if err := pagetmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render page: %w", err)
}
return buf.String(), nil
}
const specRules = `{"prerender":[{"where":{"href_matches":["/*"]},"eagerness":"moderate"}]}`
// footerLinksHTML renders the site's configured footer links as HTML anchors
// joined with " · " (the separator the footer paragraph already uses).
// It mirrors the post link post-processing in internal/post: internal
// root-absolute hrefs are anchored to the base path, external URLs open in a
// new tab, anything else is left as written.
func footerLinksHTML(links []config.FooterLink, base string) htmltemplate.HTML {
parts := make([]string, 0, len(links))
for _, l := range links {
href, attrs := l.Href, ""
switch {
case isExternalHref(href):
attrs = ` target="_blank" rel="noopener"`
case strings.HasPrefix(href, "/") && !strings.HasPrefix(href, "//"):
href = base + href
}
parts = append(parts, ``+html.EscapeString(l.Text)+``)
}
return htmltemplate.HTML(strings.Join(parts, " · "))
}
// isExternalHref is the same scheme check as isExternal in internal/post,
// kept local so the template package does not depend on the markdown pipeline.
func isExternalHref(href string) bool {
return strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") ||
strings.HasPrefix(href, "data:") || strings.HasPrefix(href, "mailto:")
}
const pageHTML = `
{{.Title}}
{{- with .Desc}}
{{- end}}
{{- with .Canonical}}
{{- end}}
{{- with .JSONLD}}
{{- end}}
{{- with .Preload}}
{{- end}}
{{.Site.Title}}
{{.Body}}
`
const css = `
/* Light theme (default). */
:root {
--bg: #fff;
--fg: #1a1a1a;
--muted: #6b6b6b;
--accent: #005a9c;
--line: #e4e4e4;
--code-bg: #f4f4f4;
--syn-key: #6f42c1;
--syn-str: #2e7d32;
--syn-com: #5e6670;
--syn-num: #c2410c;
--syn-fn: #1d4ed8;
--syn-cls: #0e7490;
--syn-op: #57606a;
color-scheme: light;
}
/* Follow the system preference… */
@media (prefers-color-scheme: dark) {
:root {
--bg: #141414;
--fg: #e8e8e8;
--muted: #9a9a9a;
--accent: #7ab8ff;
--line: #2e2e2e;
--code-bg: #1f1f1f;
--syn-key: #c678dd;
--syn-str: #98c379;
--syn-com: #93989f;
--syn-num: #d19a66;
--syn-fn: #61afef;
--syn-cls: #56b6c2;
--syn-op: #abb2bf;
color-scheme: dark;
}
}
/* …and the CSS-only switcher flips it: an unchecked box follows the
system, a checked box shows the opposite theme. No JavaScript.
(The checkbox is hidden in the body; :has() reaches up to :root.) */
html:has(#theme-switch:checked) {
--bg: #141414;
--fg: #e8e8e8;
--muted: #9a9a9a;
--accent: #7ab8ff;
--line: #2e2e2e;
--code-bg: #1f1f1f;
--syn-key: #c678dd;
--syn-str: #98c379;
--syn-com: #93989f;
--syn-num: #d19a66;
--syn-fn: #61afef;
--syn-cls: #56b6c2;
--syn-op: #abb2bf;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
html:has(#theme-switch:checked) {
--bg: #fff;
--fg: #1a1a1a;
--muted: #6b6b6b;
--accent: #005a9c;
--line: #e4e4e4;
--code-bg: #f4f4f4;
--syn-key: #6f42c1;
--syn-str: #2e7d32;
--syn-com: #5e6670;
--syn-num: #c2410c;
--syn-fn: #1d4ed8;
--syn-cls: #0e7490;
--syn-op: #57606a;
color-scheme: light;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto;
padding: 0 clamp(1rem, 4vw, 2rem);
max-width: 46rem;
font: 1.05rem/1.65 system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--fg);
}
/* Anchor jumps should land below the sticky header, not behind it. */
[id] { scroll-margin-top: 4.5rem; }
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-block: 1.25rem 0;
border-bottom: 1px solid var(--line);
.brand {
font-weight: 650;
font-size: 1.15rem;
color: var(--fg);
text-decoration: none;
}
}
/* Only the site header (a direct child of ) is pinned. A post page
also has a inside — the title block — which must
scroll normally, so do not use a bare element selector for stickiness. */
body > header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
}
nav {
display: flex;
align-items: center;
gap: 0.75rem;
a {
color: var(--muted);
text-decoration: none;
&:hover {
color: var(--fg);
text-decoration: underline;
}
}
}
/* The theme switcher: a visually hidden checkbox (kept focusable on
purpose) with a label standing in for it. The icon shows the theme
you get when you click. */
.theme-switch {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
}
.switch-label {
display: inline-block;
cursor: pointer;
line-height: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--line);
border-radius: 6px;
color: var(--fg);
user-select: none;
}
.switch-label::before { content: "☾"; }
@media (prefers-color-scheme: dark) {
.switch-label::before { content: "☀"; }
}
html:has(#theme-switch:checked) .switch-label::before { content: "☀"; }
@media (prefers-color-scheme: dark) {
html:has(#theme-switch:checked) .switch-label::before { content: "☾"; }
}
.switch-label:hover { border-color: var(--muted); }
html:has(#theme-switch:focus-visible) .switch-label {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
main { padding-block: 1.5rem 3rem; }
h1 { font-size: clamp(1.7rem, 1.2rem + 2.4vw, 2.4rem); line-height: 1.2; margin: 0 0 0.5rem; }
h2 { font-size: 1.35rem; margin-top: 2rem; }
h3 { font-size: 1.15rem; }
article > header { border: 0; }
article > header p { color: var(--muted); margin: 0 0 1.25rem; }
.tags { font-size: 0.9em; }
a { color: var(--accent); }
a:hover { text-decoration: none; }
ul.posts {
list-style: none;
padding: 0;
li {
padding: 0.6rem 0;
border-bottom: 1px solid var(--line);
time, .count {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
}
}
pre {
overflow-x: auto;
background: var(--code-bg);
padding: 0.8rem 1rem;
border-radius: 6px;
}
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
}
h1, h2, h3, h4, h5, h6, li, p {
-moz-hyphens: auto;
-o-hyphens: auto;
-webkit-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto;
}
p > code, li > code {
background: var(--code-bg);
padding: 0.1em 0.35em;
border-radius: 4px;
}
/* Syntax highlighting: chroma emits tokens at build
time, so no JavaScript is needed. Punctuation and whitespace keep the
default code colour on purpose, so they are not listed here. */
.tok-k, .tok-kc, .tok-kd, .tok-kt, .tok-kp, .tok-kn, .tok-kr { color: var(--syn-key); }
.tok-s, .tok-s1, .tok-s2, .tok-sb, .tok-sd, .tok-sr, .tok-sc, .tok-sa, .tok-se { color: var(--syn-str); }
.tok-c, .tok-c1, .tok-ch, .tok-cm, .tok-cp, .tok-cs { color: var(--syn-com); font-style: italic; }
.tok-m, .tok-mb, .tok-mf, .tok-mh, .tok-mi, .tok-mo, .tok-il { color: var(--syn-num); }
.tok-nb, .tok-nf, .tok-np, .tok-nd, .tok-nt { color: var(--syn-fn); }
.tok-nc, .tok-nn { color: var(--syn-cls); }
.tok-o, .tok-ow { color: var(--syn-op); }
img { max-width: 100%; height: auto; }
blockquote {
margin: 1.25rem 0;
padding-left: 1rem;
border-left: 3px solid var(--line);
color: var(--muted);
}
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; }
hr { border: 0; border-top: 1px solid var(--line); margin: 2rem 0; }
details {
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.5rem 0.75rem;
summary { cursor: pointer; font-weight: 600; }
}
/* CSS-only carousel: scroll snap + anchor links, no JavaScript.
Markup: with a .carousel__gallery of
.carousel__slide images (each with an id) and a .carousel__thumbs
nav of links to those ids (decorative: alt="" on the thumbs).
Each slide fills the scrollport; the thumbnail row below is the
indicator that there is more to see. */
.carousel {
margin: 1.5rem 0;
.carousel__gallery {
display: flex;
gap: 0.75rem;
overflow-x: scroll;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: thin;
padding-bottom: 0.25rem;
/* Keyboard-scrollable (tabindex on the element); show a focus ring. */
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.carousel__slide {
flex: 0 0 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
border-radius: 6px;
}
.carousel__thumbs {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
a {
flex: 1;
display: block;
overflow: hidden;
border-radius: 4px;
img {
display: block;
width: 100%;
height: auto;
filter: saturate(0.6);
transition: filter 0.3s ease, transform 0.3s ease;
}
&:hover img {
filter: saturate(1);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
}
figcaption {
margin-top: 0.5rem;
color: var(--muted);
font-size: 0.85rem;
}
}
/* Cards: a responsive grid of small cards. Each card is a list item
with an optional image, a name, an optional description, and an
optional price. When a card has a url, the whole card is one link:
a single anchor wraps the image and the body, so the card is
clickable anywhere and reachable by keyboard. No JavaScript. */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 1rem;
list-style: none;
margin: 1.5rem 0;
padding: 0;
.card {
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
/* A card with a url is one link: the anchor fills the card (cards in
a grid row share a height, so the link stretches with it) and stays
a flex column so the body below keeps growing. */
.card > a {
display: flex;
flex-direction: column;
flex: 1;
color: inherit;
text-decoration: none;
}
/* Hover and focus feedback on the card itself (the outline goes on the
list item, outside the card's overflow: hidden). */
.card:has(> a):hover { border-color: var(--accent); }
.card > a:hover .card__name { color: var(--accent); }
.card:has(> a:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.card__img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
display: block;
border-bottom: 1px solid var(--line);
}
.card__body {
display: flex;
flex-direction: column;
flex: 1;
gap: 0.35rem;
padding: 0.8rem 0.9rem 0.9rem;
}
.card__name {
margin: 0;
font-size: 1.05rem;
font-weight: 650;
line-height: 1.3;
}
.card__desc {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
flex: 1;
}
.card__price {
margin: 0.1rem 0 0;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
/* Date line on home-page post cards (homeCards config). */
.card__date {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
}
footer {
border-top: 1px solid var(--line);
padding-block: 1rem 2rem;
color: var(--muted);
font-size: 0.9rem;
p { margin: 0; }
}
`