Skip to content

Repository files navigation

Vellum

A multilingual Hugo theme that sets every page as an engineering drawing sheet — a drawn frame with a zone rail down its left edge, and a ruled title block carrying the metadata a grey caption line usually mumbles.

HugoLicence

A Vellum post: a drawn frame, a lettered zone rail down its left edge, a ruled title block of metadata, and a syntax-highlighted code block

Live demo · Source

Built for technical writing that is mostly code. The reading column is wide — 800px, measuring 92 characters at the 20px body size — because terminal output and command blocks are the substance, not an inset. The line height is correspondingly generous so a line that long stays trackable.

MultilingualPer-language content, menus, profiles, feeds and search indexes
SearchClient-side, Fuse.js, built from the site's own JSON output
Light / darkFollows the OS, or a toggle; works with JavaScript disabled
Responsive imagesBundle images auto-resized to a 480/800/1600 WebP ladder
Self-hosted fontsTwo variable faces, ~121 KB, no third-party requests
Landing pageProfile block, buttons, latest posts
Post furnitureTable of contents, reading time, breadcrumbs, share row, post nav, edit link
Archives & taxonomiesYear/month archive, tag pages
SEOOpenGraph, Twitter cards, schema.org, RSS, canonical + hreflang
PrintA dedicated print stylesheet, not an afterthought

Linked rows have a section of their own below.

Contents

Quick start · Requirements · Configuration · Content · Icons · Customising · Development · Licence

Quick start

From nothing to a running site

hugo new site myblog &&cd myblog
hugo mod init github.com/you/myblog

Replace the generated hugo.toml with this. It is the whole minimum — the theme import plus the three settings the theme cannot supply for itself:

baseURL = "https://example.org/"title = "My Site"locale = "en-us"# Chroma has to emit class names instead of inline styles, or code blocks ship a# light palette baked into the HTML and stay light on a dark page. It is a# root-level key, so it has to sit above the first [table] header — put it under# [outputs] and TOML reads it as outputs.pygmentsUseClasses, which does nothing.pygmentsUseClasses = true
[module]
[[module.imports]]
path = "github.com/cebor/vellum"# The JSON output *is* the search index. Without it, /search/ finds nothing.
[outputs]
home = ["HTML", "RSS", "JSON"]
[markup.highlight]
noClasses = false

Then:

hugo mod get github.com/cebor/vellum
hugo new posts/hello.md
# The JSON output above is the search *index*. The search *page* is a content# file, so it is the one piece you add by hand — `layout = "search"` renders it.
cat > content/search.md <<'EOF'+++title = "Search"layout = "search"hidemeta = truesearchHidden = true+++EOF
hugo server -D

That is a working site on http://localhost:1313, with search and syntax highlighting. Everything below is refinement.

Tip

A full worked example lives in exampleSite/hugo.toml — two languages, menus, profile, search and the root 404, all in one file. It is the fastest way to see how the pieces fit together.

Other ways to add the theme

The module import above keeps itself updatable — hugo mod get -u whenever you want the latest. As a submodule instead:

git submodule add https://github.com/cebor/vellum.git themes/vellum

Copying the directory in works just as well. With a submodule or a copy — not with the module import above, which names the theme itself — set it in your site config:

theme = "vellum"

If you use defaultContentLanguageInSubdir

Nothing then lands at the publish root for a web server to use as its error document. Add:

[outputFormats.ROOT404]
mediaType = "text/html"baseName = "404"notAlternative = trueroot = true
[languages.en.outputs]
# Default language only — adding it to both makes them fight over /404.html.home = ["HTML", "RSS", "JSON", "ROOT404"]

Requirements

  • Hugo extended ≥ 0.158. Lower versions fail at render time, not with a friendly message.
  • Browsers: Chrome 123+, Safari 17.5+, Firefox 120+ (CSS light-dark()). Older browsers get a plain light palette through an @supports fallback rather than a broken page.

The theme uses Hugo's flat layout structure — templates directly in layouts/, partials in layouts/_partials/, render hooks in layouts/_markup/, shortcodes in layouts/_shortcodes/. There is no layouts/_default/ and no layouts/partials/; files placed there silently do nothing.

Configuration

Everything below is optional unless marked. A key that is not listed here does nothing.

Identity

