---
title: Markdown transforms
description: The default transform stack that flattens MDX components into markdown.
lastModified: "2026-07-07T21:11:53+01:00"
---
The markdown transform stack turns interactive MDX into agent-readable markdown. Imports are stripped first, placeholders are resolved, then named components are flattened into markdown equivalents. Order matters.

```ts
import {
  defaultMarkdownTransforms,
  includeMarkdown,
  nativeMarkdownComponentsToMarkdown,
} from "leadtype/markdown";
```

## The default stack

```mermaid
flowchart TB
src["MDX (mdast)"]
imports["remove imports"]
comments["remove JSX comments"]
placeholders["resolve doc placeholders"]
native["nativeMarkdownComponentsToMarkdown"]
out["clean markdown"]
src --> imports --> comments --> placeholders --> native --> out
```

`defaultMarkdownTransforms` runs the stack in this order:

1. Strip MDX `import` and `export` statements.
2. Strip `{/* ... */}` JSX comments.
3. Replace `{framework}` and similar placeholders in URLs.
4. `nativeMarkdownComponentsToMarkdown` runs the single-pass built-in dispatcher for `<Audience>`, `<Section>`, `<Callout>`, `<Cards>`, `<Details>`, `<Mermaid>`, `<CommandTabs>`, `<Steps>`, `<Tabs>`, `<TypeTable>`, `<Accordion>`, `<TopicSwitcher>`, `<FileTree>`, `<Prompt>`, and `<Example>`.

## Why order matters

Import/export stripping runs first so authoring-only ESM never reaches later markdown output. JSX comments are removed before placeholder resolution so hidden notes cannot feed later transforms. Placeholder resolution runs before `nativeMarkdownComponentsToMarkdown` because built-in and custom flatteners expect final text and URLs, not unresolved doc-context tokens. The native dispatcher runs last so components see the cleaned tree and emit the final markdown shape.

Don't reorder casually. If you need a custom transform to run before a flattener, insert it after placeholder resolution and before the flattener you want to feed. For most custom components, reach for `defineComponentFlattener` instead of hand-ordering a raw transform.

## Custom component flatteners

When a component isn't in the [naming contract](/docs/writing/components#the-naming-contract), `defineComponentFlattener` lets you describe how it flattens without writing a raw mdast transform by hand:

```ts
// flatteners.ts
import { defineComponentFlattener } from "leadtype/markdown";

export const hint = defineComponentFlattener({
  name: "Hint",
  props: { title: "string" },
  toMarkdown: ({ props, content, b }) =>
    b.blockquote([`**${props.title ?? "Hint"}** ${content}`]),
});
```

### Wiring it in

Most projects generate with the `leadtype generate` CLI, so register flatteners on the config. They apply to every generated `.md` file and the `llms` artifacts:

```ts
// leadtype.config.ts
import { defineDocsConfig } from "leadtype";
import { hint } from "./flatteners";

export default defineDocsConfig({
  product: { name: "…", tagline: "…" },
  flatteners: [hint],
});
```

`flatteners` is also valid on each entry of `collections`. Generation runs one conversion pass over all pages, so top-level and per-collection lists are merged into one set that matches by component name. If you drive conversion programmatically instead, pass the same flattener to `createDocsSource` or `convertAllMdx` via `markdownTransforms: [...defaultMarkdownTransforms, hint]`.

### Scheduling

A flattener runs in a dedicated **custom** phase: after includes and placeholder resolution, before the built-in flatteners. Position relative to the built-ins doesn't matter because Leadtype reorders by phase. Order **among** custom flatteners is preserved, and it matters when one custom component nests inside another.

### Props

`props` is a declarative coercion map. Each value is `"string"`, `"number"`, `"boolean"`, or `"string[]"`; the matching `props` object is typed and coerced for you. Bare attributes (`<Hint open />`) resolve to `true`. Anything more exotic can read the raw `node`, which is always present on the context.

### Children

Children arrive already flattened in two forms:

* `content` — a markdown **string**. Use this for the common inline case.
* `childNodes` — the same children as **mdast nodes**. Use this when you need to preserve block structure, such as a component wrapping a table.

Use `content` unless you need to keep a table or nested list intact, then compose `childNodes`.

