Theme development

Design a theme, upload a zip, watch it go live

A Fanvaiy theme is a folder of Liquid templates, one stylesheet and a small manifest. Zip it, upload it in the dashboard, and it renders the publication's real content. No build step, no framework, no server code. Comments, likes, ads, paywalls and polls come from the platform, and the same design serves English, Dhivehi, Hindi and Sinhala without you writing a word of any of them.

Working with an assistant? Point it at fanvaiy.com/developers/themes/spec.txt, the whole contract in plain text, generated from the running platform. There are ready made prompts further down.

Liquid, not code

Templates are Liquid, the language Shopify themes use. It cannot reach the database, the filesystem or the network, so a theme can never break anything outside its own page.

One design, four languages

Fonts, line height, reading direction and every interface label come from the platform. Write the theme once in English and it works on a Dhivehi newsroom.

Hard parts included

Comments, likes, paywalls, polls with phone verification, ad slots, article audio and social embeds are one tag each. You style them, we keep them working.

How a page is put together

Worth reading once. Everything else on this page makes more sense afterwards.

  1. 1

    A reader asks for a page

    The platform works out which publication and which kind of page, then loads exactly the stories, categories and settings that page needs. Your theme is never asked for a query and never gets to make one.

  2. 2

    Your page template runs

    One of home, story, category and the rest. It reads the lists it was handed, calls your snippets for repeated pieces, and produces the body of the page.

  3. 3

    Your layout wraps it

    The layout runs once and prints three things the platform hands it. The head content carries meta tags, fonts, and the colour and font variables for this publisher and this language. The layout content is what step two produced. The footer content carries the scripts any platform tag on the page needs.

  4. 4

    Your stylesheet paints it

    Loaded from the CDN next to your other assets. It reads the variables rather than naming colours and typefaces, which is how one theme can look different on two publications and read correctly in four languages.

The one rule worth remembering

A theme decides arrangement and appearance. The platform decides content, language and behaviour. Every restriction on this page follows from that split, and so does every convenience.

Start here

  1. 1

    Take the starter theme

    It is a complete, working theme with every feature wired up, which is far easier to edit than a blank folder is to fill.

    Download the starter theme
  2. 2

    Rename it and make it yours

    Open theme.json, change the name, the slug and the version, then edit the templates and the stylesheet.

  3. 3

    Zip the folder contents

    Zip from inside the folder so theme.json sits at the top. A single wrapping folder is accepted too.

    zip -r my-theme.zip . -x '.DS_Store' '*/.DS_Store'
  4. 4

    Upload it in the dashboard

    Open Custom theme in the sidebar and drop the zip in. It is checked before it is stored, and every problem is listed with the file and the line. Preview it against sample content, then activate it when it looks right.

What goes in the zip

Six files are required. Everything else is optional, and any page template you leave out is rendered by a plain platform version inside your own layout, so a theme with three templates still serves every page.

