--- name: frontend-architecture description: Understand the frontend architecture — custom SPA routing, state management, Svelte 5 patterns, API layer, error handling, and i18n. Use when working on routing logic, navigation, stores, or understanding how the frontend fits together. --- # frontend-architecture ## When to use this skill Use this skill when: - Understanding or modifying the SPA routing mechanism - Working with stores or state management - Debugging navigation issues - Adding new Svelte 5 reactive patterns - Understanding the API layer and error handling - Working with i18n / multi-language features --- ## Routing: custom SPA router This project uses a **custom SPA router** — NOT SvelteKit, NOT file-based routing. Pages are CMS-managed content entries loaded by path. ### Architecture ``` Browser URL change ↓ history.pushState / replaceState (proxied in store.ts) ↓ $location store updates (path, search, hash) ↓ App.svelte $effect reacts to $location.path ↓ loadContent(lang, routePath) → API call: getCachedEntries("content", { lang, path, active: true }) ↓ ContentEntry.blocks[] → BlockRenderer.svelte → individual block components ``` ### Key files | File | Responsibility | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `frontend/src/lib/store.ts` | Proxies `history.pushState`/`replaceState` → updates `$location` writable store. Handles `popstate` for back/forward. | | `frontend/src/lib/navigation.ts` | `spaNavigate(url, options)` — the programmatic navigation API. Also: `initScrollRestoration()`, `spaLink` action, hash parsing. | | `frontend/src/lib/i18n.ts` | Language routing: `extractLanguageFromPath()`, `stripLanguageFromPath()`, `localizedPath()`, `currentLanguage` derived store, `ROUTE_TRANSLATIONS`. | | `frontend/src/App.svelte` | Reacts to `$location.path` + `$currentLanguage`, loads content via API, passes blocks to `BlockRenderer`. | | `frontend/src/blocks/BlockRenderer.svelte` | Maps `block.type` to Svelte components. | ### How the location store works `store.ts` wraps `history.pushState` and `history.replaceState` with a `Proxy`: ```typescript // Simplified — see store.ts for full implementation history.pushState = new Proxy(history.pushState, { apply: (target, thisArg, args) => { // Update $location store BEFORE the actual pushState publishLocation(args[2]) // args[2] = URL Reflect.apply(target, thisArg, args) }, }) ``` This means **any** `pushState`/`replaceState` call (from `spaNavigate`, `` clicks, or third-party code) automatically updates `$location`. The `popstate` event (back/forward buttons) also triggers `publishLocation()`. ### URL structure ``` /{lang}/{path} ↓ ↓ de /ueber-uns Example: /de/ueber-uns → lang="de", routePath="/ueber-uns" /en/about → lang="en", routePath="/about" /de/ → lang="de", routePath="/" ``` Root `/` redirects to `/{browserLanguage}/` via `getBrowserLanguage()`. ### Navigation API ```typescript import { spaNavigate } from "./lib/navigation" // Basic navigation (creates history entry, scrolls to top) spaNavigate("/de/kontakt") // Replace current entry (no back button) spaNavigate("/de/suche", { replace: true }) // Keep scroll position spaNavigate("/de/produkte#filter=shoes", { noScroll: true }) // With state object spaNavigate("/de/produkt/123", { state: { from: "search" } }) ``` ### SPA link action For `` elements, use the `spaLink` action instead of `spaNavigate`: ```svelte Kontakt Suche ``` The action intercepts clicks (respecting modifier keys, external links, `target="_blank"`) and calls `spaNavigate` internally. ### BrowserSync SPA fallback In development, BrowserSync uses `connect-history-api-fallback` to serve `index.html` for all routes, enabling client-side routing. In production, the webserver or tibi-server handles this. ### Localized route translations For translated URL slugs (e.g. `/ueber-uns` ↔ `/about`), configure `ROUTE_TRANSLATIONS` in `frontend/src/lib/i18n.ts`: ```typescript export const ROUTE_TRANSLATIONS: Record> = { about: { de: "ueber-uns", en: "about" }, contact: { de: "kontakt", en: "contact" }, // Add more as needed } ``` --- ## State management The project uses **Svelte writable/derived stores** (not a centralized state library). ### Store inventory | Store | File | Purpose | | ---------------------- | ---------------------- | ----------------------------------------------------------------------------------- | | `location` | `lib/store.ts` | Current URL state (path, search, hash, push/pop flags) | | `mobileMenuOpen` | `lib/store.ts` | Whether mobile hamburger menu is open | | `currentContentEntry` | `lib/store.ts` | Currently displayed page's `translationKey`, `lang`, `path` (for language switcher) | | `previousPath` | `lib/store.ts` | Previous URL path (for conditional back buttons) | | `apiBaseOverride` | `lib/store.ts` | Override API base URL (used by admin module) | | `cookieConsentVisible` | `lib/store.ts` | Whether cookie consent banner is showing | | `currentLanguage` | `lib/i18n.ts` | Derived from `$location.path` — current language code | | `selectedLanguage` | `lib/i18n.ts` | Writable — synced with `currentLanguage` on navigation | | `activeRequests` | `lib/requestsStore.ts` | Number of in-flight API requests (drives `LoadingBar`) | ### Pattern: creating a new store ```typescript // In lib/store.ts or a dedicated file import { writable, derived } from "svelte/store" // Simple writable export const myStore = writable(initialValue) // Derived from other stores export const myDerived = derived(location, ($loc) => { return computeFromPath($loc.path) }) ``` --- ## Svelte 5 patterns used in this project This project uses **Svelte 5 with Runes**. Key patterns: ### Component props ```svelte ``` ### Reactive state ```svelte ``` ### SSR-safe code ```svelte ``` ### Svelte stores in Svelte 5 Stores (`writable`, `derived`) still work in Svelte 5. Use `$storeName` syntax in components: ```svelte

