Theme Development

Theme Development Guide#

Audience. This is the developer-facing reference for Lesstruct theme development. It references source-tree paths (e.g. internal/api/template/).

If you are an end user of Lesstruct — i.e. you have installed the binary and want to customise the public site via themes/<name>/ — use the user-facing snapshot bundled with the lesstruct-theme-development skill at skills/lesstruct-theme-development/references/theme-development.md. It covers the same contract (CSS variables, template blocks, JS DOM contract, CDN assets) but with no source-tree references.

Lesstruct supports custom themes for the public-facing content site. Themes override the default CSS, JavaScript, and (optionally) HTML templates without modifying the core source.

How Themes Work#

  1. Create a theme directory with your custom files.
  2. Set the THEME_DIR environment variable to point to it.
  3. At startup, Lesstruct resolves each template and static file through a compositeFS (internal/api/template/theme.go:44-58) and readThemeFile (internal/api/template/theme.go:30-41):
    • If the file exists under THEME_DIR, that copy is used.
    • If it is missing, the embedded default from internal/api/template/ is used.

This means you can ship a partial theme — a single style.css, or a full layout.html, or anything in between — and the rest stays on the embedded defaults.

Theme Directory Structure#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
themes/
  mytheme/
    static/          # Served at /static/*
      style.css
      auth.js
      comments.js
      nav-auth.js
      search.js
      math.js
      verify-email.js
      reset-password.js
      highlight.min.js
    templates/       # Go html/template files
      layout.html
      index.html
      content.html
      author.html
      tag.html
      not_found.html
      login.html
      register.html
      forgot_password.html
      verify_email.html
      reset_password.html

The theme can override any subset of these files. Any file not present falls back to the embedded default at internal/api/template/static/ or internal/api/template/pages/.

Quick Start: CSS-Only Theme#

The simplest theme overrides only the CSS.

1. Create the theme directory#

1
mkdir -p themes/mytheme/static

2. Start from the readable source#

The minified internal/api/template/static/style.css is the file browsers receive. The readable, documented source is internal/api/template/static/style.src.css (commented, organised by section). Copy the readable source:

1
cp internal/api/template/static/style.src.css themes/mytheme/static/style.css

Theme authors do not need to run make css. Browsers receive your style.css verbatim. (If you maintain a .src.css for your own authoring convenience and want to ship a minified version, run make css against your source — but the theme override is happy with any valid CSS.)

3. Override the design tokens#

The default theme exposes every visual decision as a CSS custom property under :root. Override the ones you want to change:

1
2
3
4
5
6
:root {
  --color-bg: #ffffff;
  --color-text: #1a1a2e;
  --color-primary: #22d3ee;
  --max-width: 1200px;
}

Note on the brand tokens. The --color-* brand tokens are marked LOCKED in the embedded style.src.css:32-35 — that means the embedded source will not change those values, not that themes cannot override them. Your theme is free to redefine any token. The lock exists so the upstream visual identity stays stable.

4. Configure the theme#

Set THEME_DIR in your .env:

1
THEME_DIR=themes/mytheme

5. Restart Lesstruct#

Themes are loaded at startup. Restart the server to apply changes.

CSS Variable Reference#

The full set, defined in internal/api/template/static/style.src.css:36-85.

Brand colors#

VariableDefaultDescription
--color-bg#ffffffPage background color
--color-text#1a1a2eMain text color
--color-text-muted#6b7280Secondary / muted text
--color-primary#22d3eePrimary brand color (links, buttons, focus rings)
--color-primary-hover#06b6d4Primary color on hover
--color-secondary#2536ebSecondary brand color (logo, active nav, headings)
--color-accent#8b5cf6Accent color (tags, highlights)
--color-border#e5e7ebBorder and divider color
--color-card-bg#f9fafbCard and elevated surface background

Status colors#

VariableDefaultDescription
--color-danger#dc2626Error and validation messages
--color-success#16a34aSuccess messages

Layout#

VariableDefaultDescription
--max-width1200pxOuter container max width
--content-width768pxSingle-article reading width
--header-height80pxSticky header height (used for anchor offset)

Radii#

VariableDefaultDescription
--radius-sm4pxSmall elements (badges)
--radius-md6pxButtons, inputs, alerts
--radius-lg8pxCards, modals

Elevation#

