Skip to content

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 any callable), and writes a deterministic folder for a static host or a CDN - HTML pages, the files of an asset manifest (Vite, Webpack), the files the pages refer to, redirections, 404.html, sitemap.xml and a report with checksums and diagnostics (broken links, duplicate outputs, missing assets, non-deterministic pages). No framework, no bundler.

Floor
Bridge
Version
1.x
Verified for real
Verified in part
Licence
MIT
composer require omnibridge/static:1.x-dev

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. Installation runs it.

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 (Hosts).

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 this manual is organised

  • Installation: requirements, the command line, the example built and checked.
  • Hosts: the site file's closure, the Symfony and Artisan commands.
  • Addresses: PageList, RouteCollectionProvider, SitemapProvider, writing one.
  • Rendering: RequestRenderer, ActionRenderer, CallableRenderer, one process.
  • The export: files, redirections (and X-Static-Fragment), 404, links followed, the dynamic host, normalisers, sitemap.xml, omnibridge-static.json.
  • GitHub Pages: the base path, .nojekyll, CNAME, the check, the workflow.
  • Reference: Site, StaticPage, StaticResponse, Report - every method and field.
  • Errors: every diagnostic code, the exceptions, the exit codes.
  • Testing, Verified, and not.

Licence

MIT.

Propose a change docs/index.md

Installation

Requirements

PHP 8.2 or later, nothing else. Three packages are suggested, each needed by one class only:

Package Needed by
symfony/routing (6.4, 7 or 8) Route\RouteCollectionProvider
symfony/http-foundation (6.4, 7 or 8) Render\RequestRenderer (its constructor says so with a LogicException when it is missing)
psr/container (1.1 or 2) Render\ActionRenderer
composer require omnibridge/static:1.x-dev

The command line

vendor/bin/omnibridge-static (bin/omnibridge-static): loads the project's Composer autoloader when the package is installed in one (never the package's own vendor/), and the package's own classes from src/ otherwise - so a checkout runs it with no Composer at all.

omnibridge-static build <site.php> [--out=dist] [--target=github-pages] [--verify] [--strict] [--quiet]
omnibridge-static check <dist>
Argument or option Default Effect
build <site.php> static.php the file that returns a Site (or a closure that returns one)
--out= dist the folder to export to: created, or emptied when it holds an earlier export (an omnibridge-static.json); a folder holding anything else is refused
--target=github-pages none Site::githubPages() (GitHub Pages); another value is refused
--verify off every page rendered twice; a page that differs is an error (not-deterministic)
--strict off a warning fails the build too
--quiet off no progress lines; of the diagnostics, the errors only; the counts stay
check <dist> dist every file of the report there with its checksum, no file more, and, for a site under a folder of its origin, no internal address outside that folder

Options are written --name=value or --name; a value separated by a space is read as an argument. Every line goes to standard error.

Exit code When
0 built (or checked) with no error (and no warning with --strict)
1 an error (or, with --strict, a warning); check: a problem
2 an invalid argument: no such file, a file that returns no Site, an unknown target, a folder without omnibridge-static.json for check; no command or an unknown one (the usage is printed)

The example, built and checked

examples/plain/site.php: two posts in a PHP array rendered by PHP templates, a Webpack-style manifest naming the style sheet, the font the style sheet refers to, robots.txt, a redirection (/old-blog to /), a page for unknown addresses. Built at the root, checked, then built for GitHub Pages under https://owner.github.io/example/ (STATIC_ORIGIN) and checked, in the package's image omnibridge-core:php8.2 (2026-10-27):

