diff --git a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs index 8567e751c..3f7046546 100644 --- a/src/Capacitor.App/GitHubHtml/ChatPipeline.cs +++ b/src/Capacitor.App/GitHubHtml/ChatPipeline.cs @@ -1,4 +1,7 @@ using Markdig; +using Markdig.Extensions.EmphasisExtras; +using Markdig.Parsers; +using Markdig.Parsers.Inlines; using Markdig.Renderers; using Markdig.Syntax; using Markdig.Syntax.Inlines; @@ -13,6 +16,88 @@ public static class ChatPipeline { public static MarkdownPipeline Instance { get; } = new MarkdownPipelineBuilder().UseSupportedExtensions().Use(new Hoist()).Build(); + /// What a person typed, shown as they typed it: emphasis, strikethrough, code, lists, quotes + /// and bare URLs render, and no other markdown rule applies. A backslash is never an escape — + /// in `C:\Users\me\.config` the `\.` is a path separator — and every newline is a line break. + public static MarkdownPipeline Prompt { get; } = PromptPipeline(); + + /// Only the parsers named here run, so a parser a Markdig upgrade adds stays off. Markdig's own + /// order is kept: a paragraph, and plain text after every inline parser, take what nothing + /// earlier claimed. The paragraph parser still reads a `[label]: url` line as a definition. + static MarkdownPipeline PromptPipeline() { + var builder = new MarkdownPipelineBuilder(); + var paragraphs = new ParagraphBlockParser { ParseSetexHeadings = false }; + paragraphs.Closed += BlankLines.RecordLastLine; + builder.BlockParsers.Clear(); + builder.BlockParsers.AddRange([ + new QuoteBlockParser(), + new RulelessListBlockParser(), + new FencedCodeBlockParser(), + paragraphs, + ]); + builder.InlineParsers.Clear(); + builder.InlineParsers.AddRange([ + new EmphasisInlineParser(), + new CodeInlineParser(), + new LineBreakInlineParser { EnableSoftAsHard = true }, + ]); + return builder + .UseAutoLinks() + .UseEmphasisExtras(EmphasisExtraOptions.Strikethrough) + .Use(new Hoist()) + .Use(new BlankLines()) + .Build(); + } + + /// Markdig's list parser opens a thematic break itself, ahead of any list item, so leaving the + /// thematic break parser out is not enough to keep `---` or `***` as typed. + sealed class RulelessListBlockParser : ListBlockParser { + public override BlockState TryOpen(BlockProcessor processor) { + var rules = ThematicBreakParser.Default; + if (rules.HasOpeningCharacter(processor.CurrentChar) && rules.TryOpen(processor).IsBreak()) { + processor.NewBlocks.Pop(); + return BlockState.None; + } + return base.TryOpen(processor); + } + } + + sealed class BlankLines : IMarkdownExtension { + static readonly object LastLineKey = new(); + + public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Keep; + + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { } + + /// A paragraph's source lines are released once its inlines are parsed, and a code span + /// can cross a line without leaving a line break behind, so the last line is taken here. + public static void RecordLastLine(BlockProcessor processor, Block block) { + if (block is ParagraphBlock paragraph) paragraph.SetData(LastLineKey, paragraph.Line + Math.Max(paragraph.Lines.Count - 1, 0)); + } + + /// Markdown reads any run of blank lines as one paragraph break. Adjacent paragraphs are + /// joined instead, by a line break for each newline that separated them in the source. + static void Keep(MarkdownDocument document) { + foreach (var container in document.Descendants().Prepend(document).ToArray()) + for (var i = 0; i < container.Count - 1;) { + if (container[i] is not ParagraphBlock { Inline: { } into } before + || container[i + 1] is not ParagraphBlock { Inline: { } from } after) { + i++; + continue; + } + for (var line = LastLine(before); line < after.Line; line++) into.AppendChild(new LineBreakInline { IsHard = true }); + while (from.FirstChild is { } inline) { + inline.Remove(); + into.AppendChild(inline); + } + before.SetData(LastLineKey, LastLine(after)); + container.RemoveAt(i + 1); + } + } + + static int LastLine(ParagraphBlock paragraph) => paragraph.GetData(LastLineKey) as int? ?? paragraph.Line; + } + sealed class Hoist : IMarkdownExtension { public void Setup(MarkdownPipelineBuilder pipeline) => pipeline.DocumentProcessed += Process; diff --git a/src/Capacitor.App/Views/ChatTabView.axaml b/src/Capacitor.App/Views/ChatTabView.axaml index 4d9901e25..cf4bd12bf 100644 --- a/src/Capacitor.App/Views/ChatTabView.axaml +++ b/src/Capacitor.App/Views/ChatTabView.axaml @@ -116,7 +116,7 @@ - diff --git a/src/Capacitor.App/Views/MarkdownFlavor.cs b/src/Capacitor.App/Views/MarkdownFlavor.cs index 88de9d647..3a9ca682a 100644 --- a/src/Capacitor.App/Views/MarkdownFlavor.cs +++ b/src/Capacitor.App/Views/MarkdownFlavor.cs @@ -1,4 +1,5 @@ namespace Capacitor.App.Views; /// Chat shows HTML as source: a transcript is not written for github.com. The reader converts it. -public enum MarkdownFlavor { Chat, GitHub } +/// Prompt is what a person typed: shown as typed, with only the formatting they would type on purpose. +public enum MarkdownFlavor { Chat, GitHub, Prompt } diff --git a/src/Capacitor.App/Views/MarkdownView.cs b/src/Capacitor.App/Views/MarkdownView.cs index 76fac43f8..4e322e48f 100644 --- a/src/Capacitor.App/Views/MarkdownView.cs +++ b/src/Capacitor.App/Views/MarkdownView.cs @@ -230,7 +230,11 @@ void ApplyFlavor() { // After the highlighter, whose code block renderer this one wraps. _viewer.Extensions.Add(_codeActions); // Only the pipeline change re-renders, so it goes last. - _viewer.Pipeline = Flavor == MarkdownFlavor.GitHub ? GitHubPipeline.Instance : ChatPipeline.Instance; + _viewer.Pipeline = Flavor switch { + MarkdownFlavor.GitHub => GitHubPipeline.Instance, + MarkdownFlavor.Prompt => ChatPipeline.Prompt, + _ => ChatPipeline.Instance, + }; _selectionHost?.UpdateSelection(this); } diff --git a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs index cf4d81362..88f8c4872 100644 --- a/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs +++ b/test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs @@ -10,6 +10,7 @@ using Avalonia.Threading; using Capacitor.App.Views; using MarkView.Avalonia; +using MarkView.Avalonia.Rendering.Inlines; using ReactiveUI.Reactive; using static Capacitor.App.Tests.Unit.AvaloniaSession; using static Capacitor.App.Tests.Unit.MarkdownViewHarness; @@ -541,6 +542,82 @@ await RunOnUiAsync(async () => { }); } + /// Pins the prompt's subset: emphasis, strikethrough, code, lists, quotes and a bare URL + /// render, and nothing else markdown would read into typed text does — a heading, a rule, a + /// setext underline, HTML, an entity, link syntax, an indented block or a backslash escape. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_renders_only_what_someone_types_on_purpose() { + await RunOnUiAsync(async () => { + var typed = "C:\\Users\\me\\.config \\*\n# not a heading\nunderlined\n---\n***\n& x [label](l.example)\n indented\n**bold** *em* ~~struck~~ `code` https://u.example\n\n```\nvar x = 1;\n```\n\n- one\n\n> quoted"; + var (window, root, opened) = Show(typed, MarkdownFlavor.Prompt); + try { + var paragraph = Paragraphs(root).First(); + await Assert.That(Reads(paragraph).ReplaceLineEndings("\n")).IsEqualTo(string.Join('\n', + @"C:\Users\me\.config \*", "# not a heading", "underlined", "---", "***", + "& x [label](l.example)", "indented", "bold em struck code https://u.example")); + await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); + await Assert.That(Spans(paragraph.Inlines!).Count()).IsEqualTo(1); + await Assert.That(Spans(paragraph.Inlines!).Any(r => r.Text == "code" && r.Classes.Contains("markdown-code-inline"))).IsTrue(); + await Assert.That(Spans(paragraph.Inlines!).Select(l => l.NavigateUri!.ToString())) + .IsEquivalentTo(["https://u.example/"]); + await Assert.That(All(root).Any(s => s.Classes.Contains("markdown-thematic-break"))).IsFalse(); + var code = All(root).Single(b => b.Classes.Contains("markdown-code-block")); + await Assert.That(Reads(All(code).Single())).IsEqualTo("var x = 1;"); + await Assert.That(All(root).Count(t => t.Classes.Contains("markdown-list-marker") && t.Text == "•")).IsEqualTo(1); + var quote = All(root).Single(b => b.Classes.Contains("markdown-blockquote")); + await Assert.That(Paragraphs(quote).Select(Reads)).IsEquivalentTo(["quoted"]); + } finally { window.Close(); } + }); + } + + /// Pins that a prompt keeps its line breaks and its runs of blank lines, where chat joins the + /// lines of a paragraph and collapses blank lines to one paragraph gap. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_keeps_its_newlines_and_blank_lines() { + await RunOnUiAsync(async () => { + const string typed = "one\ntwo\n\n\n\nthree"; + var (window, root, _) = Show(typed, MarkdownFlavor.Prompt); + try { + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo(typed); + } finally { window.Close(); } + + (window, root, _) = Show(typed); + try { + await Assert.That(string.Join('|', Paragraphs(root).Select(Reads))).IsEqualTo("one two|three"); + } finally { window.Close(); } + }); + } + + /// Pins the paragraph's last line for a code span that crosses a newline: the blank line after + /// it is counted from where the span ends, not from the line it starts on. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_counts_blank_lines_after_a_code_span_that_crosses_a_line() { + await RunOnUiAsync(async () => { + var (window, root, _) = Show("`x\ny`\n\nz", MarkdownFlavor.Prompt); + try { + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo("x y\n\nz"); + } finally { window.Close(); } + }); + } + + /// Pins that a rule standing on its own line stays text: Markdig's list parser opens one itself, + /// apart from the thematic break parser the prompt leaves out. + [Test] + [NotInParallel("AvaloniaSession")] + public async Task A_prompt_shows_a_standalone_rule_as_typed() { + await RunOnUiAsync(async () => { + const string typed = "---\n\n- - -\n\n***\n\n___"; + var (window, root, _) = Show(typed, MarkdownFlavor.Prompt); + try { + await Assert.That(All(root).Any(s => s.Classes.Contains("markdown-thematic-break"))).IsFalse(); + await Assert.That(Reads(Paragraphs(root).Single()).ReplaceLineEndings("\n")).IsEqualTo(typed); + } finally { window.Close(); } + }); + } + /// Pins the degrade for text Markdig refuses to parse (128 nested quotes): the source as plain /// text, in either flavor, and the next parseable text renders as markdown again. [Test]