zfb

Type to search...

to open search from anywhere

Custom Directives

CreatedJun 1, 2026Takeshi Takatsudo

Register your own MDX directives — container, leaf, and text — that map to JSX components without writing any Rust.

ℹ️ What this page covers

How to register MDX directives (container, leaf, text) so authors can write :::callout, ::youtube{id="…"}, or :badge[new] and have them expand to your JSX components. Includes the v1 attribute restriction and how the engine reports unknown-directive diagnostics.

zfb’s MDX pipeline runs a directive registry as one of its mdast visitors. The registry is the engine primitive — frameworks (or your own project) populate it. You register a directive name, point it at a JSX component, and the pipeline rewrites matching paragraphs into <YourComponent> JSX nodes.

The shapes follow the CommonMark Directives proposal: container, leaf, and text. zfb’s seven built-in admonitions (:::note, :::tip, :::info, :::warning, :::danger, :::details, :::caution) are themselves registered through the same registry via Pipeline::with_defaults() — the registry is the only mechanism, and the defaults aren’t privileged.

The three directive shapes

Container — :::name … :::

Block-level. Body content between the fences becomes the JSX element’s children. Used for callouts, sections, anything that wraps a multi-paragraph body.

:::callout{tone="info"}

Body content runs through the markdown pipeline normally. **Inline
markdown** works. So do nested elements.

:::

Compiles to:

<Callout tone="info">
  <p>Body content runs through the markdown pipeline normally. <strong>Inline
  markdown</strong> works. So do nested elements.</p>
</Callout>

Leaf — ::name[label]{attrs}

Block-level, single line. No fenced body. Use for self-contained embeds where attributes carry the data.

::youtube[Intro to zfb]{id="abc123" start="42"}

Compiles to:

<Youtube title="Intro to zfb" id="abc123" start="42" />

(Whether [label] becomes a title="…" attribute or a child element is up to the directive’s title_from_label flag at registration time.)

Text — :name[label]{attrs}

Inline within a paragraph. Use for badges, inline icons, anything you want to mix into prose.

The new feature is :badge[new]{tone="green"} now in beta.

Compiles to:

<p>
  The new feature is <Badge tone="green">new</Badge> now in beta.
</p>

Registering a directive (Rust side)

Frameworks register directives in Rust by populating a DirectiveRegistry and inserting it into the pipeline. Each directive is a (name, kind, component_name) triple plus a title_from_label flag.

use zfb_content::plugins::directives::{
    DirectiveDef, DirectiveKind, DirectiveRegistry,
};
use zfb_content::pipeline::Pipeline;

let mut registry = DirectiveRegistry::with_defaults();

// Container: :::callout … :::
registry.register(DirectiveDef::container("callout", "Callout"));

// Leaf: ::youtube[label]{id="…"}
registry.register(DirectiveDef::leaf("youtube", "Youtube"));

// Text: :badge[new]
registry.register(DirectiveDef::text("badge", "Badge"));

let mut pipeline = Pipeline::with_defaults();
pipeline.add_mdast_visitor(registry.into_visitor());

After registration, content authors use the source-side syntax (the markdown/MDX block) and the pipeline emits the right JSX element. You’re responsible for making sure the JSX component identifier (Callout, Youtube, Badge) is in scope at the page module — that’s ordinary MDX-component plumbing.

📝 Note

Pipeline::with_defaults() is your starting point — it pre-registers the seven built-in admonitions and wires the standard plugin set (syntect highlighting, heading links, etc.). You can also start from DirectiveRegistry::new() if you want a clean slate, but the defaults are designed to be additive, not opinionated.

Attribute escaping (v1)

All directive attributes emit as JSX string-literal attributes. Authors can write {tone="green"} or {data-foo="bar"} and the emitter renders tone="green" / data-foo="bar" directly.

⚠️ No raw-expression attributes in v1

{count={5}} or {when={isLive}} syntax is not supported in v1. Every attribute value goes through as a string literal — whatever the JSX component accepts as a string-typed prop is what authors can pass. If your component needs a non-string prop, accept the string form and parse it yourself, or expose the data through a different mechanism (a context, a layout prop, a frontmatter field).

The downstream JSX emitter (zfb-content::mdx_jsx_emit) escapes ", &, <, >, and line terminators in attribute values, so authors can’t accidentally inject markup through them.

Unknown directives: diagnostics, not errors