VariableDefaultDescription
--shadow-sm0 1px 2px rgba(0, 0, 0, 0.05)Subtle lift
--shadow-md0 4px 16px rgba(0, 0, 0, 0.10)Cards, hovered inputs
--shadow-lg0 8px 24px rgba(0, 0, 0, 0.12)Modals, popovers

Spacing#

VariableDefaultDescription
--space-10.25remTightest gap
--space-20.5rem
--space-30.75rem
--space-41remStandard gap
--space-51.5remContainer padding, card padding
--space-62remSection spacing
--space-83remPage-top spacing

Motion and focus#

VariableDefaultDescription
--transition-fast0.2s easeDefault transition timing
--ring0 0 0 3px color-mix(in srgb, var(--color-primary) 22%, transparent)Focus ring for all text fields

Typography#

VariableDefaultDescription
--font-sans'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serifBody and headings
--font-mono"JetBrains Mono", "Fira Code", "Cascadia Code", monospaceCode blocks and inline <code>

Font Customization#

The default theme imports Inter from Google Fonts at the top of style.css. To switch:

  1. Replace the @import line at the top of your style.css with the new font’s @import (or self-host and @font-face it).
  2. Override --font-sans on :root.
  3. Override --font-mono if you want a different monospace stack.
1
2
3
4
5
6
@import url('https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;600;700&display=swap');

:root {
  --font-sans: 'Fira Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono: 'Fira Code', monospace;
}

Dark Theme Example#

Invert the light/dark variables, then re-tune the shadows and surfaces for a dark backdrop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
:root {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-text-muted: #94a3b8;
  --color-primary: #f59e0b;
  --color-primary-hover: #d97706;
  --color-secondary: #14b8a6;
  --color-accent: #8b5cf6;
  --color-border: #334155;
  --color-card-bg: #1e293b;
  --color-danger: #f87171;
  --color-success: #4ade80;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.40);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.45);
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.55);
}

Also re-style form inputs (background, color, caret-color) and the <pre><code> blocks if you want them distinct from the page background.

Environment Variable#

VariableDefaultDescription
THEME_DIR"" (empty)Path to custom theme directory. Relative or absolute. Read at startup; restart required.

THEME_DIR is loaded in internal/config/config.go:117 and passed to template.NewTemplates in main.go:672-678.

Fallback Behavior#

compositeFS (internal/api/template/theme.go:44-58) wraps the theme directory on top of the embedded filesystem. For every request:

  • If the file exists in THEME_DIR/..., that copy is served.
  • Otherwise, the embedded default is served.

This is independent for static files and for each named template. You can override style.css only and keep the embedded search.js, auth.js, layout.html, and every other file. The fallbacks compose — partial themes are the normal case.

When THEME_DIR is empty or unset, no disk access happens for the content site; all files come from the embedded filesystem.

Template Overrides#

Themes can override any of the 10 templates in internal/api/template/pages/. Place your overrides in themes/<name>/templates/.

Block contract#

Templates use Go’s html/template with two {{define}} blocks:

  • layout.html must define {{define "layout"}}…{{end}} — the outer page shell (DOCTYPE, <head>, header, footer). It must call {{template "body" .}} inside a <main> element. Layouts are cloned per page in internal/api/template/template.go:201-210, so each page template is parsed against a fresh copy of the layout.
  • All other templates must define {{define "body"}}…{{end}} — page-specific content that is rendered inside the layout’s <main> element.

If you override layout.html, the default body block from the embedded page templates still works. If you override only page templates, they continue to use the embedded layout.html. Either is supported; mix as you wish.

Template Data Fields#

The structs are defined in internal/api/template/template.go:18-117. Every page embeds LayoutData, so the layout’s . is always populated with the fields below.

LayoutData — available to every page:

FieldTypeDescription
.PageTitlestringHTML <title> content
.TitlestringPage heading
.DescriptionstringMeta description
.OGTitlestringOpen Graph title
.OGDescstringOpen Graph description
.OGImagestringOpen Graph image URL (may be empty)
.NavigationItems[]NavigationItemNav items, each with .Title, .URL, .IsActive
.CurrentPathstringCurrent request path
.LangstringCurrent language code (e.g. "en", "fr"); required by <html lang="…"> and {{t}} calls
.LanguageLinks[]LanguageLinkAlternate-language links (.Code, .Name, .URL); empty if no translations exist

