Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,32 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
## 2.20.0 — Unreleased

### Added

- **A Markdown format.** `MarkupFormat.Markdown` writes GitHub-flavoured Markdown, for a wiki page or a
README that stores it, and `WithAnsi()` registers the whole of it, as it does BBCode
(`AnsiMarkdownEmitter`). Bold, italic and strikethrough are `**`, `*` and `~~`, written once round runs
that share them and kept off the whitespace at their ends; colour, underline and the rest of what
Markdown cannot say are dropped with the text kept. A URL link is `[text](address)` and a command link
its text; a picture is `![description](address)`; points write nothing. See
[Markdown](docs/formats.md#markdown).
- **A layout in Markdown is drawn from its tree**, with nothing laid out to a width: a `Table` as a pipe
table whose delimiter row carries each column's alignment, its cells escaped and a line ending in a cell
written as `<br>`; `Bullets`, `Tree`, `Grid` and `Fields` as lists (`**Label:** value`); a `Rule` as
`---`; a `Frame` or a titled rule as its title in bold over what it holds; a `Flex` as its items in
order; a `Gauge` as its figures; a `Figure` as an image. A block of your own is its lines in a fenced
code block. A block is set apart from the text around it by a blank line.
- **`TextEncoding.Markdown`** escapes what would start Markdown syntax, so stored text reads back as
itself: `\`, `` ` ``, `*`, `_`, `[`, `]`, `<`, `|` and `~` anywhere, `&` where it starts an entity, and a
heading, quote, list or setext marker at the start of a line. It drops control characters, writes a line
ending as `\n`, and drops indentation at the start of a line, which a reader would take for code.

### Changed

- **`HtmlMarkup`'s `b`, `i`, `u` and `s` tags and their kin fold into the styling in Markdown** as they do in
ANSI and BBCode: `b`, `i` and `s` come out as `**`, `*` and `~~`, and `u` as its text.

## 2.19.0 — 2026-10-13

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<Authors>SharpMUSH</Authors>
<Company>SharpMUSH</Company>
<Copyright>Copyright © MarkupString Contributors 2024-$([System.DateTime]::Now.ToString(yyyy))</Copyright>
<PackageTags>markup;ansi;terminal;html;mxp;pueblo;bbcode;styled-text;mud;mush</PackageTags>
<PackageTags>markup;ansi;terminal;html;mxp;pueblo;bbcode;markdown;styled-text;mud;mush</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/SharpMUSH/MarkupString</PackageProjectUrl>
Expand Down
20 changes: 13 additions & 7 deletions MarkupString.Ansi/AnsiRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ namespace MarkupString.Ansi;
public static class AnsiRegistration
{
/// <summary>
/// Returns a registry that renders <see cref="AnsiMarkup"/> in all six formats and serialises
/// Returns a registry that renders <see cref="AnsiMarkup"/> in all seven formats and serialises
/// it under kind <c>"ansi"</c>. Plain needs nothing: the body passes through. It also writes the
/// bell, a <see cref="ClearScreenMarkup"/> for a terminal, and an <see cref="ImageMarkup"/> as
/// BBCode's <c>[img]</c>.
/// bell, a <see cref="ClearScreenMarkup"/> for a terminal, an <see cref="ImageMarkup"/> as
/// BBCode's <c>[img]</c> and Markdown's <c>![]()</c>, and a laid-out block as Markdown's own
/// structure — a table, a list, a rule.
/// </summary>
/// <remarks>
/// The five emitters are <see cref="IMarkupSetEmitter"/>s — one per format, claiming the whole
/// The six emitters are <see cref="IMarkupSetEmitter"/>s — one per format, claiming the whole
/// run — because the layers of a run have to fold into a single sequence or element. Layers
/// this package does not own are delegated to their own emitters, so composing this with
/// another package's registration works in either order.
Expand All @@ -25,17 +26,22 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry)
.With(new AnsiPuebloEmitter())
.With(new AnsiMxpEmitter())
.With(new AnsiBBCodeEmitter())
.With(new AnsiMarkdownEmitter())
.With(new AnsiMarkupCodec())
// A bell is not ANSI styling, but it is the same audience: a client that reads a control
// character, or an HTML page that reads an element. BBCode and Plain have neither, and drop
// character, or an HTML page that reads an element. BBCode, Markdown and Plain have neither, and drop
// the character with every other control.
.With(new BellEmitter(MarkupFormat.Ansi))
.With(new BellEmitter(MarkupFormat.Pueblo))
.With(new BellEmitter(MarkupFormat.Mxp))
.With(new BellEmitter(MarkupFormat.Html))
// From the shared vocabulary, what a terminal and BBCode can express.
// From the shared vocabulary, what a terminal, BBCode and Markdown can express.
.With(new ClearScreenAnsiEmitter())
.With(new ImageBBCodeEmitter());
.With(new ImageBBCodeEmitter())
.With(new ImageMarkdownEmitter())
// A layout as Markdown's own structure. Markdown has no styling this package does not already
// fold, so the whole of the format is here, as BBCode's is.
.With(LayoutMarkdownEmitter.Instance);
}

