diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5633101..aa5595d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,6 +21,9 @@ jobs: - name: Build run: dotnet build md2loop/md2loop.csproj -c Release -r win-x64 + - name: Test + run: dotnet test tests/md2loop.Tests/md2loop.Tests.csproj -c Release + - name: Publish (x64) run: | dotnet publish md2loop/md2loop.csproj ` diff --git a/.gitignore b/.gitignore index b9d6e5d..4f09db4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # Build outputs +bin/ +obj/ dist/ publish/ diff --git a/README.md b/README.md index 4ee86a9..9327848 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,20 @@ Works the other way too — copy rich text from Loop or Office and click **Markd Contributions are welcome! Please open an issue or PR. +### Project layout + +| Project | Purpose | +| --- | --- | +| `md2loop` | WinUI 3 app: window, clipboard access, UI | +| `md2loop.Core` | Conversion logic, with no UI or clipboard dependencies | +| `tests/md2loop.Tests` | Unit tests for `md2loop.Core` | + +### Running the tests + +```powershell +dotnet test tests/md2loop.Tests/md2loop.Tests.csproj +``` + ## License MIT — see [LICENSE](LICENSE) for details. diff --git a/md2loop/ClipboardContentDetector.cs b/md2loop.Core/ClipboardContentDetector.cs similarity index 91% rename from md2loop/ClipboardContentDetector.cs rename to md2loop.Core/ClipboardContentDetector.cs index 9c09530..c00eab3 100644 --- a/md2loop/ClipboardContentDetector.cs +++ b/md2loop.Core/ClipboardContentDetector.cs @@ -65,8 +65,6 @@ private static int GetMarkdownScore(string? text) return 0; var trimmed = text.Trim(); - if (ContainsHTML(trimmed)) - return 0; var lines = trimmed.ReplaceLineEndings("\n").Split('\n'); var score = 0; @@ -87,9 +85,24 @@ private static int GetMarkdownScore(string? text) if (ItalicRegex().IsMatch(trimmed)) score += 3; if (StrikethroughRegex().IsMatch(trimmed)) score += 3; + // Markdown may legitimately embed raw HTML, and technical writing often + // mentions tags inside code. Only markup outside code counts against the + // text, and it weighs against the other signals rather than vetoing them. + if (ContainsHTML(RemoveCode(trimmed))) score -= 6; + return score; } + /// + /// Strips fenced blocks and inline code spans, where angle brackets are + /// content rather than markup. + /// + private static string RemoveCode(string text) + { + var withoutFences = FencedBlockRegex().Replace(text, "\n"); + return InlineCodeRegex().Replace(withoutFences, " "); + } + private static int GetRichTextScore(string html) { var score = 0; @@ -168,6 +181,9 @@ private static bool HasMarkdownTable(string[] lines) [GeneratedRegex(@"`[^`\n]+`")] private static partial Regex InlineCodeRegex(); + [GeneratedRegex(@"(```|~~~)[\s\S]*?\1")] + private static partial Regex FencedBlockRegex(); + [GeneratedRegex(@"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")] private static partial Regex BoldRegex(); diff --git a/md2loop/HtmlToMarkdownConverter.cs b/md2loop.Core/HtmlToMarkdownConverter.cs similarity index 100% rename from md2loop/HtmlToMarkdownConverter.cs rename to md2loop.Core/HtmlToMarkdownConverter.cs diff --git a/md2loop.Core/LoopHtmlConverter.cs b/md2loop.Core/LoopHtmlConverter.cs new file mode 100644 index 0000000..ac43eb5 --- /dev/null +++ b/md2loop.Core/LoopHtmlConverter.cs @@ -0,0 +1,61 @@ +using Markdig; +using Markdig.Extensions.TaskLists; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using System.Text.RegularExpressions; + +namespace md2loop; + +/// +/// Converts Markdown to Loop-optimized HTML (minimal, no CSS classes, Unicode checkboxes). +/// +public static partial class LoopHtmlConverter +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + public static string Convert(string markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + var document = Markdown.Parse(markdown, Pipeline); + + using var writer = new StringWriter(); + var renderer = new HtmlRenderer(writer) + { + ImplicitParagraph = false, + EnableHtmlForBlock = true, + EnableHtmlForInline = true + }; + Pipeline.Setup(renderer); + + // Loop does not render , so task list items are + // written as Unicode checkboxes instead. + renderer.ObjectRenderers.Replace(new UnicodeTaskListRenderer()); + + renderer.Render(document); + writer.Flush(); + + var html = writer.ToString(); + + // Remove the CSS classes Markdig adds, except the language-* hint on + // fenced code blocks: that is the only record of the code language, and + // dropping it means a round-trip back to Markdown loses it. + html = NonLanguageClassRegex().Replace(html, ""); + + return html.Trim(); + } + + [GeneratedRegex(@"\s*class=""(?!language-)[^""]*""")] + private static partial Regex NonLanguageClassRegex(); + + private sealed class UnicodeTaskListRenderer : HtmlObjectRenderer + { + protected override void Write(HtmlRenderer renderer, TaskList obj) + { + renderer.Write(obj.Checked ? "☑" : "☐"); + } + } +} diff --git a/md2loop/RtfToMarkdownConverter.cs b/md2loop.Core/RtfToMarkdownConverter.cs similarity index 100% rename from md2loop/RtfToMarkdownConverter.cs rename to md2loop.Core/RtfToMarkdownConverter.cs diff --git a/md2loop.Core/md2loop.Core.csproj b/md2loop.Core/md2loop.Core.csproj new file mode 100644 index 0000000..c154e02 --- /dev/null +++ b/md2loop.Core/md2loop.Core.csproj @@ -0,0 +1,23 @@ + + + + + net8.0 + md2loop + md2loop.Core + enable + enable + + + + + + + + + + diff --git a/md2loop/LoopHtmlConverter.cs b/md2loop/LoopHtmlConverter.cs deleted file mode 100644 index 890f8c2..0000000 --- a/md2loop/LoopHtmlConverter.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Markdig; -using Markdig.Renderers; -using Markdig.Renderers.Html; -using Markdig.Syntax; -using Markdig.Syntax.Inlines; - -namespace md2loop; - -/// -/// Converts Markdown to Loop-optimized HTML (minimal, no CSS classes, Unicode checkboxes). -/// -public static class LoopHtmlConverter -{ - private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() - .UseAdvancedExtensions() - .Build(); - - public static string Convert(string markdown) - { - if (string.IsNullOrWhiteSpace(markdown)) - return string.Empty; - - var document = Markdown.Parse(markdown, Pipeline); - - // Transform task list items to use Unicode checkboxes - TransformTaskLists(document); - - using var writer = new StringWriter(); - var renderer = new HtmlRenderer(writer) - { - ImplicitParagraph = false, - EnableHtmlForBlock = true, - EnableHtmlForInline = true - }; - Pipeline.Setup(renderer); - renderer.Render(document); - writer.Flush(); - - var html = writer.ToString(); - - // Post-process: replace checkbox inputs with Unicode chars for Loop compatibility - html = html.Replace(" ", "☑ "); - html = html.Replace(" ", "☐ "); - - // Remove CSS classes that Markdig adds - html = System.Text.RegularExpressions.Regex.Replace(html, @"\s*class=""[^""]*""", ""); - - return html.Trim(); - } - - private static void TransformTaskLists(MarkdownDocument document) - { - foreach (var block in document.Descendants()) - { - if (block is not ListItemBlock listItem) continue; - if (listItem.Count == 0) continue; - - var firstBlock = listItem[0]; - if (firstBlock is not ParagraphBlock paragraph) continue; - if (paragraph.Inline?.FirstChild is not LiteralInline literal) continue; - - var content = literal.Content.ToString(); - if (content.StartsWith("[x] ") || content.StartsWith("[X] ")) - { - literal.Content = new Markdig.Helpers.StringSlice("☑ " + content[4..]); - } - else if (content.StartsWith("[ ] ")) - { - literal.Content = new Markdig.Helpers.StringSlice("☐ " + content[4..]); - } - } - } -} diff --git a/md2loop/md2loop.csproj b/md2loop/md2loop.csproj index 07149ae..6e484b2 100644 --- a/md2loop/md2loop.csproj +++ b/md2loop/md2loop.csproj @@ -54,10 +54,11 @@ - - - - + + + + + diff --git a/tests/md2loop.Tests/ClipboardContentDetectorTests.cs b/tests/md2loop.Tests/ClipboardContentDetectorTests.cs new file mode 100644 index 0000000..e8510fa --- /dev/null +++ b/tests/md2loop.Tests/ClipboardContentDetectorTests.cs @@ -0,0 +1,117 @@ +using md2loop; + +namespace md2loop.Tests; + +public class ClipboardContentDetectorTests +{ + [Fact] + public void Detect_ReturnsUnknown_WhenClipboardIsEmpty() + { + Assert.Equal(ClipboardMode.Unknown, ClipboardContentDetector.Detect(null, null, null)); + Assert.Equal(ClipboardMode.Unknown, ClipboardContentDetector.Detect(" ", "", "")); + } + + [Fact] + public void Detect_TreatsPlainTextAsMarkdown() + { + Assert.Equal(ClipboardMode.Markdown, ClipboardContentDetector.Detect("just a sentence", null)); + } + + [Fact] + public void Detect_TreatsHtmlOnlyAsRichText() + { + Assert.Equal(ClipboardMode.RichText, ClipboardContentDetector.Detect(null, "

hello

")); + } + + [Fact] + public void Detect_TreatsRtfAsRichText() + { + Assert.Equal(ClipboardMode.RichText, ClipboardContentDetector.Detect(null, null, @"{\rtf1\ansi hello}")); + } + + [Fact] + public void Detect_PrefersRichText_WhenHtmlCarriesRealStructure() + { + // A genuine rich text copy: the plain text flavour is just the stripped text. + const string text = "Quarterly results\nRevenue grew."; + const string html = "

Quarterly results

Revenue grew.

"; + + Assert.Equal(ClipboardMode.RichText, ClipboardContentDetector.Detect(text, html)); + } + + [Fact] + public void Detect_PrefersMarkdown_WhenTextHasStrongerMarkdownSignals() + { + // Editors publish syntax-coloured HTML next to the original Markdown. + const string text = "# Title\n\n- one\n- two\n\n`code`"; + const string html = "
# Title
- one
"; + + Assert.Equal(ClipboardMode.Markdown, ClipboardContentDetector.Detect(text, html)); + } + + // Regression test for #14. Markdown that talks about HTML used to be vetoed + // outright and misreported as rich text. + [Fact] + public void Detect_ReturnsMarkdown_WhenMarkdownMentionsHtmlInsideCode() + { + const string text = """ + # Embedding video + + Use the `