Package layout
theme.json                 required   name, version, languages, the settings you offer
layout.liquid              required   the HTML document every page is rendered into
templates/home.liquid      required
templates/story.liquid     required
templates/category.liquid  required
templates/search.liquid    optional   platform version used when you leave it out
templates/gallery.liquid   optional
templates/poll.liquid      optional
templates/page.liquid      optional   privacy, terms, code of conduct
snippets/*.liquid          optional   pieces you reuse, called with render
assets/theme.css           required   your stylesheet
assets/theme.js            optional   your scripts
assets/**                  optional   images, fonts, anything the page loads
locales/en.json            optional   your own wording, per language
screenshot.png             required   what the dashboard shows on the card

Limits

Zip size 8 MB, unpacking to no more than 24 MB
Files 300, none larger than 4 MB
File types liquid, json, css, js, map, png, jpg, webp, avif, gif, svg, ico, woff, woff2, md, txt
Folders templates, snippets, assets, locales

theme.json

The manifest names your theme, says which languages you have actually checked it in, declares the tags and ad slots it uses, and describes the settings a publisher may change. Key order matters, so the first font preset a language lists is that language's default.

theme.json
{
  "schema": 1,
  "name": "Harbour",
  "slug": "harbour",
  "version": "1.0.0",
  "description": "A quiet, image led magazine layout.",
  "author": { "name": "Studio Example", "url": "https://example.com" },

  "languages": ["en", "dv"],

  "system_tags": ["editorschoice", "longreads"],
  "category_styles": ["feature", "compact"],
  "ad_slots": ["HOME_TOP_BANNER", "CATEGORY_TOP_BANNER", "POST_TOP_BANNER"],

  "customization": {
    "colors": {
      "ink":     { "label": "Headlines and links", "default": "#111827" },
      "accent":  { "label": "Accent",              "default": "#B91C1C" },
      "border":  { "label": "Hairlines",           "default": "#E5E7EB" }
    },
    "typography": {
      "en": {
        "editorial": { "label": "Editorial", "display": "sentient", "body": "manrope" },
        "modern":    { "label": "Modern",    "display": "inter",    "body": "manrope" }
      },
      "dv": {
        "traditional": { "label": "Traditional", "display": "waheed", "body": "rasmee" }
      }
    },
    "layout": {
      "story_rail": {
        "label": "Story page",
        "default": "none",
        "options": { "none": "Single column", "latest": "Latest news rail" }
      }
    }
  }
}

languages

The languages you have looked at, from en, dv, hi and si. A publisher can only activate your theme on a site whose language you list, so claim one only when you have previewed it.

version

Three numbers, and each upload must be higher than the last. A stored version is never changed, so a fix is a new version rather than a re-upload.

system_tags

Tags an editor can put on a story, which you then read as a ready made list. Declare editorschoice and your home page can carry an editor's choice section.

category_styles

Names a publisher can assign to a category, reaching your template as category.style, so one category can be laid out differently from the rest.

ad_slots

The positions you offer advertisers. Each one you declare appears in the publisher's sponsor screen, and only declared slots ever fill.

customization

The controls a publisher gets under Appearance. Declare none and they get none, beyond text size, which every theme carries.

Liquid in two minutes

If you have written a Shopify theme you already know this. If not, there are only two kinds of marking. Double braces print a value. Brace percent does something and prints nothing by itself.

The whole language, more or less
{{ post.title }}                     print a value
{{ post.summary | truncate: 120 }}   print it through a filter
{{ post.published_at | localized_date: 'medium' }}

{% if post.image != blank %}         a tag does something, it prints nothing itself
  <img src="{{ post.image }}" alt="{{ post.title | escape }}">
{% endif %}

{% for post in latest_posts limit: 6 %}
  {{ forloop.index }} of {{ forloop.length }}
  {% if post.is_paid %}{% continue %}{% endif %}
  {% render 'card', post: post %}
{% else %}
  <p>{{ t.no_stories }}</p>
{% endfor %}

{% assign lead = featured_posts.first | default: latest_posts.first %}
{% comment %} notes for yourself, never printed {% endcomment %}

Four things that trip people up

{% comment %} A snippet sees only what you hand it. {% endcomment %}
{% render 'card', post: post, show_summary: true %}

{% comment %} Inside snippets/card.liquid, post and show_summary exist.
   latest_posts and site.name do not, except site, theme, page, t and
   request, which are global everywhere. {% endcomment %}

{% comment %} Missing things are empty, never an error. {% endcomment %}
{{ post.nonexistent }}               prints nothing
{% if post.author %}                 false when the byline is hidden

{% comment %} blank matches nil, false, "" and an empty list. {% endcomment %}
{% if post.image != blank %}         the right way to test an image
{% if post.image %}                  wrong, an empty string is truthy in Liquid

{% comment %} Lists are already sized. Slice, do not ask for more. {% endcomment %}
{% for post in latest_posts limit: 4 offset: 1 %}

Nothing you print is escaped

Story bodies are meant to be HTML and arrive ready to print. Put the escape filter on anything that goes inside an attribute, such as an image alt or a title.

Snippets are isolated

A snippet sees the globals and whatever you hand it, nothing else. That is a feature. It means a card snippet works on the home page, a category page and a search page without knowing which it is on.

There are no partial reloads

Liquid renders once on the server. Anything interactive is either a platform tag or your own JavaScript in the assets folder.

Loops carry a counter

Inside a for loop, forloop.index, forloop.first, forloop.last and forloop.length are all available, which is usually enough to make the first card in a grid larger than the rest.

Templates

Your layout is rendered once per page, with the page's own template already rendered into it. Print the three content variables and the head and foot are handled for you, including meta tags, fonts, colours and the scripts any platform tag on the page needs.

<!DOCTYPE html>
<html lang="{{ site.lang }}" dir="{{ site.direction }}">
<head>
{{ content_for_header }}
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}">
</head>
<body>
{{ content_for_layout }}
{{ content_for_footer }}
</body>
</html>

What each template receives

home featured_posts, latest_posts, categories with their posts, tag_posts by tag name, galleries, polls
story post, latest_posts, related_posts, related_categories, comments
category category, paginate with its items and page links
search query, results, results_count
gallery gallery with its images and captions
poll poll with its options and vote counts
page page.body_html, with page.kind telling you which of the three legal pages it is

Objects

Available on every page are site, theme, page, t and request. Anything you read that does not exist renders as nothing rather than an error, so a missing image never takes a page down.

post

id, url, title, title_latin, summary, body, image, image_caption, video_url, youtube_id, published_at, updated_at, likes, likes_label, is_featured, paywalled, has_audio, tags, author, category, related_categories, comments_count

site

name, name_en, display_name, slogan, description, logo, favicon, color, address, phone, email, social with its handles and urls, lang, direction, is_rtl, categories, nav_categories, has_newsletter, remove_branding

category

id, slug, url, name, name_en, display_name, description, image, style, is_promoted, hide_from_home, is_external, posts

author

name, name_en, display_name, picture, profile. It is empty when the editor has hidden the byline, so testing for the author is all you need.

page

kind, title, url, meta, body_html

theme

The publisher's saved settings. theme.layout with your own option keys is the one you will reach for, since it lets a control in Appearance change the design.

paginate

items, current_page, total_pages, total_items, has_previous, has_next, previous_url, next_url, pages

t

Interface words in the reader's language. latest, read_more, search, comments, no_results and around forty more, each translated into all four languages.

Tags and filters

Each tag prints a working feature you would otherwise have to build and secure. Place it where you want it and style it with the class it carries. Every standard Liquid filter is available as well.

Tags

comments The moderated comment form and the thread, with replies
like_button The like button and its count, in the site's brand colour
paywall Prints nothing unless the story is paid and unpurchased, then the price and the buy button
poll_widget poll Results, options and the phone verified voting flow
newsletter The subscribe form, when the publisher has turned it on
audio_button The narration player, when the story has audio
pagination paginate Page links, if you would rather not build them from the paginate object
social_embeds Loads the X, Facebook or Instagram script only when the story body has one
search_form A ready made search field, or write your own form to /search
render 'name' Your own snippet, with the values you pass it

Filters

asset_url A file from your assets folder, served from the CDN
localized_date short, medium, long, full, datetime, time, year or iso, formatted for the reader's language
ad_slot A booked ad for one of your declared slots, or nothing when none is booked. Pass a category to target it
t One interface word, for when the key is computed
excerpt Plain text from an HTML field, cut to a number of words
is_thaana Whether a run of text is written in Thaana, for content whose language is not the site's

Languages and fonts

Never name a typeface in your stylesheet. The platform sets the face, the line height and the reading direction for the reader's language and for the preset the publisher chose, from faces it has licensed and tested for each script. That is the whole reason one theme can serve a Latin magazine and a Thaana newsroom.

assets/theme.css
/* Colours come from the names you declared in theme.json.
   Fonts come from the platform, per language and per reader choice.
   Never name a typeface here, or the theme stops working in Thaana. */

