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:
tleiningerandClaude Opus 5.5 committed 2026-10-01 12:52:51 +02:00
1 parent ca0b3c311c
commit 5eb36ad3e9
12 files changed
+683 -17

No files matched your search

@@ -12,7 +12,7 @@
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
{
<article>@Body</article>
<article class="markdown-body">@Body</article>
}
@if (_unavailable)
+3 -3
View File
@@ -12,7 +12,7 @@
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
{
<article>@Body</article>
<article class="markdown-body">@Body</article>
}
@if (_unavailable)
@@ -31,13 +31,13 @@ else
<summary>@topic.Title</summary>
@if (!string.IsNullOrWhiteSpace(topic.Intro))
{
<div class="faq-topic-intro">@((MarkupString)Markdown.ToHtml(topic.Intro))</div>
<div class="faq-topic-intro markdown-body">@((MarkupString)Markdown.ToHtml(topic.Intro))</div>
}
@foreach (var faq in topic.Faqs)
{
<details class="faq-item">
<summary>@faq.Question</summary>
<div class="faq-answer">
<div class="faq-answer markdown-body">
@((MarkupString)Markdown.ToHtml(faq.Answer))
</div>
</details>
@@ -9,7 +9,7 @@ else if (_page is not null)
{
<PageTitle>@_page.Title</PageTitle>
<h1>@_page.Title</h1>
<article>@Body</article>
<article class="markdown-body">@Body</article>
<PageEmbeds Embed="_page.Embed" />
}
@* No page and not unavailable: the slug was unknown; NavigationManager.NotFound()
@@ -15,7 +15,7 @@ else if (_post is not null)
<div class="post-content">
<PostDate Value="_post.Date" />
<h2>@_post.Title</h2>
@Body
<div class="markdown-body">@Body</div>
</div>
</article>
}
@@ -12,7 +12,7 @@
@if (_page is not null && !string.IsNullOrWhiteSpace(_page.Body))
{
<article>@Body</article>
<article class="markdown-body">@Body</article>
}
@if (_unavailable)
+114 -7
View File
@@ -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>&lt;div class="name"&gt;</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>&lt;script&gt;</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:::");
/// // &lt;div class="hinweis"&gt;&lt;p&gt;Bitte vormerken.&lt;/p&gt;&lt;/div&gt;
/// </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>&lt;p&gt;</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>&lt;url&gt;</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,
};
}
+360
View File
@@ -37,6 +37,14 @@
not --color-accent, so white text stays readable across the whole sweep. */
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #3a7a3e 100%);
/* Buttons in editor content (a lone mail link, links in an "aufruf" box): a
filled blue with white text. Own tokens, not --color-brand, because in dark
mode --color-brand turns light for link text, and white on that light blue
would fail WCAG AA. Both shades carry white at >= 5.5:1. */
--color-button: #2b6f9c;
--color-button-hover: #1f5477;
--color-on-button: #fff;
/* Headings. Its own token (not --color-brand) so headings and links can differ:
in dark mode headings become a soft white while links keep the brand blue. */
--color-heading: #2b6f9c;
@@ -123,6 +131,10 @@
--gradient-brand: linear-gradient(135deg, var(--color-header) 0%, #2f6b33 100%);
--color-button: #266f9c; /* the header blue: deep enough for white
text, calm on the dark page */
--color-button-hover: #1f5477;
--color-heading: #c9ced6; /* soft white, kept a step brighter than the
body text so headings still lead the page */
@@ -461,3 +473,351 @@ h1:focus {
padding-top: var(--space-2);
color: var(--color-text);
}
/* --- Editor content (.markdown-body) -------------------------------------------
Everything the editors write in PocketBase (page bodies, posts, FAQ answers, the
intros on /posts and /events) is Markdown rendered by Shared/Markdown.cs and
wrapped in .markdown-body. Editors cannot add classes or HTML, so the look of
their text comes entirely from these rules. Every selector is scoped to
.markdown-body so nothing here leaks into the site's own components, and the
rules must hold for any Markdown an editor might type, including none at all. */
/* The line icons the content styles need, as data URIs: no request, no foreign
host. Same Lucide paths as Shared/IconSet.cs (license notice there); CSS cannot
reach that C# set, so these are copies. Drawn in black and used as a mask over
background-color: currentColor, so each icon takes the colour of its text --
dark mode included -- just like the inline-SVG <Icon> component. */
.markdown-body {
--icon-external-link: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 3h6v6'/%3E%3Cpath d='M10 14 21 3'/%3E%3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'/%3E%3C/svg%3E");
--icon-file-text: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z'/%3E%3Cpath d='M14 2v5a1 1 0 0 0 1 1h5'/%3E%3Cpath d='M10 9H8'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
--icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
--icon-mail: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7'/%3E%3Crect x='2' y='4' width='20' height='16' rx='2'/%3E%3C/svg%3E");
}
/* A block's last element needs no gap below: the surrounding card or page already
spaces it, and a trailing margin makes boxes look bottom-heavy. */
.markdown-body > :last-child,
.markdown-body div > :last-child {
margin-bottom: 0;
}
/* Section headings get a short green bar above them. The bodies are mostly a run
of ## sections, and the bar makes each start easy to spot when scrolling on a
phone. Decoration only: the heading text carries the meaning. */
.markdown-body h2::before {
content: "";
display: block;
width: 2.5rem;
height: 4px;
margin-bottom: var(--space-2);
border-radius: 2px;
background: var(--color-accent);
}
/* Lists: the reading width of paragraphs, a little air between items, and the
bullets and numbers in the green accent so lists tie into the palette. */
.markdown-body ul,
.markdown-body ol {
margin: 0 0 var(--space-3);
padding-left: 1.5rem;
max-width: 42rem;
}
.markdown-body li + li {
margin-top: var(--space-1);
}
.markdown-body li::marker {
color: var(--color-accent-dark);
font-weight: 700;
}
/* Quotes (e.g. a passage from the rules of procedure): an accent bar and a faint
green wash set them apart from the editor's own words. */
.markdown-body blockquote {
margin: 0 0 var(--space-3);
padding: var(--space-3) var(--space-4);
max-width: 42rem;
border-left: 4px solid var(--color-accent);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-accent-tint);
}
/* Links: a soft underline that firms up on hover. Underlined at all (not colour
alone) so links stay recognisable for colour-blind visitors. */
.markdown-body a {
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: color var(--transition), text-decoration-color var(--transition);
}
.markdown-body a:hover {
text-decoration-color: currentColor;
}
/* Links that leave the site get a small external-link icon, so visitors know
before clicking. Editors write links to our own pages as relative paths
(/contact), so "absolute http(s) URL" means "another site". PDFs are left out:
they carry their own file icon below. */
.markdown-body a:is([href^="http://"], [href^="https://"]):not([href$=".pdf" i])::after {
content: "";
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-left: 0.2em;
vertical-align: -0.05em;
background-color: currentColor;
mask: var(--icon-external-link) center / contain no-repeat;
}
/* PDF links inside running text: a file icon in front and a small "PDF" tag
behind, so visitors know a download (not a web page) opens. */
.markdown-body a[href$=".pdf" i]::before {
content: "";
display: inline-block;
width: 1em;
height: 1em;
margin-right: 0.25em;
vertical-align: -0.15em;
background-color: currentColor;
mask: var(--icon-file-text) center / contain no-repeat;
}
.markdown-body a[href$=".pdf" i]::after {
content: "PDF";
margin-left: 0.35em;
padding: 0 0.35em;
border-radius: 4px;
background: var(--color-brand-tint);
color: var(--color-text-soft);
font-size: 0.75em;
font-weight: 700;
letter-spacing: 0.04em;
}
/* A PDF link standing alone in its paragraph or list item becomes a file card;
this is what turns a plain list of documents on /downloads into a download
area. Markdown.cs marks such paragraphs/items .lone-link, because CSS cannot see
the text around a link. Two child paths: in a tight list the link sits directly
in the <li>, in a loose list inside a <p>. */
.markdown-body .lone-link > a[href$=".pdf" i],
.markdown-body .lone-link > p > a[href$=".pdf" i] {
display: flex;
align-items: center;
gap: var(--space-3);
max-width: 42rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
font-weight: 600;
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.markdown-body .lone-link > a[href$=".pdf" i]:hover,
.markdown-body .lone-link > p > a[href$=".pdf" i]:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
}
.markdown-body .lone-link > a[href$=".pdf" i]::before,
.markdown-body .lone-link > p > a[href$=".pdf" i]::before {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
margin: 0;
color: var(--color-accent-dark);
background-color: var(--color-accent-dark);
}
/* The "PDF" tag moves to the card's right edge. */
.markdown-body .lone-link > a[href$=".pdf" i]::after,
.markdown-body .lone-link > p > a[href$=".pdf" i]::after {
margin-left: auto;
}
/* A list of file cards needs no bullets and no indent: the cards are the
structure. */
.markdown-body li.lone-link:has(> a[href$=".pdf" i], > p > a[href$=".pdf" i]) {
list-style: none;
}
.markdown-body ul:has(> li.lone-link > a[href$=".pdf" i], > li.lone-link > p > a[href$=".pdf" i]) {
padding-left: 0;
}
/* Buttons: a mail link alone in its paragraph (the contact page's main action),
and every link inside an "aufruf" box. Filled with the button blue (see
--color-button for why not --color-brand); hover darkens it and adds a shadow,
nothing moves. */
.markdown-body .lone-link > a[href^="mailto:"],
.markdown-body .aufruf a {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
background: var(--color-button);
color: var(--color-on-button);
font-weight: 700;
text-decoration: none;
transition: background-color var(--transition), box-shadow var(--transition);
}
.markdown-body .lone-link > a[href^="mailto:"]:hover,
.markdown-body .aufruf a:hover {
background: var(--color-button-hover);
color: var(--color-on-button);
box-shadow: var(--shadow-hover);
}
/* The mail button leads with an envelope, so it reads as "write to us" at a
glance. */
.markdown-body .lone-link > a[href^="mailto:"]::before {
content: "";
width: 1.1em;
height: 1.1em;
background-color: currentColor;
mask: var(--icon-mail) center / contain no-repeat;
}
/* --- Design blocks (::: name ... :::) ------------------------------------------
Markdown.cs turns "::: name" into <div class="name">. The names are German
because editors type them (CLAUDE.md: values German). An unknown name has no
rule here and so simply reads as normal text -- a typo never breaks a page. */
/* ::: aufruf -- a highlighted call to action: a tinted box with a brand-blue edge.
Its links become buttons (rule above). */
.markdown-body .aufruf {
margin: 0 0 var(--space-4);
padding: var(--space-4);
max-width: 42rem;
border-left: 4px solid var(--color-brand);
border-radius: var(--radius);
background: var(--color-brand-tint);
}
/* ::: hinweis -- a quiet info box with an info icon in front. Green wash, not the
yellow highlight: it informs, it does not warn. The icon is decoration; the text
says what matters. */
.markdown-body .hinweis {
position: relative;
margin: 0 0 var(--space-4);
padding: var(--space-3) var(--space-4) var(--space-3) 3.25rem;
max-width: 42rem;
border-radius: var(--radius);
background: var(--color-accent-tint);
}
.markdown-body .hinweis::before {
content: "";
position: absolute;
top: calc(var(--space-3) + 0.2rem);
left: var(--space-4);
width: 1.25rem;
height: 1.25rem;
background-color: var(--color-accent-dark);
mask: var(--icon-info) center / contain no-repeat;
}
/* ::: kacheln and ::: kennzahlen -- a list laid out as a grid of cards. auto-fill
with a minimum width gives as many columns as fit (one on a phone) without any
breakpoint. */
.markdown-body :is(.kacheln, .kennzahlen) > ul {
display: grid;
gap: var(--space-3);
max-width: none;
margin: 0 0 var(--space-4);
padding: 0;
list-style: none;
}
.markdown-body .kacheln > ul {
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
}
.markdown-body .kennzahlen > ul {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
/* The grid gap does the spacing; the list's own item spacing would only push the
later cards of the first row down. */
.markdown-body :is(.kacheln, .kennzahlen) li + li {
margin-top: 0;
}
/* Kacheln: each item a white card; a bold start ("- **Mensa** ...") becomes the
card's title on its own line. */
.markdown-body .kacheln li {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius);
background: var(--color-bg);
box-shadow: var(--shadow);
}
.markdown-body .kacheln li > strong:first-child {
display: block;
margin-bottom: var(--space-1);
color: var(--color-heading);
}
/* Kennzahlen: "- **seit 1975** Gründung" -- the bold part becomes a big figure,
the rest a small caption beneath. Brand blue for the figure, not the green: the
green does not reach 3:1 on its own tint, the blue does. */
.markdown-body .kennzahlen li {
padding: var(--space-4) var(--space-3);
border-radius: var(--radius);
background: var(--color-accent-tint);
color: var(--color-text-soft);
text-align: center;
}
.markdown-body .kennzahlen li > strong:first-child {
display: block;
color: var(--color-brand);
font-size: 2rem;
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-heading);
line-height: 1.15;
}
/* Tables (pipe tables): not in the content yet, styled ahead of time. As a block
with overflow they scroll sideways on a phone instead of stretching the page. */
.markdown-body table {
display: block;
max-width: 100%;
overflow-x: auto;
margin: 0 0 var(--space-4);
border-collapse: collapse;
}
.markdown-body th,
.markdown-body td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
.markdown-body th {
background: var(--color-surface);
color: var(--color-heading);
font-weight: 700;
}
/* Images never overflow the column and get the site's rounded corners. */
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
}
/* A horizontal rule as a calm thin line with room around it. */
.markdown-body hr {
margin: var(--space-5) 0;
border: none;
border-top: 1px solid var(--color-border);
}