CMS Chronicle #11: Interactives, Media Adapters, and the Markdown Question
We built an entire Interactives engine, a pluggable Media Adapter system, GitHub-native media serving, and confronted the hardest architectural question yet: how do you embed rich interactive content inside markdown without breaking the separation of content and presentation?
The Overnight Sprint
This chronicle covers what might be the most intense 12-hour build session in the project's history. We shipped an entire feature category — Interactives — from zero to production, rebuilt the media system from scratch, and had a genuine architectural crisis that forced us to rethink how richtext content works.
Interactives Engine
Interactives — or "Ints" as we now call them — are self-contained interactive HTML components: Chart.js visualizations, animated diagrams, pricing calculators, interactive demos. They live in their own manager, have their own editor, and can be embedded in content.
What we built in a single session:
- Interactives Manager with grid/list views, search, thumbnails (Pitch Vault style)
- Three editing modes: Preview (iframe), Visual Edit (contentEditable injection from Pitch Vault), Code (Monaco editor with HTML syntax highlighting)
- Full status workflow: draft → published → trashed, matching the document editor exactly
- Properties panel with rename, ID, filename, dates
- Clone, History, Delete — all matching the document editor's button layout and icon sizes
- TipTap embed node — insert Ints into richtext content via a picker
The detail page matches the document editor pixel-for-pixel: same 48px sticky top bar, same Button components, same vertical separator, same icon sizes. Consistency matters.
The MediaAdapter Pattern
The biggest architectural addition: a pluggable MediaAdapter interface that abstracts all media operations.
interface MediaAdapter {
listMedia(): Promise<MediaFileInfo[]>;
uploadFile(filename: string, content: Buffer, folder?: string): Promise<{ url: string }>;
trashFile(folder: string, name: string): Promise<void>;
restoreFile(folder: string, name: string): Promise<void>;
readFile(pathSegments: string[]): Promise<Buffer | null>;
// ... plus all Interactives CRUD
}
Two implementations ship today:
- FilesystemMediaAdapter — reads/writes from local disk
- GitHubMediaAdapter — reads/writes via GitHub Contents API
Every API route calls getMediaAdapter() and works identically regardless of backend. Adding Supabase Storage is just a new class.
GitHub as Storage: The Reality
We use GitHub as a full storage adapter — documents AND media. Files live in the repo, every CMS save is a commit. But displaying GitHub-hosted images in the admin UI revealed a surprising challenge: raw.githubusercontent.com serves ALL files as text/plain with X-Content-Type-Options: nosniff. Browsers refuse to render them as images.
Our solution: Next.js rewrites in next.config.ts that proxy /images/*, /audio/*, and /interactives/* through the CMS admin's own /api/uploads/ endpoint, which fetches from GitHub with correct MIME types. For production sites, the previewUrl setting serves media directly from the deployed site — no proxy needed.
The Markdown Crisis
The hardest moment of the session. We needed to embed file attachments and Interactives inside richtext content. The richtext editor stores markdown. Markdown doesn't know about custom embedded nodes.
We tried everything:
- Custom HTML tags (
<div data-interactive-embed>) — escaped by markdown parser html: truein markdown-it — broke existing contenteditor.getHTML()instead ofgetMarkdown()— broke the entire architecture (content ≠ presentation)- HTML comments (
<!-- interactive:id -->) — stripped by markdown-it withhtml: false - Text tokens (
!!INTERACTIVE[id|title]) — survived roundtrip but required site-side parsing
Each approach fixed one thing and broke another. It was genuinely demoralizing.
The Resolution: Blocks
The answer was already in the codebase. We'd built a blocks system months ago. The solution:
Don't embed complex content inside markdown. Use blocks.
A blog post's sections field is a blocks array:
{ name: "sections", type: "blocks", blocks: ["text", "interactive", "image", "file"] }
Each block type handles its own rendering:
- text → pure markdown, rendered with
react-markdown - interactive → reference to an Int, rendered as iframe
- image → image with alt text and caption
- file → download link
Markdown stays pure. No tokens, no hacks, no escaped HTML. The richtext field does what it's good at — text formatting. Everything else is a block.
The Rendering Lesson
We also learned that the site's markdown renderer matters enormously. A regex-based renderMarkdown() function can't handle images with sizing, tables, or any non-trivial markdown.
The correct approach: react-markdown with remark-gfm and custom components. Specifically, the img component must parse TipTap's title field for float and width information:

This is now documented in CLAUDE.md as the standard pattern, and we're building a Next.js boilerplate that has it all set up correctly.
Everything Else
- Media Manager: dynamic type filters (Images, SVG, Audio, Documents, Interactives), proper icons per type, usage tracking across all media paths
- Soft delete everywhere: media files, Ints, and documents all go to trash with warning dialogs
- Image field: upload + Browse Media modal with search
- File attachment in editor: upload + Browse Media
- Site switcher sync: clicking a site in the dashboard now syncs the header dropdown
- Content sidebar: remembers open/close state in localStorage
- All modals close on Escape: swept entire codebase
- All delete actions have confirmation: inline "Sure?" or modal dialog
- Video URL dialog: replaced native
window.promptwith custom styled dialog - AI bubble menu: hidden for non-text nodes (Ints, audio, video, file attachments)
- Preview URL fix: no longer includes category in path
- Drag and Drop Tab Reordering — planned
- GitHub Site Auto-Sync & Webhook Revalidation — planned
- Framework Boilerplates — planned
What's Next
The boilerplate is the immediate priority. Instead of 2000 lines of CLAUDE.md instructions, AI site builders will clone a working Next.js project with every pattern already implemented.
Then: webhook revalidation so GitHub sites auto-update), AI Edit mode for Ints, and the data-driven architecture where Interactives read their content from CMS collections.
The CMS is not dead. It's evolving into something no traditional CMS has been — an AI-native content engine where the boundaries between structured data, rich text, and interactive experiences are fluid, not fixed.