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));
})();