When the registry sees a directive name it doesn’t recognise, it emits a DirectiveDiagnostic instead of failing the build. The original paragraph is left intact. The orchestrator drains diagnostics after each pipeline run and surfaces them as warnings.

pub struct DirectiveDiagnostic {
    pub message: String,
    pub line: Option<usize>,
    pub column: Option<usize>,
}

The pipeline returns a Vec<DirectiveDiagnostic> sink alongside the compiled module. A typical orchestrator drains and prints them between files:

let diagnostics = registry.take_diagnostics();
for d in diagnostics {
    eprintln!(
        "warning: {} (at {}:{})",
        d.message,
        d.line.unwrap_or(0),
        d.column.unwrap_or(0),
    );
}

The reason for non-fatal-by-default: a typo in a markdown post shouldn’t break a 1,000-page build. Diagnostics surface the problem without blocking output.

Typed attribute schemas

Declare the accepted attributes for a directive using AttrSchema and AttrType. The registry validates raw MDX attrs against the schema during expansion and emits DirectiveDiagnostics for violations. This lets content errors surface early rather than silently passing bad data to your JSX component.

use zfb_content::plugins::directives::{
    AttrSchema, AttrType, DirectiveDef, DirectiveRegistry,
};

let mut registry = DirectiveRegistry::new();

registry.register(
    DirectiveDef::container("callout", "Callout")
        .with_attrs(vec![
            // Required enum — must be one of the declared values.
            AttrSchema {
                name: "tone".to_string(),
                ty: AttrType::Enum(vec![
                    "info".to_string(),
                    "warn".to_string(),
                    "tip".to_string(),
                ]),
                default: None,
                required: true,
            },
            // Optional string with a default.
            AttrSchema {
                name: "title".to_string(),
                ty: AttrType::String,
                default: Some("Note".to_string()),
                required: false,
            },
            // Optional boolean, defaults false.
            AttrSchema {
                name: "compact".to_string(),
                ty: AttrType::Boolean,
                default: Some("false".to_string()),
                required: false,
            },
            // Optional number.
            AttrSchema {
                name: "max-width".to_string(),
                ty: AttrType::Number,
                default: None,
                required: false,
            },
        ]),
);

The four AttrType variants:

VariantInputValidated as
StringAny valuePassed through unchanged
Enum(variants)Must equal one of the declared strings (case-sensitive)ValidatedAttrValue::Enum
Boolean"true", "false", or empty string (bare attr = true)ValidatedAttrValue::Boolean — emits "true" / "false" in JSX
NumberAny string parseable as f64ValidatedAttrValue::Number (original string stored)

Validation rules

  • Missing required attr → diagnostic emitted; expansion falls back to raw attrs unchanged.
  • Enum mismatch (value not in declared variants) → diagnostic.
  • Type-coercion failure (x="abc" for a Number attr) → diagnostic.
  • Default values — absent optional attrs get their default applied before the JSX is built.

Unknown-attr policy: warning only

Attributes present in the MDX source but not declared in the schema emit a warning diagnostic — they are NOT errors and they still pass through to the JSX element unchanged. This preserves existing leniency (authors can pass HTML attributes like data-* or aria-* without declaring them) and means a missing schema entry never breaks a build.

warning: directive `callout`: unknown attribute `data-testid` (warning only — attr passes through unchanged)

If you want strict mode, register all attrs and treat any diagnostic as a build error in your orchestrator. The policy is “warn, not error” by design; the engine never blocks output on an attr it doesn’t recognise.

Backwards compatibility

Directives registered without .with_attrs(…) (i.e. via container, leaf, or text constructors without chaining) skip validation entirely. All existing call sites continue to compile and behave identically.

What the engine doesn’t do

  • Validate attribute values against JSX component prop types. The engine validates against the AttrSchema you declare (type + enum values). TypeScript typing on the JSX component side is still your responsibility.
  • Provide a default directive set beyond the seven admonitions. No built-in :::callout, :::card, ::youtube, etc. — every framework picks its own.
  • Auto-import the JSX components. You’re responsible for making the components in scope at the page module (a global components map, an MDX layout, an explicit import). See MDX Components for the defaultComponents recipe.

See also

  • MDX Components — how compiled MDX modules look up element-level overrides.
  • Engine vs Framework — why the registry is engine-shaped and the directive set is framework-shaped.
  • crates/zfb-content/src/plugins/directives.rs — the registry implementation, including parser fixtures for each shape.

Revision History