Maintenance

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. The engines.ghost major is all the current gscan compares, so keep the floor in package.json, this page and the README in step by hand.

Build commands

All commands run from the theme root.

CommandWhat it does
yarn installInstall dependencies.
yarn devVite dev server with HMR. Useful when iterating on CSS or JS in a local Ghost install pointed at the theme directory.
yarn buildProduction build to assets/built/ (minified, no sourcemaps, ES2020 target).
yarn zipRun 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 lintRuns lint:i18n, lint:theme (GScan), lint:css (Stylelint), lint:js (ESLint) and lint:contrast in order.
yarn lint:i18nThe translation-parity check across all 14 locales (see below).
yarn lint:themeGScan compliance check against Ghost's theme-API expectations. Zero errors and zero warnings is the bar.
yarn lint:contrastThe contrast checker (see Design tokens below).
yarn vitestThe unit and template tests.
yarn testyarn lint followed by yarn vitest.
yarn test:upgradeThe 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, a data-* 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 by yarn test:upgrade, kept out of yarn test): unpacks the last release tag on origin/stable (override with MERIDIAN_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, unless scripts/upgrade/allowed-changes.json records the change and why. A pull request into stable runs it in CI (.github/workflows/upgrade-check.yml).
  • Package check: after yarn zip, node scripts/check-zip.mjs confirms the archive holds no scripts, tests, configs or demo templates and that its package.json is valid.

Design tokens

Meridian's design system lives as CSS variables in assets/css/index.css under the @theme block. The token families:

Token familyPurposeSwapped by
--color-paper, --color-paper-2, --color-paper-3Page surfaces (body bg, raised cards, deeper recess)background_palette theme setting
--color-rule, --color-rule-2Horizontal rules and bordersbackground_palette theme setting
--color-ink, --color-ink-2, --color-ink-3, --color-ink-mute, --color-ink-faintText colours, from heaviest body to lightest captionInk is constant across palettes (Mono retones it to grayscale); the two muted tones are tuned per palette
--color-accentThe exact Ghost accent, for fills and decorative rulesGhost's --ghost-accent-color (Brand → Accent color in Ghost Admin)
--color-accent-readableThe accent for text and UI: kickers, links, active menu items, focus rings, underlines, inline codeDerived from --color-accent per scheme and palette
--color-accent-on-bandThe accent for text on the always-dark signup bandDerived from --color-accent
--color-accent-contrastText on an accent fill: black or white by measured luminanceDerived from --color-accent
--color-accent-hoverHover step of an accent fill, always away from its text colourDerived from --color-accent
--color-accent-ink, --color-accent-tint, --color-accent-safe, --color-accent-on-dark, --color-accent-band-readable, --color-brandOlder accent aliases, kept for Code injection written against 1.10Unchanged
--ff-*Per-family font stacks for all 11 selectable fontsfont_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)

FilePurpose
default.hbsMaster 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.hbsHomepage (body in partials/home-body.hbs): secondary aside + lead + rail; Editor's Picks strip; tag-dispatched section rows; from-the-archive tail.
post.hbsArticle page: hero, byline, reading progress, body, paywall, comments, related coverage, member CTA, author card, sticky toolbar.
page.hbsStandard page template (and the live preview of a recipe page opened at its own URL).
page-bookmarks.hbs, page-join.hbsTemplates bound to the pages slugged bookmarks and join.
custom-sections.hbsThe Sections template from the editor's Template dropdown.
tag.hbs, author.hbsTag / author archives.
index.hbsIndex archive (chronological).
error.hbs, error-404.hbsError pages.

Key partials

PartialPurpose
partials/header/editorial.hbs, partials/header/compact.hbsThe two header layouts; both mount the same More engine.
partials/navigation.hbs, partials/nav-label.hbsServer-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.hbsFooter: 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.hbsThe 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.hbsTag-dispatched section rows; homepage-section-row/feature-text.hbs is the text fallback for the two feature layouts.
partials/editors-picks-strip.hbsHomepage featured-post carousel.
partials/archive-filters.hbsThe desk chips, curated by the page slugged desk-chips (archive-filters/chip.hbs, archive-filters/all-desks.hbs).
partials/breadcrumb.hbsThe breadcrumb on tag, author, static and sections pages.
partials/membership-cta.hbsThe 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.hbs18-post asymmetric grid.
partials/related-coverage.hbs, partials/you-may-be-interested-in.hbs, partials/from-this-writer.hbsThe 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.hbsThe article toolbar, its sticky twin and the reader-preferences panel.
partials/font-slug.hbsMaps font label → CSS slug.
partials/reading-progress.hbsPost-page progress bar.
partials/social-links.hbsFooter + author social icons (hydrated client-side from meta tags / data div).

Assets

PathPurpose
assets/css/index.cssTailwind v4 entry, design tokens, the accent derivation, font imports, Koenig card overrides.
assets/css/header-nav.cssThe header menu: pre-hydration state, the More menu, the scripts-off fallback.
assets/js/index.jsAlpine.js entry, stores, components, init helpers.
assets/js/nav-model.jsPure navigation model: prefix stripping, grouping, overflow maths. Unit-tested.
assets/js/header-nav.jsThe More engine for both headers (WAI-ARIA disclosure pattern).
assets/js/navigation.jsThe 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.jsThe 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.jsThe custom-social-links feature.
assets/js/preference-sync.jsCross-tab localStorage listener (theme, fontSize, bookmarks).
vite.config.js, vitest.config.jsBuild 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

  1. scripts/check-i18n.mjs walks every *.hbs file in the theme.
  2. It extracts every {{t "..."}} and subexpression (t "...") call.
  3. It diffs the extracted key set against locales/en.json, then checks every other locale against en.json: missing keys, extra keys, empty values, and % / {brace} placeholders that went missing in translation.
  4. 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 around t calls, with one key per form (for example empty=(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.