ranty
file-based themes

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.

Pro feature: Custom themes are available on the Pro plan.

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

theme/index.liquid — the blog list
<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 %}
theme/post.liquid — a post page
<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:

minimal
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);
}
archive
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);
}
magazine
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:

<script>, on*=, javascript: — removed — no theme JS runs (sanitizer + CSP with nonce)
<iframe>, <form>, <object>, <base>, <meta> — removed
<style> inline in the template — removed — all CSS goes in theme/style.css
unbounded loops/output — render has a timeout (~400ms) and a size cap (2 MB)
← back to ranty.dev made with ranty