From 23a65c86744a2a0c73117791c197f41a690f95ab Mon Sep 17 00:00:00 2001 From: "D. Moonfire" Date: Mon, 18 Mar 2024 22:27:42 -0500 Subject: [PATCH] feat: added a Markdown transformer to convert links to index paths --- .../RewriteLinkTransformer.cs | 66 +++++++++++ .../MfGames.Nitride.Markdown.csproj | 1 + .../ParseMarkdownHeadingOne.cs | 2 + .../RewriteLinkToIndexPath.cs | 55 ++++++++++ .../RewriteMarkdownLink.cs | 49 +++++++++ .../RewriteLinkTransformer.cs | 79 ++++++++++++++ .../RewriteLinkToIndexPathTests.cs | 103 ++++++++++++++++++ 7 files changed, 355 insertions(+) create mode 100644 src/MfGames.Markdown/RewriteLinkTransformer.cs create mode 100644 src/MfGames.Nitride.Markdown/RewriteLinkToIndexPath.cs create mode 100644 src/MfGames.Nitride.Markdown/RewriteMarkdownLink.cs create mode 100644 tests/MfGames.Markdown.Tests/RewriteLinkTransformer.cs create mode 100644 tests/MfGames.Nitride.Markdown.Tests/RewriteLinkToIndexPathTests.cs diff --git a/src/MfGames.Markdown/RewriteLinkTransformer.cs b/src/MfGames.Markdown/RewriteLinkTransformer.cs new file mode 100644 index 0000000..6e15044 --- /dev/null +++ b/src/MfGames.Markdown/RewriteLinkTransformer.cs @@ -0,0 +1,66 @@ +using Markdig.Renderers.Normalize; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace MfGames.Markdown; + +/// +/// A transformer that goes through a Markdown document and visits each link +/// so it can be rewritten in a different format. +/// +public class RewriteLinkTransformer +{ + private string output; + + public RewriteLinkTransformer() { } + + public RewriteLinkTransformer(Action onLink) + { + this.OnLink = onLink; + } + + /// + /// A callback to trigger for every link found in the document. + /// + public Action? OnLink { get; set; } + + /// + /// Parses the given input as Markdown, goes through and transforms all + /// the links, and then returns the modified Markdown. + /// + /// The input text as Markdown. + /// Modified Markdown text. + public string? Transform(string? input) + { + // If we get a null or blank string, we return it. + if (string.IsNullOrWhiteSpace(input) || this.OnLink == null) + { + return input; + } + + // Parse the Markdown into an abstract syntax tree (AST). We need the + // trivia because we want to round-trip as much as possible and it + // contains things like extra whitespace or indention. + MarkdownDocument document = Markdig.Markdown.Parse(input, true); + + // Go through all the links. + IEnumerable linkList = document.Descendants(); + + foreach (LinkInline oldLink in linkList) + { + this.OnLink.Invoke(oldLink); + } + + // Convert the AST back into Markdown and return the results. The + // RoundtripRenderer doesn't work here, but NormalizeRenderer seems to + // allow us to modify the link above and get the results. + var writer = new StringWriter(); + var renderer = new NormalizeRenderer(writer); + + renderer.Write(document); + + this.output = writer.ToString(); + + return this.output; + } +} diff --git a/src/MfGames.Nitride.Markdown/MfGames.Nitride.Markdown.csproj b/src/MfGames.Nitride.Markdown/MfGames.Nitride.Markdown.csproj index 59be0fe..970cf0f 100644 --- a/src/MfGames.Nitride.Markdown/MfGames.Nitride.Markdown.csproj +++ b/src/MfGames.Nitride.Markdown/MfGames.Nitride.Markdown.csproj @@ -17,6 +17,7 @@ + diff --git a/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs b/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs index 73d6a14..5139511 100644 --- a/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs +++ b/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs @@ -41,7 +41,9 @@ public partial class ParseMarkdownHeadingOne : IOperation private string? GetText(MarkdownObject? block) { if (block == null) + { return null; + } var writer = new StringWriter(); var renderer = new RoundtripRenderer(writer); diff --git a/src/MfGames.Nitride.Markdown/RewriteLinkToIndexPath.cs b/src/MfGames.Nitride.Markdown/RewriteLinkToIndexPath.cs new file mode 100644 index 0000000..2ab823f --- /dev/null +++ b/src/MfGames.Nitride.Markdown/RewriteLinkToIndexPath.cs @@ -0,0 +1,55 @@ +using System.Text.RegularExpressions; +using Markdig.Syntax.Inlines; +using MfGames.Gallium; + +namespace MfGames.Nitride.Markdown; + +/// +/// An operation that rewrites link paths to be directory indexes. +/// +public class RewriteLinkToIndexPath : IOperation +{ + private static readonly Regex IndexRegex = new(@"/index\.(markdown|md|html|htm)$"); + + private readonly RewriteMarkdownLink rewrite; + + public RewriteLinkToIndexPath(RewriteMarkdownLink rewrite) + { + this.rewrite = rewrite; + this.rewrite.OnLinkCallback = this.OnLinkCallback; + } + + /// + public IEnumerable Run( + IEnumerable input, + CancellationToken cancellationToken = default + ) + { + return this.rewrite.Run(input, cancellationToken); + } + + private void OnLinkCallback(LinkInline link) + { + // Ignore blank links (they could happen). + string? url = link.Url; + + if (string.IsNullOrEmpty(url)) + { + return; + } + + // We only want links that start with a period or are relative to the + // current directory. Ideally, we don't want links that are remote URLs. + // This is a simplistic version. + if (url.Contains("://")) + { + return; + } + + // Check to see what the path ends with something we can map. + if (IndexRegex.IsMatch(url)) + { + link.Url = IndexRegex.Replace(url, "/"); + } + } +} diff --git a/src/MfGames.Nitride.Markdown/RewriteMarkdownLink.cs b/src/MfGames.Nitride.Markdown/RewriteMarkdownLink.cs new file mode 100644 index 0000000..86ed0b3 --- /dev/null +++ b/src/MfGames.Nitride.Markdown/RewriteMarkdownLink.cs @@ -0,0 +1,49 @@ +using Markdig.Syntax.Inlines; +using MfGames.Gallium; +using MfGames.Markdown; +using MfGames.Nitride.Contents; +using MfGames.Nitride.Generators; + +namespace MfGames.Nitride.Markdown; + +/// +/// An operation that turns rewrites Markdown links with a variable callback. +/// +[WithProperties] +public partial class RewriteMarkdownLink : IOperation +{ + private readonly RewriteLinkTransformer transformer; + + public RewriteMarkdownLink() + { + this.transformer = new RewriteLinkTransformer(); + } + + public Action? OnLinkCallback + { + get => this.transformer.OnLink; + set => this.transformer.OnLink = value; + } + + /// + public IEnumerable Run( + IEnumerable input, + CancellationToken cancellationToken = default + ) + { + return input.SelectManyEntity(x => x.Select(this.Transform)); + } + + /// + /// This turns all links that start with a link into a single link while + /// removing all trailing links within the line. This is to simplify the + /// rendering of the link on page. + /// + private Entity Transform(Entity entity) + { + string input = entity.GetTextContentString()!; + string output = this.transformer.Transform(input)!; + + return entity.SetTextContent(output); + } +} diff --git a/tests/MfGames.Markdown.Tests/RewriteLinkTransformer.cs b/tests/MfGames.Markdown.Tests/RewriteLinkTransformer.cs new file mode 100644 index 0000000..8c9de69 --- /dev/null +++ b/tests/MfGames.Markdown.Tests/RewriteLinkTransformer.cs @@ -0,0 +1,79 @@ +using MfGames.TestSetup; +using Xunit; +using Xunit.Abstractions; + +namespace MfGames.Markdown.Tests; + +/// +/// Tests the functionality of RewriteLinkTransformer. +/// +public class RewriteLinkTransformerTests : TestBase +{ + public RewriteLinkTransformerTests(ITestOutputHelper output) + : base(output) { } + + [Fact] + public void ComplexExample() + { + string input = string.Join( + "\n", + "# A [Heading](/heading/index.md)", + "", + "That includes many [not-changing](https://example.org) examples", + "and ones that [will change](/gary/index.md).", + "", + "No one knows how this [won't change](/favicon.ico).", + "" + ); + + string expected = string.Join( + "\n", + "# A [Heading](/heading/)", + "", + "That includes many [not-changing](https://example.org) examples", + "and ones that [will change](/gary/).", + "", + "", + "No one knows how this [won't change](/favicon.ico).", + "" + ); + + RewriteLinkTransformer transformer = + new((link) => link.Url = link.Url?.Replace("/index.md", "/")); + + string? output = transformer.Transform(input); + + Assert.Equal(expected, output); + } + + [Fact] + public void HandleLink() + { + const string Input = "[Link Title](/link/index.md)"; + + RewriteLinkTransformer transformer = + new((link) => link.Url = link.Url?.Replace("/index.md", "/")); + + string? output = transformer.Transform(Input); + + Assert.Equal("[Link Title](/link/)", output); + } + + [Fact] + public void HandleNulls() + { + RewriteLinkTransformer transformer = new(); + + Assert.Null(transformer.Transform(null)); + } + + [Fact] + public void HandleText() + { + const string Input = "Content"; + RewriteLinkTransformer transformer = new(); + string? output = transformer.Transform(Input); + + Assert.Equal("Content", output); + } +} diff --git a/tests/MfGames.Nitride.Markdown.Tests/RewriteLinkToIndexPathTests.cs b/tests/MfGames.Nitride.Markdown.Tests/RewriteLinkToIndexPathTests.cs new file mode 100644 index 0000000..f1ca587 --- /dev/null +++ b/tests/MfGames.Nitride.Markdown.Tests/RewriteLinkToIndexPathTests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using MfGames.Gallium; +using MfGames.Nitride.Contents; +using MfGames.TestSetup; +using Xunit; +using Xunit.Abstractions; + +namespace MfGames.Nitride.Markdown.Tests; + +/// +/// Tests the functionality of the RewriteLinkToIndexPath operation. +/// +public class RewriteLinkToIndexPathTests : TestBase +{ + /// + public RewriteLinkToIndexPathTests(ITestOutputHelper output) + : base(output) { } + + [Fact] + public void NoRewriteSimpleIndexPhp() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity().Set(IsMarkdown.Instance).SetTextContent("[Link Title](/index.php)"), + }; + + var op = context.Resolve(); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("[Link Title](/index.php)", content); + } + + [Fact] + public void RewritePeriodSlashRelativePath() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity() + .Set(IsMarkdown.Instance) + .SetTextContent("[Link Title](./bob/index.md)"), + }; + + var op = context.Resolve(); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("[Link Title](./bob/)", content); + } + + [Fact] + public void RewriteRemoteIndex() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity() + .Set(IsMarkdown.Instance) + .SetTextContent("[Link Title](https://example.org/index.md)"), + }; + + var op = context.Resolve(); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("[Link Title](https://example.org/index.md)", content); + } + + [Fact] + public void RewriteSimpleIndexMarkdown() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity().Set(IsMarkdown.Instance).SetTextContent("[Link Title](/index.md)"), + }; + + var op = context.Resolve(); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("[Link Title](/)", content); + } +}