From 55a4bbe676e598852d916368e649fa716863c220 Mon Sep 17 00:00:00 2001 From: "D. Moonfire" Date: Mon, 18 Mar 2024 21:12:22 -0500 Subject: [PATCH] feat: added parsing heading one from Markdown into a model --- .../ParseMarkdownHeadingOne.cs | 88 ++++++++++ .../ParseMarkdownHeadingOneValidator.cs | 11 ++ .../ParseMarkdownHeadingOneTests.cs | 154 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs create mode 100644 src/MfGames.Nitride.Markdown/Validators/ParseMarkdownHeadingOneValidator.cs create mode 100644 tests/MfGames.Nitride.Markdown.Tests/ParseMarkdownHeadingOneTests.cs diff --git a/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs b/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs new file mode 100644 index 0000000..73d6a14 --- /dev/null +++ b/src/MfGames.Nitride.Markdown/ParseMarkdownHeadingOne.cs @@ -0,0 +1,88 @@ +using FluentValidation; +using Markdig.Renderers.Roundtrip; +using Markdig.Syntax; +using MfGames.Gallium; +using MfGames.Nitride.Contents; +using MfGames.Nitride.Generators; + +namespace MfGames.Nitride.Markdown; + +/// +/// 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. +/// +[WithProperties] +public partial class ParseMarkdownHeadingOne : IOperation +{ + private readonly IValidator validator; + + public ParseMarkdownHeadingOne(IValidator validator) + { + this.validator = validator; + } + + /// + /// Gets or sets a callback for adding a heading to a given entity. + /// + public Func? AddModelCallback { get; set; } + + /// + public IEnumerable Run( + IEnumerable input, + CancellationToken cancellationToken = default + ) + { + this.validator.ValidateAndThrow(this); + + return input.SelectManyEntity(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); + } +} diff --git a/src/MfGames.Nitride.Markdown/Validators/ParseMarkdownHeadingOneValidator.cs b/src/MfGames.Nitride.Markdown/Validators/ParseMarkdownHeadingOneValidator.cs new file mode 100644 index 0000000..73c8aad --- /dev/null +++ b/src/MfGames.Nitride.Markdown/Validators/ParseMarkdownHeadingOneValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace MfGames.Nitride.Markdown.Validators; + +public class ParseMarkdownHeadingOneValidator : AbstractValidator +{ + public ParseMarkdownHeadingOneValidator() + { + this.RuleFor(x => x.AddModelCallback).NotNull(); + } +} diff --git a/tests/MfGames.Nitride.Markdown.Tests/ParseMarkdownHeadingOneTests.cs b/tests/MfGames.Nitride.Markdown.Tests/ParseMarkdownHeadingOneTests.cs new file mode 100644 index 0000000..1dbd695 --- /dev/null +++ b/tests/MfGames.Nitride.Markdown.Tests/ParseMarkdownHeadingOneTests.cs @@ -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; + +/// +/// Tests the functionality of the ParseMarkdownHeadingOne operation. +/// +public class ParseMarkdownHeadingOneTests : TestBase +{ + public ParseMarkdownHeadingOneTests(ITestOutputHelper output) + : base(output) { } + + [Fact] + public void ParseComplexHeader() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity() + .Set(IsMarkdown.Instance) + .SetTextContent("# Heading [One](/test)\n\nContent\nsecond\n\nline"), + }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable 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()); + } + + [Fact] + public void ParseEmptyHeader() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() { new Entity().Set(IsMarkdown.Instance).SetTextContent("#\nContent"), }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("Content", content); + Assert.Equal("", first.Get()); + } + + [Fact] + public void ParseHeaderTwo() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity().Set(IsMarkdown.Instance).SetTextContent("## Heading Two\nContent"), + }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("## Heading Two\nContent", content); + Assert.False(first.Has()); + } + + [Fact] + public void ParseMultipleHeaderOne() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity() + .Set(IsMarkdown.Instance) + .SetTextContent("# Heading One\n\n# Heading Two\n Content"), + }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable 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()); + } + + [Fact] + public void ParseNoHeader() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() { new Entity().Set(IsMarkdown.Instance).SetTextContent("Content"), }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("Content", content); + Assert.False(first.Has()); + } + + [Fact] + public void ParseSimpleHeader() + { + using MarkdownTestContext context = this.CreateContext(); + + List input = + new() + { + new Entity().Set(IsMarkdown.Instance).SetTextContent("# Heading One\n\nContent"), + }; + + ParseMarkdownHeadingOne op = context + .Resolve() + .WithAddModelCallback((entity, heading) => entity.Set(heading)); + + IEnumerable output = op.Run(input); + Entity first = output.First(); + string content = first.GetTextContentString()!.Trim(); + + Assert.Equal("Content", content); + Assert.Equal("Heading One", first.Get()); + } +}