Elemon is a periodic table card game I made for my sons. Every element is a card and every move is a true fact. Over time the site grew a /learn/ section with guides for kids and parents: all 118 element names, why gold is Au, how to teach element names in elementary school, that kind of thing.

Those guides are plain Markdown in content/learn/*.md. One Node script, tools/build-learn.mjs, turns them into static HTML. No framework, and the pages need no JavaScript to read. This post is about the parts of that script I'd build again.

Front matter that errors loudly

Each guide starts with a small front matter block. Here's the one for the gold page:

slug: why-is-gold-au-element-symbols-that-dont-match title: Why Is Gold Au? 11 Odd Element Symbols, for Kids h1: Why is gold Au? The 11 element symbols that don't match their names description: Why is gold Au and iron Fe? A kid-friendly guide to ... short: The old Latin and German names hiding behind Na, K, Fe, ... published: 2026-11-09 modified: 2026-11-09 byline: dad toc: none faq: A few more questions cards: Na, K, Fe, Au hero_element: gold 

The parser is about a dozen lines. It splits on the --- fences and reads key: value pairs. The checks after it are where the value is. A guide with no slug, title, h1, description, short, published or modified date fails the build. So does a title over 62 characters, a description outside 140 to 162 characters, or the word "free" anywhere in a title. Search engines cut long titles off, and I'd rather find out at build time.

The names table can't have a gap

The element names guide has a table of all 118 elements with a say-it spelling and what each name comes from. I didn't want 118 rows of hand-written HTML, so the data lives in content/element-names.json:

{ "H": ["HY-druh-jun", "Greek words for *water-maker*: burn it and you get water"], "He": ["HEE-lee-um", "*Helios*, the Greek word for the Sun, where it was spotted first"] } 

A {{element-names}} placeholder in the Markdown expands into the full table. The builder walks the same element list the game uses, sorted by atomic number, and if any symbol is missing from the JSON it throws. That one check is why I trust the table. A typo in a key stops the build, so the live table never ships with a hole in it.

Symbol origins get the same treatment. Gold's row says "an old English word; Au is from Latin aurum." Lead's says Pb is from Latin plumbum. Tungsten's says W is from German Wolfram. Each name row links to that element's card, so a kid who reads "Au is from aurum" is one click from the gold card.

The header card is the real card

I wanted each guide to open with an actual Elemon card instead of stock art. So hero_element: gold (a symbol or a name both work) puts gold's card in the page header.

The trick is that the builder doesn't have its own copy of the card. It loads the browser files the game already ships, data.js, facts.js, types.js, holo.js and dex-show.js, into a Node vm context and calls the same face() function the Elemondex uses:

const ctx = { window: {} }; vm.runInNewContext(readFileSync(`${root}site/js/data.js`, "utf8"), ctx); for (const f of ["facts.js", "types.js", "holo.js", "dex-show.js"]) vm.runInNewContext(readFileSync(`${root}site/js/${f}`, "utf8"), ctx); const SHOW = ctx.window.ElemonShow; 

The front of the header card is that face. The back shows the element's number, symbol, family, type and three facts from facts.js. A small script adds the tilt and lets kids drag or tap to turn it over. When I change a card in the game, every guide that uses it picks up the change on the next build. A hero element that isn't a real element throws, and so does a page that doesn't open with a paragraph, because the card sits next to that opening answer.

Old #links keep working

Most of these guides have been rewritten at least once, and headings changed. Old links like #five-activities could already be sitting in someone's notes or a class handout, and I didn't want them landing at the top of the page.

Two things handle it. A heading can pin its id with ## Heading text {#some-id}, so I can reword the heading and keep the anchor. For headings that got renamed or removed, content/learn-anchors.json maps old ids to new ones, or to null when the section is gone:

"best-periodic-table-games-for-kids": { "how-did-i-pick-these-games": "how-i-picked-them", "the-quick-comparison": null } 

The builder drops an empty <span id="old-id"> right before the heading it moved to, or before the first section when there's nowhere better. Right now that file covers 112 old ids across 16 guides.

Fewer template tells

I wanted the guides to read like someone wrote them, so most of the extra blocks are opt-in per page. A table of contents only shows up by default on pages with five or more H2s. FAQ schema only exists if the page has the heading named in faq:, so there's no identical FAQ box stapled to the bottom of everything. Even the "When you're ready" line at the end of the parent guides rotates its links based on a hash of the slug, so two pages next to each other don't end the same way.

The hub has one rule of its own. Every guide has to be placed in exactly one section, and the script throws if a guide is missing or listed twice. With a new kids' story planned every other day, that catches the "forgot to add it to the hub" mistake before it ships.

If you want to see the output, the gold page lives under elemon.win/learn/. Drag the card in the header.