# 5. Write your first plugin **Goal:** understand Laurel's extension model — what's wired today, what's typed but not yet loaded — and add a concrete extension to your site. --- ## How extension works in Laurel today > **Status note.** The plugin runtime is wired: list modules under > `plugins = […]` in `laurel.toml` and Laurel will load and invoke them at > the start of every build. Hook coverage is the full `Plugin` shape > exported from `laurel/plugin` (`beforeBuild`, `afterContentLoad`, > `beforeRender`, `afterRender`, `afterEmit`, `routes`, `transformMarkdown`). > The legacy `LaurelPlugin { setup }` shape still works as an alias so > older modules keep loading without changes. Set > `plugin_auto_detect = true` to also pick up packages named > `laurel-plugin-*` (or `@scope/laurel-plugin-*`) from `node_modules/`. The five extension surfaces, ranked by how much code you touch: | Surface | Code change | Use it for | | ----------------------- | ----------- | ------------------------------------------------ | | `[theme.custom]` | None | Theme-specific switches (header style, fonts) | | `[components.*]` | None | Toggle RSS / sitemap / OG images / Content API | | `codeinjection_*` | None | Per-post `` / `` snippets | | Custom helper | Small fork | A new `{{my_helper}}` for use in templates | | Plugin module | Plugin file | Markdown transforms, extra routes, custom hooks | --- ## Path A — Add a custom Handlebars helper This is the most "plugin-like" thing you can do today. You'll add a `{{word_count post}}` helper that themes can call. The pattern matches how all built-in Ghost helpers are registered. ### Step 1 — Clone Laurel locally The helper registration lives in `src/render/helpers/`. To add one you fork Laurel, register your helper, and depend on your fork in your blog project. (When the plugin runtime ships, this same code moves into a standalone module — see "Future-proofing" below.) ```bash git clone https://github.com/t09tanaka/laurel cd laurel bun install ``` ### Step 2 — Write the helper Create `src/render/helpers/word-count.ts`: ```ts import type { LaurelHelper } from '~/plugin.ts'; interface PostLike { html?: string; plaintext?: string; } export const wordCount: LaurelHelper = function (this: unknown, post: unknown) { const p = (post ?? this) as PostLike; const text = p.plaintext ?? p.html?.replace(/<[^>]+>/g, '') ?? ''; return text.trim().split(/\s+/).filter(Boolean).length; }; ``` ### Step 3 — Register it with the engine Open `src/render/engine.ts` (or wherever helpers are registered — search for `registerHelper`). Add: ```ts import { wordCount } from './helpers/word-count.ts'; // inside the helper registration block hb.registerHelper('word_count', wordCount); ``` ### Step 4 — Use it from a theme ```hbs {{!-- themes/source/post.hbs --}} ``` `{{word_count}}` reads the post from `this` context, just like `{{reading_time}}`. Pass an explicit post if you need to: `{{word_count @post}}`. ### Step 5 — Add a test and ship Mirror the source path under `tests/`: ```ts // tests/render/helpers/word-count.test.ts import { describe, expect, test } from 'bun:test'; import { wordCount } from '~/render/helpers/word-count.ts'; describe('word_count helper', () => { test('counts words in plaintext', () => { expect(wordCount.call(null, { plaintext: 'one two three' })).toBe(3); }); test('strips HTML before counting', () => { expect(wordCount.call(null, { html: '

one two

