Immutable styled text for terminals and the web. One value — a plain string plus layered markup runs over it — renders to ANSI, HTML, Pueblo, MXP, BBCode or plain text, and round-trips through JSON without losing a layer the reader does not understand.
vartext=MarkupText.Concat(MarkupText.Plain("Hello, "),MarkupText.Wrap(AnsiCodeParser.Parse("hr"),"world"));text.Render(MarkupFormat.Ansi);// Hello, \e[1;31mworld\e[0mtext.Render(MarkupFormat.Html);// Hello, <span style="color: #ff5555">world</span>text.Render(MarkupFormat.Plain);// Hello, worldSlicing, padding, wrapping, trimming and the rest of the string operations carry the markup with them, and measure in display cells — wide CJK, combining marks and emoji sequences count correctly, and no operation ever cuts a grapheme cluster in half.
dotnet add package MarkupString
dotnet add package MarkupString.Ansi
dotnet add package MarkupString.Html| Package | What it gives you |
|---|---|
MarkupString | The MarkupText type, runs, formats, the registry, the emitter/codec contracts, the JSON serializer, grapheme and display-width helpers. No rendering opinions. |
MarkupString.Ansi | Terminal styling: colours (16 / xterm-256 / truecolor), attributes, links; an ansi() code parser and an escape-sequence parser; emitters for ANSI, HTML, Pueblo, MXP and BBCode. |
MarkupString.Html | Raw HTML/MXP tag markup — an MXP <send>, an anchor, a <span class> — plus the stylesheet for the classes the emitters write. |
The core package renders nothing on its own: emitters live in the kind packages, so a consumer that only needs one of them pays for one of them, and a kind of your own is a first-class peer rather than a fork.
usingMarkupString;usingMarkupString.Ansi;usingMarkupString.Html;// Once, at startup. Set-once: a second, different registry throws.MarkupRegistry.Default=MarkupRegistry.Empty.WithAnsi().WithHtml();varprompt=MarkupText.Wrap(HtmlMarkup.Create("send","href=\"north\""),MarkupText.Wrap(AnsiCodeParser.Parse("hc"),"Go north"));Console.WriteLine(prompt.Render(MarkupFormat.Ansi));ToString() is always the plain text — it is never format-specific. Rendering is explicit:
Render(MarkupFormat.Ansi), RenderTo(format, bufferWriter) when you have somewhere to write.
| Guide | |
|---|---|
| Getting started | Install, wire up the registry, build and render your first styled text. |
| Text operations | Slicing, padding, alignment, splitting, splicing — and the grapheme and display-width rules they obey. |
| Formats and rendering | The six built-in formats, what each emits, framers, custom formats. |
| Custom markup kinds | Write your own IMarkup, emitters and codec; compose with the kinds already registered. |
| Serialization | The JSON wire format, forward compatibility, UnknownMarkup. |
| Releasing | How a version is cut and published (maintainers). |
- Runs, not a tree. Text is a
string; markup is anImmutableArray<Run>of coalesced, non-overlapping ranges, each holding a stack of layers. Identically marked neighbours merge, overlapping runs are rejected at construction, and a slice is a clip of that array. This is the model behindNSAttributedString, Swift'sAttributedStringand VS Code's line tokens. - An explicit registry, not reflection. Emitters are keyed on
(markup type, format)in aFrozenDictionarybuilt byWithAnsi()/WithHtml()/With(...). Nothing is discovered at runtime, so nothing breaks under trimming, and adding a kind is a call, not a convention. - Diffed output. ANSI transitions are written as the difference between the previous run's style and this one's, so nested styling does not restate what is already in effect and no run pays for a reset it does not need.
- Unicode-correct by construction. Extractions snap inward to cluster boundaries, edits snap outward; padding and alignment measure in cells, not code units.
- AOT and trimming clean. All three packages are
IsAotCompatiblewith no reflection and no dynamic code, and CI publishes a native binary with every assembly rooted, failing on anyIL2xxx/IL3xxxwarning.
.NET 10 or later.
Semantic versioning, driven by MinVer: the tag v1.2.3
builds 1.2.3, and any other commit builds the next patch as a -preview.0.N prerelease. The
three packages share one version and are released together. Public API changes are tracked in
PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt and enforced at build time.
dotnet build MarkupString.slnx
dotnet run --project MarkupString.TestsThe build fails with FORMAT001 if C# no longer matches .editorconfig; the error text carries
the dotnet format whitespace --folder <dir> command that fixes it (run it until it reports no
changes — the formatter needs two passes to converge).
Apache-2.0. Extracted from and used by SharpMUSH.