refactor: separate Markdig extensions from project

Also introduces .Common project to house common references and types
This commit is contained in:
Oliver Booth 2024-05-05 02:18:20 +01:00
parent e0037fbff2
commit 6ec4103a3a
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
104 changed files with 932 additions and 139 deletions

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog;
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents the author of a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog;
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog;
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a comment that was posted on a legacy comment framework.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Blog;
namespace OliverBooth.Common.Data.Blog;
/// <summary>
/// Represents a user which can log in to the blog.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon;
namespace OliverBooth.Common.Data.Mastodon;
public enum AttachmentType
{

View File

@ -0,0 +1,31 @@
namespace OliverBooth.Common.Data.Mastodon;
/// <summary>
/// Represents a status on Mastodon.
/// </summary>
public interface IMastodonStatus
{
/// <summary>
/// Gets the content of the status.
/// </summary>
/// <value>The content.</value>
string Content { get; }
/// <summary>
/// Gets the date and time at which this status was posted.
/// </summary>
/// <value>The post timestamp.</value>
DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the media attachments for this status.
/// </summary>
/// <value>The media attachments.</value>
IReadOnlyList<MediaAttachment> MediaAttachments { get; }
/// <summary>
/// Gets the original URI of the status.
/// </summary>
/// <value>The original URI.</value>
Uri OriginalUri { get; }
}

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Mastodon;
namespace OliverBooth.Common.Data.Mastodon;
public sealed class MediaAttachment
{

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data;
namespace OliverBooth.Common.Data;
/// <summary>
/// An enumeration of the possible visibilities of a blog post.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents the state of a book.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents an entry in the blacklist.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a book.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a code snippet.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a programming language.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a project.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a template.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a tutorial article.

View File

@ -1,4 +1,4 @@
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents a folder for tutorial articles.

View File

@ -1,6 +1,6 @@
using System.ComponentModel;
namespace OliverBooth.Data.Web;
namespace OliverBooth.Common.Data.Web;
/// <summary>
/// Represents the status of a project.

View File

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.59"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Markdig" Version="0.36.2"/>
<PackageReference Include="ZString" Version="2.5.1"/>
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Blog;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service for managing blog posts.

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Blog;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service for managing users.

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can fetch multi-language code snippets.

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Web;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service for managing contact information.

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Mastodon;
using OliverBooth.Common.Data.Mastodon;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
public interface IMastodonService
{
@ -8,5 +8,5 @@ public interface IMastodonService
/// Gets the latest status posted to Mastodon.
/// </summary>
/// <returns>The latest status.</returns>
MastodonStatus GetLatestStatus();
IMastodonStatus GetLatestStatus();
}

View File

@ -0,0 +1,14 @@
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can perform programming language lookup.
/// </summary>
public interface IProgrammingLanguageService
{
/// <summary>
/// Returns the human-readable name of a language.
/// </summary>
/// <param name="alias">The alias of the language.</param>
/// <returns>The human-readable name, or <paramref name="alias" /> if the name could not be found.</returns>
string GetLanguageName(string alias);
}

View File

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service for interacting with projects.

View File

@ -1,6 +1,6 @@
using OliverBooth.Data.Web;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which fetches books from the reading list.

View File

@ -1,9 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Services;
namespace OliverBooth.Common.Services;
/// <summary>
/// Represents a service which can retrieve tutorial articles.

View File

@ -0,0 +1,43 @@
using Markdig.Helpers;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents a callout block.
/// </summary>
internal sealed class CalloutBlock : QuoteBlock
{
/// <summary>
/// Initializes a new instance of the <see cref="CalloutBlock" /> class.
/// </summary>
/// <param name="type">The type of the callout.</param>
public CalloutBlock(StringSlice type) : base(null)
{
Type = type;
}
/// <summary>
/// Gets or sets a value indicating whether this callout is foldable.
/// </summary>
/// <value><see langword="true" /> if this callout is foldable; otherwise, <see langword="false" />.</value>
public bool Foldable { get; set; }
/// <summary>
/// Gets or sets the title of the callout.
/// </summary>
/// <value>The title of the callout.</value>
public StringSlice Title { get; set; }
/// <summary>
/// Gets or sets the trailing whitespace trivia.
/// </summary>
/// <value>The trailing whitespace trivia.</value>
public StringSlice TrailingWhitespaceTrivia { get; set; }
/// <summary>
/// Gets or sets the type of the callout.
/// </summary>
/// <value>The type of the callout.</value>
public StringSlice Type { get; set; }
}

View File

@ -0,0 +1,32 @@
using Markdig;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Extension for adding Obsidian-style callouts to a Markdown pipeline.
/// </summary>
internal sealed class CalloutExtension : IMarkdownExtension
{
/// <inheritdoc />
public void Setup(MarkdownPipelineBuilder pipeline)
{
var parser = pipeline.InlineParsers.Find<CalloutInlineParser>();
if (parser is null)
{
pipeline.InlineParsers.InsertBefore<LinkInlineParser>(new CalloutInlineParser());
}
}
/// <inheritdoc />
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
var blockRenderer = renderer.ObjectRenderers.FindExact<CalloutRenderer>();
if (blockRenderer is null)
{
renderer.ObjectRenderers.InsertBefore<QuoteBlockRenderer>(new CalloutRenderer(pipeline));
}
}
}

View File

@ -0,0 +1,176 @@
using System.Reflection;
using Cysharp.Text;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// An inline parser for Obsidian-style callouts (<c>[!NOTE]</c> etc.)
/// </summary>
internal sealed class CalloutInlineParser : InlineParser
{
// ugly hack to access internal method
private static readonly MethodInfo ReplaceParentContainerMethod =
typeof(InlineProcessor).GetMethod("ReplaceParentContainer", BindingFlags.Instance | BindingFlags.NonPublic)!;
/// <summary>
/// Initializes a new instance of the <see cref="CalloutInlineParser" /> class.
/// </summary>
public CalloutInlineParser()
{
OpeningCharacters = ['['];
}
/// <inheritdoc />
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// We expect the alert to be the first child of a quote block. Example:
// > [!NOTE]
// > This is a note
if (processor.Block is not ParagraphBlock { Parent: QuoteBlock quoteBlock } paragraphBlock ||
paragraphBlock.Inline?.FirstChild != null)
{
return false;
}
StringSlice cache = slice;
char current = slice.NextChar();
if (current != '!')
{
slice = cache;
return false;
}
current = slice.NextChar(); // skip !
int start = slice.Start;
int end = start;
while (current.IsAlphaUpper())
{
end = slice.Start;
current = slice.NextChar();
}
if (current != ']' || start == end)
{
slice = cache;
return false;
}
var type = new StringSlice(slice.Text, start, end);
current = slice.NextChar(); // skip ]
start = slice.Start;
bool fold = false;
if (current == '-')
{
fold = true;
current = slice.NextChar(); // skip -
start = slice.Start;
}
ReadTitle(current, ref slice, out StringSlice title, out end);
var callout = new CalloutBlock(type)
{
Foldable = fold,
Span = quoteBlock.Span,
TrailingWhitespaceTrivia = new StringSlice(slice.Text, start, end),
Line = quoteBlock.Line,
Column = quoteBlock.Column,
Title = title
};
AddAttributes(callout, type);
ReplaceQuoteBlock(processor, quoteBlock, callout);
return true;
}
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, out int end)
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();
char current = startChar;
while (true)
{
if (current is not ('\0' or '\r' or '\n'))
{
builder.Append(current);
current = slice.NextChar();
continue;
}
end = slice.Start;
if (HandleCharacter(ref slice, ref end, ref current))
{
continue;
}
break;
}
title = new StringSlice(builder.ToString(), 0, builder.Length);
}
private static bool HandleCharacter(ref StringSlice slice, ref int end, ref char current)
{
switch (current)
{
case '\r':
current = slice.NextChar(); // skip \r
if (current is not ('\0' or '\n'))
{
return true;
}
end = slice.Start;
if (current == '\n')
{
slice.NextChar(); // skip \n
}
break;
case '\n':
slice.NextChar(); // skip \n
break;
}
return false;
}
private static void AddAttributes(IMarkdownObject callout, StringSlice type)
{
HtmlAttributes attributes = callout.GetAttributes();
attributes.AddClass("callout");
attributes.AddProperty("data-callout", type.AsSpan().ToString().ToLowerInvariant());
}
private static void ReplaceQuoteBlock(InlineProcessor processor, QuoteBlock quoteBlock, CalloutBlock callout)
{
ContainerBlock? parentQuoteBlock = quoteBlock.Parent;
if (parentQuoteBlock is null)
{
return;
}
int indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock);
parentQuoteBlock[indexOfQuoteBlock] = callout;
while (quoteBlock.Count > 0)
{
var block = quoteBlock[0];
quoteBlock.RemoveAt(0);
callout.Add(block);
}
ReplaceParentContainerMethod.Invoke(processor, [quoteBlock, callout]);
// ReplaceParentContainer(processor, quoteBlock, callout);
}
}