IndexData — landing page:

FieldTypeDescription
.Posts[]PostItemPost cards
.Tags[]stringAll available tags

PostItem — a card in the post grid:

FieldTypeDescription
.SlugstringURL slug
.TitlestringPost title
.MetaDescriptionstringShort description
.ImageURLstringCover image URL (may be empty)
.ImageSrcsetstringResponsive image srcset (may be empty)
.ImageSizesstringResponsive image sizes (may be empty)
.AuthorstringAuthor display name
.UsernamestringAuthor username (for /authors/<username> links)
.AuthorAvatarURLstringAvatar URL (may be empty)
.CreatedAtstringPre-formatted creation date

ContentData — single post page:

FieldTypeDescription
.SlugstringPost URL slug
.Bodytemplate.HTMLRendered post body (safe HTML; do not re-escape)
.Tags[]stringPost tags
.AuthorstringAuthor display name
.UsernamestringAuthor username
.AuthorAvatarURLstringAvatar URL
.CreatedAtstringPre-formatted creation date
.AllowCommentsboolWhether comments are enabled
.CustomFieldsmap[string]anyRaw custom-field values keyed by name
.CustomFieldsFormatted[]FormattedFieldDisplay-formatted custom fields (.Label, .Value)
.Comments[]CommentItemComments (.Author, .Text, .CreatedAt)
.LanguageLinks[]LanguageLinkInherited via LayoutData; also rendered inside the article for translated posts

AuthorData — author page:

FieldTypeDescription
.AuthorNamestringAuthor display name
.UsernamestringAuthor username
.AuthorAvatarURLstringAvatar URL
.Posts[]PostItemAuthor’s posts
.CustomFieldsFormatted[]FormattedFieldAuthor “About” custom fields

TagData — tag page:

FieldTypeDescription
.TagNamestringTag display name
.Posts[]PostItemPosts with this tag

AuthPageData (login.html, register.html, forgot_password.html), NotFoundData (not_found.html), VerifyEmailData (verify_email.html), ResetPasswordData (reset_password.html) — each embeds LayoutData only. The dedicated structs exist so future per-page fields can be added without breaking the layout contract.

Example: Custom Layout#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{{define "layout"}}<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>
<meta name="description" content="{{.Description}}">
<meta property="og:title" content="{{.OGTitle}}">
<meta property="og:description" content="{{.OGDesc}}">
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}">{{end}}
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-logo">My Custom Site</a>
</div>
</header>
<main class="container">{{template "body" .}}</main>
<footer class="site-footer">
<div class="container">
<p>Custom footer text</p>
</div>
</footer>
</body>
</html>{{end}}

Reminder: if you change the math or syntax-highlighting libraries, your layout must load their CSS and JS instead of the katex / highlight.js ones pulled by the default. See CDN Assets Pulled by the Default Layout.

Template Helper Functions#

