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}}}}}";
+ }
+ }
+}