View File

@ -0,0 +1,119 @@
using HtmlAgilityPack;
using Humanizer;
using Markdig;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Callout;
/// <summary>
/// Represents an HTML renderer which renders a <see cref="CalloutBlock" />.
/// </summary>
internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
{
private readonly MarkdownPipeline _pipeline;
private static readonly Dictionary<string, string> CalloutTypes = new()
{
["NOTE"] = "pencil",
["ABSTRACT"] = "clipboard-list",
["INFO"] = "info",
["TODO"] = "circle-check",
["TIP"] = "flame",
["IMPORTANT"] = "flame",
["SUCCESS"] = "check",
["QUESTION"] = "circle-help",
["WARNING"] = "triangle-alert",
["FAILURE"] = "x",
["DANGER"] = "zap",
["BUG"] = "bug",
["EXAMPLE"] = "list",
["CITE"] = "quote",
["UPDATE"] = "calendar-check",
};
public CalloutRenderer(MarkdownPipeline pipeline)
{
_pipeline = pipeline;
}
/// <inheritdoc />
protected override void Write(HtmlRenderer renderer, CalloutBlock block)
{
renderer.EnsureLine();
if (renderer.EnableHtmlForBlock)
{
RenderAsHtml(renderer, block, _pipeline);
}
else
{
RenderAsText(renderer, block);
}
renderer.EnsureLine();
}
private static void RenderAsHtml(HtmlRenderer renderer, CalloutBlock block, MarkdownPipeline pipeline)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
Span<char> upperType = stackalloc char[type.Length];
type.ToUpperInvariant(upperType);
if (!CalloutTypes.TryGetValue(upperType.ToString(), out string? lucideClass))
{
lucideClass = "pencil";
}
var typeString = type.ToString().ToLowerInvariant();
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\"");
if (block.Foldable)
{
renderer.Write(" data-callout-fold=\"true\"");
}
renderer.Write('>');
renderer.Write("<div class=\"callout-title\"><i data-lucide=\"");
renderer.Write(lucideClass);
renderer.Write("\"></i> ");
string calloutTitle = title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title;
WriteTitle(renderer, pipeline, calloutTitle);
if (block.Foldable)
{
renderer.Write("<span class=\"callout-fold\"><i data-lucide=\"chevron-down\"></i></span>");
}
renderer.WriteLine("</div>");
renderer.Write("<div class=\"callout-content\">");
renderer.WriteChildren(block);
renderer.WriteLine("</div>");
renderer.WriteLine("</div>");
renderer.EnsureLine();
}
private static void WriteTitle(TextRendererBase renderer, MarkdownPipeline pipeline, string calloutTitle)
{
string html = global::Markdig.Markdown.ToHtml(calloutTitle, pipeline);
var document = new HtmlDocument();
document.LoadHtml(html);
if (document.DocumentNode.FirstChild is { Name: "p" } child)
{
// ugly hack to remove <p> tag generated by Markdig
document.DocumentNode.InnerHtml = child.InnerHtml;
}
document.Save(renderer.Writer);
}
private static void RenderAsText(HtmlRenderer renderer, CalloutBlock block)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
renderer.WriteLine(title.Length == 0 ? type.ToString().ToUpperInvariant() : title.ToUpperInvariant());
renderer.WriteChildren(block);
renderer.EnsureLine();
}
}