ParamTypeNotes
descriptionstringFallback meta description.
keywordslistFallback meta keywords.
authorstring, list, or mapA map may carry name and email; the email is used in the feed.
imageslistFallback social-card image, and the feed's channel image.
label.textstringHeader wordmark. Defaults to site.Title.
label.iconstringLogo, resolved from assets/.
label.iconSVGstringRaw inline SVG logo, used instead of label.icon.
label.iconHeightintLogo height in px. Default 24.
footer.textstringReplaces the copyright line. Markdown is rendered.

Behaviour

ParamTypeDefaultNotes
envstringSet to production to enable analytics, OpenGraph, Twitter cards and schema.
defaultThemeauto|light|darkautoauto follows the OS.
disableThemeToggleboolfalse
disableLangToggleboolfalse
disableScrollToTopboolfalse
displayFullLangNameboolfalseShow Deutsch instead of de in the switcher.
mainSectionslist["posts"]Sections that feed the landing page, archives and post nav.
latestPostsint5Posts under the profile on the landing page; 0 hides the block.
DateFormatstring:date_longGo layout or Hugo shorthand. Set per language for a localised format.

Display toggles

All booleans and all overridable per page in front matter. Default false, except ShowToc and TocOpen, which default to true — a page gets its contents panel, expanded, unless it or the site sets the param to false.

ParamShows
ShowReadingTimeEstimated reading time in the title block.
ShowWordCountWord count in the title block.
ShowPostNavLinksPrevious / next links under a post.
ShowCodeCopyButtonsA copy button on every code block.
ShowBreadCrumbsThe section trail above the title.
ShowShareButtonsThe share row under a post.
ShowTocTable of contents (appears whenever a page has ≥2 headings).
TocOpenRenders that table of contents expanded.
ShowFullTextinRSSFull post bodies in the feed instead of summaries.
ShowAllPagesInArchiveEvery page in the archive, not just mainSections.

Landing page

[params.profile]
title = "Your Name"subtitle = "tagline"image = "images/profile.png"# relative to assets/, no leading slashimageAlt = "Your Name"imageWidth = 120
[[params.profile.buttons]]
name = "Posts"url = "posts"

Social icons

[[params.socialIcons]]
name = "github"# must match an icon name — see Icons belowurl = "https://github.com/you"title = "GitHub"# optional accessible name

Share buttons

ShareButtons = ["mastodon", "bluesky", "reddit", "hackernews", "linkedin", "email"]

Covers

[params.cover]
linkFullImages = true# clicking a cover opens the original

Edit-post link

[params.editPost]
URL = "https://github.com/you/site/edit/main/content"Text = "Suggest an edit"appendFilePath = truedisabled = false

Important

On a multilingual site, set this per language. .File.Path is relative to that language's contentDir, so a single URL could only ever be right for one of them.

Search

Merged over the theme's defaults and handed to Fuse.js:

[params.fuseOpts]
threshold = 0.4keys = ["title", "permalink", "summary", "content"]

TOML lowercases the keys, so the camelCase options Fuse expects have to be mapped back. Six are: minMatchCharLength, isCaseSensitive, shouldSort, ignoreLocation, includeMatches and findAllMatches. Single-word options (threshold, distance, keys, location) need no mapping. Any other multi-word Fuse option arrives lowercased and is ignored — add it to the map in assets/js/search.js if you need it.

SEO and analytics

[params.schema]
publisherType = "Person"# or "Organization"sameAs = [] # defaults to your socialIcons URLs
[params.social]
twitter = "handle"# twitter:site on cards
[params.analytics.google]
SiteVerificationTag = ""# also .bing, .yandex

Favicons

Every one of these is optional and emits nothing when unset — including manifest, because the theme ships no web manifest of its own. Point it at a file your site actually serves; naming one that does not exist is a 404 on every page load.

[params.assets]favicon, favicon16x16, favicon32x32, favicon_svg, apple_touch_icon, apple_touch_icon_sizes, safari_pinned_tab, safari_pinned_tab_color, manifest
[params.assets]
favicon = "/favicon.ico"favicon16x16 = "/favicon-16x16.png"favicon32x32 = "/favicon-32x32.png"favicon_svg = "/favicon.svg"apple_touch_icon = "/apple-touch-icon.png"apple_touch_icon_sizes = "180x180"# optional, emitted only when setsafari_pinned_tab = "/safari-pinned-tab.svg"safari_pinned_tab_color = "#8a5200"# optional, defaults to --accent (light)manifest = "/site.webmanifest"

