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.
Proposer une modification 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.jsonitself.
Proposer une modification 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.
Proposer une modification 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
UrlGeneratorwith 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 (
ClassorClass::method, from_controller) and locale (_locale); a localized route's languages are each other's alternates (hreflanginsitemap.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.
Proposer une modification 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, whichRouteCollectionProviderfills from_controller):Class(invokable) orClass::method. None, or an action not in the container, is anInvalidArgumentException(arender-failederror). - Each parameter: the route parameter of that name;
$queryan empty array; else a service of the container by its class type; else its default; else null when it allows it; else anInvalidArgumentException. - 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.
Proposer une modification 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.
Links followed
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.jpgis in no public folder: the application serves it, so it was rendered and written./docs/flowjsis no page of this export: the redirection's three links to it (canonical, refresh, body) make onebroken-linkerror; so does/missing. The build exits 1./loginstays on the dynamic host; the posted form is rewritten to it.
Proposer une modification 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 (
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
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-pagescalls 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-pageswarnings), noSite::notFound().
Proposer une modification 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 anInvalidArgumentException),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 (kindspage,form,post,resource);Output\BasePath: moves addresses under a folder and finds those left outside.
Proposer une modification 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 onlyInvalidArgumentExceptioninto exit code 2: a folder that is no earlier export reaches the command line as an uncaughtRuntimeException, and the Symfony and Artisan commands as their console's error.
Proposer une modification docs/errors.md
Testing
Testing an export
- In CI:
omnibridge-static build site.php --out=dist --strict, thenomnibridge-static check dist, then build again into another folder and compare (diff -r, or the twoomnibridge-static.json): two builds of the same application must be the same folder. --verifyrenders 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 theReport: asserterrors()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.
Proposer une modification 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 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).