Loom

A blog engine that just works. One binary, no setup, no dependencies.

Documentation

Contents (32)

Everything you need to configure and use Loom. If you haven’t installed it yet, start with the Getting Started guide — you’ll be up and running in 30 seconds.

Content Structure

content/
  site.conf              # Site configuration
  posts/                 # Blog posts (markdown)
    my-post.md
    series-name/         # Subdirectory = series
      first-post.md
      second-post.md
  pages/                 # Static pages
    about.md
    projects.md
  images/                # Static assets (served as-is)
    cover.png
  theme/
    style.css            # Custom CSS override (optional)

Any non-markdown file in the content directory is a static asset — images, fonts, PDFs, downloads. Put them anywhere and reference them by path:

![screenshot](/images/screenshot.png)

How they’re served depends on how you’re running Loom:

ModeHow images are served
./loom content/Read from disk on each request
./loom --git /local/repo main contentRead from git objects via git show
./loom --git https://github.com/you/blog.git main content302 redirect to GitHub’s CDN — bytes never touch your server

In all three cases, you write the same path in your markdown. Nothing changes on the content side.

Post Frontmatter

---
title: My Post Title
date: 2025-10-15
slug: my-post
tags: systems, cpp, linux
excerpt: A one-line summary for listings and SEO.
image: /images/cover.png
draft: true
---
FieldRequiredDefaultDescription
titlenofilenamePost title
datenocurrent timePublish date (YYYY-MM-DD)
slugnofilenameURL path (/post/slug)
tagsnononeComma-separated tag list
excerptnoauto-generatedSummary for listings and meta tags
imagenofirst image in postSocial preview image (og:image, twitter:image)
draftnofalseSet true to hide from all listings
featurednofalseSet true to pin the post in a featured section on the homepage

If excerpt is omitted, the first ~200 characters of content are used.

Reading time is auto-calculated at 200 words per minute.

File modification time is used as a tiebreaker when posts share the same publish date.

Page Frontmatter

---
title: About
slug: about
---

Pages are served at /:slug (e.g., /about).

Series

Create a subdirectory inside posts/. The folder name becomes the series name. Posts are ordered by publish date, oldest first.

posts/
  linux-internals/
    virtual-memory.md     # Part 1 (earliest date)
    cgroups.md            # Part 2
    io-uring.md           # Part 3

Each post in a series displays a navigation panel listing all parts, with the current post highlighted.

Series appear in the sidebar widget and at /series and /series/:name.

The /series index page displays each series as a card with post count, date range, and the title of the latest post. The sidebar series widget also shows post counts.

Pagination

The homepage is paginated. Posts are split across pages of 12 (configurable via posts_per_page in site.conf). Navigation links appear at the bottom of each page.

/           → page 1
/page/2     → page 2
/page/3     → page 3

All pages are pre-rendered at startup. The pagination URLs are path-based, matching the compile-time routing system.

Mark posts with featured: true in frontmatter to pin them in a dedicated section at the top of the homepage (page 1 only). Featured posts are excluded from the regular chronological listing to avoid duplicates.

---
title: Important Announcement
date: 2025-12-01
featured: true
---

A client-side search page is available at /search. At startup, Loom generates a JSON index at /search.json containing the slug, title, excerpt, and tags for every post. The search page fetches this index once and filters it in the browser as you type — no server round-trips, no external dependencies.

Search matches all terms against the combined title, excerpt, and tags of each post.

Tags

The /tags page and the sidebar tag cloud display tags with weighted font sizes based on post frequency. Tags used more often appear larger. Each tag shows its post count.

Individual tag pages (/tag/:name) also display the post count in the heading.

Site Configuration

site.conf uses key = value format.

Core

KeyDescription
titleSite title (shown in header and <title>)
descriptionSite description
authorAuthor name (used in meta tags, RSS, JSON-LD)
base_urlFull base URL for canonical links, sitemap, RSS
themeTheme name — 6 built-in: default, terminal, nord, gruvbox, rose, hacker
nav = Home:/, Blog:/, About:/about, GitHub:https://github.com

Comma-separated Label:url pairs. External URLs work.