View File

@ -0,0 +1,21 @@
using Markdig;
using OliverBooth.Extensions.Markdig.Markdown.Callout;
namespace OliverBooth.Extensions.Markdig.Markdown;
/// <summary>
/// Extension methods for <see cref="MarkdownPipelineBuilder" />.
/// </summary>
internal static class MarkdownExtensions
{
/// <summary>
/// Uses this extension to enable Obsidian-style callouts.
/// </summary>
/// <param name="pipeline">The pipeline.</param>
/// <returns>The modified pipeline.</returns>
public static MarkdownPipelineBuilder UseCallouts(this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<CalloutExtension>();
return pipeline;
}
}

View File

@ -1,8 +1,8 @@
using Markdig;
using Markdig.Renderers;
using OliverBooth.Services;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Markdown.Template;
namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary>
/// Represents a Markdown extension that adds support for MediaWiki-style templates.

View File

@ -1,6 +1,6 @@
using Markdig.Syntax.Inlines;
namespace OliverBooth.Markdown.Template;
namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary>
/// Represents a Markdown inline element that represents a MediaWiki-style template.

View File

@ -2,7 +2,7 @@ using Cysharp.Text;
using Markdig.Helpers;
using Markdig.Parsers;
namespace OliverBooth.Markdown.Template;
namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary>
/// Represents a Markdown inline parser that handles MediaWiki-style templates.

View File

@ -1,8 +1,8 @@
using Markdig.Renderers;
using Markdig.Renderers.Html;
using OliverBooth.Services;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Markdown.Template;
namespace OliverBooth.Extensions.Markdig.Markdown.Template;
/// <summary>
/// Represents a Markdown object renderer that handles <see cref="TemplateInline" /> elements.

View File

@ -0,0 +1,25 @@
using Markdig;
using Markdig.Renderers;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdig extension that supports Discord-style timestamps.
/// </summary>
public class TimestampExtension : IMarkdownExtension
{
/// <inheritdoc />
public void Setup(MarkdownPipelineBuilder pipeline)
{
pipeline.InlineParsers.AddIfNotAlready<TimestampInlineParser>();
}
/// <inheritdoc />
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
if (renderer is HtmlRenderer htmlRenderer)
{
htmlRenderer.ObjectRenderers.AddIfNotAlready<TimestampRenderer>();
}
}
}

View File

@ -0,0 +1,42 @@
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// An enumeration of timestamp formats.
/// </summary>
public enum TimestampFormat
{
/// <summary>
/// Short time format. Example: 12:00
/// </summary>
ShortTime = 't',
/// <summary>
/// Long time format. Example: 12:00:00
/// </summary>
LongTime = 'T',
/// <summary>
/// Short date format. Example: 1/1/2000
/// </summary>
ShortDate = 'd',
/// <summary>
/// Long date format. Example: 1 January 2000
/// </summary>
LongDate = 'D',
/// <summary>
/// Short date/time format. Example: 1 January 2000 at 12:00
/// </summary>
LongDateShortTime = 'f',
/// <summary>
/// Long date/time format. Example: Saturday, 1 January 2000 at 12:00
/// </summary>
LongDateTime = 'F',
/// <summary>
/// Relative date/time format. Example: 1 second ago
/// </summary>
Relative = 'R',
}

View File

@ -0,0 +1,21 @@
using Markdig.Syntax.Inlines;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown inline element that contains a timestamp.
/// </summary>
public sealed class TimestampInline : Inline
{
/// <summary>
/// Gets or sets the format.
/// </summary>
/// <value>The format.</value>
public TimestampFormat Format { get; set; }
/// <summary>
/// Gets or sets the timestamp.
/// </summary>
/// <value>The timestamp.</value>
public DateTimeOffset Timestamp { get; set; }
}

View File

