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

08-bun-build.mdreferences/

Bun Build Pipeline

This reference covers the complete build system used in the base template: scripts/build.ts, the Bun CSS segfault workaround, dev/watch mode, zip packaging for Ghost upload, PostCSS, TypeScript entrypoints, source maps, multi-entrypoint builds, and how {{asset}} handles cache-busting.


Table of Contents


Directory Layout

All source lives under assets/src/ and all build output goes to assets/built/. The built directory is gitignored.

assets/
├── src/
│   ├── app.ts              ← JS entrypoint (imports components)
│   ├── app.css             ← CSS entrypoint (@imports components)
│   ├── base/
│   │   ├── variables.css   ← CSS custom properties
│   │   ├── reset.css       ← Box-sizing, body defaults
│   │   └── typography.css  ← Headings, links
│   ├── layout/
│   │   ├── container.css   ← Container widths
│   │   └── responsive.css  ← Breakpoint overrides
│   └── components/
│       ├── members-form.ts ← Membership form state
│       ├── search.ts       ← Search toggle
│       ├── header.css      ← Site header
│       ├── hero.css        ← Hero section
│       ├── post-feed.css   ← Post card grid
│       ├── post-single.css ← Single post/page + Ghost cards
│       ├── members-cta.css ← Members signup CTA
│       ├── author.css      ← Author archive
│       ├── tag.css         ← Tag archive
│       ├── error.css       ← Error pages
│       ├── pagination.css  ← Pagination controls
│       └── footer.css      ← Site footer
└── built/                  ← gitignored, generated by bun run build
    ├── app.css
    └── app.js

Templates reference built files through {{asset "built/app.js"}}, never the src path. The source is organized into component-sized files:

  • base/ — CSS custom properties, reset, typography
  • layout/ — Container widths, responsive breakpoints
  • components/ — One .css and/or .ts file per UI component

CSS uses @import statements resolved by postcss-import at build time. TypeScript uses ES module import statements resolved by Bun's bundler.


scripts/build.ts Walkthrough

Full annotated source:

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import postcss from "postcss";
import postcssImport from "postcss-import";

const srcDir = path.resolve(import.meta.dir, "../assets/src");
const outDir = path.resolve(import.meta.dir, "../assets/built");

if (!existsSync(outDir)) {
    mkdirSync(outDir, { recursive: true });
}

const isWatch = process.argv.includes("--watch");

async function buildJS() {
    const result = await Bun.build({
        entrypoints: [
            path.join(srcDir, "app.ts"),
        ],
        outdir: outDir,
        minify: !isWatch,
        target: "browser",
        sourcemap: isWatch ? "inline" : "none",
    });

    if (!result.success) {
        console.error("JS build failed:");
        for (const log of result.logs) {
            console.error(log);
        }
        process.exit(1);
    }
}

async function buildCSS() {
    const cssEntry = path.join(srcDir, "app.css");
    const cssOut = path.join(outDir, "app.css");
    const src = readFileSync(cssEntry, "utf8");

    const plugins: postcss.AcceptedPlugin[] = [postcssImport()];
    const result = await postcss(plugins).process(src, {
        from: cssEntry,
        to: cssOut,
    });

    writeFileSync(cssOut, result.css);
}

async function build() {
    await Promise.all([buildJS(), buildCSS()]);
    console.log("Build succeeded:");
    console.log("  assets/built/app.js");
    console.log("  assets/built/app.css");
}

await build();

if (isWatch) {
    console.log("\nWatching for changes...");
    const { watch } = await import("fs");
    watch(srcDir, { recursive: true }, async () => {
        await build();
    });
}

Option-by-option breakdown

entrypoints

An array of absolute file paths. Bun resolves all import/require calls starting from these files and bundles everything reachable into a single output file per entrypoint. The output filename mirrors the entrypoint basename (app.tsassets/built/app.js). Bun strips TypeScript types natively.

outdir

Absolute path to the output directory. Bun creates it if missing (the mkdirSync guard above handles the first run). All output files land here flat — no subdirectory mirroring.

minify: !isWatch

When true, Bun runs its built-in minifier (whitespace removal, identifier mangling, dead-code elimination). Disabled during watch mode so that error messages and stack traces reference readable variable names.

target: "browser"

Tells Bun to produce browser-compatible output: no process, require, or Node-specific globals are left in the bundle. Use "node" only for build scripts themselves, never for Ghost theme assets.

import.meta.dir

Bun's equivalent of __dirname. Always resolves relative to the file that contains the expression — safe even when scripts/build.ts is invoked from a different working directory (e.g., bun run scripts/build.ts from the theme root).

result.success / result.logs

Bun.build never throws. A failed build returns { success: false, logs: [] } where each log entry has .message, .level, and source location. The script calls process.exit(1) on failure so that bun run zip aborts early.

Adding entrypoints

To add a second JS bundle (e.g., a separate admin script):

entrypoints: [
    path.join(srcDir, "app.ts"),
    path.join(srcDir, "admin.ts"),
],

