A markup kind is three things: a value type that says what the layer is, one or more emitters that say how it is written, and a codec that says how it is stored. Nothing is discovered by reflection — you hand them to a registry, which is why the packages stay AOT- and trim-clean.
MarkupString.Ansi and MarkupString.Html are ordinary consumers of these same three interfaces.
Yours is a peer of theirs, not a plugin into them.
Implement IMarkup on an immutable value with value equality — a record is the obvious choice.
Equality is what makes identically marked neighbouring spans coalesce into one run, so getting it
right is what keeps the run array small.
using MarkupString;
public sealed record SpoilerMarkup(string? Reason) : IMarkup;A layer that says what the text is, rather than how it looks, implements IDataMarkup instead:
that a character separates the items of a list, say, and is not part of one.
public sealed record ListSeparator : IDataMarkup;A data layer has a codec and no emitters. The renderer takes it off a run before any emitter sees
the run, so the text is written as the layers around it write it, and an emitter registered for a
data type is never called. WithoutVisualMarkup() takes every other layer off and keeps the data
(Transforming). Everything else treats it as any layer: slicing,
concatenation and joining carry it, equality ignores it, and a reader without its codec keeps it as
an UnknownMarkup.
Two shapes, and the difference matters:
IMarkupEmitteris keyed on(markup type, format)and writes one layer, wrapping the body it is handed. Unrelated kinds never collide here. This is what you almost always want.IMarkupSetEmitteris keyed on the format alone and takes over the whole run — every layer on it, from every package. There is one per format and the last registration wins, so registering one forHtmlsilently takes that format away from whatever registered it before.
using System.Buffers;
using MarkupString;
public sealed class SpoilerHtmlEmitter : IMarkupEmitter
{
public Type MarkupType => typeof(SpoilerMarkup);
public MarkupFormat Format => MarkupFormat.Html;
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
var spoiler = (SpoilerMarkup)markup;
output.Write("<span class=\"spoiler\"");
if (spoiler.Reason is { } reason)
{
output.Write(" title=\"");
MarkupTextRenderer.EncodeText(reason, context.Format.Encoding, output);
output.Write("\"");
}
output.Write(">");
output.Write(body); // already encoded for this format
output.Write("</span>");
}
}The body arrives already encoded for the format — do not escape it again. Attribute values you
supply yourself are not encoded; run them through MarkupTextRenderer.EncodeText as above.
EmitContext tells you the format, the registry (so you can delegate to another layer's emitter),
the previous and next run's MarkupSet, and whether this is the first or last run — enough to
write state diffs rather than restating style on every run.
Register an emitter per format you care about. A format you skip is not an error: the layer is passed over and its body still comes out.
If your layer means "bold" or "this colour", do not write an ANSI emitter for it. Implement
IAnsiStyleSource and MarkupString.Ansi's fold picks it up — in ANSI, and in every other format
that package covers — with no per-format work from you:
using MarkupString.Ansi;
public sealed record SpoilerMarkup(string? Reason) : IMarkup, IAnsiStyleSource
{
public bool TryGetAnsiStyle(MarkupFormat format, out AnsiStyle style)
{
// Hidden text on a terminal: same foreground and background.
style = AnsiStyle.None with { Faint = true };
return format != MarkupFormat.Html; // HTML has its own emitter above
}
}Returning false for a format means "I have my own emitter there, leave me alone" — this is
exactly how HtmlMarkup keeps its real tag in HTML/Pueblo/MXP while still folding <b> into bold
on a terminal.
Without a codec your layer cannot be serialized. With one, it round-trips — and a reader that does
not have your package keeps it as UnknownMarkup and writes it back verbatim.
using System.Text.Json;
public sealed class SpoilerCodec : IMarkupCodec
{
public Type MarkupType => typeof(SpoilerMarkup);
public string Kind => "spoiler"; // the "k" discriminator; pick something unlikely to clash
public void Write(Utf8JsonWriter writer, IMarkup markup)
{
// The object and its "k" are written for you; write only your own properties.
if (((SpoilerMarkup)markup).Reason is { } reason) writer.WriteString("why", reason);
}
public IMarkup Read(JsonElement element) =>
new SpoilerMarkup(element.TryGetProperty("why", out var why) ? why.GetString() : null);
}public static class SpoilerRegistration
{
public static MarkupRegistry WithSpoilers(this MarkupRegistry registry) =>
registry
.With(new SpoilerHtmlEmitter())
.With(new SpoilerCodec());
}
MarkupRegistry.Default = MarkupRegistry.Empty.WithAnsi().WithHtml().WithSpoilers();MarkupRegistry is immutable — each With returns a new one — and the extension-method
convention is what lets a host compose kinds in one readable line.
var text = MarkupText.Wrap(new SpoilerMarkup("ending"), "he was dead all along");
text.Render(MarkupFormat.Html); // <span class="spoiler" title="ending">he was dead all along</span>
text.Render(MarkupFormat.Ansi); // \e[2mhe was dead all along\e[0m
text.Render(MarkupFormat.Plain); // he was dead all along- One set emitter per format, last one wins. If you register an
IMarkupSetEmitter, you own that format for every kind, including kinds you have never heard of. PreferIMarkupEmitter, orIAnsiStyleSourcewhen what you want is styling. - Per-layer emitters never collide — the key includes your type.
- Kinds must be unique across the codecs in a registry. A duplicate
Kindmeans one of them loses at read time. - A missing emitter is silence, not an error. A missing codec at write time throws, because silently dropping stored markup is a different class of problem from not styling it.