Paths are emitted root-relative and resolved against baseURL including its path, so /favicon.ico and favicon.ico both land inside a site published under a subpath. A fully qualified URL — an icon on a CDN, say — is passed through untouched.

apple_touch_icon is what iOS uses for the home-screen icon and Safari for the large Favorites and Dock tiles. apple_touch_icon_sizes adds a sizes attribute and is emitted only when you set it: the theme is handed a path, not a file it can measure, so a default would be a claim about an image it has never seen, and a size that disagrees with the actual PNG is worse than none. One 180x180 icon is what every favicon generator produces and needs no sizes at all — there is nothing for iOS to choose between. Declare it when you are hand-writing several rungs and want the pick to be explicit.

safari_pinned_tab_color is not decoration: Safari tints the pinned tab's template icon with it and wants the attribute present. It defaults to the light value of --accent, read out of 00-tokens.css the same way the theme-color pair is, so a recoloured theme carries the pinned tab with it.

favicon_svg is emitted after the raster rungs so a browser that supports image/svg+xml prefers it and favicon stays the fallback. It is the only rung that can follow the active colour scheme: put a @media (prefers-color-scheme: dark) block inside the SVG itself and the tab icon flips with the page. Custom properties on :root work there — :root is the <svg> element — so the file can be written against the same token names as the stylesheet.

Content

Front matter

+++title = "Post Title"date = 2026-04-23T11:05:31+02:00draft = falsetags = ["tag-one", "tag-two"]
summary = "One sentence, shown in list views and OpenGraph."+++
KeyEffect
toc = false / ShowToc = falseSuppress the table of contents. It otherwise appears whenever a page has ≥2 headings.
TocOpen = falseRender the ToC collapsed.
hidemeta = trueHide the title block. Useful on standalone pages.
hideSummary = trueHide the summary in list views.
searchHidden = trueKeep the page out of the search index.
hiddenInRss = trueKeep the page out of the feeds.
hiddenInHomeList = trueKeep the page off the landing page.
robotsNoIndex = trueEmit noindex, nofollow.
canonicalURLOverride the canonical link.
disableShare = trueHide the share row on this post.
comments = trueRender _partials/comments.html (an empty stub you override).
hideFooter = trueDrop the site footer.
layout = "search"Render the search page.
layout = "archives"Render the year/month archive.
menus = "main"Put a standalone page in the nav.
authorOverride the site author on this page. String, list or map, as in [params].
keywordsOverride the site keywords for this page's meta tag.
imagesSocial-card image for this page. A bundle resource name or a URL; the first entry wins. Overridden by cover.
bodyClassExtra class appended to <body>, for a page that needs its own CSS hook.
placeholderOn a layout = "search" page, the search input's placeholder text.
aiDisclose AI use on this post. See AI disclosure.
coverSee Covers.

Warning

Two TOML traps. A date-only value must stay a quoted string (date = "2026-03-27") — a bare TOML local date is not a time.Time and Hugo will not cast it reliably. And quote numeric-looking tags ("403"), or they reach templates as numbers.

Index pages

The landing page, a section's _index.md, the tag index, a tag page, the archive and the search page all take a title, an optional description, and an optional body. Each lands somewhere different on the sheet:

FieldWhere it renders
titleThe page title.
descriptionThe standfirst, directly under the title and above the sheet stamp.
bodyThe sheet note — a general note under the stamp, set one voice quieter than the index it introduces and held to the standfirst's measure.

Keep the two apart or the head says the same thing twice: the description states what the sheet is, the note says what is on it. Either may be omitted. A note is ordinary Markdown, so alerts, tables and code in one still render as they do in a post.

+++title = "Posts"description = "Every post in the demo, newest first."+++Nine posts across two languages, written to exercise the theme rather than to fill it.

AI disclosure

States that a post was written with AI assistance. It puts a small stamp in the corner of the title block — where a drawing carries its stamps — and the same drawn mark in the index, so a reader sees it before opening the post. Hovering or focusing the stamp opens the detail; the detail is also read out by a screen reader with the box closed, so nothing is hidden behind a pointer.

Writing it: ai = true, a level, or the full [ai] block — level, note, model

Three ways to write the same thing. The shortest is the point:

ai = true# enough on its own; means level = "assisted"ai = "generated"# just the level
[ai] # the full formlevel = "assisted"# "assisted" or "generated"note = "Drafted from an outline, then edited by hand"model = "Claude Opus 5"
KeyDefaultNotes
levelassistedassisted or generated. Anything else fails the build — a typo in a provenance statement is a false statement, and free wording belongs in note.
noteWhat was done. Free text.
modelparams.ai.modelWhat it was done with.

