From daa4ea17b12a235a13fd8b3c327d4a4c65662353 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Tue, 17 Mar 2026 22:32:31 +0100 Subject: [PATCH] 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. --- .../rules/context-persist-erkenntnisse.mdc | 23 + .cursor/rules/translations-i18n.mdc | 41 + .gitignore | 2 + .vscode/settings.json | 6 + package.json | 3 + public/apple-touch-icon.svg | 11 + public/favicon.svg | 17 +- public/safari-pinned-tab.svg | 10 + scripts/warm-cache.mjs | 125 + src/components/BlogOverview.svelte | 27 +- src/components/ContentRows.svelte | 18 +- .../ContentRowsWithClientSearch.astro | 9 +- src/components/Footer.svelte | 16 +- src/components/Header.svelte | 105 +- src/components/HeaderOverlay.svelte | 6 +- src/components/Pagination.svelte | 8 +- src/components/TranslationProvider.svelte | 25 + src/components/blocks/CalendarBlock.svelte | 349 ++ src/components/blocks/IframeBlock.svelte | 12 +- src/components/blocks/ImageBlock.svelte | 37 +- .../blocks/ImageGalleryBlock.svelte | 15 +- src/components/blocks/MarkdownBlock.svelte | 5 +- .../blocks/SearchableTextBlock.svelte | 136 +- .../blocks/YoutubeVideoBlock.svelte | 7 +- src/env.d.ts | 6 + src/layouts/Layout.astro | 134 +- src/lib/block-types.ts | 40 +- src/lib/blog-utils.ts | 70 +- src/lib/cms-api.generated.ts | 3736 +++++++++++++++-- src/lib/cms-cache-state.ts | 19 + src/lib/cms-cache.ts | 77 + src/lib/cms.ts | 271 +- src/lib/constants.ts | 7 +- src/lib/rusty-image.ts | 109 +- src/lib/translations.ts | 186 + src/middleware.ts | 36 + src/pages/404.astro | 9 +- src/pages/[slug].astro | 18 +- src/pages/index.astro | 22 +- src/pages/post/[slug].astro | 43 +- src/pages/posts/index.astro | 16 +- src/pages/posts/page/[page].astro | 16 +- src/pages/posts/tag/[tag].astro | 16 +- src/pages/posts/tag/[tag]/page/[page].astro | 16 +- src/styles/global.css | 123 +- yarn.lock | 5 + 46 files changed, 5207 insertions(+), 781 deletions(-) create mode 100644 .cursor/rules/context-persist-erkenntnisse.mdc create mode 100644 .cursor/rules/translations-i18n.mdc create mode 100644 .vscode/settings.json create mode 100644 public/apple-touch-icon.svg create mode 100644 public/safari-pinned-tab.svg create mode 100644 scripts/warm-cache.mjs create mode 100644 src/components/TranslationProvider.svelte create mode 100644 src/components/blocks/CalendarBlock.svelte create mode 100644 src/lib/cms-cache-state.ts create mode 100644 src/lib/cms-cache.ts create mode 100644 src/lib/translations.ts create mode 100644 src/middleware.ts diff --git a/.cursor/rules/context-persist-erkenntnisse.mdc b/.cursor/rules/context-persist-erkenntnisse.mdc new file mode 100644 index 0000000..7b921de --- /dev/null +++ b/.cursor/rules/context-persist-erkenntnisse.mdc @@ -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. diff --git a/.cursor/rules/translations-i18n.mdc b/.cursor/rules/translations-i18n.mdc new file mode 100644 index 0000000..09db069 --- /dev/null +++ b/.cursor/rules/translations-i18n.mdc @@ -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: `` 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. diff --git a/.gitignore b/.gitignore index 4dcb20c..ea52020 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # build output dist/ +# CMS response cache (Dev-Fallback bei nicht erreichbarem CMS) +.cache/ # generated types .astro/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..90ce9ff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode", + "editor.formatOnSave": true + } +} diff --git a/package.json b/package.json index aa772e4..d3ba7ac 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/apple-touch-icon.svg b/public/apple-touch-icon.svg new file mode 100644 index 0000000..269d54b --- /dev/null +++ b/public/apple-touch-icon.svg @@ -0,0 +1,11 @@ + + + + diff --git a/public/favicon.svg b/public/favicon.svg index f157bd1..75c6741 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,9 +1,10 @@ - - - + + diff --git a/public/safari-pinned-tab.svg b/public/safari-pinned-tab.svg new file mode 100644 index 0000000..3229aa1 --- /dev/null +++ b/public/safari-pinned-tab.svg @@ -0,0 +1,10 @@ + + + diff --git a/scripts/warm-cache.mjs b/scripts/warm-cache.mjs new file mode 100644 index 0000000..60432db --- /dev/null +++ b/scripts/warm-cache.mjs @@ -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); +}); diff --git a/src/components/BlogOverview.svelte b/src/components/BlogOverview.svelte index 6967fa6..472ab54 100644 --- a/src/components/BlogOverview.svelte +++ b/src/components/BlogOverview.svelte @@ -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) { + 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 @@
{#if tags.length > 0}
- diff --git a/src/components/blocks/ImageBlock.svelte b/src/components/blocks/ImageBlock.svelte index 572a34b..029dfc5 100644 --- a/src/components/blocks/ImageBlock.svelte +++ b/src/components/blocks/ImageBlock.svelte @@ -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 diff --git a/src/components/blocks/ImageGalleryBlock.svelte b/src/components/blocks/ImageGalleryBlock.svelte index 75f155d..e0c10c9 100644 --- a/src/components/blocks/ImageGalleryBlock.svelte +++ b/src/components/blocks/ImageGalleryBlock.svelte @@ -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} -

Keine Bilder in der Galerie.

+

{t(T.image_gallery_empty)}

{:else if withUrl.length === 1}