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/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.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.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"], " /// Returns a registry that renders as its own tag in Html, Pueblo and - /// Mxp, and serialises it under kind "html". Plain and BBCode need no emitter here — Plain - /// passes the body through untouched, and BBCode's formatting for the handful of tags + /// Mxp, and serialises it under kind "html". Plain, BBCode and Markdown need no emitter here — + /// Plain passes the body through untouched, and BBCode's and Markdown's formatting for the handful of tags /// understands (bold, italic, underline, strikethrough) comes from the /// Ansi package's fold via , which requires /// WithAnsi() to already be applied. diff --git a/MarkupString.Tests/Ansi/MarkdownRenderTests.cs b/MarkupString.Tests/Ansi/MarkdownRenderTests.cs new file mode 100644 index 0000000..8d80669 --- /dev/null +++ b/MarkupString.Tests/Ansi/MarkdownRenderTests.cs @@ -0,0 +1,345 @@ +using CsCheck; +using Markdig; +using Markdig.Extensions.EmphasisExtras; +using MarkupString.Ansi; +using MarkupString.Html; +namespace MarkupString.Tests.Ansi; + +/// +/// for running text: GitHub-flavoured emphasis, links and pictures from the +/// Ansi fold, everything else dropped with its text kept, and every character that would start Markdown +/// syntax escaped so that stored text reads back as itself. +/// +/// +/// Where the exact characters are not the point, the output is read back with Markdig, a GitHub-flavoured +/// reader, so a test says what a wiki shows rather than how it was spelled. +/// +public class MarkdownRenderTests +{ + private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml(); + + private static string Render(MarkupText text) => text.Render(MarkupFormat.Markdown, Registry); + + private static readonly AnsiMarkup Bold = AnsiMarkup.Create(bold: true); + private static readonly AnsiMarkup Italic = AnsiMarkup.Create(italic: true); + + // ── The format ────────────────────────────────────────────────────────────── + + [Test] + public async Task TheFormatIsBuiltIn() + { + await Assert.That(MarkupFormat.TryParse("Markdown")).IsSameReferenceAs(MarkupFormat.Markdown); + await Assert.That(MarkupFormat.Markdown.Encoding).IsEqualTo(TextEncoding.Markdown); + await Assert.That(() => MarkupFormat.Custom("markdown", TextEncoding.None)).Throws(); + } + + [Test] + public async Task PlainTextPassesThrough() + => await Assert.That(Render(MarkupText.Plain("Hello, world."))).IsEqualTo("Hello, world."); + + [Test] + public async Task ARegistryWithoutAnsiStillEscapes() + => await Assert.That(MarkupText.Plain("*x*").Render(MarkupFormat.Markdown, MarkupRegistry.Empty)).IsEqualTo("\\*x\\*"); + + // ── Escaping ──────────────────────────────────────────────────────────────── + + [Test] + [Arguments("a*b", "a\\*b")] + [Arguments("snake_case", "snake\\_case")] + [Arguments("`code`", "\\`code\\`")] + [Arguments("[link](x)", "\\[link\\](x)")] + [Arguments("![img](x)", "!\\[img\\](x)")] + [Arguments("", "\\")] + [Arguments("a|b", "a\\|b")] + [Arguments("~~gone~~", "\\~\\~gone\\~\\~")] + [Arguments("back\\slash", "back\\\\slash")] + [Arguments("& #", "\\& \\#")] + [Arguments("Tom & Jerry", "Tom & Jerry")] + [Arguments("issue #5 > 4 - 3 + 2 = 1", "issue #5 > 4 - 3 + 2 = 1")] + public async Task MetacharactersAreEscapedWhereverTheyStand(string text, string expected) + => await Assert.That(Render(MarkupText.Plain(text))).IsEqualTo(expected); + + [Test] + [Arguments("# Heading", "\\# Heading")] + [Arguments("> quote", "\\> quote")] + [Arguments("- item", "\\- item")] + [Arguments("+ item", "\\+ item")] + [Arguments("===", "\\===")] + [Arguments("---", "\\---")] + [Arguments("1. first", "1\\. first")] + [Arguments("12) twelfth", "12\\) twelfth")] + [Arguments("3.14 is pi", "3.14 is pi")] + [Arguments(" - indented", "\\- indented")] + public async Task LineStartMarkersAreEscapedAtTheStartOfALine(string text, string expected) + => await Assert.That(Render(MarkupText.Plain(text))).IsEqualTo(expected); + + [Test] + public async Task EveryLineIsALineStart() + => await Assert.That(Render(MarkupText.Plain("a\n# b\r\n- c\r1. d"))).IsEqualTo("a\n\\# b\n\\- c\n1\\. d"); + + [Test] + public async Task ACrlfSplitBetweenTwoRunsIsOneLineEnding() + => await Assert.That(Render(MarkupText.Concat([MarkupText.Wrap(Bold, "a\r"), MarkupText.Plain("\n- b")]))).IsEqualTo("**a**\n\\- b"); + + [Test] + public async Task ControlCharactersAreDropped() + => await Assert.That(Render(MarkupText.Plain("a\u0001b\u001b[1mc\u007f\td"))).IsEqualTo("ab\\[1mc\td"); + + /// + /// Indentation would make code of a line, or put it inside a list item, and a reader shows none of it, so a + /// line's own is dropped, however many stretches of text it is spread over. Whitespace after text on the + /// line is kept. + /// + [Test] + public async Task IndentationIsNotKept() + { + await Assert.That(Render(MarkupText.Plain(" code?\n\tand this\n three\n \nfour"))).IsEqualTo("code?\nand this\nthree\n\nfour"); + var text = MarkupText.Concat([MarkupText.Plain("a\n "), MarkupText.Wrap(Bold, " "), MarkupText.Plain(" "), MarkupText.Bell(), MarkupText.Plain(" # b")]); + await Assert.That(Render(text)).IsEqualTo("a\n\\# b"); + await Assert.That(Render(MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(Bold, " - b")]))).IsEqualTo("a **- b**"); + } + + /// On its own, with nothing known of what is before it, a stretch may start partway along a line. + [Test] + public async Task EncodeTextKeepsOneSpaceOfIndentation() + { + var buffer = new System.Buffers.ArrayBufferWriter(); + MarkupTextRenderer.EncodeText(" - x\n y", TextEncoding.Markdown, buffer); + await Assert.That(new string(buffer.WrittenSpan)).IsEqualTo(" \\- x\ny"); + } + + [Test] + public async Task EncodeTextIsTheSameEncoding() + { + var buffer = new System.Buffers.ArrayBufferWriter(); + MarkupTextRenderer.EncodeText("# *a*", TextEncoding.Markdown, buffer); + await Assert.That(new string(buffer.WrittenSpan)).IsEqualTo("\\# \\*a\\*"); + } + + // ── Styling ───────────────────────────────────────────────────────────────── + + [Test] + public async Task GitHubsStylingIsWritten() + { + await Assert.That(Render(MarkupText.Wrap(Bold, "b"))).IsEqualTo("**b**"); + await Assert.That(Render(MarkupText.Wrap(Italic, "i"))).IsEqualTo("*i*"); + await Assert.That(Render(MarkupText.Wrap(AnsiMarkup.Create(strikeThrough: true), "s"))).IsEqualTo("~~s~~"); + await Assert.That(Render(MarkupText.Wrap(AnsiMarkup.Create(bold: true, italic: true, strikeThrough: true), "x"))).IsEqualTo("~~***x***~~"); + } + + [Test] + public async Task WhatMarkdownCannotSayIsDroppedAndTheTextKept() + { + var markup = AnsiMarkup.Create( + foreground: new AnsiColor.Standard(1, false), background: new AnsiColor.Rgb(0, 0, 255), + underlined: true, faint: true, blink: true, overlined: true, inverted: true); + await Assert.That(Render(MarkupText.Wrap(markup, "plain"))).IsEqualTo("plain"); + } + + [Test] + public async Task HtmlTagsForStylingFold() + { + var text = MarkupText.Concat([ + MarkupText.Wrap(HtmlMarkup.Create("strong"), "b"), MarkupText.Plain(" "), + MarkupText.Wrap(HtmlMarkup.Create("em"), "i"), MarkupText.Plain(" "), + MarkupText.Wrap(HtmlMarkup.Create("del"), "s"), MarkupText.Plain(" "), + MarkupText.Wrap(HtmlMarkup.Create("span", "class=\"x\""), "kept")]); + await Assert.That(Render(text)).IsEqualTo("**b** *i* ~~s~~ kept"); + } + + [Test] + public async Task DelimitersStayOffTheWhitespaceAtEitherEnd() + { + var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(Bold, " bold "), MarkupText.Plain("b")]); + await Assert.That(Render(text)).IsEqualTo("a **bold** b"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