body {
  background: var(--site-page);
  color: var(--site-body);
  font-family: var(--font-body);
  line-height: var(--leading-body);
}

h1, h2, h3 {
  font-family: var(--font-display);
  line-height: var(--leading-display);
  color: var(--site-ink);
}

a:hover { color: var(--site-accent); }

/* Logical properties mirror themselves on Dhivehi sites.
   Use these instead of margin-left, padding-right, text-align: left. */
.card { padding-inline-start: 1rem; border-inline-start: 1px solid var(--site-border); }

/* Dates and numbers stay Latin inside a Thaana page. */
.meta { font-family: var(--font-latin); direction: ltr; unicode-bidi: isolate; }

Variables you read

--font-display and --font-body for type, --font-latin for dates and numbers inside a non Latin page, --leading-display and --leading-body for line height, and one --site- variable for each colour you declared.

Faces you can offer

Dhivehi has Waheed, Rasmee, Magey Huseynu, Ammu and Midhili Bold. English has Manrope, Sentient, Inter, Poppins and Noto Serif. Hindi has Poppins and Sinhala has Noto Sans Sinhala.

Write for both directions

Use padding-inline-start rather than padding-left, and text-align start rather than left. Your layout then mirrors itself on a Dhivehi site instead of breaking. The check warns you about each one it finds.

Ship your own face

Put woff2 files in your assets, declare them in the manifest, and use them in a preset like any platform face. See below.

Words, not just letters

Take every label from t rather than typing it. Your English theme then reads correctly in Dhivehi. Add a locales file if you want your own wording.

Fonts you ship yourself

A face the platform does not carry can be part of the package and offered as a real typography preset, not just used as decoration in your stylesheet. Declare which scripts it can draw, and the platform writes the font-face rules, serves the files from the CDN, and shows the face to the publisher in Appearance.

