// 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: