Add Formbricks configuration to environment files and update package dependencies
Deploy to Firebase Hosting / deploy (push) Failing after 1m26s

- Introduced new environment variables for Formbricks in .env.backup and .env.example.
- Updated package.json and yarn.lock to include React and related types for improved functionality.
- Added new favicon images and manifest for better branding and user experience.
- Implemented new Svelte components for enhanced content management, including Badge, Card, OrganisationsBlock, and OpnFormBlock.
- Updated ContentRows component to support new block types.
This commit is contained in:
Peter Meier
2026-04-05 20:50:23 +02:00
parent b40579ee0b
commit f5f6beeabe
66 changed files with 2788 additions and 280 deletions
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
color,
class: className = "",
}: { children?: Snippet; color?: string; class?: string } = $props();
function colorClasses(c?: string): string {
switch (c) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
default: return "bg-stein-100 text-stein-600 border border-stein-200";
}
}
</script>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {colorClasses(color)} {className}">
{@render children?.()}
</span>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
children,
href,
class: className = "",
}: { children?: Snippet; href?: 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 interactive = href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "";
</script>
{#if href}
<a {href} class="{base} {interactive} {className}">
{@render children?.()}
</a>
{:else}
<div class="{base} {className}">
{@render children?.()}
</div>
{/if}
+8
View File
@@ -11,6 +11,8 @@
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import OrganisationsBlock from "./blocks/OrganisationsBlock.svelte";
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "../lib/block-layout";
import type { BlockLayout } from "../lib/block-layout";
import type { Translations } from "../lib/translations";
@@ -29,6 +31,8 @@
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
OrganisationsBlockData,
OpnFormBlockData,
} from "../lib/block-types";
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
@@ -116,6 +120,10 @@
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
{:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
{:else if blockType(item) === "organisations"}
<OrganisationsBlock block={item as OrganisationsBlockData} />
{:else if blockType(item) === "opnform"}
<OpnFormBlock block={item as OpnFormBlockData} />
{:else}
<div class="col-span-12 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Unbekannter Block: <code>{blockType(item)}</code>
+112 -30
View File
@@ -7,9 +7,20 @@
import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations";
interface NavLink {
href: string;
label: string;
type NavChild = { href: string; label: string };
/** Flacher Link oder Navgroup (nur Label + Unterlinks, optional eigener Parent-href). */
type NavItem =
| NavChild
| { label: string; href?: string; children: NavChild[] };
function isNavGroup(
item: NavItem,
): item is { label: string; href?: string; children: NavChild[] } {
return (
"children" in item &&
Array.isArray(item.children) &&
item.children.length > 0
);
}
interface SocialLink {
@@ -22,11 +33,14 @@
links = [],
socialLinks = [],
logoUrl = null,
logoSvgHtml = null,
translations = {},
}: {
links?: NavLink[];
links?: NavItem[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
/** Inline-SVG-Markup (z. B. aus Layout per fetch); hat Vorrang vor logoUrl. */
logoSvgHtml?: string | null;
translations?: Translations | null;
} = $props();
@@ -89,15 +103,20 @@
<header
id="horizontal-navigation"
class="sticky top-0 z-20 backdrop-blur bg-stein-900/95 shadow-2xl"
class="sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-linear-to-br from-stein-50 from-0% via-wald-100/90 via-42% to-himmel-100 to-100%"
>
<div class="flex items-center justify-between container-custom py-2">
<a
href="/"
class="logo flex items-center text-stein-0 font-bold text-lg tracking-wide hover:text-wald-100 transition-colors relative"
class="logo logo--color-cycle flex items-center origin-left font-bold text-lg tracking-wide hover:scale-[1.12] transition-transform duration-200 ease-out relative"
>
{#if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-12 w-auto object-contain" />
{#if logoSvgHtml && logoSvgHtml.trim() !== ""}
<span
class="logo-svg h-10 w-auto inline-flex items-center text-inherit [&_svg]:block [&_svg]:h-full [&_svg]:w-auto [&_svg]:max-h-10"
aria-hidden="true"
>{@html logoSvgHtml}</span>
{:else if logoUrl && logoUrl.trim() !== ""}
<img src={logoUrl} alt="" class="h-10 w-auto object-contain" />
{:else}
<span>{t(T.site_name_fallback)}</span>
{/if}
@@ -108,21 +127,55 @@
class="hidden lg:flex items-center gap-4"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="text-stein-100 hover:text-wald-100 hover:animate-pulse transition-all text-xs tracking-wide"
>
{link.label}
</a>
{#each links as item}
{#if isNavGroup(item)}
<details class="relative group">
<summary
class="cursor-pointer list-none text-xs tracking-wide hover:animate-pulse transition-all [&::-webkit-details-marker]:hidden"
>
{item.label}
</summary>
<ul
class="absolute left-0 top-full z-30 mt-1 min-w-48 rounded-md border border-stein-200/80 bg-linear-to-br from-stein-50 to-himmel-50 py-1 shadow-lg"
>
{#if item.href}
<li>
<a
href={item.href}
class="block px-3 py-2 text-xs tracking-wide hover:bg-stein-200/50"
>
{item.label}
</a>
</li>
{/if}
{#each item.children as child}
<li>
<a
href={child.href}
class="block px-3 py-2 text-xs tracking-wide hover:bg-stein-200/50"
>
{child.label}
</a>
</li>
{/each}
</ul>
</details>
{:else}
<a
href={item.href}
class=" hover:animate-pulse transition-all text-xs tracking-wide"
>
{item.label}
</a>
{/if}
{/each}
<div><span class="text-stein-100">|</span></div>
<div><span class="opacity-20">|</span></div>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="p-1.5 -m-1.5 text-stein-100 hover:text-wald-100 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
class="p-1.5 -m-1.5 rounded-md transition-colors flex items-center justify-center"
aria-label={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} aria-hidden="true" />
@@ -130,7 +183,7 @@
{/each}
<button
type="button"
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
class="p-2 -mr-2 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
@@ -141,7 +194,7 @@
{#if searchOpen}
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else}
<Icon icon="mdi:magnify" class="text-wald-400!" aria-hidden="true" />
<Icon icon="mdi:magnify" aria-hidden="true" />
{/if}
</button>
</nav>
@@ -150,7 +203,7 @@
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
class="p-2 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
@@ -167,14 +220,14 @@
{:else}
<Icon
icon="mdi:magnify"
class="text-2xl text-wald-400!"
class="text-2xl"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
class="p-2 -mr-2 hover:bg-stein-0/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
aria-controls="mobile-nav"
onclick={toggleMenu}
@@ -207,14 +260,42 @@
class="container-custom py-4 flex flex-col gap-1"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
onclick={closeMenu}
>
{link.label}
</a>
{#each links as item}
{#if isNavGroup(item)}
<span
class="block py-2 px-2 text-xs font-medium uppercase tracking-wide text-stein-400"
>
{item.label}
</span>
<div class="ml-2 flex flex-col border-l border-stein-0/20 pl-3">
{#if item.href}
<a
href={item.href}
class="block py-2 px-2 text-sm text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
onclick={closeMenu}
>
{item.label}
</a>
{/if}
{#each item.children as child}
<a
href={child.href}
class="block py-2 px-2 text-sm text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
onclick={closeMenu}
>
{child.label}
</a>
{/each}
</div>
{:else}
<a
href={item.href}
class="block py-3 px-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors text-sm"
onclick={closeMenu}
>
{item.label}
</a>
{/if}
{/each}
{#if socialLinks.length > 0}
<div
@@ -242,3 +323,4 @@
</div>
{/if}
</header>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { OrganisationEntry } from "../lib/block-types";
let { org }: { org: OrganisationEntry } = $props();
const link = $derived(typeof org.link === "object" ? org.link : null);
const badge = $derived(typeof org.badge === "object" ? org.badge : null);
function badgeClasses(color?: string): string {
switch (color) {
case "green": return "bg-wald-50 text-wald-700 border border-wald-200";
case "blue": return "bg-himmel-50 text-himmel-700 border border-himmel-200";
case "amber": return "bg-[#fdf8ec] text-[#a6780a] border border-[#f0d98a]";
default: return "bg-stein-100 text-stein-600 border border-stein-200";
}
}
</script>
<div class="flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2">
<h5 class="text-base font-semibold text-stein-900 leading-snug">{org.name ?? ""}</h5>
{#if badge?.label}
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {badgeClasses(badge.color)}">
{badge.label}
</span>
{/if}
</div>
{#if org.description}
<p class="text-sm text-stein-600">{org.description}</p>
{/if}
{#if link?.url}
<a
href={link.url}
class="no-underline mt-auto inline-flex items-center gap-1 text-sm font-medium text-himmel-600 hover:text-himmel-700 transition-colors"
>
{link.linkName ?? link.url}
<span aria-hidden="true"></span>
</a>
{/if}
</div>
+100 -126
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MasonryPhotoAlbum, type Photo } from "react-photo-album";
import { marked } from "marked";
import { fade } from "svelte/transition";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
import "../../lib/iconify-offline";
import Icon from "@iconify/svelte";
import { useTranslate } from "../../lib/translations";
import { T } from "../../lib/translations";
import "react-photo-album/masonry.css";
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
@@ -28,7 +30,14 @@
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
);
let currentIndex = $state(0);
/** Fallback, solange keine echten Pixelmaße bekannt sind (CMS liefert keine width/height). */
const DEFAULT_W = 1600;
const DEFAULT_H = 900;
let galleryPhotos = $state<Photo[]>([]);
let albumHost: HTMLDivElement | undefined = $state();
let reactRoot: Root | null = null;
function imageUrl(img: ImageGalleryImage): string | null {
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
@@ -36,68 +45,98 @@
return null;
}
function prev() {
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
function metaToPhotos(
meta: { img: ImageGalleryImage; url: string }[],
dims: { w: number; h: number }[],
): Photo[] {
return meta.map(({ img, url }, i) => ({
src: url,
width: Math.max(1, dims[i]?.w ?? DEFAULT_W),
height: Math.max(1, dims[i]?.h ?? DEFAULT_H),
alt: img.description ?? "",
title: img.description ?? undefined,
}));
}
function next() {
currentIndex = (currentIndex + 1) % withUrl.length;
function loadImageSize(url: string): Promise<{ w: number; h: number }> {
return new Promise((resolve) => {
const im = new Image();
im.onload = () =>
resolve({
w: im.naturalWidth || DEFAULT_W,
h: im.naturalHeight || DEFAULT_H,
});
im.onerror = () => resolve({ w: DEFAULT_W, h: DEFAULT_H });
im.src = url;
});
}
const SWIPE_THRESHOLD = 50;
let startX = $state(0);
let isDragging = $state(false);
let dragDelta = $state(0);
function onPointerDown(e: PointerEvent) {
if (withUrl.length <= 1) return;
startX = e.clientX;
isDragging = true;
dragDelta = 0;
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
dragDelta = e.clientX - startX;
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
}
function onPointerUp() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
$effect(() => {
const meta = withUrl;
if (meta.length < 2) {
galleryPhotos = [];
return;
}
isDragging = false;
dragDelta = 0;
startX = 0;
const fallback = meta.map(() => ({ w: DEFAULT_W, h: DEFAULT_H }));
galleryPhotos = metaToPhotos(meta, fallback);
if (typeof window === "undefined") return;
let cancelled = false;
void Promise.all(meta.map(({ url }) => loadImageSize(url))).then((dims) => {
if (cancelled) return;
galleryPhotos = metaToPhotos(meta, dims);
});
return () => {
cancelled = true;
};
});
function renderAlbum() {
if (!reactRoot || galleryPhotos.length < 2) return;
reactRoot.render(
createElement(MasonryPhotoAlbum, {
photos: galleryPhotos,
defaultContainerWidth: 1180,
columns: (containerWidth: number) => {
if (containerWidth < 480) return 2;
if (containerWidth < 800) return 3;
return 4;
},
spacing: 8,
padding: 0,
sizes: {
size: "min(1180px, 100vw - 2rem)",
sizes: [{ viewport: "(max-width: 1280px)", size: "calc(100vw - 2rem)" }],
},
}),
);
}
function onTouchStart(e: TouchEvent) {
if (withUrl.length <= 1) return;
startX = e.touches[0].clientX;
isDragging = true;
dragDelta = 0;
}
onMount(() => {
if (withUrl.length < 2) return;
function onTouchMove(e: TouchEvent) {
if (!isDragging || !e.touches[0]) return;
const delta = e.touches[0].clientX - startX;
dragDelta = delta;
if (Math.abs(delta) > 10) e.preventDefault();
}
let cancelled = false;
void tick().then(() => {
if (cancelled || !albumHost) return;
reactRoot = createRoot(albumHost);
renderAlbum();
});
function onTouchEnd() {
if (!isDragging) return;
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
if (dragDelta < 0) next();
else prev();
}
isDragging = false;
dragDelta = 0;
startX = 0;
}
return () => {
cancelled = true;
reactRoot?.unmount();
reactRoot = null;
};
});
$effect(() => {
galleryPhotos;
if (withUrl.length < 2 || !reactRoot) return;
renderAlbum();
});
</script>
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
@@ -122,74 +161,9 @@
{/if}
</figure>
{:else}
<div class="group relative">
<div
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
role="region"
aria-label={t(T.image_gallery_swipe_aria)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
ontouchstart={onTouchStart}
ontouchmove={onTouchMove}
ontouchend={onTouchEnd}
ontouchcancel={onTouchEnd}
>
{#key currentIndex}
{@const current = withUrl[currentIndex]}
<figure
class="m-0 absolute inset-0"
transition:fade={{ duration: 200 }}
>
<img
src={current.url}
alt={current.img.description ?? ""}
class="w-full h-full object-cover"
loading={currentIndex === 0 ? "eager" : "lazy"}
/>
{#if current.img.description}
<figcaption
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
>
{current.img.description}
</figcaption>
{/if}
</figure>
{/key}
</div>
<button
type="button"
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_prev_aria)}
onclick={prev}
>
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
aria-label={t(T.image_gallery_next_aria)}
onclick={next}
>
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
</button>
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
{#each withUrl as _, i}
<button
type="button"
role="tab"
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
aria-selected={i === currentIndex}
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
? "bg-wald-600"
: "bg-stein-300 hover:bg-stein-400"}"
onclick={() => (currentIndex = i)}
></button>
{/each}
</div>
</div>
<div
bind:this={albumHost}
class="react-photo-album-host w-full min-h-48 [&_.react-photo-album]:max-w-none"
></div>
{/if}
</div>
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from "svelte";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { OpnFormBlockData } from "../../lib/block-types";
let { block }: { block: OpnFormBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const formId = $derived((block.formId ?? "").trim());
const baseUrl = $derived(
(typeof block.baseUrl === "string" && block.baseUrl.trim()
? block.baseUrl.trim()
: "https://opnform.pm86.de"
).replace(/\/$/, ""),
);
const iframeSrc = $derived(
formId ? `${baseUrl}/forms/${formId}` : "",
);
const iframeTitle = $derived(
block.iframeTitle?.trim() || "Kontaktformular",
);
/**
* 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(() => {
if (!formId) return;
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
function runInit() {
const w = window as Window & {
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
};
w.initEmbed?.(formId, { autoResize: true });
}
const existing = document.querySelector<HTMLScriptElement>(
`script[data-opnform-embed-js="${baseUrl}"]`,
);
const w = window as Window & { initEmbed?: unknown };
if (existing) {
if (typeof w.initEmbed === "function") {
queueMicrotask(runInit);
} else {
existing.addEventListener("load", runInit, { once: true });
}
return;
}
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);
});
</script>
<div
class="{layoutClasses} content-opnform w-full min-w-0 overflow-visible -mx-6"
data-block-type="opnform"
data-block-slug={block._slug}
>
{#if iframeSrc}
<iframe
style={iframeStyle}
id={formId}
title={iframeTitle}
src={iframeSrc}
></iframe>
{:else}
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
{/if}
</div>
@@ -0,0 +1,200 @@
<script lang="ts">
import { marked } from "marked";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { OrganisationsBlockData, OrganisationEntry } from "../../lib/block-types";
import { absoluteUrlFromCmsImageField } from "../../lib/cms-media-url";
import Card from "../Card.svelte";
import Badge from "../Badge.svelte";
marked.setOptions({ gfm: true });
/** Nach `resolveContentImages`: `resolvedLogoSrc`; sonst gleiche URL-Auflösung wie Image-Blöcke. */
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
const resolved = o.resolvedLogoSrc?.trim();
if (resolved) return resolved;
return absoluteUrlFromCmsImageField(o.logo) ?? undefined;
}
function organisationLogoAlt(o: OrganisationEntry): string {
if (o.logo && typeof o.logo === "object" && o.logo.description) {
return o.logo.description;
}
return o.name ?? "";
}
let { block }: { block: OrganisationsBlockData } = $props();
const layoutClasses = $derived(
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
);
const compact = $derived(block.presentation === "compact");
const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived(
block.addOrganisationMarkdown
? (marked.parse(block.addOrganisationMarkdown) as string)
: ""
);
</script>
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline}
<h3 class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}>{block.headline}</h3>
{/if}
<div
class={compact
? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3"
: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const linkUrl =
typeof o.link === "object" &&
o.link !== null &&
typeof o.link.url === "string" &&
o.link.url.trim() !== ""
? o.link.url.trim()
: undefined}
{@const logoSrc = organisationLogoSrc(o)}
{#if compact}
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
<div
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-0.5"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg
class="h-7 w-7 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
<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>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<Badge color={o.badge.color}>{o.badge.label}</Badge>
{/if}
</div>
{#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
{/if}
</div>
</Card>
{:else}
<Card href={linkUrl}>
<div
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
>
{#if logoSrc}
<img
src={logoSrc}
alt={organisationLogoAlt(o)}
class="h-full w-full object-contain p-1"
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg
class="h-10 w-10 text-stein-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 48 48"
fill="currentColor"
aria-hidden="true"
>
<defs>
<mask id={fishMaskId}>
<g fill="none">
<path
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M24 30v14"
/>
<path
fill="#555555"
stroke="#fff"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="4"
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
/>
<circle cx="24" cy="24" r="2" fill="#fff" />
</g>
</mask>
</defs>
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
</svg>
{/if}
</div>
{#if o.badge && typeof o.badge === "object" && o.badge.label}
<div class="absolute top-3 right-3">
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="text-base font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-sm text-stein-600">{o.description}</p>
{/if}
</Card>
{/if}
{/if}
{/each}
{#if block.addOrganisationTitle || block.addOrganisationMarkdown}
<Card
class={compact
? "border-dashed border-wald-300 bg-wald-50 p-3! gap-1.5!"
: "border-dashed border-wald-300 bg-wald-50"}
>
{#if block.addOrganisationTitle}
<h5
class={compact
? "text-sm font-semibold text-wald-800 leading-snug"
: "text-base font-semibold text-wald-800 leading-snug"}
>
{block.addOrganisationTitle}
</h5>
{/if}
{#if ctaHtml}
<div class={compact ? "markdown text-xs text-wald-700" : "markdown text-sm text-wald-700"}>
{@html ctaHtml}
</div>
{/if}
</Card>
{/if}
</div>
</div>
+4
View File
@@ -4,6 +4,10 @@ interface ImportMetaEnv {
readonly PUBLIC_CMS_URL: string;
readonly PUBLIC_UMAMI_SCRIPT_URL?: string;
readonly PUBLIC_UMAMI_WEBSITE_ID?: string;
/** Formbricks-Instanz (ohne trailing slash), z. B. https://forms.pm86.de */
readonly PUBLIC_FORMBRICKS_APP_URL?: string;
/** Formbricks Environment ID (Setup Checklist) */
readonly PUBLIC_FORMBRICKS_ENVIRONMENT_ID?: string;
}
interface ImportMeta {
+231 -34
View File
@@ -81,12 +81,12 @@ const resolvedBreadcrumbItems =
: (item.label ?? ""),
})) ?? [];
// Header: Navigation laden und Links bauen
interface NavLink {
href: string;
label: string;
}
let headerLinks: NavLink[] = [];
// Header: Navigation (flache Links + navgroup-Untermenüs, vgl. angelverein-vachdorf.de Layout.astro)
type HeaderNavChild = { href: string; label: string };
type HeaderNavItem =
| HeaderNavChild
| { label: string; href?: string; children: HeaderNavChild[] };
let headerNavItems: HeaderNavItem[] = [];
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
function normalizeSlug(s: string | undefined): string {
return (s ?? "").replace(/^\//, "").trim();
@@ -96,6 +96,49 @@ function getSlugFromEntry(entry: NavLinkEntry): string {
const e = entry as { _slug?: string; slug?: string };
return e.slug ?? e._slug ?? "";
}
function normalizeNavHref(url: string): string {
return url.startsWith("/") || url.startsWith("http")
? url
: `/${url.replace(/^\//, "")}`;
}
/** CMS-Kanon-Slug ohne führenden Slash (z. B. bi/vachdorf) → URL-Pfad. */
function pageSlugToPath(canonicalSlug: string): string {
const s = (canonicalSlug ?? "").trim();
if (!s || s === "home") return "/";
return (
"/" +
s.split("/")
.filter(Boolean)
.map((seg) => encodeURIComponent(seg))
.join("/")
);
}
function resolveNavEntryToChild(
entry: NavLinkEntry,
slugToLabel: Map<string, string>,
slugToPageHref: Map<string, string>,
slugToPostHref: Map<string, string>,
): HeaderNavChild | null {
const asObj =
typeof entry === "object" && entry !== null
? (entry as { url?: string; linkName?: string; name?: string })
: null;
if (asObj?.url && String(asObj.url).trim() !== "") {
const href = normalizeNavHref(String(asObj.url).trim());
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? "";
return { href, label };
}
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || "home";
const label = slugToLabel.get(slug) ?? slug;
const postHref = slugToPostHref.get(slug);
const pageSlug = slugToPageHref.get(slug) ?? slug;
const hrefSlug = normalizeSlug(pageSlug) || "home";
const href =
postHref ??
(hrefSlug === "home" ? "/" : pageSlugToPath(hrefSlug));
return { href, label };
}
try {
const nav = await getNavigationByKey(NavigationKeys.header, {
locale: "de",
@@ -138,29 +181,51 @@ try {
}
}
for (const entry of rawLinks) {
const asObj =
const asRecord =
typeof entry === "object" && entry !== null
? (entry as { url?: string; linkName?: string; name?: string })
? (entry as Record<string, unknown>)
: null;
if (asObj?.url) {
const href =
asObj.url.startsWith("/") || asObj.url.startsWith("http")
? asObj.url
: `/${asObj.url.replace(/^\//, "")}`;
const label = asObj.linkName ?? asObj.name ?? asObj.url ?? "";
headerLinks.push({ href, label });
const rawLinksInner = asRecord?.links;
const isNavGroup =
asRecord &&
typeof asRecord.label === "string" &&
Array.isArray(rawLinksInner) &&
rawLinksInner.length > 0 &&
typeof rawLinksInner[0] === "object" &&
rawLinksInner[0] !== null;
if (isNavGroup) {
const children: HeaderNavChild[] = [];
for (const sub of rawLinksInner as unknown[]) {
const child = resolveNavEntryToChild(
sub as NavLinkEntry,
slugToLabel,
slugToPageHref,
slugToPostHref,
);
if (child) children.push(child);
}
if (children.length === 0) continue;
const urlRaw = asRecord.url;
const parentHref =
typeof urlRaw === "string" && urlRaw.trim() !== ""
? normalizeNavHref(urlRaw.trim())
: undefined;
headerNavItems.push({
label: asRecord.label as string,
...(parentHref ? { href: parentHref } : {}),
children,
});
continue;
}
const raw = getSlugFromEntry(entry);
const slug = normalizeSlug(raw) || "home";
const label = slugToLabel.get(slug) ?? slug;
const postHref = slugToPostHref.get(slug);
const pageSlug = slugToPageHref.get(slug) ?? slug;
const hrefSlug = normalizeSlug(pageSlug) || "home";
const href =
postHref ??
(hrefSlug === "home" ? "/" : `/${encodeURIComponent(hrefSlug)}`);
headerLinks.push({ href, label });
const flat = resolveNavEntryToChild(
entry,
slugToLabel,
slugToPageHref,
slugToPostHref,
);
if (flat) headerNavItems.push(flat);
}
} catch {
// CMS nicht erreichbar
@@ -215,6 +280,8 @@ try {
// Logo und SEO-Templates aus page_config (wie windwiderstand).
let logoUrl: string | null = null;
/** Bei SVG: serverseitig geladener Markup für Inline-Render (currentColor statt img-Tag). */
let logoSvgHtml: string | null = null;
let pageConfig: Awaited<ReturnType<typeof getPageConfigBySlug>> | null = null;
try {
// Zuerst page-config-default (häufiger Slug), dann default vermeidet doppelten 404
@@ -244,11 +311,26 @@ try {
typeof logoFileUrl === "string" && logoFileUrl.startsWith("//")
? `https:${logoFileUrl}`
: logoFileUrl;
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: "contain",
format: "webp",
});
const isSvg = u.split("?")[0].toLowerCase().endsWith(".svg");
if (isSvg) {
// SVGs können nicht transformiert werden — direkt als URL nutzen
logoUrl = u.startsWith("/") ? `${import.meta.env.PUBLIC_CMS_URL}${u}` : u;
try {
const res = await fetch(logoUrl);
if (res.ok) {
const text = (await res.text()).trim();
if (text.includes("<svg")) logoSvgHtml = text;
}
} catch {
/* z. B. Build ohne erreichbares CMS → Fallback img mit logoUrl */
}
} else {
logoUrl = await ensureTransformedImage(u, {
height: 56,
fit: "contain",
format: "webp",
});
}
}
} catch {
/* Logo optional */
@@ -376,11 +458,89 @@ const cmsFromCache = getCmsFromCache();
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#2d7a45" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<link rel="icon" href="/favicons/favicon.ico" sizes="any" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicons/favicon-16x16.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/favicons/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/favicons/android-icon-192x192.png"
/>
<link
rel="apple-touch-icon"
sizes="57x57"
href="/favicons/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/favicons/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/favicons/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/favicons/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/favicons/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/favicons/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/favicons/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/favicons/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicons/apple-icon-180x180.png"
/>
<link rel="manifest" href="/favicons/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/favicons/ms-icon-144x144.png"
/>
<meta
name="msapplication-config"
content="/favicons/browserconfig.xml"
/>
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#2d7a45" />
<meta name="msapplication-TileColor" content="#2d7a45" />
<meta name="generator" content={Astro.generator} />
<title>{seoTitle}</title>
{seoDescription && <meta name="description" content={seoDescription} />}
@@ -441,6 +601,42 @@ const cmsFromCache = getCmsFromCache();
/>
)
}
{
import.meta.env.PUBLIC_FORMBRICKS_APP_URL &&
import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID && (
<script
is:inline
define:vars={{
formbricksAppUrl: import.meta.env.PUBLIC_FORMBRICKS_APP_URL,
formbricksEnvironmentId:
import.meta.env.PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
}}
>
(function () {
var appUrl = String(formbricksAppUrl || "").replace(/\/$/, "");
var environmentId = String(formbricksEnvironmentId || "").trim();
if (!appUrl || !environmentId) return;
var t = document.createElement("script");
t.type = "text/javascript";
t.async = true;
t.src = appUrl + "/js/formbricks.umd.cjs";
var e = document.getElementsByTagName("script")[0];
if (e && e.parentNode) e.parentNode.insertBefore(t, e);
setTimeout(function () {
if (
window.formbricks &&
typeof window.formbricks.setup === "function"
) {
window.formbricks.setup({
environmentId: environmentId,
appUrl: appUrl,
});
}
}, 500);
})();
</script>
)
}
<script
src="https://analytics.pm86.de/api/script.js"
data-site-id="ad2858cf2db1"
@@ -465,9 +661,10 @@ const cmsFromCache = getCmsFromCache();
</script>
<TranslationProvider translations={translations}>
<Header
links={headerLinks}
links={headerNavItems}
socialLinks={socialLinks}
logoUrl={logoUrl}
logoSvgHtml={logoSvgHtml}
translations={translations}
client:load
/>
+39 -1
View File
@@ -57,6 +57,17 @@ export interface HtmlBlockData {
layout?: BlockLayout;
}
/** OpnForm embed (_type: "opnform"). */
export interface OpnFormBlockData {
_type?: "opnform";
_slug?: string;
id?: string;
formId?: string;
baseUrl?: string;
iframeTitle?: string;
layout?: BlockLayout;
}
/** Iframe (_type: "iframe"). */
export interface IframeBlockData {
_type?: "iframe";
@@ -105,7 +116,7 @@ export interface ImageGalleryImage {
src?: string;
description?: string;
file?: { url?: string };
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (16:9). */
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (max. Breite, Ratio erhalten). */
resolvedSrc?: string;
}
@@ -195,6 +206,33 @@ export interface SearchableTextBlockData {
layout?: BlockLayout;
}
/** Einzelne Organisation (aufgelöst aus organisation-Collection). */
export interface OrganisationEntry {
_type?: "organisation";
_slug?: string;
name?: string;
logo?: string | { src?: string; description?: string };
/** Nach resolveContentImages: lokaler Pfad (public/dist) zum Logo (WebP). */
resolvedLogoSrc?: string;
description?: string;
link?: string | { url?: string; linkName?: string; newTab?: boolean };
badge?: string | { label?: string; color?: string };
}
/** Organisations-Block (_type: "organisations"). */
export interface OrganisationsBlockData {
_type?: "organisations";
_slug?: string;
headline?: string;
description?: string;
/** default = Karten-Grid; compact = engere Karten, kleinere Typo, Logo links */
presentation?: "default" | "compact";
addOrganisationTitle?: string;
addOrganisationMarkdown?: string;
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
/**
* CMS-Bild-URLs für Browser und Node (ohne fs/crypto).
* Wird von rusty-image und Svelte-Blöcken genutzt eine Quelle für LoopbackProduction und relative Pfade.
*/
export function getCmsBaseUrl(): string {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
).replace(/\/$/, "");
return base;
}
function isLoopbackAssetHost(hostname: string): boolean {
const h = hostname.toLowerCase();
return (
h === "localhost" ||
h === "127.0.0.1" ||
h === "::1" ||
h === "[::1]" ||
h === "0.0.0.0"
);
}
/**
* Relative CMS-Pfade (`/api/assets/…`) absolut machen; in gespeicherten Einträgen oft
* `http://127.0.0.1:3000/api/assets/…` `PUBLIC_CMS_URL` + Pfad (Production-Build).
*/
export function normalizeMediaUrl(url: string): string {
const u = url.trim();
if (!u) return u;
if (u.startsWith("//")) return `https:${u}`;
if (u.startsWith("http://") || u.startsWith("https://")) {
try {
const parsed = new URL(u);
if (
isLoopbackAssetHost(parsed.hostname) &&
parsed.pathname.includes("/api/assets/")
) {
const base = getCmsBaseUrl();
return `${base}${parsed.pathname}${parsed.search}`;
}
} catch {
/* ignore */
}
return u;
}
if (u.startsWith("/")) return `${getCmsBaseUrl()}${u}`;
return u;
}
/**
* Wie Image-Block / Galerie / Organisation-Logo: `string` oder Objekt mit `src` / `file.url`.
* Liefert eine für `ensureTransformedImage` nutzbare URL (nach normalizeMediaUrl).
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
if (typeof field === "string") {
const s = field.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (field && typeof field === "object") {
const o = field as { src?: string; file?: { url?: string } };
const u =
typeof o.src === "string" && o.src.trim()
? o.src
: typeof o.file?.url === "string" && o.file.url.trim()
? o.file.url
: undefined;
if (!u) return null;
return normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
}
return null;
}
+33 -11
View File
@@ -85,24 +85,40 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
}
/**
* Alle Page-Einträge (GET /api/content/page). Gecacht pro Prozess.
* Alle Page-Einträge (GET /api/content/page). Lädt alle Seiten (paginiert), damit
* getStaticPaths ([slug]) auch jenseits der API-Standard-Seitengröße (50) vollständig ist.
*/
export async function getPages(): Promise<PageEntry[]> {
const key = "getPages";
export async function getPages(options?: { locale?: string }): Promise<PageEntry[]> {
const locale = options?.locale ?? "de";
const key = `getPages:${locale}`;
if (!skipListCache) {
const p = listCache.get(key) as Promise<PageEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const res = await (await getCachedFetch())(`${base}/api/content/page`);
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
);
const perPage = 200;
const all: PageEntry[] = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = new URL(`${base}/api/content/page`);
url.searchParams.set("_locale", locale);
url.searchParams.set("_page", String(page));
url.searchParams.set("_per_page", String(perPage));
const res = await (await getCachedFetch())(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as PageListResponse;
const items = data.items ?? [];
all.push(...items);
totalPages = Math.max(1, data.total_pages ?? 1);
page += 1;
}
const data = (await res.json()) as PageListResponse;
return data.items ?? [];
return all;
})();
if (!skipListCache) listCache.set(key, p);
return p;
@@ -216,8 +232,14 @@ export async function getPageBySlug(
if (res.status === 404 && !slug.startsWith("/")) {
res = await trySlug("/" + slug);
}
// Dateiname im Content-Dir ist meist page-{urlSlug} (z. B. page-bi-vachdorf), nicht bi-vachdorf.
if (res.status === 404 && !slug.startsWith("page-")) {
res = await trySlug(`page-${slug}`);
}
if (res.status === 404) {
const items = await getPages();
const items = await getPages({
locale: options?.locale,
});
const norm = normalizePageSlug(slug);
const match = items.find(
(p) =>
+139 -62
View File
@@ -7,6 +7,14 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import {
absoluteUrlFromCmsImageField,
getCmsBaseUrl,
normalizeMediaUrl,
} from "./cms-media-url";
/** Für ältere Importe von `rusty-image`; neue Nutzung: `./cms-media-url`. */
export { normalizeMediaUrl };
/** Transform-Parameter wie von der RustyCMS-API unterstützt. */
export interface RustyImageTransformParams {
@@ -27,27 +35,6 @@ const SUBDIR = "images/transformed";
/** Persistenter Cache für Builds überlebt "astro build" (dist wird geleert). */
const TRANSFORM_CACHE_DIR = ".cache/transformed-images";
function getCmsBaseUrl(): string {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
typeof url === "string" && url.trim() ? url : "http://localhost:3000"
).replace(/\/$/, "");
return base;
}
/**
* Relative CMS-Pfade (z. B. `/api/assets/…`) für Fetch/Transform absolut machen.
* RustyCMS liefert oft relative URLs; `/api/transform` braucht eine absolute URL.
*/
export function normalizeMediaUrl(url: string): string {
const u = url.trim();
if (!u) return u;
if (u.startsWith("//")) return `https:${u}`;
if (u.startsWith("http://") || u.startsWith("https://")) return u;
if (u.startsWith("/")) return `${getCmsBaseUrl()}${u}`;
return u;
}
/** Stabile Dateiendung aus format. */
function formatToExt(format: string): string {
switch (format.toLowerCase()) {
@@ -176,20 +163,6 @@ export async function ensureTransformedImage(
/** Ein Eintrag aus einem Bild-Array: URL-String oder aufgelöstes img-Objekt (src). */
type ImageArrayItem = string | { src?: string };
function toImageUrl(item: ImageArrayItem): string | null {
if (typeof item === "string") {
const s = item.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (item && typeof item === "object" && typeof item.src === "string") {
const s = item.src.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
return null;
}
/**
* Löst ein Array von Bild-URLs/Referenzen über die Transform-API auf.
* Akzeptiert string[] oder Objekte mit src (z. B. { _type: "img", src, description }).
@@ -201,7 +174,7 @@ export async function resolveImageUrls(
): Promise<string[]> {
const result: string[] = [];
for (const item of items) {
const u = toImageUrl(item);
const u = absoluteUrlFromCmsImageField(item);
if (!u) continue;
try {
result.push(await ensureTransformedImage(u, params));
@@ -217,34 +190,14 @@ function getImageBlockUrl(block: {
image?: string | { src?: string; file?: { url?: string }; title?: string };
img?: string | { src?: string; file?: { url?: string }; title?: string };
}): string | null {
const source = block.image ?? block.img;
if (typeof source === "string") {
const s = source.trim();
if (!s) return null;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (source && typeof source === "object") {
const u =
"src" in source && typeof source.src === "string" ? source.src : source.file?.url;
if (typeof u !== "string" || !u.trim()) return null;
return normalizeMediaUrl(u.startsWith("//") ? `https:${u}` : u);
}
return null;
return absoluteUrlFromCmsImageField(block.image ?? block.img);
}
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
function getGalleryImageUrl(
img: { src?: string; file?: { url?: string } },
): string | null {
if (typeof img.src === "string" && img.src.trim()) {
const s = img.src;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
if (img.file?.url) {
const s = img.file.url;
return normalizeMediaUrl(s.startsWith("//") ? `https:${s}` : s);
}
return null;
return absoluteUrlFromCmsImageField(img);
}
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
@@ -308,6 +261,99 @@ export async function processMarkdownHtmlImages(
return out;
}
const RAW_SUBDIR = "images/raw";
const RAW_CACHE_DIR = ".cache/raw-assets";
/**
* Lädt ein CMS-Asset direkt herunter (ohne Transform) und legt es lokal ab.
* Gibt den lokalen öffentlichen Pfad zurück (z.B. /images/raw/abc123.pdf).
*/
export async function downloadAsset(url: string): Promise<string> {
const absoluteUrl = url.trim();
if (!absoluteUrl.startsWith("http")) return url;
// _environment und _locale aus URL entfernen für sauberen Hash und Dateinamen
let cleanUrl = absoluteUrl;
try {
const u = new URL(absoluteUrl);
u.searchParams.delete("_environment");
u.searchParams.delete("_locale");
cleanUrl = u.toString();
} catch { /* ignore */ }
const hash = createHash("sha256").update(cleanUrl).digest("hex").slice(0, 24);
const rawExt = cleanUrl.split("?")[0].split(".").pop()?.toLowerCase() ?? "bin";
const filename = `${hash}.${rawExt}`;
const projectRoot = typeof process !== "undefined" && process.cwd ? process.cwd() : undefined;
if (!projectRoot) return url;
const isBuild = typeof import.meta !== "undefined" && (import.meta as { env?: { PROD?: boolean } }).env?.PROD === true;
const baseDir = isBuild ? path.join(projectRoot, "dist") : path.join(projectRoot, "public");
const dir = path.join(baseDir, RAW_SUBDIR);
const filePath = path.join(dir, filename);
const publicPath = `/${RAW_SUBDIR}/${filename}`;
const cacheDir = path.join(projectRoot, RAW_CACHE_DIR);
const cacheFilePath = path.join(cacheDir, filename);
if (isBuild) {
try {
await fs.access(cacheFilePath);
await fs.mkdir(dir, { recursive: true });
await fs.copyFile(cacheFilePath, filePath);
return publicPath;
} catch { /* nicht im Cache */ }
} else {
try {
await fs.access(filePath);
return publicPath;
} catch { /* Datei existiert nicht */ }
}
console.log("[rusty-image] GET (raw)", absoluteUrl);
const res = await fetch(absoluteUrl);
if (!res.ok) throw new Error(`downloadAsset: fetch failed ${res.status} for ${absoluteUrl}`);
const buffer = Buffer.from(await res.arrayBuffer());
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, buffer);
if (isBuild) {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(cacheFilePath, buffer);
}
return publicPath;
}
const A_HREF_REGEX = /<a(\s[^>]*?)href\s*=\s*["']([^"']+)["']([^>]*)>/gi;
/**
* Findet alle <a href="..."> die auf CMS-Assets zeigen (/api/assets/),
* lädt die Datei lokal herunter und ersetzt die href mit dem lokalen Pfad.
*/
export async function downloadCmsAssetLinks(html: string): Promise<string> {
const matches = [...html.matchAll(A_HREF_REGEX)];
const replacements: { index: number; length: number; newTag: string }[] = [];
for (const m of matches) {
const rawHref = m[2];
const absolute = normalizeMediaUrl(rawHref);
if (!absolute.startsWith("http") || !absolute.includes("/api/assets/")) continue;
try {
const localPath = await downloadAsset(absolute);
const newTag = m[0].replace(rawHref, localPath);
replacements.push({ index: m.index ?? 0, length: m[0].length, newTag });
} catch (e) {
console.warn("[rusty-image] asset link download failed for", rawHref, e);
}
}
if (replacements.length === 0) return html;
replacements.sort((a, b) => b.index - a.index);
let out = html;
for (const r of replacements) {
out = out.slice(0, r.index) + r.newTag + out.slice(r.index + r.length);
}
return out;
}
/**
* Geht alle Content-Rows durch: Image-Blöcke resolvedImageSrc;
* Markdown-Blöcke resolvedContent (HTML mit transformierten Bildern + Link-Wrapper).
@@ -328,11 +374,13 @@ export async function resolveContentImages(
layout.row3Content,
].filter((r): r is unknown[] => Array.isArray(r));
/** Galerie: 1280×720 (16:9), WebP, Qualität 80 kleinere Dateien. */
/**
* Galerie: max. Breite 1280, Seitenverhältnis erhalten (contain + nur w).
* Feste 16:9-Crops würden alle Bilder gleich groß machen schlecht für Masonry/Album-Layout.
*/
const galleryParams: RustyImageTransformParams = {
width: 1280,
height: 720,
fit: "cover",
fit: "contain",
format: "webp",
quality: 80,
...transformParams,
@@ -356,7 +404,7 @@ export async function resolveContentImages(
if (block._type === "markdown" && typeof block.content === "string" && block.content.trim()) {
try {
const html = marked.parse(block.content) as string;
block.resolvedContent = await processMarkdownHtmlImages(html);
block.resolvedContent = await downloadCmsAssetLinks(await processMarkdownHtmlImages(html));
} catch (e) {
console.warn("[rusty-image] markdown block resolve failed", e);
}
@@ -392,6 +440,35 @@ export async function resolveContentImages(
console.warn("[rusty-image] gallery resolve failed for", url, e);
}
}
continue;
}
if (block._type === "organisations" && Array.isArray(block.organisations)) {
const orgLogoParams: RustyImageTransformParams = {
width: 256,
fit: "contain",
format: "webp",
quality: 85,
...transformParams,
};
for (const org of block.organisations) {
if (!org || typeof org !== "object") continue;
const o = org as {
logo?: unknown;
resolvedLogoSrc?: string;
};
const url = absoluteUrlFromCmsImageField(o.logo);
if (!url) continue;
try {
o.resolvedLogoSrc = await ensureTransformedImage(url, orgLogoParams);
} catch (e) {
console.warn(
"[rusty-image] organisation logo resolve failed for",
url,
e,
);
}
}
continue;
}
}
}
@@ -1,10 +1,17 @@
---
/**
* Alle CMS-Pages: URL-Pfad = slug aus dem CMS (ein oder mehrere Segmente, z. B. about, bi/vachdorf).
* Keine fest verdrahteten Fallbacks — getStaticPaths kommt nur aus getPages().
*/
import Layout from "../layouts/Layout.astro";
import ContentRows from "../components/ContentRows.svelte";
import { getPageSlugs, getPageBySlug } from "../lib/cms";
import type { PageEntry } from "../lib/cms";
import { getPages, getPageBySlug } from "../lib/cms";
import { resolveContentImages } from "../lib/rusty-image";
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks } from "../lib/blog-utils";
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from "../lib/blog-utils";
import { PAGE_RESOLVE } from "../lib/constants";
import { T } from "../lib/translations";
@@ -12,24 +19,26 @@ export const prerender = true;
export async function getStaticPaths() {
try {
const pageSlugs = await getPageSlugs();
const pages = await getPages({ locale: "de" });
const seen = new Set<string>();
const paths: { params: { slug: string } }[] = [];
for (const s of pageSlugs ?? []) {
const slug = (s ?? "").trim();
if (!slug || seen.has(slug)) continue;
seen.add(slug);
paths.push({ params: { slug } });
for (const p of pages) {
// Inline: Astro extrahiert getStaticPaths — keine lokalen Helfer aus dem Frontmatter.
const path = (p.slug ?? p._slug ?? "").replace(/^\//, "").trim();
if (!path) continue;
if (seen.has(path)) continue;
seen.add(path);
paths.push({ params: { slug: path } });
}
return paths;
} catch (e) {
console.error("getStaticPaths ([slug]):", e);
console.error("getStaticPaths ([...slug]):", e);
return [];
}
}
const { slug } = Astro.params as { slug: string };
const page = await getPageBySlug(slug, {
const slugPath = (Astro.params.slug ?? "").trim();
const page = await getPageBySlug(slugPath, {
locale: "de",
resolve: PAGE_RESOLVE,
});
@@ -46,7 +55,7 @@ const translations = Astro.locals.translations ?? {};
---
<Layout
title={page.seoTitle ?? page.headline ?? page.linkName ?? slug}
title={page.seoTitle ?? page.headline ?? page.linkName ?? slugPath}
description={page.seoDescription ?? page.subheadline}
headline={page.headline ?? page.linkName ?? page.slug ?? page._slug}
subheadline={page.subheadline}
@@ -54,7 +63,7 @@ const translations = Astro.locals.translations ?? {};
showBannerInLayout={!!page.topFullwidthBanner}
breadcrumbItems={[
{ href: "/", translationKey: T.nav_start },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slugPath },
]}
>
<article>
+6 -1
View File
@@ -345,7 +345,12 @@ main ol li {
.markdown ul,
.markdown ol {
@apply mt-0 mb-0;
@apply mt-0 mb-4;
}
.markdown li ul,
.markdown li ol {
@apply mb-0;
}
/* Markdown-Bilder: RustyImage-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */