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. {{ content_for_header }} {{ content_for_layout }} {{ content_for_footer }} 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 {{ content_for_header }}
{{ site.display_name }} {% for category in site.nav_categories %} {{ category.name }} {% endfor %}
{{ content_for_layout }} {{ content_for_footer }} templates/home.liquid {% assign lead = featured_posts.first | default: latest_posts.first %} {% if lead %}

{{ lead.title }}

{{ lead.summary }}

{% endif %}

{{ t.latest }}

{% 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

{{ post.title }}

{% if post.author %}

{{ t.by }} {{ post.author.display_name }}

{% endif %}

{{ post.published_at | localized_date: 'long' }}

{% if post.image != blank %}{{ post.title | escape }}{% endif %}
{{ post.body }}{% social_embeds %}{% paywall %}
{% like_button %} {% comments %}
templates/category.liquid

{{ category.name }}

{% for post in paginate.items %} {% render 'card', post: post %} {% else %}

{{ t.no_stories }}

{% endfor %} {% pagination paginate %} snippets/card.liquid
{% if post.image != blank %} {{ post.title | escape }} {% endif %}

{{ post.title }}

{{ post.published_at | localized_date: 'medium' }}

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