Static HTML / one Node.js script / no framework / no dependencies

aitheism is a site of essays on what artificial intelligence does to old questions about mind, meaning and belief: could a chatbot be conscious, should you say please to one, can a machine have free will. It has no CMS, no static-site framework and no package.json. One Node script reads one JavaScript array and writes every page. This write-up is about how that works, the tests that stop it from drifting, and what happened once the essays were up: which ones AI assistants sent readers to, and how soon after they were committed.

  • Essays: 23, each one object in a single array in build/content.cjs
  • Generator: build/build.cjs, 1,187 lines on 2026-10-09, using nothing but Node’s built-in fs and path
  • Output: 61 files under html/, every one of them generated; 28 URLs in the sitemap
  • Tests: 20 files under test/, each a plain Node script with no test runner
  • History: 46 commits, the first on 2026-08-05

Every number on this page was read on 2026-10-09: repository facts from the aitheism repository at commit 66f82820 (read only; nothing in it was changed), visitor counts from the estate’s GoatCounter export generated that day and the daily copies of it kept in version control, and Search Console figures from its export for 2026-09-09 to 2026-10-07.

One array of essays, one script that writes the site

The content file is not markdown and not a database. It is a CommonJS module that exports an array, and each essay is an object with a slug, two titles, a short dek, a date, a meta description and a body that is a template string of prose HTML. The free-will essay starts like this (build/content.cjs, lines 1396 to 1398):

    slug: 'can-ai-have-free-will',
    title: 'Who Settles the Next Word?',
    searchTitle: 'Can an AI have free will? What do the three main views say?',

build/build.cjs requires that array and runs straight down: a head() function for the canonical link, description, Open Graph tags and JSON-LD, a header() and footer(), then one function per kind of page (home, essay index, essay, reading list, about, privacy, 404). Each page is a template literal; write() makes the directory and writes index.html into it. There is no plugin system and no virtual DOM, and nothing to install: node build/build.cjs rebuilds the whole site in one pass and prints what it wrote.

The script lives outside the webroot on purpose. html/ is what deploys, so nothing in build/ can ever be served by accident, and the output path is resolved explicitly as ../html.

Derive, never type

Most of the script’s comments explain one rule: anything that can be computed from the essays is computed, never typed by hand. Reading times are the clearest case. They were once written next to each essay, drifted, and told readers a short piece would take nine minutes. Now the script counts the words in each rendered body, with tags dropped and entities resolved, and divides (build/build.cjs, line 513 and lines 530 to 532):

const WORDS_PER_MINUTE = 238;

function readingMinutes(html) {
  return Math.max(1, Math.round(wordCount(html) / WORDS_PER_MINUTE));
}

The same rule covers dates. The build never reads the clock: an essay’s sitemap <lastmod> is its own committed date (or a committed updated date), the index pages take the newest essay’s date, and the build stops with an error if any URL in the sitemap has no valid date. That is what lets the next test work.

The test that fails on a hand edit

A generated site has one dominant way to go wrong: someone edits the HTML directly, it looks right on the live site, and the next rebuild silently throws the edit away. The same estate had already been bitten by that shape of bug: two real blog posts were written straight into a release directory, never put back into source, and went missing from the sitemap.

test/build-reproducible.test.cjs is the guard. It copies build/ and html/ into a temporary directory with the same layout, runs the generator there, hashes every file in both trees, and compares them (lines 75 to 78):

  for (const [rel, hash] of before) {
    if (!after.has(rel)) problems.push(`${rel}: committed, but a rebuild does not produce it`);
    else if (after.get(rel) !== hash) problems.push(`${rel}: committed output DIFFERS from a fresh build — hand-edited, or the generator changed without rebuilding`);
  }

A second loop fails on files a rebuild produces that were never committed. A hand edit to any generated file, or a generator change nobody rebuilt, turns the test red. It also checks that the generator wrote nothing outside the copied html/, because a generator that lost its output path would leave a stale copy behind that still hashed equal, and the test would pass while proving nothing.

The test only works because the build is deterministic: no clock, no random numbers, nothing fetched. Every other choice on this page follows from keeping that true.

A ledger for every quotation

The about page promises that sources are named so readers can go and check them. That promise had failed quietly: a pull-quote on the Chinese Room essay put a slogan in quotation marks and credited it to Searle’s 1980 paper. The idea was Searle’s; the sentence is not in that paper, and nothing in the repository objected. It was found and fixed on 2026-08-05, and a rule came with the fix.

SOURCES.md is a ledger of borrowed wording: 23 numbered entries today, each naming where a quotation appears, what the page credits it to, and the work the words actually come from. test/quote-attribution.test.cjs walks every page, finds every quotation inside a <blockquote> that carries a <cite>, and fails if the quoted words have no entry in the ledger. It also bans the one misquoted phrase from ever coming back against that paper.

