From 6af41cba5a0fa79ae41b78ae1ae665e7d8e19dc0 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Tue, 8 Aug 2023 21:03:41 +0100 Subject: [PATCH] feat: add support for MediaWiki-style templates --- OliverBooth/Data/Web/ArticleTemplate.cs | 82 +++++++++ .../ArticleTemplateConfiguration.cs | 19 ++ OliverBooth/Data/WebContext.cs | 13 ++ OliverBooth/DateFormatter.cs | 33 ++++ OliverBooth/Markdown/TemplateExtension.cs | 33 ++++ OliverBooth/Markdown/TemplateInline.cs | 33 ++++ OliverBooth/Markdown/TemplateInlineParser.cs | 167 ++++++++++++++++++ OliverBooth/Markdown/TemplateRenderer.cs | 28 +++ OliverBooth/OliverBooth.csproj | 1 + OliverBooth/Program.cs | 6 +- OliverBooth/Services/BlogService.cs | 10 +- OliverBooth/Services/TemplateService.cs | 69 ++++++++ 12 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 OliverBooth/Data/Web/ArticleTemplate.cs create mode 100644 OliverBooth/Data/Web/Configuration/ArticleTemplateConfiguration.cs create mode 100644 OliverBooth/DateFormatter.cs create mode 100644 OliverBooth/Markdown/TemplateExtension.cs create mode 100644 OliverBooth/Markdown/TemplateInline.cs create mode 100644 OliverBooth/Markdown/TemplateInlineParser.cs create mode 100644 OliverBooth/Markdown/TemplateRenderer.cs create mode 100644 OliverBooth/Services/TemplateService.cs diff --git a/OliverBooth/Data/Web/ArticleTemplate.cs b/OliverBooth/Data/Web/ArticleTemplate.cs new file mode 100644 index 0000000..08ce67f --- /dev/null +++ b/OliverBooth/Data/Web/ArticleTemplate.cs @@ -0,0 +1,82 @@ +using SmartFormat; +using SmartFormat.Core.Extensions; + +namespace OliverBooth.Data.Web; + +/// +/// Represents a MediaWiki-style template. +/// +public sealed class ArticleTemplate : IEquatable +{ + /// + /// Gets or sets the format string. + /// + /// The format string. + public string FormatString { get; set; } = string.Empty; + + /// + /// Gets the name of the template. + /// + public string Name { get; private set; } = string.Empty; + + /// + /// Returns a value indicating whether two instances of are equal. + /// + /// The first instance of to compare. + /// The second instance of to compare. + /// + /// if and are equal; otherwise, + /// . + /// + public static bool operator ==(ArticleTemplate? left, ArticleTemplate? right) => Equals(left, right); + + /// + /// Returns a value indicating whether two instances of are not equal. + /// + /// The first instance of to compare. + /// The second instance of to compare. + /// + /// if and are not equal; otherwise, + /// . + /// + public static bool operator !=(ArticleTemplate? left, ArticleTemplate? right) => !(left == right); + + /// + /// Returns a value indicating whether this instance of is equal to another + /// instance. + /// + /// An instance to compare with this instance. + /// + /// if is equal to this instance; otherwise, + /// . + /// + public bool Equals(ArticleTemplate? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Name == other.Name; + } + + /// + /// Returns a value indicating whether this instance is equal to a specified object. + /// + /// An object to compare with this instance. + /// + /// if is an instance of and + /// equals the value of this instance; otherwise, . + /// + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj) || obj is ArticleTemplate other && Equals(other); + } + + /// + /// Gets the hash code for this instance. + /// + /// The hash code. + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return Name.GetHashCode(); + } +} diff --git a/OliverBooth/Data/Web/Configuration/ArticleTemplateConfiguration.cs b/OliverBooth/Data/Web/Configuration/ArticleTemplateConfiguration.cs new file mode 100644 index 0000000..4de24a9 --- /dev/null +++ b/OliverBooth/Data/Web/Configuration/ArticleTemplateConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Web.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class ArticleTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ArticleTemplate"); + builder.HasKey(e => e.Name); + + builder.Property(e => e.Name).IsRequired(); + builder.Property(e => e.FormatString).IsRequired(); + } +} diff --git a/OliverBooth/Data/WebContext.cs b/OliverBooth/Data/WebContext.cs index 838664c..5688f56 100644 --- a/OliverBooth/Data/WebContext.cs +++ b/OliverBooth/Data/WebContext.cs @@ -1,4 +1,6 @@ using Microsoft.EntityFrameworkCore; +using OliverBooth.Data.Web; +using OliverBooth.Data.Web.Configuration; namespace OliverBooth.Data; @@ -9,11 +11,21 @@ public sealed class WebContext : DbContext { private readonly IConfiguration _configuration; + /// + /// Initializes a new instance of the class. + /// + /// The configuration. public WebContext(IConfiguration configuration) { _configuration = configuration; } + /// + /// Gets the set of article templates. + /// + /// The set of article templates. + public DbSet ArticleTemplates { get; private set; } = null!; + /// protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -24,5 +36,6 @@ public sealed class WebContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.ApplyConfiguration(new ArticleTemplateConfiguration()); } } diff --git a/OliverBooth/DateFormatter.cs b/OliverBooth/DateFormatter.cs new file mode 100644 index 0000000..58aa93f --- /dev/null +++ b/OliverBooth/DateFormatter.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using SmartFormat.Core.Extensions; + +namespace OliverBooth; + +/// +/// Represents a SmartFormat formatter that formats a date. +/// +internal sealed class DateFormatter : IFormatter +{ + /// + public bool CanAutoDetect { get; set; } = true; + + /// + public string Name { get; set; } = "date"; + + /// + public bool TryEvaluateFormat(IFormattingInfo formattingInfo) + { + if (formattingInfo.CurrentValue is not string value) + return false; + + if (!DateTime.TryParseExact(value, "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out DateTime date)) + return false; + + + formattingInfo.Write(date.ToString(formattingInfo.Format?.ToString())); + return true; + } +} diff --git a/OliverBooth/Markdown/TemplateExtension.cs b/OliverBooth/Markdown/TemplateExtension.cs new file mode 100644 index 0000000..3527039 --- /dev/null +++ b/OliverBooth/Markdown/TemplateExtension.cs @@ -0,0 +1,33 @@ +using Markdig; +using Markdig.Renderers; +using OliverBooth.Services; + +namespace OliverBooth.Markdown; + +/// +/// Represents a Markdown extension that adds support for MediaWiki-style templates. +/// +internal sealed class TemplateExtension : IMarkdownExtension +{ + private readonly TemplateService _templateService; + + public TemplateExtension(TemplateService templateService) + { + _templateService = templateService; + } + + /// + public void Setup(MarkdownPipelineBuilder pipeline) + { + pipeline.InlineParsers.AddIfNotAlready(); + } + + /// + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) + { + if (renderer is HtmlRenderer htmlRenderer) + { + htmlRenderer.ObjectRenderers.Add(new TemplateRenderer(_templateService)); + } + } +} diff --git a/OliverBooth/Markdown/TemplateInline.cs b/OliverBooth/Markdown/TemplateInline.cs new file mode 100644 index 0000000..62e3c30 --- /dev/null +++ b/OliverBooth/Markdown/TemplateInline.cs @@ -0,0 +1,33 @@ +using Markdig.Syntax.Inlines; + +namespace OliverBooth.Markdown; + +/// +/// Represents a Markdown inline element that represents a MediaWiki-style template. +/// +public sealed class TemplateInline : Inline +{ + /// + /// Gets the raw argument string. + /// + /// The raw argument string. + public string ArgumentString { get; set; } = string.Empty; + + /// + /// Gets the argument list. + /// + /// The argument list. + public IReadOnlyList ArgumentList { get; set; } = ArraySegment.Empty; + + /// + /// Gets the name of the template. + /// + /// The name of the template. + public string Name { get; set; } = string.Empty; + + /// + /// Gets the template parameters. + /// + /// The template parameters. + public Dictionary Params { get; set; } = new(); +} diff --git a/OliverBooth/Markdown/TemplateInlineParser.cs b/OliverBooth/Markdown/TemplateInlineParser.cs new file mode 100644 index 0000000..d0ff5d5 --- /dev/null +++ b/OliverBooth/Markdown/TemplateInlineParser.cs @@ -0,0 +1,167 @@ +using Cysharp.Text; +using Markdig.Helpers; +using Markdig.Parsers; + +namespace OliverBooth.Markdown; + +/// +/// Represents a Markdown inline parser that handles MediaWiki-style templates. +/// +public sealed class TemplateInlineParser : InlineParser +{ + /// + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + ReadOnlySpan span = slice.Text.AsSpan(); + ReadOnlySpan template = span[slice.Start..]; + + if (!template.StartsWith("{{")) + { + return false; + } + + int endIndex = template.IndexOf("}}"); + if (endIndex == -1) + { + return false; + } + + template = template[2..endIndex]; + ReadOnlySpan templateName = template; + int pipeIndex = template.IndexOf('|'); + var templateArgs = new Dictionary(); + var rawArgumentString = string.Empty; + var argumentList = new List(); + + if (pipeIndex != -1) + { + templateName = templateName[..pipeIndex]; + rawArgumentString = template[(pipeIndex + 1)..].ToString(); + + ReadOnlySpan args = template[(pipeIndex + 1)..]; + + using Utf8ValueStringBuilder keyBuilder = ZString.CreateUtf8StringBuilder(); + using Utf8ValueStringBuilder valueBuilder = ZString.CreateUtf8StringBuilder(); + + var isKey = true; + var isEscape = false; + var nestLevel = 0; + + for (var index = 0; index < args.Length; index++) + { + char current = args[index]; + var key = keyBuilder.ToString(); + var value = valueBuilder.ToString(); + + if (current == '=' && isKey && !isEscape) + { + isKey = false; + continue; + } + + if (current == '{' && index < args.Length - 1 && args[index + 1] == '{') + { + nestLevel++; + } + + if (current == '}' && index < args.Length - 1 && args[index + 1] == '}') + { + if (nestLevel == 0) + { + template = template[..(pipeIndex + 1 + index)]; + args = args[..index]; + } + else + { + nestLevel--; + } + } + + if (isKey) + { + if (current == '\'') + { + if (isEscape) + { + keyBuilder.Append('\''); + isEscape = false; + continue; + } + + isEscape = !isEscape; + + if (index == args.Length - 1) + { + argumentList.Add(isKey ? key : $"{key}={value}"); + templateArgs.Add(key, value); + } + + continue; + } + + if (current == '|' && !isEscape) + { + argumentList.Add(key); + templateArgs.Add(key, string.Empty); + keyBuilder.Clear(); + + if (index == args.Length - 1) + { + argumentList.Add(isKey ? key : $"{key}={value}"); + templateArgs.Add(key, value); + } + + continue; + } + + keyBuilder.Append(current); + + if (index == args.Length - 1) + { + argumentList.Add(isKey ? key : $"{key}={value}"); + templateArgs.Add(key, value); + } + + continue; + } + + if (current == '\'') + { + if (isEscape) + { + valueBuilder.Append('\''); + isEscape = false; + continue; + } + + isEscape = !isEscape; + continue; + } + + if (current == '|' && !isEscape) + { + argumentList.Add($"{key}={value}"); + templateArgs.Add(key, value); + keyBuilder.Clear(); + valueBuilder.Clear(); + isKey = true; + continue; + } + + valueBuilder.Append(current); + } + } + + processor.Inline = new TemplateInline + { + Name = templateName.ToString(), + Params = templateArgs, + ArgumentString = rawArgumentString, + ArgumentList = argumentList.ToArray() + }; + + slice.End = slice.Start; + slice.Start += template.Length + 4; + return true; + } +} diff --git a/OliverBooth/Markdown/TemplateRenderer.cs b/OliverBooth/Markdown/TemplateRenderer.cs new file mode 100644 index 0000000..c55048b --- /dev/null +++ b/OliverBooth/Markdown/TemplateRenderer.cs @@ -0,0 +1,28 @@ +using Markdig.Renderers; +using Markdig.Renderers.Html; +using OliverBooth.Services; + +namespace OliverBooth.Markdown; + +/// +/// Represents a Markdown object renderer that handles elements. +/// +internal sealed class TemplateRenderer : HtmlObjectRenderer +{ + private readonly TemplateService _templateService; + + /// + /// Initializes a new instance of the class. + /// + /// The . + public TemplateRenderer(TemplateService templateService) + { + _templateService = templateService; + } + + /// + protected override void Write(HtmlRenderer renderer, TemplateInline template) + { + renderer.Write(_templateService.RenderTemplate(template)); + } +} diff --git a/OliverBooth/OliverBooth.csproj b/OliverBooth/OliverBooth.csproj index 810a7a3..118aba7 100644 --- a/OliverBooth/OliverBooth.csproj +++ b/OliverBooth/OliverBooth.csproj @@ -24,6 +24,7 @@ + diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index 4771bcf..026984b 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -1,6 +1,7 @@ using Markdig; using NLog.Extensions.Logging; using OliverBooth.Data; +using OliverBooth.Markdown; using OliverBooth.Services; using X10D.Hosting.DependencyInjection; @@ -10,7 +11,9 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true); builder.Logging.ClearProviders(); builder.Logging.AddNLog(); builder.Services.AddHostedSingleton(); -builder.Services.AddSingleton(new MarkdownPipelineBuilder() +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder() .UseAbbreviations() .UseAdvancedExtensions() .UseBootstrap() @@ -23,6 +26,7 @@ builder.Services.AddSingleton(new MarkdownPipelineBuilder() .UseMathematics() .UseAutoIdentifiers() .UseAutoLinks() + .Use(new TemplateExtension(provider.GetRequiredService())) .Build()); builder.Services.AddDbContextFactory(); diff --git a/OliverBooth/Services/BlogService.cs b/OliverBooth/Services/BlogService.cs index 57386a8..8292e0f 100644 --- a/OliverBooth/Services/BlogService.cs +++ b/OliverBooth/Services/BlogService.cs @@ -43,7 +43,7 @@ public sealed class BlogService /// The processed content of the blog post. public string GetContent(BlogPost post) { - return ProcessContent(post.Body); + return RenderContent(post.Body); } /// @@ -61,7 +61,7 @@ public sealed class BlogService int moreIndex = span.IndexOf("", StringComparison.Ordinal); trimmed = moreIndex != -1 || span.Length > 256; string result = moreIndex != -1 ? span[..moreIndex].Trim().ToString() : post.Body.Truncate(256); - return ProcessContent(result); + return RenderContent(result); } /// @@ -139,8 +139,8 @@ public sealed class BlogService post = context.BlogPosts.FirstOrDefault(p => p.WordPressId == postId); return post is not null; } - - private string ProcessContent(string content) + + private string RenderContent(string content) { content = content.Replace("", string.Empty); @@ -149,6 +149,6 @@ public sealed class BlogService content = content.Replace("\n\n", "\n"); } - return Markdown.ToHtml(content.Trim(), _markdownPipeline); + return Markdig.Markdown.ToHtml(content.Trim(), _markdownPipeline); } } diff --git a/OliverBooth/Services/TemplateService.cs b/OliverBooth/Services/TemplateService.cs new file mode 100644 index 0000000..ce1732b --- /dev/null +++ b/OliverBooth/Services/TemplateService.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data; +using OliverBooth.Data.Web; +using OliverBooth.Markdown; +using SmartFormat; +using SmartFormat.Extensions; + +namespace OliverBooth.Services; + +/// +/// Represents a service that renders MediaWiki-style templates. +/// +public sealed class TemplateService +{ + private readonly IDbContextFactory _webContextFactory; + private readonly SmartFormatter _formatter; + + /// + /// Initializes a new instance of the class. + /// + /// The factory. + public TemplateService(IDbContextFactory webContextFactory) + { + _formatter = Smart.CreateDefaultSmartFormat(); + _formatter.AddExtensions(new DefaultSource()); + _formatter.AddExtensions(new ReflectionSource()); + _formatter.AddExtensions(new DateFormatter()); + + _webContextFactory = webContextFactory; + Current = this; + } + + public static TemplateService Current { get; private set; } = null!; + + /// + /// Renders the specified template with the specified arguments. + /// + /// The template to render. + /// The rendered template. + /// + /// is . + /// + public string RenderTemplate(TemplateInline templateInline) + { + if (templateInline is null) throw new ArgumentNullException(nameof(templateInline)); + using WebContext webContext = _webContextFactory.CreateDbContext(); + ArticleTemplate? template = webContext.ArticleTemplates.Find(templateInline.Name); + if (template is null) + { + return $"{{{{{templateInline.Name}}}}}"; + } + + var formatted = new + { + templateInline.ArgumentList, + templateInline.ArgumentString, + templateInline.Params, + }; + + try + { + return Markdig.Markdown.ToHtml(_formatter.Format(template.FormatString, formatted)); + } + catch + { + return $"{{{{{templateInline.Name}|{templateInline.ArgumentString}}}}}"; + } + } +}