The polis theme system renders markdown posts and comments to styled HTML pages. Themes are self-contained packages that include layout templates, stylesheets, and snippets.
Polis stores content as signed markdown files with YAML frontmatter. While this format is ideal for:
- Cryptographic verification
- AI/LLM consumption
- Programmatic access via
public.jsonl
It's not ideal for human readers browsing your site. The theme system bridges this gap by generating HTML files that:
- Display beautifully in browsers
- Include blessed comments inline on post pages
- Provide an index page listing all content
- Preserve the original markdown files for verification
# Initialize a polis site (themes are installed automatically)
polis init
# Render all posts and comments to HTML
polis render
# Force re-render everything (ignore timestamps)
polis render --forcePolis ships with six themes:
| Theme | Description |
|---|---|
| turbo | Retro computing aesthetic with deep blue foundation |
| zane | Neutral dark theme with teal and salmon accents |
| sols | Nine Sols inspired theme with violet and peach tones |
| vice | GTA Vice City / Miami Vice inspired with coral and sunset tones |
| especial | Modelo Especial inspired dark theme with gold and cream |
| especial-light | Light variant of especial with warm fog background |
On first render, polis randomly selects a theme from those available. You can change the active theme at any time.
Open Settings in the webapp dashboard. The Theme section shows all available themes with color palette previews. Click any theme to switch immediately — the site is re-rendered automatically.
Edit metadata/manifest.json and set the active_theme field:
{
"version": "0.27.0",
"active_theme": "zane",
"post_count": 5,
"comment_count": 3
}Then re-render:
polis render --forceThe theme's CSS will be copied to styles.css at your site root.
Themes use a shared base layout system. The _base/ directory contains canonical HTML templates and snippets. Individual themes provide only a CSS file and optional template overrides.
.polis/themes/
├── _base/ # Shared base templates (system-managed)
│ ├── index.html # Homepage template
│ ├── post.html # Post page template
│ ├── comment.html # Comment page template
│ ├── comment-inline.html # Blessed comment template
│ ├── posts.html # Archive page template
│ ├── tag.html # Tag page template
│ ├── tag-index.html # Tag index template
│ └── snippets/ # Shared snippets
│ ├── about.html
│ ├── post-item.html
│ ├── comment-item.html
│ ├── blessed-comment.html
│ ├── also-reading.html
│ └── polis-widget.html
├── turbo/
│ └── turbo.css # CSS-only theme (inherits HTML from _base)
├── sols/
│ └── sols.css
└── studio13/ # Theme with structural overrides
├── studio13.css
├── index.html # Overrides _base/index.html
├── post.html # Overrides _base/post.html
├── posts.html # Overrides _base/posts.html
└── snippets/
└── post-item.html # Overrides _base/snippets/post-item.html
For each template file, the render pipeline checks:
- Theme directory — if the active theme has the file, use it
_base/directory — fall back to the shared base template
This means most themes only need a CSS file. The base templates provide the HTML structure, and CSS customizes the visual appearance.
Snippets are reusable template fragments included with {{> name}} syntax.
When resolving {{> path}}:
- Global snippets -
./snippets/{path}.mdor.html(author overrides) - Theme snippets -
.polis/themes/{active_theme}/snippets/{path}.htmlor.md - Base snippets -
.polis/themes/_base/snippets/{path}.htmlor.md
This allows you to override theme defaults by creating snippets in ./snippets/.
Use prefixes to control which tier is checked first:
| Prefix | Behavior |
|---|---|
{{> about}} |
Default: global first, then theme |
{{> global:about}} |
Explicit global-first (same as default) |
{{> theme:about}} |
Theme-first, then global fallback |
Example: A theme's about.html provides default styling, but your snippets/about.md with personal content takes precedence. Use {{> theme:about}} if you need the theme version.
By default, extension resolution tries .md → .html → exact match. Use explicit extensions to load a specific file:
| Syntax | Behavior |
|---|---|
{{> about}} |
Tries about.md, about.html, about |
{{> about.md}} |
Loads about.md only, no fallback |
{{> about.html}} |
Loads about.html only, no fallback |
Prefixes and extensions can be combined:
{{> theme:about.html}} <!-- Theme's HTML version specifically -->
{{> global:about.md}} <!-- Global's markdown version specifically -->Each theme includes these snippets:
| File | Purpose |
|---|---|
about.html |
About section (displayed on homepage) |
post-item.html |
Post list item (used in {{#posts}} loops) |
comment-item.html |
Comment list item (used in {{#comments}} loops) |
blessed-comment.html |
Blessed comment (used in {{#blessed_comments}} loops) |
Create snippets in ./snippets/ to override theme defaults or add custom content:
# Override the about section (will take precedence over theme)
echo '# About Me' > snippets/about.md
# Create a custom snippet
mkdir -p snippets/widgets
echo '<div class="newsletter">Subscribe!</div>' > snippets/widgets/newsletter.htmlReference in templates:
{{> about}} <!-- Uses your global override -->
{{> theme:about}} <!-- Forces theme version -->
{{> widgets/newsletter}} <!-- Uses your custom snippet -->- Select theme - On first render, randomly selects from available themes
- Load templates - Reads HTML templates from active theme
- Copy CSS - Copies theme stylesheet to
styles.css - Scan content - Finds all
.mdfiles inposts/andcomments/ - Check timestamps - Skips files where
.htmlis newer than.md(unless--force) - Extract frontmatter - Reads title, published date, signature, etc.
- Convert to HTML - Uses pandoc to render markdown body
- Apply template - Substitutes variables and renders snippets
- Write output - Creates
.htmlfile alongside.mdfile - Generate index - Creates
index.htmlfrompublic.jsonl
posts/
└── 2026/
└── 01/
├── my-post.md # Source (signed markdown)
└── my-post.html # Generated (rendered HTML)
comments/
└── 2026/
└── 01/
├── reply.md # Source (signed markdown)
└── reply.html # Generated (rendered HTML)
index.html # Generated (listing page)
posts/
└── index.html # Generated (archive page, all posts)
styles.css # Copied from active theme
The .md files remain the source of truth. HTML files are regenerated from them.
Polis uses a Mustache-inspired templating syntax.
{{title}} <!-- Simple variable -->
{{site_url}} <!-- Site-level variable -->
{{published_human}} <!-- Formatted date -->Use {{> path}} to include snippets:
{{> about}} <!-- Includes about section -->
{{> post-item}} <!-- Includes post list item -->
{{> widgets/newsletter}} <!-- Includes custom snippet -->Snippets can include other snippets (up to 10 levels deep).
Use {{#section}}...{{/section}} for loops:
<!-- Loop over all posts (unlimited, use in archive pages) -->
{{#posts}}
{{> post-item}}
{{/posts}}
<!-- Loop over recent posts (limited to 10, use on homepage) -->
{{#recent_posts}}
{{> post-item}}
{{/recent_posts}}
<!-- Loop over all comments (unlimited) -->
{{#comments}}
{{> comment-item}}
{{/comments}}
<!-- Loop over recent comments (limited to 10, use on homepage) -->
{{#recent_comments}}
{{> comment-item}}
{{/recent_comments}}
<!-- Loop over blessed comments (on post pages) -->
{{#blessed_comments}}
{{> blessed-comment}}
{{/blessed_comments}}The {{#recent_posts}} and {{#recent_comments}} sections display the 10 most recent items. Use {{#posts}} and {{#comments}} (unlimited) in archive templates.
Inside {{#posts}} and {{#recent_posts}} loops:
| Variable | Description |
|---|---|
{{url}} |
Link to HTML file |
{{title}} |
Post title |
{{excerpt}} |
First ~200 chars of post body (plain text) |
{{published}} |
ISO date |
{{published_human}} |
Human-readable date |
{{comment_count}} |
Number of blessed comments |
Inside {{#comments}} and {{#recent_comments}} loops:
| Variable | Description |
|---|---|
{{url}} |
Link to HTML file |
{{target_author}} |
Domain of post being replied to |
{{published}} |
ISO date |
{{published_human}} |
Human-readable date |
{{preview}} |
First ~100 chars of body |
Inside {{#blessed_comments}} loops:
| Variable | Description |
|---|---|
{{url}} |
Comment URL |
{{author_name}} |
Comment author |
{{published}} |
ISO date |
{{published_human}} |
Human-readable date |
{{content}} |
Comment body |
| Variable | Description | Example |
|---|---|---|
{{site_url}} |
Base URL from POLIS_BASE_URL |
https://example.com |
{{site_title}} |
From .well-known/polis or domain fallback |
My Polis Site |
{{year}} |
Current year (for copyright) | 2026 |
| Variable | Description | Example |
|---|---|---|
{{title}} |
Post/comment title | Why I Left Substack |
{{content}} |
HTML-rendered markdown body | <p>The story begins...</p> |
{{published}} |
Publication date (ISO 8601) | 2026-01-08T12:00:00Z |
{{published_human}} |
Human-readable date | January 8, 2026 |
{{url}} |
Canonical URL | https://example.com/posts/2026/01/post.md |
{{version}} |
Content hash | sha256:abc123... |
{{author_name}} |
From .well-known/polis |
Alice Smith |
{{author_url}} |
Site base URL | https://example.com |
{{signature_short}} |
Truncated signature (16 chars) | AAAAC3NzaC1lZD... |
{{css_path}} |
Relative path to styles.css | ../../styles.css |
| Variable | Description | Example |
|---|---|---|
{{blessed_count}} |
Number of blessed comments | 3 |
| Variable | Description | Example |
|---|---|---|
{{in_reply_to_url}} |
Parent post/comment URL | https://bob.com/posts/original.md |
| Variable | Description | Example |
|---|---|---|
{{post_count}} |
Number of posts | 12 |
{{comment_count}} |
Number of comments | 5 |
The simplest way to create a theme — just provide a CSS file:
# Create your theme directory
mkdir .polis/themes/mytheme
# Create your CSS file (must match directory name)
vim .polis/themes/mytheme/mytheme.css
# Activate your theme
# Edit .well-known/polis: "active_theme": "mytheme"
# Render
polis render --forceYour theme automatically inherits all HTML templates from _base/. Only customize CSS variables and styles.
If you need different HTML structure for specific pages, override individual templates:
# Create CSS-only theme first
mkdir -p .polis/themes/mytheme
vim .polis/themes/mytheme/mytheme.css
# Override just the homepage (other pages use _base)
cp .polis/themes/_base/index.html .polis/themes/mytheme/index.html
vim .polis/themes/mytheme/index.html
# Override a snippet
mkdir -p .polis/themes/mytheme/snippets
cp .polis/themes/_base/snippets/post-item.html .polis/themes/mytheme/snippets/post-item.html
vim .polis/themes/mytheme/snippets/post-item.html| File | Required | Purpose |
|---|---|---|
{themename}.css |
Yes | Theme stylesheet |
| HTML templates | No | Inherited from _base/ — override only if structural changes needed |
snippets/ |
No | Inherited from _base/ — override only if needed |
Base templates include comment headers documenting which snippets they load:
<!--
Polis Base Theme - Homepage Template
Snippets loaded by this template:
- theme:about - About section
- theme:post-item - Post list item
- theme:comment-item - Comment list item
Lookup order: theme snippets -> base snippets -> global snippets
-->The themes directory can be configured:
| Method | Example |
|---|---|
| Environment | THEMES_DIR=custom/themes polis render |
| .env file | THEMES_DIR=custom/themes |
| .well-known/polis | {"config": {"directories": {"themes": "custom/themes"}}} |
Default: .polis/themes
The theme system reads site information from metadata/manifest.json:
{
"version": "0.42.0",
"active_theme": "turbo",
"last_published": "2026-01-14T00:00:00Z",
"post_count": 5,
"comment_count": 3
}Note: site_title is stored in .well-known/polis, not in manifest.json.
Set the site title during initialization: polis init --site-title "My Site"
| Path | Purpose |
|---|---|
.polis/themes/ |
Installed themes |
snippets/ |
Global snippets (override theme snippets) |
styles.css |
Active theme's stylesheet (copied on render) |
index.html |
Generated listing page |
posts/index.html |
Generated archive page (all posts) |
posts/**/*.html |
Generated post pages |
comments/**/*.html |
Generated comment pages |
metadata/manifest.json |
Site metadata including active_theme |
metadata/public.jsonl |
Content index (used for index.html) |
metadata/blessed-comments.json |
Blessed comments (rendered inline) |
Install pandoc:
# Linux
sudo apt install pandoc
# macOS
brew install pandocRun polis init first to create the required directory structure.
Check that the theme exists in .polis/themes/xyz/. Available themes can be listed:
ls .polis/themes/The render command skips files where the .html is newer than the .md. Use --force to re-render:
polis render --forceCheck that:
- Variables use double curly braces:
{{variable}} - Variable names are spelled correctly (case-sensitive)
- Required data exists (e.g.,
POLIS_BASE_URLfor{{site_url}})
Verify that:
metadata/blessed-comments.jsonexists and contains entries- The post URL in the JSON matches the post being rendered
- Comment files are accessible (local or via HTTP)
Run polis blessing sync to update blessed comments from the discovery service.
Remote blessed comments are fetched when a post is rendered, but caching may show outdated content if:
- The comment author updated their comment after you last rendered
- Your post's HTML is already up-to-date based on local file timestamps
Use polis render --force to re-fetch all remote blessed comments.
- Check that
styles.cssexists at your site root - Verify the theme's CSS file exists in
.polis/themes/{theme}/{theme}.css - Re-render to copy the CSS:
polis render --force
The index is generated from metadata/public.jsonl. Ensure:
- Posts are published (have frontmatter with
versionfield) - Run
polis rebuild --contentto regenerate the index
| Tool | Required | Purpose |
|---|---|---|
| pandoc | Yes | Markdown to HTML conversion |
| jq | Yes | JSON parsing |
| curl | For remote comments | Fetching blessed comments |
This section covers advanced topics for creating polished, consistent themes.
The built-in themes follow a consistent CSS variable naming pattern:
:root {
/* Background layers (dark to light) */
--color-bg: #000818; /* Page background */
--color-bg-light: #001030; /* Slightly lighter background */
--color-surface: #001448; /* Card/panel backgrounds */
--color-panel: #001a58; /* Elevated elements */
/* Primary accent (Polis cyan - keep consistent for branding) */
--color-cyan: #00d4ff;
--color-cyan-soft: #80e4ff;
--color-cyan-dim: #0090b0;
--color-cyan-glow: rgba(0, 212, 255, 0.3);
/* Text hierarchy */
--color-text: #ffffff; /* Primary text */
--color-text-soft: #a0c0d8; /* Secondary text */
--color-text-muted: #607890; /* Tertiary/meta text */
/* Borders */
--color-border: rgba(0, 212, 255, 0.2);
--color-border-light: rgba(0, 212, 255, 0.35);
/* Typography */
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
--font-display: 'Orbitron', var(--font-mono);
/* Layout */
--max-width: 600px;
}Themes should style these key classes:
| Class | Used In | Purpose |
|---|---|---|
.hero |
index, post, comment | Page header section |
.hero-title |
all | Main page title |
.hero-subtitle |
post, comment | Date/metadata line |
.about |
index | About section wrapper |
.about-content |
index | About section content box |
.recent-posts |
index | Posts listing section |
.recent-comments |
index | Comments listing section |
.section-title |
index | Section headers |
.post-list |
index | Post list container |
.post-item |
index (loop) | Individual post entry |
.post-date |
index | Post date in list |
.post-title |
index | Post title in list |
.post-comments |
index | Comment count in list |
.comment-list |
index | Comment list container |
.comment-item |
index (loop) | Individual comment entry |
.comment-meta |
index | Comment metadata row |
.comment-author |
all | Comment author name |
.comment-date |
all | Comment date |
.comment-preview |
index | Comment preview text |
.post-content |
post, comment | Main content wrapper |
.content-body |
post, comment | Content box with styling |
.reply-context |
comment | "In reply to" section |
.context-box |
comment | Reply context container |
.context-label |
comment | "In reply to:" label |
.context-link |
comment | Link to parent content |
.comments |
post | Blessed comments section |
.comments-title |
post | "Comments (N)" header |
.comments-list |
post | Comment list container |
.comment |
post (inline) | Individual blessed comment |
.comment-header |
post (inline) | Comment header row |
.comment-body |
post (inline) | Comment content |
.site-footer |
all | Page footer |
.footer-logo |
all | Polis branding link |
.footer-tagline |
all | Tagline text |
.view-all |
index | "View all N posts" link |
.post-meta |
post, comment | Signature/metadata block |
.meta-label |
post, comment | "Signed by" etc. |
.meta-value |
post, comment | Author name etc. |
.meta-sep |
post, comment | Separator between meta items |
Include mobile breakpoints. The built-in themes use 600px:
@media (max-width: 600px) {
.hero-title {
font-size: 1.25rem;
}
.footer-logo {
font-size: 1.4rem;
}
}Templates should follow this general structure:
<!--
Polis Theme: [Name] - [Template Type]
Snippets loaded by this template:
- snippet-name - Description
Snippet lookup order: theme snippets -> global snippets
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}} - {{site_title}}</title>
<meta name="description" content="...">
<link rel="stylesheet" href="{{css_path}}">
<!-- Optional: Google Fonts for display font -->
</head>
<body>
<!-- Hero section -->
<section class="hero">...</section>
<!-- Main content -->
<article class="post-content">...</article>
<!-- Comments (for posts) -->
<section class="comments">...</section>
<!-- Footer -->
<footer class="site-footer">
<a href="https://polis.pub" class="footer-logo">POLIS
<span class="footer-tagline">Your content, free from platform control</span>
</a>
</footer>
<!-- Hidden metadata comment -->
<!-- Source: {{url}} | Version: {{version}} -->
</body>
</html>- Keep snippets focused - One purpose per snippet
- Use semantic HTML -
<article>,<section>,<time>where appropriate - Support all loop variables - Don't assume which variables will be present
- Test with empty data - Ensure graceful handling of missing comments/posts
Example well-structured snippet:
<!-- snippets/post-item.html -->
<a href="{{url}}" class="post-item">
<span class="post-date">{{published_human}}</span>
<span class="post-title">{{title}}</span>
<span class="post-comments">{{comment_count}} comments</span>
</a>- Create test content with various lengths
- Test with 0, 1, and many posts/comments
- Test mobile and desktop viewports
- Verify all links work (CSS, internal navigation)
- Check that blessed comments render correctly
# Quick test workflow
echo "active_theme: mytheme" | ... # (edit manifest.json)
polis render --force
python -m http.server 8000 # Preview at localhost:8000We'd appreciate it if you keep the cyan color (#00d4ff) for the footer POLIS logo across custom themes. This helps with consistent branding while you're free to use theme-specific accent colors elsewhere.
If you have a custom .polis/templates/ directory from a previous version:
- Create a new theme:
mkdir -p .polis/themes/custom/snippets - Move templates:
mv .polis/templates/*.html .polis/themes/custom/ - Create CSS:
touch .polis/themes/custom/custom.css - Move relevant snippets to
snippets/(global) or.polis/themes/custom/snippets/(theme-specific) - Set active theme in
manifest.json:"active_theme": "custom" - Remove old directory:
rm -r .polis/templates - Render:
polis render --force