If you maintain a docs folder that keeps drifting — missing index entries, stale translations, categories that no longer match — this is for you.
NENE2 is my small PHP framework for building AI-readable business APIs.
Repository:
https://github.com/hideyukiMORI/NENE2
Over time it grew a large set of how-to guides. Today the English directory holds 261 guides, and every one of them is mirrored into five more languages: Japanese, French, Chinese, German, and Brazilian Portuguese.
That is 261 guides across 6 locales — 1,566 files in all.
Maintained by one person.
The only way this stays sane is to stop treating docs as prose and start treating them as data.
This article is about the machinery that makes that possible.
What you'll learn:
- How to make YAML frontmatter the single source of truth and regenerate every index from disk
- How to keep six-locale translations in sync without duplicating the taxonomy
- How to turn index drift into a red CI check instead of a manual chore
The problem with hand-maintained indexes
If you have ever kept a docs folder, you know how index pages rot.
You add a guide. You forget to add it to the index. Someone else renames a file. The category list drifts. A translation goes missing and nobody notices for months.
None of this is dramatic. It is just slow decay.
At six guides it does not matter. At 261 × 6 it is fatal.
So the rule I settled on is simple:
No index is ever edited by hand. Every index is regenerated from the files on disk.
Frontmatter is the source of truth
Each English guide opens with a YAML frontmatter block:
---
title: "How-to: A/B Testing Framework"
category: product
tags: [ab-testing, experimentation, state-machine, analytics]
difficulty: advanced
related: [feature-flags, feature-flag-api]
---
That block is the source of truth for everything the index knows about the guide:
-
title— display name -
category— one of seven fixed buckets -
tags— lowercase kebab-case labels -
difficulty— beginner, intermediate, or advanced -
related— slugs of sibling guides -
ft— an optional field-trial reference
The seven categories are fixed in code:
getting-started
auth
security
database
api-design
infrastructure
product
The guide body follows the frontmatter. The frontmatter is not decoration — it is structured data that a generator reads.
The generator
A single script, tools/build-howto-index.php, rebuilds every index. It is wired to composer howto:index.
For English, it walks every guide, parses the frontmatter, and groups guides into the seven categories. It writes two things:
- a "Browse by category" table injected into
docs/howto/README.md - a separate
by-tag.mdpage grouping every guide under each tag
The category loop is the whole idea in a few lines:
foreach (guideFiles($dir) as $file) {
$slug = basename($file, '.md');
$fm = readFrontmatter($file);
if ($fm === null || !isset($fm['category'])) {
$missing[] = basename($file);
continue;
}
$byCategory[(string) $fm['category']][] = [
'slug' => $slug,
'title' => $fm['title'] ?? $slug,
'difficulty' => $fm['difficulty'] ?? '',
'tags' => $fm['tags'] ?? [],
];
}
Guides that have no frontmatter, or no category, are not silently dropped. They are collected into $missing and printed as a warning. Skipping something is a visible event, not a quiet gap.
Translations carry no frontmatter
Here is a decision that surprises people: the translated guides have no frontmatter at all.
A Japanese guide starts straight with its H1:
# ハウツー: A/B テストフレームワーク
There is no category: line in the translation. Duplicating the taxonomy into six languages would mean six chances to drift out of sync.
So the generator treats locales differently. For every non-English locale it builds a flat, alphabetical index using the first H1 it finds in each file:
function howtoTitle(string $path): string
{
foreach (preg_split('/\r?\n/', file_get_contents($path)) as $line) {
if (preg_match('/^#\s+(.+?)\s*$/', $line, $m) === 1) {
return $m[1];
}
}
return basename($path, '.md');
}
The taxonomy lives in exactly one place — the English frontmatter. Translations only need to exist and have a heading. That keeps the source of truth singular.
Injecting without clobbering
Each README has a hand-written part I want to keep: a curated "I want to…" finder table.
So the generator does not overwrite the whole file. It only replaces the content between two markers:
<!-- AUTO-INDEX:START (generated by `composer howto:index` — do not edit by hand) -->
...regenerated content...
<!-- AUTO-INDEX:END -->
Everything before the start marker and after the end marker is preserved verbatim. The injection is idempotent: run it twice and you get byte-identical output. That property is the reason the whole thing can be enforced in CI.
Drift is a CI failure
The generator is only useful if a stale index cannot be merged. That guarantee lives in the CI pipeline:
- name: Verify howto index is up to date
run: |
composer howto:index
git diff --exit-code docs/howto/README.md 'docs/*/howto/README.md' docs/howto/by-tag.md
- name: Validate howto frontmatter
run: composer howto:frontmatter -- --require-all
The first step regenerates every index and then runs git diff --exit-code. If regenerating produced any change, the working tree is dirty, the diff is non-empty, and the build fails.
In other words: if you added a guide but did not regenerate the index, CI notices — because it regenerates the index for you and sees that your committed version does not match.
The second step is a separate validator, tools/validate-howto-frontmatter.php. It checks every English guide against the schema: required fields present, category and difficulty from the allowed sets, tags lowercase kebab-case, related pointing at guides that actually exist. The --require-all flag means an English guide with no frontmatter also fails the build.
Between the two steps, drift cannot accumulate silently. A missing index entry, a typo in a category, a broken related link, or a guide with no metadata all turn into a red check.
How it got here, in phases
This was not built in one shot. The git history shows a deliberate rollout:
- Phase A — add auto-generated indexes at all, from the H1 heading.
- Phase B1 — settle the frontmatter schema and prove it on five guides.
- Phase B2 — annotate every English guide with frontmatter (256 at the time).
- Phase B3 — regenerate the indexes from the frontmatter and make it permanently required in CI.
That last step is the one that flipped frontmatter from "nice to have" to "the build fails without it." The guide count has grown from 256 to 261 since then, and the machinery absorbed the growth without any manual index edits.
The translation workload
Adding a guide is not one file. It is one English guide plus five translations, and the index must stay complete in all six locales.
In practice the translations land in batches — the commit history has entries like "translate N untranslated guides into all five locales." This is an AI-assisted solo project, so drafting six language variants of a guide is exactly the kind of work that gets delegated to a model and then reviewed.
But the honest part is this: the quality gate is not "an AI wrote it." The gate is the same mechanical check for everyone. A translation either exists and produces an index row, or the locale index differs from what CI regenerates and the build goes red. The generator does not care who or what produced the file.
What I would tell my past self
Three things held up at scale:
- One source of truth. The taxonomy lives in English frontmatter and nowhere else. Translations only mirror content, never metadata.
- Generate, never hand-edit. Every index is a build artifact. The only hand-written parts sit outside the auto-index markers.
- Make drift a failure, not a chore. Regenerate in CI and diff against the commit. A stale index is a red build, not a thing you hope someone remembers.
None of this is clever. It is boring on purpose.
Boring is what lets one person keep 261 guides alive in six languages without the docs quietly rotting underneath them.
Links
── By Hideyuki Mori (Ayane International) — I build back-office systems for small businesses at published, fixed prices.
🔗 hideyuki-mori.com
Top comments (4)
I really like the idea of treating documentation as structured data instead of prose. Making generated indexes a CI requirement eliminates an entire class of maintenance mistakes.
One thing I’d consider for future scaling is adding a version or content hash to the English frontmatter. That would allow CI to detect not only missing translations, but also outdated ones after the source document changes. Presence is important, but freshness is just as valuable when maintaining multiple locales.
Overall, this is a great example of using automation to remove repetitive work instead of relying on human discipline
Thank you, Mustafa — this is exactly the right next gap.
You're right that presence and freshness are different problems. Today CI only proves a translation exists; it can't tell whether it still reflects the English source after an edit.
The way I'd wire it: hash the English body at translation time and record that hash with each translation (or in a small manifest keyed by slug + locale), not just in the English frontmatter — because the check I actually need is "which source revision was this locale translated from." Then CI recomputes the English hash and flags any locale whose recorded hash is stale. Missing = red today; outdated = red too, once that manifest exists.
The one tradeoff is deciding what counts as a meaningful change — a typo fix shouldn't invalidate six translations — so I'd hash normalized content rather than raw bytes.
Onto the backlog. Thanks for pushing the idea past where the article stopped.
That makes sense. A per-locale source hash or manifest is a cleaner model because it records the exact revision each translation was based on.
Normalizing the content before hashing is also the right call. Otherwise harmless formatting or typo fixes would create unnecessary translation churn.
Glad the idea was useful. The system is already solid; this would just close the freshness gap nicely. Great work.
Appreciate you thinking it through with me, Mustafa — you sharpened the design more than the article did. I'll post back here if the freshness check ships. Thanks again.