Nested DSL
All Rho DSL blocks (callout / layout / tabs / stepper / modal / interact / timeline) can nest inside each other — put a callout in a layout card, an interact in a stepper step, tabs inside a modal, layout inside a timeline event ... composition is unbounded.
When to use nesting
- ✅ Composite content structure (each layout card has its own callout / code / interact)
- ✅ Tutorial-style demos (each stepper step contains a hands-on demo)
- ✅ Multi-view + hidden depth (tabs switch views, each tab has a modal for details)
- ✅ Roadmap with embedded features (timeline event with embedded demo)
- ❌ Nesting 4+ levels deep (visually confusing; readers get lost)
- ❌ Same-type self-nesting (tabs in tabs / stepper in stepper — UI confusion)
Core mechanism: Inner Processor
Every container DSL in Rho (callout / layout / tabs / stepper / modal / timeline), when parsing its own inner content, runs the full markdown + Rho DSL pipeline again — this is the inner processor.
Outer markdown parse
↓
Hits ```layout block
↓ Inner processor takes over
For each :::card content inside layout, **parse markdown + Rho DSLs again**
↓ Hits ```interact inside :::card
↓ Parse + hydrate the interact normally
Result: nesting just works — you don't need to think about "who parses first," only that the syntax is correct, and Rho handles it automatically.
Integration developers:
@rho/mdrequires callingregisterAllInnerPlugins()first, otherwise nested DSLs won't be recognized. See Developer Reference.
Recommended nesting patterns
Pattern 1: Layout + Callout (recommended)
\```layout grid cols=2
:::card accent=green
**Recommended**
> [!INFO]
> Plan A fits 90% of users; zero-config.
Detailed description...
:::
:::card accent=red
**Use with caution**
> [!WARN]
> Plan B requires self-hosting.
Detailed description...
:::
\```
Cards provide layout, callouts provide semantic emphasis — distinct responsibilities; composition feels natural.
Pattern 2: Stepper + Interact (tutorial-style demos)
\```stepper
:::step title="Step 1: feel the parameters"
Drag the slider to feel the parameter space:
\```interact
slider rate 0 0.2 0.05 0.01
template stl:
[Rate] -> [{(rate*100):.1f}%]
\```
:::
:::step title="Step 2: full formula"
Now add principal and years:
\```interact
slider rate 0 0.2 0.05 0.01
slider years 1 30 10 1
slider principal 1000 100000 10000 1000
computed compound = principal * pow(1+rate, years)
template stl:
[Final value] -> [${compound:.0f}]
\```
:::
\```
Tutorial-style walkthrough — each step has a hands-on demo; 10x more effective than pure prose.
Pattern 3: Tabs + Modal (multi-view + optional depth)
\```tabs
:::tab title="Quick start"
3 steps:
1. Install
2. Write a demo
3. Render
\```modal trigger="Full install doc (per-platform details)"
(Detailed install steps...)
\```
:::
:::tab title="API integration"
\```js
import { hydrateAll } from '@rho/md';
hydrateAll(container);
\```
\```modal trigger="Full unified pipeline config"
(Detailed setup code...)
\```
:::
\```
The main thread inside tabs is clean; depth lives in modals — readers expand on demand.
Pattern 4: Timeline + Layout
\```timeline
:::event date="2026 Q1" title="✅ v0.3 — base capabilities"
\```layout grid cols=2
:::card accent=blue
12 static / interactive capabilities
:::
:::card accent=green
Plugin-ecosystem baseline
:::
\```
:::
:::event date="2026 Q2" title="✅ v0.6 — animation + SVG"
Added timer + computed + svg = 15 capabilities
:::
\```
Timeline event with embedded layout for more structured display; higher information density than pure prose.
Combinations to avoid
1. Same-type self-nesting
\```tabs
:::tab title=Outer
\```tabs ← ⚠️ tabs in tabs
:::tab title=Inner
\```
:::
\```
Problem: two tab bars compete visually; readers can't tell outer from inner.
Improvement: lift the inner tabs out of the outer tab content, or use a different container (modal / accordion-style).
Same logic applies:
- ❌ stepper in stepper (two progress bars)
- ❌ timeline in timeline (two axes)
- ❌ modal in modal (overlay-on-overlay)
- ❌ layout in layout (multi-grid visual chaos)
2. Nesting 4+ levels
\```layout
:::card
\```stepper
:::step
\```tabs
:::tab
\```layout ← 4th-level layout, readers lose track
:::
\```
:::
\```
:::
\```
:::
\```
3 levels is the comfort ceiling. At 4 levels of visual nesting, readers can't see structure boundaries. Redesign — flatten to 2 levels, or break into multiple independent blocks.
3. interact in interact (sharing state requires namespace)
\```interact
slider x 0 10 5 1
\```interact ← ❌ This isn't nested DSL; these are two independent interact blocks
slider y 0 10 5 1
\```
\```
Problem: two interact blocks have isolated state by default — dragging one doesn't affect the other. Improvement: use Shared state (namespace):
\```interact namespace foo
slider x 0 10 5 1
\```
\```interact namespace foo
slider y 0 10 5 1
template stl:
[x+y] -> [{x + y}]
\```
Performance / render order
1. Inner processor isn't free
Each nested level runs another markdown parse + DSL plugins. Deep or repeated → performance degrades.
Empirical (2025 browsers / @rho/md v0.1):
- 1 level nesting: < 1ms increment
- 2 levels: ~2-5ms
- 3 levels: ~10-20ms
- 4 levels: ~50ms+, may feel laggy
Practical advice: keep ≤ 3 levels.
2. Hydration order
Outer → inner (top-down). Layout / tabs / stepper containers hydrate first, then their inner interact / modal / etc. blocks. Usually you don't care, but knowing the order helps when debugging hydration bugs.
3. Cost of duplicate declarations
When nesting, don't redeclare the same control — share via namespace:
:::tab title="View A"
\```interact namespace data
slider rate 0 0.15 0.05 0.01
template stl: [Rate] -> [{rate}]
\```
:::
:::tab title="View B"
\```interact namespace data
template vega-lite: ← ✅ Reuses namespace, doesn't redeclare slider
{ ... uses {rate} ... }
\```
:::
Plain-text fallback behavior
Nesting doesn't break plain-text fallback — every level falls back independently.
Example: layout containing a callout containing an interact, on GitHub web:
- Outer layout
:::cardshows as literal - Middle callout
[!INFO]renders as a GFM alert (GitHub supports it) - Inner interact shows as a code block (sliders + template visible)
Each level degrades by its own fallback rules; the whole stays readable.
Common pitfalls
1. Inner plugin not registered (developer side)
import { remarkPlugins, hydrateAll } from '@rho/md';
// Missing: registerAllInnerPlugins();
→ Nested blocks don't parse — callouts inside layouts show as literals. Integration must call registerAllInnerPlugins(). See Developer Reference.
2. Fenced code in fenced code — backtick conflict
\```layout
:::card
\```interact ← ❌ Conflicts with outer \```; markdown closes here
slider x 0 10 5 1
\```
:::
\```
→ When nesting fenced code, use more backticks for the inner:
\````layout ← 4 backticks
:::card
\```interact ← 3 backticks (fewer than outer)
slider x 0 10 5 1
\```
:::
\````
Or reverse — outer 3, inner 4. Rule: outer and inner backtick counts must differ.
3. Nesting destroys plain-text readability
\```layout grid cols=4
:::card
\```layout grid cols=3
:::card
\```layout
:::card
content
:::
\```
:::
\```
:::
\```
→ Even with valid syntax, plain-text readers see a mess of ::: and ``` markers, completely unable to understand. Keep nesting ≤ 3 levels + make each level's responsibility clear.
4. Namespace expectation mismatch from nesting
:::card
\```interact
slider x 0 10 5 1
\```
:::
:::card
\```interact
template stl: [x] -> [{x}] ← ❌ x not declared in this block
\```
:::
→ interact blocks in different cards have isolated state by default. To share, use namespace. See Shared state.
5. Height changes when nested
\```layout grid cols=2
:::card accent=blue
\```interact
slider x 0 10 5 1
template stl: [{x}]
\```
:::
:::card accent=red
Fixed text
:::
\```
→ Dragging the left card's slider may change its content height (template output line count varies) → the whole layout row's height jitters. Give the interact a fixed min-height or minimize template line-count variation.
6. timeline with stepper inside
\```timeline
:::event date=2026 title="V1.0"
\```stepper
:::step title="Feature 1"
:::
\```
:::
\```
→ Technically legal but semantically confusing — timeline is a time axis, stepper is a tutorial walkthrough; mixing them confuses readers. Redesign: lift the stepper out; timeline event just summarizes / links to the version's features.
Recommended mental model
Categorize container DSLs as structural vs state containers:
| Class | Members | Nesting behavior |
|---|---|---|
| Structural containers | callout / layout / tabs / stepper / modal / timeline | Embed any DSL; inner processor handles it |
| State containers | interact | Don't nest (same-name vars conflict); share across blocks via namespace |
Structural ↔ State: free combination (callout containing interact, layout containing interact, stepper containing interact — all legal).
Structural ↔ Structural: can nest (layout containing callout, tabs containing modal), but avoid same-type nesting (tabs in tabs).
State ↔ State: share via namespace, don't nest.
See also
- Shared state (namespace) — Multiple interact blocks sharing state
- Plain-text fallback principle — Nesting still falls back
- Layout grid + cards
- Stepper
- Modal
- Tabs
- Developer Reference: inner processor — Integrators call
registerAllInnerPlugins()