Skip to content

Writing directives

A directive is a fenced block Markdown knows nothing about and your site renders itself:

:::note level=2
Everything between the fences is the body.
:::

Partas.Nacara.Plugins.Directives turns one of those into a typed F# function. You write a decoder for the opening line and a render function for the result; the plugin does the parsing, the nesting and the error reporting.

Declaring one

A directive is a name, a decoder, and a render function.

open Nacara.Plugins

let note =
    Directive.create
        "note"
        (Decode.object (fun get ->
            {| Level = get.Optional.Field "level" Decode.int |> Option.defaultValue 1 |}))
    |> Directive.render (fun ctx args body ->
        Html.aside [
            prop.className "note"
            prop.custom ("data-level", string args.Level)
            prop.children [ body ]
        ])

Register it on the site, and :::note is yours:

let site =
    Site.create "My site"
    |> Markdown.register
    |> Directives.register [ note ]

Registering directives does not take Nacara's built-in ones away. A name the plugin does not recognise is handed back to whoever rendered it before.

Arguments

Everything after the directive's name is read as a YAML flow mapping, then given to your decoder — the same decoders that read front matter. That is why types arrive as types rather than as strings:

:::note level=2 title="Read me first" collapsed tags=[fsharp, dotnet]
WrittenDecoded withArrives as
level=2Decode.int2
title="Read me first"Decode.stringRead me first
collapsedDecode.booltrue
tags=[fsharp, dotnet]Decode.list Decode.string["fsharp"; "dotnet"]
meta={author: you}Decode.objecta nested record

An argument with no = is a flag, which is how you would write it anyway. A value in brackets is read to its matching close, so spaces and nesting inside it belong to the value:

:::note tags=[fsharp, dotnet] meta={author: you, since: 2024}

What is refused

The line is checked before YAML sees it, so a mistake on the directive's line reports the directive's line rather than a YAML document you never wrote:

  • an argument given twice
  • an argument with no name before its =
  • YAML punctuation — , { } [ ] : — in an argument's name
  • YAML punctuation in a value that is not a balanced collection, so title=a,b=2 cannot silently become two arguments

To keep punctuation as text, quote it: title="Hello, world".

Nesting

A nested directive's fence must be shorter than its parent's. Four colons wrap three:

::::steps
:::step title="Install"
Add the package.
:::
::::

Equal-length fences do not nest — the second one closes the first.

Here is that running on this page:

  1. Install

    Add the package.

  2. Register

    Add the plugin to your site.

The two directives behind it:

let steps =
    Directive.create
        "steps"
        (Decode.object (fun get ->
            {| Start = get.Optional.Field "start" Decode.int |> Option.defaultValue 1 |}))
    |> Directive.render (fun _ _ body ->
        Html.ol [ prop.className "nacara-steps"; prop.children [ body ] ])

let step =
    Directive.create
        "step"
        (Decode.object (fun get -> {| Title = get.Required.Field "title" Decode.string |}))
    |> Directive.render (fun ctx args body ->
        match ctx.TryAncestor<{| Start: int |}>() with
        | None ->
            ctx.Error ":::step only means something inside :::steps"
            Html.none
        | Some parent ->
            Html.li [
                prop.className "nacara-step"
                prop.custom ("data-number", string (parent.Start + ctx.Index))
                prop.children [ Html.h3 args.Title; body ]
            ])

What the context knows

The first argument to a render function is a DirectiveContext:

MemberAnswers
ctx.TryAncestor<'T>()the nearest enclosing directive whose arguments are 'T
ctx.TryParent<'T>()the immediately enclosing one, if its arguments are 'T
ctx.Indexwhich sibling this is, counting from zero
ctx.Depthhow many directives enclose it
ctx.Error / ctx.Warnsay that something is wrong

Ancestors are matched by the type their decoder produced, so :::step finds ::::steps by asking for {| Start: int |} — no shared name, no cast.

When something is wrong

ctx.Error and a failed decode both do the same two things: write the message to stderr, and render a visible element in place of the directive so the page shows the problem rather than hiding it.

Styling

A directive that needs CSS offers it to the theme rather than shipping a stylesheet the site has to remember to link. See Plugin style layers.

Edit this page