Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/Capacitor.App/GitHubHtml/ChatPipeline.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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 },
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
]);
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<ContainerBlock>().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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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;

Expand Down
2 changes: 1 addition & 1 deletion src/Capacitor.App/Views/ChatTabView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
<StackPanel Spacing="6">
<TextBlock Classes="toolKindChip" HorizontalAlignment="Left"
Text="{Binding $parent[ItemsControl].((vm:ChatTabViewModel)DataContext).UserTitle}" />
<views:MarkdownView Text="{Binding Text}"
<views:MarkdownView Text="{Binding Text}" Flavor="Prompt"
OpenLink="{Binding $parent[ItemsControl].((vm:ChatTabViewModel)DataContext).OpenLinkCommand}"
RunCode="{Binding $parent[ItemsControl].((vm:ChatTabViewModel)DataContext).RunCodeCommand}" />
</StackPanel>
Expand Down
3 changes: 2 additions & 1 deletion src/Capacitor.App/Views/MarkdownFlavor.cs
Original file line number Diff line number Diff line change
@@ -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 }
6 changes: 5 additions & 1 deletion src/Capacitor.App/Views/MarkdownView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
77 changes: 77 additions & 0 deletions test/Capacitor.App.Tests.Unit/MarkdownViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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&amp; <b>x</b> [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", "---", "***",
"&amp; <b>x</b> [label](l.example)", "indented", "bold em struck code https://u.example"));
await Assert.That(Spans<Bold>(paragraph.Inlines!).Count()).IsEqualTo(1);
await Assert.That(Spans<Italic>(paragraph.Inlines!).Count()).IsEqualTo(1);
await Assert.That(Spans<Run>(paragraph.Inlines!).Any(r => r.Text == "code" && r.Classes.Contains("markdown-code-inline"))).IsTrue();
await Assert.That(Spans<MarkdownHyperlink>(paragraph.Inlines!).Select(l => l.NavigateUri!.ToString()))
.IsEquivalentTo(["https://u.example/"]);
await Assert.That(All<Separator>(root).Any(s => s.Classes.Contains("markdown-thematic-break"))).IsFalse();
var code = All<Border>(root).Single(b => b.Classes.Contains("markdown-code-block"));
await Assert.That(Reads(All<TextBlock>(code).Single())).IsEqualTo("var x = 1;");
await Assert.That(All<TextBlock>(root).Count(t => t.Classes.Contains("markdown-list-marker") && t.Text == "•")).IsEqualTo(1);
var quote = All<Border>(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<Separator>(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]
Expand Down
Loading