prerenderSite function
- required List<
SeoRoute> routes, - required String siteBase,
- String buildDir = 'build/web',
- List<
String> additionalPaths = const [], - bool writeSitemap = true,
- bool writeRobotsTxt = true,
- bool writeLlmsTxt = true,
- bool write404Page = true,
- String? indexNowKey,
- SeoRenderMode renderMode = SeoRenderMode.seoOnly,
- String? stylesheet,
- String? domFirstStylesheet = seoDefaultStylesheet,
- bool enableInteractions = false,
- String? interactionNonce,
- SeoDomFirstRuntimeStore? domFirstRuntimeStore,
- int concurrency = 8,
- void onSkipped(
- String path,
- SeoResolution resolution
- void onError(
- String path,
- Object error,
- StackTrace stack
Bakes the SEO route table into the built Flutter web app as static HTML files — for hosting without a Dart server (Firebase Hosting, GitHub Pages, any CDN).
For every route without :param segments (plus every concrete path
in additionalPaths) a copy of index.html is written to
<path>/index.html, containing:
- the route's title, meta tags, OpenGraph and JSON-LD in the
<head>— the template's<title>and<meta name="description">are replaced, - the semantic HTML body inside the
#esen-seo-contentcontainer — the running app finds that container by id and simply takes it over (hydration).
With renderMode set to SeoRenderMode.visibleShell that container
is no longer hidden: the prerendered HTML becomes the first frame
the user actually sees, styled by stylesheet, and Flutter takes the
screen over as soon as it has rendered. Pass seoDefaultStylesheet
for a presentable baseline or your own CSS to match your app.
Set enableInteractions to progressively enhance explicitly marked
components while that visible shell is active. The source HTML remains
complete without JavaScript. interactionNonce is copied to the generated
style and script tags. The visible shell also has an existing inline
style attribute, which a strict Content Security Policy must allow
separately.
DOM-first routes use domFirstStylesheet independently. It defaults to
seoDefaultStylesheet; pass a generated theme stylesheet to match the
Flutter presentation, or an empty string for deliberately unstyled HTML.
That mode requires the app to call EsenSeo.init() — without it
nothing ever schedules the handoff and the shell stays on top of the
running app for good. See SeoRenderMode.visibleShell.
Because the content is baked into the files, no bot detection is
needed: crawlers, link previews and users all receive the same
file, and the semantic HTML is right in the page source. Deep links
like /demo work on static hosts too, since a real
/demo/index.html exists.
Also writes sitemap.xml, robots.txt, llms.txt, llms-full.txt
and a 404.html (Firebase Hosting, GitHub Pages & Co. serve it with
a real 404 status for unknown paths — no SPA soft-404) — plus the
IndexNow key file <key>.txt when indexNowKey is set. Returns the
written file paths.
// bin/prerender.dart — nach jedem `flutter build web` ausführen:
await prerenderSite(routes: seoRoutes, siteBase: siteBase);
Trade-off vs. the shelf server: prerendered pages are a build-time
snapshot — content changes require a redeploy, and :param routes
are only covered for the paths you enumerate. For frequently
changing content use seoBotMiddleware instead.
Implementation
Future<List<String>> prerenderSite({
required List<SeoRoute> routes,
required String siteBase,
String buildDir = 'build/web',
List<String> additionalPaths = const [],
bool writeSitemap = true,
bool writeRobotsTxt = true,
bool writeLlmsTxt = true,
bool write404Page = true,
String? indexNowKey,
SeoRenderMode renderMode = SeoRenderMode.seoOnly,
String? stylesheet,
String? domFirstStylesheet = seoDefaultStylesheet,
bool enableInteractions = false,
String? interactionNonce,
SeoDomFirstRuntimeStore? domFirstRuntimeStore,
int concurrency = 8,
void Function(String path, SeoResolution resolution)? onSkipped,
void Function(String path, Object error, StackTrace stack)? onError,
}) async {
if (enableInteractions && renderMode != SeoRenderMode.visibleShell) {
throw ArgumentError.value(
renderMode,
'renderMode',
'must be SeoRenderMode.visibleShell when interactions are enabled',
);
}
final applicationRuntimes = await _loadApplicationRuntimes(
routes,
domFirstRuntimeStore,
);
final templateFile = File('$buildDir/index.html');
if (!templateFile.existsSync()) {
throw StateError(
'$buildDir/index.html not found — run `flutter build web` first.',
);
}
final template = await templateFile.readAsString();
_validateTemplate(template, buildDir);
// Say which stylesheet is being baked in. The drift guard watches
// the generated file, but nothing else watches the last link of the
// chain — a themed stylesheet that is generated, committed and then
// never passed here fails silently in exactly the way this line
// makes visible in every build log.
stdout.writeln(_describeStylesheet(stylesheet, renderMode));
if (routes.any((route) => route.isDomFirst)) {
stdout.writeln(_describeDomFirstStylesheet(domFirstStylesheet));
}
// Der Root-Pfad überschreibt index.html — ein zweiter Lauf würde die
// eigene Ausgabe als Template lesen und alles doppelt einbauen.
if (_seoContainerMarker.hasMatch(template)) {
throw StateError(
'$buildDir/index.html is already prerendered — run '
'`flutter build web` again for a clean template. Prerendering an '
'already prerendered file would duplicate the canonical link, the '
'JSON-LD blocks and the content container.',
);
}
// Den Key prüfen, bevor irgendetwas geschrieben wird: er gehört zur
// Liste der belegten Dateinamen, und ein Abbruch nach dem halben Build
// hinterlässt ein Verzeichnis, in dem manche Seiten neu und manche alt
// sind.
if (indexNowKey != null && !_validIndexNowKey.hasMatch(indexNowKey)) {
throw ArgumentError.value(
indexNowKey,
'indexNowKey',
'must be 8–128 characters, letters, digits and dashes only — the '
'key becomes a file name',
);
}
final reserved = <String>{
..._reservedOutputNames,
// Der Key ist erst zur Laufzeit bekannt, belegt aber genauso einen
// Dateinamen wie robots.txt.
if (indexNowKey != null) '/${indexNowKey.toLowerCase()}.txt',
};
// One resolution pass for the whole build: every page is read once and
// the same list feeds the HTML, the sitemap and both llms files, so a
// dynamic route cannot show one thing on the page and another in the
// sitemap.
final pages = await resolveSeoPages(
routes: routes,
canonicalBase: siteBase,
additionalPaths: additionalPaths,
detail: SeoDetail.full,
concurrency: concurrency,
onError: onError,
);
// Validate EVERY output path — including the ones an enumerator
// produced, which is the first time untrusted strings become file
// paths — before a single file is written.
final outputPaths = <String, String>{};
for (final page in pages) {
_checkedPath(page.path, reserved);
final portableKey = page.path.toLowerCase();
final first = outputPaths[portableKey];
if (first != null && first != page.path) {
throw ArgumentError.value(
page.path,
'path',
'collides with "$first" on a case-insensitive file system',
);
}
outputPaths[portableKey] = page.path;
}
final written = <String>[];
for (final page in pages) {
final doc = page.document;
// A static host cannot emit a 301 or a 404 status from a file, so a
// redirect or an error page is not written — it is reported instead,
// for the caller to turn into a host-specific _redirects fragment.
if (doc == null || doc.statusCode != 200) {
onSkipped?.call(page.path, page.resolution);
continue;
}
final html = page.route?.isDomFirst ?? false
? SeoPage.domFirstFromNodes(
meta: doc.meta,
body: doc.body,
lang: page.lang,
stylesheet: domFirstStylesheet,
features: page.route!.domFirstFeatures,
applicationRuntime:
applicationRuntimes[page.route!.applicationRuntime],
interactionNonce: interactionNonce,
).toHtmlDocument()
: _applyTemplate(
template,
doc.meta,
const HtmlRenderer().render(doc.body),
page.lang,
renderMode,
stylesheet,
enableInteractions,
interactionNonce,
);
final file = File(
page.path == '/'
? '$buildDir/index.html'
: '$buildDir${page.path}/index.html',
);
await file.parent.create(recursive: true);
await file.writeAsString(html);
written.add(file.path);
}
if (writeSitemap) {
final file = File('$buildDir/sitemap.xml');
await file.writeAsString(seoSitemapXml(pages: pages, siteBase: siteBase));
written.add(file.path);
}
if (writeRobotsTxt) {
final file = File('$buildDir/robots.txt');
await file.writeAsString(
seoRobotsTxt(siteBase: siteBase, includeSitemap: writeSitemap),
);
written.add(file.path);
}
if (writeLlmsTxt) {
final file = File('$buildDir/llms.txt');
await file.writeAsString(seoLlmsTxt(pages: pages, siteBase: siteBase));
written.add(file.path);
final fullFile = File('$buildDir/llms-full.txt');
await fullFile
.writeAsString(await seoLlmsFullTxt(pages: pages, siteBase: siteBase));
written.add(fullFile.path);
}
if (write404Page) {
final file = File('$buildDir/404.html');
await file.writeAsString(_applyTemplate(
template,
const SeoMeta(title: '404 — Page not found', robots: 'noindex'),
'<h1>404 — Page not found</h1>',
'en',
renderMode,
stylesheet,
enableInteractions,
interactionNonce,
));
written.add(file.path);
}
if (indexNowKey != null) {
// Format und Kollisionsfreiheit sind oben geprüft, vor dem ersten
// Schreibvorgang.
final file = File('$buildDir/$indexNowKey.txt');
await file.writeAsString(indexNowKey);
written.add(file.path);
}
return written;
}