- 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.
13 KiB
name, description
| name | description |
|---|---|
| frontend-architecture | 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:
// 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
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:
<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:
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
// 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
<script lang="ts">
// Rune syntax — replaces export let
let { block, className = "" }: { block: ContentBlockEntry; className?: string } = $props()
</script>
Reactive state
<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
<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:
<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
activeRequestsstore →LoadingBar - Build-version check — auto-reloads page when server build is newer
- Mock interceptor — when
__MOCK__istrue, routes requests tofrontend/mocking/*.json - Sentry integration — span instrumentation (when enabled)
Usage patterns
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
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
- Create locale file:
frontend/src/lib/i18n/locales/fr.json - Add to
SUPPORTED_LANGUAGESinfrontend/src/lib/i18n.ts:export const SUPPORTED_LANGUAGES = ["de", "en", "fr"] as const - Add label:
export const LANGUAGE_LABELS = { ..., fr: "Français" } - Add route translations for the new language in
ROUTE_TRANSLATIONS. - Register in
frontend/src/lib/i18n/index.ts(lazy loader). - Create content entries with
lang: "fr"in the CMS.
Translation usage
<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 withtypeof 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.
_idnotidfor filters — API filters use MongoDB's_id, but response objects may have bothidand_id.$locationstrips trailing slashes —/about/becomes/about(except root/).- Content cache is 1 hour —
getCachedEntriescaches in memory for 1h. For admin previews, usegetDBEntries(uncached).