CtrlK
BlogDocsLog inGet started
Tessl Logo

alonso-skills/ghost-theme

Build, customize, and deploy Ghost CMS themes. Use this skill whenever the user mentions Ghost themes, Ghost CMS, Handlebars templates (.hbs files), Ghost Admin, Ghost membership/subscription integration, Ghost custom settings, or Ghost content API — even if they don't say "theme" explicitly. Trigger on: building a blog theme, creating a Ghost site, editing .hbs templates, adding member-only content, Ghost hero sections, Ghost routing (routes.yaml), Ghost image optimization, Ghost dark mode, Ghost search, Ghost deploy, gscan validation, Ghost JSON-LD/SEO, or any mention of {{ghost_head}}, {{ghost_foot}}, {{#foreach}}, {{#get}}, {{img_url}}, {{asset}}, @custom, @member, or Portal. Also use when the user has an existing Ghost theme they want to modify, extend, or debug — not just for new themes.

100

Quality

100%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

11-i18n.mdreferences/

Internationalization (i18n)

Ghost's i18n system lets themes serve translated strings through a simple JSON file + Handlebars helper model. The runtime is built on intl-messageformat (ICU syntax), so it handles variable interpolation and pluralization natively.


How Ghost Determines Locale

Ghost resolves locale from a single authoritative source: Ghost Admin publication settings (Settings → General → Publication Language). This value is stored in settingsCache under the key locale and is applied server-side at render time.

There is no browser-based locale detection and no URL-segment locale switching (e.g. /fr/post-slug). Ghost does not inspect Accept-Language headers or query parameters to pick a language. Every visitor to a Ghost site gets the same locale, which is the one set by the administrator.

The locale value flows into:

  • themeI18n.init({ locale }) — loads the matching locales/<locale>.json file from the active theme
  • settingsCache.get('locale') — available in templates as {{@site.locale}}

The {{t}} helper reads the locale from settingsCache each time it initializes (on first use after a theme switch or language change). If the resolved locale file does not exist, Ghost logs a warning and falls back to locales/en.json. If en.json is also absent, the translation key itself is returned as the string.

The <html> element should always carry the locale as the lang attribute:

<html lang="{{@site.locale}}">

The {{t}} Helper

The translation key is the full English string. Ghost uses "fulltext" mode — unlike dot-notation keys used internally, theme keys are verbatim English phrases. This means you never write an opaque key like pagination.next; you write the actual string.

Basic usage:

{{t "Newer Posts"}}
{{t "Older Posts"}}
{{t "Subscribe"}}

Ghost looks up the key in locales/<locale>.json. If found, it returns the translated value. If not found, it returns the key itself — so themes degrade gracefully without the locale file.

Variable Interpolation

Variables are injected using named {placeholder} tokens inside the translation string. Pass the variable as a named Handlebars hash argument:

{{t "Page {page} of {pages}" page=pagination.page pages=pagination.pages}}

The corresponding JSON entry:

"Page {page} of {pages}": "Seite {page} von {pages}"

Multiple variables in one string work the same way:

{{{t "Powered by {ghostlink}" ghostlink="<a href=\"https://ghost.org\">Ghost</a>"}}}

Note the triple-stash {{{t}}} when the interpolated value contains HTML — {{t}} HTML-escapes output by default. Using it as a subexpression also works:

{{tags prefix=(t " on ")}}

Pluralization

Ghost's i18n engine uses intl-messageformat (ICU MessageFormat syntax) for pluralization. The % character in older theme locale files is a convention used by some community themes but is not native Ghost pluralization — it is a bare string with a manual % placeholder that gets substituted via Handlebars logic outside the {{t}} helper.

Native ICU pluralization is available when strings use the full MessageFormat syntax:

"item_count": "{count, plural, one {# item} other {# items}}"

{{t "item_count" count=total}}

For themes that do not need full ICU, a simpler convention is to define discrete keys per count:

"1 min read": "1 min read",
"% min read": "% min read"