### Output

`toMarkdown` may return a markdown **string** parsed into nodes, a single mdast node, an array of nodes, or `null` to remove the component. The `b` builder namespace (`b.blockquote`, `b.table`, `b.heading`, `b.link`, `b.list`, …) produces nodes without importing mdast types.

If `toMarkdown` throws, the error is logged with the file path and the raw component is left in place rather than failing the build.

### Escape hatch

`defineComponentFlattener` is built on the same toolkit the built-in flatteners use, all exported from `leadtype/markdown`: `createJsxComponentProcessor`, the node creators (`createBlockquote`, `createTable`, …), `getAttributeValue`, `parseItemsArray`, `extractNodeText`, `processContentNode`, and `hasName`. Use these directly when you want full control over the mdast.

> Custom components nested inside **other custom** components only auto-resolve when the outer flattener is listed after the inner one. The child sub-pipeline runs the built-in flatteners, not sibling customs. Built-in ↔ custom nesting works in either direction.

## Optional transforms

### includeMarkdown

Add this when the source docs use include tags or partial composition:

```ts
import { defaultMarkdownTransforms, includeMarkdown } from "leadtype/markdown";

markdownTransforms: [includeMarkdown, ...defaultMarkdownTransforms];
```

Place it before the default stack so included content expands before any flattener sees it.

Use `src` or text content to point at a shared markdown, MDX, or code file:

```mdx
<include src="./shared/auth.mdx" />

<include>./shared/auth.mdx</include>

<import src="./shared/auth.mdx" />

<include-c15t src="./shared/auth.mdx" />
```

Markdown and MDX files are parsed and spliced into the current page. Frontmatter from the included file is stripped. Non-markdown files are included as fenced code; pass `lang` when the extension is not enough:

```mdx
<include src="../examples/login.ts" />

<include src="../examples/env" lang="bash" />
```

To reuse one section from a larger partial, append `#section-id` and wrap the reusable block in a lowercase HTML `section` element:

```mdx
<include src="./shared/auth.mdx#session-flow" />
```

```mdx
<section id="session-flow">
  This content can be reused in multiple pages.
</section>
```

Section extraction currently matches lowercase `<section id="...">` only. A capitalized MDX component such as `<Section id="session-flow">` is flattened later, but it is not an include anchor.

Relative paths resolve from the including file first. You can also pass base paths to the transform for shared partial roots:

```ts
markdownTransforms: [
  [includeMarkdown, ["docs", "content"]],
  ...defaultMarkdownTransforms,
];
```

Nested includes are supported. The transform keeps the included file's directory as `baseDir` for nested relative paths, so a partial can include files next to itself.

`leadtype generate` and `leadtype lint` run `includeMarkdown` automatically. Custom conversion scripts must add it explicitly.

### Type tables with basePath

`<ExtractedTypeTable>` reads a TypeScript file at conversion time. When the file path is relative, Leadtype defaults the base path to the source root inferred from the current MDX file. Override the dispatcher entry when your conversion script should resolve from a specific root or fail on missing types:

```ts
import {
  defaultMarkdownTransforms,
  nativeMarkdownComponentsToMarkdown,
} from "leadtype/markdown";

const markdownTransforms = [
  ...defaultMarkdownTransforms.filter(
    (p) => p !== nativeMarkdownComponentsToMarkdown
  ),
  [
    nativeMarkdownComponentsToMarkdown,
    { typeTable: { basePath: process.cwd(), strict: true } },
  ],
];
```

In your MDX, point at a local TypeScript file by `path` and the type by `name`:

```mdx
<ExtractedTypeTable
  name="PipelineExampleOptions"
  path="./src/types.ts"
/>
```

At conversion time, the transform reads the file, finds the named type, and emits a markdown table with one row per property. The rendered output is what ships in the converted `.md` and root `llms-full.txt`.

## Transform selection rules

* Use `defaultMarkdownTransforms` for any agent-facing or LLM output.
* Add `includeMarkdown` when docs are composed from shared fragments.
* Override `nativeMarkdownComponentsToMarkdown` only when you need options such as a TypeScript type-table base path.
* If output looks wrong, read the converted `.md` first. The fix is almost never reordering; it is usually a custom flattener.
