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.mdfiles, - 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
repositoriesentry pointing at the Git URL. Your Scriptor install'scomposer.jsonlikely already has one for this plugin; ifcomposer requirecannot 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_rootis an absolute filesystem path. Building it via__DIR__is the safest way; the file lives atdata/settings/custom.scriptor-config.php, so two..segments climb out to the Scriptor root and back intothemes/atelier/content.tracksis 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 ofcontent_rootthat holds an_index.mdand 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:
$site->render('content')already returns rendered HTML. The plugin substitutes the post body via theContentRenderingPSR-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.- Custom frontmatter is reachable through
$site->page->__get. ThePagevalue object exposesname,slug,template,content,parentas 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
FrontmatterReaderand 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 renderblog-index.phpwith the two posts listed,/blog/2026-04-cathedrals/should render the post body inmarkdown-section.php,- the top-nav should show Blog alongside Home, About,
Projects, Contact, sorted by the merged
positionfrom chapter 4.
If Blog does not appear in the nav, three things to check in this order:
- Plugin booted?
data/cache/plugins.phpshould listbigins/scriptor-markdown-pages, and yourscriptor-config.phpmust not list the plugin inplugins.disabled. _index.mdreachable? Since v0.1.7 the plugin skips tracks whose_index.mdis missing — that keeps the nav from advertising a link the Resolver would 404 on. If the nav slot is silent but you do havetracks => ['blog'], the most likely cause is that<content_root>/blog/_index.mdis not where the config points; double-check the__DIR__ . '/../../themes/...'path resolves to the directory that actually holds the file.- Sort order? The Atelier nav picks
weight: 30for the blog from its_index.mdfrontmatter. If Blog renders in an unexpected position, that is the lever.
To-do before chapter 6
-
composer require bigins/scriptor-markdown-pagesclean. -
scriptor-config.phpoverridescontent_rootandtracksfor the plugin. -
content/blog/_index.mdplus two posts exist. -
markdown-section.phpandblog-index.phprender. - 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.