A blog has two unusual constraints compared to the rest of the site:

  • you want to write posts in Markdown, in your editor, not the admin UI, and
  • you want the posts to live alongside the theme code in version control, not in the SQLite database.

That is exactly the use case for the bigins/scriptor-markdown-pages plugin. Install it, point it at a folder under your theme, and Scriptor synthesises a virtual Page for every markdown file. The chapter-4 navigation merge picks the blog up because the plugin contributes its own top-level NavItem.

At the end of this chapter Atelier has:

  • a content/blog/ folder under the theme,
  • one _index.md (the blog landing) plus a handful of post .md files,
  • a Blog entry in the top-nav, sorted into the right slot,
  • a /blog/ URL listing the posts,
  • a /blog/<slug>/ URL rendering each post with the theme's chrome.

Install the plugin

From the Scriptor root (not the theme folder), run:

composer require bigins/scriptor-markdown-pages

That writes the dependency into Scriptor's root composer.json and locks the version in composer.lock. The plugin auto- registers via Scriptor's plugin discovery (see Concepts → Plugin Discovery), so the next request boots it.

Gotcha: VCS repositories. If the plugin isn't on Packagist yet, or you're working against a fork, Composer needs an explicit repositories entry pointing at the Git URL. Your Scriptor install's composer.json likely already has one for this plugin; if composer require cannot resolve the package, that is the place to look.

Configure the content root and tracks

The plugin defaults content_root to <scriptor-root>/content/, and auto-discovers tracks from any immediate subdirectory of content_root that holds an _index.md. For Atelier we want the content tree to live with the theme and we want exactly one track (blog), so we set both keys explicitly in data/settings/custom.scriptor-config.php:

'plugins' => [
    'markdown_pages' => [
        'content_root' => __DIR__ . '/../../themes/atelier/content',
        'tracks'       => ['blog'],
    ],
],

Two things to absorb:

  • content_root is an absolute filesystem path. Building it via __DIR__ is the safest way; the file lives at data/settings/custom.scriptor-config.php, so two .. segments climb out to the Scriptor root and back into themes/atelier/content.
  • tracks is a whitelist of first URL segments the plugin will resolve. Anything else falls through to Scriptor's normal page resolution. Atelier only wants one track, blog, so the list has one entry. Without this key, the plugin would auto-discover every subdirectory of content_root that holds an _index.md and add each as a track — handy for a docs site, overkill for a portfolio.

Lay out content/blog/

Under the theme, create:

themes/atelier/content/blog/
├── _index.md
├── 2026-04-cathedrals.md
└── 2026-05-typography.md

Pick post slugs you actually want for URLs; the filename without the .md becomes the slug. So 2026-04-cathedrals.md is reachable at /blog/2026-04-cathedrals/.

_index.md (the blog landing):

---
title: "Blog"
summary: "Field notes on design, craft, and the occasional client war story."
track: blog
weight: 30
---

# Blog

Field notes, mostly short. Latest posts below.

The frontmatter shape mirrors what you saw in the existing Developer Guide tracks: title, summary, track, weight. The plugin reads weight to sort each track's pages; track is informational on this kind of file.

2026-04-cathedrals.md:

---
title: "Building cathedrals"
summary: "Slow work makes itself visible only at the end."
track: blog
weight: 10
---

# Building cathedrals

Most weeks I think I'm shipping pebbles. Then a year passes and
the cathedral is sort of there, slightly off-axis, but standing.

Repeat with whatever you like for the second post. weight: 10 and weight: 20 (lower wins) is fine, since the plugin sorts ascending so the lower number appears first in the listing.

Add markdown-section.php

The plugin tells Scriptor "the page for this URL has template = markdown-section". Your theme has to ship that template, otherwise template.php falls back to basic.php and the post renders with the basic layout (which is technically fine, just less expressive).

Create themes/atelier/markdown-section.php:

<?php include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--blog-post">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <article class="post">
            <header class="post-header">
                <h1><?= htmlspecialchars((string) $site->page->name) ?></h1>
                <?php if (isset($site->page->summary) && $site->page->summary !== ''): ?>
                    <p class="post-summary"><?= htmlspecialchars((string) $site->page->summary) ?></p>
                <?php endif ?>
            </header>
            <div class="post-body">
                <?= $site->render('content') ?>
            </div>
            <p class="post-back">
                <a href="<?= htmlspecialchars($site->siteUrl) ?>/blog/">← Back to blog</a>
            </p>
        </article>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

Two pieces worth a closer look:

  1. $site->render('content') already returns rendered HTML. The plugin substitutes the post body via the ContentRendering PSR-14 event (see Concepts → Frontend Events) and the renderer hands the Markdown through CommonMark before your template sees it. The template just prints; no Markdown handling on the theme side.
  2. Custom frontmatter is reachable through $site->page->__get. The Page value object exposes name, slug, template, content, parent as proper fields, and anything else from the data array (here: summary) via __get. That is the mechanism that makes per-post frontmatter useful to templates.

A blog-landing template

_index.md for the blog landing renders just fine through markdown-section.php, which is what you get if you do nothing else. But you probably want the landing to list posts, not just print "Field notes, mostly short."

The plugin lets any markdown file pick its own template right from the frontmatter, so the landing can opt out of the default without any PHP. Edit themes/atelier/content/blog/_index.md and add a template: key:

