feat: add support for Discord-style timestamps

This commit is contained in:
Oliver Booth 2023-08-10 01:49:09 +01:00
parent 7279c448da
commit e64d8b47b8
Signed by: oliverbooth
GPG Key ID: 725DB725A0D9EE61
8 changed files with 333 additions and 10 deletions

View File

@ -0,0 +1,25 @@
using Markdig;
using Markdig.Renderers;
namespace OliverBooth.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.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.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.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.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

@ -2,6 +2,7 @@ using Markdig;
using NLog.Extensions.Logging;
using OliverBooth.Data;
using OliverBooth.Markdown;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Middleware;
using OliverBooth.Services;
using X10D.Hosting.DependencyInjection;
@ -16,6 +17,7 @@ builder.Services.AddSingleton<ConfigurationService>();
builder.Services.AddSingleton<TemplateService>();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>()
.Use(new TemplateExtension(provider.GetRequiredService<TemplateService>()))
.UseAdvancedExtensions()
.UseBootstrap()

View File

@ -166,6 +166,12 @@ article {
text-decoration: none;
border-bottom: 1px dotted #ffffff;
}
span.timestamp {
background: lighten(#333333, 12.5%);
border-radius: 2px;
padding: 2px;
}
}
.blog-card {

View File

@ -2,6 +2,41 @@ declare const bootstrap: any;
declare const katex: any;
(() => {
const formatRelativeTime = function (timestamp) {
const now = new Date();
// @ts-ignore
const diff = now - timestamp;
const suffix = diff < 0 ? 'from now' : 'ago';
const seconds = Math.floor(diff / 1000);
if (seconds < 60) {
return `${seconds} second${seconds !== 1 ? 's' : ''} ${suffix}`;
}
const minutes = Math.floor(diff / 60000);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ${suffix}`;
}
const hours = Math.floor(diff / 3600000);
if (hours < 24) {
return `${hours} hour${hours !== 1 ? 's' : ''} ${suffix}`;
}
const days = Math.floor(diff / 86400000);
if (days < 30) {
return `${days} day${days !== 1 ? 's' : ''} ${suffix}`;
}
const months = Math.floor(diff / 2592000000);
if (months < 12) {
return `${months} month${months !== 1 ? 's' : ''} ${suffix}`;
}
const years = Math.floor(diff / 31536000000);
return `${years} year${years !== 1 ? 's' : ''} ${suffix}`;
};
document.querySelectorAll("pre code").forEach((block) => {
let content = block.textContent;
if (content.trim().split("\n").length > 1) {
@ -16,16 +51,6 @@ declare const katex: any;
block.innerHTML = content;
});
document.querySelectorAll("[title]").forEach((img) => {
img.setAttribute("data-bs-toggle", "tooltip");
img.setAttribute("data-bs-placement", "bottom");
img.setAttribute("data-bs-html", "true");
img.setAttribute("data-bs-title", img.getAttribute("title"));
});
const list = document.querySelectorAll('[data-bs-toggle="tooltip"]');
list.forEach((el: Element) => new bootstrap.Tooltip(el));
const tex = document.getElementsByClassName("math");
Array.prototype.forEach.call(tex, function (el) {
let content = el.textContent.trim();
@ -34,4 +59,60 @@ declare const katex: any;
katex.render(content, el);
});
const timestamps = document.querySelectorAll("span[data-timestamp][data-format]");
timestamps.forEach((timestamp) => {
const seconds = parseInt(timestamp.getAttribute("data-timestamp"));
const format = timestamp.getAttribute("data-format");
const date = new Date(seconds * 1000);
const shortTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
const shortDateString = date.toLocaleDateString([], {day: "2-digit", month: "2-digit", year: "numeric"});
const longTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"});
const longDateString = date.toLocaleDateString([], {day: "numeric", month: "long", year: "numeric"});
const weekday = date.toLocaleString([], {weekday: "long"});
timestamp.setAttribute("title", `${weekday}, ${longDateString} ${shortTimeString}`);
switch (format) {
case "t":
timestamp.textContent = shortTimeString;
break;
case "T":
timestamp.textContent = longTimeString;
break;
case "d":
timestamp.textContent = shortDateString;
break;
case "D":
timestamp.textContent = longDateString;
break;
case "f":
timestamp.textContent = `${longDateString} at ${shortTimeString}`
break;
case "F":
timestamp.textContent = `${weekday}, ${longDateString} at ${shortTimeString}`
break;
case "R":
setInterval(() => {
timestamp.textContent = formatRelativeTime(date);
}, 1000);
break;
}
});
document.querySelectorAll("[title]").forEach((el) => {
el.setAttribute("data-bs-toggle", "tooltip");
el.setAttribute("data-bs-placement", "bottom");
el.setAttribute("data-bs-html", "true");
el.setAttribute("data-bs-title", el.getAttribute("title"));
});
const list = document.querySelectorAll('[data-bs-toggle="tooltip"]');
list.forEach((el: Element) => new bootstrap.Tooltip(el));
})();