@ -0,0 +1,91 @@
using Markdig.Helpers;
using Markdig.Parsers;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown inline parser that matches Discord-style timestamps.
/// </summary>
public sealed class TimestampInlineParser : InlineParser
{
/// <summary>
/// Initializes a new instance of the <see cref="TimestampInlineParser" /> class.
/// </summary>
public TimestampInlineParser()
{
OpeningCharacters = new[] { '<' };
}
/// <inheritdoc />
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// Previous char must be a space
if (!slice.PeekCharExtra(-1).IsWhiteSpaceOrZero())
{
return false;
}
ReadOnlySpan<char> span = slice.Text.AsSpan(slice.Start, slice.Length);
if (!TryConsumeTimestamp(span, out ReadOnlySpan<char> rawTimestamp, out char format))
{
return false;
}
if (!long.TryParse(rawTimestamp, out long timestamp))
{
return false;
}
bool hasFormat = format != '\0';
processor.Inline = new TimestampInline
{
Format = (TimestampFormat)format,
Timestamp = DateTimeOffset.FromUnixTimeSeconds(timestamp)
};
int paddingCount = hasFormat ? 6 : 4; // <t:*> or optionally <t:*:*>
slice.Start += rawTimestamp.Length + paddingCount;
return true;
}
private bool TryConsumeTimestamp(ReadOnlySpan<char> source,
out ReadOnlySpan<char> timestamp,
out char format)
{
timestamp = default;
format = default;
if (!source.StartsWith("<t:")) return false;
timestamp = source[3..];
if (timestamp.IndexOf('>') == -1)
{
timestamp = default;
return false;
}
int delimiterIndex = timestamp.IndexOf(':');
if (delimiterIndex == 0)
{
// invalid format <t::*>
timestamp = default;
return false;
}
if (delimiterIndex == -1)
{
// no format, default to relative
format = 'R';
timestamp = timestamp[..^1]; // trim >
}
else
{
// use specified format
format = timestamp[^2];
timestamp = timestamp[..^3];
}
return true;
}
}

View File

@ -0,0 +1,55 @@
using System.ComponentModel;
using Humanizer;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Extensions.Markdig.Markdown.Timestamp;
/// <summary>
/// Represents a Markdown object renderer that renders <see cref="TimestampInline" /> elements.
/// </summary>
public sealed class TimestampRenderer : HtmlObjectRenderer<TimestampInline>
{
/// <inheritdoc />
protected override void Write(HtmlRenderer renderer, TimestampInline obj)
{
DateTimeOffset timestamp = obj.Timestamp;
TimestampFormat format = obj.Format;
renderer.Write("<span class=\"timestamp\" data-timestamp=\"");
renderer.Write(timestamp.ToUnixTimeSeconds().ToString());
renderer.Write("\" data-format=\"");
renderer.Write(((char)format).ToString());
renderer.Write("\" title=\"");
renderer.WriteEscape(timestamp.ToString("dddd, d MMMM yyyy HH:mm"));
renderer.Write("\">");
switch (format)
{
case TimestampFormat.LongDate:
renderer.Write(timestamp.ToString("d MMMM yyyy"));
break;
case TimestampFormat.LongDateShortTime:
renderer.Write(timestamp.ToString(@"d MMMM yyyy \a\t HH:mm"));
break;
case TimestampFormat.LongDateTime:
renderer.Write(timestamp.ToString(@"dddd, d MMMM yyyy \a\t HH:mm"));
break;
case TimestampFormat.Relative:
renderer.Write(timestamp.Humanize());
break;
case var _ when !Enum.IsDefined(format):
throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(TimestampFormat));
default:
renderer.Write(timestamp.ToString(((char)format).ToString()));
break;
}
renderer.Write("</span>");
}
}

View File

@ -0,0 +1,38 @@
using Markdig;
using OliverBooth.Extensions.Markdig.Markdown.Template;
using OliverBooth.Extensions.Markdig.Services;
namespace OliverBooth.Extensions.Markdig;
/// <summary>
/// Extension methods for <see cref="MarkdownPipelineBuilder" />.
/// </summary>
public static class MarkdownPipelineExtensions
{
/// <summary>
/// Enables the use of Wiki-style templates in this pipeline.
/// </summary>
/// <param name="builder">The Markdig markdown pipeline builder.</param>
/// <param name="templateService">The template service responsible for fetching and rendering templates.</param>
/// <returns>The modified Markdig markdown pipeline builder.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="builder" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="templateService" /> is <see langword="null" />.</para>
/// </exception>
public static MarkdownPipelineBuilder UseTemplates(this MarkdownPipelineBuilder builder, ITemplateService templateService)
{
if (builder is null)
{
throw new ArgumentNullException(nameof(builder));
}
if (templateService is null)
{
throw new ArgumentNullException(nameof(templateService));
}
builder.Use(new TemplateExtension(templateService));
return builder;
}
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web;
using OliverBooth.Markdown.Template;
using OliverBooth.Common.Data.Web;
using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Services;
namespace OliverBooth.Extensions.Markdig.Services;
/// <summary>
/// Represents a service that renders MediaWiki-style templates.

View File

@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
global.json = global.json
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Extensions.Markdig", "OliverBooth.Extensions.Markdig\OliverBooth.Extensions.Markdig.csproj", "{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OliverBooth.Common", "OliverBooth.Common\OliverBooth.Common.csproj", "{AD231E0F-FAED-4661-963F-EB22F858E148}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -23,6 +27,14 @@ Global
{A58A6FA3-480C-400B-822A-3786741BF39C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A58A6FA3-480C-400B-822A-3786741BF39C}.Release|Any CPU.Build.0 = Release|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B012CD2-3201-41A0-BEF9-8E0B6247BB7E}.Release|Any CPU.Build.0 = Release|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD231E0F-FAED-4661-963F-EB22F858E148}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection

