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, base.css + 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
25
26
themes/
  mytheme/
    static/          # Served at /static/*
      base.css
      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
      homepage.html
      index.html
      post.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/. You can also add per-post-type templates (e.g. page.html, event.html) — see Per-post-type templates.

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/base.css and style.css are the files browsers receive. The readable, documented sources are internal/api/template/static/base.src.css and style.src.css (commented, organised by section). Copy the readable sources:

1
2
cp internal/api/template/static/base.src.css themes/mytheme/static/base.css
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 base.css and style.css verbatim. (If you maintain .src.css files for your own authoring convenience and want to ship minified versions, run make css against your source — but the theme override is happy with any valid CSS.)

Your theme CSS doubles as the AI’s brand brief. When a user generates HTML/CSS with AI, the active theme’s base.css and style.css are injected into the generation prompt. The AI reuses your design tokens (var(--color-primary), var(--space-*), etc.) and component classes (.btn, .form-control) instead of inventing off-brand styles. Keep your tokens well-named and your component classes semantic — the AI follows your lead.

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#2563ebSecondary 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 base.css. To switch:

  1. Replace the @import line at the top of your base.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/.

Per-post-type templates#

Every content row is rendered through a per-post-type template. The lookup chain is:

  1. Theme <theme>/templates/<postType>-<slug>.html — per-slug override (see below)
  2. Theme <theme>/templates/<postType>.html — e.g. templates/page.html
  3. Theme <theme>/templates/post.html
  4. Embedded pages/<postType>.gohtml (reserved for future built-ins)
  5. Embedded pages/post.gohtml — the universal default

This means you can ship a page.html that omits the related-posts section, author box, and date metadata, while post.gohtml keeps the full blog chrome. If a theme only ships post.html, every post type (including page) renders through that one template. If a theme ships neither, the embedded post.gohtml is used for everything.

Per-slug template overrides#

A theme can ship a distinct template for one specific content row by naming the file <postType>-<slug>.html. The file applies only when both the post type and the slug match the request. This mirrors the WordPress page-{slug}.php convention but generalizes to every post type.

Example: a theme that wants /about (a Page with slug about) to render through a dedicated layout ships themes/<name>/templates/page-about.html. The Page with slug contact (also postType=page) is unaffected and continues to render through page.html.

The lookup chain is the one above; per-slug is checked first, then the per-post-type fallbacks apply.

Naming rules#

  • Filename pattern: <postType>-<slug>.html (e.g. page-about.html, article-spotlight.html, menu-item-special.html).
  • <postType> must be a registered post-type slug (post, page, or any custom type declared in config.toml). Files whose prefix is not a known post type are ignored.
  • When the post-type slug itself contains a hyphen (e.g. menu-item), the longest matching registered post type wins. So menu-item-special.html is interpreted as menu-item + special, not menu + item-special (assuming both menu and menu-item are registered).
  • A per-post-type template file itself (e.g. page.html) is never mistaken for a per-slug override — it has no hyphen and is therefore skipped by the per-slug scan.
  • Files are discovered at startup by scanning <theme>/templates/. Add or remove a per-slug file, then restart Lesstruct to pick up the change.

What per-slug templates do NOT solve on their own#

html/template cannot run database queries, so a per-slug template alone cannot render dynamic regions such as “recent posts of type X” or “top contributors ranked by a custom field”. Those regions are added at the theme layer via client-side fetches against the public API — see Dynamic regions via CSR below. The per-slug template is the layout shell where those region hooks live.

Dynamic regions via CSR#

