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]