110. MDX and markdown
For app developers. How a page or a piece of content is written in MDX or plain markdown, how the build lowers it like any other component, how a page picks a file by its data, the component map that styles markdown's elements, highlighted code and the collections an index reads.
MDX is a feature of fsr
The parser, the highlighter and frontmatter are Cargo features of the CLI, on by default. An install that wants none of them turns the defaults off and keeps what it needs:
cargo install snapfire_fsr_cli --no-default-features --features tera| Feature | Reads |
|---|---|
mdx | .mdx and .md routes and imports |
mdx-highlight | mdx, plus fenced code highlighted at build |
mdx-frontmatter | mdx, plus YAML or TOML frontmatter as export const meta |
An fsr built without mdx refuses an .mdx page or import by name rather than leaving it out. A build.rs that builds an application gets the features with the build dependency's defaults.
fsr use app mdx writes a starter mdx-components.tsx and, with highlighting built in, styles/code.css. fsr new shop --with mdx starts an application with them.
A page written in MDX
page.mdx beside a route's loader is the route's page. It follows MDX: ESM at the top, markdown with JSX in it and {expressions}:
export const meta = { title: "Install · The FSR handbook" };
# Install
<ol className="steps">
{props.steps.map((step) => (
<li key={step.command}>
<code>{step.command}</code>
<span>{step.explains}</span>
</li>
))}
</ol>
This page is {props.steps.length} steps from `src/content.ts`.The loader runs first, as for page.tsx. Its data is props. export const meta is the route's meta, a plain object since an MDX file has no { data } to read; a route whose loader also exports meta is refused. page.md is markdown alone, MDX's md format, with no JSX and no expressions. Raw HTML in it is left out with a warning in the build report.
The build turns the file into TSX and lowers that, so everything chapter 102 says about a component holds. An import lowers, a vendored component becomes an island, an expression lowers where it can and what does not is moved into an island the browser renders, as for a page. A residue names the line and column of the .mdx, here under [build] strict:
routes/page.mdx:3:6: `window` is not bound here; an import the build cannot follow or a name from outside the bodyImporting a file
An .mdx or .md file under the app is a module whose default export is a component:
import Post, { meta, toc } from "../content/first.mdx";
export default function Page() {
return (
<article>
<h1>{meta.title}</h1>
<nav>{toc.map((entry) => <a key={entry.id} href={`#${entry.id}`}>{entry.text}</a>)}</nav>
<Post />
</article>
);
}toc lists the file's headings, each { depth, id, text }. generated/mdx.d.ts declares *.mdx and *.md for the type checker.
A page that picks a file by its data
The blog's post page is one route for every post. It imports the file its data names, the way a Next page does:
export default async function PostPage({ post }: PostSlugProps) {
const { default: Body, meta } = await import(`../../../content/posts/${post.file}`);
return <article><h1>{meta.title}</h1><Body /></article>;
}The build reads the template: every file under the directory it names that fits it is a choice, lowered once, subdirectories included as a webpack context includes them. A file in a subdirectory is chosen by its path there, so import(`../content/${entry.file}`) reaches plaza/tutorials/010-join.md with file set to that path. This holds for a template import of any file, .tsx as much as .mdx. <Body /> renders the one whose name the key spells and meta is that file's export. A key that names no file renders nothing. The page may be async when everything it awaits is such an import; any other await belongs in its loader.
Collections
An index needs every post's title without a list of its own. [mdx.collections] names a directory:
[mdx.collections]
posts = "content/posts"The build writes generated/content.ts with one entry per .mdx or .md file under it, subdirectories included, by path:
import { posts } from "@generated/content";
export const titles = posts.map((post) => post.meta.title);
export const paths = () => posts.map((post) => ({ slug: post.slug }));Each entry is { slug, file, meta, toc, excerpt }: file is the path under the collection's directory and is what a template import takes. slug is that path without its extension. A file in a subdirectory has a / in both, plaza/tutorials/010-join, so a catch-all route takes the slug whole and a loader splits it to group by directory. A directory named node_modules or starting with . is left out. excerpt is the plain text of the file's first paragraph or null when it has none. A loader sorts and filters the list like any other constant.
The component map
mdx-components.tsx at the app's root maps the elements markdown writes to components, as Next reads it:
import type { MDXComponents } from "mdx/types";
function Heading({ id, className, children }: { id?: string; className?: string; children?: any }) {
return <h2 id={id} className={className ?? "anchored"}><a href={`#${id}`}>#</a> {children}</h2>;
}
export function useMDXComponents(components?: MDXComponents): MDXComponents {
return {
h2: Heading,
wrapper: ({ children }: { children?: any }) => <div className="prose">{children}</div>,
...components,
};
}The build reads the object useMDXComponents returns, so its entries are a component's name or a component written in place. A JSX tag written in an .mdx file is never mapped, only what markdown writes. wrapper takes the whole content as its children; a file's own export default is its layout and stands in for the wrapper. Either is given the content and no other props.
A placement can replace elements for one file with a components prop, an object of components written out:
import { Link } from "@snapfire/fsr-authoring/template";
<Body components={{ a: Link }} />The build lowers the file again with those elements replaced, so every markdown link in the post is a Link and marks the current page.
The [mdx] table
| Key | Default | What it does |
|---|---|---|
gfm | true | Tables, strikethrough, task lists, footnotes and literal autolinks, as remark-gfm writes them |
heading_ids | true | An id on every heading as rehype-slug makes it, plus the toc export |
frontmatter | false | YAML or TOML frontmatter as export const meta, with mdx-frontmatter |
highlight | on with mdx-highlight | Fenced code highlighted at build |
collections | none | A name for each content directory listed in generated/content.ts |
A key whose feature fsr was built without is refused naming the feature. Remark and rehype plugins are JavaScript and are not run; the table above is what they are used for.
Highlighted code
With highlighting, a fence whose language names a grammar is written as one span per line, each token a span whose classes are its TextMate scopes, hl- prefixed. The fence options are rehype-pretty-code's:
```ts title="routes/post/[slug]/page.loader.ts" {2}
export async function load({ params }: Ctx<"/post/{slug}">) {
return { slug: params.slug };
}
```No colours are written: styles/code.css from fsr use app mdx colours the classes in GitHub under data-sf-scheme="light" and OneHalfDark under data-sf-scheme="dark", each also under the system's preference when the document names no scheme. It also resets an application's own code style inside a highlighted block. The classes stay out of the Tailwind class list. A fence with no language is <pre><code>. A fence whose language no grammar knows keeps its title as a figcaption and is <pre data-language="x"><code className="language-x" data-language="x"> with its text unhighlighted.
In the browser
A file lowers and renders on the server like any page, so the browser loads nothing for it. When an island imports one, the browser renders it too: the build writes the browser's copy into .fsr-bundle/ and snapfirec compiles that copy. The copy reads the map through useMDXComponents as MDX's own output does:
import { useState } from "react";
import TipBody from "../../content/tip.mdx";
export function Tip() {
const [open, setOpen] = useState(false);
return <div><button onClick={() => setOpen(!open)}>Show the tip</button>{open ? <TipBody /> : null}</div>;
}Lab
In the handbook, open /install, /faq and /tips: an MDX page reading its loader's data, a markdown page titled by its frontmatter and a page whose export default is its layout. On /tips, click "Show the tip" and the island renders content/tip.mdx in the browser with the same heading component the server uses. In the portal, open /blog/post/why-a-plan-file for a table, a footnote and highlighted code, then /blog/post/a-site-is-a-mount: its markdown links are Links, so the one to the post itself carries aria-current. Add a file to sites/blog/app/content/posts/ and run fsr build sites/blog/app: the index lists it and the post route prerenders it with no other edit.