Name the model once for the whole site rather than in every post:

[params.ai]
model = "Claude Opus 5"

To mark individual passages instead of the whole post, use the ai shortcode. A post may do either, both, or neither.

Covers

[cover]
image = "cover.png"# a page-bundle resource, or a path under assets/alt = ""caption = ""# markdown, shown under the cover on the posthidden = falsehiddenInList = falsehiddenInSingle = false

With no cover block at all, a bundle resource named cover.* is picked up automatically. Covers feed og:image and the Twitter card — which is why this theme overrides Hugo's internal OpenGraph template rather than using it.

Shortcodes

Goldmark's unsafe is off, so a post never needs raw HTML. These cover what it would have been for.

ShortcodeFor
collapseA folded block — long logs, appendices
figureAn image with a caption, through the responsive-image ladder
videoA self-hosted video with a poster
audioA self-hosted audio track
intextimgA small image set inline in a sentence
aiMarking an individual passage as AI-written
rawhtmlThe deliberate escape hatch, where nothing else fits

collapse (alias details)

Folds long command output away without hiding it from search or print. Load-bearing: Goldmark's unsafe is off, so a raw <details> written in Markdown is stripped — this is the only way to get one.

{{</* collapse summary="Full output" */>}}
```console```
{{</* /collapse */>}}
ParamDefaultNotes
summaryRequired. Markdown is rendered. Also accepted positionally.
openByDefaultunsetRender the block already unfolded.

figure

Overrides Hugo's built-in figure so the image goes through the same WebP ladder as everything else and carries intrinsic dimensions. It exists only because the render hook cannot produce a caption.

{{</* figure src="shot.png" alt="A terminal" caption="After the migration" */>}}
ParamNotes
srcA page-bundle resource, or a path under assets/.
altFalls back to the plain-text caption.
captionMarkdown, rendered under the image.
attrAttribution line, markdown.
attrlinkWraps attr in a link.
linkWraps the image in a link; target and rel go with it.
classExtra class on the <figure>.

video

Never autoplays — a reader mid-task does not want sound.

{{</* video src="clip.mp4" poster="still.png" */>}}
ParamNotes
srcA page-bundle resource or a plain URL. Also accepted positionally.
posterStill frame; also resolved as a bundle resource.
typeExplicit MIME type on the <source>.
loop, mutedSet to enable.

audio

{{</* audio src="clip.mp3" */>}}
ParamNotes
srcA page-bundle resource or a plain URL. Also accepted positionally.

intextimg

An image set inline in a run of text — a glyph, a badge, a small mark — sized to the line rather than the column.

{{</* intextimg url="icon.svg" alt="the icon" height="1.1em" */>}}
ParamDefaultNotes
url (or src)A page-bundle resource or a plain URL.
alt
height1emAny CSS length.

ai

Marks a passage as written with AI assistance, using the notation a drawing already has for an altered region: a dashed change bar down the passage's edge, the sheet's AI mark flagged on the bar — the same nib the title block stamps in its corner — and a number beside it resolving to a row in the revision note at the foot of the sheet. Every number links to its own row.

Inside a sentence there is no bar for a flag to sit on, so display="inline" sets the dashed line under the words and raises the number after them, like a footnote reference.

{{</* ai note="Drafted from an outline, then edited by hand" */>}}
A whole paragraph.
{{</* /ai */>}}
… a sentence with {{</* ai display="inline" */>}}this run{{</* /ai */>}} in it.
ParamDefaultNotes
displayblockinline for a run inside a sentence. Anything else fails the build.
notethe post's ai.noteWhat happened at this point; it is what the revision note prints.
modelthe post's ai.modelOverrides the model for this passage alone.

The post-level counterpart is front matter rather than a shortcode — see AI disclosure. The two are independent: a post may mark passages without carrying a stamp, and vice versa.

rawhtml

Emits its body verbatim — a deliberate hole in unsafe = false.

{{</* rawhtml */>}}<abbrtitle="">…</abbr>{{</* /rawhtml */>}}

Caution

Everything inside is trusted exactly as far as whoever wrote the post is. Reach for a render hook or another shortcode first.

Markdown extras

