Inner processor
How nested DSLs work — how callouts inside layouts / interacts inside steppers get recognized and rendered. Common integrator bug source: forgetting
registerAllInnerPlugins().
The problem: why we need an inner processor
Consider this nesting:
\```layout grid cols=2
:::card accent=blue
> [!INFO]
> Callout nested in card
:::
:::card accent=red
\```interact
slider x 0 10 5 1
template stl: [{x}]
\```
:::
\```
The outer unified processor runs remarkLayout, parses to :::card blocks, gets each card's inner content as a string.
But card content has more markdown (callout / interact) needing parsing.
remarkLayout plugin doesn't know which Rho remark plugins the outer registered — it only handles layout itself.
So it can't re-run the full pipeline alone.
That's what the inner processor is for: a globally shared secondary processor dedicated to nested content.
Registration flow
import { registerAllInnerPlugins } from '@rho/md';
registerAllInnerPlugins(); // 1. Call before building outer processor
What it does: registers all of remarkCallout / remarkLayout / remarkAnnotate / remarkTabs / remarkStepper / remarkTimeline / remarkModal to the inner processor singleton.
When to call: once at app startup — afterward, all nested parsing uses the same plugin set.
Selective registration
registerAllInnerPlugins() is a convenience. If you only want to support some nesting:
import { registerInnerPlugin, remarkCallout, remarkLayout } from '@rho/md';
registerInnerPlugin(remarkCallout);
registerInnerPlugin(remarkLayout);
// Nested support: callout + layout only; not stepper / tabs / etc.
⚠️ Note: a common selective-registration bug is forgetting the corresponding outer plugin. E.g., outer registered remarkTabs but inner didn't — top-level tabs work, but tabs inside layout don't. Keep outer and inner registration sets in sync.
Nested parsing flow
Outer processor runs markdown:
-> remarkParse → AST
-> remarkGfm → AST
-> remarkLayout → finds :::card; extracts card inner string
│
▼
calls inner processor
│
-> remarkCallout → handles > [!INFO]
-> other inner plugins
│
▼
inner emits HTML fragment
│
▼
fragment embedded in layout's card HTML
-> remarkRehype → ...
-> rehypeStringify → final HTML
Key fact: the inner processor doesn't re-run remarkParse — nested content is processed by inner then merged. Rho encapsulates the internal details; integrators see "nesting just works."
Debugging nested rendering
Most common symptom: a callout inside a layout shows as literal > [!INFO] ....
Checklist:
- ✅ Is
processor.use(remarkLayout)registered on the outer? - ✅ Is
processor.use(remarkCallout)registered on the outer? (Does callout work at top level?) - ❌ Did you call
registerAllInnerPlugins()? ← Probably this
Fix: add this line at build pipeline startup:
import { registerAllInnerPlugins } from '@rho/md';
registerAllInnerPlugins();
Place before any processor.process() call — usually at module top-level / app entry.
parseInner(content) — advanced API
If you want to manually invoke the inner processor (e.g., writing your own remark plugin that needs to parse nested content):
import { parseInner } from '@rho/md';
const innerHTML = parseInner('> [!INFO]\n> nested content');
// → '<aside class="rho-callout">...</aside>'
Normal integration doesn't need this API — it's for people writing Rho extension plugins.
Performance / limits
Nesting levels
Each level = one inner processor run. Empirical overhead (2025 V8):
| Levels | Incremental cost |
|---|---|
| 1 | < 1ms |
| 2 | ~2-5ms |
| 3 | ~10-20ms |
| 4 | ~50ms+ |
Writers are advised ≤ 3 levels (see Nested DSL (writer)). Integrators should inform users of this limit so they don't write extreme nesting.
inner processor is a singleton
The whole app has one inner processor instance.
- Pro: fast performance, configure-once-use-everywhere
- Con: no multi-flavor (can't have page A use plugin set X and page B use set Y)
If you really need multi-flavor, the current workaround is calling registerAllInnerPlugins again — but that affects the global. Usually one registration set is enough.
Nested fence backtick convention
When nesting fenced code, backtick counts must differ:
\````layout ← 4 backticks outer
:::card
\```interact ← 3 backticks inner
slider x 0 10 5 1
\```
:::
\````
The inner processor follows markdown standards too — outer > inner or vice versa works, but they can't be the same.
The writer docs Nested DSL pitfall A1 covers this, but as integrator your users may stumble — consider adding a hint in your reader UI / error messages.
Nesting when implementing your own reader
If you don't use @rho/md and implement a reader from spec (see Implementing your own reader), you'll design your own inner-processor equivalent:
- When parsing
:::card, get card inner string - Re-run a complete markdown + Rho DSL pipeline on that string
- Embed the result into the card HTML
It doesn't have to be a singleton — could be a function / context-passing / any design. The key is "nested content must be runnable through the full pipeline again."
See also
- Quick start — Where
registerAllInnerPlugins()sits in minimal integration - Remark plugins — Per-plugin inner registration behavior
- API reference
- Nested DSL (writer) — Writer-side nesting rules
- Implementing your own reader — Nesting design when self-implementing