a bold b

"); + } + + [Test] + public async Task NothingButWhitespaceIsNotWrapped() + => await Assert.That(Render(MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(Bold, " "), MarkupText.Plain("b")]))).IsEqualTo("a b"); + + [Test] + public async Task StylingThatCarriesOnIsWrittenOnceRoundBothRuns() + { + var text = MarkupText.Concat([MarkupText.Wrap(Bold, "a "), MarkupText.Wrap(Bold, MarkupText.Wrap(Italic, "b")), MarkupText.Plain(" c")]); + await Assert.That(Render(text)).IsEqualTo("**a *b*** c"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

a b c

"); + } + + [Test] + public async Task NestedStylingReadsBackNested() + { + var text = MarkupText.Wrap(AnsiMarkup.Create(strikeThrough: true), + MarkupText.Concat([MarkupText.Plain("x "), MarkupText.Wrap(Bold, MarkupText.Concat([MarkupText.Plain("y "), MarkupText.Wrap(Italic, "z")])), MarkupText.Plain(" w")])); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

x y z w

"); + } + + [Test] + public async Task StylingThatEndsInsideAnotherStillPairsUp() + { + // Bold and italic, then italic alone: the bold closes, and the italic is closed and opened again round it. + var text = MarkupText.Wrap(Italic, MarkupText.Concat([MarkupText.Wrap(Bold, "a"), MarkupText.Plain("b")])); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

ab

"); + } + + [Test] + public async Task StylingClosesBeforeALineEndingAndOpensAfterIt() + { + var text = MarkupText.Wrap(Bold, "one\n\ntwo"); + await Assert.That(Render(text)).IsEqualTo("**one**\n\n**two**"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

one

\n

two

"); + } + + [Test] + public async Task EscapedTextInsideStylingStaysLiteral() + { + var text = MarkupText.Wrap(Bold, "*not italic*"); + await Assert.That(Render(text)).IsEqualTo("**\\*not italic\\***"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

*not italic*