theme.json
"fonts": {
  "harbour": {
    "label": "Harbour",
    "scripts": ["en"],
    "files": [
      { "src": "fonts/harbour.woff2",      "weight": "400", "style": "normal" },
      { "src": "fonts/harbour-bold.woff2", "weight": "700", "style": "normal" }
    ]
  }
},

"customization": {
  "typography": {
    "en": {
      "harbour":   { "label": "Harbour",   "display": "harbour",  "body": "manrope" },
      "editorial": { "label": "Editorial", "display": "sentient", "body": "manrope" }
    }
  }
}

Declare the scripts honestly

A face offered for Thaana that has no Thaana glyphs does not fail loudly. The reader simply gets whatever their device has, and the publisher never finds out why their site looks wrong. The check warns on every non Latin script you claim.

There is always a fallback

The platform appends its own face for the same script behind yours, so a missing glyph lands on something that can draw it rather than on a system default.

woff2, and mind the budget

Only woff2 and woff are accepted, and every file counts against the package size. Subset the face to the characters you need and ship the weights you actually use.

The licence is yours

Shipping a face in a package puts it on every site that installs the theme, so make sure your licence covers webfont use and redistribution.

What the publisher can change

You decide. The customization block in your manifest becomes the publisher's Appearance screen, so a theme can offer one accent colour or a full palette, and nothing you do not declare can be changed.

Colours

Each one you name gets a label, a default and a swatch, and arrives in your stylesheet as a variable. Leave the block out and the palette is fixed.

Font presets

A named pairing of a display face and a body face, per language. The first you list is the default. Leave them out and the platform offers its own.

Layout choices

Your own control with your own options, reaching your templates as theme.layout, so a publisher can switch a rail on or change a home page arrangement.

Choices are saved against your theme rather than a version, so an upgrade keeps a publisher's palette, and a colour you remove in a later version is simply dropped. Text size is the one control the platform adds to every theme, because it scales the root font size that any design in rem follows.

Build it with AI

Copy a prompt into Claude Code, Cursor, or whichever assistant you use. Each one carries the whole specification, so the assistant works from the real object names, filters and rules instead of inventing them. Start from the downloaded starter theme and it has working code to change rather than a blank folder to fill.

Every prompt carries the full specification. An assistant with web access can read it at spec.txt instead.

A theme from a description

Describe the publication and the look you want. The assistant writes the whole package.

Build a complete Fanvaiy theme package.

The publication is a [describe it, for example a weekly business review in English] and the look should be [describe it, for example restrained and typographic, one strong lead story, hairline rules, no shadows].

Produce every file of the package, ready to zip: theme.json, layout.liquid, the page templates, any snippets, and assets/theme.css. Follow the specification below exactly. Do not invent objects, filters or tags that are not in it. Use the platform tags for comments, likes, the paywall and polls rather than writing your own. Take every interface label from t. Read colours and fonts from the CSS variables and never name a typeface in the stylesheet.

---

FANVAIY THEME SPECIFICATION
Version 1. Generated 2026-09-05 from the running platform, so every
name, limit and font below is what the validator and the renderer actually use.

Canonical URL   https://fanvaiy.com/developers/themes/spec.txt
Human page      https://fanvaiy.com/developers/themes
Starter theme   https://fanvaiy.com/developers/themes/starter.zip

WHAT A THEME IS

A zip. Inside it are Liquid templates, one CSS file, a JSON manifest, and
whatever images, fonts and scripts the design needs. A publisher uploads it in
their dashboard, the platform checks it, and their site renders it.

Liquid is the template language Shopify themes use. It cannot reach a database,
a file or the network. A template is handed the lists it needs and prints them.

A theme never queries, never authenticates, never names a font family and never
writes an interface label in one language. Those four things are the platform's,
which is what lets one theme serve four languages and every publisher's data.