Registered in internal/api/template/template.go:191-194:

  • {{urlpath "string"}} — URL-encodes a string. Used in tag links so non-ASCII tag names resolve correctly: <a href="/tags/{{.TagName | urlpath}}">.

  • {{t .Lang "ui.key"}} — translates a UI string for the given language. Falls back through the configured languages and finally English; returns the key itself if no translation is found. The catalog lives in internal/i18n/catalog.go and the source strings in internal/i18n/translations/*.toml. Common keys:

    KeyDefault
    ui.loginLogin
    ui.logoutLogout
    ui.registerRegister
    ui.searchSearch
    ui.search_postsSearch posts...
    ui.toggle_navigationToggle navigation
    ui.no_postsNo posts yet.
    ui.no_commentsNo comments yet. Be the first to comment!
    ui.login_to_commentLogin to comment
    ui.by_authorby
    ui.commentsComments
    ui.submit_commentSubmit Comment
    ui.back_to_homeBack to home
    ui.not_found_404404
    ui.page_not_foundPage not found.
    ui.forgot_passwordForgot Password
    ui.reset_passwordReset Password
    ui.verify_email_titleVerify Email

    Run ls internal/i18n/translations/ to see every supported language.

Static File Overrides#

Any file in internal/api/template/static/ can be replaced by a same-named file under themes/<name>/static/. The files are served at /static/<filename>.

FileUsed byDOM contract
style.cssAll pages (linked from layout.html)Defines every custom property and class the embedded templates rely on. Override freely.
nav-auth.jslayout.htmlExpects #nav-login, #nav-logout; reads localStorage.token or localStorage.auth_token; handles .nav-toggle / .site-nav for mobile.
search.jslayout.htmlExpects .search-toggle, .search-box, #search-input, #search-dropdown; fetches /api/v1/public/search?q=….
auth.jslogin.html, register.html, forgot_password.htmlExpects #login-form/#register-form/#forgot-form, inputs named username/name/email/password, and #auth-error / #auth-success elements. POSTs to /api/auth/login, /api/auth/register, /api/auth/forgot-password.
comments.jscontent.html (only when AllowComments is true)Expects #comment-form[data-slug], #comment-error, #comment-success, #comment-login-link; reads localStorage.token; POSTs to /api/v1/content_items/<slug>/comments.
math.jslayout.htmlKaTeX auto-render; depends on katex from CDN (see below).
verify-email.jsverify_email.htmlReads ?token= from the URL; calls /api/auth/verify-email?token=…; toggles #auth-error / #auth-success.
reset-password.jsreset_password.htmlReads ?token= from the URL; POSTs to /api/auth/reset-password; expects #new-password input.
highlight.min.jslayout.htmlProvides the global hljs. The default layout also runs hljs.highlightAll() on DOMContentLoaded.

If you override any JS file, keep the DOM contract above — the default page templates look for those exact ids and classes. If you change them, you must also change the corresponding page template.

CDN Assets Pulled by the Default Layout#

The default layout.html (internal/api/template/pages/layout.gohtml) loads the following from cdn.jsdelivr.net:

  • katex@0.16.11/dist/katex.min.css and katex.min.js — math rendering.
  • highlight.js@11.11.1/styles/github-dark.min.css — code-block theme.

If your theme drops katex and/or highlight.js (for example, you use a different math library or a different syntax highlighter), update layout.html to drop the matching <link> / <script> tags and override math.js and highlight.min.js accordingly. Otherwise, the assets will be requested and unused.

What Does NOT Theme#

THEME_DIR only affects the public content site. It does not change:

  • The admin SPA (web/admin/, served from internal/api/static/admin/).
  • Any /api/* JSON response.
  • Plugin behaviour, hooks, or capabilities.
  • Email templates or other server-rendered channels.

To rebrand the admin panel, edit the Vue source under web/admin/ and rebuild with make build-admin. To change API responses, edit the handlers under internal/api/handlers/.

Theme Authoring Workflow#

Recommended sequence for a new theme:

  1. Pick a base. Decide whether you are re-skinning (CSS only), rearranging the layout (layout.html only), or rebuilding page templates individually.
  2. Create the directory. mkdir -p themes/mytheme/{static,templates}.
  3. Copy only what you need. Start with static/style.css; copy more files from internal/api/template/static/ and internal/api/template/pages/ only as your design requires.
  4. Author. Use the CSS Variable Reference and Template Data Fields sections as your contract.
  5. Restart Lesstruct. THEME_DIR is read at startup; live edits to a theme file are not picked up until the server is restarted.
  6. Verify. Hit each of the 10 pages (/, /<slug>, /authors/<username>, /tags/<tag>, /404, /login, /register, /forgot-password, /verify-email?token=…, /reset-password?token=…) and confirm your theme loads and the page renders. Run go test ./internal/api/template/... to confirm the embedded fallback paths still work.
  7. Maintain. When upgrading Lesstruct, run the theme development skill (lesstruct-theme-development) to detect drift between your theme files and any new embedded defaults.

Troubleshooting#

SymptomLikely cause
Theme changes have no effectTHEME_DIR is empty, points to a missing directory, or the server was not restarted.
Page renders, but no styles<link rel="stylesheet" href="/static/style.css"> is missing from your layout.html.
Search box or comment form is deadYou overrode search.js / comments.js / layout.html and the DOM ids no longer match. Restore the ids, or update the JS to match your new layout.
Tag links are broken for non-ASCII tagsThe href was built with .TagName instead of `{{.TagName
{{t .Lang "ui.x"}} shows the literal keyThe translation is missing in internal/i18n/translations/<lang>.toml. Add it, or change the key.
KaTeX or highlight.js missingYour layout.html does not load the CDN CSS/JS, or the assets are blocked by the network.