Introduction
I run more than twenty static sites as a solo developer. Article data lives in JSON, and a single build.js at the root of each repository turns it into static HTML. That output is served from Firebase Hosting. The sites update automatically four times a day.
The build.js has zero external dependencies. Both dependencies and devDependencies are absent from package.json. The only modules it loads are Node built-ins (fs, path, crypto) plus a local fleet-sites.json.
$ node -e "const p=require('./package.json');console.log(p.dependencies, p.devDependencies, p.scripts)"
undefined undefined { test: 'node --test test/' }
$ grep -oE 'require\("[^"]+"\)' build.js | sort -u
require("./fleet-sites.json")
require("crypto")
require("fs")
require("path")
The two undefined values mean the keys do not exist at all, not that they are empty objects. npm install is never needed.
I did not skip a static site generator for speed or for ideology. I did it because the one thing that matters here is that the build succeeds every single day, unattended. This post covers three design decisions that came out of that constraint.
The overall picture
The whole pipeline is shown below. Two JSON files go in, one script runs, and a directory of static files comes out.
data/articles.json array of articles (one object per article)
data/site.json site config and categories
|
v
build.js validate -> sort -> render with template literals
|
v
public/ articles, categories, tags, sitemap.xml, feed.xml, 404
That is the entire build: read two JSON files, write a directory of HTML. There is no template engine. HTML is assembled with JavaScript template literals. A full build of 147 articles finishes in well under a second.
$ time node build.js
Done. 147 articles, 8 categories, 314 tags.
node build.js 0.76s user 0.21s system 115% cpu 0.832 total
The core of the implementation
1. Make the last sort key unique so the build is deterministic
List ordering uses three keys: freshness bucket, then newest first by date, then slug. The slug is not there for presentation. It is there so that the same input always produces the same output.
// Shared comparator: freshness bucket -> newest date first -> slug (keeps the build deterministic).
function byFreshness(a, b) {
return (
freshRank(a) - freshRank(b) ||
String(b.date).localeCompare(String(a.date)) ||
String(a.slug).localeCompare(String(b.slug))
);
}
Articles added on the same day are a daily occurrence. If the comparison stops before the third key, the result of Array.prototype.sort depends on input order. Move a record inside the JSON file and the ordering changes, which means every page shows up as a diff even though nothing meaningful changed. The one article that actually changed gets buried in noise.
You can verify the fix by building twice and hashing the output.
$ for i in 1 2; do find public -name '*.html' -print0 | sort -z | xargs -0 cat | shasum; node build.js >/dev/null; done
c821dc4ca0dcf711564a3890d89de71e54a84a20 -
c821dc4ca0dcf711564a3890d89de71e54a84a20 -
Identical. Rebuilding does not move a single byte.
2. Anchor "today" to real time, not to the data
These sites cover time-limited sales, so entries that are likely over get pushed down the list. The choice of reference date decides how that behaves.
// The ordering baseline is "today" in JST. Using the survey date instead would make
// stale data look like it is still within its deadline.
const ORDER_TODAY_JST = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10);
const STALE_DAYS = 14;
function isStale(a) {
if (isEnded(a)) return false;
if (isExpiredToday(a)) return true;
if (a && a.metrics && a.metrics.deadline) return false; // a published deadline beats our guess
const conf = a && (a.confirmed || a.date);
return !!conf && daysUntil(conf, ORDER_TODAY_JST) < -STALE_DAYS;
}
If the baseline were the date the data was collected, a site whose updates had stopped would look more current, not less. That is a failure mode that hides itself, which makes it the worst kind. Anchor time-based checks to real time.
The branch that skips the staleness rule when metrics.deadline exists follows the same principle: a deadline published by the source outranks our inference. Do not let a guess override a stated fact.
3. Put validation inside the build and fail on unbacked claims
The worst outcome for these sites is publishing a discount that does not exist. So the build scans for assertive claims such as "50% OFF", "half price", or "-> 1,980 yen", and only lets an article through when it has both a confirmed date and a source URL. Otherwise the build stops.
const assertive =
/(\d{1,3}\s*[%%]\s*(OFF|オフ|還元(?!率)|割引)|半額|実質\s*無料|→\s*\d+\s*円|\d{2,4}\s*円均一)/;
The character class [%%] covers both the ASCII percent sign and its full-width counterpart, and there is a story behind that. For a long time this guard only matched the ASCII %. Meanwhile, the rendering code in the same build.js that draws the discount badge had handled [%%] from the beginning. Rendering understood full-width characters; validation did not.
As a result, an article written as 50%OFF never entered the guard at all and could ship without a source or a confirmation date. Measurement found three such articles. All three happened to carry both fields, so the published output was fine, but that was not the guard passing them; it was the guard never looking.
The lesson is not "remember full-width characters in your regex". It is that the moment you define the same rule in two places, one of them will eventually be fixed alone.
Things that bit me
Escaping HTML is the obvious cost of having no dependencies. You write it yourself, and then you have to route every output path through it: <title>, meta tags, JSON-LD (structured data for search engines, embedded as JSON inside a <script> tag), and href values. Raw <, > and & must never reach the output. Cutting corners here breaks pages.
The other one is handling artifacts that may not exist yet. Per-article Open Graph images are produced by a separate script, so sometimes they are missing.
function ogRel(a) {
return fs.existsSync(path.join(PUB, "articles", `${a.slug}.og.png`))
? `/articles/${a.slug}.og.png`
: "/og-image.png";
}
Check for the file and fall back to the shared image. Without these three lines, one failed OG generation run leaves every new article card pointing at a 404. Not letting another stage's failure corrupt this stage's output is worth doing regardless of your dependency count.
Tests run on node --test, which again needs nothing extra.
$ node --test test/
# tests 196
# pass 196
# fail 0
The result
Here is one of the sites this actually runs: https://ocha.autoarticles.net
Conclusion
Zero dependencies was not about being lightweight. It was about making sure a daily build never fails for a reason I did not write. Three decisions carried the most weight:
- Give the sort a unique final key so the build is deterministic. Diffs stay readable
- Anchor time-based logic to real time. Basing it on data timestamps hides breakage instead of exposing it
- Validate inside the build and fail on unbacked claims, but never define the same rule in two places
The third one cost the most. Because the character class was written separately for rendering and for validation, the check silently missed cases for months. If a rule exists in two places, collapse it into one.
This article is about my own side project. It was written with AI assistance.
Top comments (0)