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
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Build outputs
bin/
obj/
dist/
publish/

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/// <summary>
/// Strips fenced blocks and inline code spans, where angle brackets are
/// content rather than markup.
/// </summary>
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;
Expand Down Expand Up @@ -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();

Expand Down
61 changes: 61 additions & 0 deletions md2loop.Core/LoopHtmlConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Markdig;
using Markdig.Extensions.TaskLists;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using System.Text.RegularExpressions;

namespace md2loop;

/// <summary>
/// Converts Markdown to Loop-optimized HTML (minimal, no CSS classes, Unicode checkboxes).
/// </summary>
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 <input type="checkbox">, so task list items are
// written as Unicode checkboxes instead.
renderer.ObjectRenderers.Replace<HtmlTaskListRenderer>(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<TaskList>
{
protected override void Write(HtmlRenderer renderer, TaskList obj)
{
renderer.Write(obj.Checked ? "☑" : "☐");
}
}
}
23 changes: 23 additions & 0 deletions md2loop.Core/md2loop.Core.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
The conversion logic lives here rather than in the app project so it can be
referenced by unit tests. Nothing in this assembly touches WinUI or the
clipboard, so it builds and runs as plain .NET.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>md2loop</RootNamespace>
<AssemblyName>md2loop.Core</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
<PackageReference Include="RtfPipe" Version="2.0.7677.4303" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
</ItemGroup>

</Project>
73 changes: 0 additions & 73 deletions md2loop/LoopHtmlConverter.cs

This file was deleted.

9 changes: 5 additions & 4 deletions md2loop/md2loop.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,11 @@
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.3.2" />
<PackageReference Include="Markdig" Version="0.38.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
<PackageReference Include="RtfPipe" Version="2.0.7677.4303" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
<!-- Conversion logic, kept in a separate assembly so it can be unit tested. -->
<ProjectReference Include="..\md2loop.Core\md2loop.Core.csproj" />
</ItemGroup>

<ItemGroup>
Expand Down
117 changes: 117 additions & 0 deletions tests/md2loop.Tests/ClipboardContentDetectorTests.cs
Original file line number Diff line number Diff line change
@@ -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, "<p>hello</p>"));
}

[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 = "<h1>Quarterly results</h1><p>Revenue grew.</p>";

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 = "<div><span>#</span> Title</div><div>- one</div>";

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 `<video>` element:

```html
<video src="clip.mp4"></video>
```

- Works everywhere
- No plugin needed
""";
const string html = "<div><span>#</span> Embedding video</div>";

Assert.Equal(ClipboardMode.Markdown, ClipboardContentDetector.Detect(text, html));
}

[Fact]
public void Detect_StillPrefersRichText_WhenMarkupIsOutsideCode()
{
// Raw markup outside code counts against the text rather than vetoing it,
// so genuine HTML still wins.
const string text = "<p>Some <b>content</b></p>";
const string html = "<p>Some <b>content</b></p>";

Assert.Equal(ClipboardMode.RichText, ClipboardContentDetector.Detect(text, html));
}

[Theory]
[InlineData("# Heading\n\nSome text\n\n- a\n- b", true)]
[InlineData("- [x] done\n- [ ] todo", true)]
[InlineData("Here is `code` and a [link](http://x) and **bold**", true)]
[InlineData("Just an ordinary sentence without syntax.", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsMarkdown_ScoresTextCorrectly(string? text, bool expected)
{
Assert.Equal(expected, ClipboardContentDetector.IsMarkdown(text));
}

[Theory]
[InlineData(@"{\rtf1\ansi hello}", true)]
[InlineData(" {\\rtf1 padded}", true)]
[InlineData("not rtf", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void ContainsRTF_RecognisesTheRtfHeader(string? rtf, bool expected)
{
Assert.Equal(expected, ClipboardContentDetector.ContainsRTF(rtf));
}

[Theory]
[InlineData("<p>hi</p>", true)]
[InlineData("<table><tr><td>x</td></tr></table>", true)]
[InlineData("plain text", false)]
[InlineData("2 < 3 and 4 > 1", false)]
public void ContainsHTML_DetectsKnownTags(string html, bool expected)
{
Assert.Equal(expected, ClipboardContentDetector.ContainsHTML(html));
}
}
Loading