"); + } + + // ── Links ─────────────────────────────────────────────────────────────────── + + [Test] + public async Task AUrlLinkIsALink() + => await Assert.That(Render(MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "https://example.com/a?b=1&c=2"), "example"))) + .IsEqualTo("[example](https://example.com/a?b=1&c=2)"); + + [Test] + public async Task AnAddressIsEncodedSoTheLinkEndsWhereItShould() + { + var text = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "https://x.test/a b(c)|d"), "x"); + await Assert.That(Render(text)).IsEqualTo("[x](https://x.test/a%20b%28c%29%7Cd%3Ce%3E)"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("
"); + } + + [Test] + public async Task ACommandLinkIsItsText() + => await Assert.That(Render(MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "+who", linkKind: LinkKind.Command, linkText: "Who?"), "who"))) + .IsEqualTo("who"); + + [Test] + public async Task AnUnsafeAddressIsItsText() + => await Assert.That(Render(MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "javascript:alert(1)"), "click"))).IsEqualTo("click"); + + [Test] + public async Task StylingInsideALinkStaysInside() + { + var text = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "https://x.test"), + MarkupText.Concat([MarkupText.Plain("go "), MarkupText.Wrap(Bold, "now")])); + await Assert.That(Render(text)).IsEqualTo("[go **now**](https://x.test)"); + await Assert.That(ToHtml(Render(text))).IsEqualTo("

go now

"); + } + + // ── The shared vocabulary ─────────────────────────────────────────────────── + + [Test] + public async Task APictureIsAnImage() + { + await Assert.That(Render(MarkupText.Image("map.png", "A map of the city", width: 200))).IsEqualTo("![A map of the city](map.png)"); + await Assert.That(Render(MarkupText.Image("https://example.test/a b.png"))).IsEqualTo("https://example.test/a b.png"); + await Assert.That(Render(MarkupText.Image("map.png", "A [map]"))).IsEqualTo("![A \\[map\\]](map.png)"); + } + + [Test] + public async Task APictureAtAnAddressAPageCannotLoadIsItsDescription() + => await Assert.That(Render(MarkupText.Image("file:///etc/passwd", "secrets"))).IsEqualTo("secrets"); + + [Test] + public async Task APictureInsideALinkIsALinkedImage() + { + var text = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "https://x.test/city"), MarkupText.Image("map.png", "A map")); + await Assert.That(Render(text)).IsEqualTo("[![A map](map.png)](https://x.test/city)"); + } + + [Test] + public async Task PointsAreNothing() + { + var line = MarkupText.Concat([ + MarkupText.Sound("door.wav"), MarkupText.Music("theme.mid"), MarkupText.StopSound(), MarkupText.ClearScreen(), + MarkupText.Bell(), MarkupText.Plain("The door creaks.")]); + await Assert.That(Render(line)).IsEqualTo("The door creaks."); + } + + /// + /// A point inside styled text writes nothing, and the styling either side of it is closed against it, as + /// against anything written by an emitter of its own, so nothing is left open. + /// + [Test] + public async Task AStyledPointLeavesNothingOpen() + { + var line = MarkupText.Wrap(Bold, MarkupText.Concat([MarkupText.Plain("You hear "), MarkupText.Sound("door.wav"), MarkupText.Plain(" a door")])); + await Assert.That(Render(line)).IsEqualTo("**You hear** **a door**"); + await Assert.That(ToHtml(Render(line))).IsEqualTo("

You hear a door

