Skip to content

Commit cb26593

Browse files
Add a Markdown format (#66)
* Add a Markdown format MarkupFormat.Markdown writes GitHub-flavoured Markdown, so a layout built with BlockLayout (a table from datatable(), say) can be stored in a wiki. - TextEncoding.Markdown escapes what would start Markdown syntax, so stored text reads back as itself. A render knows where on its line each stretch starts, so line-start markers are escaped and indentation dropped only where they would be read. - AnsiMarkdownEmitter, registered by WithAnsi() as BBCode's is, writes bold, italic and strikethrough as **, * and ~~, once round runs that share them, and a URL link as [text](address); the rest is dropped with its text kept. A picture is ![description](address). - A layout is drawn from its tree, with nothing laid out to a width: a pipe table with an alignment row, lists, rules, bold titles, and a fenced code block for a block it does not know. A block is set apart from the text around it by a blank line. The tests read the output back with Markdig, a GitHub-flavoured reader. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FnLNvKZTAL8SzXwMt4ijhG * Document the Markdown format; changelog for 2.20.0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FnLNvKZTAL8SzXwMt4ijhG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e44a412 commit cb26593

34 files changed

Lines changed: 1769 additions & 61 deletions

‎CHANGELOG.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,32 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget
66
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
77
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

9-
## Unreleased
9+
## 2.20.0 — Unreleased
10+
11+
### Added
12+
13+
- **A Markdown format.** `MarkupFormat.Markdown` writes GitHub-flavoured Markdown, for a wiki page or a
14+
README that stores it, and `WithAnsi()` registers the whole of it, as it does BBCode
15+
(`AnsiMarkdownEmitter`). Bold, italic and strikethrough are `**`, `*` and `~~`, written once round runs
16+
that share them and kept off the whitespace at their ends; colour, underline and the rest of what
17+
Markdown cannot say are dropped with the text kept. A URL link is `[text](address)` and a command link
18+
its text; a picture is `![description](address)`; points write nothing. See
19+
[Markdown](docs/formats.md#markdown).
20+
- **A layout in Markdown is drawn from its tree**, with nothing laid out to a width: a `Table` as a pipe
21+
table whose delimiter row carries each column's alignment, its cells escaped and a line ending in a cell
22+
written as `<br>`; `Bullets`, `Tree`, `Grid` and `Fields` as lists (`**Label:** value`); a `Rule` as
23+
`---`; a `Frame` or a titled rule as its title in bold over what it holds; a `Flex` as its items in
24+
order; a `Gauge` as its figures; a `Figure` as an image. A block of your own is its lines in a fenced
25+
code block. A block is set apart from the text around it by a blank line.
26+
- **`TextEncoding.Markdown`** escapes what would start Markdown syntax, so stored text reads back as
27+
itself: `\`, `` ` ``, `*`, `_`, `[`, `]`, `<`, `|` and `~` anywhere, `&` where it starts an entity, and a
28+
heading, quote, list or setext marker at the start of a line. It drops control characters, writes a line
29+
ending as `\n`, and drops indentation at the start of a line, which a reader would take for code.
30+
31+
### Changed
32+
33+
- **`HtmlMarkup`'s `b`, `i`, `u` and `s` tags and their kin fold into the styling in Markdown** as they do in
34+
ANSI and BBCode: `b`, `i` and `s` come out as `**`, `*` and `~~`, and `u` as its text.
1035

1136
## 2.19.0 — 2026-10-13
1237

‎Directory.Build.props‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
<Authors>SharpMUSH</Authors>
3232
<Company>SharpMUSH</Company>
3333
<Copyright>Copyright © MarkupString Contributors 2024-$([System.DateTime]::Now.ToString(yyyy))</Copyright>
34-
<PackageTags>markup;ansi;terminal;html;mxp;pueblo;bbcode;styled-text;mud;mush</PackageTags>
34+
<PackageTags>markup;ansi;terminal;html;mxp;pueblo;bbcode;markdown;styled-text;mud;mush</PackageTags>
3535
<PackageReadmeFile>README.md</PackageReadmeFile>
3636
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
3737
<PackageProjectUrl>https://github.com/SharpMUSH/MarkupString</PackageProjectUrl>

‎MarkupString.Ansi/AnsiRegistration.cs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ namespace MarkupString.Ansi;
44
public static class AnsiRegistration
55
{
66
/// <summary>
7-
/// Returns a registry that renders <see cref="AnsiMarkup"/> in all six formats and serialises
7+
/// Returns a registry that renders <see cref="AnsiMarkup"/> in all seven formats and serialises
88
/// it under kind <c>"ansi"</c>. Plain needs nothing: the body passes through. It also writes the
9-
/// bell, a <see cref="ClearScreenMarkup"/> for a terminal, and an <see cref="ImageMarkup"/> as
10-
/// BBCode's <c>[img]</c>.
9+
/// bell, a <see cref="ClearScreenMarkup"/> for a terminal, an <see cref="ImageMarkup"/> as
10+
/// BBCode's <c>[img]</c> and Markdown's <c>![]()</c>, and a laid-out block as Markdown's own
11+
/// structure — a table, a list, a rule.
1112
/// </summary>
1213
/// <remarks>
13-
/// The five emitters are <see cref="IMarkupSetEmitter"/>s — one per format, claiming the whole
14+
/// The six emitters are <see cref="IMarkupSetEmitter"/>s — one per format, claiming the whole
1415
/// run — because the layers of a run have to fold into a single sequence or element. Layers
1516
/// this package does not own are delegated to their own emitters, so composing this with
1617
/// another package's registration works in either order.
@@ -25,17 +26,22 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry)
2526
.With(new AnsiPuebloEmitter())
2627
.With(new AnsiMxpEmitter())
2728
.With(new AnsiBBCodeEmitter())
29+
.With(new AnsiMarkdownEmitter())
2830
.With(new AnsiMarkupCodec())
2931
// A bell is not ANSI styling, but it is the same audience: a client that reads a control
30-
// character, or an HTML page that reads an element. BBCode and Plain have neither, and drop
32+
// character, or an HTML page that reads an element. BBCode, Markdown and Plain have neither, and drop
3133
// the character with every other control.
3234
.With(new BellEmitter(MarkupFormat.Ansi))
3335
.With(new BellEmitter(MarkupFormat.Pueblo))
3436
.With(new BellEmitter(MarkupFormat.Mxp))
3537
.With(new BellEmitter(MarkupFormat.Html))
36-
// From the shared vocabulary, what a terminal and BBCode can express.
38+
// From the shared vocabulary, what a terminal, BBCode and Markdown can express.
3739
.With(new ClearScreenAnsiEmitter())
38-
.With(new ImageBBCodeEmitter());
40+
.With(new ImageBBCodeEmitter())
41+
.With(new ImageMarkdownEmitter())
42+
// A layout as Markdown's own structure. Markdown has no styling this package does not already
43+
// fold, so the whole of the format is here, as BBCode's is.
44+
.With(LayoutMarkdownEmitter.Instance);
3945
}
4046

4147
/// <summary>

‎MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ internal static void EmitSegmented(
135135
}
136136

137137
/// <summary>Whether any layer is one this package does not fold and something else can write.</summary>
138-
private static bool HasDelegatedLayer(MarkupSet set, in EmitContext context)
138+
internal static bool HasDelegatedLayer(MarkupSet set, in EmitContext context)
139139
{
140140
for (var i = 0; i < set.Count; i++)
141141
if (!ClaimsStyle(set[i], context.Format, out _)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using System.Buffers;
2+
namespace MarkupString.Ansi;
3+
4+
/// <summary>
5+
/// Renders a run as GitHub-flavoured Markdown: bold as <c>**</c>, italic as <c>*</c>, strikethrough as
6+
/// <c>~~</c>, and a URL link as <c>[text](address)</c>. Markdown has no colour, underline, faint, blink,
7+
/// overline or reverse video, so those are dropped and the text kept; so is a command link, which a page
8+
/// has nowhere to send.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>Markdown spells nesting with delimiters that have to pair up, so emphasis that carries on from one
12+
/// run into the next is written once round both — <c>**a *b***</c>, not <c>**a*****b***</c> — the way the
13+
/// ANSI emitter writes only what changed. A run decides that from its neighbours alone
14+
/// (<see cref="EmitContext.Previous"/> and <see cref="EmitContext.Next"/>), the two sides agree on what stays
15+
/// open, and a run next to a point, a block or a layer drawn by another package's emitter closes everything
16+
/// it opened.</para>
17+
/// <para>A delimiter is kept off the whitespace at either end of what it covers, where Markdown would not
18+
/// read it as one, and is closed before a line ending and opened again after it, since emphasis cannot cross
19+
/// a blank line. A run of nothing but whitespace is not wrapped at all.</para>
20+
/// </remarks>
21+
public sealed class AnsiMarkdownEmitter : IMarkupSetEmitter
22+
{
23+
/// <inheritdoc/>
24+
public MarkupFormat Format => MarkupFormat.Markdown;
25+
26+
/// <inheritdoc/>
27+
public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
28+
{
29+
ArgumentNullException.ThrowIfNull(set);
30+
ArgumentNullException.ThrowIfNull(output);
31+
32+
if (StandsAlone(context.Markups, context) || AnsiEmitterSupport.HasDelegatedLayer(set, context))
33+
{
34+
AnsiEmitterSupport.EmitSegmented(set, body, context, output, WriteAlone);
35+
return true;
36+
}
37+
38+
var own = Delimiters(AnsiEmitterSupport.Fold(set, context.Format));
39+
WriteDelimited(own, Shared(own, context.Previous, context), Shared(own, context.Next, context), body, output);
40+
return true;
41+
}
42+
43+
/// <summary>One delimiter pair: what opens a span and what closes it.</summary>
44+
private readonly record struct Delimiter(string Open, string Close);
45+
46+
private static readonly Delimiter Strike = new("~~", "~~");
47+
private static readonly Delimiter Strong = new("**", "**");
48+
private static readonly Delimiter Emphasis = new("*", "*");
49+
50+
/// <summary>
51+
/// The delimiters <paramref name="style"/> is written with, outermost first, always in the same order —
52+
/// a link, then strikethrough, bold and italic — so two runs that share some of them share the outermost.
53+
/// </summary>
54+
private static List<Delimiter> Delimiters(in AnsiStyle style)
55+
{
56+
var delimiters = new List<Delimiter>(4);
57+
if (style.LinkKind == LinkKind.Url
58+
&& style.LinkUrl is { Length: > 0 } url
59+
&& UrlSafety.IsSafeNavigableUrl(url))
60+
delimiters.Add(new Delimiter("[", "](" + MarkdownSyntax.Destination(url) + ")"));
61+
if (style.StrikeThrough) delimiters.Add(Strike);
62+
if (style.Bold) delimiters.Add(Strong);
63+
if (style.Italic) delimiters.Add(Emphasis);
64+
return delimiters;
65+
}
66+
67+
/// <summary>
68+
/// Whether a run is written with nothing left open on either side: one that carries a point (written by
69+
/// its own emitter, or not at all), a block (drawn whole, or not as runs of this emitter's), or a layer
70+
/// another package writes between the stretches of styling.
71+
/// </summary>
72+
private static bool StandsAlone(MarkupSet markups, in EmitContext context)
73+
{
74+
foreach (var markup in markups)
75+
if (markup is IPointMarkup or IBlockMarkup) return true;
76+
return AnsiEmitterSupport.HasDelegatedLayer(markups, context);
77+
}
78+
79+
/// <summary>How many of <paramref name="own"/>'s outermost delimiters stay open into, or from, <paramref name="neighbour"/>.</summary>
80+
private static int Shared(List<Delimiter> own, MarkupSet? neighbour, in EmitContext context)
81+
{
82+
if (own.Count == 0 || neighbour is null || StandsAlone(neighbour, context)) return 0;
83+
var theirs = Delimiters(AnsiEmitterSupport.Fold(neighbour, context.Format));
84+
var shared = 0;
85+
while (shared < own.Count && shared < theirs.Count && own[shared] == theirs[shared]) shared++;
86+
return shared;
87+
}
88+
89+
private static void WriteAlone(in AnsiStyle style, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output) =>
90+
WriteDelimited(Delimiters(style), 0, 0, body, output);
91+
92+
/// <summary>
93+
/// Writes <paramref name="body"/> inside <paramref name="delimiters"/>, the first <paramref name="opened"/> of
94+
/// them already open before it and the first <paramref name="kept"/> left open after it.
95+
/// </summary>
96+
private static void WriteDelimited(List<Delimiter> delimiters, int opened, int kept, ReadOnlySpan<char> body, IBufferWriter<char> output)
97+
{
98+
if (delimiters.Count == 0)
99+
{
100+
output.Write(body);
101+
return;
102+
}
103+
104+
while (true)
105+
{
106+
var newline = body.IndexOf('\n');
107+
if (newline < 0)
108+
{
109+
WriteLine(delimiters, opened, kept, body, output);
110+
return;
111+
}
112+
113+
// Everything is closed before the line ending and opened again after it.
114+
WriteLine(delimiters, opened, 0, body[..newline], output);
115+
output.Write("\n");
116+
body = body[(newline + 1)..];
117+
opened = 0;
118+
}
119+
}
120+
121+
/// <summary>One line of a run: the delimiters it opens after its leading whitespace, and those it closes before its trailing whitespace.</summary>
122+
private static void WriteLine(List<Delimiter> delimiters, int opened, int kept, ReadOnlySpan<char> line, IBufferWriter<char> output)
123+
{
124+
var start = line.IndexOfAnyExcept(' ', '\t');
125+
if (start < 0)
126+
{
127+
// Nothing to cover. What was open before closes here and what stays open after opens here, against
128+
// the text either side of the whitespace; a pair that would open and close round nothing is left out.
129+
for (var i = opened - 1; i >= kept; i--) output.Write(delimiters[i].Close);
130+
output.Write(line);
131+
for (var i = opened; i < kept; i++) output.Write(delimiters[i].Open);
132+
return;
133+
}
134+
135+
var end = line.LastIndexOfAnyExcept(' ', '\t') + 1;
136+
output.Write(line[..start]);
137+
for (var i = opened; i < delimiters.Count; i++) output.Write(delimiters[i].Open);
138+
output.Write(line[start..end]);
139+
for (var i = delimiters.Count - 1; i >= kept; i--) output.Write(delimiters[i].Close);
140+
output.Write(line[end..]);
141+
}
142+
}

‎MarkupString.Ansi/Emitters/ElementEmitters.cs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,31 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
5151
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
5252
}
5353
}
54+
55+
/// <summary>
56+
/// A picture as Markdown's <c>![description](address)</c>, in place of the text a terminal is shown; the text
57+
/// itself where the address is not one a page may load.
58+
/// </summary>
59+
internal sealed class ImageMarkdownEmitter : IMarkupEmitter
60+
{
61+
public Type MarkupType => typeof(ImageMarkup);
62+
63+
public MarkupFormat Format => MarkupFormat.Markdown;
64+
65+
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
66+
{
67+
ArgumentNullException.ThrowIfNull(output);
68+
69+
var image = (ImageMarkup)markup;
70+
if (MarkdownSyntax.ImageSource(image.Source) is not { } source)
71+
{
72+
output.Write(body);
73+
return;
74+
}
75+
76+
if (image.StartsPicture(context)) MarkdownSyntax.WriteImage(image.Description, source, output);
77+
78+
// A figure's rows keep their cells, so what is beside the picture stays where it was.
79+
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
80+
}
81+
}

0 commit comments

Comments
 (0)