Bun produces assets/built/app.js and assets/built/admin.js automatically. No additional configuration is needed.


CSS Build with PostCSS

The base template uses postcss-import to resolve CSS @import chains. This is necessary because Bun 1.3.x has a segfault when CSS files are passed as entrypoints to Bun.build().

How it works: app.css is the entrypoint, containing only @import statements. PostCSS reads the file, resolves all imports recursively, and outputs a single bundled CSS file to assets/built/app.css.

Adding a new CSS component:

Create a .css file in the appropriate directory and @import it from app.css:

/* assets/src/components/gallery.css */
.gallery { display: grid; gap: 1rem; }

/* assets/src/app.css — add the import */
@import "./components/gallery.css";

When Bun 1.4 ships a stable CSS bundler, you can simplify by removing PostCSS and adding app.css directly to the Bun entrypoints array. Check the Bun changelog for "CSS bundler segfault" before switching.

Adding CSS minification: Install cssnano and add it to the plugins:

bun add -d cssnano

import cssnano from "cssnano";

const plugins: postcss.AcceptedPlugin[] = isWatch
    ? [postcssImport()]
    : [postcssImport(), cssnano()];

Adding CSS nesting or autoprefixer:

bun add -d postcss-nesting autoprefixer

import postcssNesting from "postcss-nesting";
import autoprefixer from "autoprefixer";

// Add after postcssImport() in the plugins array

Dev Mode with Watch

The dev script in package.json:

"dev": "bun run scripts/build.ts --watch"

Internally, scripts/build.ts detects --watch via:

const isWatch = process.argv.includes("--watch");

When watch mode is active:

  • Minification is disabled (minify: false).
  • After the initial build, fs.watch monitors the entire assets/src/ directory recursively.
  • Any file change triggers a full rebuild (both JS and CSS copy).

Livereload strategy

Bun does not include a built-in livereload server. The recommended approaches are:

  • Ghost's built-in livereload — Ghost Pro and local Ghost (Ghost CLI) have a livereload mechanism that picks up changes to assets/built/ automatically when running in development mode. Run Ghost locally alongside bun run dev and Ghost will inject the livereload script.

  • browser-sync — For a standalone livereload server that also proxies Ghost:

    bunx browser-sync start \
        --proxy "localhost:2368" \
        --files "assets/built/**,**/*.hbs" \
        --no-notify
  • Manual refresh — The simplest fallback. Save → switch to browser → F5. Adequate for most template and CSS work where the change is obvious.

Watch latency

Node's fs.watch on Linux uses inotify, which is near-instantaneous. The Bun build itself for a small theme typically completes in under 100 ms. Total latency from save to rebuilt files is usually under 200 ms.


Zip Creation for Ghost Upload

Ghost requires themes to be uploaded as .zip archives. The zip script in package.json:

"zip": "bash scripts/zip.sh"

The scripts/zip.sh script runs the build and packages the theme, excluding dev artifacts. Breaking down the exclusions:

  • node_modules/* — never ship dependencies; Ghost doesn't need them
  • dist.zip — prevents the archive from containing itself
  • .git/* — no version control metadata in uploaded themes
  • assets/src/* — source files are not needed at runtime; only assets/built/ ships
  • scripts/ — all build/test scripts are dev artifacts
  • bun.lock — lockfile is only meaningful for local installs
  • *.log — any stray log files

Running the zip:

bun run zip

This produces dist.zip in the theme root. Upload it via Ghost Admin → Design → Change theme → Upload theme.

What Ghost validates on upload (gscan):

Ghost runs gscan against the uploaded zip. The most common failures when using a Bun-based build are:

  • Missing package.json at zip root (the zip must include it)
  • Missing assets/built/app.css or assets/built/app.js (build must run before zip)
  • Handlebars syntax errors in .hbs files

Run bunx gscan . locally before zipping to catch these issues early.

Zip from a CI pipeline:

bun run scripts/build.ts
zip -r dist.zip . \
  -x 'node_modules/*' 'dist.zip' '.git/*' \
     'assets/src/*' 'scripts/*' 'bun.lock' '*.log'

Split into two commands so the exit code of the build step is distinct from the zip step.


CSS Organization

The base template organizes CSS into three directories:

  • base/ — Foundation styles loaded first: CSS custom properties (variables.css), box model reset (reset.css), heading/link defaults (typography.css)
  • components/ — One file per UI component, named to match the HBS partial or template it styles (e.g., header.csspartials/header.hbs)
  • layout/ — Structural concerns: container widths (container.css), responsive breakpoint overrides (responsive.css, loaded last)

Import order in app.css matters — base first, components in any order, responsive overrides last.


TypeScript Setup

The base template uses TypeScript by default. Bun handles .ts files natively — no tsc, no ts-loader, no separate compilation step. Bun strips types at build time using its built-in transpiler.

The tsconfig.json in the theme root is for editor tooling and type checking only:

{
    "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "strict": true,
        "noEmit": true,
        "lib": ["ES2020", "DOM", "DOM.Iterable"]
    },
    "include": ["assets/src/**/*.ts"]
}

