Content Pipeline Handoff Notes
Notes on treating TSX as constrained content data to enable database storage and production staging workflows.
August 29, 2026Notes to myself, written while tired, so that picking this back up does not require re-deriving everything. The feature is not done. What follows is what exists, why it is shaped the way it is, and the exact next decision I have to make.
The idea
Treat TSX as content data rather than as code. A post is a file that may only use a fixed whitelist of components. No arbitrary imports, no expressions, no logic. Conceptually similar to MDX, except the authoring surface is pure React components instead of markdown with escape hatches.
The reason this matters: if content is constrained enough to be predictable, it can eventually be stored in the database rather than the filesystem, which unlocks private drafts and a real staging flow in production.
What is built and working
The whitelist contract
whitelist.ts holds the allowed component names and allowed import specifiers. It exports names only, with no React imports, so the sync script can read it from plain Node without dragging CSS modules along.
component-map.tsx maps those names to the real components. It is typed as a record keyed by the whitelist union, so adding a name without adding a component is a compile error. The two files cannot drift.
Rendering
render.tsx holds ContentBody, which was previously duplicated across the posts and docs routes. It handles the TSX branch via dynamic import and the markdown branch separately. Both slug routes now import it instead of carrying their own copy.
Worth remembering: rendering already works. Dynamic import of the file is enough. The AST work described below is the price of database storage, not of rendering. I conflated those two things earlier and wasted time on it.
The pipeline split
Everything that runs at build time now lives in a pipeline/ subfolder, separate from what the app imports at runtime.
src/modules/
content/
whitelist.ts shared by both sides
queries.ts runtime
render.tsx runtime
component-map.tsx runtime
pipeline/
sync.ts
sync2.ts
generate-metadata.ts
validate.ts
db/
turso.ts runtime
pipeline/
migrate.ts
run-migration.ts
inspect-dates.tsThe rule is simple: anything run by hand from a terminal goes in pipeline/. Anything a route imports stays at the module root. whitelist.ts stays at the root because both sides need it, which is exactly why it kept resisting placement.
Server boundaries
Added the server-only package to queries.ts and lib/content.ts. It works by export condition: inside the server component graph it resolves to an empty file, and anywhere else it resolves to a module that throws, which fails the build. It follows the real bundler graph, so it catches transitive chains that a lint rule would miss.
Do not put it on turso.ts. The pipeline scripts import that file and run under plain Node, where the marker throws. This was almost a self-inflicted outage. Coverage is still complete because those two marked files are the only app-side paths to the database client.
The draft leak
getContentBySlug filtered on status not equal to deleted, which meant every draft row was publicly readable by guessing the slug. It now filters on published. The admin view uses a different query and is unaffected.
What is not done
- The validator is dead code. It exists, it traverses correctly, but nothing imports it. Sync still reads TSX as opaque text and writes it to the database without checking anything. The whitelist is currently a suggestion, not a rule.
- Fragments pass silently. The traversal handles JSX elements and self-closing elements but not fragments. The post template emits a fragment as its root, so this is not a hypothetical gap.
- Prettier fights the validator. When Prettier wraps a JSX line it inserts an explicit space expression. The validator rejects every expression, including that one. The formatter will routinely produce content the validator refuses. Needs a narrow exemption for string literal expressions before the gate can be turned on.
The decision waiting for me
Two paths, and I should pick one rather than trying to do both.
Option one: ship the gate
Wire the validator into sync so bad content is rejected at authoring time. Keep the current dynamic import rendering. No storage change. This is a working increment that can land quickly, and it makes the whitelist real. It does not unlock private drafts.
Option two: go to stored JSON
Parse each file to JSON at sync time, store it in a body column on the existing content table, and render from data instead of from the filesystem. Roughly sixty to eighty lines, reusing the traversal the validator already has. This is what actually unlocks private drafts and production staging.
Leaning toward option one first, purely because it is a smaller step that leaves the system working. Option two is the real destination.
Decisions already locked
- Shadcn components are out of the content whitelist.
- Expressions are banned for now.
- Body goes on the existing content table as a column, not a new table. List queries switch to explicit column lists when that happens.
- No barrel file for the content module. Re-exporting the component map pulled all the typography components and their CSS into anything that only wanted a query function.
Things I got wrong last time
Recording these so I do not repeat them. A staging route and an inbox directory got built that were never asked for, and all of it had to be deleted. A barrel file was added reflexively, then removed. There was an assumption that devDependencies are unavailable at build time on the host, which is false. The actual concern with the validator was never availability, it was bundling a large compiler into a serverless function.
The pattern in all three: acting on an assumption instead of checking it. The check was cheap every single time.