Darstellung: Inline-Code-Chips fuer Dark Mode, 58 Seiten auf .mdx (Komponenten rendern jetzt)
Befund 1 (Nutzer): Inline-Code war im Dark Mode kaum lesbar.
- src/styles/custom.css: eigene Chip-Farben (--tibi-inline-*), Rahmen, nowrap in der ersten Tabellenspalte
- gemessen: Text/Chip 10,2:1 dunkel, 12,4:1 hell
Befund 2 (beim Nachmessen gefunden): 58 Seiten waren .md - Starlight-Komponenten wurden NICHT gerendert
(rohe <Aside>/<Steps>-Tags, import-Zeile als sichtbarer Text auf 57 Seiten).
- alle 58 Seiten auf .mdx umgestellt und MDX-konform gemacht (Imports, Block-Tags in eigenen Zeilen,
HTML-Kommentare -> {/* */}, geschweifte Klammern escaped)
- nachher: 428 Asides, 87 Steps, 158 Tab-Gruppen gerendert, 0 geleakte Imports
- fehlender Import in reference/hooks-daten ergaenzt
Regressionsschutz: audit-docs.mjs prueft jetzt Komponente-ohne-Import, .md-mit-Komponente,
geleakte Imports und rohe Tags im gebauten dist/. AGENTS.md 3 dokumentiert die .mdx-Regel.
Nebenfund: de.json mit ASCII-Umschreibungen -> Issue tibi-admin-nova #6
Build: 71 Seiten gruen, Audit 7 legitime Platzhalter-Marker
This commit is contained in:
1 parent
06c5f0d8ea
commit
dcdb6ee637
64 files changed
+246
-104
No files matched your search
@@ -0,0 +1,212 @@
|
||||
---
|
||||
title: 'Website mit SSR'
|
||||
description: 'Routen, Daten-Lookups, Blöcke, SSR-Bundle, Cache und Invalidierung im Starter.'
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem, Steps, FileTree } from '@astrojs/starlight/components';
|
||||
|
||||
Deine Website ist eine Svelte-5-App mit Server-Side-Rendering.
|
||||
Der **tibi-server** rendert sie pro URL vor und legt das HTML in der Sammlung `ssr` ab.
|
||||
Container: Seiten-Routen, Daten-Lookups, Blöcke, SSR-Bundle, Cache.
|
||||
|
||||
<FileTree>
|
||||
|
||||
- frontend/src/
|
||||
- App.svelte
|
||||
- ssr.ts
|
||||
- blocks/
|
||||
- frontend/spa.html
|
||||
- api/templates/spa.html
|
||||
- api/collections/ssr.yml
|
||||
- api/hooks/
|
||||
- lib/app.server.js
|
||||
- lib/utils.js
|
||||
- ssr/get_read.js
|
||||
- ssr/post_bind.js
|
||||
- clear_cache.js
|
||||
|
||||
</FileTree>
|
||||
|
||||
## Routen
|
||||
|
||||
| URL | Was passiert |
|
||||
|---|---|
|
||||
| `/` | Leitet auf die Browser-Sprache um (`/de/` oder `/en/`) |
|
||||
| `/:lang/*` | z. B. `/de/`, `/de/ueber-uns` — Sprache plus Seitenpfad |
|
||||
| Unbekannter Pfad | `NotFound.svelte` (404) |
|
||||
|
||||
- `content.path` steht **ohne** Sprachpräfix in der Datenbank (`/`, nicht `/de/`).
|
||||
- Pfad-Regel: `^/[a-z0-9-/]*$` — klein, keine Umlaute.
|
||||
- `translationKey` + `lang` und `path` + `lang` sind je eindeutig. Setze immer `translationKey`, sonst kollidiert die zweite Seite auf `null/de`.
|
||||
- Sprache steckt im Pfad (`extractLanguageFromPath`), Titel und Meta kommen aus `contentEntry.meta`.
|
||||
|
||||
## Daten laden und Lookups
|
||||
|
||||
`App.svelte` lädt pro Seite drei Dinge über `getCachedEntries`:
|
||||
|
||||
```ts title="frontend/src/App.svelte — die drei Abfragen"
|
||||
const CONTENT_MEDIA_LOOKUP = ["blocks.heroImage.image:medialib", "blocks.image:medialib"].join(",")
|
||||
const NAVIGATION_CONTENT_LOOKUP = "elements.page:content"
|
||||
|
||||
// Inhalt: genau ein Treffer (limit 1), nur aktiv, mit Medien-Lookup und Kommentar-Zähler
|
||||
getCachedEntries("content", {
|
||||
filter: { lang, path: routePath, active: true },
|
||||
limit: 1, sort: "sort",
|
||||
aggregate: { collection: "comment", foreignField: "contentId", op: "count",
|
||||
filter: { active: true }, as: "commentCount" },
|
||||
lookup: CONTENT_MEDIA_LOOKUP,
|
||||
})
|
||||
// Navigation: je ein Eintrag header/footer mit aufgelösten Seitenpfaden
|
||||
getCachedEntries("navigation", {
|
||||
filter: { type: "header", language: lang }, lookup: NAVIGATION_CONTENT_LOOKUP, ...
|
||||
})
|
||||
```
|
||||
|
||||
| Lookup | Wirkung |
|
||||
|---|---|
|
||||
| `blocks.heroImage.image:medialib`, `blocks.image:medialib` | Bild-IDs werden zu `_lookup`-Objekten mit `file.src` |
|
||||
| `elements.page:content` | Navigations-Elemente bekommen den Zielpfad (`_lookup.page.path`) |
|
||||
| Kommentar-Aggregat | `commentCount` ohne zweite Abfrage |
|
||||
|
||||
`sort: "sort"` sortiert nach dem Feld `sort` (manuelle Reihenfolge, aufsteigend) — der Parametername und der Feldname heißen hier zufällig gleich.
|
||||
|
||||
<Aside type="caution">
|
||||
Öffentlich sichtbar ist nur `active: true` — das gilt auch für `navigation` und `medialib` (kanonische Erklärung unter [Rechte und Tokens](/projekt/rechte-tokens/)).
|
||||
</Aside>
|
||||
|
||||
## Blöcke
|
||||
|
||||
`BlockRenderer.svelte` mappt `content.blocks` auf Komponenten:
|
||||
|
||||
| `type` | Komponente | Hinweis |
|
||||
|---|---|---|
|
||||
| `hero` | `HeroBlock.svelte` | Ohne Bild: Verlauf (Gradient-Fallback) |
|
||||
| `features` | `FeaturesBlock.svelte` | Icons über `FeatureIcon.svelte` |
|
||||
| `richtext` | `RichtextBlock.svelte` | Bilder nur bei aufgelöstem `_lookup.image.file.src` |
|
||||
| `accordion` | `AccordionBlock.svelte` | Aufklapp-Sektionen |
|
||||
| `contact-form` | `ContactFormBlock.svelte` | Formular aus `widgets/` (`Form`, `Input`, `Select`, `Button`) |
|
||||
|
||||
- `hide: true` blendet einen Block aus.
|
||||
- Unbekannte Typen werden still übersprungen — nur auf `localhost` erscheint ein gelber Hinweis.
|
||||
- Bilder laufen ausschließlich über `MedialibImage.svelte` (`file.src` oder Data-URI, kein `?filter` auf Data-URIs).
|
||||
|
||||
## Eigener Block
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Feld in `api/collections/content.yml` unter `blocks` ergänzen: `type` plus Felder mit `dependsOn: $parent.type` — so zeigt der Editor nur passende Felder.
|
||||
2. Komponente in `frontend/src/blocks/` anlegen, z. B. `QuoteBlock.svelte`:
|
||||
- Props per Runen: `let { block } = $props()` — `block` nie destrukturieren.
|
||||
- Abgeleitete Werte mit `$derived`, Zustand mit `$state`.
|
||||
- SSR-sicher schreiben: jeder `window`-Zugriff braucht einen Guard (`typeof window !== "undefined"`).
|
||||
- Texte über `svelte-i18n` (`$_`), Styling Tailwind v4 (`bg-linear-to-*`, `aspect-video`, wichtiges `!` hinten: `bg-brand-600!`).
|
||||
3. Fall in `BlockRenderer.svelte` ergänzen:
|
||||
```svelte title="BlockRenderer.svelte — neuen Fall einhängen"
|
||||
{:else if block.type === "quote"}
|
||||
<QuoteBlock {block} />
|
||||
```
|
||||
4. Seite mit dem neuen Block anlegen und anonym prüfen (nächster Abschnitt).
|
||||
|
||||
</Steps>
|
||||
|
||||
## SSR-Bundle
|
||||
|
||||
Der Server ruft `app.default.render({ url })` auf. Den Wrapper liefert `frontend/src/ssr.ts` (Svelte-5-`render` aus `svelte/server`, i18n-Nachrichten synchron geladen, Sprache aus der URL).
|
||||
|
||||
```text title="SSR-Bundle: bauen, nie von Hand anfassen"
|
||||
frontend/src/ssr.ts ── esbuild (esbuild.config.server.js) ──▶ api/hooks/lib/app.server.js
|
||||
Svelte 5, generate: "server" gebaut, nie von Hand anfassen
|
||||
```
|
||||
|
||||
| Befehl | Wirkung |
|
||||
|---|---|
|
||||
| `yarn build` | Frontend-Bundle (`frontend/dist`) |
|
||||
| `yarn build:server` | SSR-Bundle nach `api/hooks/lib/app.server.js` (Ziel `esnext`, goja-kompatibel) |
|
||||
|
||||
Beide Builds schreiben `buildInfo` — der Server-Build überschreibt sie nie, sonst löst `checkBuildVersion` einen Schein-Reload aus.
|
||||
Nach jedem Frontend-Build: `yarn build:server` erneut laufen lassen und deployen (siehe [Veröffentlichen](/projekt/deploy/)).
|
||||
|
||||
## Vorlage und Cache-Sammlung
|
||||
|
||||
Quelldatei der Vorlage ist `frontend/spa.html`. `ci-modify-config.sh` ersetzt `__TIMESTAMP__` durch einen Build-Stempel und kopiert die Datei nach `api/templates/spa.html`.
|
||||
`get_read.js` liest sie pro Anfrage und füllt die Marker.
|
||||
|
||||
| Marker | Inhalt |
|
||||
|---|---|
|
||||
| `{/*HEAD*/}` | Head aus dem SSR-Render |
|
||||
| `{/*HTML*/}` | Gerendertes App-HTML |
|
||||
| `{/*PRELOAD*/}` | Preload-Hinweise (`scripts/preload-meta.sh`) |
|
||||
| `__TIMESTAMP__` | Build-Stempel für `/dist`-Assets (Cache-Buster, schon beim Kopieren ersetzt) |
|
||||
| `{/*SSR.ERROR*/}` | Render-Fehler als Kommentar (leer im Normalfall) |
|
||||
| `{/*SSR.COMMENT*/}` | Diagnose (`url`, `deps`) als Kommentar |
|
||||
| `<html lang>` | Sprache aus dem URL-Präfix (`de`/`en`, sonst `de`) |
|
||||
|
||||
Die Sammlung `ssr` (`api/collections/ssr.yml`) hält einen Eintrag je URL:
|
||||
|
||||
| Feld | Rolle |
|
||||
|---|---|
|
||||
| `path` | URL ohne Query, eindeutig (Unique-Index — parallele Writes gewinnt genau einer) |
|
||||
| `content` | Fertiges HTML aus der Vorlage |
|
||||
| `validUntil` | Ablaufdatum — der Hook setzt es derzeit auf `null`, Einträge leben bis zur Invalidierung |
|
||||
| `dependencies` | `col:<id>` (Detail) oder `col:*` (Liste), indexiert |
|
||||
| `_testdata` | Test-Marker, im Editor verborgen |
|
||||
|
||||
Schreiben und Löschen steuert `clear_cache.js` als `return`-Hook (`post`, `put`, `delete` u. a. in `content`, `navigation`, `medialib`, `comment`, `tag`) über `utils.clearSSRCache()`; `POST /api/ssr?clear=1` wischt alles (`post_bind.js`).
|
||||
|
||||
## Cache und Invalidierung
|
||||
|
||||
`api/hooks/ssr/get_read.js` rendert jede anonyme Anfrage — außer Tracking (`x-ssr-skip`) und Admins:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. URL normieren (Query ab, Schrägstrich ab, `/index` → `/`), per `ssrValidatePath` prüfen (404 oder kein Rendering).
|
||||
2. Cache in der Sammlung `ssr` suchen (`path` ist eindeutig). Treffer mit `X-SSR-Cache: true` ausliefern; abgelaufene (`validUntil`) löschen.
|
||||
3. Neu rendern, `window.__SSR_CACHE__` mit den gelesenen Einträgen einbetten, Abhängigkeiten als `col:id` (Detail) oder `col:*` (Liste) speichern. Gibt es `col:*`, fallen die `col:id` derselben Sammlung weg.
|
||||
4. Parallel geschriebene Duplikate (`duplicate key` auf `path`) werden geschluckt — der Eintrag existiert dann schon.
|
||||
|
||||
</Steps>
|
||||
|
||||
Invalidierung übernimmt `api/hooks/lib/utils.js` (`clearSSRCache`), verdrahtet über `clear_cache.js` als `return`-Hook (u. a. `content`, `navigation`, `medialib`, `comment`, `tag`):
|
||||
|
||||
| Methode | Was gelöscht wird |
|
||||
|---|---|
|
||||
| `POST` (neuer Eintrag) | Nur Seiten mit `col:*` (Listen) |
|
||||
| `PUT` / `DELETE` | Seiten mit `col:<id>` **oder** `col:*` |
|
||||
| `POST /api/ssr?clear=1` | Alles (manueller Komplett-Wisch) |
|
||||
|
||||
Jede andere `POST`-Anfrage an `ssr` antwortet **500** (`ssr is only a dummy collection`).
|
||||
Antwort-Köpfe `X-SSR-Cleared` und `X-SSR-Cleared-Collection` sagen, was flog.
|
||||
|
||||
## Abnahme
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Anonym prüfen">
|
||||
|
||||
```bash title="SSR-HTML ohne Login lesen"
|
||||
curl -s http://localhost:8080/de/ | grep -o 'window.__SSR_CACHE__ = .*' | head -c 600
|
||||
# Erwartung: navigation count > 0, heroImage/image mit _lookup
|
||||
curl -sI http://localhost:8080/de/ | grep -i x-ssr-cache
|
||||
# Zweiter Aufruf: X-SSR-Cache: true
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Nach Änderung">
|
||||
|
||||
```bash title="Cache-Verhalten nach PUT/DELETE"
|
||||
# PUT/DELETE auf einen Eintrag → betroffene Seiten neu gerendert (kein X-SSR-Cache: true beim nächsten Aufruf)
|
||||
# POST (neu) → nur Listen-Seiten neu
|
||||
# Zweifel? POST /api/ssr?clear=1 wischt alles
|
||||
curl -s "http://localhost:8080/de/?noCache=1" | head -c 300
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
| Fehlerbild | Ursache | Lösung |
|
||||
|---|---|---|
|
||||
| Öffentlich leer, mit Token voll | `active:true` fehlt (auch auf Navigation/Medien) | Flag setzen, anonym erneut prüfen |
|
||||
| `X-SSR-Cache: true` trotz neuer Daten | Abhängigkeit fehlt (z. B. Liste ohne `col:*`) | `?clear=1` oder `?noCache=1`, Hook-Verdrahtung prüfen |
|
||||
| 500 `ssr is only a dummy collection` | `POST` an `ssr` ohne `?clear=1` | Query-Parameter ergänzen |
|
||||
| PAT reicht, anonym fehlt Lookup | `lookup permission denied` — Ziel-Sammlung ohne Leserecht | [Rechte und Tokens](/projekt/rechte-tokens/) |
|
||||
|
||||
Verifiziert am e3f92eb 2026-10-17 (tibi-svelte-starter)
|
||||
Reference in new issue
Block a user