And select between them in template logic using {{#if}} or {{reading_time}} helper output.


Adding New Locales

File location

Place locale files at:

locales/<locale-code>.json

The file must be valid JSON. Ghost validates it on load and logs an error if parsing fails, then falls back to en.json.

Locale code format

Ghost passes the locale code string directly to Node's Intl.NumberFormat and Intl.DateTimeFormat. It uses IETF BCP 47 language tags:

  • Simple language: en, fr, de, es, ja, zh, ar, he, ko, pt, ru, nl
  • Language + region: en-US, pt-BR, zh-TW, zh-CN, fr-CA
  • Language + script: zh-Hans, zh-Hant

Ghost itself has no hardcoded allowlist of locale codes. Any code that Intl recognizes will work. If Intl does not have built-in data for the locale, Ghost loads the intl polyfill as a fallback.

The locale code set in Ghost Admin must exactly match the filename: a setting of pt-BR requires locales/pt-BR.json.

Locale coverage reference

A well-translated theme typically covers de, en, es, fr, jp, nl, zh — a useful baseline.

JSON structure

Locale files are flat key-value objects. Keys are verbatim English strings; values are translations:

{
    "Subscribe": "S'abonner",
    "Sign in": "Se connecter",
    "Page {page} of {pages}": "Page {page} sur {pages}",
    "This post is for paying subscribers only": "Cet article est réservé aux abonnés payants"
}

Nesting is not supported in theme locale files (unlike Ghost's internal dot-notation i18n). Every key must be a top-level string.


Default Strings Themes Should Translate

The following categories represent strings that appear across virtually every Ghost theme. Every locale file should cover these.

Navigation and pagination

  • "Newer Posts" / "Older Posts" — pagination links
  • "Next" / "Previous" — alternate pagination labels
  • "Page {page} of {pages}" — pagination summary
  • "Go to the front page" — 404 and error pages
  • "Search" — search input placeholder or label
  • "Menu" — mobile nav toggle

Membership and subscription CTAs

  • "Subscribe" — primary subscribe action
  • "Sign in" — member login link
  • "Account" / "My Account" — member account link
  • "Subscribe now" — CTA button
  • "Already have an account?" — pre-login nudge
  • "Your email address" / "Your email..." — email input placeholder
  • "Sending..." — loading state
  • "Email sent — check your inbox" — success message

Access gates

  • "Members only" — access badge
  • "This post is for subscribers only" — free-tier gate
  • "This post is for paying subscribers only" — paid-tier gate
  • "This post is only for subscribers on the" — specific tier gate preamble
  • "Subscribe to continue reading" — gate CTA
  • "Upgrade now" / "Upgrade your account" — tier upgrade prompt

Post metadata

  • "by" / "By" — author prefix (e.g. "by Jane Doe")
  • "on" — date prefix (e.g. "on January 1")
  • "1 min read" — reading time singular
  • "% min read" — reading time plural (with % as placeholder)
  • "Continue Reading" / "Read More" — post card link text
  • "You might also like" — related posts heading

Error pages

  • "Page not found" / "404: Page Not Found" — 404 heading
  • "The thing you were looking for is no longer here or never was." — 404 body copy

Social and sharing

  • "Share on Twitter" / "Share on Facebook" / "Share on LinkedIn" / "Share via email"
  • "Powered by Ghost" — footer attribution

Comments and discussion

  • "Comments" / "Member discussion" — comments section heading
  • "0 comments" / "comment" / "comments" — comment count labels
  • "Close" — modal/drawer close button

RTL Language Considerations

Ghost does not inject a dir attribute automatically. RTL support is entirely the theme author's responsibility.

HTML direction attribute

Detect RTL locales and set dir="rtl" on the <html> element. Because Handlebars templates are static and {{@site.locale}} is a plain string, the comparison must be done in the template:

{{#match @site.locale "ar"}}
<html lang="{{@site.locale}}" dir="rtl">
{{else}}
<html lang="{{@site.locale}}" dir="ltr">
{{/match}}

For themes supporting multiple RTL languages, use nested {{#match}} blocks or a CSS-only approach (see below).

RTL locales to account for include: ar (Arabic), he (Hebrew), fa (Persian/Farsi), ur (Urdu), yi (Yiddish), ku (Kurdish Sorani).

CSS logical properties

Prefer CSS logical properties over directional shorthands so layout mirrors automatically when dir="rtl" is set on an ancestor:

/* Directional — breaks in RTL */
margin-left: 1rem;
padding-right: 0.5rem;

/* Logical — adapts to dir attribute */
margin-inline-start: 1rem;
padding-inline-end: 0.5rem;

CSS-only RTL detection

When template-level branching is impractical, use the :dir() pseudo-class or [dir="rtl"] attribute selector:

.post-card-meta {
    flex-direction: row;
}

[dir="rtl"] .post-card-meta {
    flex-direction: row-reverse;
}

Font stacks

RTL languages often need different fonts. Arabic and Hebrew have no italic letterforms; fallback fonts should be chosen carefully. Load RTL-specific fonts conditionally via a <link> inside a {{#match}} block in default.hbs.

Text alignment

Do not use text-align: left as a global reset. Use text-align: start instead, which follows the document direction:

body {
    text-align: start;
}

Translating Dynamic Content

The {{t}} helper works only for static strings hardcoded in templates. Content that comes from the Ghost database — post titles, author bios, tag names, post excerpts — cannot be translated through the theme i18n system.

What cannot be translated via {{t}}

  • {{title}} / {{post.title}} — post or page titles
  • {{bio}} / {{author.bio}} — author biography
  • {{description}} — tag or author description
  • {{excerpt}} — post excerpt
  • {{navigation}} — nav item labels (set in Ghost Admin)
  • {{@site.title}} / {{@site.description}} — publication name and tagline

These values exist in one language only: whatever the author typed into Ghost Admin or the post editor.

Patterns for handling this limitation

Separate publications per language is the most common pattern for sites that need fully translated content. Each language runs its own Ghost instance with its own domain or subdomain (/fr, /de). The theme's {{t}} strings handle UI chrome.

Manual language tagging — authors tag posts with a language tag (e.g. lang-fr, lang-ar) and use {{#has tag="lang-fr"}} conditionals in templates to show language-specific navigation or UI elements, while post content itself is written in the target language.

Duplicate post strategy — each post is written once per language. The author sets the post slug with a locale prefix (/fr-mon-article) and cross-links between translations using custom fields or internal tags. Templates surface the alternate-language link from post metadata.

{{navigation}} labels — navigation labels are set in Ghost Admin UI and are language-agnostic from the theme's perspective. For multilingual sites, authors must set the nav labels in the target language in Admin. There is no mechanism to have the theme swap navigation labels per locale.

Author names and bios — these are always rendered as entered. If a publication targets a single language, this is not a problem. For mixed-language setups, the author bio is typically written in the primary publication language and left untranslated in the theme.

Tag names in headings — when displaying {{name}} inside a {{#tag}} context, the tag name is whatever was entered in Admin. Themes sometimes work around this by displaying the tag description instead of the name for archive headings, since descriptions can carry richer localized copy — but this is a content management convention, not a theme translation feature.

SKILL.md

tile.json