Bryan Hamilton / projects / segmented-sitemaps

bryanhamilton.info / open source / segmented-sitemaps

segmented-sitemaps 512 dead URLs. Zero listed.

The old sitemap on The DJ Calendar listed every venue the site had ever heard of: 702 of them. 512 had no upcoming show, so their URLs 404ed, and the site was actively telling Google to crawl hundreds of pages that do not exist. Crawl budget went to corpses while live pages waited in line. This library is the fix, extracted verbatim from production: a segmented sitemap set where every listed URL is proven live by construction, and every <lastmod> comes from real data instead of a today-stamp.

  • PHP 7.4+, zero dependencies
  • 47 contract checks in CI
  • Undercounts, never 404s
  • MIT license
In brief: segmented-sitemaps is a zero-dependency PHP 7.4+ library that builds a segmented sitemap set in which every URL is proven live by construction. Only entities with upcoming events are listed, cities are derived from those events, every lastmod is real, and a run over partial data fails loudly and writes nothing.

One run on the real site

The numbers that motivated the library, from a real diagnostic on a 1,000-page event directory

segmentbeforeafterwhy
venues702190512 venues had no upcoming show — dead URLs, gone
cities5169cities are derived from upcoming events, not a stale curated list
artists9999profiles always render — all stay
total~900473every remaining URL provably renders

The metric that should collapse after a switch like this: Crawled - not indexed on venue and city URLs.

The run report

What one run prints, from the bundled sample data

data: 5 artists, 7 events (5 upcoming), 7 venues, 2 cities
artists   -> sitemap-artists.xml        4 urls
venues    -> sitemap-venues.xml         4 urls
cities    -> sitemap-cities.xml         5 urls
genres    -> sitemap-genres.xml         6 urls
pages     -> sitemap-pages.xml          8 urls
slug-guard: skipped 1 malformed slug(s)
index    -> sitemap.xml

Output embedded verbatim in the live demo — the real generator run against the bundled sample dataset, no mocks.

The rules, in order of how hard they bite

Every rule exists because a real sitemap eventually violates it. In order:

  1. Only upcoming events count. Past shows 404 on the production router, confirmed against the real error_404_log. An event is upcoming if its date is >= today.
  2. A venue without an upcoming event is never listed. This is the whole ballgame: the sitemap undercounts rather than ever pointing Google at a 404. A wrong URL can never get in; a right one can only get left out.
  3. Venue matching is slug-normalized on both sides. Venue slugs are matched against the slugified event venue name, with a fallback attempt on the venue’s own name. Pseudo-venues sitting in event names match nothing and drop out — which is correct: those pages were noise anyway.
  4. Cities are derived; curated-only is a flag. By default any city with an upcoming event gets listed. Flip cityIncludeDynamic off to list only cities with a curated row.
  5. Every slug passes ^[a-z0-9-]+$ before it enters XML. Bad slugs are counted and reported, never emitted.
  6. <lastmod> means something. Artists get the latest upcoming tour date; venues and cities get their freshest upcoming event date. Only the sitemap index itself gets today.
  7. Never generate from partial data. If any source file is missing or unparseable, the run fails loudly and writes nothing. Half a sitemap is worse than a stale one.

Wire it up

One config file, one CLI run, one cron line. No Composer, no framework, no config DSL:

// config.php - the whole surface
return array(
    'base'               => 'https://www.example.com', // no trailing slash
    'dataDir'            => __DIR__ . '/data',         // where the JSON lives
    'outDir'             => __DIR__,                   // where XML is written
    'cityIncludeDynamic' => true,   // list event-derived cities with no curated row
    'includeShows'       => false,  // /show/ segment: off until the router provably
                                    // handles expired event URLs
    'genres'             => array('techno', 'house'),
    'staticPages'        => array('/' => '2026-10-16'),
    'jsonReport'         => true,   // also write report.json next to the XML
    // 'today' => '2026-10-16',        // optional override for deterministic runs
);
SITEMAP_OUTDIR=/tmp/out php bin/generate-sitemaps.php config.php   # one run
# 0 5 * * * php /path/to/bin/generate-sitemaps.php /path/to/config.php   # cron

Input is four tolerant JSON shapes (djs.json, events.json, venues.json, cities.json) — bare arrays and single-key wrapper objects both load. If your site renders from MySQL, export into these shapes and keep the property that matters: the sitemap is built from exactly what the site serves. Submit /sitemap.xml once in Search Console; re-run on cron after every data sync — each run rewrites the full set atomically per file, so a partially written run can never publish a torn file.

Design decisions

DecisionWhy
JSON-native, no databaseThe PDO predecessor was retired: reading the site’s own export files meant zero collation fights, zero credentials in the script, and a data source guaranteed identical to what the site renders from.
Web runner and CLI share one libraryThe production endpoint is token-gated so a curl one-liner can refresh the set; the CLI is the same library behind a config file. No secrets ship in the repo.
Undercount, never 404A sitemap that sometimes lists a dead page is worse than one that lists fewer pages than exist.
Mojibake-proof slugificationReal venue data arrives double-encoded. Slugification tries iconv translit first and falls back to an explicit map that handles the double-encoded pairs. Output is guard-clean ASCII either way.
Zero dependenciesPlain PHP. No Composer, no framework, no config DSL — drops into shared hosting.

What changed from production

The repo is production code with the private parts removed, and it says so plainly. The production web endpoint, its auth token, and the site’s data exports stay on the server; the bundled sample dataset stands in for them, and the demo pages serve that sample run verbatim. The rules, the slug guard, the report format, and the fail-loudly path are production code, unchanged.

Try it locally

No network, no API keys, no database:

php tests/run-tests.php   # 47 contract checks - what CI runs
SITEMAP_OUTDIR=/tmp/out php bin/generate-sitemaps.php examples/sample-config.php

The 47 checks cover both directions: slug guard accepts and rejects, diacritics and mojibake slugification, JSON wrapper tolerance, the upcoming filter, the undercount-never-404 behavior, curated-only mode, the shows toggle, index composition, XML well-formedness, the production report format, and the fail-loudly path. Fixtures are built at runtime with dates relative to today, so the suite never goes stale.

Frequently asked questions

What does segmented-sitemaps do?

It builds a segmented sitemap set — an index plus one file per segment — in which every listed URL is proven live by construction: only entities with upcoming events are listed, and every lastmod is derived from real data.

How does it prevent dead URLs in the sitemap?

A venue without an upcoming event is never listed. The library deliberately undercounts rather than ever pointing Google at a 404: a wrong URL can never get in, a right one can only be left out.

Does it need a database?

No. It is JSON-native: it reads the site’s own export files, so the sitemap is built from exactly what the site renders from. Tolerant loaders accept bare arrays and wrapper objects alike.

What happens if a source file is missing or broken?

The run fails loudly and writes nothing. Half a sitemap is worse than a stale one, so no partial output is ever published.

How do I run it on a schedule?

Wire the CLI runner to cron after each data sync; each run rewrites the full set atomically per file. Submit /sitemap.xml once in Search Console.

Messy data? That's the fun part.

Bryan Hamilton is a full-stack web engineer with 25+ years in PHP and JavaScript, available immediately for remote, hybrid or on-site roles in the NYC metro area.