/// <summary>
Expand Down
2 changes: 1 addition & 1 deletion MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ internal static void EmitSegmented(
}

/// <summary>Whether any layer is one this package does not fold and something else can write.</summary>
private static bool HasDelegatedLayer(MarkupSet set, in EmitContext context)
internal static bool HasDelegatedLayer(MarkupSet set, in EmitContext context)
{
for (var i = 0; i < set.Count; i++)
if (!ClaimsStyle(set[i], context.Format, out _)
Expand Down
142 changes: 142 additions & 0 deletions MarkupString.Ansi/Emitters/AnsiMarkdownEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Buffers;
namespace MarkupString.Ansi;

/// <summary>
/// Renders a run as GitHub-flavoured Markdown: bold as <c>**</c>, italic as <c>*</c>, strikethrough as
/// <c>~~</c>, and a URL link as <c>[text](address)</c>. Markdown has no colour, underline, faint, blink,
/// overline or reverse video, so those are dropped and the text kept; so is a command link, which a page
/// has nowhere to send.
/// </summary>
/// <remarks>
/// <para>Markdown spells nesting with delimiters that have to pair up, so emphasis that carries on from one
/// run into the next is written once round both — <c>**a *b***</c>, not <c>**a*****b***</c> — the way the
/// ANSI emitter writes only what changed. A run decides that from its neighbours alone
/// (<see cref="EmitContext.Previous"/> and <see cref="EmitContext.Next"/>), the two sides agree on what stays
/// open, and a run next to a point, a block or a layer drawn by another package's emitter closes everything
/// it opened.</para>
/// <para>A delimiter is kept off the whitespace at either end of what it covers, where Markdown would not
/// read it as one, and is closed before a line ending and opened again after it, since emphasis cannot cross
/// a blank line. A run of nothing but whitespace is not wrapped at all.</para>
/// </remarks>
public sealed class AnsiMarkdownEmitter : IMarkupSetEmitter
{
/// <inheritdoc/>
public MarkupFormat Format => MarkupFormat.Markdown;

/// <inheritdoc/>
public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(set);
ArgumentNullException.ThrowIfNull(output);

if (StandsAlone(context.Markups, context) || AnsiEmitterSupport.HasDelegatedLayer(set, context))
{
AnsiEmitterSupport.EmitSegmented(set, body, context, output, WriteAlone);
return true;
}

var own = Delimiters(AnsiEmitterSupport.Fold(set, context.Format));
WriteDelimited(own, Shared(own, context.Previous, context), Shared(own, context.Next, context), body, output);
return true;
}

/// <summary>One delimiter pair: what opens a span and what closes it.</summary>
private readonly record struct Delimiter(string Open, string Close);

private static readonly Delimiter Strike = new("~~", "~~");
private static readonly Delimiter Strong = new("**", "**");
private static readonly Delimiter Emphasis = new("*", "*");

/// <summary>
/// The delimiters <paramref name="style"/> is written with, outermost first, always in the same order —
/// a link, then strikethrough, bold and italic — so two runs that share some of them share the outermost.
/// </summary>
private static List<Delimiter> Delimiters(in AnsiStyle style)
{
var delimiters = new List<Delimiter>(4);
if (style.LinkKind == LinkKind.Url
&& style.LinkUrl is { Length: > 0 } url
&& UrlSafety.IsSafeNavigableUrl(url))
delimiters.Add(new Delimiter("[", "](" + MarkdownSyntax.Destination(url) + ")"));
if (style.StrikeThrough) delimiters.Add(Strike);
if (style.Bold) delimiters.Add(Strong);
if (style.Italic) delimiters.Add(Emphasis);
return delimiters;
}

/// <summary>
/// Whether a run is written with nothing left open on either side: one that carries a point (written by
/// its own emitter, or not at all), a block (drawn whole, or not as runs of this emitter's), or a layer
/// another package writes between the stretches of styling.
/// </summary>
private static bool StandsAlone(MarkupSet markups, in EmitContext context)
{
foreach (var markup in markups)
if (markup is IPointMarkup or IBlockMarkup) return true;
return AnsiEmitterSupport.HasDelegatedLayer(markups, context);
}

/// <summary>How many of <paramref name="own"/>'s outermost delimiters stay open into, or from, <paramref name="neighbour"/>.</summary>
private static int Shared(List<Delimiter> own, MarkupSet? neighbour, in EmitContext context)
{
if (own.Count == 0 || neighbour is null || StandsAlone(neighbour, context)) return 0;
var theirs = Delimiters(AnsiEmitterSupport.Fold(neighbour, context.Format));
var shared = 0;
while (shared < own.Count && shared < theirs.Count && own[shared] == theirs[shared]) shared++;
return shared;
}

private static void WriteAlone(in AnsiStyle style, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output) =>
WriteDelimited(Delimiters(style), 0, 0, body, output);

/// <summary>
/// Writes <paramref name="body"/> inside <paramref name="delimiters"/>, the first <paramref name="opened"/> of
/// them already open before it and the first <paramref name="kept"/> left open after it.
/// </summary>
private static void WriteDelimited(List<Delimiter> delimiters, int opened, int kept, ReadOnlySpan<char> body, IBufferWriter<char> output)
{
if (delimiters.Count == 0)
{
output.Write(body);
return;
}

while (true)
{
var newline = body.IndexOf('\n');
if (newline < 0)
{
WriteLine(delimiters, opened, kept, body, output);
return;
}

// Everything is closed before the line ending and opened again after it.
WriteLine(delimiters, opened, 0, body[..newline], output);
output.Write("\n");
body = body[(newline + 1)..];
opened = 0;
}
}

/// <summary>One line of a run: the delimiters it opens after its leading whitespace, and those it closes before its trailing whitespace.</summary>
private static void WriteLine(List<Delimiter> delimiters, int opened, int kept, ReadOnlySpan<char> line, IBufferWriter<char> output)
{
var start = line.IndexOfAnyExcept(' ', '\t');
if (start < 0)
{
// Nothing to cover. What was open before closes here and what stays open after opens here, against
// the text either side of the whitespace; a pair that would open and close round nothing is left out.
for (var i = opened - 1; i >= kept; i--) output.Write(delimiters[i].Close);
output.Write(line);
for (var i = opened; i < kept; i++) output.Write(delimiters[i].Open);
return;
}

var end = line.LastIndexOfAnyExcept(' ', '\t') + 1;
output.Write(line[..start]);
for (var i = opened; i < delimiters.Count; i++) output.Write(delimiters[i].Open);
output.Write(line[start..end]);
for (var i = delimiters.Count - 1; i >= kept; i--) output.Write(delimiters[i].Close);
output.Write(line[end..]);
}
}
28 changes: 28 additions & 0 deletions MarkupString.Ansi/Emitters/ElementEmitters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,31 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
}
}

/// <summary>
/// A picture as Markdown's <c>![description](address)</c>, in place of the text a terminal is shown; the text
/// itself where the address is not one a page may load.
/// </summary>
internal sealed class ImageMarkdownEmitter : IMarkupEmitter
{
public Type MarkupType => typeof(ImageMarkup);

public MarkupFormat Format => MarkupFormat.Markdown;

public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(output);

var image = (ImageMarkup)markup;
if (MarkdownSyntax.ImageSource(image.Source) is not { } source)
{
output.Write(body);
return;
}

if (image.StartsPicture(context)) MarkdownSyntax.WriteImage(image.Description, source, output);

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
}
}
Loading
Loading