"); + } + + [Test] + public async Task TextTheVocabularyMarksIsKept() + { + var line = MarkupText.Concat([MarkupText.Pane(MarkupText.Plain("hi"), "chat"), MarkupText.Plain(" "), MarkupText.Variable(MarkupText.Plain("10"), "hp")]); + await Assert.That(Render(line)).IsEqualTo("hi 10"); + } + + // ── Reading it back ───────────────────────────────────────────────────────── + + /// Pieces that are Markdown syntax somewhere, and the whitespace that decides where. + private static readonly string[] Pieces = + [ + "a", "Zed", "42", "1.", "3)", " ", " ", " ", "\t", "\n", "\n\n", "\r\n", + "*", "**", "_", "__", "`", "```", "~", "~~", "[", "]", "(", ")", "![", "<", ">", "", "&", "&", "#", + "#", "##", "-", "---", "+", "=", "===", "|", "\\", "!", ":", "http://x.test", "www.x.test", "é", "日本", + ]; + + /// + /// Whatever plain text is stored, a GitHub-flavoured reader shows exactly its words: none of it is read as + /// syntax. Whitespace is compared as a reader shows it, a run of it as one space. + /// + [Test] + public void PlainTextReadsBackAsItself() + { + var gen = Gen.OneOfConst(Pieces).Array[0, 12].Select(string.Concat); + Check.Sample(gen, text => + { + var markdown = Render(MarkupText.Plain(text)); + var shown = Words(Markdown.ToPlainText(markdown, Pipeline)); + if (shown != Words(text)) throw new InvalidOperationException($"{Show(text)} -> {Show(markdown)} reads as {Show(shown)}"); + }, seed: "000000000000", iter: 5000); + } + + /// + /// The same, for text styled in stretches a space apart: the words come back, whatever the delimiters. + /// + /// + /// The space is Markdown's limit, not this emitter's: a delimiter between punctuation and a letter, as in + /// **Note:**text, is not read as one by any GitHub-flavoured reader. + /// + [Test] + public void StyledTextReadsBackAsItsWords() + { + var styles = new[] { Bold, Italic, AnsiMarkup.Create(strikeThrough: true), AnsiMarkup.Create(bold: true, italic: true) }; + var gen = Gen.Select(Gen.OneOfConst(Pieces).Array[1, 4].Select(string.Concat), Gen.Int[-1, styles.Length - 1]) + .Array[1, 5] + .Select(parts => MarkupText.Join(MarkupText.Plain(" "), parts.Select(part => part.Item2 < 0 ? MarkupText.Plain(part.Item1) : MarkupText.Wrap(styles[part.Item2], part.Item1)))); + Check.Sample(gen, text => + { + var markdown = Render(text); + var shown = Words(Markdown.ToPlainText(markdown, Pipeline)); + if (shown != Words(text.ToPlainText())) throw new InvalidOperationException($"{Show(text.ToPlainText())} -> {Show(markdown)} reads as {Show(shown)}"); + }, seed: "000000000000", iter: 5000); + } + + internal static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UsePipeTables() + .UseEmphasisExtras(EmphasisExtraOptions.Strikethrough) + .Build(); + + internal static string ToHtml(string markdown) => Markdown.ToHtml(markdown, Pipeline).TrimEnd('\n'); + + /// Text as a reader shows it: control characters gone and every run of whitespace one space. + private static string Words(string text) => + string.Join(' ', new string([.. text.Where(c => !char.IsControl(c) || char.IsWhiteSpace(c))]) + .Split((char[])[' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); + + private static string Show(string text) => text.Replace("\n", "\\n", StringComparison.Ordinal).Replace("\t", "\\t", StringComparison.Ordinal); +} diff --git a/MarkupString.Tests/BellTests.cs b/MarkupString.Tests/BellTests.cs index 3c2f085..96648f6 100644 --- a/MarkupString.Tests/BellTests.cs +++ b/MarkupString.Tests/BellTests.cs @@ -43,6 +43,7 @@ public async Task AFormatWithNoBellLeavesNothingBehind() await Assert.That(Render(text, MarkupFormat.Plain)).IsEqualTo("Hey"); await Assert.That(Render(text, MarkupFormat.BBCode)).IsEqualTo("Hey"); + await Assert.That(Render(text, MarkupFormat.Markdown)).IsEqualTo("Hey"); } [Test] diff --git a/MarkupString.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs index 49e3445..ed04ae7 100644 --- a/MarkupString.Tests/Layout/FigurePictureTests.cs +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -151,12 +151,13 @@ public async Task AFigureWithNoAddressIsItsArtEverywhere() } [Test] - public async Task PuebloAndBBCodeWriteThePictureOnce() + public async Task PuebloBBCodeAndMarkdownWriteThePictureOnce() { var laid = BlockLayout.Build(new Figure(Cat, Art), 20); await Assert.That(Count(laid.Render(MarkupFormat.Pueblo, Registry), " +/// A layout in : drawn from its tree as Markdown's own structure — a pipe +/// table, lists, rules, bold titles — and never laid out to a width. +///

x

+public class MarkdownLayoutTests +{ + private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi(); + + private static readonly AnsiMarkup Bold = AnsiMarkup.Create(bold: true); + + private static string Render(Block block, int width = 60) => BlockLayout.Build(block, width).Render(MarkupFormat.Markdown, Registry); + + private static string Html(Block block) => MarkdownRenderTests.ToHtml(Render(block)); + + private static MarkupText P(string text) => MarkupText.Plain(text); + + private static TextBlock T(string text) => new(MarkupText.Plain(text)); + + // ── Tables ────────────────────────────────────────────────────────────────── + + private static readonly Table Scores = new( + [ + new TableColumn(MarkupText.Wrap(Bold, "Name")), + new TableColumn(P("Score")) { Alignment = Alignment.Right }, + new TableColumn(P("Note")) { Alignment = Alignment.Center }, + new TableColumn(P("Seen")) { Alignment = Alignment.Full }, + ], + [ + [T("Lyra"), T("10"), T("a|b\nc"), T("today")], + [T("Bo"), T(""), T("*x*")], + ]); + + [Test] + public async Task ATableIsAPipeTable() + => await Assert.That(Render(Scores)).IsEqualTo( + "| **Name** | Score | Note | Seen |\n" + + "| --- | ---: | :---: | --- |\n" + + "| Lyra | 10 | a\\|b
c | today |\n" + + "| Bo | | \\*x\\* | |"); + + [Test] + public async Task ATableReadsBackAsATable() + { + var html = Html(Scores); + await Assert.That(html).Contains("Name"); + await Assert.That(html).Contains("Score"); + await Assert.That(html).Contains("a|b
c"); + await Assert.That(html).Contains("*x*"); + await Assert.That(html.Split("").Length - 1).IsEqualTo(3); + } + + [Test] + public async Task MarkdownIsNotLaidOutToAWidth() + { + var wide = new Table([new TableColumn(P("Text"))], [[T("a long cell that a terminal would wrap at twenty columns")]]); + await Assert.That(Render(wide, 20)).IsEqualTo(Render(wide, 200)); + await Assert.That(Render(wide, 20)).IsEqualTo("| Text |\n| --- |\n| a long cell that a terminal would wrap at twenty columns |"); + } + + [Test] + public async Task ATableWithNoColumnsIsNothing() + => await Assert.That(Render(new Stack([new Table([], [[T("x")]]), T("after")]))).IsEqualTo("after"); + + [Test] + public async Task ABlockInACellIsItsLinesInReadingOrder() + { + var table = new Table( + [new TableColumn(P("Who")), new TableColumn(P("What"))], + [[T("Lyra"), new Stack([new Bullets([T("one"), T("two")]), new Rule(), new Rule(P("End"))])]]); + await Assert.That(Render(table)).IsEqualTo("| Who | What |\n| --- | --- |\n| Lyra | - one
- two
**End** |"); + } + + [Test] + public async Task ATableInACellIsItsRowsAsLabelledValues() + { + var inner = new Table([new TableColumn(P("k")), new TableColumn(P("v"))], [[T("a"), T("1")], [T("b")]]); + var table = new Table([new TableColumn(P("Data"))], [[inner]]); + await Assert.That(Render(table)).IsEqualTo("| Data |\n| --- |\n| - **k:** a
- **v:** 1
- **k:** b
- **v:** |"); + } + + // ── Placement among text ──────────────────────────────────────────────────── + + [Test] + public async Task ABlockStandsApartFromTheTextAroundIt() + { + var text = MarkupText.Concat([P("Intro\n"), BlockLayout.Build(new Rule(), 20), P("\nOutro")]); + var markdown = text.Render(MarkupFormat.Markdown, Registry); + await Assert.That(markdown).IsEqualTo("Intro\n\n---\n\nOutro"); + await Assert.That(MarkdownRenderTests.ToHtml(markdown)).IsEqualTo("

Intro

\n
\n

Outro

"); + } + + [Test] + public async Task ALineAfterATableIsNotARowOfIt() + { + var text = MarkupText.Concat([BlockLayout.Build(Scores, 40), P("\nOutro")]); + var html = MarkdownRenderTests.ToHtml(text.Render(MarkupFormat.Markdown, Registry)); + await Assert.That(html).EndsWith("\n

Outro

"); + } + + [Test] + public async Task ALayoutThatWasCutIsItsText() + { + var laid = BlockLayout.Build(new Stack([T("# one"), T("two")]), 20); + var cut = laid.Substring(0, 5); + await Assert.That(cut.Render(MarkupFormat.Markdown, Registry)).IsEqualTo("\\# one"); + } + + // ── Text ──────────────────────────────────────────────────────────────────── + + [Test] + public async Task TextIsEscapedAndItsLineEndingsKept() + { + await Assert.That(Render(T("# not a heading\n- nor a list\n\nnew *paragraph*"))).IsEqualTo("\\# not a heading\\\n\\- nor a list\n\nnew \\*paragraph\\*"); + await Assert.That(Html(T("one\ntwo"))).IsEqualTo("

one
\ntwo

"); + } + + [Test] + public async Task StyledTextKeepsItsStyling() + => await Assert.That(Render(new TextBlock(MarkupText.Concat([P("a "), MarkupText.Wrap(Bold, "b"), P(" c")])))).IsEqualTo("a **b** c"); + + [Test] + public async Task ALayoutInsideTextIsDrawnAsItsOwnBlocks() + { + var inner = BlockLayout.Build(new Bullets([T("x"), T("y")]), 20); + var outer = new Stack([new TextBlock(MarkupText.Concat([P("before\n"), inner, P("\nafter")]))]); + await Assert.That(Render(outer)).IsEqualTo("before\n\n- x\n- y\n\nafter"); + } + + // ── Rules and boxes ───────────────────────────────────────────────────────── + + [Test] + public async Task ARuleIsAThematicBreak() + => await Assert.That(Render(new Stack([T("a"), new Rule(), T("b")]))).IsEqualTo("a\n\n---\n\nb"); + + [Test] + public async Task ATitledRuleIsItsTitleInBold() + { + var rule = new Rule(P("Middle")) { Titles = [new EdgeTitle(P("Right"), Alignment.Right), new EdgeTitle(P("Left"), Alignment.Left)] }; + await Assert.That(Render(rule)).IsEqualTo("**Left, Middle, Right**"); + } + + [Test] + public async Task ATitleAlreadyBoldIsBoldOnce() + { + await Assert.That(Render(new Rule(MarkupText.Wrap(Bold, "Mail")))).IsEqualTo("**Mail**"); + await Assert.That(Render(new Rule(MarkupText.Concat([P("New "), MarkupText.Wrap(AnsiMarkup.Create(italic: true), "Mail")])))).IsEqualTo("**New *Mail***"); + await Assert.That(Render(new Rule(P("two\nlines")))).IsEqualTo("**two** **lines**"); + await Assert.That(Render(new Fields([new Field(MarkupText.Wrap(Bold, "Name"), T("x"))]))).IsEqualTo("- **Name:** x"); + } + + [Test] + public async Task ABoxIsItsTitleOverWhatItHolds() + { + var box = new Stack([T("Lyra Vale"), new Rule(), T("Wayfinder")]).Bordered(P("JOURNAL")) with + { + BottomTitles = [new EdgeTitle(P("page 1"))], + }; + await Assert.That(Render(box)).IsEqualTo("**JOURNAL**\n\nLyra Vale\n\n---\n\nWayfinder\n\npage 1"); + await Assert.That(Render(T("only").Bordered())).IsEqualTo("only"); + } + + [Test] + public async Task ASectionIsATitleAContentAndALine() + { + var section = new Stack([new Rule(P("Mail")), T("You have no mail."), new Rule()]); + await Assert.That(Html(section)).IsEqualTo("

Mail

\n

You have no mail.

\n
"); + } + + // ── Rows and modifiers ────────────────────────────────────────────────────── + + [Test] + public async Task ARowIsItsItemsOneAfterTheOther() + { + var row = new Flex([T("left").Sized(BlockSize.Cells(10)), T("right")]) { Separator = P(" | ") }; + await Assert.That(Render(row)).IsEqualTo("left\n\nright"); + } + + [Test] + public async Task AlignmentShadingAndThemesAreDropped() + { + var block = new Stack([ + T("centred").Aligned(Alignment.Center), + T("shaded").Shaded(new ColorGradient([AnsiMarkup.Create(foreground: new AnsiColor.Rgb(255, 0, 0)), AnsiMarkup.Create(foreground: new AnsiColor.Rgb(0, 0, 255))])), + T("themed").Themed(ThemePalette.Preset("fantasy")!.ToLayoutTheme()), + ]); + await Assert.That(Render(block)).IsEqualTo("centred\n\nshaded\n\nthemed"); + } + + [Test] + public async Task AColouredBlockKeepsWhatMarkdownCanSay() + { + await Assert.That(Render(new Stack([T("a"), T("b")]).Colored(AnsiMarkup.Create(foreground: new AnsiColor.Standard(1, false))))).IsEqualTo("a\n\nb"); + await Assert.That(Render(T("loud").Colored(Bold))).IsEqualTo("**loud**"); + } + + // ── Lists ─────────────────────────────────────────────────────────────────── + + [Test] + [Arguments(BulletStyle.Bullet)] + [Arguments(BulletStyle.Dash)] + [Arguments(BulletStyle.Star)] + [Arguments(BulletStyle.Custom)] + public async Task BulletsAreAList(BulletStyle style) + => await Assert.That(Render(new Bullets([T("one"), T("- two")]) { Style = style, Marker = P(">>") })).IsEqualTo("- one\n- \\- two"); + + [Test] + [Arguments(BulletStyle.Number)] + [Arguments(BulletStyle.Alpha)] + [Arguments(BulletStyle.Roman)] + public async Task CountedMarkersAreANumberedList(BulletStyle style) + { + var list = new Bullets([T("one"), T("two")]) { Style = style, Start = 9 }; + await Assert.That(Render(list)).IsEqualTo("9. one\n10. two"); + await Assert.That(Html(list)).IsEqualTo("
    \n
  1. one
  2. \n
  3. two
  4. \n
"); + } + + [Test] + public async Task BulletsWithNoMarkerAreTheirItems() + => await Assert.That(Render(new Bullets([T("one"), T("two")]) { Style = BulletStyle.None })).IsEqualTo("one\n\ntwo"); + + [Test] + public async Task AnItemOfSeveralLinesAndBlocksStaysInItsItem() + { + var list = new Bullets([new Stack([T("first\nline"), new Bullets([T("inner")]), T("more")]), T("second")]); + await Assert.That(Render(list)).IsEqualTo("- first\\\n line\n - inner\n\n more\n- second"); + await Assert.That(Html(list)).IsEqualTo( + "
    \n
  • first
    \nline

    \n
      \n
    • inner
    • \n
    \n

    more

    \n
  • \n
  • second

    \n
  • \n
"); + } + + [Test] + public async Task ListsOneAfterTheOtherStayTwoLists() + { + var block = new Stack([new Bullets([T("a")]), new Bullets([T("b")]), new Bullets([T("c")]), new Bullets([T("1")]) { Style = BulletStyle.Number }, new Bullets([T("2")]) { Style = BulletStyle.Number }]); + await Assert.That(Render(block)).IsEqualTo("- a\n\n* b\n\n- c\n\n1. 1\n\n1) 2"); + await Assert.That(Html(block).Split("
    ").Length - 1).IsEqualTo(3); + await Assert.That(Html(block).Split("
      ").Length - 1).IsEqualTo(2); + } + + [Test] + public async Task ATreeIsANestedList() + { + var tree = new Tree([ + new TreeItem(T("root"), [new TreeItem(T("branch"), [new TreeItem(T("leaf"))]), new TreeItem(T("twig"))]), + new TreeItem(T("other")), + ]); + await Assert.That(Render(tree)).IsEqualTo("- root\n - branch\n - leaf\n - twig\n- other"); + } + + [Test] + public async Task AGridIsAList() + => await Assert.That(Render(new Grid([P("Lyra"), P("Bo"), P("Ash")]) { Across = true })).IsEqualTo("- Lyra\n- Bo\n- Ash"); + + // ── Fields and gauges ─────────────────────────────────────────────────────── + + [Test] + public async Task FieldsAreAListOfLabelledValues() + { + var fields = new Fields([ + new Field(P("Name"), T("Lyra Vale")), + new Field(P("Gear"), new Bullets([T("rope"), T("lamp")])), + new Field(MarkupText.Empty, T("unlabelled")), + ]) + { Columns = 2 }; + await Assert.That(Render(fields)).IsEqualTo("- **Name:** Lyra Vale\n- **Gear:**\n - rope\n - lamp\n- unlabelled"); + } + + [Test] + public async Task AFieldsSeparatorOrLeaderIsHonoured() + { + await Assert.That(Render(new Fields([new Field(P("HP"), T("10"))]) { Separator = P(" = ") })).IsEqualTo("- **HP =** 10"); + await Assert.That(Render(new Fields([new Field(P("HP"), T("10"))]) { Leader = P(".") })).IsEqualTo("- **HP** 10"); + var themed = new Fields([new Field(P("HP"), T("10"))]).Themed(new LayoutTheme { FieldSeparator = P(" -> ") }); + await Assert.That(Render(themed)).IsEqualTo("- **HP ->** 10"); + } + + [Test] + public async Task AGaugeIsItsFigures() + => await Assert.That(Render(new Gauge(7, 10) { Label = P("Trail") })).IsEqualTo("Trail: 7 of 10 (70%)"); + + // ── Pictures ──────────────────────────────────────────────────────────────── + + [Test] + public async Task AFigureIsAnImageAndWhatIsBesideIt() + { + var figure = new Figure(new ImageMarkup("https://example.test/map.png", "A map"), P("[map]")) { Beside = T("The city.") }; + await Assert.That(Render(figure)).IsEqualTo("![A map](https://example.test/map.png)\n\nThe city."); + } + + [Test] + public async Task AFigureAPageCannotShowIsItsArtOrItsDescription() + { + await Assert.That(Render(new Figure(new ImageMarkup("file:///map.png", "A map"), P(" /\\\n/__\\")))).IsEqualTo("```\n /\\\n/__\\\n```"); + await Assert.That(Render(new Figure(new ImageMarkup("file:///map.png", "A map"), MarkupText.Empty))).IsEqualTo("\\[A map\\]"); + } + + // ── What Markdown has nothing for ─────────────────────────────────────────── + + private sealed record Banner(string Text) : Block + { + public override void Draw(LayoutContext context, int width, IList lines) + { + lines.Add(MarkupText.Plain("+" + new string('-', Text.Length) + "+")); + lines.Add(MarkupText.Plain("|" + Text + "|")); + lines.Add(MarkupText.Plain("+" + new string('-', Text.Length) + "+")); + } + } + + [Test] + public async Task ABlockOfYourOwnIsTheLinesItDrawsAsCode() + { + await Assert.That(Render(new Banner("hi"))).IsEqualTo("```\n+--+\n|hi|\n+--+\n```"); + await Assert.That(Render(new Banner("a ``` b"))).IsEqualTo("````\n+-------+\n|a ``` b|\n+-------+\n````"); + await Assert.That(Html(new Banner("*x*"))).IsEqualTo("
      +---+\n|*x*|\n+---+\n
      "); + } + + [Test] + public async Task TheJournalReadsBackAsItsStructure() + { + var sheet = new Stack( + [ + new Fields([new Field(P("Name"), T("Lyra Vale")), new Field(P("Role"), T("Wayfinder"))]), + new Gauge(7, 10) { Label = P("Trail") }, + ]).Bordered(P("WAYFINDER'S JOURNAL")); + await Assert.That(Html(sheet)).IsEqualTo( + "

      WAYFINDER'S JOURNAL

      \n
        \n
      • Name: Lyra Vale
      • \n
      • Role: Wayfinder
      • \n
      \n

      Trail: 7 of 10 (70%)

      "); + } +} diff --git a/MarkupString.Tests/MarkupString.Tests.csproj b/MarkupString.Tests/MarkupString.Tests.csproj index 7a42d05..4d43522 100644 --- a/MarkupString.Tests/MarkupString.Tests.csproj +++ b/MarkupString.Tests/MarkupString.Tests.csproj @@ -9,6 +9,9 @@ + + true 2.18.0 - Immutable styled text: a string plus layered markup runs, rendered to ANSI, HTML, Pueblo, MXP, BBCode or plain text through a registry of emitters, and round-tripped through JSON. + Immutable styled text: a string plus layered markup runs, rendered to ANSI, HTML, Pueblo, MXP, BBCode, Markdown or plain text through a registry of emitters, and round-tripped through JSON. diff --git a/MarkupString/MarkupTextRenderer.cs b/MarkupString/MarkupTextRenderer.cs index 0a9d665..6d0b4ce 100644 --- a/MarkupString/MarkupTextRenderer.cs +++ b/MarkupString/MarkupTextRenderer.cs @@ -34,6 +34,18 @@ public static class MarkupTextRenderer /// plus the line endings rewrites. private static readonly SearchValues ControlsHtmlAndLines = SearchValues.Create(ControlCharacters + HtmlCharacters + "\r\n"); + /// + /// The characters that start Markdown syntax wherever they stand: an escape, code, emphasis, + /// strikethrough, a link or image, raw HTML or an autolink, and a table cell's edge. + /// + private const string MarkdownCharacters = "\\`*_[]<|~"; + + /// + /// , the line endings, the characters Markdown escapes everywhere, and &, + /// which it escapes where an entity would start. + /// + private static readonly SearchValues ControlsAndMarkdown = SearchValues.Create(ControlCharacters + MarkdownCharacters + "&\r\n"); + /// Writes to under . public static void EncodeText(ReadOnlySpan text, TextEncoding encoding, IBufferWriter output) { @@ -49,6 +61,9 @@ public static void EncodeText(ReadOnlySpan text, TextEncoding encoding, IB case TextEncoding.HtmlLineBreaks: HtmlEncode(text, output, lineBreaks: true); break; + case TextEncoding.Markdown: + MarkdownEncode(text, LinePosition.Unknown, output); + break; default: output.Write(text); break; @@ -104,6 +119,128 @@ private static void HtmlEncode(ReadOnlySpan text, IBufferWriter outp } } + /// Where a stretch of text starts, as far as the Markdown encoder can tell. + private enum LinePosition + { + /// Anywhere: a stretch given to alone, with nothing known of what is before it. + Unknown, + + /// At the start of a line, or after nothing but indentation. + LineStart, + + /// After text on its line. + MidLine, + } + + private static void MarkdownEncode(ReadOnlySpan text, LinePosition position, IBufferWriter output) + { + var lineStart = position != LinePosition.MidLine; + while (!text.IsEmpty) + { + if (lineStart) + { + lineStart = false; + + // Indentation means nothing to a Markdown reader but code, at four columns, and the inside of a + // list item, so a line's own is dropped. A stretch that may start partway along a line keeps one + // space, which is all a reader shows there anyway. + var indent = text.IndexOfAnyExcept(' ', '\t'); + if (indent != 0 && position == LinePosition.Unknown) output.Write(" "); + position = LinePosition.LineStart; + if (indent < 0) return; + text = text[indent..]; + if (LineMarker(text) is { } marker) + { + output.Write(text[..marker]); + output.Write("\\"); + output.Write(text.Slice(marker, 1)); + text = text[(marker + 1)..]; + continue; + } + } + + var index = text.IndexOfAny(ControlsAndMarkdown); + if (index < 0) + { + output.Write(text); + return; + } + output.Write(text[..index]); + switch (text[index]) + { + case '\n': + output.Write("\n"); + lineStart = true; + break; + + // A CRLF is one line ending, and a lone CR still ends a line; either is written as a newline. + case '\r': + output.Write("\n"); + if (index + 1 < text.Length && text[index + 1] == '\n') index++; + lineStart = true; + break; + + // & is markup only where it starts an entity: a name or a number. + case '&': + output.Write(index + 1 < text.Length && (char.IsAsciiLetter(text[index + 1]) || text[index + 1] == '#') ? "\\&" : "&"); + break; + case var c when MarkdownCharacters.Contains(c): + output.Write("\\"); + output.Write(text.Slice(index, 1)); + break; + default: break; // a control character: dropped + } + text = text[(index + 1)..]; + } + } + + /// + /// Encodes content[start..end], a stretch of a text being rendered, for which the Markdown encoding + /// can see where on its line the stretch starts. + /// + private static void EncodeStretch(ReadOnlySpan content, int start, int end, TextEncoding encoding, IBufferWriter output) + { + if (encoding != TextEncoding.Markdown) + { + EncodeText(content[start..end], encoding, output); + return; + } + + // The newline of a CRLF split between two stretches ended its line with the CR already. + if (start > 0 && start < end && content[start - 1] == '\r' && content[start] == '\n') start++; + MarkdownEncode(content[start..end], StartsLine(content, start) ? LinePosition.LineStart : LinePosition.MidLine, output); + } + + /// + /// Whether nothing a Markdown reader would show stands between the start of 's line + /// and it: only indentation, and characters the encoding drops — a point's carrier, a control character. + /// + private static bool StartsLine(ReadOnlySpan content, int index) + { + for (var i = index - 1; i >= 0; i--) + { + var c = content[i]; + if (c is '\n' or '\r') return true; + if (c is not (' ' or '\t') && !char.IsControl(c) && c != MarkupText.PointCarrier[0]) return false; + } + return true; + } + + /// + /// Where the character that makes the start of a line Markdown syntax stands — a heading, a quote, a list + /// item, a setext underline, the . or ) after a list number — or null when none does. + /// + private static int? LineMarker(ReadOnlySpan line) + { + if (line[0] is '#' or '>' or '-' or '+' or '=') return 0; + + // A number of up to nine digits, then . or ), then whitespace or the end, starts a numbered list. The + // end of the stretch counts: the space may be the first character of the next one. + var digits = line.IndexOfAnyExceptInRange('0', '9'); + if (digits is < 1 or > 9 || line[digits] is not ('.' or ')')) return null; + return digits + 1 == line.Length || line[digits + 1] is ' ' or '\t' or '\r' or '\n' ? digits : null; + } + /// Renders in to . public static void Render(MarkupText text, MarkupFormat format, MarkupRegistry registry, IBufferWriter output) { @@ -172,7 +309,7 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis for (var i = 0; i < runs.Length; i++) { var run = runs[i]; - if (run.Start > position) EncodeText(content[position..run.Start], format.Encoding, output); + if (run.Start > position) EncodeStretch(content, position, run.Start, format.Encoding, output); if (registry.HasBlockEmitters && TryRenderBlock(text, i, format, registry, output) is { } last) { anyRunEmitted = true; @@ -190,10 +327,10 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis IsFirstRun = i == 0, IsLastRun = i == runs.Length - 1, }; - anyRunEmitted |= RenderRun(run, content.Slice(run.Start, run.Length), format, registry, context, output); + anyRunEmitted |= RenderRun(run, content, format, registry, context, output); position = run.End; } - if (position < content.Length) EncodeText(content[position..], format.Encoding, output); + if (position < content.Length) EncodeStretch(content, position, content.Length, format.Encoding, output); return anyRunEmitted; } @@ -215,7 +352,17 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis var last = Layout.BlockRegions.Extent(runs, first, block); var region = Layout.BlockRegions.Describe(text, runs[first].Start, runs[last].End, block); if (!region.Intact || !region.Standalone) return null; - return emitter.TryEmit(block, text.Substring(region.Start, region.End - region.Start), registry, output) ? last : null; + var content = text.Substring(region.Start, region.End - region.Start); + if (format.Encoding != TextEncoding.Markdown) return emitter.TryEmit(block, content, registry, output) ? last : null; + + // A Markdown block stands apart: a line of text against it would join it — a rule under the + // line would make it a heading, and a line after a table a row of it. + using var drawn = new PooledCharWriter(content.Length * 2); + if (!emitter.TryEmit(block, content, registry, drawn)) return null; + if (region.Start > 0) output.Write("\n"); + output.Write(drawn.WrittenSpan); + if (region.End < text.Length) output.Write("\n"); + return last; } return null; } @@ -225,12 +372,13 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis /// layer being unregistered and the body passing through unchanged.
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/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/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, } 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"` | `