Style editor Markdown with .markdown-body and add ::: design blocks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca0b3c311c
commit
5eb36ad3e9
12 files changed
+683
-17
No files matched your search
@@ -1,23 +1,130 @@
|
||||
using Markdig;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Renders Markdown to HTML. Content now comes from PocketBase as raw Markdown
|
||||
/// (the <c>body</c> field), so the pages render it at display time instead of
|
||||
/// reading pre-rendered HTML from files.
|
||||
/// Renders the editors' Markdown (the <c>body</c>, <c>intro</c> and <c>answer</c>
|
||||
/// fields from PocketBase) to the HTML the pages show.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pipeline is CommonMark plus exactly two extensions, each there for a reason
|
||||
/// an editor can see:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Custom containers</b> (<c>::: name</c> … <c>:::</c>) turn a block
|
||||
/// into <c><div class="name"></c>. That is how editors use the
|
||||
/// design blocks (<c>kennzahlen</c>, <c>aufruf</c>, <c>kacheln</c>,
|
||||
/// <c>hinweis</c>) without writing HTML. An unknown name just yields a
|
||||
/// div without styling, so a typo never breaks a page.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <description>
|
||||
/// <b>Pipe tables</b>: plain CommonMark has no tables at all, so without
|
||||
/// this the table styles in <c>app.css</c> could never apply.
|
||||
/// </description>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Raw HTML in the Markdown is <b>escaped</b>, not passed through
|
||||
/// (<see cref="MarkdownPipelineBuilder"/>'s <c>DisableHtml</c>). The result is
|
||||
/// rendered as a <c>MarkupString</c>, so a passed-through <c><script></c>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// After parsing, a paragraph (or list item) that consists of nothing but one
|
||||
/// link gets the class <see cref="LoneLinkClass"/>. 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 <c>mailto:</c> link as a button and a lone PDF link as a file card.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="IconSet"/>
|
||||
public static class Markdown
|
||||
{
|
||||
/// <summary>
|
||||
/// The class marking a paragraph or list item whose only content is one link.
|
||||
/// <c>app.css</c> keys the mail button and the PDF card off it.
|
||||
/// </summary>
|
||||
public const string LoneLinkClass = "lone-link";
|
||||
|
||||
private static readonly MarkdownPipeline Pipeline =
|
||||
new MarkdownPipelineBuilder().Build();
|
||||
new MarkdownPipelineBuilder()
|
||||
.UseCustomContainers()
|
||||
.UsePipeTables()
|
||||
.DisableHtml()
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Converts a Markdown string to HTML. Returns an empty string for
|
||||
/// <c>null</c> or empty input, so callers can bind the result directly.
|
||||
/// Converts a Markdown string to HTML.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <returns>
|
||||
/// The rendered HTML, or an empty string for <see langword="null"/> or empty
|
||||
/// input, so callers can bind the result directly.
|
||||
/// </returns>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.ToHtml("::: hinweis\nBitte vormerken.\n:::");
|
||||
/// // <div class="hinweis"><p>Bitte vormerken.</p></div>
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string ToHtml(string? markdown) =>
|
||||
string.IsNullOrEmpty(markdown)
|
||||
? ""
|
||||
: Markdig.Markdown.ToHtml(markdown, Pipeline);
|
||||
: Markdig.Markdown.ToHtml(MarkLoneLinks(Markdig.Markdown.Parse(markdown, Pipeline)), Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
||||
/// one link.
|
||||
/// </summary>
|
||||
/// <param name="document">The parsed document; changed in place.</param>
|
||||
/// <returns>The same <paramref name="document"/>, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// Inside a list item the class goes on the item, not the paragraph: in a tight
|
||||
/// list Markdig writes no <c><p></c> at all, so a class on the paragraph
|
||||
/// would be silently dropped.
|
||||
/// </remarks>
|
||||
private static MarkdownDocument MarkLoneLinks(MarkdownDocument document)
|
||||
{
|
||||
foreach (var paragraph in document.Descendants<ParagraphBlock>().Where(IsLoneLink))
|
||||
{
|
||||
MarkdownObject target = paragraph.Parent is ListItemBlock { Count: 1 } item ? item : paragraph;
|
||||
target.GetAttributes().AddClass(LoneLinkClass);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a paragraph's content is exactly one link, ignoring surrounding
|
||||
/// whitespace and line breaks.
|
||||
/// </summary>
|
||||
/// <param name="paragraph">The paragraph to inspect.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> for one link (inline <c>[text](url)</c> or an
|
||||
/// autolink <c><url></c>, but not an image) and nothing else.
|
||||
/// </returns>
|
||||
private static bool IsLoneLink(ParagraphBlock paragraph) =>
|
||||
paragraph.Inline?
|
||||
.Where(inline => !IsBlank(inline))
|
||||
.ToList() is [LinkInline { IsImage: false } or AutolinkInline];
|
||||
|
||||
/// <summary>
|
||||
/// Whether an inline carries no visible content (whitespace or a line break).
|
||||
/// </summary>
|
||||
/// <param name="inline">The inline to inspect.</param>
|
||||
/// <returns><see langword="true"/> if it can be ignored when looking for a lone link.</returns>
|
||||
private static bool IsBlank(Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LineBreakInline => true,
|
||||
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user