Blazingly fast, highly opinionated flyweight Hugo theme
letsblaze is built around one principle: the fastest resource is one that was never requested.
- ⛔ No JavaScript
- 🔗 No CSS
- ✏️ No web fonts
- ☁️ No CDN calls
- 🚀 Plain HTML with a single inline
<style>
The constraint that never bends is about the network: no JavaScript, no external requests, fast. Every page is self-contained HTML with one inline <style> block. Nothing else ships to the browser. The R-series constraints below enforce this and are non-negotiable.
"Minimal CSS" is not the rule. It was once used as a proxy for the hard rule above, and that proxy is misleading. Because the CSS is already inline, one more selector costs a few dozen bytes inside an already-loaded document. It triggers no request and no render-blocking. So the amount of CSS is the wrong thing to police.
The right question for any rule is: does it earn its bytes by serving reading or navigation? A CSS rule is allowed if it does one of three things:
-
Prevents a usability failure: unreadable line length, invisible focus state, or content that can't be navigated to
-
Communicates structure: show "where am I" from "where can I go," or separating navigation from content
-
Respects user or OS intent: dark mode, reduced motion, system fonts
A CSS rule is rejected if it only decorates: gradients, drop shadows, brand accent colours, rounded corners, hover animations, or anything whose only loss, if removed, is that the page looks less styled.
- No JavaScript: no
<script>tags of any kind;<details>and CSS do the interactive work - No external requests: no linked stylesheets, web fonts, or CDN calls, just one inline
<style>per page - Dark mode: follows the OS
prefers-color-schemesetting, with no toggle, cookie, or flash - Blog and docs: a paginated blog with tags and per-tag feeds, plus a nested docs section with breadcrumbs
- Math: LaTeX renders to native MathML at build time, so no KaTeX or MathJax ships to the reader
- Syntax highlighting: Chroma highlights code at build time with inline styles, in a monochrome palette
- Images:
embed,link-same-tab, andlink-new-tabmodes, with<figure>captions and LCP-aware loading - Accessible: skip link, labelled landmarks,
aria-current, and semantic HTML throughout - SEO: canonical URLs, Open Graph tags, Schema.org microdata, RSS autodiscovery, and a sitemap
- Shortcodes:
sub,sup,mark, andabbr
See the theme running at letsblaze.thomaslaurenson.com, which doubles as the documentation: Installation, Configuration, Features, and Markdown.
- Hugo 0.146.0 or later
Add letsblaze as a git submodule:
git submodule add https://github.com/thomaslaurenson/letsblaze themes/letsblazeSet the theme in your hugo.toml:
theme = "letsblaze"To update the theme later:
git submodule update --remote themes/letsblazeThis method is beginner-friendly and has no Git dependency management.
git clone https://github.com/thomaslaurenson/letsblaze themes/letsblazeSet the theme in your hugo.toml:
theme = "letsblaze"To update, delete the folder and clone again, or git pull inside it.
Requires Go to be installed. Your site must be a Hugo module:
hugo mod init github.com/<you>/<your-site>Set the theme in your hugo.toml, using the full module path:
theme = "github.com/thomaslaurenson/letsblaze"Hugo downloads it on the next build. To update later:
hugo mod get -u github.com/thomaslaurenson/letsblazeEvery design decision is governed by a numbered constraint. These identifiers are used in scripts/test.sh so test failures trace directly to this document.
Constraints are grouped by category with a category prefix:
- R: Resources & CSS authoring
- C: CSS integrity
- S: Semantic HTML
- M: SEO & metadata
| ID | Constraint |
|---|---|
| R1 | No JavaScript: no <script> tags of any kind |
| R2 | No external CSS: no rel="stylesheet" links |
| R3 | No CDN resources: no cdn., fonts.googleapis, or fonts.gstatic URLs |
| R4 | No inline style=: no style= attributes on HTML elements (Chroma <span> and <pre> are exempt). Code fence line numbers are ignored because Chroma renders them as a <table> with inline styles; hl_lines is honoured. Hugo's default table output aligns cells with style="text-align", so the theme's table render hook writes data-align attributes instead (see C17). |
| R5 | No CSS frameworks or utility classes: no Tailwind/Bootstrap/etc., no atomic or utility classes (e.g. mt-4, flex), and no class used purely for decoration. Semantic classes that name a structural region (e.g. docs-sidebar, breadcrumb) are permitted, because they enable structure-communicating CSS that is already inline and costs no request. Chroma and Goldmark footnote classes remain exempt. |
All CSS is inline inside a <style> block in <head>, in layouts/_partials/head-styles.html. Every rule has an explicit justification.
New rules must pass the gate in Philosophy: they prevent a usability failure, communicate structure, or respect user/OS intent. Decorative rules are rejected. When adding a constraint here, give it the next C number and a one-line justification, then add a matching check in scripts/test.sh.
| ID | Constraint | Justification |
|---|---|---|
| C1 | CSS inline in <head> |
No linked file = no extra HTTP request, no render blocking, no FOUC |
| C2 | Skip link hidden off-screen | position: absolute; left: -9999px, revealed on :focus with z-index: 1, background, and padding to ensure visibility |
| C3 | body { max-width: 100ch; margin: 0 auto; padding: 1rem } |
Prevents unreadable line lengths on wide viewports; the auto margin centres the column and the padding keeps text off the viewport edge on narrow screens |
| C4 | body { line-height: 1.6 } |
Browser default is too tight for comfortable reading |
| C5 | img { max-width: 100%; height: auto } |
Responsive images; height: auto prevents CLS alongside explicit width/height attributes |
| C6 | table { border-collapse: collapse } |
.table-wrap { overflow-x: auto } on the wrapper emitted by the table render hook confines horizontal scroll to the table itself, so the page never scrolls sideways. A wrapper is used rather than display: block on the table because changing a table's display strips its semantics in some browsers, notably Safari |
| C7 | nav ul { list-style: none; margin: 0; padding: 0 } |
Removes browser bullet and indent defaults from all nav lists; the breadcrumb <ol> gets the same reset |
| C8 | [aria-current="page"] { font-weight: bold } |
Active-link indicator without a class |
| C9 | Dark mode via prefers-color-scheme: dark |
Follows OS preference (no JavaScript, no toggle, no cookie). The block restyles the body, links, inline code, <mark>, table borders and the skip link so each keeps readable contrast on the dark background; fenced code keeps Chroma's own inline colours |
| C10 | pre { overflow-x: auto } |
Wide code blocks scroll horizontally instead of being clipped |
| C11 | body { font-size: 18px } |
Browser default (16px) is too small for comfortable long-form reading |
| C12 | Retired | Post lists are <ul> elements, so the former article + article spacing rule matched nothing and was removed. The number is kept so older references still resolve |
| C13 | math[display="block"] { overflow-x: auto } |
Wide display equations scroll horizontally within their own box instead of overflowing the page, mirroring C6 (tables) and C10 (code) |
| C14 | li { display: inline } in the header, breadcrumb and tag navs |
Lays navigation out on one line so it reads as a strip separate from the content, instead of a vertical list that pushes the page down; the list markup stays so assistive technology still announces item counts |
| C15 | :root { color-scheme: light dark } |
Tells the browser both schemes are supported, so scrollbars, form controls and the <details> marker follow the OS preference instead of staying light; C9 only restyles the theme's own elements |
| C16 | th, td { border: 1px solid; padding: 0.4rem 0.8rem } |
Cell borders and padding keep tabular data readable; without them columns run together and rows cannot be followed across |
| C17 | [data-align] { text-align } |
Honours the column alignment the author wrote in Markdown. Hugo's default table output does this with style="text-align", which R4 forbids, so layouts/_markup/render-table.html emits data-align attributes instead |
| C18 | li + li::before { content: " / " / "" } in the header and breadcrumb navs |
Separates inline nav items so they do not run together. The alternative-text form hides the glyph from screen readers, which would otherwise announce it |
| C19 | .post-meta { display: grid } |
Lays the blog post date, tags and author out as label and value columns, so the block reads as metadata rather than as body text and stays compact; .post-meta dt is bold to mark the labels |
| ID | Constraint |
|---|---|
| S1 | Skip link: <a href="#main-content">Skip to content</a> on every page |
| S2 | aria-label on every <nav> |
| S3 | aria-current="page" on the active nav link |
| S4 | Site title as bare <a> on every page, reserves <h1> for page content. Optionally replaced by a custom logo partial (see Logo). |
| S5 | <time datetime="..."> on blog post dates |
| S6 | Image rendering controlled by imageMode param: three modes: embed (default): wraps a standalone image in <figure> (see Images) and renders any other image as a bare <img>, first image on page uses loading="eager" fetchpriority="high", subsequent images use loading="lazy"; link-same-tab: renders a bare <a> link using alt text; link-new-tab: same with target="_blank" rel="noopener noreferrer". Overridable per-page in front matter. |
| S7 | Breadcrumb navigation: <nav aria-label="Breadcrumb"> with <ol> on every blog post page and every docs page below the docs root, which has no ancestors to show; breadcrumb walks .Ancestors so arbitrary nesting depth is supported. |
| ID | Constraint |
|---|---|
| M1 | <meta charset> and viewport on every page |
| M2 | Canonical URL: <link rel="canonical"> on every page |
| M3 | Meta description: falls back through page description, summary, then site description |
| M4 | Open Graph tags: og:title, og:description, og:type, og:url on every page |
| M5 | og:site_name on every page |
| M6 | Schema.org microdata: blog posts carry itemscope itemtype="...BlogPosting" (no <script> required) |
| M7 | article:published_time and article:modified_time on blog posts |
| M8 | RSS autodiscovery: <link rel="alternate" type="application/rss+xml"> in <head> on pages with feeds |
| M9 | noindex in <head> on the 404 page |
| M10 | <meta name="author"> on every page |