"noEmit": true means tsc is only used for type checking — Bun does the actual compilation. Run bun run typecheck (alias for tsc --noEmit) to catch type errors without building.

Adding a new component:

Create a .ts file in assets/src/components/ and import it from app.ts:

// assets/src/components/gallery.ts
export function initGallery(): void {
    // ...
}

// assets/src/app.ts
import { initGallery } from "./components/gallery";

Bun resolves the import chain and bundles everything into a single assets/built/app.js.

Mixing .ts and .js

Bun resolves cross-extension imports transparently. A .ts file can import a .js module and vice versa.


Source Maps

Source maps are not enabled in the base template by default. Add them via:

const result = await Bun.build({
    entrypoints: [...],
    outdir: outDir,
    minify: !isWatch,
    target: "browser",
    sourcemap: isWatch ? "inline" : "none",
});

sourcemap values:

  • "none" — no source maps (default; recommended for production)
  • "inline" — embeds the map as a base64 data URI at the end of the output file; no extra .map file on disk
  • "external" — writes a separate app.js.map file alongside app.js

When to enable:

  • During development (--watch mode), "inline" maps give browser DevTools the original source file and line numbers with no extra network request.
  • For production (bun run build), keep "none". Source maps expose your original source to anyone who opens DevTools, and Ghost serves assets/built/ publicly with no access restrictions.

Ghost-specific consideration:

Ghost does not serve .map files with special headers. If you use "external" maps and push to production, the .map files will be publicly accessible at https://yoursite.com/assets/built/app.js.map. This is usually acceptable for open-source themes but undesirable for commercial ones. Use "inline" only in local development or strip maps from the zip by adding 'assets/built/*.map' to the zip exclusion list.


Multi-Entrypoint Builds

Split your JavaScript when different pages need substantially different code and you don't want to ship unused code to every visitor.

Example: post page vs. homepage

entrypoints: [
    path.join(srcDir, "app.ts"),       // shared utilities + homepage
    path.join(srcDir, "post.ts"),      // post-specific: syntax highlighting, etc.
],

Bun outputs assets/built/app.js and assets/built/post.js. Reference them conditionally in templates:

In default.hbs (loads on every page):

<script src="{{asset "built/app.js"}}"></script>

In post.hbs (loads only on post pages):

<script src="{{asset "built/post.js"}}"></script>

Shared module deduplication

When multiple entrypoints import the same module, Bun by default inlines the module into each bundle (no shared chunk). For Ghost themes this is usually fine — theme JS is small. If you need a shared chunk to avoid duplicating a large dependency across bundles:

const result = await Bun.build({
    entrypoints: [...],
    outdir: outDir,
    minify: !isWatch,
    target: "browser",
    splitting: true,   // enables code-splitting / shared chunks
});

With splitting: true, Bun emits a chunk-*.js file containing the shared code. You must then load it before the page-specific bundles. Because Ghost templates don't have a bundler-aware loader, you need to add the chunk <script> tag manually — or accept the duplication and skip splitting.

CSS per page

For per-page CSS, add the CSS copy to your build loop:

for (const name of ["app", "post"]) {
    copyFileSync(
        path.join(srcDir, `${name}.css`),
        path.join(outDir, `${name}.css`)
    );
}

Then in the appropriate template:

<link rel="stylesheet" href="{{asset "built/post.css"}}">

Asset Versioning with {{asset}}

Ghost's {{asset}} helper resolves a theme-relative path and appends a cache-busting query string automatically:

{{asset "built/app.js"}}
→ /assets/built/app.js?v=abc123def456

The v= parameter is derived from the theme's version string in package.json combined with the file's last-modified timestamp. Ghost recalculates it on every theme activation and on every Ghost restart.

Practical implications:

  • You never need to manually version filenames (no app.abc123.js hashing).
  • Browser caches are invalidated on theme upload because Ghost restarts the theme engine, which recalculates the v= parameter.
  • During local development with bun run dev, the v= parameter updates each time Ghost restarts, not on each file change. If you need instant cache-busting during development, use browser DevTools → Network → Disable cache, or use browser-sync (which bypasses the cache entirely via its proxy).

Always use {{asset}} for built files — never hardcode paths:

{{! correct}}
<link rel="stylesheet" href="{{asset "built/app.css"}}">
<script src="{{asset "built/app.js"}}"></script>

{{! wrong — no cache-busting, breaks on CDN installs}}
<link rel="stylesheet" href="/assets/built/app.css">

{{asset}} with a leading slash

The ghost_root asset prefix (e.g., /content/themes/my-theme/) is prepended automatically. You must not add a leading slash to the path argument:

{{asset "built/app.js"}}    ← correct
{{asset "/built/app.js"}}   ← wrong, produces double-slash on some installs

SKILL.md

tile.json