I have a strong opinion about footers: most of them are too long. Browse any large platform and you will find a footer that fills an entire desktop viewport. Dozens of links, multiple columns each with several sections, legal text scattered across half a dozen categories. I understand why it happens, (because everything feels important to someone and the footer is where things go when there's no better answer) but that doesn't mean I have to like it.
I wasn't willing to do that with Dragon Gate, the webnovel platform I'm building. I wanted a lean footer with a small enough number of links that everything could fit on one line. But privacy policies, terms of service, and their siblings genuinely need to be accessible, often as a compliance requirement. The solution was a policy hub: one footer link to /policies and everything lives there. (Well, almost. Privacy policies are considered important enough that they need to be accessible directly from every page.)
What I didn't expect was how much building that hub would teach me about what policies actually are.
Policies aren't living documents
My initial assumption was that a privacy policy is like a blog post. Something that exists, gets updated occasionally, and always shows users the latest version. That assumption turned out to be wrong.
While planning the feature, I learned that policies in a legal context are timestamped artefacts. When a dispute arises, the relevant question isn't "what does the policy say today?", it's "what did the policy say when the incident occurred?" Courts and regulators care about the version that was in effect at a specific moment in time. A policy you can't produce from six months ago, or one that was silently updated after the fact, is a liability.
Dragon Gate is a platform for authors. They're going to be agreeing to terms that govern their revenue and their rights to their own work. Getting this wrong isn't just a legal risk, it's a trust problem. My branding is explicitly "trust-first," and nothing erodes trust faster than terms that shift under people's feet.
This meant the feature needed two things I hadn't originally planned for:
- Every version of every policy has to remain permanently accessible, even after it's superseded.
- Upcoming changes should be visible in advance, so users know what's coming and can voice concerns before the changes become legally binding.
Once I understood those requirements, the design started taking shape, but not before I got the first iteration badly wrong.
First iteration: too much frontmatter
My initial approach encoded a lot of information directly in each policy's markdown frontmatter:
---
title: Privacy Policy
slug: privacy-policy
summary: How Dragon Gate collects, uses, and protects user data.
appliesTo: All users
lastUpdated: 2026-03-31
effectiveDate: 2026-03-31
supersededDate: 2026-06-31
version: 2026-03-31
archived: false
changeSummary: Initial trust-focused draft.
supersedes: 2026-02-31
order: 10
published: true
showInHub: true
category: legal
---
At the time, this felt thorough. Explicit. In practice, it was a maintenance trap waiting to spring.
The drift problem. Publishing a new policy version meant touching multiple files: set the old version's archived to true, add a supersededDate pointing to the new one, set the new version's published to true, wire up supersedes. Up to six fields across two files for what should be a single-file operation. These fields are all redundant, because they can be inferred from the version numbers and effective dates, which means they'll inevitably drift out of sync. A showInHub: true on a version that's no longer current is a bug that's harmless until it isn't.
The audience problem. Dragon Gate is a solo project right now, but I'm building it for a future with collaborators, including non-technical ones. My LLC's lawyer is going to be reviewing and revising every policy I've written, which is why I chose markdown: .md converts cleanly to .docx and back, so the lawyer can work in Word and I can import edits without friction.
But when a lawyer opens a policy file and sees fields like archived, showInHub, and supersedes, I've handed them implementation details that mean nothing to them and that they can accidentally corrupt. The frontmatter should contain content, meaning information only a human can supply. Everything else should be the system's problem.
So I started removing everything the system could figure out on its own.
The redesign: filesystem as source of truth
The new structure is built on one principle: encode immutable facts in the file path.
src/lib/content/policies/
├── terms-of-service/
│ ├── v1.md
│ ├── v2.md
│ └── v3-upcoming.md
├── privacy/
│ └── v1.md
└── cookie/
└── v1.md
The folder name is the slug. The filename is the version number. Both are permanently true about a document: a v2 terms of service is always v2, and it always belongs to terms-of-service. These files never move, Git history stays clean, and there's no ambiguity.
The -upcoming suffix in v3-upcoming.md is purely for developer ergonomics. The parser ignores it, since version extraction uses a regex that only cares about the leading v<number>, but it tells anyone browsing the filesystem what they're looking at without requiring them to open the file:
function versionFromPath(path: string): number {
const filename = path.split('/').pop();
const match = /^v(\d+)(?:-[^.]+)?\.md$/.exec(filename!);
if (!match)
throw new Error(
`Invalid policy filename "${filename}". Expected format: v<version>(-optional-extras).md`
);
return Number(match[1]);
}
The pared-down frontmatter now looks like this:
---
title: Terms of Service
summary: Core rules governing use of Dragon Gate, focused on fairness and trust.
appliesTo: All users
effectiveDate: 2026-03-31
category: legal
---
Every field here is something only a human can supply, and the system handles the rest. The supersedes relationship, which I was previously maintaining by hand, collapses to a one-liner, since v2 always supersedes v1, v3 always supersedes v2, and so on:
supersedes: version > 1 ? version - 1 : null
Deriving state from dates
The most interesting derived property is version state: whether a given version is current, archived, or upcoming. No field in the frontmatter expresses this, because it's computed entirely from effective dates:
function deriveVersionState(
allEntriesForSlug: PolicyIndexEntry[],
entry: PolicyIndexEntry,
today = new Date().toISOString().slice(0, 10)
): PolicyVersionState {
if (entry.metadata.effectiveDate > today) return 'upcoming';
const effectiveEntries = allEntriesForSlug
.filter((entry) => entry.metadata.effectiveDate <= today)
.sort((a, b) => a.metadata.effectiveDate.localeCompare(b.metadata.effectiveDate));
return effectiveEntries.at(-1)?.metadata.version === entry.metadata.version
? 'current'
: 'archived';
}
If the effective date is in the future, the version is upcoming. Among all versions with past effective dates, the one with the most recent date is current, and everything else is archived.
One detail worth noting: today is a parameter with a default value rather than a hardcoded call to new Date() inside the function body. That's intentional, and it matters for testing, which I'll get to in a moment.
Discovering files automatically
The entry point to the whole system is a single Vite glob import:
const POLICY_MODULES = import.meta.glob('$lib/content/policies/*/*.md', {
eager: true
}) as Record<string, PolicyModule>;
import.meta.glob is a Vite feature that resolves a glob pattern at build time and returns an object keyed by file path. With eager: true, all matching modules are loaded immediately, so every policy markdown file is automatically discovered without any registration, config file, or array to maintain. Add a file and it appears, remove one and it disappears, the filesystem is the registry.
A testable API via factory function
The complete policy API is exposed through a factory function:
export function createPolicyApi(policyModules: Record<string, PolicyModule>) {
const policyIndex = Object.entries(policyModules)
.map(([path, mod]) => ({
path,
metadata: generateMetadata(mod.metadata, path)
}))
.sort(compareEntriesBySlugThenVersion);
// ... internal logic
return { getPolicySlugs, getAllPolicies, getPolicyPage };
}
// Default export uses the real glob data
export const { getPolicySlugs, getAllPolicies, getPolicyPage } = createPolicyApi(POLICY_MODULES);
Consuming code calls the exported functions directly and nothing changes. But because the logic lives in the factory rather than being tied directly to the glob import, tests can inject controlled mock data:
const api = createPolicyApi(MOCK_POLICY_MODULES as never);
This separation matters because import.meta.glob only resolves inside a Vite build context, so you can't use it in a Vitest unit test with hand-crafted data. The factory pattern is what makes the specification testable.
On the subject of specifications: my tests use Vitest's vi.useFakeTimers() and vi.setSystemTime() to control what "today" is during each test run. Since version state is derived from a comparison against the current date, a test that relied on the real system clock would be correct in April 2026 and broken sometime in mid-2026 when the test dates passed. Freezing time makes the tests permanently deterministic:
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-01T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
With time frozen at April 1st, a policy with effectiveDate: 2026-06-01 is reliably upcoming, and one with effectiveDate: 2026-03-15 is reliably current. The tests read as a specification of correct behavior, independent of when they're actually run.
The resulting UX
The hub page shows all current and upcoming policies in a table. Upcoming versions appear in italics alongside their effective date, visually distinct but present, because users deserve to know what's coming.
The detail page handles four distinct states:
- Current — standard view
- Current with an upcoming version — a notice banner linking to the preview, with the effective date and a summary of what's changing
- Upcoming — a banner clarifying this version isn't in effect yet, with a link back to the current version
- Archived — a banner identifying this as a historical document, with a link to the current version
Version navigation uses a dropdown that updates the URL query parameter on change (?version=N). Current versions use clean URLs with no version parameter (/policies/terms-of-service rather than /policies/terms-of-service?version=2) to avoid duplicate URL issues. If someone links to a specific version of an archived policy in a legal context, that link remains valid permanently.
What I'd reconsider
The one thing I'd think harder about is the fully automatic state transition. Right now, a policy file becomes current on its effective date without any deployment, since the date arithmetic just starts resolving differently. That's mostly convenient, but it means I need to be deliberate about when I commit a new version, because a file merged to main is a policy that will go live on its stated date whether or not I remember it's there.
A future improvement might be an explicit status: draft frontmatter field for versions that are still being worked on and shouldn't be publicly visible yet, distinct from upcoming versions that are finalized and ready to preview. For now, the constraint is clear enough that discipline holds: don't commit a version until it's ready to be seen.
The system is about 250 lines of TypeScript across two files, with no database, no CMS, and no admin panel required. The interesting part wasn't the code, it was the sequence of constraints that shaped it: a product opinion about footers, a legal education about what policies actually are, and the obligation to design something a non-developer could safely open and edit. Those three things together produced a solution I'm genuinely happy with.
If you're building something with a similar requirement, versioned documents that need to stay legally accessible, I hope this saves you the iteration I went through.

Top comments (0)