' })).toBe(2); }); test('reads from `this` when no arg given', () => { const ctx = { plaintext: 'four words right here' }; expect(wordCount.call(ctx)).toBe(4); }); }); ``` ```bash bun test tests/render/helpers/word-count.test.ts bun run check ``` Open a PR or run your fork against your blog with a path dependency. --- ## Path B — Author a typed plugin module (forward-compat) Write the module today against the published types; wire it up the moment the runtime ships. ### The shape From `laurel/types`: ```ts export interface BuildContext { readonly cwd: string; readonly outputDir: string; readonly config: LaurelConfig; readonly content: ContentGraph; readonly theme: ThemeBundle; } export interface LaurelPlugin { readonly name: string; setup?: (ctx: BuildContext) => void | Promise; } export type LaurelHelper = (this: unknown, ...args: unknown[]) => unknown; ``` ### A worked example: a build-time reading-list emitter ```ts // plugins/reading-list.ts import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { LaurelPlugin } from 'laurel/types'; export const readingListPlugin: LaurelPlugin = { name: 'reading-list', async setup(ctx) { const list = ctx.content.posts .filter((p) => !p.featured) .slice(0, 20) .map((p) => ({ title: p.title, url: p.url, date: p.published_at })); await writeFile( join(ctx.outputDir, 'reading-list.json'), JSON.stringify(list, null, 2), ); }, }; export default readingListPlugin; ``` `bun add laurel` in your blog project, and the import path `laurel/types` resolves to the published type definitions. The module type-checks today and will plug into the runtime when loader support ships — at which point you'll add to `laurel.toml`: ```toml # Not yet wired; pre-stage the config when you author plugins today. plugins = ["./plugins/reading-list.ts"] ``` Track loader status against the published types in [`src/plugin.ts`](../../src/plugin.ts). The comment at the top of that file is the source of truth. --- ## Path C — No-code extensions (use these first) Before writing TypeScript, check whether the thing you want is already a toggle. ### `[theme.custom]` — change a theme's behaviour by config ```toml [theme.custom] header_style = "Magazine" show_post_metadata = true enable_drop_caps_on_posts = true ``` These become `@custom.` in templates. The valid keys are theme-specific; for Source they're in `themes/source/package.json` under `config.custom`. ### `[components.*]` — turn optional features on/off ```toml [components.rss] enabled = true items = 20 [components.sitemap] enabled = true [components.opengraph] enabled = true [components.content_api] enabled = true # emits dist/content/posts/.json etc. [components.robots] enabled = true disallow = false # true → `Disallow: /` (use for staging) ``` ### `codeinjection_head` / `codeinjection_foot` — per-post snippets ```markdown --- title: My analytics-tracked post codeinjection_head: | codeinjection_foot: | --- ``` `{{ghost_head}}` and `{{ghost_foot}}` in the theme emit these blocks. Source already calls both. --- ## Path D — Markdown transform plugin (shortcodes / directives) The `transformMarkdown` hook on the `Plugin` interface lets a plugin rewrite the raw Markdown body of every post (or page) before `renderMarkdown` parses it. This is the right surface for shortcodes, custom directives, or any block-level rewrite that has to happen *before* sanitisation — anything you'd do with a `marked`-extension or remark-style plugin in another SSG. The hook signature lives in `src/plugin/types.ts`: ```ts transformMarkdown?: ( input: string, ctx: { kind: 'post' | 'page'; path: string; frontmatter: Readonly> }, ) => string | Promise; ``` Hooks compose in registration order; each transform sees the previous plugin's output. A throw is logged and the body falls through unchanged so one bad plugin can't take the whole build down. ### Example — a `{{}}…{{}}` shortcode ```ts // plugins/callout-shortcode.ts import type { Plugin } from 'laurel/plugin'; // Match block-form shortcodes like: // {{}} // Body markdown here. // {{}} const CALLOUT_RE = /\{\{<\s*callout(?:\s+type="(warn|info|success|danger)")?\s*>\}\}([\s\S]*?)\{\{<\s*\/callout\s*>\}\}/g; const calloutPlugin: Plugin = { name: 'callout-shortcode', transformMarkdown(body) { return body.replace(CALLOUT_RE, (_match, type: string | undefined, inner: string) => { const variant = type ?? 'info'; // Emit the Koenig callout-card HTML shape so existing kg-callout-card // CSS in the theme (Source, Casper, etc.) styles the result. // Blank lines around `inner` keep CommonMark parsing the body as // markdown, not as raw HTML. return [ '', `
`, '
', '', inner.trim(), '', '
', '
', '', ].join('\n'); }); }, }; export default calloutPlugin; ``` Wire it from `laurel.toml`: ```toml plugins = ["./plugins/callout-shortcode.ts"] ``` Use it in any post: ```markdown --- title: My post --- Intro paragraph. {{}} Heads up: this is a warning. **Bold text** still works. {{}} Outro. ``` After the next `bunx laurel build`, the rendered HTML contains a `kg-callout-card kg-callout-card-warn` block your theme styles. ### Picking the right hook | Goal | Hook | | ------------------------------------------------- | ------------------- | | Rewrite markdown source (shortcodes, directives) | `transformMarkdown` | | Add a Handlebars helper to all templates | `beforeBuild` | | Inject computed metadata into the content graph | `afterContentLoad` | | Tweak per-route context just before render | `beforeRender` | | Post-process the final HTML (e.g. minify / strip) | `afterRender` | | Emit extra files after the site is written | `afterEmit` | | Add generator-driven routes (custom feeds) | `routes` | `transformMarkdown` is the only hook that runs *during* content load — before the render engine exists — so it intentionally receives a slimmer context (`kind`, `path`, `frontmatter`) instead of the full `BuildContext`. Use `beforeRender` for hooks that need the engine or the full content graph. ### Testing a markdown transform plugin Markdown transforms are pure functions of `(input, ctx)`, so tests don't need a full build: ```ts import { describe, expect, test } from 'bun:test'; import calloutPlugin from '../plugins/callout-shortcode.ts'; describe('callout shortcode', () => { test('rewrites the shortcode into a kg-callout-card block', async () => { const out = await calloutPlugin.transformMarkdown?.( 'Before\n\n{{}}\nbody\n{{}}\n\nAfter', { kind: 'post', path: 'fake.md', frontmatter: {} }, ); expect(out).toContain('kg-callout-card kg-callout-card-warn'); expect(out).toContain('body'); expect(out).not.toContain('{{