Early preview

Custom themes, theme packages, and Liquid variables may change while we gather feedback. If something is unclear or you need a capability that is missing, email [email protected].

Theme development

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.

Note: Custom themes replace layout and styling only. Post content still comes from Scribbles as sanitized HTML via post.content_html. Secrets such as passcodes, API keys, and analytics tokens are never exposed to Liquid.
Note: You can build a theme with any toolchain that produces a valid Liquid ZIP — Node, Python, a static site generator, or plain files in your editor. Ruby is not required. The starter kit's Ruby preview server is optional local tooling; Scribbles ignores Gemfile, sample/, and bin/preview when importing.

Quick start

  1. Open Blog Settings → Theme Editor for a blog you own.
  2. Download the blank starter ZIP. It includes sample content, starter templates, and a local preview server.
  3. 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
  1. Zip the theme folder contents and upload the ZIP from Theme Editor.
  2. 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-fontshttps://fonts.googleapis.com, https://fonts.gstatic.com
  • bunny-fontshttps://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.