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:

  1. ✅ Is processor.use(remarkLayout) registered on the outer?
  2. ✅ Is processor.use(remarkCallout) registered on the outer? (Does callout work at top level?)
  3. ❌ 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