For developers
Build Meridian from source (yarn commands, the test suite, CSS design tokens and the accent derivation, the template file map, the i18n parity check, the lifecycle events, and CSP hashes for the inline scripts).
This page is the short reference for developers customizing Meridian from source. Most site editors won't need anything here — uploading the pre-built meridian.zip is enough. If you want to fork the theme, change design tokens, or rebuild after edits, read on.
Custom code is overwritten on theme update
If you modify theme files directly and then upload an updated meridian.zip through Ghost Admin, your changes are erased. Fork the repo, commit your changes to your fork, and rebuild your own zip — don't edit files in place on a production install. For small CSS or JavaScript tweaks, prefer Ghost's Settings → Code injection which survives theme updates — see Code injection for the stable selectors Meridian exposes for exactly this.
Build requirements
- Node.js v22.12.0 or later
- Yarn (any recent v1.x)
- Ghost v6.23.0 or later for the running site that will host the built theme. Ghost 6.0 to 6.22 refuses the upload: the gscan those versions bundle (5.0.0 to 5.3.3) reports the unknown
{{contrast_text_color}}helper (the fallback for text on the accent fill, first shipped in 6.23.0) as a fatal "Templates must contain valid Handlebars" error.{{split}}(menu labels) needs 6.5.0. Theengines.ghostmajor is all the current gscan compares, so keep the floor inpackage.json, this page and the README in step by hand.
Build commands
All commands run from the theme root.
| Command | What it does |
|---|---|
yarn install | Install dependencies. |
yarn dev | Vite dev server with HMR. Useful when iterating on CSS or JS in a local Ghost install pointed at the theme directory. |
yarn build | Production build to assets/built/ (minified, no sourcemaps, ES2020 target). |
yarn zip | Run yarn test (lint + vitest), then yarn build, then package meridian.zip for upload. Excludes node_modules, .git, docs, scripts, tests, demo templates and other source-only files. |
yarn lint | Runs lint:i18n, lint:theme (GScan), lint:css (Stylelint), lint:js (ESLint) and lint:contrast in order. |
yarn lint:i18n | The translation-parity check across all 14 locales (see below). |
yarn lint:theme | GScan compliance check against Ghost's theme-API expectations. Zero errors and zero warnings is the bar. |
yarn lint:contrast | The contrast checker (see Design tokens below). |
yarn vitest | The unit and template tests. |
yarn test | yarn lint followed by yarn vitest. |
yarn test:upgrade | The upgrade gate: compares this build with the last release on stable (see Tests below). Needs a full clone with tags, and a fresh yarn build. |
Tests
Meridian ships a test suite alongside the linters, run by yarn vitest:
- JavaScript units (
assets/js/**/*.test.js): the navigation model (label prefixes, grouping, overflow maths), the header More engine, the sticky article bar, the reader-controls host, the soft-navigation router's scroll handling and the lifecycle events. DOM tests run in happy-dom. - Template tests (
scripts/**/*.test.js): the Handlebars partials rendered on a small set of Ghost-shaped helper stubs (scripts/ghost-helper-stubs.js,scripts/render.js) that mirror Ghost's own helpers, including the{{navigation}}helper and the older{{split}}that Ghost 6.23 to 6.53 ship. They cover the card resolver in every variant, the feature-row fallback, the desk chips, the membership band audience rules, the<title>and the breadcrumb. - Compatibility guards (
scripts/compat/): snapshots of the settings, hooks and CSS custom properties that shipped in 1.10.2, plus hashes of the inline<head>scripts. A release that drops a setting, adata-*value or a token (from the source or from the built stylesheet), or changes an inline script, fails here. - Runtime contracts (
scripts/runtime-contracts.test.js): readers' saved-state keys and bookmark shape, and every Alpine component a template names. - Golden files (
scripts/fixtures/golden/): the pictured card markup from before 1.11, so it can only change deliberately. - Upgrade gate (
scripts/upgrade/, run byyarn test:upgrade, kept out ofyarn test): unpacks the last release tag onorigin/stable(override withMERIDIAN_BASELINE=v1.x.y) and renders it next to the working tree. It fails when a menu link, a story, a component class, a documented recipe class or the override strength of a CSS variable that the previous release had goes missing, unlessscripts/upgrade/allowed-changes.jsonrecords the change and why. A pull request intostableruns it in CI (.github/workflows/upgrade-check.yml). - Package check: after
yarn zip,node scripts/check-zip.mjsconfirms the archive holds no scripts, tests, configs or demo templates and that itspackage.jsonis valid.
Design tokens
Meridian's design system lives as CSS variables in assets/css/index.css under the @theme block. The token families:
| Token family | Purpose | Swapped by |
|---|---|---|
--color-paper, --color-paper-2, --color-paper-3 | Page surfaces (body bg, raised cards, deeper recess) | background_palette theme setting |
--color-rule, --color-rule-2 | Horizontal rules and borders | background_palette theme setting |
--color-ink, --color-ink-2, --color-ink-3, --color-ink-mute, --color-ink-faint | Text colours, from heaviest body to lightest caption | Ink is constant across palettes (Mono retones it to grayscale); the two muted tones are tuned per palette |
--color-accent | The exact Ghost accent, for fills and decorative rules | Ghost's --ghost-accent-color (Brand → Accent color in Ghost Admin) |
--color-accent-readable | The accent for text and UI: kickers, links, active menu items, focus rings, underlines, inline code | Derived from --color-accent per scheme and palette |
--color-accent-on-band | The accent for text on the always-dark signup band | Derived from --color-accent |
--color-accent-contrast | Text on an accent fill: black or white by measured luminance | Derived from --color-accent |
--color-accent-hover | Hover step of an accent fill, always away from its text colour | Derived from --color-accent |
--color-accent-ink, --color-accent-tint, --color-accent-safe, --color-accent-on-dark, --color-accent-band-readable, --color-brand | Older accent aliases, kept for Code injection written against 1.10 | Unchanged |
--ff-* | Per-family font stacks for all 11 selectable fonts | font_heading / font_body theme settings |
The accent derivation
The four derived accent tokens are computed in CSS with relative colour syntax, inside an @supports block that tests the exact expression shapes the derivation uses (oklch(from red min(l, 0.47) c h) and color(from red srgb-linear calc(clamp(0, (0.18 - r) * 1e6, 1)) g b)), so an engine that understands only the simplest relative colours keeps the fallbacks. The block sits in the theme cascade layer, so any unlayered Code injection rule that sets one of these variables wins over it. --color-accent-readable clamps the accent's OKLCH lightness to a per-palette maximum in light mode (min(l, max)) and a per-palette minimum in dark mode (max(l, min)), and only the lightness moves. The bounds guarantee AA for every accent, so an accent beyond a bound moves even if it already passed (the default #a4271c, at L 0.474, is clamped to Newsprint's 0.47). --color-accent-contrast picks black or white at the luminance crossover (Y = 0.179128), and --color-accent-hover mixes 18% toward the inverse of that text colour. Browsers without relative colour syntax keep the fallbacks: the raw accent, and Ghost's own {{contrast_text_color}} pick.
The per-palette lightness bounds are constants in index.css. They are solved, not guessed: scripts/check-contrast.mjs reimplements the derivations in JavaScript, sweeps 117 accents (the theme default, Ghost's defaults and presets, a hue wheel at four shades, the extremes and a few real sites) across all five palettes in both schemes, and fails if a text pair drops below WCAG 2.2 AA (4.5:1 for text, 3:1 for the focus ring) or if the constants in the CSS drift from the ones it solved. It also lints the templates: every accent fill must pair with text-accent-contrast. Run it with yarn lint:contrast; node scripts/check-contrast.mjs --emit-fixtures writes the reference outputs the docs' accent tester checks itself against.
Adding a new background preset means: add a new CSS rule against the data-palette attribute on <html>, override the relevant token family, add the palette's three lightness bounds, and run the checker until it is green. (Accent has no <html> attribute: it flows through Ghost's --ghost-accent-color variable.)
File map
The repo's root templates and key partials at a glance:
Templates (root)
| File | Purpose |
|---|---|
default.hbs | Master layout: declares the data-color-scheme and data-palette attributes on <html>; conditional font preloads; FOUC-prevention inline script; the <title> with the translated "(Page 2)" suffix on paged archives. |
home.hbs | Homepage (body in partials/home-body.hbs): secondary aside + lead + rail; Editor's Picks strip; tag-dispatched section rows; from-the-archive tail. |
post.hbs | Article page: hero, byline, reading progress, body, paywall, comments, related coverage, member CTA, author card, sticky toolbar. |
page.hbs | Standard page template (and the live preview of a recipe page opened at its own URL). |
page-bookmarks.hbs, page-join.hbs | Templates bound to the pages slugged bookmarks and join. |
custom-sections.hbs | The Sections template from the editor's Template dropdown. |
tag.hbs, author.hbs | Tag / author archives. |
index.hbs | Index archive (chronological). |
error.hbs, error-404.hbs | Error pages. |
Key partials
| Partial | Purpose |
|---|---|
partials/header/editorial.hbs, partials/header/compact.hbs | The two header layouts; both mount the same More engine. |
partials/navigation.hbs, partials/nav-label.hbs | Server-rendered menus (primary, drawer, footer source and the 404 browse list) and the label that removes one leading -, plus one leading # in the secondary menu, escaping every character itself (partials/nav-label/) so labels stay safe on every supported Ghost version. |
partials/footer.hbs | Footer: the Membership CTA band (on every page except join), the brand column with its dark-mode logo and social icons, one labelled <nav> of columns, legal links. |
partials/post-card.hbs | The article card every caller uses. It delegates to partials/post-card/layout.hbs (picture or text card), image-layout.hbs and text-layout.hbs; assets/js/text-fill.js and assets/css/text-cards.css make text cards fill a row of pictured cards. |
partials/homepage-sections.hbs, partials/homepage-section-row.hbs | Tag-dispatched section rows; homepage-section-row/feature-text.hbs is the text fallback for the two feature layouts. |
partials/editors-picks-strip.hbs | Homepage featured-post carousel. |
partials/archive-filters.hbs | The desk chips, curated by the page slugged desk-chips (archive-filters/chip.hbs, archive-filters/all-desks.hbs). |
partials/breadcrumb.hbs | The breadcrumb on tag, author, static and sections pages. |
partials/membership-cta.hbs | The band above the footer; the audience chain lives in partials/membership-cta/ (sale-gate, sale-check, band, priced, picker, no-sale, signin). |
partials/archive-grid.hbs | 18-post asymmetric grid. |
partials/related-coverage.hbs, partials/you-may-be-interested-in.hbs, partials/from-this-writer.hbs | The recommendation rails below a post. "You may be interested in" runs stories from the post's other tags first and tops up from its main section (partials/you-may-be-interested-in/). |
partials/reader-controls.hbs, partials/sticky-article-bar.hbs, partials/post-toolbar-actions.hbs | The article toolbar, its sticky twin and the reader-preferences panel. |
partials/font-slug.hbs | Maps font label → CSS slug. |
partials/reading-progress.hbs | Post-page progress bar. |
partials/social-links.hbs | Footer + author social icons (hydrated client-side from meta tags / data div). |
Assets
| Path | Purpose |
|---|---|
assets/css/index.css | Tailwind v4 entry, design tokens, the accent derivation, font imports, Koenig card overrides. |
assets/css/header-nav.css | The header menu: pre-hydration state, the More menu, the scripts-off fallback. |
assets/js/index.js | Alpine.js entry, stores, components, init helpers. |
assets/js/nav-model.js | Pure navigation model: prefix stripping, grouping, overflow maths. Unit-tested. |
assets/js/header-nav.js | The More engine for both headers (WAI-ARIA disclosure pattern). |
assets/js/navigation.js | The phone section strip of both headers, plus drawer and footer hydration from the secondary menu. |
assets/js/sticky-article-bar.js, assets/js/reader-host.js | The sticky bar's show/hide (it never hides while focused) and the reader-panel focus host. |
assets/js/meridian-core/ | The persistent player: router, stage, player, reinit.js, which fires the public lifecycle events, and safe-init.js, which keeps one failing initialiser from stopping the rest. |
assets/js/social-icons.js, assets/js/social-links.js | The custom-social-links feature. |
assets/js/preference-sync.js | Cross-tab localStorage listener (theme, fontSize, bookmarks). |
vite.config.js, vitest.config.js | Build and test configuration. |
Lifecycle events
Three events on document are a supported API for Code injection scripts: meridian:load (after the theme has initialised a page: on first load, after every soft navigation while the persistent player is engaged, and when the browser restores a page from its back/forward cache), meridian:unload (before a page is torn down or put into that cache; it always pairs with the meridian:load before it) and meridian:pageview (for analytics). Usage and examples are in Code injection → Running a script on every page.
i18n workflow
The yarn lint:i18n parity check is enforced: never add a {{t "..."}} call to a template without also adding the matching key to locales/en.json and every other locale.
The mechanics
scripts/check-i18n.mjswalks every*.hbsfile in the theme.- It extracts every
{{t "..."}}and subexpression(t "...")call. - It diffs the extracted key set against
locales/en.json, then checks every other locale againsten.json: missing keys, extra keys, empty values, and%/{brace}placeholders that went missing in translation. - It fails the build on any drift.
The discipline
- Add a translation key first, then reference it in the template.
- Use Ghost's interpolation syntax (
{{t "Members of {site}" [email protected]}}) instead of string concatenation, so word order is correct in non-English locales. - Pluralization uses Ghost's
{{plural}}helper aroundtcalls, with one key per form (for exampleempty=(t "No stories") singular=(t "% story") plural=(t "% stories")). - Never hardcode user-visible English in a template.
See also: Publication Language for the reader-facing side of the same system.
CSP & the inline scripts
With ads off (the default), Meridian ships one inline <script> block in <head>: the FOUC-prevention script that reads localStorage + prefers-color-scheme to apply the saved appearance class before the page paints. Turning on Enable ads with Ad consent mode adds a second inline block: the Consent Mode v2 deny-by-default baseline, emitted before {{ghost_head}}. With Enable persistent player on (the default), the end of <body> also carries an inline <script type="speculationrules"> block that sets up link prefetching. Every other theme script is loaded from assets/built/ (the AdSense loader, when a publisher ID is set, is an external src script, not inline).
If your site enforces a strict Content Security Policy that disallows script-src 'unsafe-inline', you'll need to whitelist each inline script's SHA-256 hash: one for the appearance script, one more for the consent baseline when ads and consent mode are on, and one for the speculation rules when the persistent player is on. This command prints them all:
curl -s https://your-site.example.com/ \
| perl -0777 -ne 'while (/<script(?: type="speculationrules")?>([\s\S]*?)<\/script>/g) { open my $h, "|-", "openssl dgst -sha256 -binary | base64" or die; print $h $1; close $h }'Each output line is a hash to add to your script-src directive (prefixed with 'sha256-'). The list also includes any inline <script> you added through Code injection, which a strict policy needs as well.
The scripts' behaviour is intentionally stable across releases, but a hash will change if the script bytes change, so re-run the command after every theme update on a strict-CSP site, and after changing the Color scheme setting, which is written into the appearance script. Meridian 1.11 changed none of these scripts, so hashes from 1.10 carry over.