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 `