None of these need a shortcode.

  • Alerts. A blockquote opening with > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING] or [!CAUTION] becomes a labelled callout.
  • Headings get an anchor link on hover.
  • External links get rel="noopener noreferrer", a new tab, and a marker.
  • Tables are wrapped in a focusable scroll container — Goldmark emits a bare <table> and a wide one would push the whole page sideways.
  • Images in a page bundle are resized to a 480/800/1600 WebP ladder with sizes and intrinsic dimensions, so they neither shift the layout nor ship at source resolution. An image referenced from outside a bundle is passed through untouched and gets none of that — always put post images in a page bundle.

Icons

UI glyphs, authored on a 24×24 grid with a 1.75 stroke:

arrow-up · arrow-right · arrow-left · external · hash · search · pencil · chevron-right · check · moon · sun · rss · email · ai

Brand marks, from Simple Icons (CC0) as filled paths:

github · bluesky · stackoverflow · reddit · mastodon · linkedin · x · telegram · whatsapp · ycombinator · gitlab · codeberg

Unknown names fall back to a generic link glyph, so a typo in socialIcons is visible rather than silent. Extend the set by adding a branch to _partials/icon.html.

Customising

Your own CSS

Add assets/css/99-local.css to your site — site assets join the theme's glob and land last, so your rules win without forking anything.

assets/css/ holds numerically prefixed files that are globbed, concatenated, minified and fingerprinted into one stylesheet with an SRI hash. The numeric prefix is the cascade order, which is why a local file wants a high number.

00-tokens.css is the single source of truth for colour, type, space and motion — override a token there and the whole sheet follows. Sizes come from --step--2--step-5 for type and --space-4xs--space-4xl for spacing.

Important

Dim text by dropping to --meta-soft and a smaller step, never by lowering opacity. Every text token is contrast-checked at ≥4.5:1 against its surface in both schemes, and an opacity: 0.6 silently undoes that.

Colours resolve through CSS light-dark(), so each is written once and the page themes correctly with JavaScript disabled. Note that light-dark() only produces colours — a display swap like the theme-toggle icon still needs a real prefers-color-scheme query. Browsers under the baseline are caught by an @supports not (color: light-dark(…)) block at the foot of 00-tokens.css that repeats the light palette; override a colour and you may want to override it there too.

Template overrides

_partials/extend-head.html, _partials/extend-footer.html, _partials/extend-post-content.html and _partials/comments.html are empty stubs. Create a file of the same name in your site's own layouts/_partials/ and it wins.

Fonts

Two self-hosted variable faces, subset to latin and latin-ext, preloaded, font-display: swap:

  • Archivo (SIL OFL) carries a width axis as well as a weight axis, so one file supplies both the reading face and the narrow tracked lettering the title blocks and labels are set in. A matching italic ships too, fetched only when a page actually sets italic text.
  • JetBrains Mono (SIL OFL) for code, figures and tabular numerals. No italic: syntax comments are separated by colour instead, so a code block never pulls a second mono file.

Together they are ~121 KB on first load and cached thereafter — a deliberate trade for a theme whose subject is code. To fall back to system stacks, drop the @font-face blocks in 10-base.css and the preloads in _partials/head-assets.html.

Development

hugo server -D --source exampleSite --themesDir ../..

exampleSite/ is a standalone site that exercises the theme, and is what every change is tested against. DESIGN.md records the visual system behind the stylesheet — line weights, palette, motion, print — if you want to extend it in keeping.

Contributing

Note

Please open issues and pull requests on GitHub.

Development happens on a self-hosted GitLab; the GitHub repository is a copy of it, pushed by hand. So a pull request cannot simply be merged there — GitHub is not where main is written. Patches are applied on GitLab instead and reach GitHub with the next push, authorship intact. In practice that changes nothing about how you contribute: open the pull request, and treat the branch as the contribution rather than the merge button.

CONTRIBUTING.md has the details, including the commit message format — commits are the source CHANGELOG.md is generated from, so they follow a fixed type(scope): summary shape. Read the changelog's Breaking entries before upgrading: they are the ones that move a URL or drop a param.

Licence

MIT — see LICENSE.

Bundled third-party assets and their licence texts live in licenses/: Archivo and JetBrains Mono (SIL OFL-1.1) and Fuse.js (Apache-2.0). The brand marks in icon.html are Simple Icons paths, which are CC0 and carry no licence text to bundle.

About

A multilingual Hugo theme that sets every page as an engineering drawing sheet: a drawn frame, a zone rail, and a ruled title block instead of a grey caption line.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages