129 lines
5.4 KiB
C#
129 lines
5.4 KiB
C#
using Elternbeirat.Web.Shared;
|
|
using Markdig.Syntax;
|
|
using Markdig.Syntax.Inlines;
|
|
|
|
namespace Elternbeirat.Web.Features.Home;
|
|
|
|
/// <summary>
|
|
/// The head of the home page, taken apart from the <c>home</c> page's Markdown body:
|
|
/// a large heading, an intro and a row of buttons.
|
|
/// </summary>
|
|
/// <param name="Title">The large heading.</param>
|
|
/// <param name="IntroHtml">The intro rendered to HTML, or an empty string.</param>
|
|
/// <param name="Links">The buttons, in the order the editor wrote them; may be empty.</param>
|
|
/// <remarks>
|
|
/// Everything visible here is written by editors in PocketBase. The page only gives
|
|
/// it the hero layout, so the editor needs no new field and no special syntax:
|
|
/// <list type="bullet">
|
|
/// <item><description>The first <c># heading</c> becomes the large heading.</description></item>
|
|
/// <item>
|
|
/// <description>
|
|
/// A last paragraph made of nothing but links becomes the button row;
|
|
/// the first link is the primary button.
|
|
/// </description>
|
|
/// </item>
|
|
/// <item><description>Everything else is the intro text.</description></item>
|
|
/// </list>
|
|
/// <para>
|
|
/// <see cref="Fallback"/> keeps a static heading when PocketBase cannot be
|
|
/// reached, so the start page never renders without a title.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <example>
|
|
/// <code>
|
|
/// HomeIntro.Parse("# Willkommen\n\nSchön, dass Sie da sind.\n\n[Termine](/events) [Kontakt](/contact)");
|
|
/// // Title "Willkommen", IntroHtml "<p>Schön, dass Sie da sind.</p>\n", two links
|
|
/// </code>
|
|
/// </example>
|
|
public sealed record HomeIntro(string Title, string IntroHtml, IReadOnlyList<HomeLink> Links)
|
|
{
|
|
/// <summary>
|
|
/// The heading shown when the body has none or PocketBase is not reachable.
|
|
/// </summary>
|
|
public const string FallbackTitle = "Elternbeirat der IGMH";
|
|
|
|
/// <summary>
|
|
/// The head of the home page without any content from PocketBase: the static
|
|
/// heading, no intro, no buttons.
|
|
/// </summary>
|
|
public static HomeIntro Fallback { get; } = new(FallbackTitle, "", []);
|
|
|
|
/// <summary>
|
|
/// Takes the <c>home</c> page's body apart into heading, intro and buttons.
|
|
/// </summary>
|
|
/// <param name="markdown">The body; may be <see langword="null"/> or empty.</param>
|
|
/// <returns>
|
|
/// The parts; <see cref="Fallback"/> for an empty body. Without a level-1
|
|
/// heading, the title is <see cref="FallbackTitle"/> and the rest is used as is.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// Only top-level blocks are looked at: a heading or a link paragraph inside a
|
|
/// list or a <c>:::</c> block stays part of the intro. Links in the button row
|
|
/// may be separated by spaces or line breaks; any other text in the paragraph
|
|
/// (e.g. "oder") keeps it a normal paragraph, because the button row would drop
|
|
/// that text.
|
|
/// </remarks>
|
|
public static HomeIntro Parse(string? markdown)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(markdown))
|
|
{
|
|
return Fallback;
|
|
}
|
|
|
|
var document = Markdown.Parse(markdown);
|
|
|
|
var heading = document.OfType<HeadingBlock>().FirstOrDefault(block => block.Level == 1);
|
|
if (heading is not null)
|
|
{
|
|
document.Remove(heading);
|
|
}
|
|
|
|
var links = document.LastChild is ParagraphBlock last ? LinksOnly(last) : [];
|
|
if (links.Count > 0)
|
|
{
|
|
document.RemoveAt(document.Count - 1);
|
|
}
|
|
|
|
var title = heading?.Inline is { } inline ? Markdown.PlainText(inline) : "";
|
|
return new HomeIntro(
|
|
string.IsNullOrEmpty(title) ? FallbackTitle : title,
|
|
Markdown.Render(document),
|
|
links);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the links of a paragraph that holds nothing but links.
|
|
/// </summary>
|
|
/// <param name="paragraph">The paragraph to inspect.</param>
|
|
/// <returns>
|
|
/// Its links, or an empty list if the paragraph contains anything besides links
|
|
/// and whitespace (including an image).
|
|
/// </returns>
|
|
private static List<HomeLink> LinksOnly(ParagraphBlock paragraph) =>
|
|
paragraph.Inline?.Where(inline => !Markdown.IsBlank(inline)).ToList() is { Count: > 0 } inlines
|
|
&& inlines.All(inline => inline is LinkInline { IsImage: false } or AutolinkInline)
|
|
? [.. inlines.Select(ToLink)]
|
|
: [];
|
|
|
|
/// <summary>
|
|
/// Turns a link inline into a button.
|
|
/// </summary>
|
|
/// <param name="inline">A <see cref="LinkInline"/> or <see cref="AutolinkInline"/>.</param>
|
|
/// <returns>The button's target and visible text.</returns>
|
|
/// <exception cref="ArgumentException"><paramref name="inline"/> is neither kind of link.</exception>
|
|
private static HomeLink ToLink(Inline inline) =>
|
|
inline switch
|
|
{
|
|
LinkInline link => new HomeLink(link.Url ?? "", Markdown.PlainText(link)),
|
|
AutolinkInline autolink => new HomeLink(autolink.Url, autolink.Url),
|
|
_ => throw new ArgumentException("Not a link.", nameof(inline)),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// A button in the head of the home page.
|
|
/// </summary>
|
|
/// <param name="Href">The link target as the editor wrote it.</param>
|
|
/// <param name="Text">The visible button text.</param>
|
|
public sealed record HomeLink(string Href, string Text);
|