The test cannot open the book and check the words are there, and says so in its own header. What it can force is that someone wrote down where the words came from, next to the quotation, where the next editor will see it.

Two titles: the essay’s name and the reader’s question

The essays have literary names: “The God-Shaped Socket”, “Who Settles the Next Word?”. Nobody types those into a search box or a chat window. Since 2026-09-29 every essay also carries a searchTitle, the question it answers in a reader’s own words, and the script puts that question in the <title> and the share titles while the page’s visible heading keeps the essay’s name (build/build.cjs, lines 996 to 998):

  const docTitle = e.searchTitle
    ? { title: e.searchTitle }
    : { title: e.title, ogTitle: e.title };

test/search-titles.test.cjs holds the contract: every searchTitle ends in a question mark, the full title is at most 70 characters, no two essays share one, and each page’s visible heading is still the literary name it had, so a search title can never quietly replace it.

Feed, llms.txt and IndexNow, all built from the same array

Three files exist for machines rather than readers, and all three come out of the same build:

  • feed.xml (since 2026-08-16) is an Atom feed of all 23 essays. Its dates are each essay’s committed date, never build time, so a rebuild never re-announces an essay subscribers have already read, and the reproducible-build test enforces that for free.
  • llms.txt (since 2026-09-09) is a plain index of the site for AI assistants. It is written last, after every page exists, because it reads each link’s label from that page’s emitted <title> and its summary from the page’s own meta description. No summary in it is typed by hand, and the build fails rather than list a page with no title.
  • The IndexNow key file is emitted by the build from a constant: 32 hex characters that a search engine fetches to confirm the site is ours before it accepts a list of changed URLs. Sending that list is a separate tool, tools/indexnow-ping.cjs, which is a dry run unless told to send, and a GET per URL. Its log records one real send, on 2026-09-17; it is not wired to run on every deploy.

What happened: which essays ChatGPT sent readers to

The repository’s history has a burst in it. Between 2026-09-29 and 2026-10-02, 13 new essays were committed, each written to answer one question a person might ask: about a chatbot’s consciousness, a soul, free will, sermons, AI art, the Bible. The same estate export that counts visitors has a section listing which pages search engines and AI assistants sent people to, and it is generated daily and kept in version control, so it can be read day by day.

Three of those 13 essays, plus the reading list, are in that list today:

PageCommittedExport of 2026-10-03Export of 2026-10-07Export of 2026-10-09
Can an AI have free will?2026-10-02no row89
Should you say please to a chatbot?2026-09-29no row55
Could a chatbot be conscious?2026-09-29no row34
Reading list2026-08-05no row33

The figures are search and assistant arrivals since the counter began. “No row” means at most 1: the 2026-10-03 list stops at 400 rows and was already listing pages with a single arrival when it stopped. No copy was kept between 2026-10-03 and 2026-10-07, so the first arrivals can only be placed inside that window.

Where they came from is not in doubt. The export’s table of where AI assistants land lists the free-will essay with 9 and the please essay with 5 across all time, and the conscious essay with 4 and the reading list with 3 in the last 7 days, each from chatgpt.com. Every one of the 21 arrivals on those four pages came from chatgpt.com. The free-will essay had 8 arrivals in the export taken 5 days after it was committed.

The other 10 essays from that burst have no row in the 2026-10-09 list. That list is cut at 400 rows too, and its last rows have 2 arrivals each, so each of those essays had at most 2. The front page has 13 arrivals across all time, and in the last 7 days 22 of the site’s 41 pageviews arrived through a search engine or an assistant, by the rows the list shows.

What Google shows

Search Console tells a different story. For 2026-09-09 to 2026-10-07, Google showed aitheism pages 2 times in total, with 0 clicks, at an average position of 2.5. The one query it reports is the site’s own name, shown once at position 2. Over the same window it showed this site, dankdev.com, 575 times. Whatever the assistant found, Google had barely found the site at all.

What it cannot do

  • It is not a writing tool. Essays are HTML inside a JavaScript string, so a stray backtick or an unclosed tag is the author’s problem. That suits a small site with a careful editor; it would not suit many authors.
  • The rebuild test proves sameness, not correctness. A wrong sentence that was committed and rebuilt passes; the test only says the live files are what the source produces.
  • The quotation ledger records, it does not verify. It can force a line in SOURCES.md; it cannot read the book.
  • No cause is shown. The essays that drew arrivals were committed after the question titles and llms.txt existed, and so were the 10 that drew at most 2. Nothing here shows that the question titles, llms.txt or the feed caused any arrival.
  • The counts are small and partly hidden. Lists cut at 400 rows hide pages with an arrival or two, and the export cannot tell the owner’s own visit from a stranger’s. 21 arrivals is a handful of readers, not an audience.
  • No reading is measured. The numbers count arrivals; nothing here says whether a reader finished an essay or found the answer.
Static SiteJavaScriptReproducible BuildsAnalytics

← Back to Projects

Back to the top