DEV Community

takahiro hashito
takahiro hashito

Posted on

A dependency-free build.js: assembling a static site from JSON data

Intro

I run several small information sites that get new data appended every day. I started with a static site generator, but as the number of sites grew, I spent more and more time on things unrelated to the content: framework version bumps, plugin incompatibilities, slow builds. So I switched everything to a dependency-free build.js (Node standard library only) that assembles public/ HTML from data/*.json.

What I learned is that static site generation splits cleanly into three layers — data, template, validation — and once you do that, you can keep growing without a framework. This post walks through that design.

The big picture

The layout is simple.

data/site.json        … site config and categories
data/articles.json    … an array of articles (one object per article)
build.js              … reads data, renders HTML via template strings, writes public/
public/               … the output (this is what gets deployed)
Enter fullscreen mode Exit fullscreen mode

build.js does exactly three things:

  1. Read the data (JSON.parse(fs.readFileSync(...)))
  2. Validate (reject broken articles — more below)
  3. Apply templates (build HTML with template strings, fs.writeFileSync)

The only dependencies are Node's built-in fs and path. There is no npm install, so any machine can clone the repo and run node build.js immediately.

The core of the implementation

The templates are neither JSX nor a template engine — just plain template strings. Rendering one article to HTML is only this:

function renderArticle(a, site) {
  return `<!DOCTYPE html>
<html lang="ja"><head>
<meta charset="utf-8">
<title>${esc(a.title)} | ${esc(site.name)}</title>
<meta name="description" content="${esc(a.summary)}">
<link rel="canonical" href="${site.canonical}/articles/${a.slug}/">
<script type="application/ld+json">${JSON.stringify(breadcrumb(a, site))}</script>
</head><body>
<h1>${esc(a.title)}</h1>
${a.synopsis.split("\n").map(p => `<p>${esc(p)}</p>`).join("\n")}
</body></html>`;
}
Enter fullscreen mode Exit fullscreen mode

The key point: every value that goes into meta must pass through esc(). Template strings do raw string interpolation, so letting <, >, & through produces broken HTML or double-escaping. Fixing this in one function means no accident, no matter how much the data grows.

Listings, categories, tags, sitemap, and RSS are all generated with the same "map the data, fill a template string" pattern. Shared header and footer are extracted into functions and injected into every page. A framework's "layout inheritance" turned out to be just a function call.

The gotcha

Since data is appended unattended every day, the scariest thing is broken data getting published as-is. So I put validation at the top of build.js and stop the build (fail-closed) if any article fails the check.

function validateArticles(articles) {
  const errors = [];
  const seen = new Set();
  for (const a of articles) {
    if (!a.slug || seen.has(a.slug)) errors.push(`duplicate/missing slug: ${a.slug}`);
    seen.add(a.slug);
    if (!a.title || !a.summary) errors.push(`${a.slug}: missing title/summary`);
    // raw < > left in meta (detects a template accident)
    if (/[<>]/.test(a.title)) errors.push(`${a.slug}: raw tag in title`);
  }
  if (errors.length) {
    console.error(errors.join("\n"));
    process.exit(1);       // stop here; never deploy broken HTML
  }
}
Enter fullscreen mode Exit fullscreen mode

Putting the check inside the build rather than in a separate lint tool is what made it work. "The build passed" becomes an invariant that means "it is safe to publish", so "the build succeeded but the content is broken" can't happen even when nobody is watching.

One more gotcha: tag-page file names. If you use a Japanese tag directly as a file name, the links need percent-encoding. Decide "file name is raw UTF-8, href is encoded" up front, or links break depending on the environment.

The result

Here is one of the sites actually running this way: https://ocha.autoarticles.net

I run several sites on the same skeleton. Adding a new one is just preparing data/ and copying the listing generation in build.js.

Takeaways

  • Static site generation splits into three layers — data, template, validation — and stays maintainable without a framework.
  • Plain template strings are enough. Just fix meta escaping in one function.
  • Put validation inside the build so that "the build passed" means "it is safe to publish". That is what makes unattended operation safe.

This article is about my own side project. It was written with AI assistance.

Top comments (0)