pushword/static-generator
Pushword Static Generator exports your Pushword site as static HTML, letting you deploy fast, secure, cache-friendly pages without running PHP in production. Ideal for blogs, docs, and brochure sites that need simple, reliable hosting.
The static generator is the deploy step for a Pushword site: it renders every Page through the normal Twig pipeline and writes plain HTML, assets and media into a target directory you can serve from nginx, S3, Netlify or GitHub Pages. It is not a separate templating layer — anything that works in the dynamic site renders identically here, which is the point.
First, get oriented in the monorepo rather than in this split package alone:
composer install at the repo root, then run console commands from packages/dev-app/ — composer console list pw is the fast path to every Pushword command, including the pushword:static:* namespace.packages/docs/content/architecture.md and the static-generator page under packages/docs/content/extension/. Skip the rest; the docs are one-topic-per-file and the index is the map.composer dev (or composer reset-dev-app for a clean SQLite database). You want to see a page render dynamically before you build it statically, so you can diff the two.Your first use case should be the smallest possible one: a single site, SQLite, one page, one image. Run the static build, point php -S localhost:8000 -t <target> at the output, and confirm the page and the image load with no dynamic fallbacks. If the page loads but the image 404s, you have a media-path config problem, not a generator problem — that distinction saves hours later.
Do not start by wiring CI. Get a manual build working, then a second build that changes nothing, and confirm it is fast (the generator skips unchanged pages via a content hash). That second run is the real smoke test; a build that always rebuilds everything is silently broken.
Local authoring loop. Author in the admin (composer dev), preview dynamically, then run the static build only when you are checking output fidelity — asset paths, canonical URLs, feed/sitemap contents. The dynamic preview is faster to iterate in; static builds are for verifying what ships.
CI as the only place that builds. Treat the static build as a pure function of committed content plus the lockfile. A workflow that builds on every push to main, caches var/cache/pushword-pools between runs, and syncs with rsync -a --delete (or aws s3 sync --delete) to the host is the canonical shape. Key the cache on the lockfile and on MarkdownParser::CACHE_VERSION / SplitContent::TOC_CACHE_VERSION, or you will ship stale rendered markdown after a template change.
Incremental by content hash. The generator's skip-unchanged behaviour is the feature you will lean on most: a typo fix in one page should touch one HTML file. Preserve that by feeding it a stable environment — same working directory, same locale and timezone, no injected build timestamps. When it degrades to a full rebuild, that is a determinism bug, not a performance bug, and it usually originates in a template.
Multi-site and multi-locale. Run one build per site/locale with a distinct target directory and base_url, rather than trying to fan out inside one build. This makes parallel CI jobs trivial and keeps each output directory atomically swappable.
Deploy as an atomic swap. Build into a fresh directory (or a versioned prefix) and flip the symlink or CDN pointer when it completes. A half-written static site behind a live host is worse than a stale one, and the generator has no transactional output of its own.
Staging builds. Generate a second artifact for staging with base_url pointed at the staging host and a noindex flag in the layout. Two builds from one content tree is cheap; trying to rewrite URLs after the fact is not.
Content in Git, build in CI. Pushword supports flat-file pages alongside the database, which means the highest-leverage pattern is: markdown pages in the repo, no database in CI at all, generator runs against the files, output deployed. That drops your build image to PHP plus Composer.
Byte-determinism is a hard requirement, not a nicety. The skip-unchanged logic and the content-hash cache both assume two renders of unchanged input produce identical bytes. A {{ "now"|date }} in a footer, a random() in a "related pages" shuffle, an absolute path leaking into a meta tag — any of these makes the cache never hit and turns every build into a full rebuild. Debug by building twice into two directories and diff -r-ing them; the first divergent line names the culprit.
cache:clear does not reach the markdown fragment pool. var/cache/pushword-pools is deliberately kept across deploys. If you change how markdown renders and the site still shows the old output, you have not cleared a cache — you have not bumped MarkdownParser::CACHE_VERSION (or SplitContent::TOC_CACHE_VERSION for the table of contents). This is the single most common "the generator is ignoring my change" report.
Run bin/console from packages/dev-app/, not the repo root. The root is a monorepo of bundles; the app that wires them together is the dev app. Every "command not found" or "no site configured" error traces back to this.
Schema changes use doctrine:schema:update --force. There are no migrations. On a fresh SQLite database after a pull, run it before you assume the generator is broken — it is usually an empty table.
Check the command names before scripting them. The namespace is pushword:static:*; confirm with composer console list pushword:static rather than trusting a blog post. Commands that support agent output emit a single JSON line when auto-detected, so a CI pipeline that parses human progress output will break when it runs inside an agent context — pass --format text where you need stable prose, or parse the JSON deliberately.
base_url must exactly match the deploy origin, including trailing-slash conventions. Permalinks, canonical tags, sitemap entries and feeds are all generated from it, and relative-vs-absolute mistakes only surface after deploy, not in the local preview.
Media is copied, not linked. If your deploy relies on symlinked media into a shared CDN bucket, the generator's copy step will fight you; either exclude media from the build and serve it from the shared origin, or make the copy the source of truth. Mixing the two produces a site that works locally and 404s on the CDN.
Order feeds and sitemaps by a stable key. Sorted by date alone, two pages published the same day can swap order between builds and reintroduce nondeterminism.
Extension points are the usual Pushword ones. Override templates in your site theme's Twig paths, hook the core events listed in packages/core/src/Event/PushwordEvents.php, and register custom page-type renderers rather than forking the generator. Anything you add to a render path inherits the determinism constraint above.
When output looks wrong, diff dynamic against static first. Render the page through composer dev and through the generator and compare the HTML. That tells you instantly whether the bug is in your template (both wrong) or in the build step (only static wrong).
How can I help you explore Laravel packages today?