KeyDescription
sidebar_widgetsComma-separated widget list
sidebar_recent_countNumber of recent posts to show (default: 5)
sidebar_aboutText for the about widget

Available widgets: recent_posts, tag_cloud, archives, series, about.

Layout

KeyValuesDefaultDescription
header_styledefault, centered, minimaldefaultHeader layout
show_descriptiontrue, falsefalseShow site description below title
show_theme_toggletrue, falsetrueDark/light mode toggle button
posts_per_pageinteger12Posts per page on the index (pagination)
post_list_stylelist, cardslistIndex page layout
show_post_datestrue, falsetruePublication dates on listings
show_post_tagstrue, falsetrueTags on listings
show_excerptstrue, falsetrueExcerpts on listings
show_reading_timetrue, falsetrueReading time estimates
show_breadcrumbstrue, falsetrueBreadcrumb trail on post, tag, and series pages
external_links_new_tabtrue, falsefalseOpen all external links in a new tab site-wide
sidebar_positionright, left, nonerightSidebar placement
date_formatstrftime format%Y-%m-%dDate display format
custom_cssCSS stringnoneAdditional CSS injected after theme
custom_head_htmlHTML stringnoneHTML injected in <head> (fonts, analytics)
footer_copyright = &copy; 2025 Your Name
footer_links = GitHub:https://github.com, Twitter:https://twitter.com

Theme Variable Overrides

Override any CSS variable without writing a full theme:

theme = nord
theme_accent = #ff6600
theme_dark-accent = #ff8833
theme_font_size = 18px
theme_max_width = 800px

Underscores in key names become hyphens in CSS. Keys prefixed with dark- apply only to dark mode.

Available variables: bg, text, muted, border, accent, font, font-size, max-width, and any custom CSS variable defined in the theme.

Markdown

Loom’s hand-written markdown parser supports:

Block elements: headings (ATX # and setext), paragraphs, fenced code blocks (backticks and tildes with language hints), blockquotes, unordered lists (-, *, +), ordered lists, task lists (- [ ], - [x]), nested lists, tables with alignment, horizontal rules, footnote definitions, raw HTML blocks.

Inline elements: bold, italic, bold italic, strikethrough, inline code, links, reference links, images, reference images, autolinks, footnote references, backslash escapes, line breaks (trailing spaces).

Smart typography: Straight quotes become curly ("hello" renders as “hello”), apostrophes become smart (it's renders as it’s), -- becomes an en dash, --- becomes an em dash, and ... becomes an ellipsis. Applied automatically during rendering — no config needed, and code blocks are unaffected.

Code block titles: Add a filename tab above any fenced code block with title="...":

```rust title="main.rs"
fn main() {}
```

A copy button appears on hover for all code blocks. Both features work across all themes.

Sidenotes: Footnotes render as Tufte-style margin notes on wide screens (>1100px). On narrower screens, they become toggleable inline notes — click the superscript number to expand. No JavaScript required for the toggle; it uses a CSS checkbox hack.

A claim[^1] that needs a source.

[^1]: The source appears in the margin on desktop.

New-tab links: append ^ after the closing ) or ] to open a link in a new tab: [text](url)^. Works on all link forms. The external_links_new_tab = true config option applies this site-wide to all external URLs automatically.

Tables:

| Left | Center | Right |
|:-----|:------:|------:|
| a    |   b    |     c |

UX Features

These features are built in and work across all themes. No configuration needed.

Command Palette

Press Ctrl+K (or Cmd+K on Mac) to open a floating command palette. Type to fuzzy-search all posts by title, excerpt, and tags. Arrow keys to navigate, Enter to go, Esc to close. It uses the same /search.json index as the search page.

Keyboard Navigation

On any page with post listings:

KeyAction
jMove to next post
kMove to previous post
EnterOpen the focused post
/Focus the search input
EscClose command palette or image zoom

Only active when you’re not in a text input.

Image Zoom

Click any image in a post or page to expand it fullscreen with a dark backdrop. Click anywhere or press Esc to close. No lightbox library — uses native CSS transforms and a single event listener.

Active Table of Contents

