[ Content & feeds ]
A markdown content pipeline
Parse posts with typed frontmatter and custom tags, transform them in one walk, and render them in a Remix route.
Last updated 2026-09-29
This guide builds the path a post takes from a .md file in your repository to a rendered
page: frontmatter validated against a schema, a small vocabulary of custom tags checked at
parse time, one tree walk that highlights code and anchors headings, and a Remix v3 route that
renders the result through components you own. It combines
@sdxc/markdown, @sdxc/highlight and
@sdxc/ui, with remix/data-schema for the schema.
Every step answers with a Result, so a post with a typo in its frontmatter becomes a 404 and
a log line naming the line to fix, not an exception halfway through a render.
npm add remix @sdxc/markdown @sdxc/highlight @sdxc/ui @sdxc/result @sdxc/http
Describe what a post may contain
Start with the two things a post file is allowed to say: the fields its frontmatter carries, and the tags its body may use. Both are schemas, and both go in one options object that every read of a post shares.
import type { Markdown } from "@sdxc/markdown";
import * as s from "remix/data-schema";
import * as coerce from "remix/data-schema/coerce";
const FRONTMATTER = s.object({
title: s.string(),
description: s.string(),
publishedAt: coerce.date(),
tags: s.defaulted(s.array(s.string()), []),
draft: s.defaulted(s.boolean(), false),
});
export type Frontmatter = s.InferOutput<typeof FRONTMATTER>;
export const TAGS = {
callout: {
attributes: s.object({ type: s.enum_(["info", "warning", "danger"]) }),
},
video: { content: "none", attributes: s.object({ src: s.string() }) },
} satisfies Record<string, Markdown.TagDefinition>;
export const MARKDOWN_OPTIONS = {
frontmatter: FRONTMATTER,
tags: TAGS,
} satisfies Markdown.Options;
The frontmatter schema is what types the parse. publishedAt arrives as a Date because
coerce.date() converts the YAML string, and a post missing its title fails with the line the
block starts on. Any Standard Schema works here;
remix/data-schema is the one the rest of a Remix app already uses.
A tag is an element the parser knows by name. Only registered names become tags, so an
unregistered <div> stays raw HTML and renders as escaped text. Registering one with an
attribute schema moves a whole class of mistakes to parse time: <callout type="tip"> is a
parse error with a line number rather than a box that renders without a colour. content
decides what the parser reads inside the tag: blocks by default, "inline" for a tag inside a
sentence, and "none" for a self-closing one. A post then reads:
<callout type="warning">
Deleting a workspace also deletes its **history**.
</callout>
<video src="/media/tour.mp4" />
Transform the tree in one walk
Markdown.walk is the only transform. A visitor is a plain object with one handler per node
type, so visitors merge by spread and a whole preparation runs as a single pass. highlight
from @sdxc/highlight/markdown is exactly such an object, holding one code
handler that attaches the painted tokens to each fence.
import { highlight } from "@sdxc/highlight/markdown";
import { Markdown } from "@sdxc/markdown";
import { toPlainText } from "@sdxc/markdown/plain";
const SITE_ORIGIN = "https://example.com";
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function preparePost(document: Markdown.Document) {
let taken = new Map<string, number>();
return Markdown.walk(document, {
...highlight,
heading(node) {
if (typeof node.attributes.id === "string") return;
let base = slugify(toPlainText(node));
let seen = taken.get(base) ?? 0;
taken.set(base, seen + 1);
let id = seen === 0 ? base : `${base}-${seen}`;
return { ...node, attributes: { ...node.attributes, id } };
},
link(node) {
if (!node.href.startsWith("/")) return;
return { ...node, href: new URL(node.href, SITE_ORIGIN).href };
},
});
}
What a handler returns decides the node's fate: a node replaces it, null removes it, and
returning nothing leaves it alone. A heading an author already gave an id with
{% #install %} keeps it, and a repeated heading gets a suffix so every one stays linkable.
Absolute links matter once the same document is rendered into a feed, where a reader resolves
/posts/… against the wrong origin.
Every handler here is synchronous, so the walk answers with a Result rather than a promise.
A handler that throws lands on the failure branch with the node's position attached, which is
how a check you add later (a code block with no language, say) reports the line it found.
Load and parse inside the request
Keep the files as raw strings and parse per request. A Worker rejects an upload whose global scope does real work, and a lazy glob means a page pays for the one post it renders.
import { Markdown } from "@sdxc/markdown";
import { isFailure, success } from "@sdxc/result";
import { preparePost } from "~/app/content/prepare";
import { MARKDOWN_OPTIONS } from "~/app/content/schema";
export const POSTS = import.meta.glob<string>("../../content/posts/*.md", {
query: "?raw",
import: "default",
});
export async function readPost(slug: string) {
let load = POSTS[`../../content/posts/${slug}.md`];
if (!load) return null;
let parsed = Markdown.parse(await load(), MARKDOWN_OPTIONS);
if (isFailure(parsed)) return parsed;
let prepared = preparePost(parsed.data.document);
if (isFailure(prepared)) return prepared;
return success({ frontmatter: parsed.data.frontmatter, document: prepared.data });
}
An index page needs titles and dates, not bodies. Markdown.frontmatter takes the same options
and stops after the block, so listing a hundred posts reads a hundred frontmatter blocks and
parses no markdown at all. It reads the same POSTS glob the loader above exports:
import { Markdown } from "@sdxc/markdown";
import { isFailure } from "@sdxc/result";
import type { Frontmatter } from "~/app/content/schema";
import { POSTS } from "~/app/content/posts";
import { MARKDOWN_OPTIONS } from "~/app/content/schema";
export async function listPosts() {
let posts: Array<Frontmatter & { slug: string }> = [];
for (let [path, load] of Object.entries(POSTS)) {
let read = Markdown.frontmatter(await load(), MARKDOWN_OPTIONS);
if (isFailure(read) || read.data.frontmatter.draft) continue;
let slug = path.slice(path.lastIndexOf("/") + 1, -".md".length);
posts.push({ ...read.data.frontmatter, slug });
}
return posts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
}
Read the tree without changing it
A handler that returns nothing changes nothing, so the same walk doubles as a traversal. The table of contents reads the ids the preparation pass wrote:
import { Markdown } from "@sdxc/markdown";
import { toPlainText } from "@sdxc/markdown/plain";
export function tableOfContents(document: Markdown.Document) {
let entries: Array<{ id: string; text: string; level: number }> = [];
Markdown.walk(document, {
heading(node) {
let id = node.attributes.id;
if (node.level > 3 || typeof id !== "string") return;
entries.push({ id, text: toPlainText(node), level: node.level });
},
});
return entries;
}
toPlainText takes any node, which is what makes it usable inside a visitor. Called on the
whole document it gives you the prose for a word count or a search index; pass
{ code: true } to include the bodies of code blocks.
Render it in a route
toRemix from @sdxc/markdown/remix turns the tree into remix/ui nodes, and its
components map is where a tag gets its markup. A component receives the tag's attributes as
props, already validated by the schema you registered, plus its rendered children:
import type { Handle, RemixNode } from "remix/ui";
import { Alert } from "@sdxc/ui";
const CALLOUT_COLORS = {
info: "brand",
warning: "warning",
danger: "danger",
} as const;
interface CalloutProps {
[key: string]: unknown;
type: keyof typeof CALLOUT_COLORS;
children: RemixNode;
}
export function Callout(handle: Handle<CalloutProps>) {
return () => (
<Alert color={CALLOUT_COLORS[handle.props.type]} live="off">
<Alert.Content>{handle.props.children}</Alert.Content>
</Alert>
);
}
The index signature is what lets Callout sit in the components map, which hands every
component the node's whole bag of attributes; the named fields are the ones it reads.
The controller is mapped to a posts.show route declared as get("/posts/:slug") in your
routes/web.ts. It reads the slug, handles both kinds of failure the same way, and wraps the output
in Typeset, which supplies the sizes, spacing and colours for markup it did not write:
import { notFound } from "@sdxc/http/response/html";
import { toRemix } from "@sdxc/markdown/remix";
import { isFailure } from "@sdxc/result";
import { Typeset } from "@sdxc/ui";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";
import { Callout } from "~/app/components/callout";
import { readPost } from "~/app/content/posts";
import routes from "~/routes/web";
export default createAction(routes.posts.show, async (ctx) => {
let { slug } = s.parse(s.object({ slug: s.string() }), ctx.params);
let post = await readPost(slug);
if (post === null) return notFound("No such post");
if (isFailure(post)) {
ctx.log.fail(post.error, {
slug,
line: post.error.position?.start.line ?? null,
});
return notFound("No such post");
}
let { frontmatter, document } = post.data;
return ctx.render(
<article>
<h1>{frontmatter.title}</h1>
<Typeset preset="reading">
{toRemix(document, { components: { callout: Callout } })}
</Typeset>
</article>,
);
});
A tag with no component renders its children and nothing else, so video above shows nothing
until you register one, and a missing component drops the chrome while keeping the content.
The same map takes over built-in nodes when you key it by node type (code, heading,
link, alert).
The painted tokens become <span class="token …"> runs, and the stylesheet that colours them
ships with the highlighter. Link it from your document layout:
import highlightStyles from "@sdxc/highlight/styles.css?url";
<link rel="stylesheet" href={highlightStyles} />;
HTML and plain text for everything else
A feed body, an email or a JSON API wants a string rather than UI nodes. toHTML renders any
node as static HTML, with its own tags map returning markup for each tag:
import type { Markdown } from "@sdxc/markdown";
import { toHTML } from "@sdxc/markdown/html";
import { toPlainText } from "@sdxc/markdown/plain";
export function renderHTML(document: Markdown.Document): string {
return toHTML(document, {
tags: {
callout: ({ attributes, children }) =>
`<aside class="callout" data-type="${attributes.type}">` +
`${children}</aside>`,
},
});
}
export function summarize(document: Markdown.Document): string {
return toPlainText(document).slice(0, 160);
}
Interpolating attributes.type is safe only because its schema allows three fixed words; an
attribute that takes free text has to be escaped before it goes into markup. Raw HTML an author
typed renders as escaped text in both renderers, so neither one writes markup you did not vet.
Where to go next
Publish RSS, Atom and JSON feeds — serve these posts to feed readers, with
toHTMLsupplying each item's body.Join the IndieWeb — send a Webmention to every page a post links to.
Build the interface with remix/ui — the components and mixins the page around the article is built from.
@sdxc/markdown— variables, annotations, GitHub alerts and the full AST.