---
title: "Blog"
summary: "Field notes on design, craft, and the occasional client war story."
track: blog
weight: 30
template: blog-index
---

The plugin (since v0.1.7) reads template: and hands it to Scriptor's render layer, which picks themes/atelier/blog-index.php for this page only. The value has to look like a slug (^[A-Za-z0-9_-]+$) — letters, digits, underscore, hyphen. Anything else (slashes, dots, traversal attempts) is rejected and the page falls back to the default markdown-section. Posts keep no template: key because markdown-section.php is exactly what they want.

Then create themes/atelier/blog-index.php. Listing posts inside the theme means walking content/blog/. That's the one place a theme legitimately reads from the plugin's content directory, for its own listing, not to peek into post bodies:

<?php

$blogDir = __DIR__ . '/content/blog';
$posts   = [];

foreach (glob($blogDir . '/*.md') ?: [] as $file) {
    $basename = basename($file, '.md');
    if ($basename === '_index') {
        continue;
    }
    // Lightweight frontmatter sniff. The plugin parses YAML for
    // routing; the theme just needs title + summary + weight to
    // list. A real implementation would call into the plugin's
    // FrontmatterReader to avoid drift.
    $raw = file_get_contents($file) ?: '';
    $meta = ['title' => $basename, 'summary' => '', 'weight' => 0];
    if (preg_match('/^---\s*\n(.+?)\n---\s*\n/s', $raw, $m)) {
        foreach (preg_split('/\R/', $m[1]) ?: [] as $line) {
            if (preg_match('/^(\w+):\s*"?([^"\n]*)"?\s*$/', $line, $kv)) {
                $meta[$kv[1]] = $kv[2];
            }
        }
    }
    $posts[] = [
        'slug'    => $basename,
        'title'   => (string) $meta['title'],
        'summary' => (string) $meta['summary'],
        'weight'  => (int) $meta['weight'],
    ];
}

usort($posts, static fn ($a, $b) => $a['weight'] <=> $b['weight']);

include __DIR__ . '/resources/partials/head.php'; ?>
<body class="page page--blog-index">
    <?php include __DIR__ . '/resources/partials/header.php'; ?>
    <main>
        <header class="blog-header">
            <h1>Blog</h1>
            <?= $site->render('content') ?>
        </header>
        <ul class="post-list">
            <?php foreach ($posts as $post): ?>
                <li>
                    <a href="<?= htmlspecialchars($site->siteUrl . '/blog/' . $post['slug'] . '/') ?>">
                        <h2><?= htmlspecialchars($post['title']) ?></h2>
                        <?php if ($post['summary'] !== ''): ?>
                            <p><?= htmlspecialchars($post['summary']) ?></p>
                        <?php endif ?>
                    </a>
                </li>
            <?php endforeach ?>
        </ul>
    </main>
    <?php include __DIR__ . '/resources/partials/footer.php'; ?>
</body>
</html>

Gotcha: don't reach into the plugin's parsing internals. The frontmatter sniff above intentionally stays naïve because Atelier is a sample theme. In a production theme, register a Scriptor-side helper that wraps the plugin's FrontmatterReader and lets themes ask for "the metadata of the posts in track X" through a published surface (Plugin-owned data stays inside the plugin). The blog-index template above breaks that rule on purpose to keep the chapter self-contained; the Behind the scenes link at the bottom points at the right helper.

What you should see now

Reload the site. Visiting:

  • /blog/ should render blog-index.php with the two posts listed,
  • /blog/2026-04-cathedrals/ should render the post body in markdown-section.php,
  • the top-nav should show Blog alongside Home, About, Projects, Contact, sorted by the merged position from chapter 4.

If Blog does not appear in the nav, three things to check in this order:

  1. Plugin booted? data/cache/plugins.php should list bigins/scriptor-markdown-pages, and your scriptor-config.php must not list the plugin in plugins.disabled.
  2. _index.md reachable? Since v0.1.7 the plugin skips tracks whose _index.md is missing — that keeps the nav from advertising a link the Resolver would 404 on. If the nav slot is silent but you do have tracks => ['blog'], the most likely cause is that <content_root>/blog/_index.md is not where the config points; double-check the __DIR__ . '/../../themes/...' path resolves to the directory that actually holds the file.
  3. Sort order? The Atelier nav picks weight: 30 for the blog from its _index.md frontmatter. If Blog renders in an unexpected position, that is the lever.

To-do before chapter 6

  • composer require bigins/scriptor-markdown-pages clean.
  • scriptor-config.php overrides content_root and tracks for the plugin.
  • content/blog/_index.md plus two posts exist.
  • markdown-section.php and blog-index.php render.
  • Blog appears in the top-nav and /blog/ lists the posts.

Atelier has six surfaces now (Home, About, Projects, Project detail, Contact, Blog) plus the post template. Chapter 6 closes the loop: contact-form action, custom 404, and packaging the theme for distribution.


Behind the scenes. Plugin source lives at scriptor-markdown-pages. The PSR-14 events the plugin hooks (PageResolving, ContentRendering) are documented in Concepts → Frontend Events. The Atelier-style theme-local content tree is the same pattern this site uses; see scriptor-cms-site/theme/themes/info/content/ for a fuller example, including the multi-track setup.