Enhance component functionality and styling across the project
Deploy to Firebase Hosting / deploy (push) Failing after 1m46s

- Updated .gitignore to include raw image files for better asset management.
- Added optional icon and color properties to Tag and TagItem interfaces for improved tag customization.
- Enhanced BlogOverview and PostCard components to support new tag features, including icon and color display.
- Refined Card component to accept target and rel attributes for better link handling.
- Improved layout responsiveness in ContentRows and PostCard components.
- Updated OrganisationsBlock to include a new CTA link feature for adding organizations.
- Enhanced QuoteBlock styling for better visual emphasis on quotes.
- Refactored image handling in various components to ensure consistent display and performance.
- Introduced utility functions for resolving body content and filtering hidden posts in blog utilities.
This commit is contained in:
Peter Meier
2026-04-09 14:39:43 +02:00
parent f5f6beeabe
commit 5b1648bf82
20 changed files with 440 additions and 131 deletions
+1
View File
@@ -28,4 +28,5 @@ pnpm-debug.log*
.firebase/ .firebase/
.history/ .history/
public/images/transformed/ public/images/transformed/
public/images/raw/
public/pagefind public/pagefind
+5 -1
View File
@@ -10,6 +10,8 @@
interface TagEntry { interface TagEntry {
_slug?: string; _slug?: string;
name: string; name: string;
icon?: string;
color?: string;
} }
let { let {
@@ -62,6 +64,8 @@
href={tagHref(slug)} href={tagHref(slug)}
variant={activeTag === slug ? "inactive" : "green"} variant={activeTag === slug ? "inactive" : "green"}
active={activeTag === slug} active={activeTag === slug}
icon={tag.icon?.trim() || null}
customColor={activeTag === slug ? null : tag.color?.trim() || null}
/> />
{/each} {/each}
</div> </div>
@@ -75,7 +79,7 @@
activeTag={activeTag} activeTag={activeTag}
/> />
<div class="py-2 grid grid-cols-1 gap-4"> <div class="py-2 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each posts as post} {#each posts as post}
<PostCard <PostCard
post={post} post={post}
+14 -4
View File
@@ -4,15 +4,25 @@
let { let {
children, children,
href, href,
target,
rel,
class: className = "", class: className = "",
}: { children?: Snippet; href?: string; class?: string } = $props(); }: {
children?: Snippet;
href?: string;
target?: string;
rel?: string;
class?: string;
} = $props();
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm"; const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm min-w-0 overflow-hidden";
const interactive = href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : ""; const interactive = $derived(
href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "",
);
</script> </script>
{#if href} {#if href}
<a {href} class="{base} {interactive} {className}"> <a {href} {target} {rel} class="{base} {interactive} {className}">
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
+7 -2
View File
@@ -95,6 +95,7 @@
> >
{#each row.content as item} {#each row.content as item}
{#if isResolvedBlock(item)} {#if isResolvedBlock(item)}
{@const rowBlockType = blockType(item)}
{#snippet blockContent()} {#snippet blockContent()}
{#if blockType(item) === "markdown"} {#if blockType(item) === "markdown"}
<MarkdownBlock block={item as MarkdownBlockData} /> <MarkdownBlock block={item as MarkdownBlockData} />
@@ -132,8 +133,12 @@
{/snippet} {/snippet}
{#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)} {#if isBlockLayoutBreakout((item as { layout?: BlockLayout }).layout)}
<div class="col-span-12"> <div class="col-span-12">
<div class="component-breakout--{blockType(item)} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none [background:var(--color-container-breakout)]"> <div
<div class="container-custom py-4"> class="component-breakout--{rowBlockType} {getSpaceBottomClass((item as { layout?: BlockLayout }).layout)} w-screen relative left-1/2 -translate-x-1/2 max-w-none {rowBlockType === 'quote'
? 'bg-wald-50'
: '[background:var(--color-container-breakout)]'}"
>
<div class="container-custom py-8">
{@render blockContent()} {@render blockContent()}
</div> </div>
</div> </div>
+20 -14
View File
@@ -15,23 +15,29 @@
} = $props(); } = $props();
const resolvedImg = $derived(post._resolvedImageUrl); const resolvedImg = $derived(post._resolvedImageUrl);
const bgStyle = $derived(resolvedImg ? `background-image: url(${resolvedImg})` : "");
</script> </script>
<a <a
href={href} href={href}
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white relative min-h-[200px]" class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white"
> >
{#if resolvedImg} <div class="aspect-3/2 w-full overflow-hidden bg-gray-100">
<div {#if resolvedImg}
class="absolute inset-0 bg-cover bg-center opacity-10" <img
style={bgStyle} src={resolvedImg}
aria-hidden="true" alt={post.headline ?? ""}
></div> class="w-full h-full object-cover"
{/if} loading="lazy"
<div />
class="relative flex-1 flex flex-col justify-start p-4" {:else}
> <div class="w-full h-full flex items-center justify-center text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="size-12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
{/if}
</div>
<div class="flex-1 flex flex-col justify-start p-4">
<PostMeta <PostMeta
date={post.created ?? post.date ?? null} date={post.created ?? post.date ?? null}
tags={post.postTag ?? []} tags={post.postTag ?? []}
@@ -42,12 +48,12 @@
{post.headline ?? post.linkName ?? post.slug ?? post._slug} {post.headline ?? post.linkName ?? post.slug ?? post._slug}
</h5> </h5>
{#if post.subheadline} {#if post.subheadline}
<div class="text-xs line-clamp-2 mb-1 "> <div class="text-xs line-clamp-2 mb-1">
{post.subheadline} {post.subheadline}
</div> </div>
{/if} {/if}
{#if post.excerpt} {#if post.excerpt}
<p class="text-xs font-light line-clamp-3 "> <p class="text-xs font-light line-clamp-3">
{post.excerpt} {post.excerpt}
</p> </p>
{/if} {/if}
+94 -15
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import "../lib/iconify-offline";
import Icon from "@iconify/svelte";
type Variant = "white" | "green" | "blue" | "inactive" | "date"; type Variant = "white" | "green" | "blue" | "inactive" | "date";
let { let {
@@ -7,36 +10,112 @@
href = null, href = null,
active = false, active = false,
onclick = null, onclick = null,
icon = null,
customColor = null,
}: { }: {
label: string; label: string;
variant?: Variant; variant?: Variant;
href?: string | null; href?: string | null;
active?: boolean; active?: boolean;
onclick?: (() => void) | null; onclick?: (() => void) | null;
/** Iconify id (z. B. mdi:tag) */
icon?: string | null;
/** CSS-Farbe für Pill-Hintergrund (z. B. #2d5a27) */
customColor?: string | null;
} = $props(); } = $props();
/** Grobe Helligkeit für lesbare Textfarbe auf customColor-Hintergrund. */
function isLightBackground(cssColor: string): boolean {
const s = cssColor.trim();
const hex6 = /^#([0-9a-f]{6})$/i.exec(s);
const hex3 = /^#([0-9a-f]{3})$/i.exec(s);
let r = 200;
let g = 200;
let b = 200;
if (hex6) {
const n = parseInt(hex6[1], 16);
r = (n >> 16) & 255;
g = (n >> 8) & 255;
b = n & 255;
} else if (hex3) {
const h = hex3[1];
r = parseInt(h[0] + h[0], 16);
g = parseInt(h[1] + h[1], 16);
b = parseInt(h[2] + h[2], 16);
} else if (s.startsWith("rgb")) {
const m = s.match(/\d+/g);
if (m && m.length >= 3) {
r = Number(m[0]);
g = Number(m[1]);
b = Number(m[2]);
}
} else {
return true;
}
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55;
}
const baseClass = const baseClass =
"inline-flex shrink-0 whitespace-nowrap py-1 px-2 text-[.6rem] rounded-full transition-all ring-1 ring-white/50 no-underline!"; "inline-flex shrink-0 items-center gap-1 whitespace-nowrap border-0 py-1 px-2.5 text-[.6rem] rounded-full transition-[opacity,filter] no-underline shadow-none outline-none ring-0";
const variantClass = $derived.by(() => { const variantClass = $derived.by(() => {
if (variant === "white") return "bg-gradient-to-b from-white to-gray-100 text-black! ring-black/5!"; if (customColor?.trim()) {
if (variant === "green") return "bg-gradient-to-b from-green-300 to-green-400 text-black! "; return "";
if (variant === "blue") return "bg-gradient-to-b from-teal-300 to-teal-400 text-black! "; }
if (variant === "inactive") return "bg-gradient-to-t from-gray-200 to-gray-300 text-gray-500! shadow-none ring-black/15!"; if (variant === "white")
if (variant === "date") return "bg-gradient-to-b from-green-50 to-green-100 text-green-700! ring-green-200!"; return "bg-stein-100 text-stein-800";
return "bg-gray-200 text-gray-500"; if (variant === "green") return "bg-wald-100 text-wald-800";
if (variant === "blue") return "bg-sky-100/90 text-sky-900";
if (variant === "inactive") return "bg-stein-200 text-stein-500";
if (variant === "date") return "bg-wald-50 text-wald-800";
return "bg-stein-200 text-stein-500";
}); });
const shadowClass = $derived.by(() => { const pillStyle = $derived.by(() => {
if (!href && !onclick) return "shadow-none"; const c = customColor?.trim();
if (variant === "date") return "shadow-[0_0_6px_rgba(0,0,0,0.3)]"; if (!c) return "";
return "shadow-[0_2px_6px_rgba(0,0,0,0.2)]"; const light = isLightBackground(c);
const fg = light ? "#111827" : "#fafafa";
return `background-color: ${c}; color: ${fg};`;
}); });
const classes = $derived.by(() => `${baseClass} ${variantClass} ${shadowClass} ${active ? "pointer-events-none" : ""}`); const interactiveClass = $derived.by(() =>
href || onclick ? "hover:brightness-[0.97] active:brightness-[0.94]" : "",
);
const classes = $derived.by(
() =>
`${baseClass} ${variantClass} ${interactiveClass} ${active ? "pointer-events-none opacity-80" : ""}`.trim(),
);
</script> </script>
{#if href} {#if href}
<a href={href} class={classes} aria-current={active ? "true" : undefined}>{label}</a> <a
href={href}
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</a>
{:else if onclick} {:else if onclick}
<button type="button" class={classes} aria-current={active ? "true" : undefined} onclick={onclick}>{label}</button> <button
type="button"
class={classes}
style={pillStyle || undefined}
aria-current={active ? "true" : undefined}
onclick={onclick}
>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</button>
{:else} {:else}
<span class={classes}>{label}</span> <span class={classes} style={pillStyle || undefined}>
{#if icon}
<Icon {icon} class="size-3 shrink-0 opacity-90" aria-hidden="true" />
{/if}
{label}
</span>
{/if} {/if}
+25 -3
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import Tag from "./Tag.svelte"; import Tag from "./Tag.svelte";
type TagItem = string | { name?: string; _slug?: string }; type TagItem =
| string
| { name?: string; _slug?: string; icon?: string; color?: string };
type Variant = "white" | "green" | "blue"; type Variant = "white" | "green" | "blue";
let { let {
@@ -16,12 +18,30 @@
function displayName(t: TagItem): string { function displayName(t: TagItem): string {
if (typeof t === "string") return t; if (typeof t === "string") return t;
return (t as { name?: string; _slug?: string })?.name ?? (t as { _slug?: string })?._slug ?? ""; return (
(t as { name?: string; _slug?: string })?.name ??
(t as { _slug?: string })?._slug ??
""
);
} }
function slug(t: TagItem): string { function slug(t: TagItem): string {
if (typeof t === "string") return t; if (typeof t === "string") return t;
return (t as { _slug?: string })?._slug ?? (t as { name?: string })?.name ?? ""; return (
(t as { _slug?: string })?._slug ?? (t as { name?: string })?.name ?? ""
);
}
function tagIcon(t: TagItem): string | null {
if (typeof t === "string") return null;
const i = (t as { icon?: string }).icon?.trim();
return i || null;
}
function tagColor(t: TagItem): string | null {
if (typeof t === "string") return null;
const c = (t as { color?: string }).color?.trim();
return c || null;
} }
</script> </script>
@@ -32,6 +52,8 @@
label={name} label={name}
variant={variant} variant={variant}
href={tagBase ? `${tagBase}/${slug(t)}` : null} href={tagBase ? `${tagBase}/${slug(t)}` : null}
icon={tagIcon(t)}
customColor={tagColor(t)}
/> />
{/if} {/if}
{/each} {/each}
+44 -24
View File
@@ -13,25 +13,41 @@
: "https://opnform.pm86.de" : "https://opnform.pm86.de"
).replace(/\/$/, ""), ).replace(/\/$/, ""),
); );
const iframeSrc = $derived( const iframeSrc = $derived(formId ? `${baseUrl}/forms/${formId}` : "");
formId ? `${baseUrl}/forms/${formId}` : "", const iframeTitle = $derived(block.iframeTitle?.trim() || "Kontaktformular");
);
const iframeTitle = $derived(
block.iframeTitle?.trim() || "Kontaktformular",
);
/** let iframeEl = $state<HTMLIFrameElement | null>(null);
* Wie im OpnForm-Embed: initEmbed erst wenn iframe.min.js fertig geladen ist
* (entspricht <script … onload="initEmbed('…', { autoResize: true })">).
* Zusätzlich min-height, weil das Widget oft ~150px inline setzt, bevor autoResize greift.
*/
const iframeStyle =
"border: none; width: 100%; min-height: min(88vh, 48rem) !important;";
onMount(() => { onMount(() => {
if (!formId) return; if (!formId || !iframeEl) return;
const el = iframeEl;
// Höhe ausschließlich per DOM verwalten nie über Sveltes style= Prop.
el.style.height = "600px";
el.style.transition = "height 0.25s ease";
// Verhindert native Browser-Scrollbar im Iframe, egal ob Höhe stimmt oder nicht.
el.setAttribute("scrolling", "no");
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`; const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
function handleMessage(event: MessageEvent) {
if (event.source !== el.contentWindow) return;
const d = event.data;
let h: number | undefined;
if (typeof d === "number") {
h = d;
} else if (d && typeof d === "object") {
h = d.height ?? d.frameHeight ?? d.iFrameHeight;
} else if (typeof d === "string" && d.startsWith("[iFrameSizer]")) {
const parts = d.split(":");
h = parts.length >= 2 ? parseFloat(parts[1]) : undefined;
}
if (typeof h === "number" && h > 50) {
el.style.height = `${Math.ceil(h)}px`;
}
}
window.addEventListener("message", handleMessage);
function runInit() { function runInit() {
const w = window as Window & { const w = window as Window & {
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void; initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
@@ -50,27 +66,31 @@
} else { } else {
existing.addEventListener("load", runInit, { once: true }); existing.addEventListener("load", runInit, { once: true });
} }
return; } else {
const s = document.createElement("script");
s.type = "text/javascript";
s.async = true;
s.src = scriptUrl;
s.dataset.opnformEmbedJs = baseUrl;
s.onload = () => runInit();
document.body.appendChild(s);
} }
const s = document.createElement("script"); return () => {
s.type = "text/javascript"; window.removeEventListener("message", handleMessage);
s.async = true; };
s.src = scriptUrl;
s.dataset.opnformEmbedJs = baseUrl;
s.onload = () => runInit();
document.body.appendChild(s);
}); });
</script> </script>
<div <div
class="{layoutClasses} content-opnform w-full min-w-0 overflow-visible -mx-6" class="{layoutClasses} content-opnform w-[calc(100%+2rem)] max-w-none -mx-4 min-w-0 overflow-visible border border-stein-200 lg:rounded-lg px-0 py-3 md:w-[calc(100%+3rem)] md:-mx-6 md:px-4 md:py-4"
data-block-type="opnform" data-block-type="opnform"
data-block-slug={block._slug} data-block-slug={block._slug}
> >
{#if iframeSrc} {#if iframeSrc}
<iframe <iframe
style={iframeStyle} bind:this={iframeEl}
style="border: none; width: 100%;"
id={formId} id={formId}
title={iframeTitle} title={iframeTitle}
src={iframeSrc} src={iframeSrc}
+40 -11
View File
@@ -34,6 +34,30 @@
? (marked.parse(block.addOrganisationMarkdown) as string) ? (marked.parse(block.addOrganisationMarkdown) as string)
: "" : ""
); );
const addOrganisationCtaLink = $derived.by(() => {
const raw = block.addOrganisationLink;
if (!raw || typeof raw !== "object") return undefined;
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return undefined;
const linkName =
typeof raw.linkName === "string" && raw.linkName.trim() !== ""
? raw.linkName.trim()
: url;
return { url, linkName, newTab: Boolean(raw.newTab) };
});
const showAddOrganisationCard = $derived(
Boolean(
block.addOrganisationTitle?.trim() ||
block.addOrganisationMarkdown?.trim() ||
addOrganisationCtaLink
)
);
const addOrganisationCardClass = $derived(
`${compact ? "border-dashed border-wald-300 bg-wald-50! p-3! gap-1.5!" : "border-dashed border-wald-300 bg-wald-50!"}${addOrganisationCtaLink ? " cursor-pointer" : ""}`,
);
</script> </script>
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}> <div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
@@ -103,12 +127,7 @@
{/if} {/if}
</div> </div>
<div class="min-w-0 flex-1 flex flex-col gap-0.5"> <div class="min-w-0 flex-1 flex flex-col gap-0.5">
<div class="flex flex-wrap items-center gap-2"> <h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<Badge color={o.badge.color}>{o.badge.label}</Badge>
{/if}
</div>
{#if o.description} {#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p> <p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
{/if} {/if}
@@ -165,7 +184,7 @@
<Badge color={o.badge.color}>{o.badge.label}</Badge> <Badge color={o.badge.color}>{o.badge.label}</Badge>
</div> </div>
{/if} {/if}
<h5 class="text-base font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5> <h5 class="min-w-0 text-sm font-semibold leading-snug text-stein-900 truncate">{o.name ?? ""}</h5>
{#if o.description} {#if o.description}
<p class="text-sm text-stein-600">{o.description}</p> <p class="text-sm text-stein-600">{o.description}</p>
{/if} {/if}
@@ -174,11 +193,12 @@
{/if} {/if}
{/each} {/each}
{#if block.addOrganisationTitle || block.addOrganisationMarkdown} {#if showAddOrganisationCard}
<Card <Card
class={compact href={addOrganisationCtaLink?.url}
? "border-dashed border-wald-300 bg-wald-50 p-3! gap-1.5!" target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
: "border-dashed border-wald-300 bg-wald-50"} rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class={addOrganisationCardClass}
> >
{#if block.addOrganisationTitle} {#if block.addOrganisationTitle}
<h5 <h5
@@ -194,6 +214,15 @@
{@html ctaHtml} {@html ctaHtml}
</div> </div>
{/if} {/if}
{#if addOrganisationCtaLink}
<p
class={compact
? "mb-0 mt-1 text-xs font-medium text-wald-800"
: "mb-0 mt-2 text-sm font-medium text-wald-800"}
>
{addOrganisationCtaLink.linkName}
</p>
{/if}
</Card> </Card>
{/if} {/if}
</div> </div>
+3 -3
View File
@@ -9,10 +9,10 @@
</script> </script>
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}> <div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
<blockquote class="border-l-4 border-green-700 pl-4 {variantClass}"> <blockquote class="border-l-4 border-wald-700 pl-4 {variantClass}">
<p class="text-lg italic text-zinc-700">"{block.quote ?? ""}"</p> <p class="text-xl font-semibold text-wald-700">{block.quote ?? ""}</p>
{#if block.author} {#if block.author}
<cite class="mt-1 block not-italic text-sm text-zinc-500">{block.author}</cite> <cite class="mt-1 block not-italic text-sm text-wald-300">{block.author}</cite>
{/if} {/if}
</blockquote> </blockquote>
</div> </div>
+2
View File
@@ -229,6 +229,8 @@ export interface OrganisationsBlockData {
presentation?: "default" | "compact"; presentation?: "default" | "compact";
addOrganisationTitle?: string; addOrganisationTitle?: string;
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
addOrganisationLink?: string | { url?: string; linkName?: string; newTab?: boolean };
organisations?: (string | OrganisationEntry)[]; organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout; layout?: BlockLayout;
} }
+74 -28
View File
@@ -11,35 +11,74 @@ import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types"; import type { CalendarItemData } from "./block-types";
/** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */ /** rusty-image nur dynamisch importieren, damit client-seitige Importe (z. B. PostOverviewBlock) nicht node:crypto in den Browser ziehen. */
/** Slug → Anzeigename (name) aus der Tag-API. Für Auflösung von post.postTag in Lesbarkeit. */ /** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
export async function getTagsMap(): Promise<Map<string, string>> { export type TagMeta = {
name: string;
icon?: string;
color?: string;
};
/** Slug → TagMeta aus der Tag-API. Für Auflösung von post.postTag. */
export async function getTagsMap(): Promise<Map<string, TagMeta>> {
const tags = await getTags(); const tags = await getTags();
const m = new Map<string, string>(); const m = new Map<string, TagMeta>();
for (const t of tags) { for (const t of tags) {
const slug = const slug =
(t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? ""; (t as { _slug?: string })._slug ?? (t as { slug?: string }).slug ?? "";
if (slug) m.set(slug, (t as { name?: string }).name ?? slug); if (!slug) continue;
const name = (t as { name?: string }).name?.trim() ?? slug;
const icon = (t as { icon?: string }).icon?.trim();
const color = (t as { color?: string }).color?.trim();
m.set(slug, {
name,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
});
} }
return m; return m;
} }
/** Setzt post.postTag auf { _slug, name }[]; bei reinen Slug-Strings wird name aus slugToName geholt. */ export type ResolvedPostTag = {
_slug: string;
name: string;
icon?: string;
color?: string;
};
/** Setzt post.postTag auf { _slug, name, icon?, color? }[]; Metadaten aus slugToMeta bzw. bereits aufgelösten Refs. */
export function resolvePostTagsInPost( export function resolvePostTagsInPost(
post: PostEntry, post: PostEntry,
slugToName: Map<string, string>, slugToMeta: Map<string, TagMeta>,
): void { ): void {
const raw = post.postTag ?? []; const raw = post.postTag ?? [];
if (!Array.isArray(raw)) return; if (!Array.isArray(raw)) return;
(post as { postTag?: { _slug: string; name: string }[] }).postTag = raw.map( (post as { postTag?: ResolvedPostTag[] }).postTag = raw.map((t) => {
(t) => { if (typeof t === "string") {
if (typeof t === "string") { const meta = slugToMeta.get(t);
return { _slug: t, name: slugToName.get(t) ?? t }; return {
} _slug: t,
const o = t as { _slug?: string; name?: string }; name: meta?.name ?? t,
const slug = o._slug ?? o?.name ?? ""; ...(meta?.icon ? { icon: meta.icon } : {}),
return { _slug: slug, name: o?.name ?? slugToName.get(slug) ?? slug }; ...(meta?.color ? { color: meta.color } : {}),
}, };
); }
const o = t as {
_slug?: string;
name?: string;
icon?: string;
color?: string;
};
const slug = o._slug ?? o?.name ?? "";
const meta = slug ? slugToMeta.get(slug) : undefined;
const icon = (o.icon ?? meta?.icon)?.trim();
const color = (o.color ?? meta?.color)?.trim();
return {
_slug: slug,
name: o?.name ?? meta?.name ?? slug,
...(icon ? { icon } : {}),
...(color ? { color } : {}),
};
});
} }
const POSTS_PER_PAGE = 10; const POSTS_PER_PAGE = 10;
@@ -48,6 +87,13 @@ export function getPostsPerPage(): number {
return POSTS_PER_PAGE; return POSTS_PER_PAGE;
} }
/** Filtert Posts mit hideFromListing: true aus Übersichten heraus. */
export function filterHiddenPosts(posts: PostEntry[]): PostEntry[] {
return posts.filter(
(p) => !(p as PostEntry & { hideFromListing?: boolean }).hideFromListing,
);
}
/** Sortiert Posts nach Datum (neueste zuerst). */ /** Sortiert Posts nach Datum (neueste zuerst). */
export function sortPostsByDate(posts: PostEntry[]): PostEntry[] { export function sortPostsByDate(posts: PostEntry[]): PostEntry[] {
return [...posts].sort((a, b) => { return [...posts].sort((a, b) => {
@@ -159,12 +205,12 @@ function isPostOverviewBlock(item: unknown): item is {
/** /**
* Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved. * Lädt Posts für alle post_overview-Blöcke im Layout und setzt postsResolved.
* Nutzt die globale Tag-API (getTagsMap()) für Slug → Anzeigename. * Nutzt die globale Tag-API (getTagsMap()) für Slug → TagMeta.
* Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug].astro). * Gibt die tagsMap zurück (z. B. für resolvePostTagsInPost auf [slug].astro).
*/ */
export async function resolvePostOverviewBlocks( export async function resolvePostOverviewBlocks(
layout: RowContentLayout | null | undefined, layout: RowContentLayout | null | undefined,
): Promise<Map<string, string>> { ): Promise<Map<string, TagMeta>> {
const tagsMap = await getTagsMap(); const tagsMap = await getTagsMap();
if (!layout) return tagsMap; if (!layout) return tagsMap;
@@ -186,7 +232,7 @@ export async function resolvePostOverviewBlocks(
_order: "desc", _order: "desc",
resolve: "all", resolve: "all",
}); });
let list = sortPostsByDate(allPosts); let list = filterHiddenPosts(sortPostsByDate(allPosts));
const rawFilter = item.filterByTag ?? []; const rawFilter = item.filterByTag ?? [];
const tagSlugs = Array.isArray(rawFilter) const tagSlugs = Array.isArray(rawFilter)
? rawFilter ? rawFilter
@@ -242,8 +288,8 @@ export async function resolvePostOverviewBlocks(
try { try {
const { ensureTransformedImage } = await import("./rusty-image"); const { ensureTransformedImage } = await import("./rusty-image");
const url = await ensureTransformedImage(raw, { const url = await ensureTransformedImage(raw, {
width: 150, width: 400,
height: 200, height: 267,
fit: "cover", fit: "cover",
format: "webp", format: "webp",
}); });
@@ -340,19 +386,19 @@ function isResolvedFragment(f: unknown): boolean {
/** /**
* Normalisiert tags eines Fragments zu Anzeigenamen (string[]). * Normalisiert tags eines Fragments zu Anzeigenamen (string[]).
* Nutzt slugToName für Slug → Name; Objekte mit name/_slug werden unterstützt. * Nutzt slugToMeta für Slug → Anzeigename; Objekte mit name/_slug werden unterstützt.
*/ */
function resolveFragmentTags( function resolveFragmentTags(
tagsRaw: unknown[] | undefined, tagsRaw: unknown[] | undefined,
slugToName: Map<string, string>, slugToMeta: Map<string, TagMeta>,
): string[] { ): string[] {
if (!Array.isArray(tagsRaw)) return []; if (!Array.isArray(tagsRaw)) return [];
return tagsRaw return tagsRaw
.map((t) => { .map((t) => {
if (typeof t === "string") return slugToName.get(t) ?? t; if (typeof t === "string") return slugToMeta.get(t)?.name ?? t;
const o = t as { _slug?: string; name?: string }; const o = t as { _slug?: string; name?: string };
const slug = o._slug ?? ""; const slug = o._slug ?? "";
return (o.name ?? slugToName.get(slug) ?? slug).trim(); return (o.name ?? slugToMeta.get(slug)?.name ?? slug).trim();
}) })
.filter(Boolean); .filter(Boolean);
} }
@@ -361,14 +407,14 @@ function resolveFragmentTags(
* Lädt für alle searchable_text-Blöcke im Layout die textFragments nach * Lädt für alle searchable_text-Blöcke im Layout die textFragments nach
* (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und * (wenn sie als Slugs/Refs kommen), setzt die aufgelösten Einträge und
* löst Tag-Slugs/Refs in Anzeigenamen auf. * löst Tag-Slugs/Refs in Anzeigenamen auf.
* @param slugToName Optionale Map Slug → Anzeigename (z. B. von resolvePostOverviewBlocks); sonst getTagsMap(). * @param slugToMeta Optionale Map Slug → TagMeta (z. B. von resolvePostOverviewBlocks); sonst getTagsMap().
*/ */
export async function resolveSearchableTextBlocks( export async function resolveSearchableTextBlocks(
layout: RowContentLayout | null | undefined, layout: RowContentLayout | null | undefined,
slugToName?: Map<string, string>, slugToMeta?: Map<string, TagMeta>,
): Promise<void> { ): Promise<void> {
if (!layout) return; if (!layout) return;
const tagsMap = slugToName ?? (await getTagsMap()); const tagsMap = slugToMeta ?? (await getTagsMap());
const rows = [ const rows = [
layout.row1Content ?? [], layout.row1Content ?? [],
layout.row2Content ?? [], layout.row2Content ?? [],
+32 -2
View File
@@ -4300,7 +4300,9 @@ export interface components {
organisations: { organisations: {
/** @description Entry identifier (filename) */ /** @description Entry identifier (filename) */
readonly _slug?: string; readonly _slug?: string;
/** @description Optional markdown content shown above the organisation list */ /** @description Optional CTA link in the dashed card (label from link.linkName) */
addOrganisationLink?: string;
/** @description Optional markdown body for the dashed CTA card (no link here; use addOrganisationLink) */
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** @description Optional section title shown above the organisation list */ /** @description Optional section title shown above the organisation list */
addOrganisationTitle?: string; addOrganisationTitle?: string;
@@ -4322,7 +4324,9 @@ export interface components {
organisations_input: { organisations_input: {
/** @description URL slug (used as filename) */ /** @description URL slug (used as filename) */
_slug: string; _slug: string;
/** @description Optional markdown content shown above the organisation list */ /** @description Optional CTA link in the dashed card (label from link.linkName) */
addOrganisationLink?: string;
/** @description Optional markdown body for the dashed CTA card (no link here; use addOrganisationLink) */
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** @description Optional section title shown above the organisation list */ /** @description Optional section title shown above the organisation list */
addOrganisationTitle?: string; addOrganisationTitle?: string;
@@ -4547,6 +4551,11 @@ export interface components {
excerpt?: string; excerpt?: string;
/** @description Post headline */ /** @description Post headline */
headline: string; headline: string;
/**
* @description Hide this post from all listing pages (still accessible via direct URL)
* @default false
*/
hideFromListing: boolean;
/** @description Optional icon identifier */ /** @description Optional icon identifier */
icon?: string; icon?: string;
/** /**
@@ -4639,6 +4648,11 @@ export interface components {
excerpt?: string; excerpt?: string;
/** @description Post headline */ /** @description Post headline */
headline: string; headline: string;
/**
* @description Hide this post from all listing pages (still accessible via direct URL)
* @default false
*/
hideFromListing: boolean;
/** @description Optional icon identifier */ /** @description Optional icon identifier */
icon?: string; icon?: string;
/** /**
@@ -5193,12 +5207,20 @@ export interface components {
tag: { tag: {
/** @description Entry identifier (filename) */ /** @description Entry identifier (filename) */
readonly _slug?: string; readonly _slug?: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string;
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */ /** @description Tag name (e.g. politik, termin). Must be unique. */
name: string; name: string;
}; };
tag_input: { tag_input: {
/** @description URL slug (used as filename) */ /** @description URL slug (used as filename) */
_slug: string; _slug: string;
/** @description Optional pill color (CSS, e.g. #2d5a27 or rgb()). Admin: color picker + text field. */
color?: string;
/** @description Optional Iconify icon id (e.g. mdi:map-marker, same as link.icon). */
icon?: string;
/** @description Tag name (e.g. politik, termin). Must be unique. */ /** @description Tag name (e.g. politik, termin). Must be unique. */
name: string; name: string;
}; };
@@ -11976,6 +11998,8 @@ export interface operations {
addOrganisationTitle?: string; addOrganisationTitle?: string;
/** @description Filter by addOrganisationMarkdown */ /** @description Filter by addOrganisationMarkdown */
addOrganisationMarkdown?: string; addOrganisationMarkdown?: string;
/** @description Filter by addOrganisationLink */
addOrganisationLink?: string;
/** @description Filter by allowedBadges */ /** @description Filter by allowedBadges */
allowedBadges?: string; allowedBadges?: string;
/** @description Filter by organisations */ /** @description Filter by organisations */
@@ -12764,6 +12788,8 @@ export interface operations {
date?: string; date?: string;
/** @description Filter by icon */ /** @description Filter by icon */
icon?: string; icon?: string;
/** @description Filter by hideFromListing */
hideFromListing?: boolean;
/** @description Filter by important */ /** @description Filter by important */
important?: boolean; important?: boolean;
/** @description Filter by linkName */ /** @description Filter by linkName */
@@ -14226,6 +14252,10 @@ export interface operations {
_locale?: string; _locale?: string;
/** @description Filter by name */ /** @description Filter by name */
name?: string; name?: string;
/** @description Filter by icon */
icon?: string;
/** @description Filter by color */
color?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
+16
View File
@@ -354,6 +354,22 @@ export async function downloadCmsAssetLinks(html: string): Promise<string> {
return out; return out;
} }
/**
* Verarbeitet das Top-Level `content`-Feld (Markdown-Body) eines Post/Page-Eintrags:
* Markdown → HTML → Bilder transformieren → Asset-Links lokalisieren.
* Gibt fertiges HTML zurück (oder "" wenn kein content vorhanden).
*/
export async function resolveBodyContent(
entry: { content?: unknown },
): Promise<string> {
const raw = typeof entry.content === "string" ? entry.content.trim() : "";
if (!raw) return "";
const { marked } = await import("marked");
marked.setOptions({ gfm: true });
const html = marked.parse(raw) as string;
return downloadCmsAssetLinks(await processMarkdownHtmlImages(html));
}
/** /**
* Geht alle Content-Rows durch: Image-Blöcke → resolvedImageSrc; * Geht alle Content-Rows durch: Image-Blöcke → resolvedImageSrc;
* Markdown-Blöcke → resolvedContent (HTML mit transformierten Bildern + Link-Wrapper). * Markdown-Blöcke → resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
+2 -12
View File
@@ -4,14 +4,11 @@ import ContentRows from "../../components/ContentRows.svelte";
import Tag from "../../components/Tag.svelte"; import Tag from "../../components/Tag.svelte";
import Tags from "../../components/Tags.svelte"; import Tags from "../../components/Tags.svelte";
import { getPostSlugs, getPostBySlug } from "../../lib/cms"; import { getPostSlugs, getPostBySlug } from "../../lib/cms";
import { marked } from "marked";
marked.setOptions({ gfm: true });
import type { PostEntry } from "../../lib/cms"; import type { PostEntry } from "../../lib/cms";
import { import {
resolveContentImages, resolveContentImages,
resolveBodyContent,
ensureTransformedImage, ensureTransformedImage,
processMarkdownHtmlImages,
} from "../../lib/rusty-image"; } from "../../lib/rusty-image";
import { import {
resolvePostTagsInPost, resolvePostTagsInPost,
@@ -66,14 +63,7 @@ const postImageUrl = rawPostImageUrl
const postDate = formatPostDate(post.created ?? post.date); const postDate = formatPostDate(post.created ?? post.date);
const postContent = (post as { content?: string }).content; const contentHtml = await resolveBodyContent(post as { content?: unknown });
let contentHtml =
typeof postContent === "string" && postContent.trim()
? (marked.parse(postContent) as string)
: "";
if (contentHtml) {
contentHtml = await processMarkdownHtmlImages(contentHtml);
}
const hasRowContent = const hasRowContent =
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 || ((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 || ((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
+15 -2
View File
@@ -7,12 +7,14 @@ import {
} from '../../lib/cms'; } from '../../lib/cms';
import { import {
sortPostsByDate, sortPostsByDate,
filterHiddenPosts,
filterPostsByTag, filterPostsByTag,
getTotalPages, getTotalPages,
paginate, paginate,
getPostsPerPage, getPostsPerPage,
getTagsMap, getTagsMap,
resolvePostTagsInPost, resolvePostTagsInPost,
getPostImageUrl,
} from '../../lib/blog-utils'; } from '../../lib/blog-utils';
import { t, T } from '../../lib/translations'; import { t, T } from '../../lib/translations';
@@ -23,10 +25,21 @@ let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null; let cmsError: string | null = null;
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap(); const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap); for (const p of posts) resolvePostTagsInPost(p, tagsMap);
// Resolve post card images
const { ensureTransformedImage } = await import('../../lib/rusty-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch { /* image optional */ }
}
}
} catch (e) { } catch (e) {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
+15 -3
View File
@@ -4,10 +4,12 @@ import BlogOverview from '../../../components/BlogOverview.svelte';
import { getPosts, getTags } from '../../../lib/cms'; import { getPosts, getTags } from '../../../lib/cms';
import { import {
sortPostsByDate, sortPostsByDate,
filterHiddenPosts,
filterPostsByTag, filterPostsByTag,
getTotalPages, getTotalPages,
paginate, paginate,
getPostsPerPage, getPostsPerPage,
getPostImageUrl,
} from '../../../lib/blog-utils'; } from '../../../lib/blog-utils';
import { t, T } from '../../../lib/translations'; import { t, T } from '../../../lib/translations';
@@ -18,7 +20,7 @@ export async function getStaticPaths() {
let posts: Awaited<ReturnType<typeof getPosts>> = []; let posts: Awaited<ReturnType<typeof getPosts>> = [];
try { try {
posts = await getPosts(); posts = await getPosts();
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
} catch { } catch {
return []; return [];
} }
@@ -37,8 +39,18 @@ let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null; let cmsError: string | null = null;
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch { /* image optional */ }
}
}
} catch (e) { } catch (e) {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
+15 -3
View File
@@ -4,12 +4,14 @@ import BlogOverview from '../../../components/BlogOverview.svelte';
import { getPosts, getTags } from '../../../lib/cms'; import { getPosts, getTags } from '../../../lib/cms';
import { import {
sortPostsByDate, sortPostsByDate,
filterHiddenPosts,
filterPostsByTag, filterPostsByTag,
getTotalPages, getTotalPages,
paginate, paginate,
getPostsPerPage, getPostsPerPage,
getTagsMap, getTagsMap,
resolvePostTagsInPost, resolvePostTagsInPost,
getPostImageUrl,
} from '../../../lib/blog-utils'; } from '../../../lib/blog-utils';
import { t, T } from '../../../lib/translations'; import { t, T } from '../../../lib/translations';
@@ -22,7 +24,7 @@ export async function getStaticPaths() {
let tags: Awaited<ReturnType<typeof getTags>> = []; let tags: Awaited<ReturnType<typeof getTags>> = [];
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts(), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
} catch { } catch {
return []; return [];
} }
@@ -46,10 +48,20 @@ let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null; let cmsError: string | null = null;
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap(); const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap); for (const p of posts) resolvePostTagsInPost(p, tagsMap);
const { ensureTransformedImage } = await import('../../../lib/rusty-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch { /* image optional */ }
}
}
} catch (e) { } catch (e) {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
+15 -3
View File
@@ -4,12 +4,14 @@ import BlogOverview from '../../../../../components/BlogOverview.svelte';
import { getPosts, getTags } from '../../../../../lib/cms'; import { getPosts, getTags } from '../../../../../lib/cms';
import { import {
sortPostsByDate, sortPostsByDate,
filterHiddenPosts,
filterPostsByTag, filterPostsByTag,
getTotalPages, getTotalPages,
paginate, paginate,
getPostsPerPage, getPostsPerPage,
getTagsMap, getTagsMap,
resolvePostTagsInPost, resolvePostTagsInPost,
getPostImageUrl,
} from '../../../../../lib/blog-utils'; } from '../../../../../lib/blog-utils';
import { t, T } from '../../../../../lib/translations'; import { t, T } from '../../../../../lib/translations';
@@ -21,7 +23,7 @@ export async function getStaticPaths() {
let tags: Awaited<ReturnType<typeof getTags>> = []; let tags: Awaited<ReturnType<typeof getTags>> = [];
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts(), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
} catch { } catch {
return []; return [];
} }
@@ -58,10 +60,20 @@ let tags: Awaited<ReturnType<typeof getTags>> = [];
let cmsError: string | null = null; let cmsError: string | null = null;
try { try {
[posts, tags] = await Promise.all([getPosts(), getTags()]); [posts, tags] = await Promise.all([getPosts({ resolve: ['postImage'] }), getTags()]);
posts = sortPostsByDate(posts); posts = filterHiddenPosts(sortPostsByDate(posts));
const tagsMap = await getTagsMap(); const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap); for (const p of posts) resolvePostTagsInPost(p, tagsMap);
const { ensureTransformedImage } = await import('../../../../../lib/rusty-image');
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
try {
const url = await ensureTransformedImage(raw, { width: 400, height: 267, fit: 'cover', format: 'webp' });
(post as typeof post & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch { /* image optional */ }
}
}
} catch (e) { } catch (e) {
cmsError = e instanceof Error ? e.message : String(e); cmsError = e instanceof Error ? e.message : String(e);
} }
+1 -1
View File
@@ -272,7 +272,7 @@ main a:not(.no-underline):hover {
} }
/* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */ /* Lange URLs umbrechen, damit sie nicht über den Bildschirmrand ragen */
main a, main a:not(.no-underline),
.markdown a { .markdown a {
overflow-wrap: break-word; overflow-wrap: break-word;
word-break: break-all; word-break: break-all;