Compare commits

...

2 commits

Author SHA1 Message Date
D. Moonfire 23a65c8674 feat: added a Markdown transformer to convert links to index paths
All checks were successful
deploy / deploy (push) Successful in 41m4s
2024-03-18 22:27:42 -05:00
D. Moonfire 55a4bbe676 feat: added parsing heading one from Markdown into a model 2024-03-18 21:12:22 -05:00
9 changed files with 608 additions and 0 deletions

View file

@ -0,0 +1,66 @@
using Markdig.Renderers.Normalize;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
namespace MfGames.Markdown;
/// <summary>
/// A transformer that goes through a Markdown document and visits each link
/// so it can be rewritten in a different format.
/// </summary>
public class RewriteLinkTransformer
{
private string output;
public RewriteLinkTransformer() { }
public RewriteLinkTransformer(Action<LinkInline> onLink)
{
this.OnLink = onLink;
}
/// <summary>
/// A callback to trigger for every link found in the document.
/// </summary>
public Action<LinkInline>? OnLink { get; set; }
/// <summary>
/// Parses the given input as Markdown, goes through and transforms all
/// the links, and then returns the modified Markdown.
/// </summary>
/// <param name="input">The input text as Markdown.</param>
/// <returns>Modified Markdown text.</returns>
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<LinkInline> linkList = document.Descendants<LinkInline>();
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;
}
}

View file

@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\MfGames.Markdown.Gemtext\MfGames.Markdown.Gemtext.csproj" />
<ProjectReference Include="..\MfGames.Markdown\MfGames.Markdown.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Gemtext\MfGames.Nitride.Gemtext.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Html\MfGames.Nitride.Html.csproj" />
<ProjectReference Include="..\MfGames.Nitride.Slugs\MfGames.Nitride.Slugs.csproj" />

View file

@ -0,0 +1,90 @@
using FluentValidation;
using Markdig.Renderers.Roundtrip;
using Markdig.Syntax;
using MfGames.Gallium;
using MfGames.Nitride.Contents;
using MfGames.Nitride.Generators;
namespace MfGames.Nitride.Markdown;
/// <summary>
/// An operation that parses the Markdown and converts the first heading one
/// into a model to include in the component. The rest of the Markdown is put
/// back as the text content of the entity.
/// </summary>
[WithProperties]
public partial class ParseMarkdownHeadingOne : IOperation
{
private readonly IValidator<ParseMarkdownHeadingOne> validator;
public ParseMarkdownHeadingOne(IValidator<ParseMarkdownHeadingOne> validator)
{
this.validator = validator;
}
/// <summary>
/// Gets or sets a callback for adding a heading to a given entity.
/// </summary>
public Func<Entity, string?, Entity>? AddModelCallback { get; set; }
/// <inheritdoc />
public IEnumerable<Entity> Run(
IEnumerable<Entity> input,
CancellationToken cancellationToken = default
)
{
this.validator.ValidateAndThrow(this);
return input.SelectManyEntity<IsMarkdown>(x => x.Select(this.Parse));
}
private string? GetText(MarkdownObject? block)
{
if (block == null)
{
return null;
}
var writer = new StringWriter();
var renderer = new RoundtripRenderer(writer);
renderer.Write(block);
return writer.ToString();
}
private Entity Parse(Entity entity)
{
// Get the text content of the file. No text, we don't do anything (but
// there is going to be text since we filtered on IsMarkdown).
string? oldText = entity.GetTextContentString();
if (oldText == null)
{
return entity;
}
// Parse the result as Markdown and pull out the heading. If we can't
// find one, then we just return the entity. We need to track trivia
// because we are round-tripping back to Markdown.
MarkdownDocument document = Markdig.Markdown.Parse(oldText, true);
Block? block = document.FirstOrDefault(block => block is HeadingBlock);
if (block is not HeadingBlock { Level: 1 } heading)
{
return entity;
}
string? headingText = this.GetText(heading.Inline);
// Convert the heading into the model.
// Pull out the heading so we can write the rest back.
document.Remove(heading);
string newText = this.GetText(document)!;
// Allow the extending class to add the model to the entity and then
// set the text content to the new value before returning the results.
return this.AddModelCallback!.Invoke(entity, headingText).SetTextContent(newText);
}
}

View file

@ -0,0 +1,55 @@
using System.Text.RegularExpressions;
using Markdig.Syntax.Inlines;
using MfGames.Gallium;
namespace MfGames.Nitride.Markdown;
/// <summary>
/// An operation that rewrites link paths to be directory indexes.
/// </summary>
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;
}
/// <inheritdoc />
public IEnumerable<Entity> Run(
IEnumerable<Entity> 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, "/");
}
}
}

View file

