373 lines
16 KiB
C#
373 lines
16 KiB
C#
using System.Globalization;
|
||
using Markdig.Extensions.CustomContainers;
|
||
using Markdig.Renderers.Html;
|
||
using Markdig.Syntax;
|
||
using Markdig.Syntax.Inlines;
|
||
|
||
namespace Elternbeirat.Web.Shared;
|
||
|
||
/// <summary>
|
||
/// One person in a <c>::: team</c> block, read from a list item that follows the
|
||
/// convention the board page has always used.
|
||
/// </summary>
|
||
/// <param name="Name">The person's name: the bold start of the item.</param>
|
||
/// <param name="Role">
|
||
/// The role after the name, without its parentheses, or <see langword="null"/>
|
||
/// when the item has none (most of the "Beisitz" members).
|
||
/// </param>
|
||
/// <param name="Duties">
|
||
/// The responsibilities from the italic second line, split at commas; empty when
|
||
/// there is no such line.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// The convention, one list item per person:
|
||
/// <code>
|
||
/// - **Maik Palm** (Vorsitzender)
|
||
/// *Schulkonferenz, Mensarat, Homepage*
|
||
/// Mitglied im Gesamtelternbeirat.
|
||
/// </code>
|
||
/// The bold name becomes the card's heading, the role a chip, every duty a small
|
||
/// tag and any further line the note. Line breaks may be hard (two spaces) or soft:
|
||
/// editors forget the two spaces, and the card should not depend on them.
|
||
/// <para>
|
||
/// The board stays Markdown in the page body on purpose: a dedicated
|
||
/// collection would be cleaner, but there is no way yet to bring a schema
|
||
/// change to production (Gitea #39). Reading the convention costs the editors
|
||
/// nothing they do not already write.
|
||
/// </para>
|
||
/// <para>
|
||
/// An item that does not start with a bold name is not an error: it becomes a
|
||
/// plain card showing its text as written (<see cref="PlainCardClass"/>).
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <seealso cref="Markdown"/>
|
||
public sealed record TeamMember(string Name, string? Role, IReadOnlyList<string> Duties)
|
||
{
|
||
/// <summary>
|
||
/// The block name editors type: <c>::: team</c>.
|
||
/// </summary>
|
||
public const string ContainerName = "team";
|
||
|
||
/// <summary>
|
||
/// The modifier for larger cards, <c>::: team gross</c>; also the class it adds
|
||
/// to the block.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A word after the block name rather than a block of its own
|
||
/// (<c>::: team-gross</c>): the block stays <c>team</c>, so a typo in the
|
||
/// modifier only costs the larger size, never the whole grid. <c>groß</c> is
|
||
/// accepted too; <c>gross</c> is the documented form because it types the same
|
||
/// on every keyboard.
|
||
/// </remarks>
|
||
public const string LargeModifier = "gross";
|
||
|
||
/// <summary>
|
||
/// The class on every card, i.e. on every list item in a <c>::: team</c> block.
|
||
/// </summary>
|
||
public const string CardClass = "team-card";
|
||
|
||
/// <summary>
|
||
/// The extra class on a card whose item does not follow the convention.
|
||
/// </summary>
|
||
public const string PlainCardClass = "team-card-plain";
|
||
|
||
/// <summary>
|
||
/// How many avatar colours there are; <c>app.css</c> defines
|
||
/// <c>--color-avatar-1</c> to <c>--color-avatar-5</c> to match.
|
||
/// </summary>
|
||
public const int ColorCount = 5;
|
||
|
||
/// <summary>
|
||
/// FNV-1a offset basis and prime (32 bit), see <see cref="Fnv"/>.
|
||
/// </summary>
|
||
private const uint FnvOffset = 2166136261;
|
||
|
||
private const uint FnvPrime = 16777619;
|
||
|
||
/// <summary>
|
||
/// The letters on the avatar, see <see cref="InitialsOf"/>.
|
||
/// </summary>
|
||
public string Initials => InitialsOf(Name);
|
||
|
||
/// <summary>
|
||
/// The avatar colour, from <c>1</c> to <see cref="ColorCount"/>, see
|
||
/// <see cref="ColorOf"/>.
|
||
/// </summary>
|
||
public int Color => ColorOf(Name);
|
||
|
||
/// <summary>
|
||
/// Builds the initials shown on a person's avatar.
|
||
/// </summary>
|
||
/// <param name="name">The person's name.</param>
|
||
/// <returns>
|
||
/// The first letter of the first and of the last word, upper case; one letter
|
||
/// for a single word; an empty string when the name holds no letter at all.
|
||
/// </returns>
|
||
/// <remarks>
|
||
/// A double name counts as one word, so it adds one letter, not two: two
|
||
/// letters are what fits the circle, and they are what people expect from a
|
||
/// name like <c>Reger-Stilgenbauer</c>. Letters outside A–Z (<c>Ö</c>,
|
||
/// <c>Ş</c>) are kept as they are, and leading punctuation is skipped.
|
||
/// </remarks>
|
||
/// <example>
|
||
/// <code>
|
||
/// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR"
|
||
/// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ"
|
||
/// TeamMember.InitialsOf("Isabell"); // "I"
|
||
/// </code>
|
||
/// </example>
|
||
public static string InitialsOf(string name) =>
|
||
name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) switch
|
||
{
|
||
[] => "",
|
||
[var only] => Initial(only),
|
||
[var first, .., var last] => Initial(first) + Initial(last),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Picks a person's avatar colour from the name.
|
||
/// </summary>
|
||
/// <param name="name">The person's name.</param>
|
||
/// <returns>A number from <c>1</c> to <see cref="ColorCount"/>.</returns>
|
||
/// <remarks>
|
||
/// The same name always gets the same colour, on every request and after every
|
||
/// restart, so a person does not change colour between visits. That is why this
|
||
/// is a fixed hash (FNV-1a) and not <see cref="string.GetHashCode()"/>, which
|
||
/// .NET randomises per process. Surrounding whitespace does not count.
|
||
/// <para>
|
||
/// FNV-1a alone spreads short names badly over its low bits, which the
|
||
/// remainder by <see cref="ColorCount"/> reads: four of the six "Beisitz"
|
||
/// members shared one colour. The MurmurHash3 finaliser (<see cref="Mix"/>)
|
||
/// stirs all bits into the low ones first.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static int ColorOf(string name) =>
|
||
(int)(Mix(Fnv(name.Trim())) % ColorCount) + 1;
|
||
|
||
/// <summary>
|
||
/// Reads a person from the first paragraph of a list item.
|
||
/// </summary>
|
||
/// <param name="paragraph">The paragraph to read; not changed.</param>
|
||
/// <returns>
|
||
/// The person and the inlines that form the note (everything after the name
|
||
/// line and, if present, the duties line; possibly empty), or
|
||
/// <see langword="null"/> when the paragraph does not start with a bold name.
|
||
/// </returns>
|
||
/// <remarks>
|
||
/// The duties line is only recognised when it is nothing but one italic span;
|
||
/// any other second line is part of the note, so no text is lost.
|
||
/// </remarks>
|
||
internal static (TeamMember Member, Inline[] Note)? Read(ParagraphBlock paragraph) =>
|
||
Lines(paragraph).ToArray() is [var head, .. var rest]
|
||
&& Content(head) is [EmphasisInline { DelimiterCount: 2 } name, .. var role]
|
||
&& Markdown.PlainText(name) is { Length: > 0 } nameText
|
||
? rest is [var second, .. var after] && Content(second) is [EmphasisInline { DelimiterCount: 1 } duties]
|
||
? (new TeamMember(nameText, RoleOf(role), DutiesOf(duties)), [.. after.SelectMany(line => line)])
|
||
: (new TeamMember(nameText, RoleOf(role), []), [.. rest.SelectMany(line => line)])
|
||
: null;
|
||
|
||
/// <summary>
|
||
/// Turns one list item of a <c>::: team</c> block into a person card.
|
||
/// </summary>
|
||
/// <param name="item">The list item; changed in place.</param>
|
||
/// <remarks>
|
||
/// The first paragraph is rebuilt from new inlines: raw HTML inlines for the
|
||
/// tags and literal inlines for the text, so Markdig escapes everything the
|
||
/// editor wrote. The note keeps its original inlines, so links and emphasis in it
|
||
/// still render. Any further blocks in the item stay untouched below.
|
||
/// </remarks>
|
||
internal static void ToCard(ListItemBlock item)
|
||
{
|
||
var attributes = item.GetAttributes();
|
||
attributes.AddClass(CardClass);
|
||
|
||
if (item.FirstOrDefault() is ParagraphBlock { Inline: not null } paragraph && Read(paragraph) is { } card)
|
||
{
|
||
foreach (var inline in card.Note)
|
||
{
|
||
inline.Remove();
|
||
}
|
||
|
||
paragraph.Inline = card.Member.Head()
|
||
.Concat(card.Note.Length > 0 ? [Open("span", "team-note"), .. card.Note, Close("span")] : [])
|
||
.Aggregate(new ContainerInline(), (container, inline) => container.AppendChild(inline));
|
||
}
|
||
else
|
||
{
|
||
attributes.AddClass(PlainCardClass);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether a <c>:::</c> block is a team block.
|
||
/// </summary>
|
||
/// <param name="container">The block to inspect.</param>
|
||
/// <returns><see langword="true"/> for <c>::: team</c>, with or without modifier.</returns>
|
||
internal static bool IsTeam(CustomContainer container) =>
|
||
string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Whether a team block asks for larger cards (<c>::: team gross</c>).
|
||
/// </summary>
|
||
/// <param name="container">The team block.</param>
|
||
/// <returns>
|
||
/// <see langword="true"/> when one of the words after the name is
|
||
/// <c>gross</c> or <c>groß</c>, in any case.
|
||
/// </returns>
|
||
internal static bool IsLarge(CustomContainer container) =>
|
||
(container.Arguments ?? "")
|
||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
|
||
.Any(word => word.Equals(LargeModifier, StringComparison.OrdinalIgnoreCase)
|
||
|| word.Equals("groß", StringComparison.OrdinalIgnoreCase));
|
||
|
||
/// <summary>
|
||
/// The card's head: avatar, name, role chip and duty tags, as inlines.
|
||
/// </summary>
|
||
/// <returns>The inlines, in reading order; missing parts are left out.</returns>
|
||
/// <remarks>
|
||
/// The avatar is hidden from screen readers: it only repeats the name next to
|
||
/// it.
|
||
/// </remarks>
|
||
private Inline[] Head() =>
|
||
[
|
||
.. Initials is { Length: > 0 } initials
|
||
? [Open("span", $"team-avatar team-avatar-{Color.ToString(CultureInfo.InvariantCulture)}", " aria-hidden=\"true\""), new LiteralInline(initials), Close("span")]
|
||
: Array.Empty<Inline>(),
|
||
.. Element("team-name", Name, "strong"),
|
||
.. Role is { } role ? Element("team-role", role) : [],
|
||
.. Duties.Count > 0
|
||
? [Open("span", "team-duties"), .. Duties.SelectMany(Duty), Close("span")]
|
||
: Array.Empty<Inline>(),
|
||
];
|
||
|
||
/// <summary>
|
||
/// The 32-bit FNV-1a hash of a text's UTF-16 code units.
|
||
/// </summary>
|
||
/// <param name="text">The text.</param>
|
||
/// <returns>The hash.</returns>
|
||
private static uint Fnv(string text) =>
|
||
unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime));
|
||
|
||
/// <summary>
|
||
/// The MurmurHash3 finaliser (<c>fmix32</c>): spreads every input bit over all
|
||
/// output bits.
|
||
/// </summary>
|
||
/// <param name="hash">The hash to mix.</param>
|
||
/// <returns>The mixed hash.</returns>
|
||
private static uint Mix(uint hash) =>
|
||
unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16));
|
||
|
||
/// <summary>
|
||
/// Folds the high bits of a value into its low bits.
|
||
/// </summary>
|
||
/// <param name="value">The value.</param>
|
||
/// <param name="shift">How far to shift right before the XOR.</param>
|
||
/// <returns><paramref name="value"/> XOR <paramref name="value"/> shifted right.</returns>
|
||
private static uint XorShift(uint value, int shift) => value ^ (value >> shift);
|
||
|
||
/// <summary>
|
||
/// One duty tag, led by a space so screen readers keep the tags apart.
|
||
/// </summary>
|
||
/// <param name="duty">The duty.</param>
|
||
/// <returns>The space and the tag.</returns>
|
||
private static Inline[] Duty(string duty) =>
|
||
[new LiteralInline(" "), .. Element("team-duty", duty)];
|
||
|
||
/// <summary>
|
||
/// An element with escaped text inside.
|
||
/// </summary>
|
||
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||
/// <param name="text">The text; escaped by Markdig on rendering.</param>
|
||
/// <param name="tag">The element name.</param>
|
||
/// <returns>Opening tag, text and closing tag.</returns>
|
||
private static Inline[] Element(string classes, string text, string tag = "span") =>
|
||
[Open(tag, classes), new LiteralInline(text), Close(tag)];
|
||
|
||
/// <summary>
|
||
/// An opening tag with a class, as a raw HTML inline.
|
||
/// </summary>
|
||
/// <param name="tag">The element name.</param>
|
||
/// <param name="classes">The value of the class attribute; written as is.</param>
|
||
/// <param name="attributes">
|
||
/// Further attributes, written as is after the class, with a leading space.
|
||
/// </param>
|
||
/// <returns>The tag.</returns>
|
||
/// <remarks>
|
||
/// Only ever called with constants from this class, never with editor text:
|
||
/// nothing here is escaped.
|
||
/// </remarks>
|
||
private static HtmlInline Open(string tag, string classes, string attributes = "") =>
|
||
new($"<{tag} class=\"{classes}\"{attributes}>");
|
||
|
||
/// <summary>
|
||
/// A closing tag, as a raw HTML inline.
|
||
/// </summary>
|
||
/// <param name="tag">The element name.</param>
|
||
/// <returns>The tag.</returns>
|
||
private static HtmlInline Close(string tag) => new($"</{tag}>");
|
||
|
||
/// <summary>
|
||
/// The first letter of a word, upper case.
|
||
/// </summary>
|
||
/// <param name="word">One word of a name.</param>
|
||
/// <returns>The letter, or an empty string if the word has none.</returns>
|
||
private static string Initial(string word) =>
|
||
word.FirstOrDefault(char.IsLetter) is var letter and not '\0'
|
||
? letter.ToString().ToUpper(Cultures.German)
|
||
: "";
|
||
|
||
/// <summary>
|
||
/// The text after the name, without separators and parentheses.
|
||
/// </summary>
|
||
/// <param name="inlines">The inlines after the bold name on the first line.</param>
|
||
/// <returns>The role, or <see langword="null"/> if nothing is left.</returns>
|
||
private static string? RoleOf(IEnumerable<Inline> inlines) =>
|
||
Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text
|
||
&& (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role
|
||
? role
|
||
: null;
|
||
|
||
/// <summary>
|
||
/// Splits the italic duties line at its commas.
|
||
/// </summary>
|
||
/// <param name="duties">The italic span.</param>
|
||
/// <returns>The duties, trimmed, without empty entries.</returns>
|
||
private static string[] DutiesOf(EmphasisInline duties) =>
|
||
Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
|
||
/// <summary>
|
||
/// The inlines of a line that carry content: no whitespace, no line break.
|
||
/// </summary>
|
||
/// <param name="line">One line from <see cref="Lines"/>.</param>
|
||
/// <returns>The content inlines, in order.</returns>
|
||
private static Inline[] Content(IEnumerable<Inline> line) =>
|
||
[.. line.Where(inline => !Markdown.IsBlank(inline))];
|
||
|
||
/// <summary>
|
||
/// Splits a paragraph's inlines into lines.
|
||
/// </summary>
|
||
/// <param name="paragraph">The paragraph.</param>
|
||
/// <returns>
|
||
/// The lines; each keeps the line break that ends it, so the note can be moved
|
||
/// with its breaks intact.
|
||
/// </returns>
|
||
private static IEnumerable<List<Inline>> Lines(ParagraphBlock paragraph)
|
||
{
|
||
var line = new List<Inline>();
|
||
foreach (var inline in paragraph.Inline?.ToList() ?? [])
|
||
{
|
||
line.Add(inline);
|
||
if (inline is LineBreakInline)
|
||
{
|
||
yield return line;
|
||
line = [];
|
||
}
|
||
}
|
||
|
||
if (line.Count > 0)
|
||
{
|
||
yield return line;
|
||
}
|
||
}
|
||
}
|