Custom themes
Replace the built-in blog appearance with your own Liquid templates, CSS, and assets. Themes are versioned ZIP packages you develop locally, preview in the browser, and upload from the Theme Editor.
Overview
When a custom theme is active on a blog, Scribbles renders public HTML pages through your templates instead of the built-in theme. Each page type maps to a required Liquid template. Scribbles injects safe public data as Liquid variables, serves your assets from a signed URL, and adds platform features (feeds, analytics, post content styles, comments, and kudos) around your markup.
Custom themes are user-owned and versioned. Uploading a ZIP creates an immutable version. Blogs keep serving the version they were activated on until you activate a newer one. You can preview any version before activating it.
post.content_html. Secrets such as passcodes, API keys, and analytics tokens are never exposed to Liquid.
Gemfile, sample/, and bin/preview when importing.
Quick start
- Open Blog Settings → Theme Editor for a blog you own.
- Download the blank starter ZIP. It includes sample content, starter templates, and a local preview server.
- Unzip, edit templates and assets, then run the preview server:
Local preview
bundle install
bundle exec ruby bin/preview
# Optional: use another port
PORT=4010 bundle exec ruby bin/preview
- Zip the theme folder contents and upload the ZIP from Theme Editor.
- Preview the uploaded version in the editor, then Activate it when ready.
Create upload ZIP
zip -r my-theme.zip README.md theme.yml templates snippets assets sample Gemfile bin
Scribbles ignores development-only files (Gemfile, README.md, sample/, bin/preview) when importing. Only theme.yml, templates, snippets, and assets are stored for production.
When you download the starter from a live blog, sample/blog.json includes that blog's safe preview data (recent posts, pages, categories, and compatible images). Empty blogs get fictional sample content instead.
Package structure
A valid theme ZIP has this layout:
my-theme/
├── theme.yml # Manifest (required)
├── templates/
│ ├── layout.liquid # Wraps every page; receives {{ content }}
│ ├── home.liquid
│ ├── post.liquid
│ ├── page.liquid
│ ├── archive.liquid
│ ├── category.liquid
│ ├── not_found.liquid
│ └── passcode.liquid
├── snippets/ # Reusable partials (optional)
│ └── header.liquid
├── assets/ # CSS, JS, fonts, images (optional)
│ └── theme.css
├── sample/ # Local preview only
│ └── blog.json
├── Gemfile # Local preview only
└── bin/preview # Local preview only
theme.yml
The manifest names your theme and optionally declares external font origins for Content Security Policy.
theme.yml
name: My Theme
external_fonts:
- google-fonts
- bunny-fonts
# Or an explicit HTTPS origin:
# - https://fonts.example.com
Supported external_fonts presets expand to these origins:
google-fonts→https://fonts.googleapis.com,https://fonts.gstatic.combunny-fonts→https://fonts.bunny.net
You may also list explicit HTTPS origins (host only, no path). Invalid entries cause upload rejection.
Templates
Every template is rendered inside layout.liquid. The layout receives a content string containing the rendered page template. Set <title> and meta description in the layout using {{ page_title }} and {{ page_description }}.
| Template | When it renders | Page-specific data |
|---|---|---|
| layout.liquid | Every HTML page | content, all global variables |
| home.liquid | Blog homepage | posts — recent posts for the homepage |
| post.liquid | Individual post | post |
| page.liquid | Static page | post (page stored as a post with is_page) |
| archive.liquid | Archive listing | posts, pagination, search |
| category.liquid | Category listing | category, posts, pagination |
| not_found.liquid | 404 responses | error_message |
| passcode.liquid | Private blog gate | Global variables only. POST a passcode field to /{{ blog.slug }}/check_passcode. |
Snippets
Place reusable partials in snippets/*.liquid. Include them with {% include 'header' %} or {% render 'post_card', post: post %}. Scribbles converts {% render %} to {% include %} internally, so both work.
Every snippet referenced in a template must exist in the package. Upload validation fails on missing snippets.
Assets
Store CSS, JavaScript, images, icons, and font files under assets/. Reference them with the asset_url filter:
Link a stylesheet
<link rel="stylesheet" href="{{ 'assets/theme.css' | asset_url }}">
Liquid variables
These variables are available in every template unless noted. All data is read-only. Use Liquid conditionals for optional values.
Global page variables
| Name | Type | Required | Description |
|---|---|---|---|
| page_title | string | Yes | Suggested HTML title. Scribbles sets this per template (post title, archive title, etc.). |
| page_description | string | Yes | Suggested meta description, truncated from the post or blog about text. |
| error_message | string | No | Human-readable message on not_found.liquid. |
| content | string | Layout only | Rendered page template HTML, injected into layout.liquid. |
blog
Public blog settings, URLs, display flags, and override text. Colour values come from the blog's appearance settings.
| Name | Type | Required | Description |
|---|---|---|---|
| blog.title | string | — | Blog title. |
| blog.slug | string | — | URL slug on scribbles.page. |
| blog.language | string | — | BCP 47 language code (e.g. en). |
| blog.timezone | string | — | IANA timezone identifier. |
| blog.accent | string | — | Accent colour hex (e.g. #0f766e). |
| blog.background_light | string | — | Light-mode background hex without #. |
| blog.background_dark | string | — | Dark-mode background hex without #. |
| blog.text_light | string | — | Light-mode text hex without #. |
| blog.text_dark | string | — | Dark-mode text hex without #. |
| blog.url | string | — | Homepage URL. Root-relative on custom domains; profile URL on scribbles.page. |
| blog.archive_url | string | — | Archive page URL. |
| blog.categories_url | string | — | Categories index URL. |
| blog.feed_url | string | — | Atom feed URL. |
| blog.json_feed_url | string | — | JSON Feed URL. |
| blog.about_html | string | — | Sanitized about HTML. |
| blog.footer_html | string | — | Sanitized footer HTML. |
| blog.image_url | string | No | Blog logo/image URL when set. |
| blog.logo_alt_text | string | No | Alt text for the blog logo. |
| blog.posts_count | integer | — | Count of currently active posts. |
Override text — blog owners can customize these strings in settings:
| Name | Type | Required | Description |
|---|---|---|---|
| blog.archive_title | string | — | Archive page heading. |
| blog.archive_button_text | string | — | Archive link label. |
| blog.back_to_homepage_text | string | — | Back-to-home link label. |
| blog.seen_on_homepage_text | string | — | Seen-on-homepage label. |
| blog.rss_link_text | string | — | RSS subscribe link label. |
| blog.category_feed_text | string | — | Category RSS link label. |
| blog.comments_button_text | string | — | Comments button label. |
| blog.search_placeholder | string | — | Archive search input placeholder. |
| blog.search_button_text | string | — | Archive search submit label. |
| blog.search_clear_text | string | — | Clear search link label. |
| blog.search_not_found_text | string | — | No search results message. |
| blog.search_back_to_archive_text | string | — | Back to archive link after empty search. |
| blog.buttondown_subscribe_text | string | No | Buttondown subscribe label when configured. |
| blog.buttondown_description_text | string | No | Buttondown description when configured. |
Display flags — booleans that mirror built-in theme behaviour. Respect these when building navigation, category badges, and archive features:
| Name | Type | Required | Description |
|---|---|---|---|
| blog.hide_blog_title | boolean | — | Hide the blog title next to the logo. |
| blog.hide_rss_feed_link | boolean | — | Hide RSS links. |
| blog.hide_category_icon | boolean | — | Hide category icons. |
| blog.hide_category_tags_on_posts_list | boolean | — | Hide category badges on post lists. |
| blog.hide_categories_on_archive_page | boolean | — | Hide categories on the archive page. |
| blog.hide_back_to_homepage_link_on_category_page | boolean | — | Hide back-to-home on category pages. |
| blog.hide_category_rss_feed | boolean | — | Hide category RSS links. |
| blog.show_categories_on_homepage | boolean | — | Show categories on the homepage. |
| blog.show_categories_on_archives | boolean | — | Show categories on archive pages. |
| blog.show_search_on_archives | boolean | — | Enable archive search UI. |
| blog.show_expanded_posts | boolean | — | Expanded post layout preference. |
| blog.show_image_previews | boolean | — | Show image previews in lists. |
| blog.show_meta_on_private | boolean | — | Show meta on private posts. |
| blog.show_powered_by | boolean | — | Show powered-by Scribbles badge. |
| blog.enable_lightbox | boolean | — | Enable image lightbox on posts with attachments. |
| blog.simple_menu | boolean | — | Simple menu layout preference. |
| blog.page_width_class | string | — | Built-in width class (for reference if mirroring defaults). |
| blog.content_post_size_class | string | — | Built-in prose size class (for reference). |
| blog.status_lol_address | string | No | Status.lol address when configured. |
| blog.shoutouts_embed_code | string | No | Shoutouts embed code when configured. |
| blog.shoutouts_theme_id | string | No | Shoutouts theme ID when configured. |
| blog.buttondown_username | string | No | Buttondown username when configured. |
post and posts
On post.liquid and page.liquid, post is the current item. On list pages, iterate posts. Each post exposes:
| Name | Type | Required | Description |
|---|---|---|---|
| post.title | string | — | Display title with placeholders resolved. |
| post.plain_title | string | — | Raw title without placeholders. |
| post.url | string | — | Public URL for this post or page. |
| post.canonical_url | string | — | Absolute canonical URL. |
| post.relative_path | string | — | Path such as /post/my-slug or /page/about. |
| post.content_html | string | — | Rendered post body HTML. |
| post.summary | string | No | Plain-text summary. |
| post.excerpt | string | No | Alias for summary. |
| post.description | string | — | Meta description with placeholders resolved. |
| post.published_at | datetime | — | Publication time. |
| post.updated_at | datetime | — | Last update time. |
| post.date_text | string | — | Formatted date respecting blog language, timezone, and date display settings. |
| post.hero_image_url | string | No | Featured list image URL. |
| post.og_image_url | string | No | Open Graph image URL. |
| post.is_page | boolean | — | True for static pages. |
| post.is_scribble | boolean | — | True for scribble/microblog posts. |
| post.is_image_only | boolean | — | True when the post is image-only. |
| post.content_present | boolean | — | True when body or embeds exist. |
| post.has_attachments | boolean | — | True when the post has image attachments. |
| post.category | category | No | Category object when assigned. |
| post.comments_enabled | boolean | — | True when Komments comments are available. |
| post.comments_url | string | No | Komments URL when enabled. |
| post.microblog_conversation_url | string | No | Microblog conversation URL for non-page posts. |
| post.scribble_url | string | No | Send-a-scribble URL when enabled. |
| post.contact_form_enabled | boolean | — | True when the post exposes a contact form. |
| post.kudos_symbol | string | No | Tinylytics kudos symbol when analytics is configured. |
category, categories, and collections
| Name | Type | Required | Description |
|---|---|---|---|
| category.name | string | — | Category display name. |
| category.slug | string | — | URL slug. |
| category.url | string | — | Category page URL. |
| category.feed_url | string | — | Category Atom feed URL. |
| category.accent | string | — | Accent colour hex. |
| category.accent_rgb | string | — | Comma-separated RGB components for CSS (e.g. 15,118,110). Use with rgba(var(--category-rgb), .12). |
| category.description_html | string | — | Sanitized category description HTML. |
| category.description_text | string | — | Plain-text category description. |
| category.active | boolean | — | True when the category is active. |
| category.featured | boolean | — | True when marked featured. |
| category.expanded_posts | boolean | — | True when expanded post layout is enabled for this category. |
| category.posts_count | integer | — | Active posts in this category. |
| categories | array | — | All active categories, sorted. |
| categories_with_posts | array | — | Active categories that have at least one post. |
| pages | array | — | Published static pages (same shape as post). |
| posts_by_year | array | — | Archive groups with year and posts. |
| featured_post | post | No | Latest post from a featured category. |
| featured_categories | array | — | Featured categories with category, post, and latest_post. |
| footer_featured_post | post | No | Same as featured_post; mirrors built-in footer behaviour. |
menu
| Name | Type | Required | Description |
|---|---|---|---|
| menu.header | array | — | Header menu items. |
| menu.footer | array | — | Footer menu items. |
| item.title | string | — | Link label. |
| item.url | string | — | Resolved URL (page or external link). |
| item.location | string | — | header or footer. |
search
Archive search state. Check search.enabled before rendering a form.
| Name | Type | Required | Description |
|---|---|---|---|
| search.enabled | boolean | — | True when archive search is enabled for the blog. |
| search.active | boolean | — | True when a query is present. |
| search.query | string | — | Current search query. |
| search.action_url | string | — | Form action URL (current path). |
| search.clear_url | string | — | URL to clear the search. |
| search.placeholder | string | — | Input placeholder. |
| search.button_text | string | — | Submit button label. |
| search.clear_text | string | — | Clear link label. |
| search.not_found_text | string | — | Empty results message. |
| search.back_to_archive_text | string | — | Back link after empty search. |
pagination
Available on archive.liquid and category.liquid when results span multiple pages.
| Name | Type | Required | Description |
|---|---|---|---|
| pagination.current_page | integer | — | Current page number. |
| pagination.total_pages | integer | — | Total page count. |
| pagination.next_page | integer | No | Next page number. |
| pagination.previous_page | integer | No | Previous page number. |
| pagination.next_url | string | No | URL for the next page. |
| pagination.previous_url | string | No | URL for the previous page. |
| pagination.has_pages | boolean | — | True when pagination should render. |
| pagination.pages | array | — | Page entries with number, url, current, and gap (ellipsis). |
request and theme
| Name | Type | Required | Description |
|---|---|---|---|
| request.path | string | — | Current path. |
| request.query_string | string | — | Raw query string. |
| request.full_path | string | — | Path including query string. |
| request.host | string | — | Request hostname. |
| request.protocol | string | — | Protocol prefix (e.g. https://). |
| request.url | string | — | Full request URL. |
| request.query | object | — | Parsed query parameters. |
| theme.name | string | — | Theme name from theme.yml or upload. |
| theme.version_number | integer | — | Immutable version number for this upload. |
Filters and tags
Scribbles filters
Only these custom filters are available. Unknown filters cause upload rejection.
| Name | Type | Required | Description |
|---|---|---|---|
| asset_url | filter | — | Turns a theme asset path into a production URL. Example: {{ 'assets/theme.css' | asset_url }}. |
| strip_html | filter | — | Removes HTML tags from a string. |
| truncate_words | filter | — | Truncates to a word count. Example: {{ post.summary | truncate_words: 20 }}. |
| json | filter | — | Serializes a value to JSON for inline scripts. |
Standard Liquid tags and filters (such as if, for, assign, date, and default) work as usual.
Common patterns
Optional category badge
{% if post.category and blog.hide_category_tags_on_posts_list != true %}
{% render 'category_badge', category: post.category %}
{% endif %}
Post list
{% for post in posts %}
{% render 'post_card', post: post %}
{% endfor %}
Category accent in CSS
<a class="category-badge"
href="{{ category.url }}"
style="--category-accent: {{ category.accent }}; --category-rgb: {{ category.accent_rgb }}">
{{ category.name }}
</a>
URLs and preview
Custom domains
On a blog's custom hostname, Scribbles emits root-relative URLs (/post/slug, /archive, /categories/notes). On scribbles.page, URLs include the blog slug (/my-blog/posts/slug).
Theme Editor preview
Preview any uploaded version from Theme Editor before activating it. Preview mode uses signed asset URLs and preview paths so you can iterate safely without affecting the live blog.
Local preview server
The starter kit's bin/preview script serves templates at http://localhost:4000 using sample/blog.json. It mirrors the main routes:
/— homepage/archive— archive with search/post/:public_id— post/page/:public_id— page/categories/:slug— category
The local server converts {% render %} to {% include %} the same way production does. Pagination in local preview is simplified (single page).
Versioning
Each upload to the same theme creates a new version number. Existing activated blogs stay on their current version until you activate a newer one. Uploading under a new theme name creates a separate theme in your library.
Platform features
Scribbles injects platform markup into your rendered HTML. You do not need to add these yourself, but be aware of them when styling:
- Head — favicons, feed links, Open Graph tags, canonical link on posts, post content styles (
lexxy-content), lightbox scripts when enabled, and analytics scripts when configured. - Body — owner toolbar for signed-in blog owners.
- Post footer — Tinylytics kudos button and Komments link on eligible posts, inserted before the closing
</article>tag.
Custom theme pages run inside a Content Security Policy sandbox. External font origins declared in theme.yml are allowlisted for style-src and font-src. Analytics providers (Tinylytics, Plausible, Cloudflare Web Analytics) add script origins when configured on the blog.
Upload limits and validation
Scribbles validates every ZIP at upload time. Templates are parsed in strict Liquid mode with dummy data. Common rejection reasons:
| Name | Type | Required | Description |
|---|---|---|---|
| Archive size | limit | — | 10 MB maximum total uncompressed size. |
| Per-file size | limit | — | 2 MB maximum per file. |
| File count | limit | — | 100 files maximum. |
| Required templates | rule | — | All eight templates under templates/ must be present. |
| Allowed assets | rule | — | .css, .js, images, icons, and font files under assets/. |
| Snippet references | rule | — | Every {% render %} or {% include %} must resolve to a snippet in the package. |
| Liquid syntax | rule | — | Strict parse and render with placeholder data; syntax errors fail the upload. |
| Safe paths | rule | — | No absolute paths, backslashes, or .. segments. |
When validation fails, Theme Editor shows the error messages returned by the importer. Fix the package locally, re-zip, and upload again to create a new version.
Starter kit
The fastest way to begin is the blank starter ZIP from Theme Editor. It includes working templates, reusable snippets (header, footer, post_card, category_badge, search, pagination), sample CSS, and a README with the same reference material in plain Markdown.