PACKAGE LAYOUT

  theme.json                 required
  layout.liquid              required
  templates/home.liquid      required
  templates/story.liquid     required
  templates/category.liquid  required
  templates/search.liquid    optional
  templates/gallery.liquid   optional
  templates/poll.liquid      optional
  templates/page.liquid      optional, used for privacy, terms and code of conduct
  snippets/NAME.liquid       optional, rendered with the render tag
  assets/theme.css           required
  assets/*                   optional
  locales/LANG.json          optional, your own wording per language
  screenshot.png             required

An optional template you leave out is rendered by a plain platform version
inside your own layout, so a package with three templates still serves the
whole site.

Zip from inside the folder so theme.json is at the top. One wrapping folder is
also accepted.

LIMITS
  Zip size              8 MB
  Unpacked size         24 MB
  Files                 300
  Single file           4 MB
  Themes per site       5

Versions per theme are unlimited. The cap is on distinct themes, so uploading a
higher version of a theme a site already holds is always allowed.

ALLOWED FILE TYPES
  liquid, json, css, js, map, png, jpg, jpeg, webp, avif, gif, svg, ico, woff, woff2, md, txt

ALLOWED FOLDERS
  templates, snippets, assets, locales

LANGUAGES
  en, dv, hi, si

MANIFEST, theme.json


{
  "schema": 1,
  "name": "Harbour",
  "slug": "harbour",
  "version": "1.0.0",
  "description": "A quiet, image led magazine layout.",
  "author": { "name": "Studio Example", "url": "https://example.com" },
  "languages": ["en", "dv"],
  "system_tags": ["editorschoice", "longreads"],
  "category_styles": ["feature", "compact"],
  "ad_slots": ["HOME_TOP_BANNER", "CATEGORY_TOP_BANNER", "POST_TOP_BANNER"],
  "customization": {
    "colors": {
      "ink":    { "label": "Headlines and links", "default": "#111827" },
      "accent": { "label": "Accent",              "default": "#B91C1C" }
    },
    "typography": {
      "en": {
        "editorial": { "label": "Editorial", "display": "sentient", "body": "manrope" },
        "modern":    { "label": "Modern",    "display": "inter",    "body": "manrope" }
      }
    },
    "layout": {
      "story_rail": {
        "label": "Story page",
        "default": "none",
        "options": { "none": "Single column", "latest": "Latest news rail" }
      }
    }
  }
}


MANIFEST FIELDS

  schema           Always 1.
  name             Shown to publishers. Up to 80 characters.
  slug             Lowercase letters, digits and hyphens. Identifies the theme
                   across versions, so keep it the same when you release an
                   update. Defaults to a slug of the name.
  version          Three numbers. Each upload must be higher than the last
                   upload of the same slug. A stored version never changes.
  languages        Which of the platform languages you have actually previewed.
                   A publisher can only activate the theme on a site whose
                   language you list, and the check renders every page in every
                   language you claim.
  system_tags      Tags an editor can put on a story. Each one you declare
                   becomes tag_posts.THAT_TAG in your home template.
  category_styles  Names a publisher can assign to a category. Reaches your
                   template as category.style.
  fonts            Faces the package ships itself. See FONTS below.
  ad_slots         Positions you offer advertisers. Only declared slots appear
                   in the publisher's sponsor screen, and the ad_slot filter
                   returns nothing for a slot you did not declare. Uppercase,
                   digits and underscores.
  customization    The publisher's Appearance controls. See SETTINGS below.

Key order is preserved and meaningful. The first font preset a language lists is
that language's default.

LAYOUT

Rendered once per page, with the page template already rendered into it.


<!DOCTYPE html>
<html lang="{{ site.lang }}" dir="{{ site.direction }}">
<head>
{{ content_for_header }}
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}">
</head>
<body>
{{ content_for_layout }}
{{ content_for_footer }}
</body>
</html>


  content_for_header   Meta and Open Graph tags, fonts, the colour and font
                       variables, analytics. Print it once inside head.
  content_for_layout   The page template.
  content_for_footer   Scripts the platform tags on this page need. Print it
                       once before the closing body tag.

All three are required exactly once. The check refuses a layout without them.

TEMPLATE VARIABLES

  home       featured_posts, latest_posts, categories, tag_posts, galleries, polls
  story      post, latest_posts, related_posts, related_categories, comments
  category   category, paginate
  search     query, results, results_count
  gallery    gallery
  poll       poll
  page       page.body_html, with page.kind saying which legal page it is

Lists arrive ready and sized. featured_posts and latest_posts hold up to 12,
each tag_posts entry up to 6, categories carry up to 8 posts each, and a
category page paginates 20 at a time. Slice what you need in the template.

GLOBAL OBJECTS

site
  id, name, name_en, display_name, slogan, description, logo, favicon,
  color, contrast_color, address, phone, email,
  social.twitter, social.facebook, social.instagram,
  social.twitter_url, social.facebook_url, social.instagram_url,
  lang, direction, is_rtl, url, categories, nav_categories,
  has_newsletter, remove_branding,
  privacy_html, terms_html, code_of_conduct_html, og.title, og.description,
  og.image, og.keywords

theme
  colors.KEY, layout.KEY, typography, scale, name, version
  The publisher's saved settings. theme.layout.KEY is the one to branch on.

page
  kind, title, url, description, meta, body_html
  kind is one of home, story, category, search, gallery, poll, privacy, terms,
  code_of_conduct

t
  Interface words in the reader's language. Any key resolves.
  Keys: home, latest, latest_news, featured, news, editors_choice, long_reads, people, podcast, view_all, more_in, more_stories, read_more, no_stories, galleries, gallery, no_images, polls, poll, votes, search, search_placeholder, search_results_for, results_count, no_results, privacy, privacy_policy, terms, terms_of_service, terms_and_conditions, code_of_conduct, open_menu, close_menu, menu, by, comments, anonymous, copyright, all_rights_reserved, made_with, page, previous, next, advertisement, share, listen, published, updated, related, categories, follow_us, back_to_home, not_found

request
  path, url, query.NAME, is_preview

CONTENT OBJECTS

post
  id, url, title, title_latin, summary, body, image, image_caption,
  video_url, youtube_id, published_at, updated_at, likes, likes_label,
  is_featured, is_paid, paywalled, has_audio, audio_url, tags,
  author, category, related_categories, comments_count

  body is ready to print. Link cards are already expanded, and a paywalled
  story is already cut to its preview.
  author is empty when the editor hid the byline, so testing for it is enough.

author
  name, name_en, display_name, picture,
  profile.username, profile.bio, profile.url, profile.is_public

category
  id, slug, url, name, name_en, display_name, description, image, style,
  is_promoted, hide_from_home, is_external, posts, posts_count

  url is the external link when the category has one. posts is filled on the
  home page only.

comment
  id, name, content, created_at, time_ago, avatar, is_reply, replies,
  replies_count

gallery
  id, url, title, summary, published_at, cover, images, images_count
  each image has url and caption

poll
  id, url, question, description, image, votes_count, options, can_vote,
  is_active
  each option has id, text, votes, percentage

paginate
  items, current_page, total_pages, total_items, per_page, has_previous,
  has_next, has_pages, previous_url, next_url, first_url, last_url, pages
  each page has number, url, is_current, is_gap

ad, returned by the ad_slot filter
  html, width, height, type. Printing the object prints the ad.

TAGS

Each prints a working platform feature. Style it with the class it carries.
  comments              The moderated comment form and the thread, on a story.
  like_button           The like button and its count.
  paywall               Prints nothing unless the story is paid and unpurchased.
  newsletter            The subscribe form, when the publisher has turned it on.
  poll_widget           A poll with its results and the verified voting flow. Takes the poll.
  audio_button          The narration player, when the story has audio.
  pagination            Page links. Takes the paginate object.
  social_embeds         Loads an embed script only when the story body has one.
  search_form           A ready made search form posting to /search.

Snippets use the standard Liquid render tag. include is not supported.

  {% render 'card', post: post, show_summary: true %}


FILTERS
  ad_slot               A booked ad for a declared slot, or nothing. Takes an optional category.
  asset_url             A file from the package assets, on the CDN.
  excerpt               Plain text from HTML, cut to a number of words.
  image_url             Reserved for resizing. Returns the URL unchanged today.
  is_thaana             Whether a run of text is written in Thaana.
  localized_date        A date in the reader language. Takes a style.
  platform_asset_url    A file the platform serves.
  t                     One interface word, for a computed key.

Every standard Liquid filter is available as well, including date, size, first,
last, where, map, sort, reverse, truncate, truncatewords, strip_html, escape,
url_encode, default, replace, append, prepend, upcase, downcase, join, split,
plus, minus, times, divided_by, round and json.

localized_date styles
  short      09 Mar 2026
  medium     Mar 9, 2026
  long       09 March 2026
  full       March 9, 2026
  datetime   09 March 2026, 14:05
  time       14:05
  year       2026
  iso        for meta tags

CSS VARIABLES

Read these in theme.css. Never name a typeface, and never hard code a colour a
publisher can change.

  --site-KEY          One for every colour declared in the manifest.
  --site-type-scale   The reader's text size, applied to the root font size.
  --font-display      Display face for the reader's language and chosen preset.
  --font-body         Body face for the same.
  --font-latin        A Latin face for dates and numbers inside a non Latin page.
  --leading-display   Line height for display text in this language.
  --leading-body      Line height for body text in this language.

PLATFORM FACES, USABLE IN ANY THEME
  dv    waheed, rasmee, mageyhuseynu, ammu, midhilibold
  en    manrope, sentient, inter, poppins, noto, sans, serif
  hi    poppins
  si    notosinhala

FONTS THE PACKAGE SHIPS

A theme can carry its own face and offer it as a real preset, not only as
decoration in the stylesheet. Put the files in assets and declare them.


"fonts": {
  "harbour": {
    "label": "Harbour",
    "scripts": ["en"],
    "files": [
      { "src": "fonts/harbour.woff2",      "weight": "400", "style": "normal" },
      { "src": "fonts/harbour-bold.woff2", "weight": "700", "style": "normal" }
    ]
  }
},
"customization": {
  "typography": {
    "en": {
      "harbour": { "label": "Harbour", "display": "harbour", "body": "manrope" }
    }
  }
}


  Ids are lowercase letters, digits and hyphens, and can then be used as a
  display or body face in any preset for a script the font declares.
  scripts says which of the platform languages the face can actually draw. It
  is not a preference: a face offered for Thaana without Thaana glyphs gives
  the reader a silent system fallback, which is worse than not offering it.
  files must be .woff2 or .woff and must exist in assets. The platform writes
  the font-face rules, serves the files from the CDN, and appends the platform
  face for the same script as a fallback.
  A theme font is also what the publisher sees in Appearance, drawn in the
  real face.
  Licensing the face for webfont use is the designer's responsibility.

SETTINGS, what the publisher can change

The customization block in the manifest becomes the Appearance screen.

  colors      Each key gets a label, a default and a colour picker, and arrives
              in your CSS as --site-KEY. Declare none for a fixed palette.
  typography  A named pairing of a display face and a body face, per language,
              from the font list above. The first listed is the default. Declare
              none and the platform offers its own presets.
  layout      Your own control. A key, a label, an options map and a default.
              The chosen option id reaches templates as theme.layout.KEY.

Text size is added by the platform to every theme and cannot be removed.

Choices are saved against the theme rather than the version, so an upgrade keeps
a publisher's palette. A colour you drop in a later version is simply forgotten.

WRITING FOR FOUR LANGUAGES

  Direction   The layout sets dir from site.direction. Use logical CSS
              (padding-inline-start, text-align: start, border-inline-end) so
              the design mirrors itself on a Dhivehi site. The check warns on
              every physical property it finds.
  Words       Take labels from t rather than typing them. Add locales/LANG.json
              to override any of them with your own wording.
  Dates       Always through localized_date.
  Names       Use display_name on site, author and category, which picks the
              native name on a Dhivehi site and the Latin one elsewhere.

WHAT THE CHECK LOOKS AT

Errors block an upload. Warnings are shown and can be ignored.

  Errors     Missing required files, a folder or file type not on the list,
             a path outside the layout, a manifest that does not match this
             spec, a version not higher than the last, a Liquid syntax error,
             a render tag pointing at a snippet that does not exist, a layout
             missing one of the three content variables, use of include, and
             any failure while rendering every page in every declared language
             with strict variables on.
  Warnings   An ad_slot used but not declared, a tag_posts key not declared,
             physical direction properties in CSS on a theme claiming Dhivehi,
             a missing screenshot.

RENDERING RULES

  A property that does not exist prints as nothing rather than raising an error.
  blank and empty both match nil, false, an empty string and an empty list.
  Templates are compiled once per version and cached, so a custom theme costs
  no more per request than a built in one.
  Assets are served from a CDN with a one year cache.
  If a custom theme ever fails to render, the site falls back to the built in
  theme the publisher chose, so a reader sees a working page rather than an
  error.

NOT AVAILABLE TO THEMES

  Server code, PHP, database access or queries of any kind.
  Build steps. Ship plain CSS; Tailwind and similar are not run.
  Reader accounts, purchases, or any private field.
  Editing content. A theme presents, it does not write.

MINIMAL WORKING THEME

theme.json

{
  "schema": 1,
  "name": "Minimal",
  "slug": "minimal",
  "version": "1.0.0",
  "languages": ["en"],
  "ad_slots": ["HOME_TOP_BANNER"],
  "customization": {
    "colors": { "ink": { "label": "Text", "default": "#111111" } }
  }
}


layout.liquid

<!DOCTYPE html>
<html lang="{{ site.lang }}" dir="{{ site.direction }}">
<head>
{{ content_for_header }}
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}">
</head>
<body>
<header><a href="/">{{ site.display_name }}</a>
  {% for category in site.nav_categories %}
    <a href="{{ category.url }}">{{ category.name }}</a>
  {% endfor %}
</header>
{{ content_for_layout }}
{{ content_for_footer }}
</body>
</html>


templates/home.liquid

{% assign lead = featured_posts.first | default: latest_posts.first %}
{% if lead %}
  <a href="{{ lead.url }}"><h1>{{ lead.title }}</h1></a>
  <p>{{ lead.summary }}</p>
{% endif %}

<h2>{{ t.latest }}</h2>
{% for post in latest_posts limit: 9 %}
  {% render 'card', post: post %}
{% endfor %}

{% assign banner = 'HOME_TOP_BANNER' | ad_slot %}
{% if banner %}{{ banner }}{% endif %}


templates/story.liquid

<article>
  <h1>{{ post.title }}</h1>
  {% if post.author %}<p>{{ t.by }} {{ post.author.display_name }}</p>{% endif %}
  <p>{{ post.published_at | localized_date: 'long' }}</p>
  {% if post.image != blank %}<img src="{{ post.image }}" alt="{{ post.title | escape }}">{% endif %}
  <div>{{ post.body }}{% social_embeds %}{% paywall %}</div>
  {% like_button %}
  {% comments %}
</article>


templates/category.liquid

<h1>{{ category.name }}</h1>
{% for post in paginate.items %}
  {% render 'card', post: post %}
{% else %}
  <p>{{ t.no_stories }}</p>
{% endfor %}
{% pagination paginate %}


snippets/card.liquid

<article>
  <a href="{{ post.url }}">
    {% if post.image != blank %}
      <img src="{{ post.image }}" alt="{{ post.title | escape }}" loading="lazy">
    {% endif %}
    <h3>{{ post.title }}</h3>
  </a>
  <p>{{ post.published_at | localized_date: 'medium' }}</p>
</article>


assets/theme.css

body {
  color: var(--site-ink);
  font-family: var(--font-body);
  line-height: var(--leading-body);
}
h1, h2, h3 {
  font-family: var(--font-display);
  line-height: var(--leading-display);
}


END OF SPECIFICATION

Read what comes back before you upload it. The specification is accurate, the code an assistant writes from it is still yours to check, and the upload check is the backstop rather than the review.

When the check complains

Every upload is checked before anything is stored, and each problem names the file. The common ones and what they mean.

Required file missing You zipped the folder rather than its contents, so everything sits one level down. Zip from inside the folder, or keep exactly one wrapping folder.
File type not allowed Something in the package is not on the list, often an editor backup, a source map, or a font in a format other than woff2.
layout.liquid must print ... All three content variables are required, once each. Without the footer one, likes, audio and embeds quietly stop working.
renders snippet 'x' but ... does not exist A render tag points at a file that is not in snippets, usually a rename or a typo. Names are case sensitive.
Rendering home (dv) failed at ... The check renders every page in every language you claim, with unknown variables treated as errors. Either the name is wrong or you claimed a language you have not tried. The message carries the template and the line.
version is not newer than ... A stored version never changes, so every upload of the same theme needs a higher number in theme.json.
ad slot 'X' is not declared A warning, not an error. The slot will always be empty because a publisher cannot book it, so either declare it or drop it from the template. The same applies to a tag_posts key.
uses of a physical side property A warning on a theme that claims Dhivehi. Those rules do not mirror, so the design breaks on a right to left site. Swap to the logical property.

Nothing is stored until it passes

A failed upload changes nothing, on your site or anyone else's, so there is no cleaning up to do. Fix what the list says and upload the same version again.

Good to know

Every upload is checked

Files, manifest, template syntax, missing snippets, undeclared slots, direction properties, then a full render of every page in every language you claim. Nothing is stored until it passes.

Versions never change

Once stored, a version is fixed, and a publisher chooses which one is live. That means a bad edit cannot reach a site that is already running your theme.

There is always a way back

Every site keeps a built in template underneath. If a custom theme is switched off, or ever fails to render, the site falls back to it rather than showing an error.

Scripts are allowed

Ship your own JavaScript for menus and interactions. Load it from your assets rather than a third party, since a slow outside host holds up the whole page.

Speed is handled

Templates are compiled once and cached, and your assets are served from a CDN with a one year cache. A custom theme costs no more per request than a built in one.

Five themes to a site

A site keeps up to five distinct themes. Versions are unlimited, so the upload, preview, fix, upload again loop is never in the way; only abandoned themes are, and those can be deleted.

Preview before you commit

Each uploaded version has a preview link that renders sample stories in any language it supports, so a publisher can look before switching their live site.

Not available to themes

Server code No PHP and no database access. Templates read the lists they are given
Build steps Ship plain CSS. Tailwind and other build tools are not run on an uploaded package
Reader data No accounts, purchases or private fields. The paywall tag knows what it needs
Queries Lists arrive ready and sized. Slice them in the template rather than asking for more

Start from something that works

The starter theme is a complete magazine layout with every feature wired up. Change what you like and upload it.