Update project configuration and enhance translation support. Added caching mechanism for CMS responses with a new script, updated .gitignore to include cache files, and introduced translation handling in various components. Enhanced package.json with new scripts for cache warming and deployment. Added new translation-related components and improved existing ones for better localization support.

This commit is contained in:
Peter Meier
2026-03-17 22:32:31 +01:00
parent 1f5454d04e
commit daa4ea17b1
46 changed files with 5207 additions and 781 deletions
@@ -0,0 +1,23 @@
---
description: Erkenntnisse und Konventionen in Cursor-Regeln festhalten, damit der Kontext automatisch erweitert wird
globs: ".cursor/rules/**/*.mdc"
alwaysApply: true
---
# Kontext bei Erkenntnissen anpassen und erweitern
Wenn im Gespräch **wichtige Erkenntnisse** zu diesem Projekt entstehen (Architektur, Konventionen, Patterns, „so machen wir es hier“), diese **sofort in den Cursor-Kontext übernehmen**:
1. **Neue Regel anlegen oder bestehende erweitern** unter `.cursor/rules/`:
- Neue Themen → neue `.mdc`-Datei (kurzer, prägnanter Titel, z.B. `translations-i18n.mdc`).
- Gehört zu einem bestehenden Thema → die passende Regel-Datei bearbeiten und den Inhalt ergänzen/aktualisieren.
2. **Format:** `.mdc` mit YAML-Frontmatter (`description`, `globs` oder `alwaysApply: true`). Inhalt knapp, unter ~50 Zeilen pro Regel, mit konkreten Beispielen wo sinnvoll.
3. **Wann eine Regel anpassen/erweitern:**
- Einverständnis oder expliziter Wunsch des Users (z.B. „füge das in den Cursor-Context hinzu“).
- Oder: Wir haben gemeinsam eine wiederkehrende Konvention oder Architektur-Entscheidung geklärt, die für künftige Arbeit relevant ist dann **proaktiv** eine Regel anlegen oder aktualisieren, ohne extra nachzufragen.
4. **Nicht** in Regeln packen: Einmalige Fixes, reine Code-Änderungen ohne Konventions-Charakter, Dinge die nur für einen einzelnen Task gelten.
**Ziel:** Der Projekt-Kontext wächst mit jeder wichtigen Erkenntnis; spätere Sessions und andere Agent-Durchläufe haben die gleichen Konventionen automatisch zur Verfügung.
+41
View File
@@ -0,0 +1,41 @@
---
description: Übersetzungssystem (i18n) Keys, CMS, Nutzung in Astro und Svelte
globs: "src/lib/translations.ts,**/Layout.astro,src/pages/**/*.astro,src/components/**/*.svelte,src/middleware.ts"
alwaysApply: false
---
# Übersetzungssystem (i18n)
Alle sichtbaren UI-Texte kommen aus dem CMS (RustyCMS) über das Translation-Bundle.
## Quelle
- **CMS:** Translation-Bundle über API (Single Source of Truth). Slug `app`, geladen in der Middleware mit `_resolve=all`.
- **Slug:** Konstante `TRANSLATION_BUNDLE_SLUG = "app"` in `src/lib/constants.ts`.
- **Middleware:** `src/middleware.ts` lädt das Bundle einmal pro Request und setzt `Astro.locals.translations`.
## Keys definieren
1. **Key in `src/lib/translations.ts`** in das Array `TRANSLATION_KEYS` eintragen (damit `T.key_name` und Typ `TranslationKey` existieren).
2. **String in RustyCMS** in `content/de/translation_bundle/app.json5` unter `strings` eintragen. Platzhalter: `{{name}}` wird durch `t(..., { name: value })` ersetzt.
## Nutzung
### In Astro (.astro)
- `translations = Astro.locals.translations ?? {}`
- `t(translations, T.xy)` oder mit Ersetzungen: `t(translations, T.xy, { count: 5 })`
- Breadcrumbs: optional `translationKey: T.nav_start` statt `label: "Start"`; Layout löst das mit `t(translations, item.translationKey)` auf.
### In Svelte
- **Innerhalb von TranslationProvider** (z. B. Header, Footer): `const t = useTranslate();` dann `t(T.xy)`.
- **client:load-Inseln (Astro Islands):** `useTranslate()` hat keinen Zugriff auf den Svelte-Context, und beim SSR gibt es kein `window` → Übersetzungen fehlen, es erscheint der Key (z. B. `blog_tag_all`).
**Lösung:** Übersetzungen immer als Prop von der Astro-Seite übergeben und die statische `t()` nutzen:
- Komponente: Prop `translations?: Translations`; keine `useTranslate()`, sondern lokale Hilfsfunktion `t(key, replacements) => t(translations ?? null, key, replacements)` mit `t` aus `../lib/translations`.
- Astro-Seite: `<BlogOverview ... translations={translations} />` mit `translations = Astro.locals.translations ?? {}`.
- Siehe `BlogOverview.svelte` als Referenz.
### Neue UI-Texte
Keine hartkodierten deutschen/englischen Sätze in Komponenten oder Seiten. Stattdessen: Key in `TRANSLATION_KEYS` + Eintrag in `app.json5`, dann überall `t(translations, T.key)` bzw. `t(T.key)` nutzen.
+2
View File
@@ -1,5 +1,7 @@
# build output
dist/
# CMS response cache (Dev-Fallback bei nicht erreichbarem CMS)
.cache/
# generated types
.astro/
+6
View File
@@ -0,0 +1,6 @@
{
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true
}
}
+3
View File
@@ -6,12 +6,14 @@
"dev": "yarn generate:api-types; astro dev",
"build": "yarn generate:api-types && astro build && pagefind --site dist && cp -r dist/pagefind public/",
"preview": "astro preview",
"cache:warm": "node scripts/warm-cache.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"astro": "astro",
"generate:api-types": "node scripts/generate-api-types.mjs",
"postinstall": "yarn generate:api-types || true",
"firebase-deploy": "yarn build && firebase deploy --only hosting",
"deploy": "yarn firebase-deploy",
"firebase-deploy:nocache": "yarn build && firebase deploy --only hosting"
},
"dependencies": {
@@ -23,6 +25,7 @@
"@iconify/svelte": "^5.2.1",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.17.1",
"json5": "^2.2.3",
"marked": "^17.0.2",
"svelte": "^5.51.2",
"tailwindcss": "^4.1.18"
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" fill="#f0f5f1" rx="6" />
<path
d="M6 8l4 16 4-10 4 10 4-16"
stroke="#2d7a45"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

+9 -8
View File
@@ -1,9 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<path
d="M6 8l4 16 4-10 4 10 4-16"
stroke="#2d7a45"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 241 B

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<path
d="M6 8l4 16 4-10 4 10 4-16"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Befüllt .cache/cms mit allen relevanten API-Responses.
* CMS muss erreichbar sein (PUBLIC_CMS_URL, Standard: http://localhost:3000).
* Nutzung: yarn cache:warm oder node scripts/warm-cache.mjs
*/
import { createHash } from "node:crypto";
import { writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const CACHE_DIR = join(root, ".cache", "cms");
const BASE =
process.env.PUBLIC_CMS_URL?.trim() || "http://localhost:3000";
function cacheKey(url) {
return createHash("sha256").update(url).digest("hex");
}
async function fetchAndCache(url) {
const key = cacheKey(url);
try {
const res = await fetch(url);
const body = await res.text();
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
const path = join(CACHE_DIR, `${key}.json`);
await writeFile(
path,
JSON.stringify({ status: res.status, body }),
"utf-8"
);
return { url, ok: res.ok, status: res.status };
} catch (err) {
return { url, ok: false, error: err.message };
}
}
/** Basis-URLs, die beim Build/Dev immer angefragt werden. */
const BASE_URLS = [
`${BASE}/api-docs/openapi.json`,
`${BASE}/api/content/page`,
`${BASE}/api/content/page_config?_per_page=50&_locale=de`,
`${BASE}/api/content/translation_bundle/app?_resolve=all&_locale=de`,
`${BASE}/api/content/navigation?_locale=de&_resolve=links&_page=1&_per_page=50`,
`${BASE}/api/content/navigation?_locale=de&_resolve=all&_page=1&_per_page=50`,
`${BASE}/api/content/post?_per_page=100`,
`${BASE}/api/content/tag`,
`${BASE}/api/content/footer/footer-main?_locale=de&_resolve=row1Content,row2Content,row3Content`,
`${BASE}/api/content/page_config/page-config-default?_locale=de&_resolve=logo`,
`${BASE}/api/content/page_config/default?_locale=de&_resolve=logo`,
];
async function main() {
console.log("Cache befüllen: .cache/cms");
console.log("CMS-Basis:", BASE);
const results = [];
for (const url of BASE_URLS) {
const r = await fetchAndCache(url);
results.push(r);
const status = r.ok ? r.status : r.error || r.status;
console.log(r.ok ? " ✓" : " ✗", url.replace(BASE, ""), status);
}
// Page-Slugs und einzelne Pages
try {
const pageRes = await fetch(`${BASE}/api/content/page?_per_page=200`);
const pageData = await pageRes.json();
const items = pageData?.items ?? [];
for (const p of items.slice(0, 50)) {
const slug = p._slug ?? p.slug ?? "";
if (!slug) continue;
const url = `${BASE}/api/content/page/${encodeURIComponent(slug)}?_locale=de&_resolve=all`;
const r = await fetchAndCache(url);
results.push(r);
if (!r.ok) console.log(" ✗", slug, r.error || r.status);
}
if (items.length > 0) {
console.log(" ✓", items.length, "Pages (erste 50 gecacht)");
}
} catch (e) {
console.log(" ✗ Pages-Liste", e.message);
}
// Post-Slugs und einzelne Posts
try {
const postRes = await fetch(
`${BASE}/api/content/post?_per_page=200&_resolve=postImage,postTag,row1Content,row2Content,row3Content`
);
const postData = await postRes.json();
const items = postData?.items ?? [];
for (const p of items.slice(0, 50)) {
const slug = p._slug ?? p.slug ?? "";
if (!slug) continue;
const url = `${BASE}/api/content/post/${encodeURIComponent(slug)}?_locale=de&_resolve=postImage,postTag,row1Content,row2Content,row3Content`;
const r = await fetchAndCache(url);
results.push(r);
if (!r.ok) console.log(" ✗", slug, r.error || r.status);
}
if (items.length > 0) {
console.log(" ✓", items.length, "Posts (erste 50 gecacht)");
}
} catch (e) {
console.log(" ✗ Posts-Liste", e.message);
}
const failed = results.filter((r) => !r.ok);
if (failed.length > 0) {
console.log("\nFehlgeschlagen:", failed.length);
process.exitCode = 1;
} else {
console.log("\nCache befüllt.");
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+23 -4
View File
@@ -4,6 +4,8 @@
import Pagination from "./Pagination.svelte";
import type { PostEntry } from "../lib/cms";
import { postHref } from "../lib/blog-utils";
import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations";
interface TagEntry {
_slug?: string;
@@ -18,6 +20,7 @@
totalPages = 1,
totalPosts = 0,
basePath = "/posts",
translations = {},
}: {
posts: PostEntry[];
tags: TagEntry[];
@@ -26,8 +29,14 @@
totalPages: number;
totalPosts?: number;
basePath?: string;
translations?: Translations | null;
} = $props();
/** Immer Übersetzungen von der Astro-Seite nutzen (SSR + Insel), damit Keys wie blog_tag_all ankommen. */
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
function tagHref(tagSlug: string | null): string {
if (!tagSlug) return basePath;
return `${basePath}/tag/${encodeURIComponent(tagSlug)}`;
@@ -39,9 +48,9 @@
<div class="blog-overview">
{#if tags.length > 0}
<div class="mb-2">
<div class="flex flex-wrap gap-2" role="navigation" aria-label="Nach Tag filtern">
<div class="flex flex-wrap gap-2" role="navigation" aria-label={t(T.blog_filter_aria)}>
<Tag
label="Alle"
label={t(T.blog_tag_all)}
href={tagHref(null)}
variant={activeTag ? "blue" : "green"}
active={!activeTag}
@@ -87,9 +96,19 @@
<div class="bg-white rounded-md font-thin">
<div class="inline-flex text-xs p-2">
{#if totalCount > 0}
{posts.length} von {totalCount} Beiträgen, Seite {currentPage} von {totalPages}
{t(T.blog_count, {
visible: posts.length,
total: totalCount,
current: currentPage,
totalPages,
})}
{:else}
Seite {currentPage} von {totalPages}
{t(T.blog_count, {
visible: 0,
total: 0,
current: currentPage,
totalPages,
})}
{/if}
</div>
</div>
+8 -10
View File
@@ -10,8 +10,10 @@
import LinkListBlock from "./blocks/LinkListBlock.svelte";
import PostOverviewBlock from "./blocks/PostOverviewBlock.svelte";
import SearchableTextBlock from "./blocks/SearchableTextBlock.svelte";
import CalendarBlock from "./blocks/CalendarBlock.svelte";
import { isBlockLayoutBreakout, getSpaceBottomClass } from "../lib/block-layout";
import type { BlockLayout } from "../lib/block-layout";
import type { Translations } from "../lib/translations";
import type {
RowContentLayout,
ResolvedBlock,
@@ -26,13 +28,10 @@
LinkListBlockData,
PostOverviewBlockData,
SearchableTextBlockData,
CalendarBlockData,
} from "../lib/block-types";
let {
layout,
class: className = "",
searchableTextHelpTooltip,
}: { layout: RowContentLayout; class?: string; searchableTextHelpTooltip?: string } = $props();
let { layout, class: className = "", translations = {} }: { layout: RowContentLayout; class?: string; translations?: Translations | null } = $props();
function isResolvedBlock(item: unknown): item is ResolvedBlock {
return typeof item === "object" && item !== null;
@@ -100,7 +99,7 @@
{:else if blockType(item) === "html"}
<HtmlBlock block={item as HtmlBlockData} />
{:else if blockType(item) === "iframe"}
<IframeBlock block={item as IframeBlockData} />
<IframeBlock block={item as IframeBlockData} translations={translations} />
{:else if blockType(item) === "image"}
<ImageBlock block={item as ImageBlockData} />
{:else if blockType(item) === "image_gallery"}
@@ -114,10 +113,9 @@
{:else if blockType(item) === "post_overview"}
<PostOverviewBlock block={item as PostOverviewBlockData} />
{:else if blockType(item) === "searchable_text"}
<SearchableTextBlock
block={item as SearchableTextBlockData}
helpTooltip={searchableTextHelpTooltip}
/>
<SearchableTextBlock block={item as SearchableTextBlockData} translations={translations} />
{:else if blockType(item) === "calendar"}
<CalendarBlock block={item as CalendarBlockData} translations={translations} />
{: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>
@@ -6,10 +6,9 @@ import type { RowContentLayout } from "../lib/block-types";
interface Props {
layout: RowContentLayout;
class?: string;
searchableTextHelpTooltip?: string;
}
const { layout, class: className = "", searchableTextHelpTooltip } = Astro.props;
const { layout, class: className = "" } = Astro.props;
function justifyToClass(j?: string): string {
switch (j) {
@@ -90,11 +89,7 @@ function layoutBreakout(item: unknown): boolean {
{row.content.map((item) => {
const content = isResolvedBlock(item) ? (
blockType(item) === "searchable_text" ? (
<SearchableTextBlock
client:load
block={item}
helpTooltip={searchableTextHelpTooltip}
/>
<SearchableTextBlock client:load block={item} />
) : blockType(item) === "iframe" ||
blockType(item) === "image_gallery" ? (
<BlockRenderer client:load block={item} />
+12 -4
View File
@@ -1,12 +1,18 @@
<script lang="ts">
import ContentRows from "./ContentRows.svelte";
import type { RowContentLayout } from "../lib/block-types";
import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations";
interface FooterEntry extends RowContentLayout {
id?: string;
}
let { footer = null }: { footer?: FooterEntry | null } = $props();
let { footer = null, translations = {} }: { footer?: FooterEntry | null; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
const justifyClass = $derived(
footer?.row1JustifyContent === "end"
@@ -37,12 +43,14 @@
>
<div class="container-custom py-6">
{#if hasRowContent}
<div class="content-footer">
<ContentRows layout={footer} />
<div class="content-footer text-xs">
<ContentRows layout={footer} translations={translations} />
</div>
{:else}
<p class="text-sm text-white/80 {justifyClass} flex">
© {new Date().getFullYear()} RustyAstro
{t(T.footer_copyright) !== T.footer_copyright
? t(T.footer_copyright)
: `© ${new Date().getFullYear()} RustyAstro`}
</p>
{/if}
</div>
+68 -37
View File
@@ -4,6 +4,8 @@
import Icon from "@iconify/svelte";
import Search from "./Search.svelte";
import { overlayOpen, overlayClose } from "../lib/overlay-store";
import { t as tStatic, T } from "../lib/translations";
import type { Translations } from "../lib/translations";
interface NavLink {
href: string;
@@ -16,11 +18,21 @@
label?: string;
}
let { links = [], socialLinks = [], logoUrl = null }: {
let {
links = [],
socialLinks = [],
logoUrl = null,
translations = {},
}: {
links?: NavLink[];
socialLinks?: SocialLink[];
logoUrl?: string | null;
translations?: Translations | null;
} = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
let menuOpen = $state(false);
let searchOpen = $state(false);
@@ -77,61 +89,59 @@
<header
id="horizontal-navigation"
class="sticky top-0 z-20 backdrop-blur bg-zinc-950/90 shadow-2xl"
class="sticky top-0 z-20 backdrop-blur bg-stein-900/95 shadow-2xl"
>
<div class="flex items-center justify-between container-custom py-2">
<a
href="/"
class="logo flex items-center text-white font-bold text-lg tracking-wide hover:text-green-100 transition-colors"
class="logo flex items-center text-stein-0 font-bold text-lg tracking-wide hover:text-wald-100 transition-colors relative"
>
{#if logoUrl && logoUrl.trim() !== ""}
<img
src={logoUrl}
alt=""
class="h-12 w-auto object-contain"
/>
<img src={logoUrl} alt="" class="h-12 w-auto object-contain" />
{:else}
<span>RustyAstro</span>
<span>{t(T.site_name_fallback)}</span>
{/if}
</a>
<!-- Desktop: horizontale Nav (ab lg) -->
<nav
class="hidden lg:flex items-center gap-4"
aria-label="Hauptnavigation"
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
<a
href={link.href}
class="text-white/90 hover:text-green-100 hover:animate-pulse transition-all text-xs tracking-wide"
class="text-stein-100 hover:text-wald-100 hover:animate-pulse transition-all text-xs tracking-wide"
>
{link.label}
</a>
{/each}
<div><span class="text-white/90">|</span></div>
<div><span class="text-stein-100">|</span></div>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="p-1.5 -m-1.5 text-white/90 hover:text-green-100 hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
aria-label={social.label || "Social Media"}
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"
aria-label={social.label || t(T.header_social_aria)}
>
<Icon icon={social.icon} aria-hidden="true" />
</a>
{/each}
<button
type="button"
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
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"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon icon="mdi:close" class="text-red-400!" aria-hidden="true" />
<Icon icon="mdi:close" class="text-error!" aria-hidden="true" />
{:else}
<Icon icon="mdi:magnify" class="text-emerald-400!" aria-hidden="true" />
<Icon icon="mdi:magnify" class="text-wald-400!" aria-hidden="true" />
{/if}
</button>
</nav>
@@ -140,31 +150,43 @@
<div class="lg:hidden flex items-center gap-1">
<button
type="button"
class="p-2 hover:text-white hover:bg-white/10 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen ? "Suche schließen" : "Suche öffnen"}
class="p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors flex items-center justify-center"
aria-label={searchOpen
? t(T.header_search_close)
: t(T.header_search_open)}
aria-expanded={searchOpen}
aria-controls="search-panel"
onclick={() => (searchOpen = !searchOpen)}
>
{#if searchOpen}
<Icon icon="mdi:close" class="text-2xl text-red-400!" aria-hidden="true" />
<Icon
icon="mdi:close"
class="text-2xl text-error!"
aria-hidden="true"
/>
{:else}
<Icon icon="mdi:magnify" class="text-2xl text-emerald-400!" aria-hidden="true" />
<Icon
icon="mdi:magnify"
class="text-2xl text-wald-400!"
aria-hidden="true"
/>
{/if}
</button>
<button
type="button"
class="p-2 -mr-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors aria-expanded={menuOpen} flex items-center justify-center"
aria-label={menuOpen ? "Menü schließen" : "Menü öffnen"}
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"
aria-label={menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}
aria-controls="mobile-nav"
onclick={toggleMenu}
>
<span class="sr-only">{menuOpen ? "Menü schließen" : "Menü öffnen"}</span>
{#if menuOpen}
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
{:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
{/if}
<span class="sr-only"
>{menuOpen ? t(T.header_menu_close) : t(T.header_menu_open)}</span
>
{#if menuOpen}
<Icon icon="mdi:close" class="text-2xl" aria-hidden="true" />
{:else}
<Icon icon="mdi:menu" class="text-2xl" aria-hidden="true" />
{/if}
</button>
</div>
</div>
@@ -175,34 +197,43 @@
{#if menuOpen}
<div
id="mobile-nav"
class="lg:hidden border-t border-white/10 bg-zinc-950/95 backdrop-blur overflow-hidden"
class="lg:hidden border-t border-stein-0/10 bg-stein-900/98 backdrop-blur overflow-hidden"
role="dialog"
aria-label="Navigation"
in:slide={{ duration: 200 }}
out:slide={{ duration: 150 }}
>
<nav class="container-custom py-4 flex flex-col gap-1" aria-label="Hauptnavigation">
<nav
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-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors text-sm"
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}
{#if socialLinks.length > 0}
<div class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-white/10">
<div
class="flex flex-wrap gap-2 pt-2 mt-2 border-t border-stein-0/10"
>
{#each socialLinks as social}
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="inline-flex p-2 text-white/90 hover:text-white hover:bg-white/10 rounded-md transition-colors"
aria-label={social.label || "Social Media"}
class="inline-flex p-2 text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 rounded-md transition-colors"
aria-label={social.label || t(T.header_social_aria)}
onclick={closeMenu}
>
<Icon icon={social.icon} class="text-[1.5rem]" aria-hidden="true" />
<Icon
icon={social.icon}
class="text-[1.5rem]"
aria-hidden="true"
/>
</a>
{/each}
</div>
+5 -1
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import { get } from "svelte/store";
import { overlayOpen, overlayClose } from "../lib/overlay-store";
import { useTranslate } from "../lib/translations";
import { T } from "../lib/translations";
const t = useTranslate();
function handleClick() {
get(overlayClose)?.();
@@ -11,7 +15,7 @@
<button
type="button"
class="fixed inset-0 z-[15] bg-black/50 cursor-default"
aria-label="Menü bzw. Suche schließen"
aria-label={t(T.header_overlay_close)}
onclick={handleClick}
></button>
{/if}
+6 -2
View File
@@ -1,5 +1,9 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { useTranslate } from "../lib/translations";
import { T } from "../lib/translations";
const t = useTranslate();
let {
currentPage = 1,
@@ -48,7 +52,7 @@
<div class="inline-flex py-4">
<div class="flex">
{#if hasPrev}
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label="Vorherige Seite">
<a href={pageHref(prevPage)} class={paginationLinkClass} aria-label={t(T.pagination_prev)}>
<Icon icon="mdi:chevron-left" class="text-xs" aria-hidden="true" />
</a>
{/if}
@@ -62,7 +66,7 @@
</a>
{/each}
{#if hasNext}
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label="Nächste Seite">
<a href={pageHref(nextPage)} class={paginationLinkClass} aria-label={t(T.pagination_next)}>
<Icon icon="mdi:chevron-right" class="text-xs" aria-hidden="true" />
</a>
{/if}
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { setContext } from "svelte";
import type { Snippet } from "svelte";
import type { Translations, TranslationReplacements } from "../lib/translations";
import { T_CONTEXT_KEY, replacePlaceholders } from "../lib/translations";
let {
translations = {},
children,
}: {
translations?: Translations;
children?: Snippet;
} = $props();
const t = (key: string, replacements?: TranslationReplacements): string => {
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
};
setContext(T_CONTEXT_KEY, t);
</script>
{#if children}
{@render children()}
{/if}
+349
View File
@@ -0,0 +1,349 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type {
CalendarBlockData,
CalendarItemData,
} from "../../lib/block-types";
import { t as tStatic, T } from "../../lib/translations";
import type { Translations } from "../../lib/translations";
import "../../lib/iconify-offline";
import Icon from "@iconify/svelte";
type EventCardItem = CalendarItemData & {
date: Date | null;
timeStr: string;
};
let { block, translations = {} }: { block: CalendarBlockData; translations?: Translations | null } = $props();
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const items = $derived.by(() => {
const raw = block.items ?? [];
return raw
.filter(
(x): x is CalendarItemData =>
typeof x === "object" &&
x !== null &&
"terminZeit" in x &&
"title" in x,
)
.map((x) => ({
...x,
date: parseDate(x.terminZeit),
timeStr: formatTime(x.terminZeit),
}))
.filter((x) => x.date)
.sort((a, b) => (a.date!.getTime() ?? 0) - (b.date!.getTime() ?? 0));
});
function parseDate(iso: string): Date | null {
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
/** Link aus calendar_item: API kann string (URL) oder aufgelöstes Objekt (_slug oder url) liefern. */
function getEventLinkHref(link: unknown): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object") {
const o = link as { url?: string; _slug?: string };
if (typeof o.url === "string" && o.url) return o.url;
if (typeof o._slug === "string" && o._slug)
return `/post/${encodeURIComponent(o._slug)}`;
}
return "";
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const h = d.getHours();
const m = d.getMinutes();
if (h === 0 && m === 0) return "";
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")} Uhr`;
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const eventsByDay = $derived.by(() => {
const map = new Map<string, typeof items>();
for (const item of items) {
if (!item.date) continue;
const key = dayKey(item.date);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return map;
});
let currentMonth = $state(new Date());
let selectedDay = $state<string | null>(null);
const year = $derived(currentMonth.getFullYear());
const month = $derived(currentMonth.getMonth());
const monthLabel = $derived(
currentMonth.toLocaleDateString("de-DE", {
month: "long",
year: "numeric",
}),
);
const daysInMonth = $derived.by(() => {
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const count = last.getDate();
const startWeekday = (first.getDay() + 6) % 7;
return { count, startWeekday, last };
});
const calendarDays = $derived.by(() => {
const { count, startWeekday } = daysInMonth;
const empty = Array(startWeekday).fill(null);
const days = Array.from(
{ length: count },
(_, i) => new Date(year, month, i + 1),
);
return [...empty, ...days];
});
const selectedDayEvents = $derived(
selectedDay ? (eventsByDay.get(selectedDay) ?? []) : [],
);
/** Nächste 3 Termine ab heute zur Laufzeit gefiltert (statische App). */
const nextUpcomingEvents = $derived.by(() => {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
return items
.filter((item) => item.date && item.date.getTime() >= todayMs)
.slice(0, 3);
});
function prevMonth() {
currentMonth = new Date(year, month - 1);
selectedDay = null;
}
function nextMonth() {
currentMonth = new Date(year, month + 1);
selectedDay = null;
}
function selectDay(key: string) {
selectedDay = selectedDay === key ? null : key;
}
const todayKey = $derived(dayKey(new Date()));
const weekdays = $derived([
t(T.calendar_weekday_mo),
t(T.calendar_weekday_di),
t(T.calendar_weekday_mi),
t(T.calendar_weekday_do),
t(T.calendar_weekday_fr),
t(T.calendar_weekday_sa),
t(T.calendar_weekday_so),
]);
</script>
<div
class="{layoutClasses} w-full min-w-0 calendar-block border border-stein-200 overflow-hidden"
data-block-type="calendar"
data-block-slug={block._slug}
>
{#snippet eventCard(item: EventCardItem)}
{@const eventHref = getEventLinkHref(item.link)}
<li class="bg-himmel-100 p-2">
<div class="flex gap-1">
{#if item.date}
<div class="text-xs text-stein-500 mb-0.5">
{item.date.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
})}
</div>
{#if item.timeStr}
<span class="text-xs text-stein-600">/</span>
{/if}
{/if}
{#if item.timeStr}
<div class="text-xs text-stein-600">
{item.timeStr}
{t(T.calendar_time_suffix)}
</div>
{/if}
</div>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="text-base font-medium text-stein-900 text-himmel-600 hover:text-himmel-700 hover:underline"
>
{item.title}
</a>
{:else}
<div class="text-base font-medium text-stein-900">{item.title}</div>
{/if}
{#if item.description}
<p class="text-sm font-light text-stein-600 mb-0! line-clamp-3">
{item.description}
</p>
{#if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
{:else if eventHref}
<a
href={eventHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 mt-2 text-sm text-himmel-600 hover:text-himmel-700"
>
{t(T.calendar_more_info)}
<Icon
icon="mdi:open-in-new"
class="size-4"
aria-hidden="true"
/>
</a>
{/if}
</li>
{/snippet}
{#if block.title}
<h3 class="text-lg font-semibold text-stein-900 px-4 pt-4 pb-2">
{block.title}
</h3>
{/if}
<div class="calendar-grid-wrapper grid w-full grid-cols-1 gap-0">
<!-- Links: Kalender (max 600px) -->
<div class="calendar-column min-h-full p-4 flex flex-col bg-himmel-800 text-himmel-100 shadow-sm">
<div class="flex items-center justify-between gap-2 mb-4">
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_prev_month)}
onclick={prevMonth}
>
<Icon icon="mdi:chevron-left" class="size-5" aria-hidden="true" />
</button>
<span
class="text-sm font-medium text-himmel-100 capitalize whitespace-nowrap"
>{monthLabel}</span
>
<button
type="button"
class="p-2 rounded-md text-himmel-100 hover:bg-himmel-700 hover:text-himmel-50 transition-colors"
aria-label={t(T.calendar_next_month)}
onclick={nextMonth}
>
<Icon icon="mdi:chevron-right" class="size-5" aria-hidden="true" />
</button>
</div>
<div class="grid grid-cols-7 gap-0.5 text-center text-sm">
{#each weekdays as w}
<div class="py-1.5 text-xs font-medium text-himmel-100 opacity-80">{w}</div>
{/each}
{#each calendarDays as d}
{#if d === null}
<div class="aspect-square" aria-hidden="true"></div>
{:else}
{@const key = dayKey(d)}
{@const isCurrentMonth = d.getMonth() === month}
{@const events = eventsByDay.get(key) ?? []}
{@const hasEvents = events.length > 0}
{@const isSelected = selectedDay === key}
{@const isFuture = key >= todayKey}
{#if hasEvents}
<button
type="button"
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 transition-colors cursor-pointer {isCurrentMonth
? 'text-himmel-100 hover:bg-himmel-700'
: 'text-himmel-100 opacity-60 hover:opacity-80'} bg-himmel-700/50 font-semibold hover:bg-himmel-600 {isSelected
? 'ring-2 ring-himmel-400 bg-himmel-600'
: ''}"
onclick={() => selectDay(key)}
>
<span class="text-xs">{d.getDate()}</span>
<span
class="absolute -bottom-1 -right-1 inline-flex items-center justify-center min-w-3.5 h-3.5 px-0.5 rounded-full text-stein-0 text-[9px] font-medium leading-none {isFuture ? 'bg-wald-400' : 'bg-error'}"
aria-hidden="true"
>
{events.length}
</span>
</button>
{:else}
<div
class="relative aspect-square rounded-md flex flex-col items-center justify-center min-w-0 cursor-default {isCurrentMonth
? 'text-himmel-100'
: 'text-himmel-100 opacity-50'}"
>
<span class="text-xs">{d.getDate()}</span>
</div>
{/if}
{/if}
{/each}
</div>
</div>
<!-- Rechts: Wrapper relativ → Eventblock absolut (nur bei 2 Spalten, siehe global.css), Höhe = Kalender -->
<div class="calendar-events-wrapper min-h-0">
<div
class="calendar-events-panel border-t border-stein-200 p-4 bg-stein-0/50 flex flex-col overflow-hidden"
>
{#if items.length === 0}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_no_events)}</p>
{:else if selectedDay}
<p class="text-xs font-medium text-stein-500 mb-2">
{new Date(selectedDay + "T12:00:00").toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
})}
</p>
<ul class="m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2">
{#each selectedDayEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else if nextUpcomingEvents.length > 0}
<p class="text-xs uppercase font-medium text-stein-500">
{t(T.calendar_next_events)}
</p>
<ul
class="space-y-3 m-0! p-0! flex-1 min-h-0 overflow-auto flex flex-col gap-2"
>
{#each nextUpcomingEvents as item}
{@render eventCard(item)}
{/each}
</ul>
{:else}
<p class="text-sm text-stein-500 mt-2">{t(T.calendar_select_day)}</p>
{/if}
</div>
</div>
</div>
</div>
+9 -3
View File
@@ -2,8 +2,14 @@
import Icon from "@iconify/svelte";
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { IframeBlockData } from "../../lib/block-types";
import { t as tStatic, T } from "../../lib/translations";
import type { Translations } from "../../lib/translations";
let { block }: { block: IframeBlockData } = $props();
let { block, translations = {} }: { block: IframeBlockData; translations?: Translations | null } = $props();
function t(key: string) {
return tStatic(translations ?? null, key);
}
/** Overlay blockiert Scroll/Klick über Map; nach Klick entfernen (client:load nötig). */
let overlayActive = $state(true);
@@ -40,7 +46,7 @@
role="button"
tabindex="0"
class="absolute inset-0 z-10 cursor-pointer rounded-sm bg-black/50 flex items-center justify-center"
aria-label="Klicken zum Aktivieren der Karte"
aria-label={t(T.iframe_activate_aria)}
onclick={activateMap}
onkeydown={(e) => e.key === "Enter" && activateMap()}
>
@@ -55,6 +61,6 @@
{/if}
</div>
{:else}
<p class="text-sm text-zinc-500">Iframe-URL fehlt oder ist ungültig.</p>
<p class="text-sm text-zinc-500">{t(T.iframe_missing)}</p>
{/if}
</div>
+25 -12
View File
@@ -4,20 +4,33 @@
let { block }: { block: ImageBlockData } = $props();
function getUrl(b: ImageBlockData): string | null {
if (typeof b.resolvedImageSrc === "string" && b.resolvedImageSrc) return b.resolvedImageSrc;
const img = b.img ?? b.image;
if (typeof img === "string" && img.startsWith("http")) return img;
if (img && typeof img === "object") {
const src = (img as { src?: string }).src ?? (img as { file?: { url?: string } }).file?.url;
if (typeof src === "string" && src) return src.startsWith("//") ? `https:${src}` : src;
}
return null;
}
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const imageSource = $derived(block.image ?? block.img);
const imageUrl = $derived(
block.resolvedImageSrc ??
(typeof imageSource === "string"
? imageSource.startsWith("http")
? imageSource
: null
: imageSource && typeof imageSource === "object"
? "src" in imageSource && typeof imageSource.src === "string"
? imageSource.src
: imageSource.file?.url ?? null
: null),
);
const imageUrl = $derived(getUrl(block));
$effect(() => {
if (!imageUrl && block._slug) {
console.warn("[ImageBlock] Bild nicht verfügbar:", block._slug, {
resolvedImageSrc: block.resolvedImageSrc,
hasImage: !!block.image,
hasImg: !!block.img,
imageType: typeof block.image,
imgType: typeof block.img,
imgSrc: block.img && typeof block.img === "object" ? (block.img as { src?: string }).src : undefined,
imageSrc: block.image && typeof block.image === "object" ? (block.image as { src?: string }).src : undefined,
});
}
});
const title = $derived(
typeof imageSource === "object" && imageSource
? ("description" in imageSource && imageSource.description
@@ -5,7 +5,10 @@
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";
const t = useTranslate();
let { block }: { block: ImageGalleryBlockData } = $props();
marked.setOptions({ gfm: true });
@@ -105,7 +108,7 @@
{/if}
{#if withUrl.length === 0}
<p class="text-sm text-stein-500">Keine Bilder in der Galerie.</p>
<p class="text-sm text-stein-500">{t(T.image_gallery_empty)}</p>
{:else if withUrl.length === 1}
<figure class="m-0">
<img
@@ -123,7 +126,7 @@
<div
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
role="region"
aria-label="Galerie: wischen zum Wechseln"
aria-label={t(T.image_gallery_swipe_aria)}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
@@ -159,7 +162,7 @@
<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="Vorheriges Bild"
aria-label={t(T.image_gallery_prev_aria)}
onclick={prev}
>
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
@@ -167,18 +170,18 @@
<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="Nächstes Bild"
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="Galerieposition">
<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="Bild {i + 1}"
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"
+3 -2
View File
@@ -7,9 +7,10 @@
marked.setOptions({ gfm: true });
const html = $derived(
block.content && typeof block.content === "string"
block.resolvedContent ??
(block.content && typeof block.content === "string"
? (marked.parse(block.content) as string)
: "",
: ""),
);
const alignClass = $derived(
@@ -10,21 +10,23 @@
import Tag from "../Tag.svelte";
import Tags from "../Tags.svelte";
import Tooltip from "../../lib/ui/Tooltip.svelte";
const DEFAULT_HELP_TOOLTIP =
"Hier könnt ihr in Titeln und Inhalten nach Stichwörtern suchen. Über die Schlagwörter unter dem Suchfeld lassen sich die Einträge eingrenzen. Öffnet einen Eintrag per Klick auf die Zeile; mit „Inhalt kopieren“ könnt ihr den Text in die Zwischenablage übernehmen (z.B. für Stellungnahmen).";
import { t as tStatic, T } from "../../lib/translations";
import type { Translations } from "../../lib/translations";
let {
block,
class: className = "",
helpTooltip,
translations = {},
}: {
block: SearchableTextBlockData;
class?: string;
helpTooltip?: string;
translations?: Translations | null;
} = $props();
const helpTooltipText = $derived(helpTooltip ?? DEFAULT_HELP_TOOLTIP);
function t(key: string, replacements?: Record<string, string | number>) {
return tStatic(translations ?? null, key, replacements);
}
const helpTooltipText = $derived(t(T.searchable_text_help));
marked.setOptions({ gfm: true });
@@ -33,11 +35,16 @@
const fragments = $derived.by(() => {
const raw = block.textFragments ?? [];
return raw
.filter((f): f is SearchableTextFragment => typeof f === "object" && f !== null && ("title" in f || "text" in f))
.filter(
(f): f is SearchableTextFragment =>
typeof f === "object" && f !== null && ("title" in f || "text" in f),
)
.map((f) => {
const tagsRaw = f.tags ?? [];
const tags = tagsRaw
.map((t) => (typeof t === "string" ? t : (t as { name?: string })?.name ?? ""))
.map((t) =>
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
)
.filter(Boolean);
return {
title: (f.title ?? "").trim(),
@@ -51,14 +58,19 @@
});
const allTags = $derived(
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) => a.localeCompare(b)),
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) =>
a.localeCompare(b),
),
);
let searchQuery = $state("");
let selectedTag = $state("all");
let copiedIndex = $state<number | null>(null);
async function copyFragmentContent(fragment: { title: string; text: string }, index: number) {
async function copyFragmentContent(
fragment: { title: string; text: string },
index: number,
) {
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
try {
await navigator.clipboard.writeText(plain);
@@ -110,7 +122,10 @@
if (!term.trim()) return escapeHtml(text);
const escaped = escapeRegex(term);
const re = new RegExp(escaped, "gi");
return escapeHtml(text).replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
return escapeHtml(text).replace(
re,
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
);
}
/** Suchbegriff in HTML-Content hervorheben (nur in Textknoten, nicht in Tags). */
@@ -122,7 +137,10 @@
return parts
.map((part) => {
if (part.startsWith("<")) return part;
return part.replace(re, (match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`);
return part.replace(
re,
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
);
})
.join("");
}
@@ -153,50 +171,64 @@
>
<div class="p-4 md:p-6">
{#if block.title}
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">{block.title}</h2>
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">
{block.title}
</h2>
{/if}
{#if descriptionHtml}
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">{@html descriptionHtml}</div>
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
{@html descriptionHtml}
</div>
{/if}
</div>
<header class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none">
<header
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
>
<div class="flex flex-col gap-2">
<div class="relative flex items-center gap-2">
<Tooltip content={helpTooltipText} placement="top">
<button
type="button"
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
aria-label="Hilfe zur Suche anzeigen"
aria-label={t(T.searchable_text_help_aria)}
>
<Icon icon="mdi:help-circle-outline" class="size-5" aria-hidden="true" />
<Icon
icon="mdi:help-circle-outline"
class="size-5"
aria-hidden="true"
/>
</button>
</Tooltip>
<input
type="search"
placeholder="In Titeln und Inhalten suchen…"
placeholder={t(T.searchable_text_placeholder)}
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
bind:value={searchQuery}
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
aria-label="Suche in Titeln und Inhalten"
aria-label={t(T.searchable_text_search_aria)}
/>
{#if searchQuery}
<button
type="button"
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Suche leeren"
aria-label={t(T.searchable_text_clear_search)}
>
</button>
{/if}
<Icon icon="mdi:magnify" class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5" aria-hidden="true" />
<Icon
icon="mdi:magnify"
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5"
aria-hidden="true"
/>
</div>
{#if allTags.length > 0}
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
<div class="flex gap-2 flex-nowrap min-w-max md:flex-wrap md:w-auto">
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
<Tag
label="Alle"
label={t(T.searchable_text_tag_all)}
variant={selectedTag === "all" ? "green" : "white"}
active={selectedTag === "all"}
onclick={() => (selectedTag = "all")}
@@ -215,14 +247,20 @@
</div>
</header>
<div class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200" aria-live="polite">
<div
class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
aria-live="polite"
>
{#if visibleFragments.length === 0}
<span>Keine Treffer. Versuchen Sie einen anderen Suchbegriff oder Schlagwort.</span>
<span>{t(T.searchable_text_no_results)}</span>
{:else}
<span>
{visibleFragments.length === fragments.length
? `${visibleFragments.length} Einträge`
: `${visibleFragments.length} von ${fragments.length} Einträgen`}
? t(T.searchable_text_count_all, { count: visibleFragments.length })
: t(T.searchable_text_count_filtered, {
visible: visibleFragments.length,
total: fragments.length,
})}
</span>
{/if}
</div>
@@ -230,10 +268,21 @@
<div class="flex flex-col gap-0 bg-stein-100">
{#each visibleFragments as fragment, i}
<details class="group border-b border-stein-200 last:border-b-0">
<summary class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors">
<summary
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
>
<div class="flex items-start gap-2 min-w-0">
<div class="grow min-w-0">{@html highlightInText(fragment.title || "Ohne Titel", searchQuery.trim())}</div>
<Icon icon="mdi:chevron-down" class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform" aria-hidden="true" />
<div class="grow min-w-0">
{@html highlightInText(
fragment.title || t(T.searchable_text_untitled),
searchQuery.trim(),
)}
</div>
<Icon
icon="mdi:chevron-down"
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
aria-hidden="true"
/>
</div>
{#if fragment.tags.length > 0}
<span class="flex flex-wrap gap-1">
@@ -241,20 +290,33 @@
</span>
{/if}
</summary>
<div class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs">
<div
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
>
<div class="flex justify-end mb-3 -mt-1">
<button
type="button"
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
onclick={(e) => { e.preventDefault(); copyFragmentContent(fragment, i); }}
aria-label="Inhalt in Zwischenablage kopieren"
onclick={(e) => {
e.preventDefault();
copyFragmentContent(fragment, i);
}}
aria-label={t(T.searchable_text_copy_aria)}
>
{#if copiedIndex === i}
<Icon icon="mdi:check" class="size-4 text-wald-600" aria-hidden="true" />
<span>Kopiert!</span>
<Icon
icon="mdi:check"
class="size-4 text-wald-600"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copied)}</span>
{:else}
<Icon icon="mdi:content-copy" class="size-4" aria-hidden="true" />
<span>Inhalt kopieren</span>
<Icon
icon="mdi:content-copy"
class="size-4"
aria-hidden="true"
/>
<span>{t(T.searchable_text_copy_button)}</span>
{/if}
</button>
</div>
@@ -1,7 +1,10 @@
<script lang="ts">
import { getBlockLayoutClasses } from "../../lib/block-layout";
import type { YoutubeVideoBlockData } from "../../lib/block-types";
import { useTranslate } from "../../lib/translations";
import { T } from "../../lib/translations";
const t = useTranslate();
let { block }: { block: YoutubeVideoBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
@@ -16,7 +19,7 @@
{#if block.youtubeId}
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
<iframe
title={block.title ?? "YouTube-Video"}
title={block.title ?? t(T.youtube_title_fallback)}
src={embedUrl}
class="h-full w-full border-0"
loading="lazy"
@@ -28,6 +31,6 @@
<div class="mt-1 text-sm text-stein-600">{block.description}</div>
{/if}
{:else}
<div class="text-sm text-stein-500">YouTube-Video-ID fehlt.</div>
<div class="text-sm text-stein-500">{t(T.youtube_missing)}</div>
{/if}
</div>
+6
View File
@@ -9,3 +9,9 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare namespace App {
interface Locals {
translations?: Record<string, string>;
}
}
+92 -42
View File
@@ -23,6 +23,10 @@ import {
SITE_NAME,
SOCIAL_IMAGE_TRANSFORM,
} from "../lib/constants";
import TranslationProvider from "../components/TranslationProvider.svelte";
import { t, T } from "../lib/translations";
import { getCmsFromCache } from "../lib/cms-cache-state";
import type { TranslationKey } from "../lib/translations";
import { marked } from "marked";
marked.setOptions({ gfm: true });
@@ -47,8 +51,8 @@ interface Props {
showBannerInLayout?: boolean;
/** Optional: absoluter oder relativer URL für og:image / twitter:image (z. B. Post-Bild). */
image?: string | null;
/** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. */
breadcrumbItems?: { href?: string; label: string }[];
/** Optional: Breadcrumb-Items. Letzter Eintrag ohne href = aktuelle Seite. translationKey überschreibt label (übersetzt). */
breadcrumbItems?: { href?: string; label?: string; translationKey?: TranslationKey }[];
}
const {
@@ -62,6 +66,21 @@ const {
breadcrumbItems = undefined,
} = Astro.props;
// Übersetzungen aus Middleware (Astro.locals.translations) für TranslationProvider und Breadcrumbs
const translations: Record<string, string> = Astro.locals.translations ?? {};
/** JSON für script type="application/json": </ escapen, damit das Tag nicht geschlossen wird. */
function safeJsonScriptContent(obj: Record<string, string>): string {
return JSON.stringify(obj).replace(/<\//g, "\\u003c/");
}
const resolvedBreadcrumbItems =
breadcrumbItems?.map((item) => ({
href: item.href,
label:
item.translationKey != null
? t(translations, item.translationKey)
: (item.label ?? ""),
})) ?? [];
// Header: Navigation laden und Links bauen
interface NavLink {
href: string;
@@ -251,7 +270,8 @@ const baseUrl = isLocalhost
const pathname = Astro.url.pathname || "/";
const canonical = `${baseUrl}${pathname}`;
// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen)
// Social-Image immer über RustyImage (externe URLs transformieren; lokale Pfade unverändert nutzen).
// Fallback: Wenn kein Bild/Logo und Transform fehlschlägt → DEFAULT_SOCIAL_IMAGE_URL (absolut), damit WhatsApp/og immer ein Vorschaubild hat.
const socialImageSource = imageProp ?? logoUrl ?? null;
const needsTransform =
!socialImageSource ||
@@ -275,7 +295,9 @@ if (needsTransform) {
// Bereits lokaler Pfad (z. B. von Post), nur baseUrl davor
socialImageResolved = `${baseUrl}${urlToTransform.startsWith("/") ? "" : "/"}${urlToTransform}`;
}
const socialImage = socialImageResolved ?? "";
const socialImage =
socialImageResolved ??
(urlToTransform.startsWith("http") ? urlToTransform : "");
const siteName =
(import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
@@ -342,15 +364,19 @@ const titleSubheadlineHtml =
showTitleInContent && subheadline?.trim()
? (marked.parseInline(subheadline) as string)
: "";
const cmsFromCache = getCmsFromCache();
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width" />
<meta name="theme-color" content="#2d7a45" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<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} />}
@@ -411,47 +437,71 @@ const titleSubheadlineHtml =
/>
)
}
<script
src="https://analytics.pm86.de/api/script.js"
data-site-id="ad2858cf2db1"
defer
/>
</head>
<body class="flex min-h-screen flex-col text-zinc-900">
<Header
links={headerLinks}
socialLinks={socialLinks}
logoUrl={logoUrl}
client:load
/>
<HeaderOverlay client:load />
{
showBannerInLayout &&
(topBanner != null || topBannerResolvedImages.length > 0) && (
<TopBanner
banner={topBanner}
resolvedImages={topBannerResolvedImages}
headline={headline}
subheadline={subheadline}
/>
)
import.meta.env.DEV && cmsFromCache && (
<div
class="sticky top-0 z-50 bg-amber-500 text-amber-950 text-center py-1.5 px-2 text-sm font-medium"
role="status"
aria-live="polite"
>
CMS nicht erreichbar Daten werden aus dem Cache (.cache/cms) geladen.
</div>
)
}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{breadcrumbItems && breadcrumbItems.length > 0 && (
<Breadcrumbs items={breadcrumbItems} client:load />
)}
{
showTitleInContent && (titleHeadlineHtml || titleSubheadlineHtml) && (
<div class="mb-6 pt-10 pageTitle">
{titleHeadlineHtml && (
<h1 class="pageTitle" set:html={titleHeadlineHtml} />
)}
{titleSubheadlineHtml && (
<h2 class="pageTitle" set:html={titleSubheadlineHtml} />
)}
</div>
<script id="astro-translations" type="application/json" set:html={safeJsonScriptContent(translations)}></script>
<script define:vars={{ translations }}>
try { window.__TRANSLATIONS__ = typeof translations !== "undefined" && translations != null ? translations : {}; } catch (e) {}
</script>
<TranslationProvider translations={translations}>
<Header
links={headerLinks}
socialLinks={socialLinks}
logoUrl={logoUrl}
translations={translations}
client:load
/>
<HeaderOverlay client:load />
{
showBannerInLayout &&
(topBanner != null || topBannerResolvedImages.length > 0) && (
<TopBanner
banner={topBanner}
resolvedImages={topBannerResolvedImages}
headline={headline}
subheadline={subheadline}
/>
)
}
<slot />
</div>
</main>
<Footer footer={footerData} />
}
<main class="flex-1 min-h-[60em]">
<div class="container-custom pb-12">
{resolvedBreadcrumbItems.length > 0 && (
<Breadcrumbs items={resolvedBreadcrumbItems} client:load />
)}
{
showTitleInContent &&
(titleHeadlineHtml || titleSubheadlineHtml) && (
<div class="mb-6 pt-10 pageTitle">
{titleHeadlineHtml && (
<h1 class="pageTitle" set:html={titleHeadlineHtml} />
)}
{titleSubheadlineHtml && (
<h2 class="pageTitle" set:html={titleSubheadlineHtml} />
)}
</div>
)
}
<slot />
</div>
</main>
<Footer footer={footerData} translations={translations} />
</TranslationProvider>
<script src="/pagefind/pagefind-ui.js" is:inline></script>
</body>
</html>
+37 -3
View File
@@ -1,9 +1,11 @@
/**
* Gemeinsame Typen für Content-Blöcke (Rows, Markdown, …).
* Wird von Astro-Seiten und Svelte-Komponenten genutzt.
* Kalender-Typen basieren auf der OpenAPI-Spec (cms-api.generated).
*/
import type { BlockLayout } from "./block-layout";
import type { components } from "./cms-api.generated";
/** Aufgelöster Block vom CMS (mit _type, _slug + typ-spezifische Felder). */
export type ResolvedBlock = {
@@ -25,12 +27,14 @@ export interface RowContentLayout {
row3AlignItems?: string;
}
/** Resolved Markdown-Block vom CMS (_type: "markdown"). */
/** Resolved Markdown-Block vom CMS (_type: "markdown"). resolvedContent = HTML mit transformierten Bildern (von resolveContentImages). */
export interface MarkdownBlockData {
_type?: "markdown";
_slug?: string;
name?: string;
content?: string;
/** Gesetzt von resolveContentImages: HTML (Bilder transformiert, in Links eingewickelt). */
resolvedContent?: string;
alignment?: "left" | "center" | "right";
layout?: BlockLayout;
}
@@ -68,11 +72,25 @@ export interface ImageBlockData {
_slug?: string;
image?:
| string
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
title?: string;
};
/** Alternative zu image (manche CMS-Responses liefern img). */
img?:
| string
| { _type?: "img"; _slug?: string; src?: string; description?: string; file?: { url?: string }; title?: string };
| {
_type?: "img";
_slug?: string;
src?: string;
description?: string;
file?: { url?: string };
title?: string;
};
/** Nach resolveContentImages: lokaler Pfad (public) zum transformierten Bild. */
resolvedImageSrc?: string;
caption?: string;
@@ -176,3 +194,19 @@ export interface SearchableTextBlockData {
textFragments?: (string | SearchableTextFragment)[];
layout?: BlockLayout;
}
/** Kalender-Item (calendar_item) aus OpenAPI; terminZeit = ISO date-time. */
export type CalendarItemData = components["schemas"]["calendar_item"];
/** Kalender-Block (_type: "calendar"). items nach Resolve: CalendarItemData[]. Basis aus OpenAPI (calendar). */
export type CalendarBlockData = Omit<
components["schemas"]["calendar"],
"items"
> & {
_type?: "calendar";
/** Optionaler Titel über dem Kalender. */
title?: string;
/** Slugs oder aufgelöste calendar_item-Einträge. */
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
};
+69 -1
View File
@@ -1,6 +1,14 @@
import type { PostEntry } from "./cms";
import { getPosts, getPostBySlug, getTags, getTextFragmentBySlug } from "./cms";
import {
getPosts,
getPostBySlug,
getTags,
getTextFragmentBySlug,
getCalendarBySlug,
getCalendarItemBySlug,
} from "./cms";
import type { RowContentLayout } 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. */
/** Slug → Anzeigename (name) aus der Tag-API. Für Auflösung von post.postTag in Lesbarkeit. */
@@ -253,6 +261,66 @@ export async function resolvePostOverviewBlocks(
return tagsMap;
}
function isCalendarBlock(
item: unknown,
): item is { _type?: string; _slug?: string; items?: unknown[] } {
return (
typeof item === "object" &&
item !== null &&
(item as { _type?: string })._type === "calendar"
);
}
/**
* Löst Calendar-Blöcke auf: Lädt Kalender per Slug und setzt block.items
* mit den aufgelösten calendar_item-Einträgen (title, terminZeit, description, link).
*/
export async function resolveCalendarBlocks(
layout: RowContentLayout,
): Promise<void> {
const rows = [
layout.row1Content ?? [],
layout.row2Content ?? [],
layout.row3Content ?? [],
];
for (const content of rows) {
if (!Array.isArray(content)) continue;
for (const item of content) {
if (!isCalendarBlock(item)) continue;
const slug = item._slug;
if (!slug) continue;
const rawItems = item.items ?? [];
const first = rawItems[0];
const alreadyResolved =
typeof first === "object" &&
first !== null &&
"terminZeit" in first &&
"title" in first;
if (alreadyResolved) continue;
const calendar = await getCalendarBySlug(slug, {
locale: "de",
resolve: "items",
});
const raw = calendar?.items ?? rawItems;
const resolved: CalendarItemData[] = [];
for (const x of raw) {
if (typeof x === "object" && x !== null && "terminZeit" in x && "title" in x) {
resolved.push(x as CalendarItemData);
} else {
const islug = typeof x === "string" ? x : (x as { _slug?: string })?._slug;
if (islug) {
const entry = await getCalendarItemBySlug(islug, { locale: "de" });
if (entry) resolved.push(entry);
}
}
}
(item as { items?: CalendarItemData[] }).items = resolved;
}
}
}
function isSearchableTextBlock(
item: unknown,
): item is { _type?: string; textFragments?: unknown[] } {
+3383 -353
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
/**
* Nur das Cache-Flag (ob in diesem Request aus Cache gelesen wurde).
* Keine Node-Abhängigkeiten kann von Layout/Middleware importiert werden.
* Die eigentliche Cache-Logik (Node fs/crypto) lebt in cms-cache.ts und wird nur per dynamic import geladen.
*/
let usedCacheThisRequest = false;
export function resetCmsFromCache(): void {
usedCacheThisRequest = false;
}
export function getCmsFromCache(): boolean {
return usedCacheThisRequest;
}
export function setCmsFromCacheUsed(): void {
usedCacheThisRequest = true;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Cache für CMS-API-Responses (nur serverseitig, Node fs/crypto).
* Wird ausschließlich per dynamic import aus cms.ts geladen nie von Layout/Middleware,
* damit es nicht ins Client-Bundle gelangt. Flag/State: cms-cache-state.ts.
*/
import { createHash } from "node:crypto";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { setCmsFromCacheUsed } from "./cms-cache-state";
const CACHE_DIR = `${process.cwd()}/.cache/cms`;
function cacheKeyFromUrl(url: string): string {
return createHash("sha256").update(url).digest("hex");
}
async function ensureCacheDir(): Promise<void> {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
}
interface CachedResponse {
status: number;
body: string;
}
async function getCached(key: string): Promise<CachedResponse | null> {
try {
const path = `${CACHE_DIR}/${key}.json`;
if (!existsSync(path)) return null;
const raw = await readFile(path, "utf-8");
const data = JSON.parse(raw) as CachedResponse;
return data?.body != null ? data : null;
} catch {
return null;
}
}
async function setCached(key: string, data: CachedResponse): Promise<void> {
try {
await ensureCacheDir();
const path = `${CACHE_DIR}/${key}.json`;
await writeFile(path, JSON.stringify(data), "utf-8");
} catch {
// Cache schreiben optional
}
}
/**
* Fetch mit Cache: bei Erfolg wird die Response in .cache/cms gespeichert (überschreibt vorhanden).
* Bei Fehler wird versucht, aus dem Cache zu lesen; wenn gefunden, wird usedCacheThisRequest gesetzt.
*/
export async function cachedFetch(url: string): Promise<Response> {
const key = cacheKeyFromUrl(url);
try {
const res = await fetch(url);
const body = await res.text();
await setCached(key, { status: res.status, body });
return new Response(body, {
status: res.status,
headers: res.headers,
});
} catch {
const cached = await getCached(key);
if (cached) {
setCmsFromCacheUsed();
return new Response(cached.body, {
status: cached.status,
headers: { "Content-Type": "application/json" },
});
}
throw new Error(`CMS nicht erreichbar (${url}) und kein Cache für diese Anfrage.`);
}
}
+168 -103
View File
@@ -1,6 +1,7 @@
/**
* RustyCMS-Client: OpenAPI-Spec laden, Page-Content abrufen.
* Basis-URL über Umgebungsvariable PUBLIC_CMS_URL (z.B. http://localhost:3000).
* API-Responses werden in .cache/cms gespeichert; bei nicht erreichbarem CMS wird aus dem Cache gelesen.
*
* Konvention: slug = URL (für Links/Pfade), _slug = API/interne ID (für GET-Requests).
* Typen für Page und API-Antworten kommen aus der generierten OpenAPI-Spec.
@@ -9,6 +10,25 @@
import type { components, operations } from "./cms-api.generated";
/** Lazy-Import nur bei SSR cms-cache (Node-only) darf nicht ins Client-Bundle. */
let cachedFetchFn: ((url: string) => Promise<Response>) | null = null;
async function getCachedFetch(): Promise<(url: string) => Promise<Response>> {
if (!cachedFetchFn) {
if (import.meta.env.SSR) {
// In Dev immer direkt zur API, damit sofort sichtbar ist, was die API liefert (kein Disk-Cache).
if (import.meta.env.DEV) {
cachedFetchFn = (url: string) => fetch(url);
} else {
const m = await import("./cms-cache");
cachedFetchFn = m.cachedFetch;
}
} else {
cachedFetchFn = (url: string) => fetch(url);
}
}
return cachedFetchFn;
}
const getBaseUrl = (): string => {
const url = import.meta.env.PUBLIC_CMS_URL;
const base = (
@@ -43,6 +63,8 @@ let openApiCache: OpenApiSpec | null = null;
/** Einfacher Request-Cache: gleiche Aufrufe (pro Prozess) nur einmal ausführen. Reduziert doppelte CMS-Requests bei Layout + Page. */
const listCache = new Map<string, Promise<unknown>>();
/** In Dev keinen List-Cache, damit neue CMS-Inhalte sofort erscheinen. */
const skipListCache = typeof import.meta.env !== "undefined" && import.meta.env.DEV;
/**
* Lädt die OpenAPI-Spezifikation von GET /api-docs/openapi.json.
@@ -51,7 +73,7 @@ const listCache = new Map<string, Promise<unknown>>();
export async function fetchOpenApi(): Promise<OpenApiSpec> {
if (openApiCache) return openApiCache;
const base = getBaseUrl();
const res = await fetch(`${base}/api-docs/openapi.json`);
const res = await (await getCachedFetch())(`${base}/api-docs/openapi.json`);
if (!res.ok) {
throw new Error(
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
@@ -67,21 +89,22 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
*/
export async function getPages(): Promise<PageEntry[]> {
const key = "getPages";
let p = listCache.get(key) as Promise<PageEntry[]> | undefined;
if (!p) {
p = (async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/page`);
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;
return data.items ?? [];
})();
listCache.set(key, p);
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 data = (await res.json()) as PageListResponse;
return data.items ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -97,7 +120,7 @@ export async function getPageConfigs(options?: {
const url = new URL(`${base}/api/content/page_config`);
if (options?.locale) url.searchParams.set("_locale", options.locale);
url.searchParams.set("_per_page", String(options?.per_page ?? 50));
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
@@ -116,26 +139,27 @@ export async function getPageConfigBySlug(
): Promise<PageConfigEntry | null> {
const opts = options ?? {};
const key = `getPageConfigBySlug:${slug}:${opts.locale ?? ""}:${opts.resolve ?? ""}`;
let p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
if (!p) {
p = (async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/page_config/${encodeURIComponent(slug)}`,
);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
const res = await fetch(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get page_config failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as PageConfigEntry;
})();
listCache.set(key, p);
if (!skipListCache) {
const p = listCache.get(key) as Promise<PageConfigEntry | null> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/page_config/${encodeURIComponent(slug)}`,
);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`RustyCMS get page_config failed: ${res.status} for slug "${slug}".`,
);
}
return (await res.json()) as PageConfigEntry;
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -181,12 +205,12 @@ export async function getPageBySlug(
options?: ContentGetOptions,
): Promise<PageEntry | null> {
const base = getBaseUrl();
const trySlug = (s: string): Promise<Response> => {
const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/page/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
return fetch(u.toString());
return (await getCachedFetch())(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
@@ -250,40 +274,39 @@ export async function getNavigations(options?: {
}> {
const opts = options ?? {};
const key = `getNavigations:${opts.locale ?? ""}:${opts.per_page ?? 50}:${opts.resolve ?? ""}`;
let p = listCache.get(key) as
| Promise<{
items: NavigationEntry[];
page: number;
per_page: number;
total: number;
total_pages: number;
}>
| undefined;
if (!p) {
p = (async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/navigation`);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
url.searchParams.set("_page", String(opts.page ?? 1));
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
const res = await fetch(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as NavigationListResponse;
return {
items: data.items ?? [],
page: data.page ?? 1,
per_page: data.per_page ?? 50,
total: data.total ?? 0,
total_pages: data.total_pages ?? 1,
};
})();
listCache.set(key, p);
if (!skipListCache) {
const p = listCache.get(key) as Promise<{
items: NavigationEntry[];
page: number;
per_page: number;
total: number;
total_pages: number;
}> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/navigation`);
if (opts.locale) url.searchParams.set("_locale", opts.locale);
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
url.searchParams.set("_page", String(opts.page ?? 1));
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
const res = await (await getCachedFetch())(url.toString());
if (!res.ok) {
throw new Error(
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
);
}
const data = (await res.json()) as NavigationListResponse;
return {
items: data.items ?? [],
page: data.page ?? 1,
per_page: data.per_page ?? 50,
total: data.total ?? 0,
total_pages: data.total_pages ?? 1,
};
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -332,7 +355,7 @@ export async function getNavigationBySlug(
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as NavigationEntry;
@@ -354,7 +377,7 @@ export async function getFooterBySlug(
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
url.searchParams.set("_resolve", options.resolve.join(","));
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -384,12 +407,12 @@ export async function getPostBySlug(
options?: ContentGetOptions,
): Promise<PostEntry | null> {
const base = getBaseUrl();
const trySlug = (s: string): Promise<Response> => {
const trySlug = async (s: string): Promise<Response> => {
const u = new URL(`${base}/api/content/post/${encodeURIComponent(s)}`);
if (options?.locale) u.searchParams.set("_locale", options.locale);
if (options?.resolve?.length)
u.searchParams.set("_resolve", options.resolve.join(","));
return fetch(u.toString());
return (await getCachedFetch())(u.toString());
};
let res = await trySlug(slug);
if (res.status === 404 && !slug.startsWith("/")) {
@@ -441,21 +464,22 @@ export async function getPosts(
? opts.resolve.join(",")
: (opts.resolve ?? "");
const key = `getPosts:${opts._sort ?? ""}:${opts._order ?? ""}:${resolveVal}`;
let p = listCache.get(key) as Promise<PostEntry[]> | undefined;
if (!p) {
p = (async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
if (opts._sort) url.searchParams.set("_sort", opts._sort);
if (opts._order) url.searchParams.set("_order", opts._order);
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
const res = await fetch(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
return data.items ?? [];
})();
listCache.set(key, p);
if (!skipListCache) {
const p = listCache.get(key) as Promise<PostEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const url = new URL(`${base}/api/content/post`);
if (opts._sort) url.searchParams.set("_sort", opts._sort);
if (opts._order) url.searchParams.set("_order", opts._order);
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
const res = await (await getCachedFetch())(url.toString());
if (!res.ok) throw new Error("RustyCMS list post failed");
const data = (await res.json()) as { items?: PostEntry[] };
return data.items ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -465,17 +489,18 @@ export type TagEntry = components["schemas"]["tag"];
/** Alle Tags (GET /api/content/tag). Gecacht pro Prozess. */
export async function getTags(): Promise<TagEntry[]> {
const key = "getTags";
let p = listCache.get(key) as Promise<TagEntry[]> | undefined;
if (!p) {
p = (async () => {
const base = getBaseUrl();
const res = await fetch(`${base}/api/content/tag`);
if (!res.ok) return [];
const data = (await res.json()) as { items?: TagEntry[] };
return data.items ?? [];
})();
listCache.set(key, p);
if (!skipListCache) {
const p = listCache.get(key) as Promise<TagEntry[]> | undefined;
if (p) return p;
}
const p = (async () => {
const base = getBaseUrl();
const res = await (await getCachedFetch())(`${base}/api/content/tag`);
if (!res.ok) return [];
const data = (await res.json()) as { items?: TagEntry[] };
return data.items ?? [];
})();
if (!skipListCache) listCache.set(key, p);
return p;
}
@@ -492,7 +517,7 @@ export async function getTextFragmentBySlug(
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -515,7 +540,7 @@ export async function getFullwidthBannerBySlug(
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
@@ -541,7 +566,7 @@ export async function getTranslationBySlug(
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
return null;
@@ -558,7 +583,7 @@ export type TranslationBundleEntry = {
/**
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
* Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
* Slug z. B. "de" für content/de/translation_bundle/de.json5.
* Mit _resolve=all werden Referenzen in strings aufgelöst (z. B. blog_tag_all).
*/
export async function getTranslationBundleBySlug(
slug: string,
@@ -568,11 +593,51 @@ export async function getTranslationBundleBySlug(
const url = new URL(
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
);
url.searchParams.set("_resolve", "all");
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await fetch(url.toString());
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) {
return null;
}
return (await res.json()) as TranslationBundleEntry;
}
/** Kalender-Eintrag (calendar) aus OpenAPI-Spec. */
export type CalendarEntry = components["schemas"]["calendar"];
/** Kalender-Item (calendar_item) aus OpenAPI-Spec. */
export type CalendarItemEntry = components["schemas"]["calendar_item"];
/** Kalender anhand Slug (GET /api/content/calendar/:slug). */
export async function getCalendarBySlug(
slug: string,
options?: { locale?: string; resolve?: string },
): Promise<CalendarEntry | null> {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarEntry;
}
/** Einzelnes calendar_item anhand Slug (GET /api/content/calendar_item/:slug). */
export async function getCalendarItemBySlug(
slug: string,
options?: { locale?: string },
): Promise<CalendarItemEntry | null> {
const base = getBaseUrl();
const url = new URL(
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
);
if (options?.locale) url.searchParams.set("_locale", options.locale);
const res = await (await getCachedFetch())(url.toString());
if (res.status === 404) return null;
if (!res.ok) return null;
return (await res.json()) as CalendarItemEntry;
}
+5 -2
View File
@@ -25,11 +25,14 @@ export const ROW_RESOLVE: string[] = [
/** Resolve für Page-Requests: "all" = alle Referenzen inkl. verschachtelt (z. B. image_gallery.images). */
export const PAGE_RESOLVE = ["all"];
/** Resolve für Post-Requests (Rows + Post-Bild, Tags). */
export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
/** Resolve für Post-Requests: "all" damit auch img in row1Content/Image-Blöcken aufgelöst wird (src), sonst nur Referenz. */
export const POST_RESOLVE = ["all"];
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
export const SITE_NAME = "RustyAstro";
/** Slug des Translation-Bundles im CMS (content/de/translation_bundle/{slug}.json5). Alle UI-Texte aus diesem Bundle. */
export const TRANSLATION_BUNDLE_SLUG = "app";
+101 -8
View File
@@ -24,6 +24,8 @@ export interface RustyImageTransformParams {
const DEFAULT_FORMAT = "jpeg";
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;
@@ -105,11 +107,25 @@ export async function ensureTransformedImage(
const filePath = path.join(dir, filename);
const publicPath = `/${SUBDIR}/${filename}`;
try {
await fs.access(filePath);
return publicPath;
} catch {
/* Datei existiert nicht, laden und schreiben */
// Build: zuerst persistenten Cache prüfen (dist wird bei jedem Build geleert).
const cacheDir = path.join(projectRoot, TRANSFORM_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, laden und in Cache + dist schreiben */
}
} else {
try {
await fs.access(filePath);
return publicPath;
} catch {
/* Datei existiert nicht, laden und schreiben */
}
}
const baseUrl = getCmsBaseUrl();
@@ -135,6 +151,10 @@ export async function ensureTransformedImage(
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;
}
@@ -203,14 +223,73 @@ export interface RowContentLayoutLike {
row3Content?: unknown[];
}
/** Parameter für resolveContentImages (optional baseUrl für Markdown-Bild-Links). */
export interface ResolveContentImagesOptions {
transformParams?: RustyImageTransformParams;
/** Basis-URL für Links auf Bilder (z. B. Astro.url.origin oder SITE). Fehlt → aus import.meta.env.SITE. */
baseUrl?: string;
}
/** Markdown-Inline-Bilder: 1200px Breite, WebP, Link öffnet dasselbe Bild in neuem Tab. */
const MARKDOWN_IMAGE_PARAMS: RustyImageTransformParams = {
width: 1200,
fit: "contain",
format: "webp",
quality: 85,
};
const IMG_SRC_REGEX =
/<img\s+([^>]*?)src\s*=\s*["']([^"']+)["']([^>]*)>/gi;
/**
* Geht alle Content-Rows durch und setzt bei Image-Blöcken resolvedImageSrc
* (transformiert über RustyCMS und speichert in public).
* Ersetzt in HTML alle <img src="...">: Bild-URL wird transformiert (RustyImage),
* Bild wird in <a href="..." target="_blank"> eingewickelt (öffnet Bild in neuem Tab).
* href ist relativ (z. B. /images/transformed/…), damit auf localhost und Production dieselbe Seite funktioniert.
*/
export async function processMarkdownHtmlImages(
html: string,
_baseUrl?: string,
transformParams: RustyImageTransformParams = MARKDOWN_IMAGE_PARAMS,
): Promise<string> {
const matches = [...html.matchAll(IMG_SRC_REGEX)];
const replacements: { index: number; length: number; newBlock: string }[] = [];
for (const m of matches) {
const rawSrc = m[2];
const url = rawSrc.startsWith("//") ? `https:${rawSrc}` : rawSrc;
if (!url.startsWith("http")) continue;
try {
const transformedPath = await ensureTransformedImage(url, transformParams);
const href = transformedPath.startsWith("/") ? transformedPath : `/${transformedPath}`;
const newImg = m[0].replace(rawSrc, transformedPath);
const newBlock = `<a href="${href}" target="_blank" rel="noopener noreferrer" class="markdown-image-link">${newImg}</a>`;
replacements.push({ index: m.index ?? 0, length: m[0].length, newBlock });
} catch (e) {
console.warn("[rusty-image] markdown img resolve failed for", url, 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.newBlock + 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).
*/
export async function resolveContentImages(
layout: RowContentLayoutLike,
transformParams: RustyImageTransformParams = {},
transformParamsOrOptions: RustyImageTransformParams | ResolveContentImagesOptions = {},
): Promise<void> {
const options: ResolveContentImagesOptions =
transformParamsOrOptions && "baseUrl" in transformParamsOrOptions
? (transformParamsOrOptions as ResolveContentImagesOptions)
: { transformParams: transformParamsOrOptions as RustyImageTransformParams };
const transformParams = options.transformParams ?? {};
const rows = [
layout.row1Content,
layout.row2Content,
@@ -227,16 +306,30 @@ export async function resolveContentImages(
...transformParams,
};
const { marked } = await import("marked");
marked.setOptions({ gfm: true });
for (const content of rows) {
for (const item of content) {
if (typeof item !== "object" || item === null) continue;
const block = item as {
_type?: string;
content?: string;
image?: unknown;
img?: unknown;
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
resolvedImageSrc?: string;
resolvedContent?: string;
};
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);
} catch (e) {
console.warn("[rusty-image] markdown block resolve failed", e);
}
continue;
}
if (block._type === "image") {
const url = getImageBlockUrl(
block as {
+186
View File
@@ -0,0 +1,186 @@
/**
* Zentrale Übersetzungen (i18n-ähnlich).
* Quelle: CMS translation_bundle (API, Middleware setzt Astro.locals.translations).
* Wird in Layout geladen und über TranslationProvider/Svelte-Context bereitgestellt.
*
* Wichtig für client:load-Inseln: useTranslate() hat dort keinen Context und beim SSR kein window,
* daher fehlen Übersetzungen (es erscheint der Key). Lösung: Übersetzungen als Prop von der
* Astro-Seite übergeben und t(translations, key, replacements) nutzen. Siehe .cursor/rules/translations-i18n.mdc.
*/
import { getContext } from "svelte";
export type Translations = Record<string, string>;
/** Context-Key für die t-Funktion (von TranslationProvider gesetzt). */
export const T_CONTEXT_KEY = Symbol("translations.t");
/** Platzhalter für Ersetzung: z. B. { name: "Max" } ersetzt {{name}} im Übersetzungstext. */
export type TranslationReplacements = Record<string, string | number>;
/** t(key) oder t(key, { placeholder: value }) mit optionalen Ersetzungen für {{placeholder}}. */
export type TFunction = (
key: string,
replacements?: TranslationReplacements,
) => string;
/**
* Ersetzt {{name}} im Text durch replacements[name]; fehlende Keys bleiben als {{name}}.
*/
export function replacePlaceholders(
text: string,
replacements?: TranslationReplacements | null,
): string {
if (!replacements || typeof text !== "string") return text;
return text.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
const val = replacements[name];
return val !== undefined && val !== null ? String(val) : `{{${name}}}`;
});
}
/** Liest Übersetzungen aus window oder aus script#astro-translations (Fallback für Inseln). */
function getTranslationsFromWindow(): Translations | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { __TRANSLATIONS__?: Translations };
if (w.__TRANSLATIONS__ && typeof w.__TRANSLATIONS__ === "object") return w.__TRANSLATIONS__;
try {
const el = document.getElementById("astro-translations");
const json =
el?.getAttribute("data-translations") ??
(el as HTMLScriptElement | null)?.textContent?.trim();
if (json) {
const parsed = JSON.parse(json) as Translations;
w.__TRANSLATIONS__ = parsed;
return parsed;
}
} catch {
/* ignore */
}
return null;
}
/**
* t-Funktion aus Svelte-Context oder aus window.__TRANSLATIONS__.
* Nur zuverlässig innerhalb des TranslationProvider-Baums. Für client:load-Inseln:
* Übersetzungen als Prop übergeben und statische t(translations, key) nutzen (siehe Regel translations-i18n).
*/
export function useTranslate(): TFunction {
const fromContext = getContext<TFunction | undefined>(T_CONTEXT_KEY);
if (fromContext) return fromContext;
const warnedKeys = new Set<string>();
return (key: string, replacements?: TranslationReplacements): string => {
const translations = getTranslationsFromWindow();
const raw = translations?.[key] ?? key;
if (
typeof import.meta.env !== "undefined" &&
import.meta.env.DEV &&
raw === key &&
key.length > 0 &&
!warnedKeys.has(key)
) {
warnedKeys.add(key);
console.warn(
`[translations] Key "${key}" in Insel nicht gefunden (kein Context/window). ` +
`Übersetzungen als Prop von der Astro-Seite übergeben und t(translations, key) nutzen. Siehe .cursor/rules/translations-i18n.mdc`,
);
}
return replacePlaceholders(raw, replacements);
};
}
/** Bekannte Übersetzungs-Keys nur hier eintragen, Nutzung über T.xy (Autocomplete, keine Tippfehler). */
const TRANSLATION_KEYS = [
"searchable_text_help",
"searchable_text_help_aria",
"searchable_text_placeholder",
"searchable_text_search_aria",
"searchable_text_clear_search",
"searchable_text_tag_all",
"searchable_text_no_results",
"searchable_text_count_all",
"searchable_text_count_filtered",
"searchable_text_copy_aria",
"searchable_text_copy_button",
"searchable_text_copied",
"searchable_text_untitled",
"footer_copyright",
"nav_start",
"nav_posts",
"nav_home",
"header_search_open",
"header_search_close",
"header_menu_open",
"header_menu_close",
"header_nav_aria",
"header_overlay_close",
"header_social_aria",
"site_name_fallback",
"pagination_prev",
"pagination_next",
"blog_tag_all",
"blog_filter_aria",
"blog_count",
"page_404_title",
"page_404_text",
"page_404_back",
"posts_title",
"posts_subtitle",
"posts_tag_title",
"posts_tag_subtitle",
"posts_cms_error",
"iframe_activate_aria",
"iframe_missing",
"image_gallery_swipe_aria",
"image_gallery_prev_aria",
"image_gallery_next_aria",
"image_gallery_position_aria",
"image_gallery_slide_aria",
"image_gallery_empty",
"youtube_missing",
"youtube_title_fallback",
"calendar_prev_month",
"calendar_next_month",
"calendar_weekday_mo",
"calendar_weekday_di",
"calendar_weekday_mi",
"calendar_weekday_do",
"calendar_weekday_fr",
"calendar_weekday_sa",
"calendar_weekday_so",
"calendar_more_info",
"calendar_no_events",
"calendar_time_suffix",
"calendar_select_day",
"calendar_next_events",
"calendar_show_more",
"calendar_show_less",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
/** Keys als Objekt: T.searchable_text_help → "searchable_text_help" (für t(T.xy)). */
export const T: { [K in TranslationKey]: K } = Object.fromEntries(
TRANSLATION_KEYS.map((k) => [k, k]),
) as { [K in TranslationKey]: K };
/**
* Platzhalter im CMS-Text: {{name}} wird durch t(key, { name: "Wert" }) ersetzt.
* Beispiel im CMS: "Hallo {{user}}, du hast {{count}} Einträge."
* Aufruf: t(T.xyz, { user: "Max", count: 5 })
*/
/**
* Gibt den übersetzten Text für key zurück, optional mit Ersetzung von {{placeholder}}.
* Fallback: key selbst (damit fehlende Einträge im CMS erkennbar sind).
* Nutzbar in Astro und in Svelte-Inseln: Übersetzungen von der Astro-Seite als Prop übergeben,
* dann in der Komponente z. B. `t(translations ?? null, T.xy, { count: 5 })` (siehe BlogOverview.svelte).
*/
export function t(
translations: Translations | null | undefined,
key: string,
replacements?: TranslationReplacements,
): string {
if (!key) return key;
const raw = translations?.[key] ?? key;
return replacePlaceholders(raw, replacements);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Middleware: lädt Übersetzungen aus der CMS-API (translation_bundle) und setzt Astro.locals.translations.
* Single source of truth: API des Servers.
*/
import { defineMiddleware } from "astro:middleware";
import { getTranslationBundleBySlug } from "./lib/cms";
import { TRANSLATION_BUNDLE_SLUG } from "./lib/constants";
import { resetCmsFromCache } from "./lib/cms-cache-state";
export const onRequest = defineMiddleware(async (context, next) => {
resetCmsFromCache();
let translations: Record<string, string> = {};
try {
const bundle = await getTranslationBundleBySlug(TRANSLATION_BUNDLE_SLUG, {
locale: "de",
});
if (bundle?.strings && typeof bundle.strings === "object") {
const normalized: Record<string, string> = {};
for (const [key, val] of Object.entries(bundle.strings)) {
const v = val as unknown;
const str =
typeof v === "string"
? v
: typeof v === "object" && v !== null && "value" in v
? (v as { value?: unknown }).value
: (v as { text?: unknown })?.text ?? (v as { label?: unknown })?.label;
if (typeof str === "string") normalized[key] = str;
}
translations = { ...normalized };
}
} catch {
/* CMS nicht erreichbar */
}
context.locals.translations = translations;
return next();
});
+6 -3
View File
@@ -1,13 +1,16 @@
---
import Layout from '../layouts/Layout.astro';
import { t, T } from '../lib/translations';
const translations = Astro.locals.translations ?? {};
---
<Layout title="Seite nicht gefunden">
<Layout title={t(translations, T.page_404_title)}>
<main class="mx-auto max-w-3xl px-4 py-12 text-center">
<h1 class="text-3xl font-bold text-zinc-900">404</h1>
<p class="mt-2 text-zinc-600">Seite nicht gefunden.</p>
<p class="mt-2 text-zinc-600">{t(translations, T.page_404_text)}</p>
<a href="/" class="mt-6 inline-block rounded bg-zinc-900 px-4 py-2 text-white hover:bg-zinc-800">
Zur Startseite
{t(translations, T.page_404_back)}
</a>
</main>
</Layout>
+6 -12
View File
@@ -4,9 +4,9 @@ import ContentRows from "../components/ContentRows.svelte";
import { getPageSlugs, getPageBySlug } from "../lib/cms";
import type { PageEntry } from "../lib/cms";
import { resolveContentImages } from "../lib/rusty-image";
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks } from "../lib/blog-utils";
import { getTranslationBundleBySlug } from "../lib/cms";
import { resolvePostOverviewBlocks, resolveSearchableTextBlocks, resolveCalendarBlocks } from "../lib/blog-utils";
import { PAGE_RESOLVE } from "../lib/constants";
import { T } from "../lib/translations";
export const prerender = true;
@@ -41,9 +41,8 @@ if (!page) {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
const translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
await resolveCalendarBlocks(page);
const translations = Astro.locals.translations ?? {};
---
<Layout
@@ -54,16 +53,11 @@ const translationBundle = await getTranslationBundleBySlug("de", {
topFullwidthBanner={page.topFullwidthBanner}
showBannerInLayout={!!page.topFullwidthBanner}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/", translationKey: T.nav_start },
{ label: page.headline ?? page.linkName ?? page.slug ?? page._slug ?? slug },
]}
>
<article>
<ContentRows
client:load
layout={page}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
class="mt-8"
/>
<ContentRows client:load layout={page} class="mt-8" translations={translations} />
</article>
</Layout>
+8 -14
View File
@@ -12,14 +12,14 @@ import { resolveContentImages } from "../lib/rusty-image";
import {
resolvePostOverviewBlocks,
resolveSearchableTextBlocks,
resolveCalendarBlocks,
} from "../lib/blog-utils";
import { getTranslationBundleBySlug } from "../lib/cms";
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE } from "../lib/constants";
import {
DEFAULT_HOME_PAGE_SLUG,
PAGE_RESOLVE,
} from "../lib/constants";
let page: PageEntry | null = null;
let translationBundle: Awaited<
ReturnType<typeof getTranslationBundleBySlug>
> = null;
let cmsError: string | null = null;
let openApiTitle = "RustyCMS API";
let pages: PageEntry[] = [];
@@ -40,13 +40,12 @@ try {
await resolveContentImages(page);
const tagsMap = await resolvePostOverviewBlocks(page);
await resolveSearchableTextBlocks(page, tagsMap);
translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
await resolveCalendarBlocks(page);
}
} catch (e) {
cmsError = e instanceof Error ? e.message : String(e);
}
const translations = Astro.locals.translations ?? {};
---
{
@@ -60,12 +59,7 @@ try {
showBannerInLayout={!!page.topFullwidthBanner}
>
<article>
<ContentRows
client:load
layout={page}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
class="mt-8"
/>
<ContentRows client:load layout={page} class="mt-8" translations={translations} />
</article>
</Layout>
) : (
+22 -21
View File
@@ -11,6 +11,7 @@ import type { PostEntry } from "../../lib/cms";
import {
resolveContentImages,
ensureTransformedImage,
processMarkdownHtmlImages,
} from "../../lib/rusty-image";
import {
resolvePostTagsInPost,
@@ -19,8 +20,8 @@ import {
getPostImageUrl,
formatPostDate,
} from "../../lib/blog-utils";
import { getTranslationBundleBySlug } from "../../lib/cms";
import { POST_RESOLVE } from "../../lib/constants";
import { T } from "../../lib/translations";
export const prerender = true;
@@ -53,9 +54,6 @@ await resolveContentImages(post);
const tagsMap = await resolvePostOverviewBlocks(post);
await resolveSearchableTextBlocks(post, tagsMap);
resolvePostTagsInPost(post, tagsMap);
const translationBundle = await getTranslationBundleBySlug("de", {
locale: "de",
});
const rawPostImageUrl = getPostImageUrl(post.postImage);
const postImageUrl = rawPostImageUrl
@@ -69,10 +67,18 @@ const postImageUrl = rawPostImageUrl
const postDate = formatPostDate(post.created ?? post.date);
const postContent = (post as { content?: string }).content;
const contentHtml =
let contentHtml =
typeof postContent === "string" && postContent.trim()
? (marked.parse(postContent) as string)
: "";
if (contentHtml) {
contentHtml = await processMarkdownHtmlImages(contentHtml);
}
const hasRowContent =
((post as { row1Content?: unknown[] }).row1Content?.length ?? 0) > 0 ||
((post as { row2Content?: unknown[] }).row2Content?.length ?? 0) > 0 ||
((post as { row3Content?: unknown[] }).row3Content?.length ?? 0) > 0;
const translations = Astro.locals.translations ?? {};
---
<Layout
@@ -84,8 +90,8 @@ const contentHtml =
showBannerInLayout={false}
image={postImageUrl ?? undefined}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ href: "/", translationKey: T.nav_start },
{ href: "/posts/", translationKey: T.nav_posts },
{ label: post.headline ?? post.linkName ?? post.slug ?? post._slug ?? slug },
]}
>
@@ -121,19 +127,14 @@ const contentHtml =
</div>
</div>
<article class="mt-8">
{
contentHtml ? (
<div
class="content markdown max-w-none prose prose-zinc"
set:html={contentHtml}
/>
) : (
<ContentRows
client:load
layout={post}
searchableTextHelpTooltip={translationBundle?.strings?.searchable_text_help}
/>
)
}
{contentHtml && (
<div
class="content markdown max-w-none prose prose-zinc"
set:html={contentHtml}
/>
)}
{hasRowContent && (
<ContentRows client:load layout={post} translations={translations} />
)}
</article>
</Layout>
+10 -6
View File
@@ -14,6 +14,7 @@ import {
getTagsMap,
resolvePostTagsInPost,
} from '../../lib/blog-utils';
import { t, T } from '../../lib/translations';
export const prerender = true;
@@ -34,24 +35,26 @@ const perPage = getPostsPerPage();
const filtered = filterPostsByTag(posts, null);
const totalPages = getTotalPages(filtered.length, perPage);
const pagePosts = paginate(filtered, 1, perPage);
const translations = Astro.locals.translations ?? {};
---
<Layout
title="Beiträge Aktuelles"
title={`${t(translations, T.posts_title)} Aktuelles`}
description="Übersicht aller Beiträge und Meldungen."
breadcrumbItems={[
{ href: "/", label: "Start" },
{ label: "Beiträge" },
{ href: "/", translationKey: T.nav_start },
{ translationKey: T.nav_posts },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
<h2>Aktuelles und Meldungen</h2>
<h1>{t(translations, T.posts_title)}</h1>
<h2>{t(translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{cmsError ? (
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>CMS nicht erreichbar:</strong> {cmsError}
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
</div>
) : (
<BlogOverview
@@ -62,6 +65,7 @@ const pagePosts = paginate(filtered, 1, perPage);
totalPages={totalPages}
totalPosts={filtered.length}
basePath="/posts"
translations={translations}
/>
)}
</div>
+10 -6
View File
@@ -9,6 +9,7 @@ import {
paginate,
getPostsPerPage,
} from '../../../lib/blog-utils';
import { t, T } from '../../../lib/translations';
export const prerender = true;
@@ -46,24 +47,26 @@ const filtered = filterPostsByTag(posts, null);
const totalPages = getTotalPages(filtered.length, perPage);
const pageNum = Math.min(currentPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
const translations = Astro.locals.translations ?? {};
---
<Layout
title={`Beiträge Seite ${pageNum}`}
title={`${t(translations, T.posts_title)} Seite ${pageNum}`}
description="Übersicht aller Beiträge und Meldungen."
breadcrumbItems={[
{ href: "/", label: "Start" },
{ label: "Beiträge" },
{ href: "/", translationKey: T.nav_start },
{ translationKey: T.nav_posts },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
<h2>Aktuelles und Meldungen</h2>
<h1>{t(translations, T.posts_title)}</h1>
<h2>{t(translations, T.posts_subtitle)}</h2>
</div>
<div class="content mt-8">
{cmsError ? (
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>CMS nicht erreichbar:</strong> {cmsError}
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
</div>
) : (
<BlogOverview
@@ -74,6 +77,7 @@ const pagePosts = paginate(filtered, pageNum, perPage);
totalPages={totalPages}
totalPosts={filtered.length}
basePath="/posts"
translations={translations}
/>
)}
</div>
+10 -6
View File
@@ -11,6 +11,7 @@ import {
getTagsMap,
resolvePostTagsInPost,
} from '../../../lib/blog-utils';
import { t, T } from '../../../lib/translations';
export const prerender = true;
@@ -58,25 +59,27 @@ const totalPages = getTotalPages(filtered.length, perPage);
const pagePosts = paginate(filtered, 1, perPage);
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
const translations = Astro.locals.translations ?? {};
---
<Layout
title={`Beiträge ${tagName}`}
title={`${t(translations, T.posts_title)} ${tagName}`}
description={`Beiträge zum Thema ${tagName}.`}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ href: "/", translationKey: T.nav_start },
{ href: "/posts/", translationKey: T.nav_posts },
{ label: tagName },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
<h2>Beiträge: {tagName}</h2>
<h1>{t(translations, T.posts_title)}</h1>
<h2>{t(translations, T.posts_tag_subtitle, { tag: tagName })}</h2>
</div>
<div class="content mt-8">
{cmsError ? (
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>CMS nicht erreichbar:</strong> {cmsError}
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
</div>
) : (
<BlogOverview
@@ -87,6 +90,7 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
totalPages={totalPages}
totalPosts={filtered.length}
basePath="/posts"
translations={translations}
/>
)}
</div>
+10 -6
View File
@@ -11,6 +11,7 @@ import {
getTagsMap,
resolvePostTagsInPost,
} from '../../../../../lib/blog-utils';
import { t, T } from '../../../../../lib/translations';
export const prerender = true;
@@ -71,25 +72,27 @@ const pageNum = Math.min(currentPage, totalPages);
const pagePosts = paginate(filtered, pageNum, perPage);
const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
const translations = Astro.locals.translations ?? {};
---
<Layout
title={`Beiträge ${tagName} (Seite ${pageNum})`}
title={`${t(translations, T.posts_title)} ${tagName} (Seite ${pageNum})`}
description={`Beiträge zum Thema ${tagName}.`}
breadcrumbItems={[
{ href: "/", label: "Start" },
{ href: "/posts/", label: "Beiträge" },
{ href: "/", translationKey: T.nav_start },
{ href: "/posts/", translationKey: T.nav_posts },
{ label: tagName },
]}
>
<div class="mb-6 pt-10 pageTitle">
<h1>Beiträge</h1>
<h2>Beiträge: {tagName}</h2>
<h1>{t(translations, T.posts_title)}</h1>
<h2>{t(translations, T.posts_tag_subtitle, { tag: tagName })}</h2>
</div>
<div class="content mt-8">
{cmsError ? (
<div class="rounded-sm border border-amber-200 bg-amber-50 p-4 text-amber-800">
<strong>CMS nicht erreichbar:</strong> {cmsError}
<strong>{t(translations, T.posts_cms_error)}</strong> {cmsError}
</div>
) : (
<BlogOverview
@@ -100,6 +103,7 @@ const tagName = tags.find((t) => (t._slug ?? '') === tagSlug)?.name ?? tagSlug;
totalPages={totalPages}
totalPosts={filtered.length}
basePath="/posts"
translations={translations}
/>
)}
</div>
+63 -60
View File
@@ -73,6 +73,14 @@
--color-error-subtle: #fdf2f1;
--color-info: var(--color-himmel-500);
--color-info-subtle: var(--color-himmel-50);
/* Button (Primary = Wald) für @apply bg-btn-bg, text-btn-txt, bg-btn-hover-bg */
--color-btn-bg: var(--color-wald-500);
--color-btn-hover-bg: var(--color-wald-600);
--color-btn-txt: #fff;
/* Link (Himmel) für @apply text-link */
--color-link: var(--color-himmel-500);
}
:root {
@@ -294,39 +302,28 @@ main ol li {
/* Buttons Wald (Primary) */
.btn-primary {
display: inline-block;
text-decoration: none;
transition: background-color 0.2s ease;
font-weight: 300;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
background: var(--color-btn-bg);
color: var(--color-btn-txt);
@apply inline-block no-underline transition-colors duration-200 font-light px-4 py-2 rounded-md shadow-sm bg-btn-bg text-btn-txt;
}
.btn-primary:hover {
background: var(--color-btn-hover-bg);
@apply bg-btn-hover-bg;
}
/* Content-Blöcke */
.content p {
margin-bottom: 1rem;
@apply mb-4;
}
.content h1,
.content h2,
.content h3,
.content h4 {
margin-bottom: 0.5em;
@apply mb-2;
}
/* Markdown: Abstände, Listen, Bilder, Tabellen (Stein-Palette) */
.markdown {
min-width: 0;
overflow-x: auto;
@apply min-w-0 overflow-x-auto;
}
.markdown h1,
@@ -334,68 +331,63 @@ main ol li {
.markdown h3,
.markdown h4,
.markdown hr {
margin-top: 1rem;
margin-bottom: 0.75rem;
@apply mt-4 mb-3;
}
.markdown p,
.markdown li {
margin-top: 0;
margin-bottom: 0;
.markdown li,
.markdown li p {
@apply mt-0 mb-0;
}
.markdown p {
@apply mb-4;
}
.markdown ul,
.markdown ol {
margin-top: 0;
margin-bottom: 0;
@apply mt-0 mb-0;
}
/* Markdown-Bilder: RustyImage-Transform, max-height begrenzt Höhe; Link öffnet Bild in neuem Tab */
.markdown img {
max-width: 400px;
width: 100%;
@apply max-w-full w-full my-2 border border-stein-200 rounded-md bg-stein-200 shadow-sm inline-block;
max-height: 30vh;
}
.markdown .markdown-image-link {
@apply inline-block;
}
.markdown table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
margin-bottom: 1rem;
@apply w-full min-w-max border-collapse text-left text-sm mb-4;
}
.markdown thead {
background: var(--color-stein-100);
@apply bg-stein-100;
}
.markdown th {
padding: 0.5rem 0.75rem;
font-weight: 600;
border: 1px solid var(--color-stein-200);
@apply px-3 py-2 font-medium border border-stein-200;
}
.markdown td {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-stein-200);
@apply px-3 py-2 border border-stein-200;
}
.markdown tbody tr:nth-child(even) {
background: var(--color-stein-50);
@apply bg-stein-50;
}
.markdown tbody tr:hover {
background: var(--color-stein-100);
@apply bg-stein-100;
}
.markdown table a {
color: var(--color-link);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
@apply text-link underline underline-offset-2 break-all;
}
.markdown table a:hover {
color: var(--color-himmel-600);
@apply text-himmel-600;
}
/* Code: Inline und Block (pre/code) lesbare Größe, Stein-Palette */
@@ -403,37 +395,24 @@ main code,
.content code,
.markdown code {
@apply font-mono bg-stein-100 text-stein-800 border border-stein-200;
font-size: 0.875em;
padding: 0.15em 0.4em;
@apply text-sm px-1.5 py-0.5;
}
main pre,
.content pre,
.markdown pre {
@apply mt-3 mb-4 overflow-x-auto rounded-md border border-stein-200 bg-stein-100 px-4 py-3 text-stein-800 leading-normal;
font-size: 0.8125rem;
}
main pre code,
.content pre code,
.markdown pre code {
@apply border-0 bg-transparent p-0 text-inherit;
font-size: 1em;
}
/* Container */
.container-custom {
width: 100%;
margin-left: auto;
margin-right: auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
@media (min-width: 640px) {
.container-custom {
max-width: 640px;
}
@apply w-full mx-auto px-4 md:px-6;
}
@media (min-width: 768px) {
@@ -481,3 +460,27 @@ footer a:hover,
margin-right: calc(50% - 50vw + 0.01rem);
background: var(--color-container-breakout);
}
/* Kalender: Container Query links Kalender, rechts Eventblock ab ~500px Containerbreite */
.calendar-block {
container-type: inline-size;
container-name: calendar;
}
@container calendar (min-width: 600px) {
.calendar-block .calendar-grid-wrapper {
grid-template-columns: auto 1fr;
}
.calendar-block .calendar-column {
min-width: 300px;
max-width: 450px;
}
.calendar-block .calendar-events-wrapper {
position: relative;
}
.calendar-block .calendar-events-panel {
position: absolute;
inset: 0;
border-top: none;
border-left: 1px solid var(--color-stein-200);
}
}
+5
View File
@@ -2891,6 +2891,11 @@ json-schema-traverse@^1.0.0:
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
json5@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"