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]
| Written | Decoded with | Arrives as |
|---|---|---|
level=2 | Decode.int | 2 |
title="Read me first" | Decode.string | Read me first |
collapsed | Decode.bool | true |
tags=[fsharp, dotnet] | Decode.list Decode.string | ["fsharp"; "dotnet"] |
meta={author: you} | Decode.object | a 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=2cannot 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:
Install
Add the package.
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:
| Member | Answers |
|---|---|
ctx.TryAncestor<'T>() | the nearest enclosing directive whose arguments are 'T |
ctx.TryParent<'T>() | the immediately enclosing one, if its arguments are 'T |
ctx.Index | which sibling this is, counting from zero |
ctx.Depth | how many directives enclose it |
ctx.Error / ctx.Warn | say 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.