# Kochwas — Architektur ## Stack - **SvelteKit 2** + **Svelte 5 Runes** (`$state`, `$derived`, `$effect`, `$props`) - **TypeScript strict** - **SQLite** über `better-sqlite3` (synchron, native Binding arm64) - **FTS5** Virtual Tables mit BM25-Ranking für Volltext-Suche - **linkedom** für HTML-Parsing (JSON-LD-Extraktion, og:image-Enrichment) - **zod** für API-Schema-Validierung - Adapter: `@sveltejs/adapter-node` → Node 22 Alpine im Container ## Top-Level-Struktur ``` src/ ├── app.html, app.d.ts # Shell + Env-Types ├── service-worker.ts # PWA-Shell ├── lib/ │ ├── client/ # reaktive Stores (Profile, Search, Wishlist, PWA, Network, Sync, Toast, Install, Confirm, API-Fetch-Wrapper) │ ├── components/ # Svelte-Komponenten: │ │ # - Recipe: RecipeView, RecipeEditor + Editor-Sub-Components │ │ # (IngredientRow, StepList, ImageUploadBox, TimeDisplay, recipe-editor-types) │ │ # - UI-Shell: ConfirmDialog, ProfileSwitcher, SyncIndicator, Toast, UpdateToast │ │ # - Search: SearchFilter, SearchLoader, StarRating │ ├── recipes/ # shared: Portionen-Scaler (Client UND Server) │ ├── server/ # nur Server-Code (nie in Client-Bundle!) │ │ ├── db/ # openDb, Migrations, DB-Singleton │ │ ├── domains/ # Whitelist-Repo │ │ ├── http.ts # fetch-Wrapper mit Timeout / maxBytes / extraHeaders │ │ ├── images/ # Download, SHA256-Dedup, Save │ │ ├── parsers/ # json-ld-recipe.ts, iso8601-duration.ts │ │ ├── profiles/ # Profile-Repo │ │ ├── recipes/ # importer, actions, repository, search-local │ │ ├── search/ # searxng.ts (Web-Suche + Thumbnail-Cache) │ │ ├── wishlist/ # Repo │ │ └── backup/ # ZIP-Export via archiver, Import via yauzl │ ├── quotes.ts # 150 Flachwitze für die Homepage │ └── types.ts # shared types └── routes/ ├── +layout.svelte # Header, Confirm-Dialog-Mount, Header-Search-Dropdown ├── +page.svelte # Home: Hero + Live-Search + Zuletzt-hinzugefügt ├── recipes/[id]/ # Rezept-Detail ├── preview/ # Vorschau vor dem Speichern ├── wishlist/ ├── admin/ # Whitelist, Profile, Backup/Restore ├── images/[filename] # Statische Auslieferung lokaler Bilder └── api/ # REST-Endpoints ``` ## Datenfluss ### Import (User klickt auf Web-Treffer) 1. User klickt auf Web-Hit → `/preview?url=...` 2. `/api/recipes/preview` → `importer.ts` lädt HTML, `parseHTML` von linkedom, `json-ld-recipe.ts` extrahiert `Recipe`-Objekt mit **externer** Bild-URL 3. Preview-Seite rendert das `Recipe` via `RecipeView.svelte` (erkennt externe URL und lädt direkt vom Original-CDN) 4. User klickt „Speichern" → `/api/recipes/import` → Importer lädt Bild (`images/downloader.ts`), SHA256-Hash-Dedup, speichert lokal, INSERT in `recipe` + `ingredient` + `step` + `recipe_tag` 5. Redirect zu `/recipes/[id]` ### Web-Suche Die gesamte Live-Search-Logik ist im `SearchStore` (`src/lib/client/search.svelte.ts`) gekapselt: Debounce, Race-Guard, Pagination, Web-Fallback, Snapshot/Restore für Back-Nav. Sowohl Header-Dropdown (`+layout.svelte`) als auch Startseite (`+page.svelte`) teilen sich die Klasse mit unterschiedlicher `filterParam`-Quelle. 1. User tippt → 300 ms Debounce → `/api/recipes/search?q=...` (lokal FTS5) 2. Wenn 0 Treffer: automatisch `/api/recipes/search/web?q=...` 3. `searxng.ts` → SearXNG-API mit `site:domain OR site:domain2 ...`-Filter aus Whitelist 4. Filtert Non-Recipe-Pfade (Foren, Magazin, Listings) via `NON_RECIPE_PATH_PATTERNS` 5. Pro Treffer: parallel (max 6) `enrichThumbnail`: - SQLite-Cache hit → return - Sonst: Seite holen (max 512 KB, 4 s), `extractPageImage`: og:image → link rel=image_src → JSON-LD → erstes Content-img - Ergebnis (auch null) in `thumbnail_cache` persistieren (30 Tage TTL) - **Überschreibt** bestehendes SearXNG-Thumbnail, weil das meist LowRes ist ### Confirm / Alert Promise-basiert statt `window.confirm`/`window.alert`: ```ts import { confirmAction, alertAction } from '$lib/client/confirm.svelte'; if (await confirmAction({ title: 'Löschen?', destructive: true })) { /* ... */ } await alertAction({ title: 'Fehler', message: 'xyz' }); ``` Gemeinsame Komponente `ConfirmDialog.svelte` wird im Root-Layout einmal gemountet. Store (`confirmStore`) hält die Promise-Resolve-Funktion, Komponente rendert nur wenn `pending !== null`. ## Design-Entscheidungen - **Kein Login, nur Profile**: Profile werden beim Start gewählt, in localStorage persistiert. Actions (Rating, Favorit, Cooked, Kommentar) brauchen aktives Profil → sonst Custom-Alert „Bitte Profil wählen". - **FTS5 als Haupt-Suche**: statt externer Search-Engine-DB. Passt zu SQLite-only-Stack. - **JSON-LD first**: Alle drei Ziel-Domains (Chefkoch, Emmi, Experimente) liefern `schema.org/Recipe` im JSON-LD. LLM-Fallback war geplant, aktuell nicht nötig. - **SearXNG als Such-Engine**: Self-hosted, daher keine API-Keys. Das Bot-Detection-Theater wird mit gesetzten `X-Forwarded-For`-Headern aus Docker-IPs umgangen. - **Thumbnail-Cache in SQLite**: 30 Tage TTL (per `KOCHWAS_THUMB_TTL_DAYS`). Negative Einträge (Seite ohne Bild) werden auch gecacht. - **Svelte 5 Runes** — kein `$:` mehr, keine alten Stores außer `$app/stores`. Neue Stores via Klasse mit `$state`-Feldern. Form-lokale Snapshots in Edit-Komponenten mit `untrack()` aus `svelte`, damit Prop-Updates nicht laufende Edits überschreiben. - **Zutaten-Sektionen** (ab Migration 012, v1.2): `ingredient.section_heading TEXT NULL`. Ist das Feld gesetzt, startet an dieser Zeile eine neue Sektion — folgende Zutaten gehören dazu, bis die nächste Zeile wieder ein Heading hat. Kein zweites Tabellen-Modell, Ordnung bleibt `position`. Importer setzt immer `null` (schema.org/Recipe hat das Konzept nicht). Editor erlaubt Inline-Insert via `Abschnitt hinzufügen`-Button vor jeder Zeile; leeres Heading wird beim Save zu `null` normalisiert. - **Service Worker** rein zum Shell-Cachen für Offline-First-PWA, kein intelligentes Cache-Matching (keine externe Rezept-Seiten). ## Migrations-Workflow Bei Schema-Änderung: 1. Neue Datei `src/lib/server/db/migrations/00N_beschreibung.sql` — nächste freie Nummer 2. SQL sollte nicht-destruktiv sein (nur `CREATE`, `ALTER ADD`); keine `DROP` auf bestehende Daten 3. `migrate.ts` liest via Vite-Glob und führt neue Einträge aus (über `schema_migrations`-Tabelle getrackt) 4. Tests anpassen: `db idempotent` zählt vorher/nachher — bleibt automatisch grün ## Test-Strategie - **Unit**: `tests/unit/` — pure Funktionen + Client-Stores via jsdom (json-ld-recipe, iso8601-duration, quotes-random, scaler, ingredient-parser, SearchStore, PWA/Toast/Sync-Stores, SW-Logik). - **Integration**: `tests/integration/` — mit `openInMemoryForTest()` fresh SQLite pro Test. Externe HTTP via `node:http`-TestServer auf Port 0 gemockt. - **E2E local**: `tests/e2e/` — Playwright gegen `npm run preview`, deckt PWA-Offline-Lifecycle ab (`offline.spec.ts`). - **E2E remote**: `tests/e2e/remote/` — Playwright gegen `kochwas-dev.siegeln.net` via `playwright.remote.config.ts` (`workers:1`, `serviceWorkers:block`). Testet Live-API-Verhalten, inkl. destruktiver CRUD-Flows (Recipes, Kommentare, Favoriten). Run: `npm run test:e2e:remote`. Siehe `tests/e2e/remote/fixtures/` für Profile-Setup + idempotente API-Cleanup-Helper. - **Keine Svelte-Component-Unit-Tests** (bewusst, Aufwand/Nutzen stimmt nicht; UI wird per E2E und manuell getestet). - **Vor Commit**: `npm test && npm run check` muss grün sein. Vor Merge zu main: zusätzlich `npm run test:e2e:remote`. ### Service Worker (PWA) `src/service-worker.ts` ist SvelteKits eingebauter SW-Slot. Er nutzt `$service-worker` (`build`, `files`, `version`) für den App-Shell-Cache und implementiert eigene Logik für: - **Pre-Cache** (alle Rezepte + Bilder beim Initial-Sync), über paginierten Fetch von `/api/recipes/all`. - **Delta-Sync** beim App-Start (diff vs. Cache-Manifest, nur Delta laden). - **Drei Cache-Strategien** (dispatcht per `resolveStrategy`): Shell = cache-first, Daten = SWR, Bilder = cache-first. - **Message-Protokoll** (`sync-start`, `sync-progress`, `sync-done`, `sync-error`) zwischen SW und Client. Reine Logik-Einheiten (testbar, Unit-Tests in `tests/unit/`): - `src/lib/sw/cache-strategy.ts` — `resolveStrategy({url, method})` → `'shell' | 'swr' | 'images' | 'network-only'` - `src/lib/sw/diff-manifest.ts` — `diffManifest(current, cached)` → `{toAdd, toRemove}` Client-Stores (SSR-safe via typeof-Guards): - `src/lib/client/network.svelte.ts` — `navigator.onLine` + Events. - `src/lib/client/sync-status.svelte.ts` — SW-Message-Spiegel, `lastSynced` in localStorage. - `src/lib/client/toast.svelte.ts` — Toast-Queue für Offline-Fehler + Sync-Meldungen. - `src/lib/client/install-prompt.svelte.ts` — fängt `beforeinstallprompt`, erkennt Plattform. - `src/lib/client/sw-register.ts` — registriert den SW, leitet Messages an den Sync-Status-Store. - `src/lib/client/require-online.ts` — Helper für Schreib-Aktionen (Toast statt stillem Fail). UI-Komponenten: - `src/lib/components/SyncIndicator.svelte` — Pill unten rechts (Sync-Fortschritt / Offline-Status). - `src/lib/components/Toast.svelte` — Top-Center-Toast-Renderer. Admin-UI: `src/routes/admin/app/+page.svelte` mit Install-Button, manuellem Sync-Trigger, Cache-Reset. E2E-Tests: `tests/e2e/offline.spec.ts` — Playwright setzt das Netzwerk offline und prüft Navigation/Toast/Indikator-Verhalten. ## Was später kommt (laut Spec, aktuell nicht implementiert) - LLM-Fallback für nicht-JSON-LD-Seiten - Print-View ist nur rudimentär, könnte ein eigenes Print-CSS bekommen - Tag-Editor im Admin - Export-Format JSON zusätzlich zu ZIP