using System.Globalization; using Markdig.Extensions.CustomContainers; using Markdig.Renderers.Html; using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace Elternbeirat.Web.Shared; /// /// One person in a ::: team block, read from a list item that follows the /// convention the board page has always used. /// /// The person's name: the bold start of the item. /// /// The role after the name, without its parentheses, or /// when the item has none (most of the "Beisitz" members). /// /// /// The responsibilities from the italic second line, split at commas; empty when /// there is no such line. /// /// /// The convention, one list item per person: /// /// - **Maik Palm** (Vorsitzender) /// *Schulkonferenz, Mensarat, Homepage* /// Mitglied im Gesamtelternbeirat. /// /// 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. /// /// 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. /// /// /// An item that does not start with a bold name is not an error: it becomes a /// plain card showing its text as written (). /// /// /// public sealed record TeamMember(string Name, string? Role, IReadOnlyList Duties) { /// /// The block name editors type: ::: team. /// public const string ContainerName = "team"; /// /// The modifier for larger cards, ::: team gross; also the class it adds /// to the block. /// /// /// A word after the block name rather than a block of its own /// (::: team-gross): the block stays team, so a typo in the /// modifier only costs the larger size, never the whole grid. groß is /// accepted too; gross is the documented form because it types the same /// on every keyboard. /// public const string LargeModifier = "gross"; /// /// The class on every card, i.e. on every list item in a ::: team block. /// public const string CardClass = "team-card"; /// /// The extra class on a card whose item does not follow the convention. /// public const string PlainCardClass = "team-card-plain"; /// /// How many avatar colours there are; app.css defines /// --color-avatar-1 to --color-avatar-5 to match. /// public const int ColorCount = 5; /// /// FNV-1a offset basis and prime (32 bit), see . /// private const uint FnvOffset = 2166136261; private const uint FnvPrime = 16777619; /// /// The letters on the avatar, see . /// public string Initials => InitialsOf(Name); /// /// The avatar colour, from 1 to , see /// . /// public int Color => ColorOf(Name); /// /// Builds the initials shown on a person's avatar. /// /// The person's name. /// /// 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. /// /// /// 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 Reger-Stilgenbauer. Letters outside A–Z (Ö, /// Ş) are kept as they are, and leading punctuation is skipped. /// /// /// /// TeamMember.InitialsOf("Jenny Reger-Stilgenbauer"); // "JR" /// TeamMember.InitialsOf("Özlem Ünal"); // "ÖÜ" /// TeamMember.InitialsOf("Isabell"); // "I" /// /// 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), }; /// /// Picks a person's avatar colour from the name. /// /// The person's name. /// A number from 1 to . /// /// 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 , which /// .NET randomises per process. Surrounding whitespace does not count. /// /// FNV-1a alone spreads short names badly over its low bits, which the /// remainder by reads: four of the six "Beisitz" /// members shared one colour. The MurmurHash3 finaliser () /// stirs all bits into the low ones first. /// /// public static int ColorOf(string name) => (int)(Mix(Fnv(name.Trim())) % ColorCount) + 1; /// /// Reads a person from the first paragraph of a list item. /// /// The paragraph to read; not changed. /// /// The person and the inlines that form the note (everything after the name /// line and, if present, the duties line; possibly empty), or /// when the paragraph does not start with a bold name. /// /// /// 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. /// 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; /// /// Turns one list item of a ::: team block into a person card. /// /// The list item; changed in place. /// /// 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. /// 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); } } /// /// Whether a ::: block is a team block. /// /// The block to inspect. /// for ::: team, with or without modifier. internal static bool IsTeam(CustomContainer container) => string.Equals(container.Info, ContainerName, StringComparison.OrdinalIgnoreCase); /// /// Whether a team block asks for larger cards (::: team gross). /// /// The team block. /// /// when one of the words after the name is /// gross or groß, in any case. /// 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)); /// /// The card's head: avatar, name, role chip and duty tags, as inlines. /// /// The inlines, in reading order; missing parts are left out. /// /// The avatar is hidden from screen readers: it only repeats the name next to /// it. /// 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(), .. 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(), ]; /// /// The 32-bit FNV-1a hash of a text's UTF-16 code units. /// /// The text. /// The hash. private static uint Fnv(string text) => unchecked(text.Aggregate(FnvOffset, (hash, letter) => (hash ^ letter) * FnvPrime)); /// /// The MurmurHash3 finaliser (fmix32): spreads every input bit over all /// output bits. /// /// The hash to mix. /// The mixed hash. private static uint Mix(uint hash) => unchecked(XorShift(XorShift(XorShift(hash, 16) * 0x85EBCA6B, 13) * 0xC2B2AE35, 16)); /// /// Folds the high bits of a value into its low bits. /// /// The value. /// How far to shift right before the XOR. /// XOR shifted right. private static uint XorShift(uint value, int shift) => value ^ (value >> shift); /// /// One duty tag, led by a space so screen readers keep the tags apart. /// /// The duty. /// The space and the tag. private static Inline[] Duty(string duty) => [new LiteralInline(" "), .. Element("team-duty", duty)]; /// /// An element with escaped text inside. /// /// The value of the class attribute; written as is. /// The text; escaped by Markdig on rendering. /// The element name. /// Opening tag, text and closing tag. private static Inline[] Element(string classes, string text, string tag = "span") => [Open(tag, classes), new LiteralInline(text), Close(tag)]; /// /// An opening tag with a class, as a raw HTML inline. /// /// The element name. /// The value of the class attribute; written as is. /// /// Further attributes, written as is after the class, with a leading space. /// /// The tag. /// /// Only ever called with constants from this class, never with editor text: /// nothing here is escaped. /// private static HtmlInline Open(string tag, string classes, string attributes = "") => new($"<{tag} class=\"{classes}\"{attributes}>"); /// /// A closing tag, as a raw HTML inline. /// /// The element name. /// The tag. private static HtmlInline Close(string tag) => new($""); /// /// The first letter of a word, upper case. /// /// One word of a name. /// The letter, or an empty string if the word has none. private static string Initial(string word) => word.FirstOrDefault(char.IsLetter) is var letter and not '\0' ? letter.ToString().ToUpper(Cultures.German) : ""; /// /// The text after the name, without separators and parentheses. /// /// The inlines after the bold name on the first line. /// The role, or if nothing is left. private static string? RoleOf(IEnumerable inlines) => Markdown.PlainText(inlines).Trim(' ', ',', ':', ';', '-', '–') is var text && (text.StartsWith('(') && text.EndsWith(')') ? text[1..^1].Trim() : text) is { Length: > 0 } role ? role : null; /// /// Splits the italic duties line at its commas. /// /// The italic span. /// The duties, trimmed, without empty entries. private static string[] DutiesOf(EmphasisInline duties) => Markdown.PlainText(duties).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); /// /// The inlines of a line that carry content: no whitespace, no line break. /// /// One line from . /// The content inlines, in order. private static Inline[] Content(IEnumerable line) => [.. line.Where(inline => !Markdown.IsBlank(inline))]; /// /// Splits a paragraph's inlines into lines. /// /// The paragraph. /// /// The lines; each keeps the line break that ends it, so the note can be moved /// with its breaks intact. /// private static IEnumerable> Lines(ParagraphBlock paragraph) { var line = new List(); 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; } } }