Compare commits
4 Commits
4e032c3aa5
...
e64d8b47b8
Author | SHA1 | Date | |
---|---|---|---|
e64d8b47b8 | |||
7279c448da | |||
fa51e0a189 | |||
434c61d7fa |
25
OliverBooth/Markdown/Timestamp/TimestampExtension.cs
Normal file
25
OliverBooth/Markdown/Timestamp/TimestampExtension.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
OliverBooth/Markdown/Timestamp/TimestampFormat.cs
Normal file
42
OliverBooth/Markdown/Timestamp/TimestampFormat.cs
Normal 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',
|
||||||
|
}
|
21
OliverBooth/Markdown/Timestamp/TimestampInline.cs
Normal file
21
OliverBooth/Markdown/Timestamp/TimestampInline.cs
Normal 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; }
|
||||||
|
}
|
91
OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs
Normal file
91
OliverBooth/Markdown/Timestamp/TimestampInlineParser.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
55
OliverBooth/Markdown/Timestamp/TimestampRenderer.cs
Normal file
55
OliverBooth/Markdown/Timestamp/TimestampRenderer.cs
Normal 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>");
|
||||||
|
}
|
||||||
|
}
|
@ -16,7 +16,6 @@
|
|||||||
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true"/>
|
<link rel="stylesheet" href="~/css/prism.min.css" asp-append-version="true"/>
|
||||||
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
|
<link rel="stylesheet" href="~/css/prism.vs.min.css" asp-append-version="true">
|
||||||
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true"/>
|
<link rel="stylesheet" href="~/css/app.min.css" asp-append-version="true"/>
|
||||||
<link rel="stylesheet" href="~/oliverbooth.dev.styles.css" asp-append-version="true"/>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="container" style="margin-top: 20px;">
|
<header class="container" style="margin-top: 20px;">
|
||||||
|
@ -2,6 +2,7 @@ using Markdig;
|
|||||||
using NLog.Extensions.Logging;
|
using NLog.Extensions.Logging;
|
||||||
using OliverBooth.Data;
|
using OliverBooth.Data;
|
||||||
using OliverBooth.Markdown;
|
using OliverBooth.Markdown;
|
||||||
|
using OliverBooth.Markdown.Timestamp;
|
||||||
using OliverBooth.Middleware;
|
using OliverBooth.Middleware;
|
||||||
using OliverBooth.Services;
|
using OliverBooth.Services;
|
||||||
using X10D.Hosting.DependencyInjection;
|
using X10D.Hosting.DependencyInjection;
|
||||||
@ -16,6 +17,7 @@ builder.Services.AddSingleton<ConfigurationService>();
|
|||||||
builder.Services.AddSingleton<TemplateService>();
|
builder.Services.AddSingleton<TemplateService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
|
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
|
||||||
|
.Use<TimestampExtension>()
|
||||||
.Use(new TemplateExtension(provider.GetRequiredService<TemplateService>()))
|
.Use(new TemplateExtension(provider.GetRequiredService<TemplateService>()))
|
||||||
.UseAdvancedExtensions()
|
.UseAdvancedExtensions()
|
||||||
.UseBootstrap()
|
.UseBootstrap()
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es6"
|
"target": "es2020"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -166,6 +166,12 @@ article {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px dotted #ffffff;
|
border-bottom: 1px dotted #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.timestamp {
|
||||||
|
background: lighten(#333333, 12.5%);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-card {
|
.blog-card {
|
||||||
|
105
src/ts/app.ts
105
src/ts/app.ts
@ -1,10 +1,45 @@
|
|||||||
declare const bootstrap: any;
|
declare const bootstrap: any;
|
||||||
declare const katex: 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) => {
|
document.querySelectorAll("pre code").forEach((block) => {
|
||||||
let content = block.textContent;
|
let content = block.textContent;
|
||||||
if (content.split("\n").length > 1) {
|
if (content.trim().split("\n").length > 1) {
|
||||||
block.parentElement.classList.add("line-numbers");
|
block.parentElement.classList.add("line-numbers");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,16 +51,6 @@ declare const katex: any;
|
|||||||
block.innerHTML = content;
|
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");
|
const tex = document.getElementsByClassName("math");
|
||||||
Array.prototype.forEach.call(tex, function (el) {
|
Array.prototype.forEach.call(tex, function (el) {
|
||||||
let content = el.textContent.trim();
|
let content = el.textContent.trim();
|
||||||
@ -34,4 +59,60 @@ declare const katex: any;
|
|||||||
|
|
||||||
katex.render(content, el);
|
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));
|
||||||
})();
|
})();
|
||||||
|
Loading…
Reference in New Issue
Block a user