Current path: {$location.path}

``` --- ## API layer ### Core function: `api()` Located in `frontend/src/lib/api.ts`. Features: - **Request deduplication** — identical concurrent GETs share one promise - **Loading indicator** — drives `activeRequests` store → `LoadingBar` - **Build-version check** — auto-reloads page when server build is newer - **Mock interceptor** — when `__MOCK__` is `true`, routes requests to `frontend/mocking/*.json` - **Sentry integration** — span instrumentation (when enabled) ### Usage patterns ```typescript import { api, getCachedEntries, getCachedEntry, getDBEntries, postDBEntry } from "./lib/api" // Cached (1h TTL, for read-heavy data) const pages = await getCachedEntries<"content">("content", { lang: "de", active: true }) const page = await getCachedEntry<"content">("content", { path: "/about" }) // Uncached const items = await getDBEntries<"content">("content", { type: "blog" }, "sort", 10) // Write const result = await postDBEntry("content", { name: "New Page", active: true }) // Raw API call const { data, count } = await api("mycollection", { filter: { active: true }, limit: 20 }) ``` ### Error handling ```typescript try { const result = await api("content", { filter: { path: "/missing" } }) } catch (err) { // err has shape: { response: Response, data: { error: string } } const status = (err as any)?.response?.status // e.g. 404 const message = (err as any)?.data?.error // e.g. "Not found" // For user-visible errors: import { addToast } from "./lib/toast" addToast({ type: "error", message: "Seite nicht gefunden" }) // For debugging: console.error("[MyComponent] API error:", err) } ``` ### Error handling guidelines | Scenario | Approach | | --------------------------------- | ------------------------------------------------- | | API error the user should see | `addToast({ type: "error", message })` | | API error that's silently handled | `console.error(...)` for dev logging | | Unexpected error in production | Sentry captures automatically (when enabled) | | Missing content / 404 | Set `notFound = true` → renders `NotFound.svelte` | | Network error / offline | Loading bar stays visible; user can retry | ### API request flow (client-side) ``` Component calls api() / getCachedEntries() ↓ Deduplication check (skip if signal provided) ↓ incrementRequests() → LoadingBar appears ↓ __MOCK__? → mockApiRequest() (in-memory JSON filtering) ↓ (else) apiRequest() from api/hooks/lib/ssr (shared with SSR bundle) ↓ fetch("${apiBaseURL}${endpoint}?filter=...&sort=...&limit=...") ↓ Parse response → check X-Build-Time header ↓ decrementRequests() → LoadingBar disappears ↓ Return { data, count, buildTime } ``` --- ## i18n system ### Architecture - **svelte-i18n** for translation strings (`$_("key")`) - **URL-based language routing** (`/{lang}/...`) - **Lazy-loaded locale files** in `frontend/src/lib/i18n/locales/{lang}.json` - **Route translations** for localized URL slugs ### Adding a new language 1. Create locale file: `frontend/src/lib/i18n/locales/fr.json` 2. Add to `SUPPORTED_LANGUAGES` in `frontend/src/lib/i18n.ts`: ```typescript export const SUPPORTED_LANGUAGES = ["de", "en", "fr"] as const ``` 3. Add label: `export const LANGUAGE_LABELS = { ..., fr: "Français" }` 4. Add route translations for the new language in `ROUTE_TRANSLATIONS`. 5. Register in `frontend/src/lib/i18n/index.ts` (lazy loader). 6. Create content entries with `lang: "fr"` in the CMS. ### Translation usage ```svelte

{$_("hero.title")}

{$_("hero.subtitle", { values: { name: "World" } })}

``` --- ## Common pitfalls - **Never `spaNavigate()` in SSR** — always guard with `typeof window !== "undefined"`. - **Store subscriptions in modules** — if subscribing to stores outside components, remember to unsubscribe to prevent memory leaks. - **API PUT returns only changed fields** — don't expect a full object back from PUT requests. - **`_id` not `id` for filters** — API filters use MongoDB's `_id`, but response objects may have both `id` and `_id`. - **`$location` strips trailing slashes** — `/about/` becomes `/about` (except root `/`). - **Content cache is 1 hour** — `getCachedEntries` caches in memory for 1h. For admin previews, use `getDBEntries` (uncached).