A per-slug template defines DOM hooks (elements with data-* attributes); a theme JS file fetches /api/v1/public/* and renders into them. The page body stays server-rendered; only the dynamic regions are client-rendered.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<!-- themes/<name>/templates/page-chefs.html -->
{{define "body"}}
<article class="page-chefs">
	<div class="page-body">{{.Body}}</div>

	<aside class="page-sidebar">
		<h2>Top Chefs</h2>
		<ul id="top-chefs" data-limit="5"></ul>
	</aside>

	<section class="recent-recipes">
		<h2>Latest Recipes</h2>
		<div id="recent-recipes"
		     data-post-type="recipe"
		     data-limit="8"></div>
		<a href="/recipe">See all recipes.</a>
	</section>
</article>
<script src="/static/page-chefs.js" defer></script>
{{end}}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// themes/<name>/static/page-chefs.js
async function renderTopChefs() {
	const ul = document.getElementById('top-chefs');
	const limit = ul.dataset.limit;
	const res = await fetch(`/api/v1/public/authors?sort_by=cf:rating&order=desc&limit=${limit}`);
	const authors = (await res.json()).data;
	ul.innerHTML = authors.map(a =>
		`<li><a href="/authors/${a.username}">${a.displayName}</a> (${a.contentCount})</li>`
	).join('');
}

async function renderRecentRecipes() {
	const el = document.getElementById('recent-recipes');
	const { postType, limit } = el.dataset;
	const res = await fetch(`/api/v1/public/content_items?post_type=${postType}&limit=${limit}`);
	const items = (await res.json()).data;
	el.innerHTML = items.map(p =>
		`<article><a href="/${p.slug}">${p.title}</a></article>`
	).join('');
}

renderTopChefs();
renderRecentRecipes();

The corresponding config.toml entries — these are mandatory; without them the cf queries above return 400 field_not_queryable:

1
2
3
4
[[public_field]]
resource   = "user"
field      = "rating"
operations = ["sort"]

See Public custom-field query in the API reference for the full parameter catalog and the numeric-safety rules.

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.

GOHTML Formatting#

All .gohtml files must follow the Lesstruct GOHTML coding standard (docs/coding-standards/html.md). Key rules:

  • Tabs for indentation (one per nesting level).
  • Strategic {{- -}} trimming around block-control actions (define, range, if, with, end) so source is indented without injecting blank lines into rendered output.
  • Inline runs on one line{{if}}…{{end}} around inline elements (nav links, author bylines, language links) must not be broken across lines, as newlines between inline elements create visible spaces.
  • style="display:none" must be kept verbatim — test assertions check it.

Lesstruct ships server-rendered HTML unminified. Without trimming, formatting changes would ship whitespace to the browser. The standard exists so source is readable for humans and AI while rendered output stays clean.

Template Data Fields#

The structs are defined in internal/api/template/template.go. 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
.SiteConfigSiteConfigSite-wide identity (see below); same on every page

IndexData — homepage (rendered via homepage.html) and post-type listing pages (rendered via index.html). Both use the same data struct; override homepage.html to give the homepage a distinct layout from listing pages:

FieldTypeDescription
.Posts[]PostItemLatest posts (homepage) or posts of the listed type. Paginated via ?page=N; filterable by ?year= and ?month= on homepage, post-type listings, and tag pages
.Tags[]stringDistinct tags across all published content; populated on the homepage for tag clouds (empty on post-type listings)
.Sections[]HomeSectionPer-post-type homepage sections; only populated when [[homepage_section]] blocks are configured in config.toml (see docs/configuration.md). Empty otherwise — fall back to .Posts
.HasPrev / .HasNextboolPagination state (see PaginationData below)
.PrevURL / .NextURLstringPrev/next page URLs; empty when the corresponding flag is false
.CurrentPageint1-based current page number

HomeSection — one per [[homepage_section]] block (homepage only):

FieldTypeDescription
.PostTypeSlugstringThe post-type slug (e.g. "article")
.TitlestringDisplay title (from the block’s title, else the post type’s name)
.DescriptionstringPost-type description (may be empty)
.URLstringLink to the post-type listing (e.g. /article)
.Posts[]PostItemItems in this section

SiteConfig — site-wide identity (from the optional [site_config] block in config.toml; see docs/configuration.md):

FieldTypeDescription
.NamestringSite name. Always populated (defaults to "Lesstruct" when unconfigured). Drives og:site_name and the PageTitle suffix; use it as the logo text and the logo alt.
.LogostringOptional logo image URL/path (e.g. /uploads/logo.png). Empty means no image — render .Name as text.

PaginationData — embedded in IndexData, AuthorData, and TagData:

FieldTypeDescription
.CurrentPageint1-based page number from ?page=N (defaults to 1)
.HasPrevboolTrue when not on page 1
.HasNextboolTrue when another page exists (detected without a COUNT query)
.PrevURLstringURL of the previous page; page 1 links to the bare path
.NextURLstringURL of the next page

Page size is set by the POSTS_PER_PAGE env var (default 50, max 100). Use {{if or .HasPrev .HasNext}}…{{end}} to render a <nav class="pagination">.

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)
.ImageVariantsmap[string]stringMap of thumbnail variant URLs keyed by configured suffix (e.g. "_large", "_medium", "_thumb"). Guard with {{with (index .ImageVariants "_large")}} — keys depend on [[thumbnail]] config. Populated when a media record is resolved; empty when no media.
.OriginalURLstringThe unscaled original image URL (from Media.URL). Falls back to the raw content image URL when no media record is found. Useful for hero backgrounds, high-DPI displays, and download links.
.AuthorstringAuthor display name
.UsernamestringAuthor username (for /authors/<username> links)
.AuthorAvatarURLstringAvatar URL (may be empty)
.CreatedAttime.TimeCreation timestamp; format with {{formatDate .Lang .CreatedAt}} for a localized date, {{.CreatedAt.Format "2006-01-02"}} for a custom layout, or call any time.Time method
.PostTypestringPost type (e.g. post, article, event); use it to branch card layouts, render type badges, or build type-aware links. Emitted as data-post-type="…" on the default cards
.Tags[]stringPost tags; render with {{range .Tags}}<a href="/tags/{{. | urlpath}}" class="tag">{{.}}</a>{{end}} (empty when none). Not rendered on the default card — override index.html to show them

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
.CreatedAttime.TimeCreation timestamp; format with {{formatDate .Lang .CreatedAt}} for a localized date, {{.CreatedAt.Format "2006-01-02"}} for a custom layout, or call any time.Time method
.AllowCommentsboolWhether comments are enabled
.CustomFieldsmap[string]anyRaw custom-field values keyed by name
.CustomFieldsFormatted[]FormattedFieldDisplay-formatted custom fields (.Label, .Value)
.Related[]PostItemRelated posts (same post type & language, ranked by shared tags), rendered above the comments section; empty slice when none
.Comments[]CommentItemComments (.Author, .Text, .CreatedAt — timestamp as time.Time)
.PostTypestringPost type; branch the single-page template to show type-specific metadata (e.g. event start/end vs. article link). Emitted as data-post-type="…" on the default <article>
.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 (paginated via ?page=N)
.CustomFieldsFormatted[]FormattedFieldAuthor “About” custom fields plus any system fields allowlisted with "expose" in [[public_field]]; merged into one list
.HasPrev / .HasNext / .PrevURL / .NextURL / .CurrentPageEmbedded PaginationData

TagData — tag page:

FieldTypeDescription
.TagNamestringTag display name
.Posts[]PostItemPosts with this tag (paginated via ?page=N)
.HasPrev / .HasNext / .PrevURL / .NextURL / .CurrentPageEmbedded PaginationData

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
27
28
{{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}}">
<meta property="og:site_name" content="{{.SiteConfig.Name}}">
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}">{{end}}
<link rel="stylesheet" href="/static/base.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<div class="container">
<a href="/" class="site-logo">{{if .SiteConfig.Logo}}<img src="{{.SiteConfig.Logo}}" alt="{{.SiteConfig.Name}}">{{else}}{{.SiteConfig.Name}}{{end}}</a>
</div>
</header>
<main class="container">{{template "body" .}}</main>
<footer class="site-footer">
<div class="container">
<p>&copy; {{.SiteConfig.Name}}</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.

  • {{assetURL "string"}}assetURL "base.css"/static/base.<hash>.css, assetURL "style.css"/static/style.<hash>.css — Returns a versioned URL for the named static asset. Currently base.css and style.css are supported. The hash is computed from both files at startup; cache headers are immutable for versioned URLs.

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
base.cssAll pages (linked from layout.html)Imports Google Fonts, defines CSS custom properties (:root tokens), resets, base element styles, and .content-body typography. Override freely.
style.cssAll pages (linked from layout.html)Defines layout primitives, components, header/nav, grid, article wrappers, tags, comments, auth, footer, and responsive rules. Override freely.

The default layout uses {{assetURL "base.css"}} and {{assetURL "style.css"}} to emit content-hashed filenames with immutable cache headers. Plain /static/base.css and /static/style.css still work for backward compatibility. | nav-auth.js | layout.html | Expects #nav-login, #nav-logout; reads localStorage.token or localStorage.auth_token; handles .nav-toggle / .site-nav for mobile. | | search.js | layout.html | Expects .search-toggle, .search-box, #search-input, #search-dropdown; fetches /api/v1/public/search?q=…. | | auth.js | login.html, register.html, forgot_password.html | Expects #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.js | post.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.js | layout.html | KaTeX auto-render; depends on katex from CDN (see below). | | verify-email.js | verify_email.html | Reads ?token= from the URL; calls /api/auth/verify-email?token=…; toggles #auth-error / #auth-success. | | reset-password.js | reset_password.html | Reads ?token= from the URL; POSTs to /api/auth/reset-password; expects #new-password input. | | highlight.min.js | layout.html | Provides 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/base.css and 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/base.css"> and/or <link rel="stylesheet" href="/static/style.css"> are 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.