Content Layer: Current System Documentation

By Jay Griffin, Claude Sonnet 4.6*Claude Sonnet 4.6 via GitHub CopilotΒ·Β  February 28, 2026
docsProject: jaygriffFeature: Content System
🏷️ Tags:content-systemsqlitearchitecturedocspostsfilesystem

Reference doc for the SQLite migration. Captures exactly how the current filesystem-based content system works, where the pain points are, and what each function actually does.

Overview

The content layer lives in three files:

Content lives in two directories:


posts.ts

What it does

Scans both content directories on every call and returns arrays of posts or docs with their metadata.

The problem: duplicated filesystem scans

getAllPosts() and getAllDocs() are nearly identical β€” both scan content/tsx/ and content/md/, import every file, parse metadata, deduplicate, and filter by type. The only difference is the final filter: posts check for type === 'post', docs check for type?.startsWith('doc').

This means any caller that needs both posts and docs triggers two full filesystem scans.

Key functions

getAllPosts()

getAllDocs()

getPostBySlug(slug)

getDocBySlug(slug)

getAllAppRoutes()

The scan cost

Every call to getAllPosts() or getAllDocs() does:

  1. fs.readdirSync() on both directories
  2. import() on every TSX file (dynamic import, not cached between requests in dev)
  3. fs.readFileSync() + frontmatter parse on every MD file

At 69 files this is acceptable. At 500+ files this becomes noticeably slow.


content-loader.ts

What it does

Loads a single piece of content by slug β€” returns either a TSX component or markdown string depending on what exists.

Key types

.ts
export type ContentType = 'post' | 'doc';

export interface LoadedContent {
  type: 'tsx' | 'markdown';
  Component?: React.ComponentType;
  markdownContent?: string;
  metadata: PostMeta;
  filename: string;
}

The type field is a discriminated union β€” callers know whether to render a React component or a markdown string.

Key functions

loadContentBySlug(slug, type)

Note: this does a full scan to find the filename, then separately checks if the file exists β€” redundant since the scan already confirmed it exists.

getAllPostSlugs() / getAllDocSlugs()


routes.ts

What it does

Builds a flat RouteEntry[] index used by the Navigator search feature. Combines sitemap entries with post/doc metadata to produce searchable route data.

Key type

.ts
export interface RouteEntry {
  path: string;
  title: string;
  description?: string;
  keywords?: string[];
}

The problem: three scans per call

getAllRoutes() calls:

  1. sitemap() β€” generates the full sitemap (which itself may scan content)
  2. getAllPosts() β€” full filesystem scan
  3. getAllDocs() β€” full filesystem scan

All three happen on every call to getAllRoutes().

How it works

What it produces

A flat array of RouteEntry objects β€” path, title, description, keywords. This is intentionally stripped down from the full PostMeta type. The Navigator only needs what it needs.


The core architectural problem

Every data access goes through a full filesystem scan. There is no caching layer, no persistent index, no way to query a subset of content without loading everything.

This works fine at current scale. It breaks down as content grows because:

The SQLite migration replaces these filesystem scans with actual queries. The three files above become thin wrappers around SQL β€” the shape of the API stays the same, the internals become fast and queryable.


Data Model Problems

These are the design issues in the current PostMeta interface that should be fixed during the SQLite migration.

1. No runtime validation

TypeScript only enforces types at compile time. When frontmatter is parsed from a file:

.ts
const { metadata } = parseMarkdownWithJsonFrontmatter(fileContent);
return { filename, metadata };

You're casting the parsed object to PostMeta with zero runtime checks. The file could be missing required fields, have wrong types, or have typos in field names β€” TypeScript won't catch any of it. It all blows up at render time instead of at write time.

Required fields that are routinely missing in practice: description is typed as required string but the metadata scanner shows empty descriptions across many posts. Same risk with date. These should either be truly required (enforced at write time) or explicitly optional in the interface.

SQLite fixes this β€” NOT NULL constraints on title, slug, date mean bad data can't get in. You catch the problem when populating the DB, not when someone loads a page.

2. Inconsistent union types on author and updated

.ts
author?: string | string[]
updated?: string | string[]

Both fields are string | string[] β€” meaning every single consumer has to branch on Array.isArray() before using them. This pattern spreads defensive code everywhere and is easy to forget.

These should just always be arrays:

.ts
authors: string[]      // always an array, required, minimum 1
updatedDates: string[] // always an array, empty if never updated

A single author is just an array of length 1. A single update date is just an array of length 1. No branching needed anywhere downstream. This happened organically β€” single author came first, multi-author was bolted on later with | string[] to avoid a breaking change. The SQLite migration is the right time to normalize this.

3. description is required but treated as optional

The interface says:

.ts
description: string; // required, non-optional

But in practice many posts don't have one. This means TypeScript thinks it's always there, so any code doing metadata.description.toLowerCase() or passing it to a component won't complain at compile time but will throw at runtime for posts missing it.

Should be description?: string to match reality, or actually enforced as required at write time via SQLite schema.

4. type union is incomplete

.ts
type?: 'post' | 'doc' | 'doc:commit'

But getAllDocs() filters with type?.startsWith('doc') β€” implying there are or could be other doc:* variants beyond doc:commit. The type definition doesn't match the actual filtering logic. Either lock down the union to all known variants or widen it and document the convention.

5. relatedPosts is manual and unvalidated

.ts
relatedPosts?: string[] // Array of slugs for related posts

These are freehand slug strings in a file. No validation that the slugs actually exist. A typo or a renamed slug silently produces a broken related posts link with no error anywhere.

SQLite with a foreign key constraint fixes this β€” a related_posts junction table referencing content.slug will reject invalid slugs at insert time.


What maps to what in SQLite

CurrentSQLite equivalent
getAllPosts()SELECT * FROM content WHERE type = 'post'
getAllDocs()SELECT * FROM content WHERE type LIKE 'doc%'
getPostBySlug(slug)SELECT filename FROM content WHERE slug = ?
loadContentBySlug(slug)SELECT * FROM content WHERE slug = ? AND type = ?
getAllRoutes()SELECT path, title, description, tags FROM content
Related posts (not yet built)SELECT ... JOIN content_tags ... WHERE tag IN (...)
Tag filtering (not yet built)SELECT * FROM content WHERE id IN (SELECT content_id FROM content_tags WHERE tag = ?)

The migration doesn't change the API surface. getAllPosts() still returns the same shape. The filesystem scan just becomes a query.