seoSitemapXml function

String seoSitemapXml({
  1. required String siteBase,
  2. List<SeoRoute>? routes,
  3. List<SeoResolvedPage>? pages,
  4. List<String> additionalPaths = const [],
})

Generates a sitemap.xml from the SEO route table.

Pass exactly one of routes and pages:

  • routes is the classic path — the table is resolved synchronously. It throws a StateError when the table contains a SeoRoute.dynamic, because a database read cannot happen without awaiting; the message names the fix.
  • pages is a pre-resolved snapshot from resolveSeoPages, which works for every table. Passing additionalPaths together with pages is an ArgumentError — they were already folded into the pass. seoBotMiddleware and prerenderSite take this path for you.

Includes every indexable URL: a route without :param segments, plus any concrete instances from a route's enumeratePaths or from additionalPaths. A page that resolves to a redirect, a non-200, or opts out via includeInSitemap is dropped.

Per URL the entry carries <lastmod> (a per-record date beats the route's static one) and <xhtml:link rel="alternate" hreflang="…"/> for every language variant in the page's SeoMeta.alternates.

Implementation

String seoSitemapXml({
  required String siteBase,
  List<SeoRoute>? routes,
  List<SeoResolvedPage>? pages,
  List<String> additionalPaths = const [],
}) {
  final resolved = pagesForGenerator(
    routes: routes,
    pages: pages,
    additionalPaths: additionalPaths,
    canonicalBase: siteBase,
  );
  final base = siteBase.endsWith('/')
      ? siteBase.substring(0, siteBase.length - 1)
      : siteBase;

  final entries = [
    for (final page in resolved)
      if (page.isIndexable) page,
  ];

  final hasAlternates =
      entries.any((e) => e.document!.meta.alternates.isNotEmpty);
  final buffer = StringBuffer()
    ..writeln('<?xml version="1.0" encoding="UTF-8"?>')
    ..write('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"')
    ..writeln(
        hasAlternates ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml">' : '>');
  for (final entry in entries) {
    final url = entry.path == '/' ? '$base/' : '$base${entry.path}';
    final lastmod = entry.lastModified;
    final alternates = entry.document!.meta.alternates;
    if (lastmod == null && alternates.isEmpty) {
      buffer.writeln('  <url><loc>${_xmlSafe(url)}</loc></url>');
      continue;
    }
    buffer
      ..writeln('  <url>')
      ..writeln('    <loc>${_xmlSafe(url)}</loc>');
    if (lastmod != null) {
      buffer.writeln('    <lastmod>${_lastmodDate(lastmod)}</lastmod>');
    }
    alternates.forEach((hreflang, href) {
      // The same allow list the head renderer applies: a URL SeoMeta
      // refuses to emit has no business being advertised in
      // sitemap.xml either. Before this, the sitemap was the one
      // output path around the policy choke point — a javascript:
      // alternate from CMS data shipped verbatim.
      if (!isAllowedSeoAttribute('href', href)) return;
      buffer
        ..write('    <xhtml:link rel="alternate" hreflang="')
        ..write(_xmlSafeAttr(hreflang))
        ..write('" href="')
        ..write(_xmlSafeAttr(href))
        ..writeln('"/>');
    });
    buffer.writeln('  </url>');
  }
  buffer.write('</urlset>');
  return buffer.toString();
}