Style editor Markdown with .markdown-body and add ::: design blocks #52

Merged
Tom merged 1 commits from design/markdown-blocks into main 2026-10-01 12:54:24 +02:00
12 changed files with 683 additions and 17 deletions
Showing only changes of commit 5eb36ad3e9 - Show all commits

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
using Elternbeirat.Web.Shared;
namespace Elternbeirat.Web.Tests;
/// <summary>
/// Unit tests for the Markdown pipeline behind every editor-written body. Pure logic,
/// no PocketBase needed.
/// </summary>
public class MarkdownTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
public void Empty_input_renders_nothing(string? markdown) =>
Markdown.ToHtml(markdown).ShouldBe("");
[Fact]
public void Custom_container_renders_a_div_with_its_name_as_class()
{
var html = Markdown.ToHtml("::: kennzahlen\n- **seit 1975** Gründung\n:::");
html.ShouldContain("<div class=\"kennzahlen\">");
html.ShouldContain("<strong>seit 1975</strong>");
}
[Fact]
public void Unknown_container_renders_its_content_as_normal_text()
{
// A typo in the block name must cost the styling, never the text.
var html = Markdown.ToHtml("::: gibtsnicht\nNormaler Text.\n:::");
html.ShouldContain("<p>Normaler Text.</p>");
html.ShouldNotContain(":::");
}
[Fact]
public void Raw_html_is_escaped_not_passed_through()
{
// The body is rendered as a MarkupString; a passed-through <script> would run
// in the visitor's browser.
var html = Markdown.ToHtml("Hallo <script>alert(1)</script>\n\n<div onclick=\"x()\">Box</div>");
html.ShouldNotContain("<script>");
html.ShouldNotContain("<div onclick");
html.ShouldContain("&lt;script&gt;");
}
[Fact]
public void Pipe_table_renders_a_table()
{
var html = Markdown.ToHtml("| Fach | Raum |\n|---|---|\n| Mathe | A1 |");
html.ShouldContain("<table>");
html.ShouldContain("<td>Mathe</td>");
}
[Theory]
[InlineData("[kontakt@example.org](mailto:kontakt@example.org)")]
[InlineData("<mailto:kontakt@example.org>")] // autolink
[InlineData("[Satzung](/dokumente/satzung.pdf) ")] // trailing whitespace ignored
public void Link_alone_in_its_paragraph_is_marked(string markdown) =>
Markdown.ToHtml(markdown).ShouldStartWith($"<p class=\"{Markdown.LoneLinkClass}\">");
[Theory]
[InlineData("Schreiben Sie an [uns](mailto:kontakt@example.org).")]
[InlineData("[eins](/a) und [zwei](/b)")]
[InlineData("![Foto](/bild.jpg)")] // an image is not a link
[InlineData("Nur Text.")]
public void Paragraph_that_is_not_a_single_link_is_not_marked(string markdown) =>
Markdown.ToHtml(markdown).ShouldNotContain(Markdown.LoneLinkClass);
[Fact]
public void Lone_link_in_a_tight_list_marks_the_list_item()
{
// A tight list renders no <p>, so the class has to sit on the <li>.
var html = Markdown.ToHtml("- [Antrag](/dokumente/antrag.pdf)\n- Text mit [Link](/x)");
html.ShouldContain($"<li class=\"{Markdown.LoneLinkClass}\"><a href=\"/dokumente/antrag.pdf\">");
html.ShouldContain("<li>Text mit");
}
}
+5 -1
View File
@@ -236,7 +236,11 @@ public sealed class PocketBaseFixture : IAsyncLifetime
});
await CreateRecordAsync(http, "pages", new
{
title = "Förderverein", body = "# Förderverein", location = "header",
// The body uses a design block, so the smoke tests can follow a ":::"
// container through PocketBase and the page into the rendered HTML.
title = "Förderverein",
body = "Der Förderverein unterstützt die Schule.\n\n::: kennzahlen\n- **seit 1975** Gründung\n:::",
location = "header",
order = 3, slug = "patrons", embed = Array.Empty<string>(), @public = true,
});
// Page record for the FAQ list. Route /faqs is served by FaqList, which reads
+16 -1
View File
@@ -138,7 +138,22 @@ public sealed partial class RouteSmokeTests : IDisposable
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("Förderverein"); // the page body, rendered from Markdown
html.ShouldContain("Der Förderverein unterstützt die Schule."); // the page body, rendered from Markdown
}
[Fact]
public async Task Content_page_body_carries_the_markdown_styles_and_blocks()
{
// The editor-content styles in app.css all hang off .markdown-body, and the
// design blocks off the class a ":::" container renders. Assert both reach
// the markup of a real page, so a renamed wrapper or a pipeline without
// custom containers fails here instead of silently unstyling the site.
var client = _factory.CreateClient();
var html = await client.GetStringAsync(new Uri("/patrons", UriKind.Relative));
html.ShouldContain("class=\"markdown-body\"");
html.ShouldContain("<div class=\"kennzahlen\">");
}
[Fact]
@@ -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);
}
+99
View File
@@ -201,6 +201,105 @@ Eine Frage hat `question`, `answer` (Markdown), `topic`, `order` und `public`.
---
## Gestaltungsbausteine
Alle Texte (`body` von Seiten und Beiträgen, `intro` und `answer` bei den FAQs)
sind **Markdown**. Die Website gestaltet sie automatisch: Zwischenüberschriften
(`## …`) bekommen einen grünen Strich, Listen grüne Punkte, Zitate (`> …`) einen
farbigen Balken. Darüber hinaus gibt es ein paar **Bausteine**, mit denen du eine
Seite selbst gestaltest — ohne Code und ohne HTML.
> **Kein HTML.** HTML im Text (`<div>`, `<br>`, `<script>` …) wird nicht
> ausgeführt, sondern als Text angezeigt. Das ist Absicht: Die Datenschutzerklärung
> sagt zu, dass kein Programmcode im Browser läuft. Für Gestaltung die Bausteine
> unten nehmen.
### Bausteine mit `:::`
Ein Baustein beginnt mit `::: name` in einer eigenen Zeile und endet mit `:::`.
Dazwischen steht normales Markdown. Vor und nach dem Baustein eine Leerzeile
lassen. Die Namen sind fest; ein **vertippter Name** schadet nicht — der Inhalt
erscheint dann einfach als normaler Text.
**Kennzahlen** — eine Liste wird zu großen Zahlen-Kacheln. Der **fett**
geschriebene Anfang ist die Zahl, der Rest die Beschriftung darunter:
```markdown
::: kennzahlen
- **seit 1975** im Einsatz für die IGMH
- **über 500** Mitglieder
- **ab 10 €** Jahresbeitrag
:::
```
**Aufruf** — eine hervorgehobene Box für die wichtigste Aktion der Seite. Links
darin erscheinen als Button:
```markdown
::: aufruf
Die Beitrittserklärung füllen Sie online beim Förderverein aus.
[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)
:::
```
**Kacheln** — eine Liste als Raster aus Karten. Ein **fetter** Anfang wird zur
Überschrift der Karte:
```markdown
::: kacheln
- **Pädagogik** Bessere Rahmenbedingungen für die Arbeit an der Schule
- **Projekte** Musische, sportliche und kulturelle Vorhaben
- **Ausstattung** Anschaffungen, die allen zugutekommen
:::
```
**Hinweis** — eine dezente Info-Box mit Info-Symbol:
```markdown
::: hinweis
Bei dringenden Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat.
:::
```
### Links, die sich automatisch verändern
- **Links auf andere Websites** (`https://…`) bekommen ein kleines Pfeil-Symbol.
Links auf eigene Seiten schreibst du ohne Domain: `[Kontakt](/contact)`.
- **PDF-Links** (Adresse endet auf `.pdf`) bekommen ein Datei-Symbol und den
Hinweis „PDF“. Steht der Link **allein** in einer Zeile oder einem
Listenpunkt, wird er zur Datei-Karte — so wird eine Liste von Dokumenten zum
Download-Bereich:
```markdown
- [Aufnahmeantrag Förderverein](/dokumente/aufnahmeantrag-foerderverein.pdf)
- [Einzugsermächtigung](/dokumente/einzugsermaechtigung.pdf)
```
Die PDF-Dateien selbst liegen nicht in PocketBase, sondern im Code unter
`wwwroot/dokumente/` (ein neues PDF braucht also einen Entwickler).
- **E-Mail-Link allein in einem Absatz** wird zum Button:
```markdown
[kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info)
```
Steht der Link mitten im Satz („Schreiben Sie an …“), bleibt er ein normaler
Link.
### Tabellen
Tabellen gehen mit senkrechten Strichen; die zweite Zeile trennt die Kopfzeile
ab. Auf dem Handy lässt sich eine breite Tabelle seitlich schieben.
```markdown
| Tag | Uhrzeit | Ort |
|----------|-----------|------------|
| Dienstag | 19:00 Uhr | Hörsaal 2 |
```
---
## Regeln beim Speichern
- **Nach dem Ändern einer Zugriffsregel** immer den Haupt-*Save* der Collection
+1 -1
View File
@@ -21,7 +21,7 @@ migrate((app) => {
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
const records = {
pages: [{"body":"Der Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\nDer Vorstand wurde am 13. November 2025 gewählt.\n\n## Vorsitz\n\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat, Homepage* \n Mitglied im Gesamtelternbeirat und im Schulbeirat der Stadt Mannheim.\n- **Gentiana Erol** (1. Stellvertreterin) \n *Schulkonferenz, Kommunikation Hausmeister, Mensarat*\n- **Raul Chira** (2. Stellvertreter) \n *Mensarat*\n\n## Schriftführung\n\n- **Marion Leininger** \n *Kasse, Schriftführung, Kontakt Förderverein*\n\n## Beisitz\n\n- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*\n- **Mariella Ianello** \n *Mensarat, Kontakt Förderverein*\n- **Isabell Vesper**\n- **Susanne Naumburg** \n *Stellvertretende Schulkonferenz*\n- **Rebecca Goldmann**\n- **Michael Weber** \n *Stellvertretende Schulkonferenz*\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen. Die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"Der Förderverein **„Freunde der IGMH e.V.\"** unterstützt die Schule seit 1975 bei Anschaffungen und Projekten, die aus dem regulären Schulbudget nicht finanziert werden können. Mehr als 500 Mitglieder aus Eltern, Lehrkräften, Ehemaligen und Förderern bringen sich mit Zeit, Ideen oder finanziellen Beiträgen ein.\n\nDer Förderverein ist ein eigenständiger, gemeinnützig anerkannter Verein und nicht Teil des Elternbeirats.\n\n## Was der Verein fördert\n\n- die pädagogische Arbeit und bessere Rahmenbedingungen an der Schule\n- musische, sportliche und kulturelle Projekte\n- Anschaffungen, die allen Schülerinnen und Schülern zugutekommen\n- Chancengerechtigkeit, damit kein Kind aus finanziellen Gründen zurückbleibt\n\n## Was bereits gefördert wurde\n\n- Rennräder, Bälle, Trikots sowie Sport- und Spielgeräte für AGs\n- Zuschüsse für Chorwochenenden und Kanutage\n- Unterstützung der Theater-AG\n- Veranstaltungstechnik und Musikinstrumente, zum Beispiel DJ-Mischpult und Klavier\n- Vor- und Mitfinanzierung der Schülerhandbücher\n- Kletterwand, Außenmöbel in den Innenhöfen, Sitzbänke im Schulhaus und Wasserspender im Mensabereich\n- Laptops und Tablets für den Unterricht\n\n## Mitglied werden\n\nMitglied werden kann, wer die Arbeit der Schule unterstützen möchte. Eine Mitgliedschaft ist bereits ab einem Jahresbeitrag von **10 €** möglich.\n\nDie Beitrittserklärung füllen Sie online beim Förderverein aus:\n[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Der Verein ist als gemeinnützig anerkannt. Wie Sie spenden können, erfahren Sie beim Förderverein.\n\n## Kontakt zum Förderverein\n\n**Freunde der IGMH e.V.** \nHerzogenriedstraße 50 \n68169 Mannheim \n(Fach 165)\n\nE-Mail: [foerderverein@igmh.de](mailto:foerderverein@igmh.de) \nWebsite: [foerderverein.igmh.de](https://foerderverein.igmh.de)","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"Auf dieser Seite haben wir die Fragen zusammengetragen, die uns am häufigsten erreichen.\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact) – wir helfen gern weiter.","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQs"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"Haben Sie eine Frage, eine Anregung oder möchten Sie sich einbringen? Der Elternbeirat der IGMH freut sich über jede Nachricht.\n\nSie erreichen uns per E-Mail unter [kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info). In der Regel melden wir uns innerhalb weniger Tage zurück.\n\nBei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der IGMH.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. DieLine truncated
pages: [{"body":"Der Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\nDer Vorstand wurde am 13. November 2025 gewählt.\n\n## Vorsitz\n\n- **Maik Palm** (Vorsitzender) \n *Schulkonferenz, Mensarat, Homepage* \n Mitglied im Gesamtelternbeirat und im Schulbeirat der Stadt Mannheim.\n- **Gentiana Erol** (1. Stellvertreterin) \n *Schulkonferenz, Kommunikation Hausmeister, Mensarat*\n- **Raul Chira** (2. Stellvertreter) \n *Mensarat*\n\n## Schriftführung\n\n- **Marion Leininger** \n *Kasse, Schriftführung, Kontakt Förderverein*\n\n## Beisitz\n\n- **Jenny Reger-Stilgenbauer** \n *Schulkonferenz, Schaukasten*\n- **Mariella Ianello** \n *Mensarat, Kontakt Förderverein*\n- **Isabell Vesper**\n- **Susanne Naumburg** \n *Stellvertretende Schulkonferenz*\n- **Rebecca Goldmann**\n- **Michael Weber** \n *Stellvertretende Schulkonferenz*\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen. Die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"Der Förderverein **„Freunde der IGMH e.V.\"** unterstützt die Schule seit 1975 bei Anschaffungen und Projekten, die aus dem regulären Schulbudget nicht finanziert werden können.\n\n::: kennzahlen\n- **seit 1975** im Einsatz für die IGMH\n- **über 500** Mitglieder\n- **ab 10 €** Jahresbeitrag\n:::\n\n::: hinweis\nDer Förderverein ist ein eigenständiger, gemeinnützig anerkannter Verein und nicht Teil des Elternbeirats.\n:::\n\n## Was der Verein fördert\n\n::: kacheln\n- **Pädagogik** Bessere Rahmenbedingungen für die Arbeit an der Schule\n- **Projekte** Musische, sportliche und kulturelle Vorhaben\n- **Ausstattung** Anschaffungen, die allen Schülerinnen und Schülern zugutekommen\n- **Chancengerechtigkeit** Damit kein Kind aus finanziellen Gründen zurückbleibt\n:::\n\n## Was bereits gefördert wurde\n\n- Rennräder, Bälle, Trikots sowie Sport- und Spielgeräte für AGs\n- Zuschüsse für Chorwochenenden und Kanutage\n- Unterstützung der Theater-AG\n- Veranstaltungstechnik und Musikinstrumente, zum Beispiel DJ-Mischpult und Klavier\n- Vor- und Mitfinanzierung der Schülerhandbücher\n- Kletterwand, Außenmöbel in den Innenhöfen, Sitzbänke im Schulhaus und Wasserspender im Mensabereich\n- Laptops und Tablets für den Unterricht\n\n## Mitglied werden\n\nMitglied werden kann, wer die Arbeit der Schule unterstützen möchte. Eine Mitgliedschaft ist bereits ab einem Jahresbeitrag von **10 €** möglich.\n\n::: aufruf\nDie Beitrittserklärung füllen Sie online beim Förderverein aus.\n\n[Zur Beitrittserklärung](https://easyverein.com/public/FDI/applicationform/21612)\n:::\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Der Verein ist als gemeinnützig anerkannt. Wie Sie spenden können, erfahren Sie beim Förderverein.\n\n## Kontakt zum Förderverein\n\n**Freunde der IGMH e.V.** \nHerzogenriedstraße 50 \n68169 Mannheim \n(Fach 165)\n\nE-Mail: [foerderverein@igmh.de](mailto:foerderverein@igmh.de) \nWebsite: [foerderverein.igmh.de](https://foerderverein.igmh.de)","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"Auf dieser Seite haben wir die Fragen zusammengetragen, die uns am häufigsten erreichen.\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact) – wir helfen gern weiter.","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQs"},{"body":"Formulare und Dokumente zum Herunterladen:\n\n- [Aufnahmeantrag Förderverein](/dokumente/aufnahmeantrag-foerderverein.pdf)\n- [Einzugsermächtigung](/dokumente/einzugsermaechtigung.pdf)\n- [Erstattungsformular Klassenkasse](/dokumente/erstattungsformular-klassenkasse.pdf)\n\n::: hinweis\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).\n:::","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"Haben Sie eine Frage, eine Anregung oder möchten Sie sich einbringen? Der Elternbeirat der IGMH freut sich über jede Nachricht.\n\n[kontakt@elternbeirat-igmh.info](mailto:kontakt@elternbeirat-igmh.info)\n\nIn der Regel melden wir uns innerhalb weniger Tage zurück.\n\n::: hinweis\nBei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der IGMH.\n:::","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MSLine truncated
,
posts: [{"body":"In fast allen Familien wird über die Handy-Nutzung diskutiert. Auch im schulischen Alltag ist das immer wieder ein Thema.\n\nSinnvolles und weniger Sinnvolles ist mit dem Handy möglich. Viele Jugendliche haben das Handy bis in die späten Abendstunden und über Nacht bei sich, was natürlich zur Nutzung verführt. Innerhalb der Familie dazu Regelungen zu finden, ist nicht einfach. Auch wir in der Schule kennen diese Gespräche.\n\nWir möchten Ihnen ein Angebot digitaler Elternmodule zu diesem Themenfeld vorstellen und kurz aus schulischer, sozialpädagogischer und strafrechtlicher Sicht zur Handynutzung informieren.\n\n**Dienstag, 18. Februar 2025 um 18 Uhr im Hörsaal 2.**\n\nBitte geben Sie kurz über die Klassenleitung Rückmeldung, dass Sie teilnehmen werden.","date":"2025-02-07 12:00:00.000Z","public":true,"slug":"elternabend-handy-nutzung","title":"Elternabend zur Handy-Nutzung"},{"body":"Am 8. Februar öffnete die IGMH ihre Türen für interessierte Eltern und Kinder, die sich einen Eindruck von unserer Schule verschaffen wollten. Besonders Eltern, die planen, ihre Kinder im kommenden Schuljahr anzumelden, konnten sich umfassend informieren und direkt einen Anmeldetermin vereinbaren.\n\n## Vielfältige Einblicke in das Schulleben\n\nAn verschiedenen Stationen lernten die Besucherinnen und Besucher das breite Angebot der IGMH kennen. Die Eliteschule des Sports präsentierte ihre Fördermöglichkeiten, und Schulhund Tonka ließ viele Kinderherzen höherschlagen. Auch der Elternbeirat und der Förderverein der IGMH informierten in der Aula über ihre Arbeit.\n\nFür die jungen Gäste gab es zahlreiche Mitmach-Aktionen:\n\n- Im römischen Zimmer konnten sie sich spielerisch mit der lateinischen Sprache beschäftigen.\n- Im Chemielabor wurden spannende Experimente durchgeführt.\n- Die Kletterwand war ein echter Publikumsmagnet, hier ging es unter professioneller Sicherung hoch hinaus.\n\n## Unterhaltung und kulinarische Highlights\n\n- Die Schülerband begeisterte das Publikum mit ihren Auftritten in der Aula.\n- Im Hörsaal 2 führten die 6. und 7. Klassen eine unterhaltsame Theateraufführung auf.\n- Für das leibliche Wohl war bestens gesorgt, ob im gemütlichen Schülercafé oder im Bistro Français.\n\nDer Tag der offenen Tür war ein voller Erfolg und lockte zahlreiche Besucherinnen und Besucher an. Die positive Resonanz zeigt, wie groß das Interesse an unserer Schule ist.","date":"2025-02-09 12:00:00.000Z","public":true,"slug":"tag-der-offenen-tuer-2025","title":"Rückblick: Tag der offenen Tür an der IGMH"},{"body":"Digitale Medien spielen eine zentrale Rolle im Alltag von Kindern und Jugendlichen, sei es das Smartphone, ein Tablet oder die Spielkonsole. Doch was machen junge Menschen eigentlich damit? Welcher Erwachsene weiß genau, was sich auf WhatsApp, Instagram oder Snapchat abspielt? Und was passiert auf TikTok? Wann wird ein Videospiel zur Sucht? Und welches Vorbild geben wir als Erwachsene ab, wenn auch unser eigener Alltag vom Smartphone bestimmt wird?\n\n## Digitaler Medienelternabend an der IGMH\n\nVor Kurzem fand an der IGMH ein digitaler Medienelternabend statt, der auf großes Interesse stieß. Frau Ruthig, stellvertretende Mittelstufenleiterin, führte in das Thema ein und beleuchtete die Herausforderungen der Mediennutzung.\n\nAnschließend erklärte Schulpolizist Herr Ebert die juristischen Aspekte. Dabei zeigte er auf, welche Möglichkeiten Eltern haben, wenn ihr Kind von Cybermobbing betroffen ist:\n\n- Gespräch mit Schule und Polizeibeamten\n- Gespräch mit dem Präventionsbeamten der Polizei\n- Durchführung eines Präventionstermins in der Klasse\n- Mit Einverständnis der Eltern des Täters: Gespräch mit dem Täter\n- Mit Einverständnis der Eltern des Täters: Gespräch mit Täter, Opfer, Eltern und Schule\n- Strafanzeige durch die Polizei\n\nWeiterführende Informationen zu Computerspielen finden Sie unter [usk.de](http://www.usk.de) (USK Unterhaltungssoftware) und [zavatar.de](http://www.zavatar.de).\n\n## Herausforderungen der Handynutzung aus sozialpädagogischer Sicht\n\nFrau Graske (Sozialpädagogin) stellte die Herausforderungen aus Sicht der Schulsozialarbeit dar und gab Eltern konkrete Tipps:\n\n**1. Bildschirmzeit im Blick behalten**\n\n**2. Handy am Abend außer Reichweite bringen.** Vereinbaren Sie einen festen Schlafplatz für das Handy und aktivieren Sie einen Ruhemodus.\n\n**3. Online-Kontakte des Kindes sensibel begleiten.** Lassen Sie sich Chat-Gruppen zeigen, schauen Sie gemeinsam das TikTok-Profil an und nutzen Sie Ratschläge von [Clemens hilft](https://www.clemenshilft.de/).\n\n**4. Bleiben Sie die wichtigste Ansprechperson für Ihr Kind.** Zeigen Sie, dass Ihr Kind Ihnen vertrauen kann, begründen Sie Ihre Maßnahmen und bleiben Sie offen für Fragen. Der [Mediennutzungsvertrag](https://www.mediennutzungsvertrag.de/) hilft bei klaren Absprachen.\n\n**5. Denken Sie auch an sich selbst.** Anerkennen Sie Ihren Einsatz, seien Line truncated
,