From e64d8b47b8a2cc4323c3330fd448e05315330d37 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Thu, 10 Aug 2023 01:49:09 +0100 Subject: [PATCH] feat: add support for Discord-style timestamps --- .../Markdown/Timestamp/TimestampExtension.cs | 25 +++++ .../Markdown/Timestamp/TimestampFormat.cs | 42 ++++++++ .../Markdown/Timestamp/TimestampInline.cs | 21 ++++ .../Timestamp/TimestampInlineParser.cs | 91 ++++++++++++++++ .../Markdown/Timestamp/TimestampRenderer.cs | 55 ++++++++++ OliverBooth/Program.cs | 2 + src/scss/app.scss | 6 ++ src/ts/app.ts | 101 ++++++++++++++++-- 8 files changed, 333 insertions(+), 10 deletions(-) create mode 100644 OliverBooth/Markdown/Timestamp/TimestampExtension.cs create mode 100644 OliverBooth/Markdown/Timestamp/TimestampFormat.cs create mode 100644 OliverBooth/Markdown/Timestamp/TimestampInline.cs create mode 100644 OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs create mode 100644 OliverBooth/Markdown/Timestamp/TimestampRenderer.cs diff --git a/OliverBooth/Markdown/Timestamp/TimestampExtension.cs b/OliverBooth/Markdown/Timestamp/TimestampExtension.cs new file mode 100644 index 0000000..f772b84 --- /dev/null +++ b/OliverBooth/Markdown/Timestamp/TimestampExtension.cs @@ -0,0 +1,25 @@ +using Markdig; +using Markdig.Renderers; + +namespace OliverBooth.Markdown.Timestamp; + +/// +/// Represents a Markdig extension that supports Discord-style timestamps. +/// +public class TimestampExtension : IMarkdownExtension +{ + /// + public void Setup(MarkdownPipelineBuilder pipeline) + { + pipeline.InlineParsers.AddIfNotAlready(); + } + + /// + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) + { + if (renderer is HtmlRenderer htmlRenderer) + { + htmlRenderer.ObjectRenderers.AddIfNotAlready(); + } + } +} diff --git a/OliverBooth/Markdown/Timestamp/TimestampFormat.cs b/OliverBooth/Markdown/Timestamp/TimestampFormat.cs new file mode 100644 index 0000000..f66d5bd --- /dev/null +++ b/OliverBooth/Markdown/Timestamp/TimestampFormat.cs @@ -0,0 +1,42 @@ +namespace OliverBooth.Markdown.Timestamp; + +/// +/// An enumeration of timestamp formats. +/// +public enum TimestampFormat +{ + /// + /// Short time format. Example: 12:00 + /// + ShortTime = 't', + + /// + /// Long time format. Example: 12:00:00 + /// + LongTime = 'T', + + /// + /// Short date format. Example: 1/1/2000 + /// + ShortDate = 'd', + + /// + /// Long date format. Example: 1 January 2000 + /// + LongDate = 'D', + + /// + /// Short date/time format. Example: 1 January 2000 at 12:00 + /// + LongDateShortTime = 'f', + + /// + /// Long date/time format. Example: Saturday, 1 January 2000 at 12:00 + /// + LongDateTime = 'F', + + /// + /// Relative date/time format. Example: 1 second ago + /// + Relative = 'R', +} diff --git a/OliverBooth/Markdown/Timestamp/TimestampInline.cs b/OliverBooth/Markdown/Timestamp/TimestampInline.cs new file mode 100644 index 0000000..42cf57f --- /dev/null +++ b/OliverBooth/Markdown/Timestamp/TimestampInline.cs @@ -0,0 +1,21 @@ +using Markdig.Syntax.Inlines; + +namespace OliverBooth.Markdown.Timestamp; + +/// +/// Represents a Markdown inline element that contains a timestamp. +/// +public sealed class TimestampInline : Inline +{ + /// + /// Gets or sets the format. + /// + /// The format. + public TimestampFormat Format { get; set; } + + /// + /// Gets or sets the timestamp. + /// + /// The timestamp. + public DateTimeOffset Timestamp { get; set; } +} diff --git a/OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs b/OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs new file mode 100644 index 0000000..48469ce --- /dev/null +++ b/OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs @@ -0,0 +1,91 @@ +using Markdig.Helpers; +using Markdig.Parsers; + +namespace OliverBooth.Markdown.Timestamp; + +/// +/// Represents a Markdown inline parser that matches Discord-style timestamps. +/// +public sealed class TimestampInlineParser : InlineParser +{ + /// + /// Initializes a new instance of the class. + /// + public TimestampInlineParser() + { + OpeningCharacters = new[] { '<' }; + } + + /// + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + // Previous char must be a space + if (!slice.PeekCharExtra(-1).IsWhiteSpaceOrZero()) + { + return false; + } + + ReadOnlySpan span = slice.Text.AsSpan(slice.Start, slice.Length); + + if (!TryConsumeTimestamp(span, out ReadOnlySpan 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; // or optionally + slice.Start += rawTimestamp.Length + paddingCount; + return true; + } + + private bool TryConsumeTimestamp(ReadOnlySpan source, + out ReadOnlySpan timestamp, + out char format) + { + timestamp = default; + format = default; + + if (!source.StartsWith("') == -1) + { + timestamp = default; + return false; + } + + int delimiterIndex = timestamp.IndexOf(':'); + if (delimiterIndex == 0) + { + // invalid format + 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; + } +} diff --git a/OliverBooth/Markdown/Timestamp/TimestampRenderer.cs b/OliverBooth/Markdown/Timestamp/TimestampRenderer.cs new file mode 100644 index 0000000..1738324 --- /dev/null +++ b/OliverBooth/Markdown/Timestamp/TimestampRenderer.cs @@ -0,0 +1,55 @@ +using System.ComponentModel; +using Humanizer; +using Markdig.Renderers; +using Markdig.Renderers.Html; + +namespace OliverBooth.Markdown.Timestamp; + +/// +/// Represents a Markdown object renderer that renders elements. +/// +public sealed class TimestampRenderer : HtmlObjectRenderer +{ + /// + protected override void Write(HtmlRenderer renderer, TimestampInline obj) + { + DateTimeOffset timestamp = obj.Timestamp; + TimestampFormat format = obj.Format; + + 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(""); + } +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index a84d53a..56e0b08 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder() + .Use() .Use(new TemplateExtension(provider.GetRequiredService())) .UseAdvancedExtensions() .UseBootstrap() diff --git a/src/scss/app.scss b/src/scss/app.scss index 4ed887b..e79ed29 100644 --- a/src/scss/app.scss +++ b/src/scss/app.scss @@ -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 { diff --git a/src/ts/app.ts b/src/ts/app.ts index 059bf0d..65a8bb1 100644 --- a/src/ts/app.ts +++ b/src/ts/app.ts @@ -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)); })();