[]previous and next page

MDX and Markdown

Write pages in MDX with @shadow-js/vite/mdx, mount them as components, replace Markdown elements, read frontmatter, build a content collection, and know what the plugin does in dev, build and SSR.

For agentsUpdated

@shadow-js/vite/mdx compiles .mdx files into ShadowJS components. @mdx-js/mdx turns the Markdown into JSX, and the ShadowJS compiler turns that JSX into the same templates and reactive bindings as the code you write by hand, in one step. An MDX page is therefore a normal component: it renders on the server, hydrates, accepts props, and uses your own components inside the text.

Set it up#

Install the MDX compiler next to the plugin. It is an optional peer dependency of @shadow-js/vite, so apps that do not write MDX never install it.

bash
npm install --save-dev @mdx-js/mdx

Add shadowMdx() to vite.config.ts. The plugin runs before Vite's own handling (enforce: "pre"), so Vite never meets MDX or the JSX it produces.

vite.config.tsts
import shadow from "@shadow-js/vite";
import { shadowMdx } from "@shadow-js/vite/mdx";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [shadowMdx(), shadow()],
});

For TypeScript, tell the compiler what an .mdx import is. Put this line in a .d.ts file that your tsconfig.json includes (src/env.d.ts in the starters):

src/env.d.tsts
/// <reference types="@shadow-js/vite/mdx-env" />

The default export of an .mdx file is a component (props) => JSX.Element. By default only .mdx files are compiled; set include: /\.mdx?$/ to read .md files with the same pipeline.

Write a page#

A page is Markdown with three additions: import and export lines, {expressions}, and JSX tags.

mdx
import { Note } from "./note";

export const meta = { readingMinutes: 3 };

# Hello, {props.name}

Some *emphasis*, a [link](https://example.com) and `inline code`.

<Note kind="tip">Mind the **gap**.</Note>

Imports are resolved by Vite like any other import, so they may name components, data files or other pages. props is the object the page was called with. A prop that is a reactive value keeps the text in step with it: an expression such as {props.name} is compiled like {props.name} in a component.

src/main.tsxtsx
import { render } from "@shadow-js/web";
import Page from "./page.mdx";

render(() => <Page name="Ada" />, document.getElementById("app") as HTMLElement);

Replace Markdown elements#

Every Markdown element (h1, p, a, code, pre, table, ...) is looked up in the components prop before it is rendered, so one object restyles every page:

src/components.tsxtsx
import type { ParentProps } from "@shadow-js/web";

export function Heading(props: ParentProps) {
  return <h1 class="title">{props.children}</h1>;
}

export function Layout(props: ParentProps) {
  return <main class="prose">{props.children}</main>;
}
src/blog.tsxtsx
import { Heading, Layout } from "./components";
import Page from "./page.mdx";

export const Post = () => <Page name="Ada" components={{ h1: Heading, wrapper: Layout }} />;

wrapper is special: it wraps the whole page. Any other key replaces the element of that name, and also supplies the tags the file names itself: a page that writes <Chart /> without importing it takes Chart from components, and fails with Expected component Chart to be defined when it is missing.

Each looked-up element is rendered by Dynamic, which accepts a tag name or a component. That costs a little per element. A page that never replaces a Markdown element can opt out:

vite.config.tsts
import shadow from "@shadow-js/vite";
import { shadowMdx } from "@shadow-js/vite/mdx";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [shadowMdx({ elements: "static" }), shadow()],
});

With elements: "static" the elements are part of the page's template and are created in one call. wrapper and the tags the file names itself still come from components; only the replacement of h1, a and the other Markdown elements is no longer possible.

Frontmatter, tables and other syntax#

The plugin passes remarkPlugins, rehypePlugins, recmaPlugins and remarkRehypeOptions to @mdx-js/mdx as they are, so the whole remark and rehype ecosystem works. The usual three:

vite.config.tsts
import shadow from "@shadow-js/vite";
import { shadowMdx } from "@shadow-js/vite/mdx";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    shadowMdx({ remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter, remarkGfm] }),
    shadow(),
  ],
});

A file that starts with --- and a YAML block now exports it as frontmatter, next to the default component and any export const of the file. Merge a declaration to type it:

src/env.d.tsts
/// <reference types="@shadow-js/vite/mdx-env" />

declare module "*.mdx" {
  export const frontmatter: { title: string; date: string; draft?: boolean };
}

A content collection#

A folder of pages is a collection. Vite's import.meta.glob loads every file at build time, with the frontmatter, and a plain function sorts and filters them. Nothing here is specific to MDX: the pages are modules.

src/posts.tsts
/// <reference types="vite/client" />
/// <reference types="@shadow-js/vite/mdx-env" />

import type { JSX } from "@shadow-js/web";

export interface Post {
  readonly slug: string;
  readonly title: string;
  readonly date: string;
  readonly Body: (props: Record<string, unknown>) => JSX.Element;
}

const modules = import.meta.glob<{
  default: (props: Record<string, unknown>) => JSX.Element;
  frontmatter: { title: string; date: string; draft?: boolean };
}>("./posts/*.mdx", { eager: true });

/** Published posts, newest first. */
export const posts: readonly Post[] = Object.entries(modules)
  .filter(([, page]) => page.frontmatter.draft !== true)
  .map(([path, page]) => ({
    slug: path.slice("./posts/".length, -".mdx".length),
    title: page.frontmatter.title,
    date: page.frontmatter.date,
    Body: page.default,
  }))
  .sort((a, b) => b.date.localeCompare(a.date));

eager: true puts every post in the bundle that imports the list. For a long list, drop it: import.meta.glob("./posts/*.mdx") returns functions that load one page on demand, which suits a route that renders a single post.

Options#

Option Default Meaning
include /\.mdx$/ Files to compile, matched against the id without its query string.
exclude any node_modules path Files to skip; checked first.
remarkPlugins, rehypePlugins, recmaPlugins none Plugins of the three syntax trees. Yours run before the plugin's own pass over the elements.
remarkRehypeOptions none Options of remark-rehype.
providerImportSource none A module that exports useMDXComponents, for components every page may use without a components prop.
elements "overridable" "static" renders Markdown elements from the template and ignores replacements of them.
mdx "@mdx-js/mdx" The module name of the MDX compiler, or your own function with the same shape as its compile.
plugins none ShadowJS compiler plugins that run on the JSX of every page.
moduleName "@shadow-js/web" Where the compiled code imports its helpers from ("@shadow-js/web/server" is chosen for SSR).

Dev, build and server rendering#

  • Server rendering. The plugin compiles for the server when Vite asks for an SSR module, so a page renders to a string and hydrates like any component. Dusk and the router need nothing else.
  • Errors. A syntax error in the MDX fails the build with the file, line and column, and the dev server shows it in the overlay. A compile error in the JSX that MDX produced is reported the same way.
  • Hot updates. Editing an .mdx file reloads the page. A page has no component state worth keeping, and a reload is the one behavior that is always correct.
  • Missing compiler. If @mdx-js/mdx is not installed, the first .mdx import fails with the command to install it.
  • Not included. The plugin does not add syntax highlighting, a table of contents or a sitemap. They are rehype plugins (rehype-slug, rehype-pretty-code, ...) that you list in rehypePlugins.