When a post or page has a table of contents (3+ headings), the current section is highlighted as you scroll. Uses IntersectionObserver for zero-jank tracking.

Reading Position Memory

On long posts, Loom saves your scroll position to localStorage. When you revisit, a subtle toast appears: “Continue where you left off?” with a link that smooth-scrolls to your last position. The toast auto-dismisses after 6 seconds.

Post Staleness Notice

Posts older than 18 months display a subtle banner below the reading progress bar: “This post was written over N years ago. Some information may be outdated.” Styled with the theme’s accent color.

View Transitions

Pages use the browser-native View Transitions API for smooth crossfade navigation. No JavaScript required — a single <meta> tag enables it. Older browsers that don’t support it simply fall back to normal navigation.

Post Connections Graph

The Archives page includes an SVG graph showing how posts relate to each other through shared tags. Posts are nodes arranged in a circle; lines between them indicate shared tags (thicker lines = more shared tags). Each node links to its post. Generated at build time with zero JavaScript.

Routes

RouteDescription
/Post index (page 1)
/page/:numPaginated post index
/post/:slugSingle post (with prev/next, related posts, series nav)
/searchClient-side full-text search
/search.jsonSearch index (JSON, built at startup)
/tag/:tagPosts filtered by tag (with post count)
/tagsAll tags with post counts and weighted sizing
/archivesPosts grouped by year
/seriesAll series with post count, date range, and latest post
/series/:namePosts in a series
/:slugStatic page
/feed.xmlRSS 2.0 feed (latest 20 posts)
/sitemap.xmlXML sitemap
/robots.txtRobots exclusion
/*Static assets (images, fonts, etc.)

Git Source

Serve content directly from a git repository without a working tree. No checkout needed — Loom reads everything via git show and git ls-tree.

# local or bare repo
./loom --git /path/to/repo main content

# public GitHub remote — clones bare into /tmp automatically
./loom --git https://github.com/you/blog.git main content
./loom --git git@github.com:you/blog.git main content

Arguments: repo_path_or_url, branch (default: main), content_prefix (default: root).

The git watcher polls for new commits every 100ms and hot-reloads automatically. Push a commit and the site updates within seconds.

Images and Static Assets

Write image paths exactly as you would in filesystem mode:

![cover](/images/cover.png)

For a local repo, Loom reads the blob from git objects and serves it directly.

For a public GitHub remote, Loom issues a 302 redirect to raw.githubusercontent.com instead:

GET /images/cover.png
→ 302 Location: https://raw.githubusercontent.com/you/blog/refs/heads/main/content/images/cover.png

The browser fetches the image straight from GitHub’s CDN. Your server only ever serves HTML — it never reads or streams image bytes. This works automatically whenever you pass a GitHub URL; no config needed.

Hot Reload

Filesystem mode: inotify watches the content directory for file creates, modifies, deletes, and moves. Changes are debounced (500ms) then the cache is rebuilt.

Git mode: polls git rev-parse HEAD for commit hash changes. New commit triggers a full rebuild.

In both modes, the new cache is built in the background and swapped atomically via shared_ptr. Active requests finish on the old cache. Zero downtime.

HTTP Server

  • Epoll-based non-blocking event loop
  • TCP_NODELAY enabled
  • Keep-alive connections (5s idle timeout)
  • Gzip compression (auto-detected from Accept-Encoding)
  • ETag caching with 304 Not Modified responses
  • Cache-Control: public, max-age=60, must-revalidate
  • HTML minification
  • 1MB max request size
  • SEO: canonical URLs, Open Graph, Twitter Cards, JSON-LD structured data, RSS autodiscovery

Custom Themes

Drop a style.css in content/theme/ to fully override the default stylesheet. Or use a built-in theme and override specific variables via theme_* keys in site.conf.

Running

# Filesystem source (default)
./loom                        # uses content/
./loom /path/to/content       # custom content dir

# Git source
./loom --git /path/to/repo              # main branch, root prefix
./loom --git /path/to/repo develop      # custom branch
./loom --git /path/to/repo main site/   # custom content prefix

Port 8080, hardcoded.