- Introduced `admin-ui-config` skill for configuring admin UI for collections. - Added `content-authoring` skill detailing page and block creation in the CMS. - Included `frontend-architecture` skill explaining custom SPA routing and state management. - Updated `AGENTS.md` to reference new skills and provide infrastructure prerequisites. - Enhanced `frontend/AGENTS.md` with routing details and SPA navigation information.
361 lines
13 KiB
Markdown
361 lines
13 KiB
Markdown
---
|
|
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`, `<a>` 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 `<a>` elements, use the `spaLink` action instead of `spaNavigate`:
|
|
|
|
```svelte
|
|
<script>
|
|
import { spaLink } from "../lib/navigation"
|
|
</script>
|
|
|
|
<a href="/de/kontakt" use:spaLink>Kontakt</a>
|
|
<a href="/de/suche" use:spaLink={{ replace: true }}>Suche</a>
|
|
```
|
|
|
|
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<string, Record<SupportedLanguage, string>> = {
|
|
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<MyType>(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
|
|
<script lang="ts">
|
|
// Rune syntax — replaces export let
|
|
let { block, className = "" }: { block: ContentBlockEntry; className?: string } = $props()
|
|
</script>
|
|
```
|
|
|
|
### Reactive state
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
// Local reactive state (replaces let x; with $: reactivity)
|
|
let count = $state(0)
|
|
let items = $state<Item[]>([])
|
|
|
|
// Computed/derived values (replaces $: derived = ...)
|
|
let total = $derived(items.reduce((sum, i) => sum + i.price, 0))
|
|
|
|
// Side effects (replaces $: { ... } reactive blocks)
|
|
$effect(() => {
|
|
// Runs when dependencies change
|
|
console.log("count changed:", count)
|
|
})
|
|
</script>
|
|
```
|
|
|
|
### SSR-safe code
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { untrack } from "svelte"
|
|
|
|
// Guard browser-only APIs
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
|
}
|
|
|
|
// untrack: capture initial value without creating reactive dependency
|
|
// Used in App.svelte for SSR initial URL
|
|
untrack(() => {
|
|
if (url) { /* set initial location */ }
|
|
})
|
|
</script>
|
|
```
|
|
|
|
### Svelte stores in Svelte 5
|
|
|
|
Stores (`writable`, `derived`) still work in Svelte 5. Use `$storeName` syntax in components:
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { location } from "./lib/store"
|
|
// $location is reactive — auto-subscribes in Svelte 5
|
|
</script>
|
|
<p>Current path: {$location.path}</p>
|
|
```
|
|
|
|
---
|
|
|
|
## 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<MyType[]>("mycollection", { filter: { active: true }, limit: 20 })
|
|
```
|
|
|
|
### Error handling
|
|
|
|
```typescript
|
|
try {
|
|
const result = await api<ContentEntry[]>("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
|
|
<script>
|
|
import { _ } from "./lib/i18n/index"
|
|
</script>
|
|
|
|
<h1>{$_("hero.title")}</h1>
|
|
<p>{$_("hero.subtitle", { values: { name: "World" } })}</p>
|
|
```
|
|
|
|
---
|
|
|
|
## 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).
|