$ omnibridge-static build site.php --out=/out/dist
5/5 pages rendered
3 pages, 11 files, 1 redirections; 0 errors, 0 warnings
exit 0
$ find /out/dist -type f | sort
./404.html
./_redirects
./build/app.3f2a1c.css
./build/example.woff2
./index.html
./old-blog/index.html
./omnibridge-static.json
./posts/hello/index.html
./posts/static/index.html
./redirects.json
./robots.txt
./sitemap.xml
$ cat _redirects
/old-blog / 301
$ cat old-blog/index.html
<!DOCTYPE html><html><head><meta charset="utf-8"><title>/</title><meta http-equiv="refresh" content="0; url=/"><link rel="canonical" href="/"><meta name="robots" content="noindex"></head><body><a href="/">/</a></body></html>
$ omnibridge-static check /out/dist
11 files checked, 0 problems
exit 0
$ STATIC_ORIGIN=https://owner.github.io/example/ omnibridge-static build site.php --out=/out/pages --target=github-pages --strict
5/5 pages rendered
3 pages, 11 files, 1 redirections; 0 errors, 0 warnings
exit 0
./.nojekyll
./404.html
./build/app.3f2a1c.css
./build/example.woff2
./index.html
./old-blog/index.html
./omnibridge-static.json
./posts/hello/index.html
./posts/static/index.html
./redirects.json
./robots.txt
./sitemap.xml
$ grep -o "href=\"[^\"]*\"" posts/hello/index.html
href="/example/build/app.3f2a1c.css"
href="/example/"
href="/example/"
11 files checked, 0 problems
exit 0
$ head -20 omnibridge-static.json (the root build)
{
    "generator": "omnibridge/static",
    "baseUrl": "https://example.org",
    "pages": {
        "/": {
            "file": "index.html",
            "status": 200,
            "route": null,
            "locale": null
        },
        "/posts/hello": {
            "file": "posts/hello/index.html",
            "status": 200,
            "route": null,
            "locale": null
        },
        "/posts/static": {
            "file": "posts/static/index.html",
            "status": 200,
            "route": null,
  • Five pages rendered: the three listed, the redirection and the page for unknown addresses; three pages in the report (/, the two posts), one redirection.
  • The font is found by following the style sheet's url(); the style sheet by the manifest.
  • For GitHub Pages: .nojekyll, no _redirects, every internal address moved under /example/.
  • The file count (11) leaves out omnibridge-static.json itself.

Propose a change docs/installation.md

Hosts

The plain command line and the hosts' commands share one Console\Cli. A host hands the site's file what it offers; the file chooses what to use.

The site file's closure

// static.php
use Omnibridge\Static\Console\Cli;
use Omnibridge\Static\Route\RouteCollectionProvider;
use Omnibridge\Static\Site;

return static function (array $host): Site {
    return (new Site('https://example.org'))
        ->provide(new RouteCollectionProvider($host[Cli::HOST_ROUTES], parameters: [/* ... */], actions: ['App\Action\\']))
        ->renderWith($host[Cli::HOST_RENDERER])
        ->publicDir($host[Cli::HOST_PUBLIC_DIR])
        ->notFound('/404');
};
Key Constant Symfony Laravel Plain command line
host Cli::HOST_NAME symfony laravel absent
renderer Cli::HOST_RENDERER a RequestRenderer over the kernel's handle() a RequestRenderer over the HTTP kernel absent
routes Cli::HOST_ROUTES the router's RouteCollection given --actions, the routes omnibridge/laravel's Routing\ActionRoutes reads from the actions of those folders; absent otherwise absent
container Cli::HOST_CONTAINER the kernel's container the application absent
publicDir Cli::HOST_PUBLIC_DIR <project>/public public_path() absent

Cli::load(string $file, array $host = []): Site requires the file, calls the closure with the offer, and refuses anything that is not a Site (InvalidArgumentException). From the plain command line the offer is empty: the file loads its application's autoloader and boots what it needs itself.

omnibridge/symfony: bin/console omnibridge:static:build

Registered by omnibridge/symfony's config/services.php when this package is installed (Omnibridge\Symfony\Static\BuildCommand):

bin/console omnibridge:static:build static.php --out=var/static [--render-origin=https://example.org] [--verify] [--strict] [--quiet-progress]

--out defaults to var/static; --render-origin to the router's request context (scheme, host, port unless 80 or 443); --quiet-progress is the command line's --quiet. There is no --target option: a site for GitHub Pages calls ->githubPages() in its file. omnibridge/symfony's manual has the details.

omnibridge/laravel: php artisan omnibridge:static:build

Registered by omnibridge/laravel's OmnibridgeServiceProvider when this package is installed (Omnibridge\Laravel\Static\BuildCommand):

php artisan omnibridge:static:build static.php --out=storage/static --actions='App\Action\=src/Action' [--render-origin=...] [--verify] [--strict] [--quiet-progress]
Argument or option Default Effect
site static.php relative to the application's base path unless absolute
--out storage/static relative to the base path unless absolute
--render-origin config('app.url'), else http://localhost the origin the pages are rendered at
--actions=Namespace\=folder none (repeatable) the actions whose #[Route] attributes give the offer's routes
--verify, --strict, --quiet-progress off as the command line's --verify, --strict, --quiet

Each page goes through the HTTP kernel as Illuminate\Http\Request::createFromBase() of the renderer's request. An invalid argument prints the message and exits with INVALID (2).

Calling Cli yourself

use Omnibridge\Static\Console\Cli;

$cli = new Cli(static fn (string $line) => print($line."\n"));   // where the lines go; STDERR by default
$code = $cli->build('static.php', ['out' => 'dist', 'strict' => true], $host);
$code = $cli->check('dist');
$code = $cli->run(['build', 'static.php', '--out=dist']);           // the command line's parsing

build() and check() throw InvalidArgumentException for what run() answers with exit code 2.

Propose a change docs/hosts.md

Addresses

How a site declares its static addresses: explicitly, and nothing is left out without being said so. A provider implements StaticRouteProviderInterface::pages(): iterable<StaticPage>; one that leaves routes out also implements Route\SkipReportingInterface::skipped(): array<string, string> (subject => why), which the exporter turns into skipped-route warnings. Site::provide() takes any number of providers; the same path given twice is a duplicate-page warning (the first kept), and the pages are rendered in the order of their paths.

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 (The export).

PageList

new PageList(['/', '/about', '/blog/hello']);
new PageList(fn () => array_map(fn ($slug) => '/blog/'.$slug, $repository->slugs()));
new PageList([new StaticPage('/en/', locale: 'en', alternates: ['fr' => '/'])]);

The paths written out, or a closure that gives them; each a string (made a StaticPage) or a StaticPage.

RouteCollectionProvider

A router's routes: a symfony/routing RouteCollection (Symfony's router, the one omnibridge/laravel's Routing\ActionRoutes reads from an application's #[Route] actions, symfony/routing's attribute loader in plain PHP).

new RouteCollectionProvider($routes,
    parameters: [
        'app_article' => fn () => $db->query('SELECT destination, slug FROM article WHERE published')->fetchAll(),
        'app_index' => fn () => [['destination' => 'japon'], ['destination' => 'usa']],
    ],
    include: null,                                   // fnmatch patterns of route names; null: all
    exclude: ['app_*_comments', 'newsletter_*'],     // fnmatch patterns
    actions: ['App\Action\\'],                       // the routes of these actions only (class prefixes)
    noSitemap: ['app_legal'],                        // their pages are not listed in sitemap.xml
);
Argument Type Default Effect
routes RouteCollection required
parameters route name => iterable of parameter sets, or a closure returning one [] the values of a route's variables, by its name or, for a localized route (blog.fr), by its canonical name (_canonical_route)
include ?list<string> null the routes read, by name
exclude list<string> [] the routes left out, by name
actions list<string> [] the routes whose _controller starts with one of these prefixes
noSitemap list<string> [] routes whose pages sitemap.xml does not list
  • A route without a variable is one page; a route with variables one page per parameter set, the set cut to the path's variables.
  • The path is generated by symfony/routing's UrlGenerator with strict requirements: a value the requirement refuses is skipped and reported; a parameter the path does not name is not added as a query string.
  • Only GET routes are read (a route with no method restriction counts as GET); another is skipped and reported, as a route with variables and no source.
  • The page carries its route, parameters, action (Class or Class::method, from _controller) and locale (_locale); a localized route's languages are each other's alternates (hreflang in sitemap.xml).

A run (a RouteCollection by hand, the host's handling by a closure; The export has the whole script and output) reports:

[warning] skipped-route article {"slug":"Not Valid"}: not generated: Parameter "slug" for route "article" must match "[a-z-]+" ("Not Valid" given) to generate a corresponding URL.
[warning] skipped-route comment: it answers POST, not GET
[warning] skipped-route feed: its path /feed/{page} has variables (page) and no source gives their values

SitemapProvider

The addresses of the application's own sitemap (sitemaps.org's urlset, its xhtml:link alternates): a site that already enumerates its addresses for the crawlers enumerates them for the export too.

new SitemapProvider(renderer: $renderer);                  // rendered at build time, at /sitemap.xml
new SitemapProvider(renderer: $renderer, path: '/sitemap-index.xml');
new SitemapProvider(file: 'public/sitemap.xml');
Argument Default Effect
renderer null renders path (as a resource, not listed in the export's sitemap)
path /sitemap.xml the sitemap's address
file null read from this file instead; one of renderer or file is required (InvalidArgumentException)

A sitemap index (sitemapindex) is followed one level: each listed sitemap rendered by its path (a file alone cannot follow it: RuntimeException). An address with a query string, or on another host than the first one, is skipped and reported. A page's locale is the hreflang its own address has among the alternates. A sitemap that does not render (a status other than 2xx) or is not XML is a RuntimeException, which fails the build.

Writing a provider

use Omnibridge\Static\StaticPage;
use Omnibridge\Static\StaticRouteProviderInterface;

final class Documentation implements StaticRouteProviderInterface
{
    public function __construct(private readonly Catalogue $catalogue) {}

    public function pages(): iterable
    {
        foreach ($this->catalogue->packages() as $package) {
            yield new StaticPage('/docs/'.$package->key, route: 'docs', parameters: ['key' => $package->key]);
        }
    }
}

A path is absolute, with no query string and no fragment (StaticPage refuses them with an InvalidArgumentException): a file is one address, whatever its query.

Propose a change docs/addresses.md

Rendering

With the application's own code. A renderer implements StaticRendererInterface::render(StaticPage): StaticResponse and answers as a visitor does: no session, no cookie, nothing a signed-in visitor would see. A renderer that throws is a render-failed error for that page; the build goes on.

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');
Argument Effect
handle \Closure(Request): Response, the host's handling of one request
baseUrl the origin the pages are rendered for: the scheme, host and port the application sees
headers headers every request carries (Accept-Language...), beside each page's own (StaticPage::$headers); where both name a header, the renderer's is kept ($this->headers + $page->headers)

Each request: GET <baseUrl><path>, Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 (*/* for a resource the links led to), User-Agent: omnibridge-static (RequestRenderer::USER_AGENT). The response's status, body (a BinaryFileResponse's file read, a StreamedResponse captured), Content-Type, Location and X-Static-Fragment make the StaticResponse; nothing else is kept. 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). For a site under a folder of its origin (GitHub Pages), give the origin, not the folder: (new Site($url))->origin().

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's Base\Screen\Negotiation, a template engine). Nothing of the host around the action: use it where the action's result is the whole page.

new ActionRenderer($container, function (mixed $result, StaticPage $page) use ($negotiation): StaticResponse {
    $answer = $negotiation->answer($result, new Visit($page->path, 'text/html', null, null));

    return new StaticResponse($answer->status, $answer->body, $answer->headers['Content-Type'] ?? 'text/html; charset=UTF-8');
});
  • The page names its action (StaticPage::$action, which RouteCollectionProvider fills from _controller): Class (invokable) or Class::method. None, or an action not in the container, is an InvalidArgumentException (a render-failed error).
  • Each parameter: the route parameter of that name; $query an empty array; else a service of the container by its class type; else its default; else null when it allows it; else an InvalidArgumentException.
  • An action that returns null is no such page (404). The answer closure returns a StaticResponse, a string (HTML, 200) or null (404).

CallableRenderer

new CallableRenderer(fn (StaticPage $page) => $twig->render('page.html.twig', ['path' => $page->path]));

Any closure: a StaticResponse, a string (an HTML page, status 200), or null (404).

One process

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.

Propose a change docs/rendering.md

The export

Omnibridge\Static\Exporter::export(Site $site, string $outDir, bool $verify = false): Report - what the command line's build runs. In order: the folder prepared (created, or emptied when it holds an earlier export; marked as an export from the start, so a build that stops half-way is replaced by the next one; a folder holding anything else is refused with a RuntimeException), the pages collected, the manifests' files and the copies written from the public folder, each page rendered and written, the links followed, the diagnostics summarised, sitemap.xml, the redirections, the base path applied (GitHub Pages), omnibridge-static.json. A progress line every 50 pages, and a last line with the counts.

The files

Pages are written as a static host serves them: / as index.html, /blog/hello as blog/hello/index.html (nginx: try_files $uri $uri/index.html =404;), a path ending in .html and any other file at its path. Paths are percent-decoded (/caf%C3%A9 is café/index.html). 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) - unless it answers StaticResponse::bytes(): a static host serves a path without extension as application/octet-stream, which a reader such as an npm or Unity registry client takes (omnisense/omnihub's static hub writes /<package> and /-/v1/search so).

Answer Written
2xx HTML OutputPath::page()
2xx other type, path with an extension (1 to 8 letters or digits), or bytes() OutputPath::file(), at its path
3xx with a Location a redirection page, below
the page for unknown addresses 404.html (a warning when it does not answer 404)
any other status render-failed error, nothing written

The public files: each file a manifest names (Site::assets(), read by Asset\AssetManifest: Vite's manifest.json with file, css, assets relative to its base; Webpack's or Encore's manifest.json, logical name => public path; Encore's entrypoints.json), and each pattern of Site::copy() (relative to the public folder, fnmatch), copied as they are - a missing one is a missing-asset error. Addresses on another host (a CDN) are not copied. The exporter fingerprints nothing: assets are copied as the bundler fingerprinted them, and each file's checksum recorded.

Redirections

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) and redirects.json ({from: {to, status}}). 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, through RequestRenderer, a response header X-Static-Fragment: sticky- beside its Location - carries it over, prefixed, with a line of script before the refresh: /docs/flowjs/sticky#keys lands on /docs/flowjs#sticky-keys (a page merged into another, its anchors prefixed); without script, the target's own anchor. fragment: '' carries the fragment unprefixed; null (the default) drops it, as a meta refresh does. The page is noindex; its target is followed like any link.

The page for unknown addresses

Site::notFound('/404') renders an address the application does not serve and writes the answer to 404.html, its status expected to be 404; it is not listed in the sitemap. Its relative addresses are resolved against the address it was rendered at, since a host serves it at any depth.

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: href of links, action of 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 attributes Site::resourceAttributes() adds (a gallery's data-thumb). Not read: what a script builds at run time. URI templates (/tiles/{z}.png) are no addresses. Skipped: other origins, a fragment alone, mailto:, tel:, sms:, javascript:, data:, blob:, about:, geo:, callto:, file:. A link with a query string to an exported page is a query-ignored warning: a static host answers the page whatever the query. Site::followResources(false) stops rendering the files the application serves: a link to one is then a broken link.

The dynamic host

Site::dynamic($patterns, $host): addresses that stay on the dynamic host (fnmatch patterns of paths, * crossing slashes). 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 (dynamic notices); without one, a link to them is no broken link, and a posted form is.

Normalisers

Site::normalise(fn (string $html, StaticPage $page): string => ...): what changes from one request to the next (a CSP nonce, a form token, a render time, a random badge) taken out of each HTML page before it is written (and before --verify compares), so that two builds give the same folder.

->normalise(fn (string $html) => preg_replace('~name="_token" value="[^"]*"~', 'name="_token" value=""', $html))

sitemap.xml and omnibridge-static.json

sitemap.xml lists the HTML pages of role page whose sitemap is true, sorted, at the base URL, with their alternates (xhtml:link hreflang), with no date. Site::sitemap(false) writes none.

omnibridge-static.json (Exporter::REPORT): generator, baseUrl, pages (path => file, status, route, locale), redirects, files (every file's sha256 and size), diagnostics - sorted, no date. Two builds of the same application give the same file; check compares a folder with it.

A run

A router's routes, the host's handling as a closure, a dynamic host, a fragment carried over (the package's image, 2026-10-27):

<?php

require 'vendor/autoload.php';

use Omnibridge\Static\Exporter;
use Omnibridge\Static\Render\RequestRenderer;
use Omnibridge\Static\Route\RouteCollectionProvider;
use Omnibridge\Static\Site;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

$routes = new RouteCollection();
$routes->add('home', new Route('/'));
$routes->add('article', new Route('/articles/{slug}', requirements: ['slug' => '[a-z-]+']));
$routes->add('docs_sticky', new Route('/docs/flowjs/sticky'));
$routes->add('comment', (new Route('/articles/{slug}/comment'))->setMethods(['POST']));
$routes->add('feed', new Route('/feed/{page}'));

// A host's request handling, by hand: what a kernel's handle() would answer.
$handle = static function (Request $request): Response {
    return match ($request->getPathInfo()) {
        '/' => new Response('<html><body><a href="/articles/le-fuji">Le Fuji</a> <a href="/login">Sign in</a> <a href="/missing">?</a></body></html>', 200, ['Content-Type' => 'text/html; charset=UTF-8']),
        '/articles/le-fuji' => new Response('<html><body><form method="post" action="/articles/le-fuji/comment"></form><img src="/img/fuji.jpg"></body></html>', 200, ['Content-Type' => 'text/html; charset=UTF-8']),
        '/img/fuji.jpg' => new Response('JPEG', 200, ['Content-Type' => 'image/jpeg']),
        '/docs/flowjs/sticky' => new Response('', 301, ['Location' => '/docs/flowjs#sticky', 'X-Static-Fragment' => 'sticky-']),
        default => new Response('<html><body>Not found</body></html>', 404, ['Content-Type' => 'text/html; charset=UTF-8']),
    };
};

$site = (new Site('https://example.org'))
    ->provide(new RouteCollectionProvider($routes, parameters: ['article' => [['slug' => 'le-fuji'], ['slug' => 'Not Valid']]]))
    ->renderWith(new RequestRenderer($handle, 'https://example.org'))
    ->dynamic(['/login', '/articles/*/comment'], 'https://app.example.org')
    ->notFound('/404');

$report = (new Exporter())->export($site, 'dist');
foreach ($report->diagnostics as $diagnostic) {
    echo $diagnostic, "\n";
}
echo json_encode($report->redirects, JSON_UNESCAPED_SLASHES), "\n";
echo implode(' ', array_keys($report->files)), "\n";
echo file_get_contents('dist/docs/flowjs/sticky/index.html');
echo file_get_contents('dist/articles/le-fuji/index.html'), "\n";
[warning] skipped-route article {"slug":"Not Valid"}: not generated: Parameter "slug" for route "article" must match "[a-z-]+" ("Not Valid" given) to generate a corresponding URL.
[warning] skipped-route comment: it answers POST, not GET
[warning] skipped-route feed: its path /feed/{page} has variables (page) and no source gives their values
[error] broken-link /docs/flowjs: linked from /docs/flowjs/sticky (answered 404) and 2 more
[error] broken-link /missing: linked from / (answered 404)
[notice] dynamic /login: 1 link(s) left to the dynamic host (https://app.example.org)
[notice] dynamic POST /articles/le-fuji/comment: 1 form(s) posted to the dynamic host (https://app.example.org)
{"/docs/flowjs/sticky":{"to":"/docs/flowjs#sticky","status":301}}
404.html _redirects articles/le-fuji/index.html docs/flowjs/sticky/index.html img/fuji.jpg index.html redirects.json sitemap.xml
<!DOCTYPE html><html><head><meta charset="utf-8"><title>/docs/flowjs#sticky</title><link rel="canonical" href="/docs/flowjs#sticky"><script>(function(){var t=document.querySelector('link[rel="canonical"]').getAttribute('href'),h=location.hash.slice(1);if(h){t=t.split('#')[0]+'#'+"sticky-"+h}location.replace(t)})()</script><meta http-equiv="refresh" content="0; url=/docs/flowjs#sticky"><meta name="robots" content="noindex"></head><body><a href="/docs/flowjs#sticky">/docs/flowjs#sticky</a></body></html>
<html><body><form method="post" action="https://app.example.org/articles/le-fuji/comment"></form><img src="/img/fuji.jpg"></body></html>
  • /img/fuji.jpg is in no public folder: the application serves it, so it was rendered and written.
  • /docs/flowjs is no page of this export: the redirection's three links to it (canonical, refresh, body) make one broken-link error; so does /missing. The build exits 1.
  • /login stays on the dynamic host; the posted form is rewritten to it.

Propose a change docs/export.md

GitHub Pages

The base path and the target

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 (RequestRenderer given 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's url(), a refresh, the Site::resourceAttributes() attributes - a search page's index URL), in style sheets their url() 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 and hreflang links, Open Graph, JSON-LD, a share link's percent-encoded URL. sitemap.xml and 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/b with a redirection to /a/b/ when a/b/index.html is the file, so every page's relative addresses are resolved too.
  • .nojekyll (no Jekyll: files and folders starting with _ are served), CNAME when 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.json stays, 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), and omnibridge-static check dist runs 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 - a data- attribute named by Site::resourceAttributes(), document.baseURI - or from a bundler's public path that names the folder.

The workflow

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;
}

What the target changes, in the code

  • Site::githubPages() refuses a custom domain with a path (InvalidArgumentException: "GitHub Pages serves a custom domain at its root"); customDomain() is the base URL's host unless it ends in .github.io.
  • The command line's --target=github-pages calls it; omnibridge/symfony's and omnibridge/laravel's commands have no such option: the site's file calls ->githubPages().
  • Without the target, a base URL with a path still moves every address under the folder and checks it (outside-base): the target adds .nojekyll, CNAME, the redirections as pages only, the resolution of every page's relative addresses and the size warnings.
  • The limits warned of: a file above 100 MB, a site above 1 GB (github-pages warnings), no Site::notFound().

Propose a change docs/github-pages.md

Reference

Site

Omnibridge\Static\Site (final). new Site(string $baseUrl): the public address of the static site - its origin (https://example.org) or a folder of one (https://owner.github.io/repository/); a URL without scheme or host, with a query or a fragment, or whose path is no folder (.., characters a path segment cannot hold) is an InvalidArgumentException. Every setter returns the site.

Method Default Effect
provide(StaticRouteProviderInterface ...$providers) none where the addresses come from (Addresses)
renderWith(StaticRendererInterface $renderer) none: renderer() throws LogicException ("The site names no renderer") how a page is rendered (Rendering)
publicDir(string $dir) none the public folder files are copied from
assets(string $manifest, string $base = '') none a front-end build's manifest; base is the public path a Vite manifest's files are relative to (/build)
copy(string ...$patterns) none public files copied as they are, fnmatch patterns relative to the public folder; without publicDir(), a missing-asset error
notFound(string $path = '/404') none the address rendered for 404.html
dynamic(array $patterns, ?string $host = null) none addresses left to the dynamic host; its origin to rewrite links and posted forms to
normalise(\Closure $normaliser) none fn (string $html, StaticPage $page): string, applied to every HTML page in order
followResources(bool $follow = true) on render and write the files the application serves that the pages link to
resourceAttributes(string ...$attributes) none more attributes naming a file the page's scripts load
sitemap(bool $sitemap = true) on write sitemap.xml
githubPages(bool $githubPages = true) off export for GitHub Pages (GitHub Pages)

Read back: basePath() (/repository, '' at the root), origin() (scheme, host, port, lower case), customDomain(), isForGitHubPages(), providers(), renderer(), getPublicDir(), manifests(), copies(), notFoundPage(), dynamicHost(), normalisers(), followsResources(), writesSitemap(), getResourceAttributes(), isDynamic(string $path).

StaticPage

Property Type Default Meaning
path string required absolute, no query, no fragment (InvalidArgumentException otherwise)
route ?string null the route it was generated from, for the diagnostics
parameters array [] the route's parameters
action ?string null Class or Class::method
locale ?string null its language
alternates array<string, string> [] language => path of the same page
headers array<string, string> [] request headers to render it with
sitemap bool true listed in sitemap.xml
role string StaticPage::PAGE PAGE (page), NOT_FOUND (not-found), RESOURCE (resource: a file found by following links)

StaticPage::notFound(string $path = '/404'), withAlternates(array), describe() (/path (route)).

StaticResponse

Property Type Meaning
status int the HTTP status
body string the content, as it is written
contentType ?string the Content-Type
location ?string where a redirection sends
fragment ?string the prefix the visitor's #fragment gains on a redirection; '' as it is; null dropped
Factory Status Type
html(string $body, int $status = 200) text/html; charset=UTF-8
json(string $body, int $status = 200) application/json (exported only at a path with an extension)
bytes(string $body, int $status = 200) application/octet-stream, exported at its path, extension or not
redirect(string $location, int $status = 301, ?string $fragment = null) 301
notFound(string $body = '') 404 text/html; charset=UTF-8

StaticResponse::FRAGMENT_HEADER is X-Static-Fragment. Predicates: isSuccessful() (2xx), isRedirect() (3xx with a location), isHtml() (text/html, application/xhtml+xml), isCss(), isBytes(), mediaType(); withBody(string).

Report

What Exporter::export() returns and writes as omnibridge-static.json: baseUrl, files (path => {sha256, size}), pages (path => {file, status, route, locale}), redirects (path => {to, status}), diagnostics (list<Diagnostic>, in the order found). errors(), warnings(), level($level), hasErrors(), counts() ("<level> <code>" => count, sorted), toJson() (stable keys, the diagnostics sorted, no date).

Diagnostic: level (error, warning, notice), code, subject, message; printed [level] code subject: message (Errors).

The helpers

  • Output\OutputPath: page($path), file($path) (a folder is an InvalidArgumentException), hasExtension($path), key($path) (decoded, no trailing slash: how the exporter compares addresses).
  • Asset\AssetManifest::read($file, $base = ''), parse($json, $base = ''): the public paths a manifest names, sorted, once each; another host's addresses left out.
  • Link\Links: reads and rewrites the addresses of HTML and CSS (kinds page, form, post, resource); Output\BasePath: moves addresses under a folder and finds those left outside.

Propose a change docs/reference.md

Errors

Diagnostics

The export does not stop at a page's failure: every finding is a Diagnostic in the report and in omnibridge-static.json. An error fails the build (exit 1), a warning too with --strict, a notice never.

Code (Diagnostic::) Level When
render-failed (RENDER_FAILED) error a page answered a status other than 2xx or 3xx, or its renderer threw (the exception's class and message)
render-failed warning the page for unknown addresses answered something else than 404
duplicate-output (DUPLICATE_OUTPUT) error two pages for one file, a file where a folder is needed or the reverse (the first kept)
duplicate-page (DUPLICATE_PAGE) warning the same path given twice (the first kept)
broken-link (BROKEN_LINK) error an internal link that resolves to nothing in the export: the target, the first page linking to it and how many more; a document whose links could not be read
missing-asset (MISSING_ASSET) error a manifest that cannot be read, a file it names not in the public folder, a copy pattern that matches nothing, copies without a public folder
not-deterministic (NOT_DETERMINISTIC) error with --verify, a page rendered twice with two different bodies (normalised): where they first differ
not-exportable (NOT_EXPORTABLE) error a page that is neither HTML nor a path with an extension nor bytes(); a redirection at an address that names a folder
skipped-route (SKIPPED_ROUTE) warning a provider left a route out (no source of values, not GET, a value its requirement refuses; a sitemap's address with a query or on another host)
query-ignored (QUERY_IGNORED) warning a link with a query string to an exported page
dynamic (DYNAMIC) notice links left to, and forms posted to, the dynamic host, counted per address
outside-base (OUTSIDE_BASE) error a written file with an internal address outside the site's base path, or whose addresses could not be moved
github-pages (GITHUB_PAGES) warning for GitHub Pages: no page for unknown addresses, a file above 100 MB, a site above 1 GB

Exceptions

Exception Thrown by When
InvalidArgumentException Site::__construct(), githubPages() a base URL that is no origin or folder of one; a custom domain with a path
InvalidArgumentException StaticPage::__construct() a relative path, a query string, a fragment
InvalidArgumentException Cli::load(), build(), check() no such file, no Site returned, an unknown target, no report in the folder (exit 2)
InvalidArgumentException SitemapProvider::__construct() neither a renderer nor a file
InvalidArgumentException ActionRenderer::render() no action, an action not in the container, a parameter with no value (a render-failed error)
LogicException Site::renderer() no renderer
LogicException RequestRenderer::__construct() symfony/http-foundation missing
RuntimeException Exporter::export() the output folder holds files and no earlier export, or cannot be made: the build stops (from the command line, PHP's uncaught exception and exit code 255)
RuntimeException SitemapProvider::pages() an unreadable or invalid sitemap: a provider's exception is not caught, the build stops
RuntimeException AssetManifest::read() an unreadable manifest: caught by the exporter, a missing-asset error
JsonException AssetManifest::parse(), Cli::check() invalid JSON

Cli::run() turns only InvalidArgumentException into exit code 2: a folder that is no earlier export reaches the command line as an uncaught RuntimeException, and the Symfony and Artisan commands as their console's error.

Propose a change docs/errors.md

Testing

Testing an export

  • In CI: omnibridge-static build site.php --out=dist --strict, then omnibridge-static check dist, then build again into another folder and compare (diff -r, or the two omnibridge-static.json): two builds of the same application must be the same folder.
  • --verify renders every page twice in the same process: a page that differs shows a service that keeps state between requests.
  • Against the dynamic site: render the same addresses through the host and compare page by page, normalised as the export is (lesringostars' parity suite does it).
  • In PHPUnit, Exporter::export() returns the Report: assert errors() is empty, the pages and the redirections, then read the folder.

The package's suite

docker compose run --rm test

The service test uses the image omnibridge-core:php8.2 (docker/Dockerfile: PHP 8.2-cli with git, unzip and Composer 2, no extension added), the package at /package, a named volume composer-cache; it runs composer update then vendor/bin/phpunit. With the checkout's vendor/ installed:

docker run --rm --network none -v "$PWD":/package:ro omnibridge-core:php8.2 vendor/bin/phpunit --do-not-cache-result
Test class What it checks
BareTest every class loads without any other package
ExporterTest pages written as a static host serves them; the files the pages need copied or rendered; a redirection a small page and a line; the sitemap lists the pages and their languages; every link checked and the dynamic ones sent to the dynamic host; the report written and two builds the same; an earlier export replaced, any other folder refused; a page rendered differently twice an error unless normalised; what cannot be a static file an error
GitHubPagesTest a redirection carries the visitor's fragment under the base path; the export moved under the base path; the files GitHub Pages reads; a custom domain served at the root with a CNAME file; two builds the same and the check passes; the command line targets GitHub Pages; a base URL is an origin or a folder of one
Console\CliTest build exports the site the file declares and fails on its errors; check compares an export with its report; a file that declares no site is a usage error; the example site exports without an error; the plain PHP script runs without Composer
Route\RouteCollectionProviderTest a route without variables is one page; a parameterised route exported for the values its source gives; a route without a source or without GET left out and said so; a localized route's languages alternates of one another; routes chosen by name and by action
Route\SitemapProviderTest the sitemap's addresses are the pages; a sitemap index followed; a sitemap file
Render\RequestRendererTest the page is a visitor's GET of its address on the base URL; a redirection, a file and a stream
Render\ActionRendererTest the route's action called with its parameters and services; an action that finds nothing is no such page; a page without an action refused
Link\LinksTest the markup's addresses read by kind; more resource attributes; a style sheet's urls and imports; links and forms rewritten, scripts and other tags not; a refresh and an SVG's references read; every address read can be rewritten
Output\OutputPathTest, Output\BasePathTest where a page and a file are written, an address leaving the export refused, keys, references resolved; addresses under the base path or not, moved in HTML, CSS, JSON, feeds and text, 404.html's relative addresses resolved, what is left outside found
Asset\AssetManifestTest Vite's files relative to its folder; Webpack's public paths; Encore's entrypoints; a missing manifest

The CI also builds examples/plain, checks it, builds it again and compares, and keeps the folder as an artifact.

Propose a change docs/testing.md

Verified, and not

Run for this manual

On 2026-10-27, in omnibridge-core:php8.2 (PHP 8.2), the checkout's own vendor/, no network: 72 tests, 258 assertions, green. The example site built at the root and for GitHub Pages and checked (Installation), and a router-provider export with a dynamic host and a fragment carried over (The export), their output pasted.

As the README states

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 for https://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; --verify finds 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:build on 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.io linked 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).

Propose a change docs/verified.md