Files
tibi-svelte-starter/.agents/skills/frontend-architecture/SKILL.md
T

511 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
- Understanding how SSR and SPA loading share one app-level data path
---
## 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()`.
### SSR interaction with routing
This frontend is not just an SPA. The same top-level app also participates in SSR.
- `frontend/src/ssr.ts` is intentionally thin and should mostly bootstrap locale state and call `render(App, { props: { url } })`.
- `App.svelte` owns page loading for both browser and SSR.
- Browser navigation triggers page loading from `$effect`.
- SSR triggers the same page-loading path directly inside `typeof window === "undefined"`.
This means route changes, i18n path handling, and content-loading behavior must be reasoned about together. If a route works in the browser but SSR returns empty content or 404, inspect the mapping between:
- public URL (`/de/...`)
- stripped route path (`/...`)
- `content.path` in the DB
- `api/hooks/config.js` SSR route validation
### 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
}
```
Keep in mind that these translations affect the public URL shape and therefore also the SSR route-validation layer. Changing localized slugs is not purely a frontend concern.
---
## 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 entry data such as `translationKey`, `lang`, and `path` |
| `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)
### Shared browser/SSR transport
The project intentionally shares the low-level API transport between browser and SSR via `api/hooks/lib/ssr`.
- In the browser, it eventually becomes `fetch(...)`.
- In SSR, `apiRequest(...)` delegates to `context.ssrRequest(...)`.
- GET responses reached during SSR are written into `window.__SSR_CACHE__` for hydration.
This is why SSR can preload both content and navigation without building a separate frontend-only data layer.
### 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 })
```
### `aggregate` for sub-queries
The server supports an `aggregate` parameter to compute reverse aggregates against another collection and store the result under `_aggregate`. This efficiently calculates counts, sums, existence, etc. without embedding the target documents.
```typescript
const res = await api<MyEntry[]>("mycollection", {
filter: { active: true },
params: {
// String syntax: "collection:foreignField:op:valueField:as"
aggregate: "posts:categoryId:count",
// JSON syntax for advanced use cases (custom source field, filtering)
aggregate: JSON.stringify({
collection: "comments",
foreignField: "entryId",
op: "count",
filter: { approved: true },
as: "approvedComments",
}),
},
})
// Result in res.data[0]._aggregate.postsCount and res.data[0]._aggregate.approvedComments
```
Available operations: `count` (default), `exists`, `sum`, `avg`, `min`, `max`.
### 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)
}
```
### `aggregate` for sub-queries
The server supports an `aggregate` parameter to compute reverse aggregates against another collection and store the result under `_aggregate`. This efficiently calculates counts, sums, existence, etc. without embedding the target documents.
```typescript
const res = await api<MyEntry[]>("mycollection", {
filter: { active: true },
params: {
// String syntax: "collection:foreignField:op:valueField:as"
aggregate: "posts:categoryId:count",
// JSON syntax for advanced use cases (custom source field, filtering)
aggregate: JSON.stringify({
collection: "comments",
foreignField: "entryId",
op: "count",
filter: { approved: true },
as: "approvedComments",
}),
},
})
// Result in res.data[0]._aggregate.postsCount and res.data[0]._aggregate.approvedComments
```
Available operations: `count` (default), `exists`, `sum`, `avg`, `min`, `max`.
### 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 }
```
### `_count` endpoint
Der tibi-server stellt einen dedizierten `_count`-Endpoint bereit, der **nur** `{"count": N}` zurückgibt kein Data-Transfer:
```
GET /api/{collection}/_count?filter={"active":true,"category":"<id>"}
→ {"count": 8}
```
Der Endpoint wird durch den BrowserSync-Proxy korrekt geroutet (`/api``/api/v1/_/{namespace}`).
**Frontend-Aufruf:**
```ts
const res = await api<{ count: number }>("machines/_count", {
filter: { active: true, category: catId },
})
// res.data.count === 8
```
Das ist effizienter als `count=1&limit=1`, weil keine Collection-Objekte serialisiert/übertragen werden.
### `select` für schlanke Queries
Der tibi-server unterstützt einen `select`-Parameter als Komma-Liste der gewünschten Felder. Nicht gelistete Felder werden nicht übertragen:
```ts
const res = await api<MachineEntry[]>("machines", {
filter: { active: true, category: catId },
sort: "sortOrder",
limit: 20,
params: {
lookup: "images:medialib",
select: "name,slug,tagline,priceFrom,weight,sku,images",
},
})
```
Nicht aufgeführte Felder (z.B. `description`, `specs`) entfallen spart Bandbreite bei Listen/Grids. `_lookup` und `id` werden automatisch ergänzt.
**Wichtig:** `select` muss als String im `params`-Objekt übergeben werden (der `apiRequest` hängt es als Query-Parameter an). Es wird direkt an den tibi-server durchgereicht.
---
## 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 only have `id` as string via API.
- **`$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).
- **`$effect` alone is not SSR** — server-side rendering must trigger the same data path explicitly outside browser-only reactive effects.
- **A rendered shell is not enough** — always verify that SSR HTML actually contains page-critical content and navigation.
## Cart persistence (localStorage)
For SSR-safe cart/inquiry persistence:
```ts
let cartItems = $state<any[]>([])
// Laden
$effect(() => {
try {
if (typeof localStorage !== "undefined") {
const saved = localStorage.getItem("cart_key")
if (saved) cartItems = JSON.parse(saved)
}
} catch {}
})
// Speichern
$effect(() => {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem("cart_key", JSON.stringify(cartItems))
}
} catch {}
})
```
Immer mit `typeof localStorage !== "undefined"` für SSR-Sicherheit.