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/md requires calling registerAllInnerPlugins() first, otherwise nested DSLs won't be recognized. See Developer Reference.


\```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 :::card shows 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.


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