omnibridge / static
The static host of the omnibridge family: a generic exporter that enumerates an application's addresses (explicit lists, a router's #[Route] routes with the values of their parameters, a sitemap), renders each at build time by the application's own code (its host's request handling, its actions, or
Requires
- php: >=8.2
Requires (Dev)
- phpunit/phpunit: ^11.5
- psr/container: ^1.1|^2.0
- symfony/http-foundation: ^6.4|^7.0|^8.0
- symfony/routing: ^6.4|^7.0|^8.0
Suggests
- psr/container: Render\ActionRenderer: pages rendered by calling the route's action, taken from a PSR-11 container
- symfony/http-foundation: Render\RequestRenderer: pages rendered through a host's own request handling (Symfony's kernel, Laravel's)
- symfony/routing: Route\RouteCollectionProvider: the pages of a router's routes (Symfony's router, omnibridge/laravel's Routing\ActionRoutes, symfony/routing's attribute loader)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-26 10:05:04 UTC
README
The static host of the omnibridge family: a generic exporter. It enumerates an application's addresses, renders each at build time with the application's own code, and writes a folder that any static host or CDN serves without PHP. It is not a documentation generator: blogs, documentation versions and package catalogues belong to the application it exports.
explicit addresses (lists, routes + parameter values, a sitemap)
|
build-time rendering by the application's own code
|
HTML + files the pages need + redirections + 404.html + sitemap.xml
|
a deterministic folder with omnibridge-static.json (checksums, diagnostics)
|
static host / CDN
No framework, no bundler, no extension: the package requires PHP 8.2 and
nothing else (BareTest loads every class with no vendor/ at all). The
adapters for symfony/routing, symfony/http-foundation or a PSR-11 container
name those types only in their signatures, so they need them only when used.
composer require omnibridge/static:1.x-dev
Plain PHP first
A site is declared in one PHP file that returns an Omnibridge\Static\Site:
use Omnibridge\Static\Render\CallableRenderer; use Omnibridge\Static\Route\PageList; use Omnibridge\Static\Site; use Omnibridge\Static\StaticPage; use Omnibridge\Static\StaticResponse; return (new Site('https://example.org')) ->provide(new PageList(['/', '/posts/hello', '/old-blog'])) ->renderWith(new CallableRenderer(fn (StaticPage $page) => match ($page->path) { '/' => render('home.php'), '/posts/hello' => render('post.php', ['slug' => 'hello']), '/old-blog' => StaticResponse::redirect('/'), default => StaticResponse::notFound(render('not-found.php')), })) ->publicDir(__DIR__.'/public') ->assets(__DIR__.'/public/build/manifest.json') // Vite, Webpack or Encore ->copy('robots.txt', 'favicon.ico') ->notFound('/404');
vendor/bin/omnibridge-static build site.php --out=dist # exit 1 on an error (--strict: on a warning too) vendor/bin/omnibridge-static check dist # the files against omnibridge-static.json
examples/plain/ is a complete one (PHP templates, a manifest, a font a style
sheet refers to, a redirection); the CI builds it, checks it, builds it again
and compares, and keeps the folder as an artifact.
The file may also return a closure that takes what a host offers
(Cli::HOST_RENDERER, HOST_ROUTES, HOST_CONTAINER, HOST_PUBLIC_DIR):
the Symfony and Laravel commands pass them, the plain command line passes
nothing and the file boots the application itself.
The contracts
The master plan's illustrative contracts, as built:
| Type | What it is |
|---|---|
StaticPage |
an address to export: its path (no query string), the route and parameters it came from, the action the route runs, its language and its alternates, whether the sitemap lists it, its role (page, page for unknown addresses, resource) |
StaticResponse |
what the page answered: status, body, content type, the location of a redirection |
StaticRouteProviderInterface::pages(): iterable<StaticPage> |
where the addresses come from |
StaticRendererInterface::render(StaticPage): StaticResponse |
how one is rendered |
How a site declares its static addresses
Explicitly, and nothing is left out without being said so:
-
Route\PageList: the paths written out (or a closure that gives them). -
Route\RouteCollectionProvider: a router's routes (a symfony/routingRouteCollection: Symfony's router, the one omnibridge/laravel'sRouting\ActionRoutesreads from an application's#[Route]actions, symfony/routing's attribute loader in plain PHP). A route without a variable is one page; a route with variables is exported for the values a source gives, by the route's name or, for a localized route (blog.fr), by its canonical name:new RouteCollectionProvider($routes, parameters: [ 'app_article' => fn () => $db->query('SELECT destination, slug FROM article WHERE published')->fetchAll(), 'app_index' => fn () => [['destination' => 'japon'], ['destination' => 'usa']], ], exclude: ['app_*_comments', 'newsletter_*'], // fnmatch patterns actions: ['App\Action\\'], // the routes of these actions only );
Only GET routes are read. A route with variables and no source, a value its requirement refuses, a POST route are skipped and reported (
skipped-route). A localized route's languages are each other's alternates (hreflangin sitemap.xml). -
Route\SitemapProvider: the addresses of the application's own sitemap (rendered at build time, or a file; a sitemap index is followed).
Routes that must not be static (signed-in pages, posted forms, payments,
per-visitor JSON) are not enumerated, and Site::dynamic() names them so that
links to them are not broken links (see below).
Rendering with the application's own code
-
Render\RequestRenderer: a GET request of the address handled in this process by the host - the route matched, the same action run, the same templates rendered, the host's listeners on the way. Symfony's kernel and Laravel's both speak symfony/http-foundation:new RequestRenderer(fn (Request $request) => $kernel->handle($request), 'https://example.org'); new RequestRenderer(fn (Request $request) => $app->make(Kernel::class)->handle(\Illuminate\Http\Request::createFromBase($request)), 'https://example.org');
No cookie, no session: every page is a visitor's. The host's terminate step is not run (a host that counts page views there counts none).
-
Render\ActionRenderer: the route's action called directly from a PSR-11 container (its route parameters by name, an empty$query, its services by type), its result turned into a page by the application's own answer (omnibase'sBase\Screen\Negotiation, a template engine). Nothing of the host around the action: use it where the action's result is the whole page. -
Render\CallableRenderer: any callable.
One process renders every page, as a long-lived worker (FrankenPHP's worker
mode, a queue consumer) would serve them. A service that keeps what one
request left it shows it on the next page; --verify (each page rendered
twice) and a page-by-page comparison with the dynamic site find these.
What the export does
- Pages are written as a static host serves them:
/asindex.html,/blog/helloasblog/hello/index.html(nginx:try_files $uri $uri/index.html =404;), a path ending in.htmland any other file at its path. Two pages for one file is an error (duplicate-output), the same page twice a warning (duplicate-page), a page that is neither HTML nor a path with an extension an error (not-exportable). - A redirection is a small page (meta refresh and canonical link) and a line
of
_redirects(Netlify, Cloudflare Pages; not for GitHub Pages, which reads none) andredirects.json. A target keeps its own anchor (/docs/flowjs#sticky). A static host never sees the visitor's own#fragment:StaticResponse::redirect($to, 301, fragment: 'sticky-')- or, throughRequestRenderer, a response headerX-Static-Fragment: sticky-beside itsLocation- carries it over, prefixed, with a line of script before the refresh:/docs/flowjs/sticky#keyslands on/docs/flowjs#sticky-keys(a page merged into another, its anchors prefixed); without script, the target's own anchor. - The page for unknown addresses (
Site::notFound()) is404.html. - Every internal link of the written pages and style sheets is followed: to a
page or file of the export, to a file of the public folder (copied, symbolic
links followed), to a file the application serves (rendered and written, a
style sheet's own links followed in turn), and otherwise reported
(
broken-link, with the first page that links to it and how many more). Read:hrefof links,actionof forms,src,srcset,poster, the lazy loaders'data-src/data-srcset/data-poster,<link href>, an SVG's<use href>and<image href>, a<meta http-equiv="refresh">,url()and@import, and the attributesSite::resourceAttributes()adds (a gallery'sdata-thumb). Not read: what a script builds at run time. URI templates (/tiles/{z}.png) are no addresses. Site::dynamic($patterns, $host): addresses that stay on the dynamic host. Given its origin, their links and forms, and every form posted (method="post", which a static host cannot take), are rewritten to it, and each is listed (dynamicnotices); without one, a posted form is a broken link.Site::normalise(): what changes from one request to the next (a CSP nonce, a form token, a render time, a random badge) taken out of each page, so that two builds give the same folder.sitemap.xmllists the pages (and their alternates), with no date.omnibridge-static.json: every file's SHA-256 and size, the pages and the file each went to, the redirections, the diagnostics - sorted, no date. A build that stops half-way leaves the folder marked, and the next replaces it; a folder holding anything else is refused.
Deploying to GitHub Pages
GitHub Pages serves a project site under a folder of its owner's origin
(https://<owner>.github.io/<repository>/), a user or organisation site and
a custom domain at the root. Give the site its public address and the
target:
return (new Site(getenv('SITE_URL') ?: 'https://owner.github.io/repository/')) // ... providers, renderer, assets, as anywhere ->notFound('/404') ->githubPages(); // or: omnibridge-static build site.php --target=github-pages
- The base path. The application renders its pages at the root of the
origin, as it always does (
RequestRenderergiven the origin, not the folder:(new Site($url))->origin()). Once the pages are written, every internal address of the export is moved under the folder (Output\BasePath): in HTML every address the exporter reads (links, forms,src,srcset,poster,<link>, a style'surl(), a refresh, theSite::resourceAttributes()attributes - a search page's index URL), in style sheets theirurl()and@import, in JSON the strings that are the root-relative address of a page or file of the export, and everywhere (HTML, CSS, JSON, feeds,sitemap.xml,robots.txt) the absolute URLs of the origin - canonical andhreflanglinks, Open Graph, JSON-LD, a share link's percent-encoded URL.sitemap.xmland the redirection pages are written at the base URL. An address already under the folder is left as it is (an application that knows its base path writes them so). - 404.html at the root of the export, which Pages serves for any unknown
address under the site: its relative addresses are resolved against the
address it was rendered at (it answers at any depth). Pages also answers
/a/bwith a redirection to/a/b/whena/b/index.htmlis the file, so every page's relative addresses are resolved too. .nojekyll(no Jekyll: files and folders starting with_are served),CNAMEwhen the base URL's host is a custom domain (a custom domain is served at its root: a base URL with a path and a custom domain is refused).- Redirections as small pages only: Pages reads no
_redirects(redirects.jsonstays, at the public addresses). - The check. Every written HTML, CSS, JSON, XML and text file is read
again: an address left outside the folder is an error (
outside-base), andomnibridge-static check distruns the same check on a folder before it is published. A warning (github-pages) when there is no page for unknown addresses, a file above 100 MB or a site above 1 GB. - Not moved: what a script builds at run time (
fetch("/api")in a script). A script reads its addresses from the page - adata-attribute named bySite::resourceAttributes(),document.baseURI- or from a bundler's public path that names the folder.
The workflow (a repository whose Settings → Pages → Source is "GitHub Actions"):
permissions: contents: read jobs: build: runs-on: ubuntu-24.04 permissions: { contents: read, pages: read } steps: - uses: actions/checkout@v4 - id: pages uses: actions/configure-pages@v6 # ... PHP, dependencies - run: vendor/bin/omnibridge-static build site.php --out=dist --target=github-pages --strict env: SITE_URL: ${{ vars.SITE_URL || steps.pages.outputs.base_url }} - run: vendor/bin/omnibridge-static check dist - uses: actions/upload-pages-artifact@v5 with: { path: dist, include-hidden-files: true } # .nojekyll deploy: needs: build runs-on: ubuntu-24.04 permissions: { pages: write, id-token: write } environment: { name: github-pages, url: '${{ steps.deployment.outputs.page_url }}' } steps: - id: deployment uses: actions/deploy-pages@v5
(Pin the actions by commit SHA in a real workflow.) To try the folder
locally, mount the export under it and serve it as Pages does - a folder
redirected to its address with a slash, 404.html for the rest:
server { listen 80; root /srv/www; # the export mounted at /srv/www/repository index index.html; absolute_redirect off; location / { } error_page 404 /repository/404.html; }
The Symfony and Artisan commands
Thin wrappers over the same Console\Cli, each in its host's bridge and
registered only when this package is installed:
- omnibridge/symfony:
bin/console omnibridge:static:build static.php --out=dist(the kernel's renderer, its router's routes, its container,public/). - omnibridge/laravel:
php artisan omnibridge:static:build static.php --out=dist(the HTTP kernel's renderer, the container,public/).
Verified, and not
Verified (2026-10-24):
- The suite: 71 tests (contracts, providers, renderers, links, manifests, the exporter, the base path and the GitHub Pages target, the command line and the plain script without Composer, the example), PHP 8.2; PHPStan at max, no baseline entry.
- GitHub Pages (2026-10-24): the Omnisense website (Symfony, omnibase 4, its
static.php) built forhttps://glitchr-omnisense.github.io/omnisense/: 1419 pages, 2254 files, 612 redirections, 47 MB, no diagnostic, no address outside/omnisense/; two builds identical (diff -r); its root build unchanged but for the pages whose text changed. Served under/omnisense/by an nginx that answers as Pages does (folders redirected to a slash, 404.html), headless Chrome on 10 pages at two widths and both colour schemes (landing in two languages, a guide, the package list and a package, a manual page, the news, the search with its index, 404.html at one and four levels): no console error, no failed request, no request nor link outside the folder. Not deployed: the repository is not created yet. - Les Ringo Stars (Symfony, omnibase 4): its public pages - the shared actions
of
src/Action, enumerated from Symfony's router with the values of their parameters read from the database - rendered by its own kernel: 243 pages, 898 files (the pictures the pages show, the Encore build, the map tiles), about 280 MB. Compared page by page with the live site, normalised as the twin's parity suite does, every difference is one of the handled kinds (form tokens emptied, posted forms sent to the dynamic host, the render time). Two builds give the same report;--verifyfinds no page rendered differently. Served by a plain nginx, headless Chrome on 19 pages: no console error and no failed request but the JSON the pages' scripts fetch from the dynamic host (below). - The example site: built, checked, built again, same report (CI).
- The wrappers, their working copies loaded by hand (not yet installed in
either application):
omnibridge:static:buildon lesringostars gives the same report as the plain command line but for the dev 404 page (Symfony's exception page, its trace); the artisan command on lesringostars-laravel rendered the twin's home, intro, destinations, two articles and the travels pages through Laravel, the routes read from lesringostars' actions. - A long-lived process shows what a service keeps between requests: on lesringostars, omnibase's AdvancedRouter (a localized route memoised under its canonical name: the first page's language stayed in every later page's links) and HtmlTagRenderer (a page's style sheet printed on the next pages). Both reset between requests since omnisense/omnibase d92b04c3 (ResetInterface); the export of lesringostars f4b94b7d needs no workaround.
Not done, or not verified:
- What a page's script fetches at run time (search feeds, "load more", a token-bound JSON) is not static: it is not enumerated, and a script that calls it from the static site gets a 404. It needs the dynamic host (cross-origin, with no session token) or a static JSON the application exports at a path with an extension.
- Incremental builds (dependency tracking) are not done: every build is a full, clean build.
- Assets are copied as the bundler fingerprinted them; the exporter fingerprints nothing itself, and records each file's checksum.
- A site under a folder of its origin moves what the export reads and the
origin's absolute URLs; an inline script's root-relative strings
(
fetch("/api")) are not read, and neither the export nor its check sees them. Same-origin absolute URLs are all taken for the site's own: another project site of the same<owner>.github.iolinked absolutely would be moved under the folder too. - GitHub Pages' own behaviour (the slash redirection, 404.html at any depth) was reproduced with nginx, not observed on Pages.
- Neither wrapper has run from an installed package (omnibridge/static is not yet on Packagist); the Laravel command has not exported a whole site.
- The dev environment's 404 page is Symfony's exception page, which differs from one build to the next; a deployable build is made in prod (on lesringostars, rendered at its production origin: 243 pages and two builds with the same report at first; later the same day the six search pages answered 500 in a prod container compiled afresh, a constructor of the site given another repository, outside this package).