From 49d3eee57ab62b9f363c306471d3a6690a841585 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:31:50 +0000 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_01FnLNvKZTAL8SzXwMt4ijhG --- Directory.Build.props | 2 +- MarkupString.Ansi/AnsiRegistration.cs | 20 +- .../Emitters/AnsiEmitterSupport.cs | 2 +- .../Emitters/AnsiMarkdownEmitter.cs | 142 ++++++ MarkupString.Ansi/Emitters/ElementEmitters.cs | 28 ++ .../Emitters/LayoutMarkdownEmitter.cs | 471 ++++++++++++++++++ MarkupString.Ansi/Emitters/MarkdownSyntax.cs | 67 +++ MarkupString.Ansi/MarkupString.Ansi.csproj | 2 +- MarkupString.Ansi/PublicAPI.Unshipped.txt | 4 + MarkupString.AotSmoke/Program.cs | 13 +- MarkupString.Html/HtmlMarkup.cs | 14 +- MarkupString.Html/HtmlRegistration.cs | 4 +- .../Ansi/MarkdownRenderTests.cs | 345 +++++++++++++ MarkupString.Tests/BellTests.cs | 1 + .../Layout/FigurePictureTests.cs | 3 +- .../Layout/MarkdownLayoutTests.cs | 334 +++++++++++++ MarkupString.Tests/MarkupString.Tests.csproj | 3 + .../Properties/RoundTripProperties.cs | 2 +- MarkupString.Tests/RendererTests.cs | 2 + MarkupString.Tests/SharedVocabularyTests.cs | 2 + ...ersStably_formatName=markdown.verified.txt | 28 ++ .../Snapshots/FormatSnapshotTests.cs | 1 + MarkupString/MarkupFormat.cs | 10 +- MarkupString/MarkupString.csproj | 2 +- MarkupString/MarkupTextRenderer.cs | 160 +++++- MarkupString/PublicAPI.Unshipped.txt | 2 + MarkupString/TextEncoding.cs | 21 + 27 files changed, 1653 insertions(+), 32 deletions(-) create mode 100644 MarkupString.Ansi/Emitters/AnsiMarkdownEmitter.cs create mode 100644 MarkupString.Ansi/Emitters/LayoutMarkdownEmitter.cs create mode 100644 MarkupString.Ansi/Emitters/MarkdownSyntax.cs create mode 100644 MarkupString.Tests/Ansi/MarkdownRenderTests.cs create mode 100644 MarkupString.Tests/Layout/MarkdownLayoutTests.cs create mode 100644 MarkupString.Tests/Snapshots/FormatSnapshotTests.EveryFixture_RendersStably_formatName=markdown.verified.txt diff --git a/Directory.Build.props b/Directory.Build.props index 434b63d..2ad814a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -31,7 +31,7 @@ SharpMUSH SharpMUSH Copyright © MarkupString Contributors 2024-$([System.DateTime]::Now.ToString(yyyy)) - markup;ansi;terminal;html;mxp;pueblo;bbcode;styled-text;mud;mush + markup;ansi;terminal;html;mxp;pueblo;bbcode;markdown;styled-text;mud;mush README.md Apache-2.0 https://github.com/SharpMUSH/MarkupString diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs index e651ba1..fb462b5 100644 --- a/MarkupString.Ansi/AnsiRegistration.cs +++ b/MarkupString.Ansi/AnsiRegistration.cs @@ -4,13 +4,14 @@ namespace MarkupString.Ansi; public static class AnsiRegistration { /// - /// Returns a registry that renders in all six formats and serialises + /// Returns a registry that renders in all seven formats and serialises /// it under kind "ansi". Plain needs nothing: the body passes through. It also writes the - /// bell, a for a terminal, and an as - /// BBCode's [img]. + /// bell, a for a terminal, an as + /// BBCode's [img] and Markdown's ![](), and a laid-out block as Markdown's own + /// structure — a table, a list, a rule. /// /// - /// The five emitters are s — one per format, claiming the whole + /// The six emitters are 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. @@ -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); } /// diff --git a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs index dc80fab..fa1354f 100644 --- a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs +++ b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs @@ -135,7 +135,7 @@ internal static void EmitSegmented( } /// Whether any layer is one this package does not fold and something else can write. - 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 _) diff --git a/MarkupString.Ansi/Emitters/AnsiMarkdownEmitter.cs b/MarkupString.Ansi/Emitters/AnsiMarkdownEmitter.cs new file mode 100644 index 0000000..8457005 --- /dev/null +++ b/MarkupString.Ansi/Emitters/AnsiMarkdownEmitter.cs @@ -0,0 +1,142 @@ +using System.Buffers; +namespace MarkupString.Ansi; + +/// +/// Renders a run as GitHub-flavoured Markdown: bold as **, italic as *, strikethrough as +/// ~~, and a URL link as [text](address). 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. +/// +/// +/// 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 — **a *b***, not **a*****b*** — the way the +/// ANSI emitter writes only what changed. A run decides that from its neighbours alone +/// ( and ), 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. +/// 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. +/// +public sealed class AnsiMarkdownEmitter : IMarkupSetEmitter +{ + /// + public MarkupFormat Format => MarkupFormat.Markdown; + + /// + public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext context, IBufferWriter 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; + } + + /// One delimiter pair: what opens a span and what closes it. + 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("*", "*"); + + /// + /// The delimiters 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. + /// + private static List Delimiters(in AnsiStyle style) + { + var delimiters = new List(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; + } + + /// + /// 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. + /// + 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); + } + + /// How many of 's outermost delimiters stay open into, or from, . + private static int Shared(List 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 body, in EmitContext context, IBufferWriter output) => + WriteDelimited(Delimiters(style), 0, 0, body, output); + + /// + /// Writes inside , the first of + /// them already open before it and the first left open after it. + /// + private static void WriteDelimited(List delimiters, int opened, int kept, ReadOnlySpan body, IBufferWriter 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; + } + } + + /// One line of a run: the delimiters it opens after its leading whitespace, and those it closes before its trailing whitespace. + private static void WriteLine(List delimiters, int opened, int kept, ReadOnlySpan line, IBufferWriter 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..]); + } +} diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index 40bdca4..bb80773 100644 --- a/MarkupString.Ansi/Emitters/ElementEmitters.cs +++ b/MarkupString.Ansi/Emitters/ElementEmitters.cs @@ -51,3 +51,31 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body))); } } + +/// +/// A picture as Markdown's ![description](address), in place of the text a terminal is shown; the text +/// itself where the address is not one a page may load. +/// +internal sealed class ImageMarkdownEmitter : IMarkupEmitter +{ + public Type MarkupType => typeof(ImageMarkup); + + public MarkupFormat Format => MarkupFormat.Markdown; + + public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter 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))); + } +} diff --git a/MarkupString.Ansi/Emitters/LayoutMarkdownEmitter.cs b/MarkupString.Ansi/Emitters/LayoutMarkdownEmitter.cs new file mode 100644 index 0000000..8c8d834 --- /dev/null +++ b/MarkupString.Ansi/Emitters/LayoutMarkdownEmitter.cs @@ -0,0 +1,471 @@ +using System.Buffers; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using MarkupString.Layout; +namespace MarkupString.Ansi; + +/// +/// Draws a as GitHub-flavoured Markdown, from its tree rather than its lines: a +/// table as a pipe table, a list as a list, a rule as ---, a box as its title in bold over what it +/// holds. Markdown is not laid out to a width, so nothing is padded, wrapped or set side by side. +/// +/// +/// What Markdown has no structure for is written in reading order — the items of a row one after the +/// other, labelled values as a list of **Label:** value, a gauge as its figures — and a block this does +/// not know is written as the lines it draws, in a fenced code block, so nothing is ever left out. +/// A table cell holds one line of inline text in Markdown, so a block in a cell is written in reading +/// order with its lines joined by <br>, and a table in a cell as its rows of labelled values. +/// +internal sealed class LayoutMarkdownEmitter : IBlockEmitter +{ + public static LayoutMarkdownEmitter Instance { get; } = new(); + + private LayoutMarkdownEmitter() + { + } + + public Type MarkupType => typeof(LayoutMarkup); + + public MarkupFormat Format => MarkupFormat.Markdown; + + public bool TryEmit(IBlockMarkup markup, MarkupText region, MarkupRegistry registry, IBufferWriter output) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(output); + if (markup is not LayoutMarkup layout) return false; + + var chunks = new MarkdownLayoutWriter(registry, layout.Width, cell: false, LayoutTheme.Default).Blocks(layout.Root); + for (var i = 0; i < chunks.Count; i++) + { + if (i > 0) output.Write("\n\n"); + output.Write(chunks[i].Text); + } + return true; + } +} + +/// Writes a layout's blocks as Markdown, each as one or more chunks that a blank line separates. +/// The registry the render runs against, for the text in the blocks. +/// The width the layout was laid out at, which a block written as its lines is drawn at. +/// Whether this is the inside of a table cell, which holds no block structure. +/// The theme the blocks are drawn under, for the parts that are text: a field's separator. +internal sealed class MarkdownLayoutWriter(MarkupRegistry registry, int width, bool cell, LayoutTheme theme) +{ + /// One block of Markdown; when it is running text a label can go in front of. + internal readonly record struct Chunk(string Text, bool Paragraph) + { + /// For a list, the character after each number, or the bullet; otherwise none. + public char Marker { get; init; } + + /// Whether it is a list that may follow a line of a paragraph with no blank line between. + public bool Interrupts { get; init; } + } + + private LayoutTheme _theme = theme; + + /// The chunks is written as. + public List Blocks(Block block) + { + var chunks = new List(); + Block(block, chunks); + + // In a cell a list is only text, and two of them are not read as one. + if (!cell) Separate(chunks); + return chunks; + } + + /// + /// Two lists one after the other with the same marker are one list to a reader, blank line or not, so + /// every second one is written with the other marker: * for -, ) for .. + /// + private static void Separate(List chunks) + { + for (var i = 1; i < chunks.Count; i++) + { + if (chunks[i].Marker == '\0' || chunks[i].Marker != chunks[i - 1].Marker) continue; + var (from, to) = chunks[i].Marker switch { '-' => ('-', '*'), '*' => ('*', '-'), '.' => ('.', ')'), _ => (')', '.') }; + var lines = chunks[i].Text.Split('\n'); + + // Only an item's first line starts at the margin: the rest of an item is indented, or blank. + for (var l = 0; l < lines.Length; l++) + { + if (lines[l].Length == 0 || lines[l][0] == ' ') continue; + var at = lines[l].IndexOf(from); + lines[l] = string.Concat(lines[l].AsSpan(0, at), to.ToString(), lines[l].AsSpan(at + 1)); + } + chunks[i] = chunks[i] with { Text = string.Join('\n', lines), Marker = to }; + } + } + + private void Block(Block block, List chunks) + { + switch (block) + { + case TextBlock text: + Text(text.Content, chunks); + break; + case Stack stack: + foreach (var child in stack.Children.IsDefault ? [] : stack.Children) Block(child, chunks); + break; + case Rule rule: + Rule(rule, chunks); + break; + case Frame frame: + Frame(frame, chunks); + break; + + // A row has no width to share here: its items are read one after the other. + case Flex flex: + foreach (var item in flex.Items.IsDefault ? [] : flex.Items) Block(item, chunks); + break; + case Sized sized: + Block(sized.Content, chunks); + break; + + // Markdown has no alignment, gradient or colour; what they hold is written as it is. + case Aligned aligned: + Block(aligned.Content, chunks); + break; + case Shaded shaded: + Block(shaded.Content, chunks); + break; + case Colored colored: + Block(Painted(colored), chunks); + break; + case Themed themed: + var before = _theme; + _theme = themed.Within(_theme); + try + { + Block(themed.Content, chunks); + } + finally + { + _theme = before; + } + break; + + case Figure figure: + Figure(figure, chunks); + break; + case Fields fields: + Fields(fields, chunks); + break; + case Tree tree: + if (!tree.Items.IsDefaultOrEmpty) chunks.Add(List(tree.Items.Select(TreeEntry), start: null)); + break; + case Bullets bullets: + Bullets(bullets, chunks); + break; + case Grid grid: + if (!grid.Items.IsDefaultOrEmpty) chunks.Add(List(grid.Items.Select(Texts), start: null)); + break; + case Table table: + Table(table, chunks); + break; + case Gauge gauge: + Text(MarkupText.Join(MarkupText.NewLine, (LayoutContext.Default with { Linear = true, Theme = _theme }).Lines(gauge, width)), chunks); + break; + default: + Fenced(string.Join('\n', (LayoutContext.Default with { Theme = _theme }).Lines(block, width).Select(line => line.ToPlainText())), chunks); + break; + } + } + + /// + /// Text as paragraphs, its line endings kept as hard breaks and a blank line between paragraphs; a layout + /// standing on lines of its own inside it is written as its own blocks. + /// + private void Text(MarkupText content, List chunks) + { + var parts = BlockLayout.Blocks(content); + if (parts is [TextBlock only]) + { + Paragraphs(Inline(only.Content), chunks); + return; + } + foreach (var part in parts) Block(part, chunks); + } + + private List Texts(MarkupText content) + { + var chunks = new List(); + Text(content, chunks); + return chunks; + } + + /// + /// Rendered Markdown as paragraphs: each line trimmed, since leading whitespace would make code of it and + /// trailing whitespace a break, the lines of one paragraph joined by a hard break (a backslash at the end + /// of the line), and blank lines between paragraphs. In a cell, the lines are only joined. + /// + private void Paragraphs(string markdown, List chunks) + { + var lines = new List(); + foreach (var raw in markdown.Split('\n')) + { + var line = raw.Trim(' ', '\t'); + if (line.Length > 0) + { + lines.Add(line); + continue; + } + Flush(); + } + Flush(); + + void Flush() + { + if (lines.Count == 0) return; + chunks.Add(new Chunk(string.Join(cell ? "\n" : "\\\n", lines), Paragraph: true)); + lines.Clear(); + } + } + + /// rendered as inline Markdown. + private string Inline(MarkupText text) + { + var buffer = new ArrayBufferWriter(Math.Max(16, text.Length * 2)); + MarkupTextRenderer.RenderFragment(text, MarkupFormat.Markdown, registry, buffer); + return new string(buffer.WrittenSpan); + } + + private static readonly AnsiMarkup Bold = AnsiMarkup.Create(bold: true); + + /// + /// in bold, on one line, or null when there is nothing to show. The bold is a layer + /// over the text, so bold already in it is folded into it rather than written twice. + /// + private string? Strong(MarkupText? text) + { + if (text is null || text.ToPlainText().AsSpan().IndexOfAnyExcept(" \t\r\n") < 0) return null; + // The bold is closed before each line ending and opened after it, so the lines join into one as they are. + var inline = Inline(MarkupText.Wrap(Bold, text)).ReplaceLineEndings(" ").Trim(' ', '\t'); + return inline.Length == 0 ? null : inline; + } + + /// + /// The titles along an edge as they are read, left, middle, right, a comma between, or null for none — + /// what reads out. + /// + private static MarkupText? Titles(MarkupText? title, Alignment alignment, ImmutableArray more) + { + var titles = new List(); + if (title is { Length: > 0 }) titles.Add(new EdgeTitle(title, alignment)); + foreach (var extra in more.IsDefault ? [] : more) + if (extra is { Text.Length: > 0 }) titles.Add(extra); + if (titles.Count == 0) return null; + return MarkupText.Join(MarkupText.Plain(", "), titles + .OrderBy(edge => edge.Side switch { Alignment.Left => 0, Alignment.Right => 2, _ => 1 }) + .Select(edge => edge.Text)); + } + + /// A rule as ---, or, when it carries a title, as the title in bold: the title is what the line is there to set off. + private void Rule(Rule rule, List chunks) + { + if (Strong(Titles(rule.Title, rule.TitleAlignment, rule.Titles)) is { } title) chunks.Add(new Chunk(title, Paragraph: true)); + else if (!cell) chunks.Add(new Chunk("---", Paragraph: false)); + } + + /// A box as its title in bold, what it holds, and the titles on its bottom edge as a line of text. + private void Frame(Frame frame, List chunks) + { + if (Strong(Titles(frame.Title, frame.TitleAlignment, frame.Titles)) is { } title) chunks.Add(new Chunk(title, Paragraph: true)); + Block(frame.Body, chunks); + if (Titles(null, Alignment.Center, frame.BottomTitles) is { } bottom) Text(bottom, chunks); + } + + /// The block's text under the block's layer, which comes out as whatever Markdown makes of it: bold stays, colour goes. + private static Block Painted(Colored colored) => + colored.Markup is IPointMarkup or IBlockMarkup + ? colored.Content + : colored.Content.MapText(text => MarkupText.Wrap(colored.Markup, text)); + + /// + /// A picture as an image, when it has an address a page may load; otherwise its text art in a code block, + /// or its description. What is beside it follows. + /// + private void Figure(Figure figure, List chunks) + { + var image = figure.Image; + if (MarkdownSyntax.ImageSource(image.Source) is { } source) + { + var buffer = new ArrayBufferWriter(); + MarkdownSyntax.WriteImage(image.Description, source, buffer); + chunks.Add(new Chunk(new string(buffer.WrittenSpan), Paragraph: true)); + } + else if (!cell && figure.Art.ToPlainText().AsSpan().ContainsAnyExcept(" \t\r\n")) + { + Fenced(figure.Art.ToPlainText(), chunks); + } + else if ((image.Description is { Length: > 0 } description ? description : image.Source) is { Length: > 0 } shown) + { + Paragraphs(Inline(MarkupText.Plain($"[{shown}]")), chunks); + } + + if (figure.Beside is { } beside) Block(beside, chunks); + } + + /// + /// Labelled values as a list of **Label:** value. GitHub-flavoured Markdown has no definition list, + /// and a list keeps a value of several blocks under its label. + /// + private void Fields(Fields fields, List chunks) + { + if (fields.Items.IsDefaultOrEmpty) return; + var separator = fields.Separator ?? _theme.FieldSeparator ?? LayoutTheme.Defaults.FieldSeparator!; + var head = separator.Trim(TrimType.TrimEnd); + var leader = fields.Leader is { Length: > 0 }; + chunks.Add(List(fields.Items.Select(field => + Labelled(field.Label.Length > 0 ? Strong(leader ? field.Label : MarkupText.Concat(field.Label, head)) : null, Blocks(field.Value))), start: null)); + } + + /// after : on the same line when it starts with running text. + private static List Labelled(string? label, List value) + { + if (label is null) return value; + if (value.Count > 0 && value[0].Paragraph) value[0] = new Chunk(label + " " + value[0].Text, Paragraph: true); + else value.Insert(0, new Chunk(label, Paragraph: true)); + return value; + } + + private List TreeEntry(TreeItem item) + { + var content = Blocks(item.Content); + if (!item.Children.IsDefaultOrEmpty) content.Add(List(item.Children.Select(TreeEntry), start: null)); + return content; + } + + /// + /// A list: - for bullets, dashes, stars and a marker of the game's own; numbers for a numbered list, + /// and for letters and numerals, which Markdown cannot number with. A list with no markers is its items. + /// + private void Bullets(Bullets bullets, List chunks) + { + if (bullets.Items.IsDefaultOrEmpty) return; + if (bullets.Style == BulletStyle.None) + { + foreach (var item in bullets.Items) Block(item, chunks); + return; + } + + // Markdown numbers a list from zero up, in at most nine digits. + var start = Math.Clamp(bullets.Start, 0, 999_999_999 - bullets.Items.Length); + chunks.Add(List(bullets.Items.Select(Blocks), bullets.Ordered ? start : null)); + } + + /// + /// List items, each its chunks: the first line after the marker, the rest indented under it so they stay + /// in the item, and a blank line between the chunks of one item, except before a list that can follow a + /// line of text directly. Numbered from , or bulleted when it is null. + /// + private static Chunk List(IEnumerable> items, int? start) + { + var list = new StringBuilder(); + var number = start ?? 0; + foreach (var content in items) + { + if (list.Length > 0) list.Append('\n'); + var marker = start is null ? "-" : (number++).ToString(CultureInfo.InvariantCulture) + "."; + list.Append(marker); + var indent = new string(' ', marker.Length + 1); + for (var c = 0; c < content.Count; c++) + { + var lines = content[c].Text.Split('\n'); + for (var l = 0; l < lines.Length; l++) + { + if (c == 0 && l == 0) + { + list.Append(' ').Append(lines[0]); + continue; + } + list.Append('\n'); + if (l == 0 && !(content[c].Interrupts && content[c - 1].Paragraph)) list.Append('\n'); + if (lines[l].Length > 0) list.Append(indent).Append(lines[l]); + } + } + } + + // A numbered list can break into a paragraph only when it counts from one. + return new Chunk(list.ToString(), Paragraph: false) { Marker = start is null ? '-' : '.', Interrupts = start is null or 1 }; + } + + /// + /// A pipe table: the headings, a delimiter row that carries each column's alignment, and a row per row, + /// short rows filled with empty cells. In a cell, where a table cannot go, each row as labelled values. + /// + private void Table(Table table, List chunks) + { + if (table.Columns.IsDefaultOrEmpty) return; + var columns = table.Columns; + var rows = table.Rows.IsDefault ? [] : table.Rows; + if (cell) + { + foreach (var row in rows) + Block(new Fields([.. columns.Select((column, c) => new Field(column.Header, !row.IsDefault && c < row.Length ? row[c] : new TextBlock(MarkupText.Empty)))]), chunks); + return; + } + + var markdown = new StringBuilder(); + Row(columns.Select(column => Cell(new TextBlock(column.Header))), markdown); + markdown.Append('\n'); + Row(columns.Select(column => column.Alignment switch + { + Alignment.Right => "---:", + Alignment.Center => ":---:", + _ => "---", + }), markdown); + foreach (var row in rows) + { + markdown.Append('\n'); + Row(Enumerable.Range(0, columns.Length).Select(c => !row.IsDefault && c < row.Length ? Cell(row[c]) : string.Empty), markdown); + } + chunks.Add(new Chunk(markdown.ToString(), Paragraph: false)); + } + + private static void Row(IEnumerable cells, StringBuilder markdown) + { + markdown.Append('|'); + foreach (var text in cells) + { + markdown.Append(' '); + if (text.Length > 0) markdown.Append(text).Append(' '); + markdown.Append('|'); + } + } + + /// A block as one table cell: its chunks in reading order, their lines joined by <br>. + private string Cell(Block block) + { + var chunks = new MarkdownLayoutWriter(registry, width, cell: true, _theme).Blocks(block); + return string.Join("
", chunks.Select(chunk => chunk.Text.Replace("\n", "
", StringComparison.Ordinal))); + } + + /// + /// Text that keeps its own spacing, in a fenced code block whose fence is longer than any run of backticks + /// in it; in a cell, where a code block cannot go, its lines as text. + /// + private void Fenced(string plain, List chunks) + { + var stripped = new ArrayBufferWriter(Math.Max(16, plain.Length)); + MarkupTextRenderer.EncodeText(plain.ReplaceLineEndings("\n").TrimEnd('\n'), TextEncoding.StripControls, stripped); + var content = new string(stripped.WrittenSpan); + if (content.AsSpan().IndexOfAnyExcept(" \t\n") < 0) return; + if (cell) + { + Paragraphs(Inline(MarkupText.Plain(content)), chunks); + return; + } + + var longest = 0; + var run = 0; + foreach (var c in content) + { + run = c == '`' ? run + 1 : 0; + longest = Math.Max(longest, run); + } + var fence = new string('`', Math.Max(3, longest + 1)); + chunks.Add(new Chunk(fence + "\n" + content + "\n" + fence, Paragraph: false)); + } +} diff --git a/MarkupString.Ansi/Emitters/MarkdownSyntax.cs b/MarkupString.Ansi/Emitters/MarkdownSyntax.cs new file mode 100644 index 0000000..bb7daa5 --- /dev/null +++ b/MarkupString.Ansi/Emitters/MarkdownSyntax.cs @@ -0,0 +1,67 @@ +using System.Buffers; +using System.Globalization; +namespace MarkupString.Ansi; + +/// The pieces of Markdown more than one of this package's Markdown emitters writes. +internal static class MarkdownSyntax +{ + /// + /// as a link destination: whitespace, control characters, parentheses, angle + /// brackets, the backslash and the pipe percent-encoded, so the address ends where the link does and + /// a table cell does not end inside it. + /// + public static string Destination(string url) + { + var plain = true; + foreach (var c in url) + { + if (NeedsEncoding(c)) + { + plain = false; + break; + } + } + if (plain) return url; + + var encoded = new System.Text.StringBuilder(url.Length + 8); + foreach (var c in url) + { + if (NeedsEncoding(c) && c <= 0x7f) encoded.Append('%').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture)); + else if (!char.IsControl(c) && !char.IsWhiteSpace(c)) encoded.Append(c); + } + return encoded.ToString(); + } + + private static bool NeedsEncoding(char c) => + c is '(' or ')' or '<' or '>' or '\\' or '|' or ' ' or '`' || char.IsControl(c) || char.IsWhiteSpace(c); + + /// + /// as an image's destination when a page may load it — an http(s) or relative + /// address with no whitespace in it — or null when it is to be described instead. + /// + public static string? ImageSource(string? source) + { + if (string.IsNullOrWhiteSpace(source) || source.Any(c => char.IsControl(c) || char.IsWhiteSpace(c))) return null; + + // On Unix a rooted path parses as an absolute file: address and "//host" as one on another host, + // so both are decided before the parser sees them. + if (source.StartsWith("//", StringComparison.Ordinal) || source.StartsWith('\\')) return null; + if (!source.StartsWith('/')) + { + if (!Uri.TryCreate(source, UriKind.RelativeOrAbsolute, out var uri)) return null; + if (uri.IsAbsoluteUri && uri.Scheme is not ("http" or "https")) return null; + } + return Destination(source); + } + + /// Writes an image, ![description](destination), its description on one line. + public static void WriteImage(string? description, string destination, IBufferWriter output) + { + output.Write("!["); + if (description is { Length: > 0 }) + MarkupTextRenderer.EncodeText(description.ReplaceLineEndings(" "), TextEncoding.Markdown, output); + output.Write("]("); + output.Write(destination); + output.Write(")"); + } +} diff --git a/MarkupString.Ansi/MarkupString.Ansi.csproj b/MarkupString.Ansi/MarkupString.Ansi.csproj index cbc0318..1f7794d 100644 --- a/MarkupString.Ansi/MarkupString.Ansi.csproj +++ b/MarkupString.Ansi/MarkupString.Ansi.csproj @@ -13,7 +13,7 @@ it back straight after. --> true 2.18.0 - Terminal styling for MarkupString: colours (16, 256 and truecolor), text attributes and links, an ansi() code parser, and emitters that fold a run's layers into one SGR sequence, HTML span, Pueblo/MXP tag or BBCode tag. + Terminal styling for MarkupString: colours (16, 256 and truecolor), text attributes and links, an ansi() code parser, and emitters that fold a run's layers into one SGR sequence, HTML span, Pueblo/MXP tag, BBCode tag or Markdown, which also draws a layout as tables and lists. diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 7dc5c58..5e64f6b 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +MarkupString.Ansi.AnsiMarkdownEmitter +MarkupString.Ansi.AnsiMarkdownEmitter.AnsiMarkdownEmitter() -> void +MarkupString.Ansi.AnsiMarkdownEmitter.Format.get -> MarkupString.MarkupFormat! +MarkupString.Ansi.AnsiMarkdownEmitter.TryEmit(MarkupString.MarkupSet! set, System.ReadOnlySpan body, in MarkupString.EmitContext context, System.Buffers.IBufferWriter! output) -> bool diff --git a/MarkupString.AotSmoke/Program.cs b/MarkupString.AotSmoke/Program.cs index 6b1908b..a93414e 100644 --- a/MarkupString.AotSmoke/Program.cs +++ b/MarkupString.AotSmoke/Program.cs @@ -2,6 +2,7 @@ using MarkupString; using MarkupString.Ansi; using MarkupString.Html; +using MarkupString.Layout; using MarkupString.Mxp; using MarkupString.Pueblo; @@ -53,7 +54,8 @@ MarkupFormat.Html, MarkupFormat.Pueblo, MarkupFormat.Mxp, - MarkupFormat.BBCode + MarkupFormat.BBCode, + MarkupFormat.Markdown ]; var failures = new List(); @@ -102,6 +104,15 @@ Expect(rendered["html"], "
private static bool RenderRun( Run run, - ReadOnlySpan body, + ReadOnlySpan content, MarkupFormat format, MarkupRegistry registry, in EmitContext context, IBufferWriter output) { + var body = content.Slice(run.Start, run.Length); var markups = run.Markups; var points = CountPoints(markups); @@ -252,7 +400,7 @@ private static bool RenderRun( } else { - EncodeText(body, EncodingFor(markups, format, registry), front); + EncodeStretch(content, run.Start, run.End, EncodingFor(markups, format, registry), front); } var setEmitter = registry.FindSetEmitter(format); diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 7dc5c58..ea09587 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +MarkupString.TextEncoding.Markdown = 4 -> MarkupString.TextEncoding +static readonly MarkupString.MarkupFormat.Markdown -> MarkupString.MarkupFormat! diff --git a/MarkupString/TextEncoding.cs b/MarkupString/TextEncoding.cs index 0c6edfa..75bf02c 100644 --- a/MarkupString/TextEncoding.cs +++ b/MarkupString/TextEncoding.cs @@ -31,4 +31,25 @@ public enum TextEncoding /// Nor is it MXP's, whose clients are line-oriented and read a newline as a break already. ///
HtmlLineBreaks, + + /// + /// GitHub-flavoured Markdown: , a line ending is a newline (the \r of a + /// \r\n dropped), and every character that would start Markdown syntax is escaped with a backslash, + /// so the text reads back as itself. \, `, *, _, [, ], <, + /// | and ~ are escaped wherever they are; & where it would start an entity; and at the + /// start of a line #, >, -, +, = and the . or ) of a + /// number that would start a numbered list. Indentation, which a reader would take for code or for the + /// inside of a list item and shows none of, is dropped. + /// + /// + /// A render knows where on its line each stretch of text starts. A stretch given to + /// alone may start partway along one, so its start is escaped as + /// a line's would be, which is more than a reader needs and never less, and its indentation is one space. + /// Markdown keeps whitespace no more than HTML does: a run of it reads as one space. A single line + /// ending is a soft break, which a reader joins into the paragraph, and a blank line ends the paragraph. + /// A block a format draws as its own structure — a layout's table, list or rule — is set apart from + /// the text before and after it by a blank line, without which a rule under a line of text would make it a + /// heading, and a line after a table a row of it. + /// + Markdown, } From 46061e38669a38258de814287f23e4018e218ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:31:50 +0000 Subject: [PATCH 2/2] Document the Markdown format; changelog for 2.20.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FnLNvKZTAL8SzXwMt4ijhG --- CHANGELOG.md | 27 +++++++++++++++- MarkupString.Ansi/README.md | 6 ++-- MarkupString/README.md | 4 +-- README.md | 4 +-- docs/formats.md | 64 ++++++++++++++++++++++++++++++++++--- docs/getting-started.md | 2 +- docs/layout.md | 38 ++++++++++++---------- 7 files changed, 116 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da1fb26..65afebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `
`; `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 diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index 5e794ad..2d22523 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -38,8 +38,10 @@ var back = MarkupTextSerializer.Deserialize(json); ## Formats -`WithAnsi()` registers emitters for `Ansi`, `Html`, `Pueblo`, `Mxp` and `BBCode`. `Plain` needs -none: the body passes through. +`WithAnsi()` registers emitters for `Ansi`, `Html`, `Pueblo`, `Mxp`, `BBCode` and `Markdown`. `Plain` +needs none: the body passes through. For `Markdown` it also draws a layout from its tree — a table as a +pipe table, lists as lists — so a page made with `BlockLayout` can be stored in a wiki. See +[Markdown](../docs/formats.md#markdown). A command link (`linkKind: LinkKind.Command`) is written in each client's own dialect: `` for Pueblo, `` for MXP, an `ms-cmd-link` anchor carrying `xch_cmd` for HTML, and the bare diff --git a/MarkupString/README.md b/MarkupString/README.md index fb3be9a..58d7650 100644 --- a/MarkupString/README.md +++ b/MarkupString/README.md @@ -1,7 +1,7 @@ # MarkupString Immutable styled text for terminals and the web: a plain string plus layered markup runs over it. -One value renders to ANSI, HTML, Pueblo, MXP, BBCode or plain text, and round-trips through JSON +One value renders to ANSI, HTML, Pueblo, MXP, BBCode, Markdown or plain text, and round-trips through JSON without losing a layer it does not understand. Slicing, padding, wrapping and the rest of the string operations work in display cells (wide CJK, @@ -38,7 +38,7 @@ var back = MarkupTextSerializer.Deserialize(json); ## Formats -`Plain`, `Ansi`, `Html`, `Pueblo`, `Mxp`, `BBCode`. `MarkupFormat.Custom(name, encoding)` declares +`Plain`, `Ansi`, `Html`, `Pueblo`, `Mxp`, `BBCode`, `Markdown`. `MarkupFormat.Custom(name, encoding)` declares one of your own; a registry keyed by it renders through your emitters like any built-in. ## Extension points diff --git a/README.md b/README.md index 12106dd..5352f79 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,14 @@ --- `MarkupText` pairs a plain string with immutable, layered markup runs. The same value can render -as ANSI, HTML, Pueblo, MXP, BBCode, or plain text—and round-trip through JSON without discarding +as ANSI, HTML, Pueblo, MXP, BBCode, Markdown, or plain text—and round-trip through JSON without discarding markup a reader does not understand. ```text ┌─ ANSI ├─ HTML plain text + runs ┼─ MXP / Pueblo - ├─ BBCode + ├─ BBCode / Markdown └─ plain text ``` diff --git a/docs/formats.md b/docs/formats.md index b3c08e6..cfffcc5 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -12,7 +12,7 @@ text.RenderTo(MarkupFormat.Ansi, bufferWriter); // straight into a buffer, no s `ToString()` is always the plain text. There is no `ToAnsi()` or `ToHtml()`: rendering is a decision about an audience, and it is made where that audience is known. -## The six built-in formats +## The seven built-in formats | Format | Text encoding | What `MarkupString.Ansi` writes | What `MarkupString.Html` writes | |---|---|---|---| @@ -22,6 +22,7 @@ decision about an audience, and it is made where that audience is known. | `Pueblo` | HTML-escaped, and a line ending becomes `
` | SGR sequences (Pueblo reads ANSI), plus `
` / `` for a link | the tag itself | | `Mxp` | HTML-escaped | SGR sequences, plus `` / `` for a link | the tag itself | | `BBCode` | control characters stripped | `[color]`, `[b]`, `[i]`, `[u]`, `[s]` | nothing — the body passes through | +| `Markdown` | control characters stripped, Markdown escaped | `**`, `*`, `~~`, `[text](url)`; a layout as tables and lists | nothing — the body passes through | ```csharp var link = MarkupText.Wrap( @@ -33,6 +34,7 @@ link.Render(MarkupFormat.Pueblo); // \e[1;31mnorth\e[0m link.Render(MarkupFormat.Ansi); // \e[1;31mnorth\e[0m link.Render(MarkupFormat.BBCode); // [color=#ff5555]north[/color] +link.Render(MarkupFormat.Markdown); // north link.Render(MarkupFormat.Plain); // north ``` @@ -77,11 +79,11 @@ line.Render(MarkupFormat.Html); // `, `` | ``, or `"loop"` | `