using Markdig; using Markdig.Renderers.Html; using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace Elternbeirat.Web.Shared; /// /// Renders the editors' Markdown (the body, intro and answer /// fields from PocketBase) to the HTML the pages show. /// /// /// The pipeline is CommonMark plus exactly two extensions, each there for a reason /// an editor can see: /// /// /// /// Custom containers (::: name … :::) turn a block /// into <div class="name">. That is how editors use the /// design blocks (kennzahlen, aufruf, kacheln, /// hinweis) without writing HTML. An unknown name just yields a /// div without styling, so a typo never breaks a page. /// /// /// /// /// Pipe tables: plain CommonMark has no tables at all, so without /// this the table styles in app.css could never apply. /// /// /// /// /// Raw HTML in the Markdown is escaped, not passed through /// ('s DisableHtml). The result is /// rendered as a MarkupString, so a passed-through <script> /// would run in the visitor's browser, and the privacy text promises that no /// program code runs there. Editors style pages with the blocks instead. /// /// /// After parsing, a paragraph (or list item) that consists of nothing but one /// link gets the class . Pure CSS cannot tell /// "a link alone on its line" from "a link inside a sentence" (selectors ignore /// the text around an element), but the stylesheet needs exactly that to show a /// lone mailto: link as a button and a lone PDF link as a file card. /// /// /// public static class Markdown { /// /// The class marking a paragraph or list item whose only content is one link. /// app.css keys the mail button and the PDF card off it. /// public const string LoneLinkClass = "lone-link"; private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() .UseCustomContainers() .UsePipeTables() .DisableHtml() .Build(); /// /// Converts a Markdown string to HTML. /// /// The editor's Markdown; may be . /// /// The rendered HTML, or an empty string for or empty /// input, so callers can bind the result directly. /// /// /// /// Markdown.ToHtml("::: hinweis\nBitte vormerken.\n:::"); /// // <div class="hinweis"><p>Bitte vormerken.</p></div> /// /// public static string ToHtml(string? markdown) => string.IsNullOrEmpty(markdown) ? "" : Markdig.Markdown.ToHtml(MarkLoneLinks(Markdig.Markdown.Parse(markdown, Pipeline)), Pipeline); /// /// Adds to every paragraph that holds nothing but /// one link. /// /// The parsed document; changed in place. /// The same , for chaining. /// /// Inside a list item the class goes on the item, not the paragraph: in a tight /// list Markdig writes no <p> at all, so a class on the paragraph /// would be silently dropped. /// private static MarkdownDocument MarkLoneLinks(MarkdownDocument document) { foreach (var paragraph in document.Descendants().Where(IsLoneLink)) { MarkdownObject target = paragraph.Parent is ListItemBlock { Count: 1 } item ? item : paragraph; target.GetAttributes().AddClass(LoneLinkClass); } return document; } /// /// Whether a paragraph's content is exactly one link, ignoring surrounding /// whitespace and line breaks. /// /// The paragraph to inspect. /// /// for one link (inline [text](url) or an /// autolink <url>, but not an image) and nothing else. /// private static bool IsLoneLink(ParagraphBlock paragraph) => paragraph.Inline? .Where(inline => !IsBlank(inline)) .ToList() is [LinkInline { IsImage: false } or AutolinkInline]; /// /// Whether an inline carries no visible content (whitespace or a line break). /// /// The inline to inspect. /// if it can be ignored when looking for a lone link. private static bool IsBlank(Inline inline) => inline switch { LineBreakInline => true, LiteralInline literal => literal.Content.IsEmptyOrWhitespace(), _ => false, }; }