View File

@ -1,7 +1,7 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Services;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
namespace OliverBooth.Controllers.Blog;

View File

@ -1,8 +1,8 @@
using System.Xml.Serialization;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
using OliverBooth.Data.Blog.Rss;
using OliverBooth.Services;
namespace OliverBooth.Controllers.Blog;

View File

@ -1,7 +1,7 @@
using System.Text;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Web;
using OliverBooth.Services;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Services;
namespace OliverBooth.Controllers;

View File

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
using SmartFormat;
namespace OliverBooth.Data.Blog;

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Blog.Configuration;

View File

@ -1,4 +1,5 @@
using System.Web;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Data.Blog;

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography;
using System.Text;
using Cysharp.Text;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Data.Blog;

View File

@ -1,34 +1,24 @@
using System.Text.Json.Serialization;
using OliverBooth.Common.Data.Mastodon;
namespace OliverBooth.Data.Mastodon;
public sealed class MastodonStatus
/// <inheritdoc />
internal sealed class MastodonStatus : IMastodonStatus
{
/// <summary>
/// Gets the content of the status.
/// </summary>
/// <value>The content.</value>
/// <inheritdoc />
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
/// <summary>
/// Gets the date and time at which this status was posted.
/// </summary>
/// <value>The post timestamp.</value>
/// <inheritdoc />
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
/// <summary>
/// Gets the media attachments for this status.
/// </summary>
/// <value>The media attachments.</value>
/// <inheritdoc />
[JsonPropertyName("media_attachments")]
public IReadOnlyList<MediaAttachment> MediaAttachments { get; set; } = ArraySegment<MediaAttachment>.Empty;
public IReadOnlyList<MediaAttachment> MediaAttachments { get; set; } = ArraySegment<MediaAttachment>.Empty;
/// <summary>
/// Gets the original URI of the status.
/// </summary>
/// <value>The original URI.</value>
/// <inheritdoc />
[JsonPropertyName("url")]
public Uri OriginalUri { get; set; } = null!;
}

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IBlacklistEntry"/>

View File

@ -1,4 +1,5 @@
using NetBarcode;
using OliverBooth.Common.Data.Web;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Processing;

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <inheritdoc />

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web.Configuration;

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web.Configuration;

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Web.Configuration;

View File

@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OliverBooth.Common.Data;
namespace OliverBooth.Data.Web.Configuration;

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <inheritdoc cref="IProgrammingLanguage" />

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <summary>

View File

@ -1,3 +1,5 @@
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <summary>

View File

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;

View File

@ -1,3 +1,6 @@
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Web;
namespace OliverBooth.Data.Web;
/// <summary>

View File

@ -1,8 +1,8 @@
using System.Web;
using Cysharp.Text;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
using OliverBooth.Services;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Services;
namespace OliverBooth.Extensions;

View File

@ -1,8 +1,9 @@
using System.Diagnostics;
using System.Text;
using Markdig;
using OliverBooth.Data.Web;
using OliverBooth.Services;
using OliverBooth.Common.Data.Web;
using OliverBooth.Common.Services;
using OliverBooth.Extensions.Markdig.Markdown.Template;
namespace OliverBooth.Markdown.Template;