Program your blog.
For full control of the structure, put .liquid templates in the theme/ folder of your repository. You write the HTML for the list and the post in a sandboxed language (Liquid), with {{ variables }} and {% blocks %}. Push it and your live blog starts using them.
01 / repository structure
meu-blog/
posts/
ola-mundo.md # seus posts (markdown + frontmatter)
theme/
index.liquid # a página inicial do blog (lista de posts)
post.liquid # a página de um post
style.css # o CSS do tema
ranty.yml # opcional (metadados do tema)
02 / available variables
| blog.handle | the author's @handle |
| blog.display_name | public name |
| blog.bio | author bio |
| blog.url | blog path — "/@handle" on the platform, "/" on a custom domain |
| posts | list of published posts — iterate with {% for post in posts %} |
| post.title | post title |
| post.slug | post slug |
| post.url | post path — "/@handle/slug" on the platform, "/slug" on a custom domain |
| post.date | publish date (YYYY-MM-DD) |
| post.date_unix | unix timestamp of the publish date (for sorting/formatting with the date filter) |
| post.reading_time | estimated reading minutes |
| post.excerpt | your `excerpt:`/`description:` when set, otherwise a lead from the body |
| post.tags | list of tags from the frontmatter — empty list when there are none |
| post.cover_url | cover image URL — EMPTY STRING when the post has none, so test it before use |
| post.thumb_url | thumbnail URL, falling back to the cover; empty when neither |
| post.body | rendered post HTML — only on the post page |
| site.name | "ranty" |
| site.base_url | "https://ranty.dev" |
Standard Liquid filters are available: upcase, downcase, truncate, date, default, size… No access to files, network or code.
A variable that doesn't exist renders as empty text — no error, no warning on the page. Writing post.cover_image when the field is post.cover_url just leaves a blank space.
After each sync we check your templates against this list and report unknown references, with file and line, under Settings → theme.
Rules that are easy to miss:
Your template renders the body of the page — don't write <html>, <head> or <body>. We wrap it and add your stylesheet.
<link>, <meta>, <style> and <script> are removed from your HTML. All CSS goes in style.css; web fonts must be @imported at the top of it, and only Google Fonts is reachable under the Content Security Policy.
Each tag keeps its normal attributes (href, src, alt, width, colspan…). On top of those, class, id, data-*, role, lang, datetime, title and aria-* work on any tag. Every on* handler is removed, and so is inline style= — except on <span> and <pre>, where syntax highlighting relies on it.
There is no {% include %} — each template is a single file.
03 / examples
<h1>@{{ blog.handle }}</h1>
{% if blog.bio != "" %}<p>{{ blog.bio }}</p>{% endif %}
{% for post in posts %}
<article>
<a href="{{ post.url }}">{{ post.title }}</a>
<small>{{ post.date }} · {{ post.reading_time }} min</small>
</article>
{% else %}
<p>Ainda sem posts.</p>
{% endfor %}
<article>
<h1>{{ post.title }}</h1>
<time>{{ post.date }}</time>
<div class="ry-prose">{{ post.body }}</div>
</article>
Use class="ry-prose" around {{ post.body }} to inherit ranty's typography, and var(--ry-bg), var(--ry-accent)… in your CSS to follow the light/dark theme.
04 / starter themes
Starting from scratch? The starter repository is a complete blog you can fork in one click — plain theme, example posts, and an AGENTS.md carrying this whole contract for coding agents to read.
Official starter themes, one per built-in free template. Copy one into your repository's theme/ folder and make it your own:
index.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="/">ranty</a>
<span class="ry-loc">ranty.dev{{ blog.url }}</span>
</header>
<main class="ry-main">
<section class="ry-author">
<div class="ry-avatar" aria-hidden="true">{{ blog.handle | upcase | slice: 0, 1 }}</div>
<div class="ry-author-info">
<h1 class="ry-h1">@{{ blog.handle }}</h1>
{% if blog.display_name != "" %}<div class="ry-name">{{ blog.display_name }}</div>{% endif %}
{% if blog.bio != "" %}<p class="ry-bio">{{ blog.bio }}</p>{% endif %}
</div>
</section>
<section class="ry-list">
{% for post in posts %}
<article class="ry-item">
<a class="ry-item-link" href="{{ post.url }}">
<div class="ry-meta">{{ post.date }} <span>│</span> {{ post.reading_time }} min</div>
<h2 class="ry-item-title">{{ post.title }}</h2>
<p class="ry-excerpt">{{ post.excerpt }}</p>
</a>
</article>
{% else %}
<p class="ry-empty">Ainda sem posts.</p>
{% endfor %}
</section>
</main>
<footer class="ry-footer">@{{ blog.handle }} · feito com ranty</footer>
</div>
post.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="{{ blog.url }}">◂ @{{ blog.handle }}</a>
<a class="ry-home" href="/">ranty</a>
</header>
<article class="ry-article">
<header class="ry-article-head">
<h1 class="ry-article-title">{{ post.title }}</h1>
<div class="ry-meta">
{{ post.date }} <span>│</span> {{ post.reading_time }} min de leitura <span>│</span> por @{{ blog.handle }}
</div>
</header>
<div class="ry-prose">{{ post.body }}</div>
</article>
<footer class="ry-footer">
<a href="{{ blog.url }}">◂ todos os posts de @{{ blog.handle }}</a>
<span>feito com ranty</span>
</footer>
</div>
style.css
/* Minimal starter theme — mirrors ranty's built-in "minimal" template.
Reuses the platform design tokens (var(--ry-*), defined by the site) and the
shared `.ry-prose` rules for the post body, so it stays theme-aware (light/dark).
Modern/neutral: thin 1px borders, rounded avatars, one accent, generous space. */
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.ry-blog {
min-height: 100vh;
background: var(--ry-bg);
color: var(--ry-fg);
font-family: var(--ry-font-sans, "Inter", ui-sans-serif, system-ui, sans-serif);
}
.ry-header {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 680px;
margin: 0 auto;
padding: 10px 4px;
border-bottom: 1px solid var(--ry-border);
background: color-mix(in srgb, var(--ry-bg) 75%, transparent);
backdrop-filter: blur(12px);
}
.ry-header a {
text-decoration: none;
color: inherit;
}
.ry-brand {
font-weight: 600;
letter-spacing: -0.02em;
}
.ry-loc {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-main {
max-width: 680px;
margin: 0 auto;
padding: 0 4px;
}
.ry-author {
display: flex;
flex-direction: column;
gap: 14px;
padding: 40px 0;
border-bottom: 1px solid var(--ry-border);
}
.ry-avatar {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--ry-border);
border-radius: 999px;
background: var(--ry-surface-2);
font-size: 18px;
font-weight: 600;
}
.ry-author-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.ry-h1 {
margin: 0;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.03em;
}
.ry-name {
font-size: 18px;
font-weight: 600;
}
.ry-bio {
margin: 0;
max-width: 52ch;
line-height: 1.6;
color: var(--ry-muted);
}
.ry-item {
border-bottom: 1px solid var(--ry-border);
}
.ry-item-link {
display: flex;
flex-direction: column;
gap: 6px;
padding: 24px 0;
text-decoration: none;
color: inherit;
}
.ry-meta {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-meta span {
color: var(--ry-faint);
}
.ry-item-title {
margin: 0;
font-size: 21px;
font-weight: 600;
letter-spacing: -0.02em;
}
.ry-excerpt {
margin: 0;
color: var(--ry-muted);
line-height: 1.6;
}
.ry-empty {
padding: 64px 0;
text-align: center;
color: var(--ry-muted);
}
.ry-article {
max-width: 680px;
margin: 0 auto;
padding: 0 4px;
}
.ry-article-head {
padding: 48px 0 28px;
border-bottom: 1px solid var(--ry-border);
}
.ry-article-title {
margin: 0;
font-size: clamp(30px, 5vw, 42px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.08;
}
.ry-article .ry-prose {
padding: 36px 0;
}
.ry-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
max-width: 680px;
margin: 0 auto;
padding: 24px 4px;
border-top: 1px solid var(--ry-border);
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-footer a {
color: var(--ry-muted);
}
index.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="/">ranty</a>
<span class="ry-loc">ranty.dev{{ blog.url }}</span>
</header>
<main class="ry-main">
<section class="ry-author">
<div class="ry-avatar" aria-hidden="true">{{ blog.handle | upcase | slice: 0, 1 }}</div>
<div class="ry-author-info">
<h1 class="ry-h1">@{{ blog.handle }}</h1>
{% if blog.display_name != "" %}<div class="ry-name">{{ blog.display_name }}</div>{% endif %}
{% if blog.bio != "" %}<p class="ry-bio">{{ blog.bio }}</p>{% endif %}
</div>
</section>
<section class="ry-list">
{% assign current_year = "" %}
{% for post in posts %}
{% assign post_year = post.date | slice: 0, 4 %}
{% if post_year != current_year %}
{% assign current_year = post_year %}
<h2 class="ry-year">{{ current_year }}</h2>
{% endif %}
<a class="ry-row" href="{{ post.url }}">
<span class="ry-row-date">{{ post.date | slice: 5, 5 }}</span>
<span class="ry-row-title">{{ post.title }}</span>
<span class="ry-row-time">{{ post.reading_time }} min</span>
</a>
{% else %}
<p class="ry-empty">Ainda sem posts.</p>
{% endfor %}
</section>
</main>
<footer class="ry-footer">@{{ blog.handle }} · feito com ranty</footer>
</div>
post.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="{{ blog.url }}">◂ @{{ blog.handle }}</a>
<a class="ry-home" href="/">ranty</a>
</header>
<article class="ry-article">
<header class="ry-article-head">
<h1 class="ry-article-title">{{ post.title }}</h1>
<div class="ry-meta">{{ post.date }} <span>│</span> {{ post.reading_time }} min de leitura</div>
</header>
<div class="ry-prose">{{ post.body }}</div>
</article>
<footer class="ry-footer">
<a href="{{ blog.url }}">◂ todos os posts de @{{ blog.handle }}</a>
<span>feito com ranty</span>
</footer>
</div>
style.css
/* Archive starter theme — dense, year-grouped list. Same platform tokens as
"minimal" (var(--ry-*)) so light/dark just works; wider column than minimal
since the post page reads as a plain, wide reading column. */
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.ry-blog {
min-height: 100vh;
background: var(--ry-bg);
color: var(--ry-fg);
font-family: var(--ry-font-sans, "Inter", ui-sans-serif, system-ui, sans-serif);
}
.ry-header {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 760px;
margin: 0 auto;
padding: 10px 4px;
border-bottom: 1px solid var(--ry-border);
background: color-mix(in srgb, var(--ry-bg) 75%, transparent);
backdrop-filter: blur(12px);
}
.ry-header a {
text-decoration: none;
color: inherit;
}
.ry-brand {
font-weight: 600;
letter-spacing: -0.02em;
}
.ry-loc {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-main {
max-width: 760px;
margin: 0 auto;
padding: 0 4px;
}
.ry-author {
display: flex;
flex-direction: column;
gap: 14px;
padding: 36px 0;
border-bottom: 1px solid var(--ry-border);
}
.ry-avatar {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--ry-border);
border-radius: 12px;
background: var(--ry-surface-2);
font-size: 16px;
font-weight: 600;
}
.ry-author-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.ry-h1 {
margin: 0;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.03em;
}
.ry-name {
font-size: 16px;
font-weight: 600;
color: var(--ry-muted);
}
.ry-bio {
margin: 0;
max-width: 56ch;
line-height: 1.6;
color: var(--ry-muted);
}
.ry-list {
padding: 8px 0 24px;
}
.ry-year {
margin: 28px 0 4px;
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 13px;
font-weight: 600;
color: var(--ry-accent);
}
.ry-year:first-child {
margin-top: 0;
}
.ry-row {
display: flex;
align-items: baseline;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid var(--ry-border);
text-decoration: none;
color: inherit;
}
.ry-row-date {
flex: 0 0 44px;
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-row-title {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 500;
}
.ry-row-time {
flex: 0 0 auto;
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-empty {
padding: 64px 0;
text-align: center;
color: var(--ry-muted);
}
.ry-article {
max-width: 760px;
margin: 0 auto;
padding: 0 4px;
}
.ry-article-head {
padding: 48px 0 28px;
border-bottom: 1px solid var(--ry-border);
}
.ry-article-title {
margin: 0;
font-size: clamp(30px, 5vw, 42px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.08;
}
.ry-meta {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
margin-top: 12px;
}
.ry-meta span {
color: var(--ry-faint);
}
.ry-article .ry-prose {
padding: 36px 0;
}
.ry-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
max-width: 760px;
margin: 0 auto;
padding: 24px 4px;
border-top: 1px solid var(--ry-border);
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-footer a {
color: var(--ry-muted);
}
index.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="/">ranty</a>
<span class="ry-loc">ranty.dev{{ blog.url }}</span>
</header>
<main class="ry-main">
<section class="ry-author">
<div class="ry-avatar" aria-hidden="true">{{ blog.handle | upcase | slice: 0, 1 }}</div>
<div class="ry-author-info">
<h1 class="ry-h1">{% if blog.display_name != "" %}{{ blog.display_name }}{% else %}@{{ blog.handle }}{% endif %}</h1>
{% if blog.bio != "" %}<p class="ry-bio">{{ blog.bio }}</p>{% endif %}
</div>
</section>
{% for post in posts %}
{% if forloop.first %}
<a class="ry-hero" href="{{ post.url }}">
<div class="ry-hero-meta">{{ post.date }} <span>│</span> {{ post.reading_time }} min</div>
<h2 class="ry-hero-title">{{ post.title }}</h2>
<p class="ry-hero-excerpt">{{ post.excerpt }}</p>
</a>
{% endif %}
{% else %}
<p class="ry-empty">Ainda sem posts.</p>
{% endfor %}
{% assign rest = posts | slice: 1, 500 %}
<div class="ry-grid">
{% for post in rest %}
<a class="ry-card" href="{{ post.url }}">
<div class="ry-card-meta">{{ post.date }} <span>│</span> {{ post.reading_time }} min</div>
<h3 class="ry-card-title">{{ post.title }}</h3>
<p class="ry-card-excerpt">{{ post.excerpt }}</p>
</a>
{% endfor %}
</div>
</main>
<footer class="ry-footer">@{{ blog.handle }} · feito com ranty</footer>
</div>
post.liquid
<div class="ry-blog">
<header class="ry-header">
<a class="ry-brand" href="{{ blog.url }}">◂ @{{ blog.handle }}</a>
<a class="ry-home" href="/">ranty</a>
</header>
<article class="ry-article">
<header class="ry-article-head">
<h1 class="ry-article-title">{{ post.title }}</h1>
<div class="ry-meta">{{ post.date }} <span>│</span> {{ post.reading_time }} min de leitura</div>
</header>
<div class="ry-prose">{{ post.body }}</div>
</article>
<footer class="ry-footer">
<a href="{{ blog.url }}">◂ todos os posts de @{{ blog.handle }}</a>
<span>feito com ranty</span>
</footer>
</div>
style.css
/* Magazine starter theme — hero post + responsive card grid. Same platform
tokens as "minimal" (var(--ry-*)) so light/dark just works; bolder display
type and a wider canvas to fit 2-3 columns of cards. */
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.ry-blog {
min-height: 100vh;
background: var(--ry-bg);
color: var(--ry-fg);
font-family: var(--ry-font-sans, "Inter", ui-sans-serif, system-ui, sans-serif);
}
.ry-header {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 1040px;
margin: 0 auto;
padding: 10px 4px;
border-bottom: 1px solid var(--ry-border);
background: color-mix(in srgb, var(--ry-bg) 75%, transparent);
backdrop-filter: blur(12px);
}
.ry-header a {
text-decoration: none;
color: inherit;
}
.ry-brand {
font-weight: 600;
letter-spacing: -0.02em;
}
.ry-loc {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-main {
max-width: 1040px;
margin: 0 auto;
padding: 0 4px;
}
.ry-author {
display: flex;
align-items: center;
gap: 20px;
padding: 40px 0;
border-bottom: 1px solid var(--ry-border);
}
.ry-avatar {
width: 64px;
height: 64px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--ry-border);
border-radius: 18px;
background: var(--ry-surface-2);
font-size: 22px;
font-weight: 700;
}
.ry-author-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.ry-h1 {
margin: 0;
font-size: clamp(30px, 5vw, 40px);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1;
}
.ry-bio {
margin: 0;
max-width: 60ch;
line-height: 1.6;
color: var(--ry-muted);
}
.ry-hero {
display: flex;
flex-direction: column;
gap: 10px;
padding: 32px 0;
border-bottom: 1px solid var(--ry-border);
text-decoration: none;
color: inherit;
}
.ry-hero-meta,
.ry-card-meta {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-hero-meta span,
.ry-card-meta span {
color: var(--ry-faint);
}
.ry-hero-title {
margin: 0;
font-size: clamp(28px, 4.5vw, 40px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
}
.ry-hero-excerpt {
margin: 0;
max-width: 72ch;
font-size: 16px;
line-height: 1.6;
color: var(--ry-muted);
}
.ry-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
padding: 28px 0 40px;
}
@media (min-width: 640px) {
.ry-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 980px) {
.ry-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.ry-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
border: 1px solid var(--ry-border);
border-radius: 12px;
background: var(--ry-surface-2);
text-decoration: none;
color: inherit;
}
.ry-card-title {
margin: 0;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.ry-card-excerpt {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--ry-muted);
}
.ry-empty {
padding: 64px 0;
text-align: center;
color: var(--ry-muted);
}
.ry-article {
max-width: 720px;
margin: 0 auto;
padding: 0 4px;
}
.ry-article-head {
padding: 48px 0 28px;
border-bottom: 1px solid var(--ry-border);
}
.ry-article-title {
margin: 0;
font-size: clamp(32px, 6vw, 48px);
font-weight: 800;
letter-spacing: -0.04em;
line-height: 1.05;
}
.ry-meta {
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
margin-top: 14px;
}
.ry-meta span {
color: var(--ry-faint);
}
.ry-article .ry-prose {
padding: 36px 0;
}
.ry-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
max-width: 1040px;
margin: 0 auto;
padding: 24px 4px;
border-top: 1px solid var(--ry-border);
font-family: var(--ry-font-mono, ui-monospace, monospace);
font-size: 12px;
color: var(--ry-faint);
}
.ry-footer a {
color: var(--ry-muted);
}
05 / preview locally
Run the same production engine on your machine and see the blog before you push:
$ mix ranty.preview --path ./meu-blog
▸ http://localhost:4001 (recarrega ao salvar)
06 / security limits
Your blog is served on the same domain as everyone else's, so the theme runs in a sandbox. What's blocked, and why: