Two Thousand Pages From One City File: Operating a Programmatic Static Site
How 2,197 static pages come out of a 152-entry city file and one 11,156-line Node script (measured 2026-09-05) with no framework and no dependencies: the content-hash asset URL that makes a single stylesheet edit rewrite a string on every page, the 18 guards that run before a commit, where the climate numbers actually come from, and the 675 mature pages that have never been referred by any search engine.
Programmatic generation / static output
weatherhack.com is a static site with 2,197 HTML files in it. There is no CMS, no framework, and no build pipeline — one Node script reads a file of 152 cities and writes the tree.
- Generator:
generate.cjs, 11,156 lines, one file - Dependencies: none — no
package.json, nonode_modules, and exactly threerequirecalls, all Node builtins (fs,path,crypto) - Input:
cities.json, 152 entries, plus 152 committed JSON files underhtml/data/ - Output: 2,197
*.html, of which 2,194 are listed in the sitemap - Guards: 18
*.test.cjsfiles run bynode test/run-all.cjs - Never found: 675 of its pages that have been live 21 or more days have never been referred by any search engine
Every number on this page was taken from the repository at commit c743660ba with the command printed beside it, on 2026-09-05. Nothing here was fetched over the network — the generator does not use the network either, which is half the point.
What the generator actually is
It is one script. You run it:
$ node generate.cjs
There is no install step before that line, because there is nothing to install:
$ ls package.json
ls: cannot access 'package.json': No such file or directory
$ grep -o "require('[^']*')" generate.cjs | sort -u
require('crypto')
require('fs')
require('path')
That is the whole dependency surface. The cost is that everything a framework would have given you — templating, routing, incremental builds — is a function in an 11,156-line file instead:
$ wc -l generate.cjs
11156 generate.cjs
I will not pretend an eleven-thousand-line single file is a design triumph. It is the shape you get when the alternative is a dependency tree you have to keep patched for a site that renders text into HTML. What it buys is that the build has no failure mode outside the file you are looking at.
The page count comes out of the city file by multiplication, which is the entire idea behind “programmatic”:
$ node -e "const c=require('./cities.json');console.log(c.length)"
152
$ find html -name '*.html' | wc -l
2197
The 2,197 break down by family like this:
$ find html -name '*.html' | sed 's|^html/||' \
| awk -F/ '{print (NF>1?$1:"ROOT")}' | sort | uniq -c | sort -rn
1977 weather
111 compare
46 similar
27 climate
13 best-weather
9 rankings
8 region
2 legal
2 ROOT
1 methodology
1 data
The weather family is 90% of the site and is pure arithmetic on the city count — one family index, one page per city, and twelve month pages per city:
$ find html/weather -mindepth 2 -maxdepth 2 -name index.html | wc -l
152
$ find html/weather -mindepth 3 -maxdepth 3 -name index.html | wc -l
1824
$ node -e "console.log(152 * 13 + 1)"
1977
152 cities × 12 months is 1,824; plus 152 city pages and one index gives 1,977. That is the honest description of a programmatic site: one entry in a JSON file becomes thirteen URLs, and adding a city adds thirteen more without anybody writing a word.
The sitemap carries 2,194, three fewer than exist. The three are not a bug and it is worth knowing which they are before you go hunting:
$ grep -c '<loc>' html/sitemap.xml
2194
$ comm -23 \
<(find html -name '*.html' | sed 's|^html||; s|/index\.html$|/|; s|\.html$||' | sort) \
<(grep -o '<loc>[^<]*</loc>' html/sitemap.xml \
| sed 's|<loc>https://weatherhack.com||; s|</loc>||' | sort)
/404
/legal/privacy/
/legal/terms/
An error page and two legal pages. A sitemap is a list of URLs you are asking to have indexed, and those three are not that.
The ?v= hash, and why one stylesheet edit rewrites 2,197 pages
This is the most instructive mechanism in the repository, and it is nine lines long.
Everything under html/js/ and html/css/ is served with cache-control: public, max-age=2592000 — thirty days. The asset URLs originally carried no version, so an edit to an interactive tool reached a returning visitor up to a month late. The comment above the fix records what that cost, measured on the wire on 2026-08-27: /js/wxt-compare.js was still serving a copy four commits stale.
The fix appends a content hash to every asset URL:
const ASSET_VERSIONS = new Map();
function assetUrl(p) {
let v = ASSET_VERSIONS.get(p);
if (v === undefined) {
const bytes = fs.readFileSync(path.join(HTML, p.replace(/^\//, '')));
v = crypto.createHash('sha1').update(bytes).digest('hex').slice(0, 8);
ASSET_VERSIONS.set(p, v);
}
return `${p}?v=${v}`;
}
assetUrl('/css/style.css') returns /css/style.css?v= followed by the first eight hex characters of the SHA-1 of that file’s own bytes. You can check that the pages are telling the truth, because the input is a local file and the hash is reproducible:
$ node -e "const c=require('crypto'),f=require('fs');console.log(
c.createHash('sha1').update(f.readFileSync('html/css/style.css')).digest('hex').slice(0,8))"
a9cd9cd9
$ grep -rho '/css/style\.css?v=[0-9a-f]\{8\}' html --include='*.html' | sort -u
/css/style.css?v=a9cd9cd9
Now the consequence, which is the thing I would want a stranger to take away. Fourteen distinct assets are versioned this way, and style.css is referenced by every page in the site:
$ grep -rl '/css/style.css?v=' html --include='*.html' | wc -l
2197
So change one byte in a 12,572-byte stylesheet, re-run the generator, and the hash changes, and the same eight characters are rewritten inside all 2,197 HTML files. A one-line CSS edit is a 2,197-file diff. On a programmatic site the blast radius of any shared asset is the whole site, always, and the version string makes that visible in git status instead of invisible in a CDN.
Two design choices in there are worth stealing. It is a content hash, not a timestamp or a build counter: the URL changes if and only if the bytes change, so a build with no asset edit produces byte-identical pages and the thirty-day cache keeps working for every page nobody touched. A timestamp would move every page on every run and throw the entire cache away for nothing. And the hash is defined in exactly one place — the comment above it says so in capital letters — because the failure mode of a cache key is a stale copy that looks fine.
It is also not self-referential, and that is not an accident. The generator only ever writes */index.html, html/data/* and html/sitemap.xml; every file under html/js/ and html/css/ is hand-maintained source. If the generator wrote the assets it hashes, the build would not have a fixed point.
The 18 guards
node test/run-all.cjs runs every *.test.cjs in the directory:
$ ls test/*.test.cjs | wc -l
18
The runner refuses to be quietly diminished. Before running anything it checks that three specific guards are still present by name — determinism.test.cjs, hooks.test.cjs, month-score-context.test.cjs — and exits 1 if one has gone missing, on the grounds that a load-bearing test which disappears otherwise looks exactly like a passing suite. Two of the eighteen are worth describing properly.
asset-version.test.cjs (153 lines) is the other half of the mechanism above. It walks every generated page, extracts each ?v= claim, hashes the referenced file itself, and compares. There is no shared code with the generator’s assetUrl — it re-derives the hash from the file on disk. Its failure message is the assertion in plain sight:
} else if (actual !== claimed) {
fail(page + ' -> ' + asset + '?v=' + claimed +
" but the file's sha1-8 is " + actual);
}
It also refuses to pass vacuously: if it matched zero versioned references across the tree it fails with asset-version: FAILED — MATCHED ZERO versioned asset references across …. That guard-on-the-guard matters more than the main check, because a regex that silently stops matching is how an assertion becomes decoration.
determinism.test.cjs (90 lines) runs node generate.cjs twice and asserts the entire html/ tree is byte-identical between the two runs. Its own header explains why it snapshots every file rather than just the generated HTML: “the generator must not touch hand-maintained files nondeterministically either”. On success it prints
determinism: OK — two consecutive regenerations produced identical html/ trees (N files, digest …)
and on failure it does the useful thing, listing exactly which paths changed, appeared or vanished rather than just reporting a digest mismatch. This is the guard I would write first on any generated site. Nondeterminism in a 2,197-file generator does not announce itself; it shows up as a working tree that is dirty after a build you did not intend to change anything with, and by then you cannot tell which of the 2,197 diffs was real.
Where the numbers on the pages come from
Two sources, no API key, and the split between them is the part people get wrong.
Climate normals are offline and committed. Monthly average high, average low and mean precipitation come from Open-Meteo’s ERA5 archive over 2020–2024 — five full calendar years, roughly 1,826 days per city — fetched once by a small script that is not the generator, and committed as static JSON:
$ ls html/data/*.json | wc -l
152
The generator never touches the network. A city missing from the file simply renders no climate section rather than a placeholder.
Live conditions are client-side. Current weather comes from api.open-meteo.com/v1/forecast, called from the visitor’s own browser, with the air-quality and geocoding endpoints alongside it. There is no server in the middle and no account or API key on either path.
The interesting bit is the discipline around not over-claiming that provenance. Five of the archive series are ERA5 2020–2024. Two are not, and the source comments shout about it: sea-surface temperature comes from marine-api.open-meteo.com averaged over 2023–2024 only, and the ultraviolet index from air-quality-api.open-meteo.com, which Open-Meteo derives from the CAMS model rather than ERA5, also 2023–2024. Both carry their own source string (open-meteo-marine-2023-2024, open-meteo-cams-2023-2024) and an instruction not to describe them as ERA5 or as a five-year normal anywhere they render.
Why the windows differ is measured rather than assumed: the ERA5 archive returns all nulls for sea_surface_temperature_mean — it is not a sea-surface source at all — and marine-api’s first non-null row is 2022-11-24, so any window opening before 2023 under-weights January through November. Same for UV: ERA5 accepts a request for uv_index_max and answers with a column of nulls, which is the worst possible failure, because it is a green HTTP response that ships an empty column.
If you generate pages from data, the label on the data is generated too, and it is the easiest thing on the page to get wrong at scale. A wrong provenance string does not appear once; it appears 1,824 times.
Honest limits
Here is the number that made this article worth writing, and it is not a good one.
675 of weatherhack’s pages that have been live 21 or more days have never been referred by any search engine. Not “rank poorly” — never referred, by anything, once. The source is our own first-party traffic scan against the property’s referrer log, and the median length of those 675 pages is 3,504 words. It is the largest such count of any site we run.
That is roughly a third of the site, at around 3,500 words each, generated correctly, tested by 18 guards, deterministic, correctly hashed, correctly labelled, and never visited from a search engine. Every mechanism described above works. None of them creates demand.
The lesson I would hand someone about to build a generator like this one is the one the generator itself cannot teach you: multiplication is not a strategy. 152 × 13 + 1 is a fact about a for-loop, not evidence that 1,977 pages were wanted. The cost of the 675 is not storage — it is that a site whose page count outruns its demand spends its crawl budget and its author’s attention on pages that were never asked for, and you cannot tell which third is which from inside the repository. On this property the consequence is now a written rule: expanding the page count is refused on measured evidence, and depth on the pages that already earn attention is what is permitted instead.
Two smaller limits, since the article is about operating one of these honestly:
- A shared-asset edit is untestable by inspection. A one-byte CSS change producing a 2,197-file diff is correct behaviour, but it also means “review the diff before committing” stops being a real instruction. What actually protects you is
determinism.test.cjsandasset-version.test.cjs, not reading. - The determinism guard runs the whole generator twice. That is the right check and it is not free; it is the slowest thing in the suite, and it gets slower with every city added.
When not to build it this way
If your pages need editing by a person after they are generated, do not do this — there is nowhere to put the edit that the next run will not overwrite. This shape suits pages that are entirely a function of a data file and are willing to stay that way. And before you write the loop, get a number for how many of the pages it will produce anybody actually wants. I did not have that number when this was built. I have it now, and it is 675.
Where visitors land now (TRAFFIC.md, 2026-10-08)
Everything above is the 2026-09-05 snapshot of the repository at commit c743660ba, and it stays as it was measured. This section is a later reading from a different source: the estate’s first-party traffic file, TRAFFIC.md, as generated on 2026-10-08, and its all-time table of visits referred by a search engine or an AI assistant. A path’s www. and bare-domain rows are added together. The notes on how each page is built come from reading weatherhack’s generate.cjs at commit 7e9e78fee.
Three pages that are not about any single city are where a good share of those visitors arrive:
- Cities with a similar climate — 42 referred visits (37 on
www., 5 on the bare domain), and those 5 all came from ChatGPT.similarIndexPage()writes this hub and embeds every city’s monthly normals fromdata/climate.jsonas an inline JSON block, which the in-browser matcher ranks by the same distanceclimateDistance()uses for the ready-made/similar/<city>/pages: for every month, the squared difference in average high, plus the squared difference in average low, plus the squared rainfall difference weighted at one tenth, summed over the year and square-rooted. - Compare city climates — 28 referred visits (17 on
www., 11 on the bare domain), at least 10 of them from ChatGPT.compareIndexPage()lists the published pairs thatbuildComparePairs()picks from the popular cities, dropping any pair where either city lacks normals, and embeds the same normals for the builder, so a visitor can put any two to four cities side by side without a server being asked anything. - Where is it warm in Europe, month by month — 22 referred visits across three month pages: October 10 (6 on the bare domain, all from ChatGPT, plus 4 on
www.), November 8 (at least 5 of them from ChatGPT) and March 4. 21 of those 22 also appear in the file’s last-7-days table, so this is a seasonal question being asked right now, not an old accumulation.bestWeatherRegionMonthPage()takes the citiesbuildRegions()files under Europe — the very list/region/europe/prints, so the two cannot drift — and sorts every one by its average daytime high for that month fromdata/climate.json, breaking ties toward the sunnier city.
None of the three is in the weather family that makes up most of the page count. Each is a hub or a month page computed across all the cities at once from the same normals, which is the one thing a single city page cannot answer. That does not undo the never-found count above; it says which kind of generated page people are actually asking for.