Theme development
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.
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.
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.
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.
Worth reading once. Everything else on this page makes more sense afterwards.
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.
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.
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.
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.
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.
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 themeOpen theme.json, change the name, the slug and the version, then edit the templates and the stylesheet.
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'
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.
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.
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
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.
{
"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" }
}
}
}
}
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.
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.
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.
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.
The positions you offer advertisers. Each one you declare appears in the publisher's sponsor screen, and only declared slots ever fill.
The controls a publisher gets under Appearance. Declare none and they get none, beyond text size, which every theme carries.
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.
{{ 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 %}
{% 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 %}
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.
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.
Liquid renders once on the server. Anything interactive is either a platform tag or your own JavaScript in the assets folder.
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.
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>
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
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.
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
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
id, slug, url, name, name_en, display_name, description, image, style, is_promoted, hide_from_home, is_external, posts
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.
kind, title, url, meta, body_html
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.
items, current_page, total_pages, total_items, has_previous, has_next, previous_url, next_url, pages
Interface words in the reader's language. latest, read_more, search, comments, no_results and around forty more, each translated into all four languages.
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.
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
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
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.
/* 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; }
--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.
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.
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.
Put woff2 files in your assets, declare them in the manifest, and use them in a preset like any platform face. See below.
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.
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.
"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" }
}
}
}
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The starter theme is a complete magazine layout with every feature wired up. Change what you like and upload it.