Add home hero with intro and buttons from the body, next-event card and page tiles
This commit is contained in:
1 parent
b737f57ef7
commit
e51afeb34b
23 files changed
+1262
-65
No files matched your search
@@ -0,0 +1,33 @@
|
||||
namespace Elternbeirat.Web.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the current calendar day as people at the school see it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The container's clock runs in UTC, so <see cref="DateTime.Today"/> would flip to
|
||||
/// the next day at 01:00 or 02:00 Berlin time, and an event shortly after midnight
|
||||
/// would count as past for an hour or two. Event times are Berlin wall-clock
|
||||
/// values (see <c>LocalDateTimeConverter</c>), so "today" has to be a Berlin day
|
||||
/// too.
|
||||
/// <para>
|
||||
/// The time comes from an injected <see cref="TimeProvider"/>, not the system
|
||||
/// clock, so tests can pin "today" and check what a page shows before and after
|
||||
/// an event.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class BerlinClock
|
||||
{
|
||||
/// <summary>
|
||||
/// IANA id; resolves on every platform .NET supports, Windows included.
|
||||
/// </summary>
|
||||
private static readonly TimeZoneInfo Berlin =
|
||||
TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
/// <summary>
|
||||
/// Returns today's date in Berlin.
|
||||
/// </summary>
|
||||
/// <param name="time">The clock to read, usually <see cref="TimeProvider.System"/>.</param>
|
||||
/// <returns>The current Berlin calendar day.</returns>
|
||||
public static DateOnly Today(TimeProvider time) =>
|
||||
DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(time.GetUtcNow(), Berlin).DateTime);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
@@ -45,7 +47,7 @@ namespace Elternbeirat.Web.Shared;
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <seealso cref="IconSet"/>
|
||||
public static class Markdown
|
||||
public static partial class Markdown
|
||||
{
|
||||
/// <summary>
|
||||
/// The class marking a paragraph or list item whose only content is one link.
|
||||
@@ -75,9 +77,155 @@ public static class Markdown
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string ToHtml(string? markdown) =>
|
||||
string.IsNullOrEmpty(markdown)
|
||||
? ""
|
||||
: Markdig.Markdown.ToHtml(MarkLoneLinks(Markdig.Markdown.Parse(markdown, Pipeline)), Pipeline);
|
||||
string.IsNullOrEmpty(markdown) ? "" : Render(Parse(markdown));
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the first sentence of a Markdown text as plain text, e.g. as the
|
||||
/// short description on a home page tile.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
|
||||
/// <param name="maxLength">
|
||||
/// The longest sentence kept as is; a longer one is cut at the last word
|
||||
/// boundary before the limit and ends in <c>…</c>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The first sentence of the first top-level paragraph, with all Markdown
|
||||
/// removed and whitespace collapsed, or an empty string when there is no such
|
||||
/// paragraph (empty body, or only headings, lists and blocks).
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// <paramref name="maxLength"/> is zero or negative.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// Only top-level paragraphs count: a heading repeats the title, and the text in
|
||||
/// a list or a <c>:::</c> block is rarely a sentence that describes the page.
|
||||
/// <para>
|
||||
/// A sentence ends at <c>.</c>, <c>!</c> or <c>?</c> followed by a space or
|
||||
/// the end. A period after a single letter or a number does not count, so
|
||||
/// <c>z. B.</c> and <c>13. November</c> do not cut the sentence short. A
|
||||
/// longer abbreviation such as <c>bzw.</c> still does; that costs the rest
|
||||
/// of a teaser, never the page.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// Markdown.FirstSentence("# Vorstand\n\nWir sind **sieben** Eltern. Mehr unten.");
|
||||
/// // "Wir sind sieben Eltern."
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static string FirstSentence(string? markdown, int maxLength = 140) =>
|
||||
maxLength <= 0
|
||||
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "Must be positive.")
|
||||
: string.IsNullOrWhiteSpace(markdown)
|
||||
? ""
|
||||
: Parse(markdown).OfType<ParagraphBlock>().FirstOrDefault()?.Inline is { } inline
|
||||
? Shorten(UpToSentenceEnd(PlainText(inline)), maxLength)
|
||||
: "";
|
||||
|
||||
/// <summary>
|
||||
/// Parses Markdown with the site's pipeline, for callers that take the document
|
||||
/// apart before rendering it (see <see cref="Render"/>).
|
||||
/// </summary>
|
||||
/// <param name="markdown">The editor's Markdown.</param>
|
||||
/// <returns>The parsed document.</returns>
|
||||
internal static MarkdownDocument Parse(string markdown) =>
|
||||
Markdig.Markdown.Parse(markdown, Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document from <see cref="Parse"/> to HTML, marking lone links on
|
||||
/// the way, exactly as <see cref="ToHtml"/> does.
|
||||
/// </summary>
|
||||
/// <param name="document">The parsed document; changed in place.</param>
|
||||
/// <returns>The rendered HTML.</returns>
|
||||
internal static string Render(MarkdownDocument document) =>
|
||||
Markdig.Markdown.ToHtml(MarkLoneLinks(document), Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Flattens inline Markdown to the text a reader sees: emphasis and link
|
||||
/// markup dropped, link text kept, images left out, whitespace collapsed.
|
||||
/// </summary>
|
||||
/// <param name="inline">The inline content, e.g. of a paragraph or heading.</param>
|
||||
/// <returns>The visible text, trimmed.</returns>
|
||||
internal static string PlainText(ContainerInline inline) =>
|
||||
Whitespace().Replace(AppendText(new StringBuilder(), inline).ToString(), " ").Trim();
|
||||
|
||||
/// <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 links that stand
|
||||
/// alone in a paragraph.
|
||||
/// </returns>
|
||||
internal static bool IsBlank(Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LineBreakInline => true,
|
||||
LiteralInline literal => literal.Content.IsEmptyOrWhitespace(),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Appends the visible text of an inline and its children.
|
||||
/// </summary>
|
||||
/// <param name="text">The builder to append to.</param>
|
||||
/// <param name="inline">The inline to flatten.</param>
|
||||
/// <returns>The same <paramref name="text"/>, for chaining.</returns>
|
||||
private static StringBuilder AppendText(StringBuilder text, Inline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LiteralInline literal => text.Append(literal.Content.ToString()),
|
||||
CodeInline code => text.Append(code.Content),
|
||||
HtmlEntityInline entity => text.Append(entity.Transcoded.ToString()),
|
||||
AutolinkInline autolink => text.Append(autolink.Url),
|
||||
LineBreakInline => text.Append(' '),
|
||||
LinkInline { IsImage: true } => text,
|
||||
ContainerInline container => container.Aggregate(text, AppendText),
|
||||
_ => text,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Cuts a text after its first sentence.
|
||||
/// </summary>
|
||||
/// <param name="text">Plain text.</param>
|
||||
/// <returns>
|
||||
/// The first sentence including its end mark, or the whole text if it has none.
|
||||
/// </returns>
|
||||
private static string UpToSentenceEnd(string text) =>
|
||||
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
|
||||
|
||||
/// <summary>
|
||||
/// Shortens a text to at most <paramref name="maxLength"/> characters plus an
|
||||
/// ellipsis, cutting between words.
|
||||
/// </summary>
|
||||
/// <param name="text">Plain text.</param>
|
||||
/// <param name="maxLength">The longest text kept as is.</param>
|
||||
/// <returns>The text, shortened if needed.</returns>
|
||||
/// <remarks>
|
||||
/// The search for a space starts at <paramref name="maxLength"/> itself, so a
|
||||
/// word that ends exactly at the limit is kept. A single word longer than the
|
||||
/// limit is cut hard.
|
||||
/// </remarks>
|
||||
private static string Shorten(string text, int maxLength) =>
|
||||
text.Length <= maxLength
|
||||
? text
|
||||
: text.LastIndexOf(' ', maxLength) is > 0 and var space
|
||||
? $"{text[..space].TrimEnd(',', ';', ':', '-', '–')}…"
|
||||
: $"{text[..maxLength]}…";
|
||||
|
||||
/// <summary>
|
||||
/// Matches the end mark of a sentence: <c>.</c>, <c>!</c> or <c>?</c> before a
|
||||
/// space or the end, unless it follows a lone letter (<c>z.</c>) or a number
|
||||
/// (<c>13.</c>).
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<!\b\p{L}|\b\d+)[.!?](?=\s|$)")]
|
||||
private static partial Regex SentenceEnd();
|
||||
|
||||
/// <summary>
|
||||
/// Matches a run of whitespace, collapsed to one space in plain text.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex Whitespace();
|
||||
|
||||
/// <summary>
|
||||
/// Adds <see cref="LoneLinkClass"/> to every paragraph that holds nothing but
|
||||
@@ -114,17 +262,4 @@ public static class Markdown
|
||||
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