@ -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;
/// <summary>
/// An operation that turns rewrites Markdown links with a variable callback.
/// </summary>
[WithProperties]
public partial class RewriteMarkdownLink : IOperation
{
private readonly RewriteLinkTransformer transformer;
public RewriteMarkdownLink()
{
this.transformer = new RewriteLinkTransformer();
}
public Action<LinkInline>? OnLinkCallback
{
get => this.transformer.OnLink;
set => this.transformer.OnLink = value;
}
/// <inheritdoc />
public IEnumerable<Entity> Run(
IEnumerable<Entity> input,
CancellationToken cancellationToken = default
)
{
return input.SelectManyEntity<IsMarkdown>(x => x.Select(this.Transform));
}
/// <summary>
/// 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.
/// </summary>
private Entity Transform(Entity entity)
{
string input = entity.GetTextContentString()!;
string output = this.transformer.Transform(input)!;
return entity.SetTextContent(output);
}
}

View file

@ -0,0 +1,11 @@
using FluentValidation;
namespace MfGames.Nitride.Markdown.Validators;
public class ParseMarkdownHeadingOneValidator : AbstractValidator<ParseMarkdownHeadingOne>
{
public ParseMarkdownHeadingOneValidator()
{
this.RuleFor(x => x.AddModelCallback).NotNull();
}
}

View file

@ -0,0 +1,79 @@
using MfGames.TestSetup;
using Xunit;
using Xunit.Abstractions;
namespace MfGames.Markdown.Tests;
/// <summary>
/// Tests the functionality of RewriteLinkTransformer.
/// </summary>
public class RewriteLinkTransformerTests : TestBase<TestContext>
{
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);
}
}

View file

@ -0,0 +1,154 @@
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;
/// <summary>
/// Tests the functionality of the ParseMarkdownHeadingOne operation.
/// </summary>
public class ParseMarkdownHeadingOneTests : TestBase<MarkdownTestContext>
{
public ParseMarkdownHeadingOneTests(ITestOutputHelper output)
: base(output) { }
[Fact]
public void ParseComplexHeader()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new()
{
new Entity()
.Set(IsMarkdown.Instance)
.SetTextContent("# Heading [One](/test)\n\nContent\nsecond\n\nline"),
};
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("Content\nsecond\n\nline", content);
Assert.Equal("Heading [One](/test)", first.Get<string>());
}
[Fact]
public void ParseEmptyHeader()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new() { new Entity().Set(IsMarkdown.Instance).SetTextContent("#\nContent"), };
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("Content", content);
Assert.Equal("", first.Get<string>());
}
[Fact]
public void ParseHeaderTwo()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new()
{
new Entity().Set(IsMarkdown.Instance).SetTextContent("## Heading Two\nContent"),
};
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("## Heading Two\nContent", content);
Assert.False(first.Has<string>());
}
[Fact]
public void ParseMultipleHeaderOne()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new()
{
new Entity()
.Set(IsMarkdown.Instance)
.SetTextContent("# Heading One\n\n# Heading Two\n Content"),
};
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("# Heading Two\n Content", content);
Assert.Equal("Heading One", first.Get<string>());
}
[Fact]
public void ParseNoHeader()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new() { new Entity().Set(IsMarkdown.Instance).SetTextContent("Content"), };
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("Content", content);
Assert.False(first.Has<string>());
}
[Fact]
public void ParseSimpleHeader()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new()
{
new Entity().Set(IsMarkdown.Instance).SetTextContent("# Heading One\n\nContent"),
};
ParseMarkdownHeadingOne op = context
.Resolve<ParseMarkdownHeadingOne>()
.WithAddModelCallback((entity, heading) => entity.Set(heading));
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("Content", content);
Assert.Equal("Heading One", first.Get<string>());
}
}

View file

@ -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;
/// <summary>
/// Tests the functionality of the RewriteLinkToIndexPath operation.
/// </summary>
public class RewriteLinkToIndexPathTests : TestBase<MarkdownTestContext>
{
/// <inheritdoc />
public RewriteLinkToIndexPathTests(ITestOutputHelper output)
: base(output) { }
[Fact]
public void NoRewriteSimpleIndexPhp()
{
using MarkdownTestContext context = this.CreateContext();
List<Entity> input =
new()
{
new Entity().Set(IsMarkdown.Instance).SetTextContent("[Link Title](/index.php)"),
};
var op = context.Resolve<RewriteLinkToIndexPath>();
IEnumerable<Entity> 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<Entity> input =
new()
{
new Entity()
.Set(IsMarkdown.Instance)
.SetTextContent("[Link Title](./bob/index.md)"),
};
var op = context.Resolve<RewriteLinkToIndexPath>();
IEnumerable<Entity> 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<Entity> input =
new()
{
new Entity()
.Set(IsMarkdown.Instance)
.SetTextContent("[Link Title](https://example.org/index.md)"),
};
var op = context.Resolve<RewriteLinkToIndexPath>();
IEnumerable<Entity> 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<Entity> input =
new()
{
new Entity().Set(IsMarkdown.Instance).SetTextContent("[Link Title](/index.md)"),
};
var op = context.Resolve<RewriteLinkToIndexPath>();
IEnumerable<Entity> output = op.Run(input);
Entity first = output.First();
string content = first.GetTextContentString()!.Trim();
Assert.Equal("[Link Title](/)", content);
}
}