DEV Community

takahiro hashito
takahiro hashito

Posted on

Designing a zero-dependency build.js: static sites assembled from JSON

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
  );
}
Enter fullscreen mode Exit fullscreen mode

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  -
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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*円均一)/;
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Give the sort a unique final key so the build is deterministic. Diffs stay readable
  2. Anchor time-based logic to real time. Basing it on data timestamps hides breakage instead of exposing it
  3. 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)