Add post and event cards, shared calendar sheet and per-event ICS; rework past events and next-event card

This commit is contained in:
tleininger committed 2026-10-01 15:32:01 +02:00
1 parent d328adac5c
commit b2dc033de1
33 files changed
+1150 -206

No files matched your search

+73
View File
@@ -55,6 +55,12 @@ public static partial class Markdown
/// </summary>
public const string LoneLinkClass = "lone-link";
/// <summary>
/// The default teaser length: about two lines on a card, enough to say what a
/// post is about without turning the list into a wall of text.
/// </summary>
public const int TeaserLength = 160;
private static readonly MarkdownPipeline Pipeline =
new MarkdownPipelineBuilder()
.UseCustomContainers()
@@ -120,6 +126,53 @@ public static partial class Markdown
? UpToSentenceEnd(PlainText(inline)).TrimEnd(':', ' ')
: "";
/// <summary>
/// Builds a short plain-text teaser from a Markdown text, e.g. for a post card.
/// </summary>
/// <param name="markdown">The editor's Markdown; may be <see langword="null"/>.</param>
/// <param name="maxLength">
/// The longest teaser, in characters, before the ellipsis. Defaults to
/// <see cref="TeaserLength"/>.
/// </param>
/// <returns>
/// The text of all paragraphs (including those in lists, quotes and <c>:::</c>
/// blocks), with all Markdown removed and whitespace collapsed. A longer text is
/// cut at the last word boundary within <paramref name="maxLength"/> and ends
/// in <c>…</c>. An empty string when there is no paragraph text at all.
/// </returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="maxLength"/> is zero or negative.
/// </exception>
/// <remarks>
/// Headings are skipped: on a card the title already heads the teaser. Unlike
/// <see cref="FirstSentence"/> the teaser does not stop at the first sentence,
/// because a post often opens with a short line ("Liebe Eltern,") that says
/// nothing on its own.
/// <para>
/// Punctuation left dangling at the cut (<c>,</c>, <c>.</c>, <c>:</c> …) is
/// dropped, so the teaser never ends in <c>,…</c>.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// Markdown.Teaser("## Rückblick\n\nDer **Basar** war ein voller Erfolg.", 20);
/// // "Der Basar war ein…"
/// </code>
/// </example>
public static string Teaser(string? markdown, int maxLength = TeaserLength) =>
maxLength <= 0
? throw new ArgumentOutOfRangeException(nameof(maxLength), maxLength, "The teaser needs room for at least one character.")
: string.IsNullOrWhiteSpace(markdown)
? ""
: Shorten(
string.Join(
' ',
Parse(markdown)
.Descendants<ParagraphBlock>()
.Select(paragraph => paragraph.Inline is { } inline ? PlainText(inline) : "")
.Where(text => text.Length > 0)),
maxLength);
/// <summary>
/// Parses Markdown with the site's pipeline, for callers that take the document
/// apart before rendering it (see <see cref="Render"/>).
@@ -192,6 +245,26 @@ public static partial class Markdown
private static string UpToSentenceEnd(string text) =>
SentenceEnd().Match(text) is { Success: true } end ? text[..(end.Index + 1)] : text;
/// <summary>
/// Cuts a text to a word boundary and marks the cut.
/// </summary>
/// <param name="text">Plain text.</param>
/// <param name="maxLength">The longest result before the ellipsis.</param>
/// <returns>
/// The text unchanged if it fits; otherwise everything up to the last space
/// within <paramref name="maxLength"/>, trailing punctuation removed, plus
/// <c>…</c>. A single word longer than <paramref name="maxLength"/> is cut hard.
/// </returns>
/// <remarks>
/// The space is searched one character past the limit: if the text breaks
/// exactly at <paramref name="maxLength"/>, the last whole word still fits.
/// </remarks>
private static string Shorten(string text, int maxLength) =>
text.Length <= maxLength
? text
: (text[..(maxLength + 1)].LastIndexOf(' ') is var space and > 0 ? text[..space] : text[..maxLength])
.TrimEnd(' ', ',', ';', ':', '.', '-', '–') + "…";
/// <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