cristianradulescu / staccato-bundle
Symfony bundle that turns Markdown content into a static site.
Package info
github.com/cristianradulescu/staccato-bundle
Type:symfony-bundle
pkg:composer/cristianradulescu/staccato-bundle
Requires
- php: >=8.5
- ext-ctype: *
- ext-iconv: *
- symfony/config: ^8.1
- symfony/console: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/event-dispatcher: ^8.1
- symfony/filesystem: ^8.1
- symfony/finder: ^8.1
- symfony/http-foundation: ^8.1.7
- symfony/http-kernel: ^8.1
- symfony/twig-bundle: ^8.1
- symfony/yaml: ^8.1.6
- tempest/highlight: ^2.29
- tempest/markdown: ^1.1
- twig/twig: ^3.14
Requires (Dev)
- fakerphp/faker: ^1.24
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.1
- symfony/framework-bundle: ^8.1
Suggests
- fakerphp/faker: Required for the site:build --use-fixtures option, which generates demo articles.
Provides
None
Conflicts
Replaces
None
README
Staccato is a Symfony bundle that turns Markdown content into a static site: pages, a paginated homepage, an RSS feed, and a sitemap, all rendered with Twig.
This repository is the engine — a reusable cristianradulescu/staccato-bundle package. It ships default templates and styling so you can focus on writing content instead of building a layout; it is meant to be required into a Symfony application (or a dedicated Staccato project skeleton, once published) that supplies content.
Requirements
- PHP 8.5+
- A Symfony 8.1+ application with
bin/consoleto install the bundle into (e.g. one created fromsymfony/skeleton)
symfony/twig-bundle is installed as a dependency of the bundle: it renders the default templates and makes them overridable (see "Customizing templates" below).
Quick start: a new blog
bin/create-blog.sh creates a Symfony application with Staccato installed, a sample article (plus a draft) and an About page, then builds it:
bin/create-blog.sh --title "My Blog" --base-url https://blog.example.com my-blog cd my-blog php -S localhost:8000 -t build
It checks for PHP 8.5+ and Composer first. The generated app requires PHP 8.5+, has no web front controller (public/, src/Controller/ and the skeleton's templates/base.html.twig are removed, since a static site only needs the console), and comes with empty assets/ and static/ directories and a GitHub Pages workflow in .github/workflows/pages.yml (delete it if you host elsewhere). Options: --bundle-path <path> installs the bundle from a local checkout instead of Packagist, and --no-build skips the initial build. Run it with --help to see all options.
Installation (into a consuming Symfony app)
composer require cristianradulescu/staccato-bundle
Register the bundles (if not done automatically by Symfony Flex):
// config/bundles.php return [ // ... Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Staccato\StaccatoBundle::class => ['all' => true], ];
Configure it under its own staccato key, e.g. config/packages/staccato.yaml:
staccato: title: My Site base_url: https://example.com description: A simple static site generated with Staccato. articles_per_page: 10 footer_text: Built with Staccato
The consuming application only needs to provide:
content/— Markdown source files
Everything else — layout, homepage pagination, 404 page, RSS/sitemap markup, and a default stylesheet — is shipped by the bundle and works out of the box. Optionally, the consuming app can also provide:
assets/— static files copied as-is into the build output (images, extra scripts, etc.)
Then build the site with the bundle's console command:
php bin/console site:build
Configuration reference
All keys are optional and live under the staccato root key:
| Key | Type | Default |
|---|---|---|
title |
string | Staccato |
base_url |
absolute URL | http://localhost |
description |
string | Static site generated with Staccato. |
language |
language tag (e.g. en, pt-BR) |
en |
articles_per_page |
positive integer | 10 |
footer_text |
string | Built with Staccato |
include_default_styles |
boolean | true |
stylesheets |
array | [] |
highlighter_css |
string | null |
content_dir |
relative path | content |
build_dir |
relative path | build |
assets_dir |
relative path | assets |
static_dir |
relative path | static |
content_dir, build_dir, assets_dir and static_dir are relative to your project root and may not contain . or .. segments. Because the build directory is removed on every clean build, build_dir may not equal, contain, or sit inside content_dir, assets_dir or static_dir. Whatever assets_dir is called, its files are published under build/assets/, so links like /assets/logo.png keep working. Files in static_dir are published at the root of the build directory instead — see "Output layout".
The rest of this README uses the default names (content/, build/, assets/).
Customizing templates
Staccato ships default templates under the @Staccato Twig namespace: base.html.twig, page.html.twig, home.html.twig, 404.html.twig, redirect.html.twig, rss.xml.twig, sitemap.xml.twig.
To override any single one of them, place a file at the matching path in your own app — Symfony's standard bundle template override convention:
templates/bundles/StaccatoBundle/page.html.twig
This file takes priority over the bundle's own page.html.twig, while every other default template keeps working unchanged. Override base.html.twig to change the overall layout, or override individual page templates to change just their content structure.
To add something to the layout (analytics, a favicon, navigation links, a linked footer), override base.html.twig by extending the bundle's own version and filling in only the blocks you need, so later changes to the default layout still reach your site:
{# templates/bundles/StaccatoBundle/base.html.twig #} {% extends '@!Staccato/base.html.twig' %} {% block head_extra %} <script async src="https://analytics.example/script.js"></script> {% endblock %} {% block nav %} <nav class="site-nav" aria-label="Main"> <a href="{{ staccato_path('/about/') }}">About</a> </nav> {% endblock %} {% block footer_content %} <p>Built with <a href="https://github.com/cristianradulescu/staccato-bundle">Staccato</a>.</p> {% endblock %}
@!Staccato refers to the bundle's original template, bypassing your override. Layout blocks in base.html.twig:
| Block | Default | Use it for |
|---|---|---|
head_extra |
empty, at the end of <head> |
analytics, favicon, extra meta tags or scripts |
header |
the whole <header> element |
replacing the header entirely |
site_title |
the site title link, inside the header | a custom title, logo or icon |
nav |
empty, after the site title | navigation links (<nav class="site-nav"> is styled by the default theme) |
footer |
the whole <footer> element |
replacing the footer entirely |
footer_content |
footer_text in a <p>, inside the footer |
footer markup with links |
Helpers available in every template:
staccato_path(path)— a site-relative link or asset path ({{ staccato_path(article.urlPath) }},{{ staccato_path('/assets/logo.png') }}). Use it for every internalhref/srcso links keep working when the site is served from a sub-path — see "Hosting under a sub-path" below.staccato_url(path)— the absolute URL underbase_url({{ staccato_url(page.urlPath) }}), for canonical links, feeds and social metadata.staccato— the site configuration ({{ staccato.title }},{{ staccato.description }},{{ staccato.language }},{{ staccato.baseUrl }}, ...).
The default base.html.twig renders <html lang> from language, a <meta name="description">, an RSS <link rel="alternate">, and exposes two blocks for page-specific head metadata: description (defaults to the site description; pages use their excerpt) and canonical (pages and homepage listings render an absolute <link rel="canonical">).
It also renders social metadata for link previews: og:site_name, og:type, og:title, og:description (same as the description block), og:url and a twitter:card of type summary. Pages use their own title and URL; dated pages are typed article and get an article:published_time. The values come from the og_type, og_title and og_meta (page-specific tags such as og:url) blocks; override social_meta to replace the whole set, e.g. to add an og:image.
Hosting under a sub-path
If the site is served from a sub-path rather than a domain root — e.g. a GitHub Pages project site at https://user.github.io/my-repo/ — include that path in base_url:
staccato: base_url: https://user.github.io/my-repo
The default templates prefix every internal link and stylesheet with it (/my-repo/about/, /my-repo/assets/staccato/staccato.css), and RSS/sitemap URLs include it as part of base_url. The build/ layout itself does not change: deploy its contents to the sub-path.
Two things to keep in mind:
- Links written by hand inside Markdown content are not rewritten. Use relative links (
../other-article/) or include the sub-path yourself. - With a sub-path
base_url, previewingbuild/from a local web server's root breaks links. Serve it under the same sub-path, or set the sub-pathbase_urlonly for the environment you deploy (e.g. inconfig/packages/prod/staccato.yaml).
Styling and themes
The bundle ships a small, dependency-free default theme (no Node/Tailwind build step). On every build it is copied to build/assets/staccato/staccato.css and linked from the default base.html.twig, so a new site is styled out of the box.
Three options control the stylesheets, independently of each other:
include_default_styles(defaulttrue) — copy and link the bundledstaccato.css.stylesheets— your own CSS files (paths absolute or relative to your project root). Each is copied tobuild/assets/staccato/<file name>and linked afterstaccato.cssand the highlighter stylesheet, so it can override them. File names must be unique and must not bestaccato.cssorhighlighter.css.highlighter_css— a syntax-highlighting theme for code blocks (path absolute or relative to your project root, e.g. one of the themes shipped invendor/tempest/highlight/src/Themes/Css/). It is copied tobuild/assets/staccato/highlighter.cssand linked; when unset, nothing is copied or linked.
Tweak the default theme by adding your own stylesheet on top of it:
staccato: stylesheets: ['themes/tweaks.css']
Replace the default theme with your own, with no template override needed:
staccato: include_default_styles: false stylesheets: ['themes/my-theme.css'] highlighter_css: 'vendor/tempest/highlight/src/Themes/Css/github-light.css'
For full control over the markup, override base.html.twig as described in "Customizing templates" above.
Writing content
Supported Markdown front matter keys:
title(string)slug(string)date(string; parseable date)excerpt(string)draft(boolean)aliases(list of URL paths; see "Redirects" below)
Any other key is passed through untouched to templates as page.meta (and article.meta for items in the homepage listing), so you can add your own:
--- title: Hello author: Jane Doe tags: [php, symfony] ---
{% if page.meta.author is defined %}<p class="byline">By {{ page.meta.author }}</p>{% endif %}
{% for tag in page.meta.tags ?? [] %}<span class="tag">{{ tag }}</span>{% endfor %}
Values keep their YAML types; an unquoted date such as updated: 2026-04-01 becomes a Unix timestamp, which Twig's date filter accepts.
Coming from Jekyll, Hugo or Jigsaw? There description usually feeds the <meta name="description"> and the post summary; in Staccato that is excerpt. A description key is only available as page.meta.description.
Notes:
- Missing
title/slugare derived from the source path:content/about.md->/about/,content/docs/setup.md->/docs/setup/. Files incontent/articles/drop thearticles/prefix (content/articles/hello.md->/hello/). - To give a directory its own page, add a sibling file:
content/docs.md->/docs/, next tocontent/docs/setup.md->/docs/setup/. index.mdfiles are not supported and fail the build with a hint on how to rename them. The homepage/is always generated fromcontent/articles/, so aslugresolving to/fails the build too.- Homepage listing only includes files from
content/articles/.
Redirects
When a page moves — renamed, or migrated from another generator with different URLs — list its old URLs under aliases:
--- title: GitHub pages with Jigsaw aliases: ['/2022-08-20-Github-pages-with-Jigsaw/', /blog/github-pages.html] ---
Each alias gets a small redirect page (redirect.html.twig: a meta refresh to the page, a canonical link to it and noindex, so search engines index only the new URL). Aliases are not in the sitemap, and aliases of drafts left out of a build are not written either.
- An alias is a URL path starting with
/. Unlike slugs it keeps its case and may contain dots, so old mixed-case URLs survive a migration. - A path ending in
.htmlis written as that file (/blog/github-pages.html->build/blog/github-pages.html); anything else is directory-style like page URLs (/Old-Post->build/Old-Post/index.html). - An alias may not point at
/, at another page, a homepage listing page, a generated file, anything under/assets/, another alias or a static file. Conflicts fail the build before the build directory is cleaned. - On a case-insensitive filesystem (macOS and Windows defaults), an alias that differs from its page's URL only by case is the same file as the page; the build fails rather than replace the page with a redirect to itself. Build on Linux or in CI to keep such aliases.
Markdown is rendered with tempest/markdown. Fenced code blocks with a language (```php) are syntax-highlighted — pick a theme with highlighter_css — and headings get id attributes, so #anchor links work. When excerpt is missing, it is derived from the first ~220 characters of the page's text.
site:build command
Options:
--clean(default) cleansbuild/before generation;--no-cleankeeps existing output.--use-fixturesgenerates 35 Faker-based demo articles instead of readingcontent/. It needs the optionalfakerphp/fakerpackage:composer require --dev fakerphp/faker.
Environment behavior:
- In
dev, drafts are included. - In
prod, drafts are excluded.
php bin/console site:build --env=prod
Output layout
Directory-style URLs are used:
/->build/index.html/about/->build/about/index.html/docs/getting-started/->build/docs/getting-started/index.html
Assets are mirrored from assets/ to build/assets/. The default stylesheet (unless disabled) is copied to build/assets/staccato/staccato.css. Special generated files: build/404.html, build/rss.xml, build/sitemap.xml.
Files that must live at the site root — favicon.ico, robots.txt, CNAME, .well-known/… — go in static/ (or whatever static_dir is set to), which is copied to build/ as-is, subdirectories and dotfiles included (version-control directories such as .git/ are skipped). A static file may not replace or nest inside generated output: index.html, 404.html, rss.xml, sitemap.xml, anything under assets/, and every page and pagination path are reserved. A conflict fails the build before the build directory is cleaned.
The default is static/ rather than public/ on purpose: a Symfony app's public/ holds its front controller (index.php), which must not end up in a static site.
Adding files after a build
When a build has written every page, feed, asset and static file, SiteBuilder dispatches a Staccato\Event\SiteBuiltEvent. Listen to it to add your own files to the build — redirects, a search index, an extra feed — in PHP instead of a shell step after site:build:
// src/EventListener/SearchIndexListener.php namespace App\EventListener; use Staccato\Event\SiteBuiltEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\Filesystem\Filesystem; #[AsEventListener] final class SearchIndexListener { public function __invoke(SiteBuiltEvent $event): void { $index = array_map( static fn ($page): array => ['title' => $page->title, 'url' => $page->urlPath, 'excerpt' => $page->excerpt], $event->pages, ); (new Filesystem())->dumpFile($event->buildDirectory.'/search.json', json_encode($index, JSON_THROW_ON_ERROR)); } }
The event carries buildDirectory (absolute path) and pages (every page written by this build, drafts only when they were included). Inject Staccato\Model\SiteConfig into the listener if you need the site configuration. The event is not dispatched when the build fails. An exception thrown by a listener fails site:build, but the files written so far stay in the build directory. Unlike static files, files written by listeners are not checked for conflicts, so a listener can deliberately replace a generated file.
Homepage and pagination
- Homepage lists only files from
content/articles/, ordered bydate(newest first). - Pagination uses
articles_per_page(default10). - Generated listing routes:
/(page 1),/page/2/,/page/3/, ...
Developing this bundle
composer installcomposer cs:check/composer cs:fixcomposer stancomposer testcomposer qa(runs all of the above)
Tests boot a minimal Symfony test kernel (tests/Fixtures/TestKernel.php) registering FrameworkBundle + TwigBundle + StaccatoBundle to exercise the bundle's configuration and DI wiring and to render the default templates end-to-end, alongside plain unit tests for the content-loading and build services.