# Website Builder — Block-Tree CMS für Privat + Firma
_Started 2026-04-23._
Ein Modul `website`, mit dem Nutzer (privat) und Firmen (Space mit mehreren Mitgliedern) mehrseitige Websites bauen, live bearbeiten und unter `mana.how`-Domains veröffentlichen. Kein Drag-Drop-Canvas wie Framer/Webflow, sondern ein **Block-Baum-Editor** mit Zod-validierten Block-Typen — dieselben Svelte-Komponenten rendern im Editor, in der Live-Preview und im öffentlichen Seitenaufruf. Content aus anderen Mana-Modulen (picture, library, news, …) wird per `moduleEmbed`-Block direkt eingebettet.
Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Zielzustand direkt, keine Legacy-Reste.
## Ziel in einem Satz
Jeder Mana-Nutzer (oder jede Firma via Space) kann eine vollständige Website mit beliebig vielen Seiten über einen Block-Baum-Editor bauen, Daten aus seinen Mana-Modulen einbetten, und unter `/s/{slug}`, später `{slug}.mana.how` oder einer Custom-Domain veröffentlichen — mit SSR-Rendering aus denselben Svelte-Komponenten, ohne separaten Astro-Build-Pfad.
## Nicht-Ziele
- **Kein Free-Form Canvas.** Keine absolute Positionierung, keine Pixel-CSS. Layout über Block-Typen, Theme-Variablen und wenige Container (`columns`, `rows`, `spacer`).
- **Kein dualer Renderer.** Keine Svelte-Komponenten **und** Astro-Komponenten für dieselben Blöcke. Der öffentliche Renderer ist SvelteKit-SSR, der Editor rendert dieselben Components.
- **Keine Admin-UI-Nutzung des bestehenden `mana-landing-builder` Services** für User-Sites. Der Service bleibt für Org-Landing-Pages (andere Code-Pfade, andere Zielgruppe). Wir beschreiben in M6 optional eine Konsolidierung.
- **Kein Plugin-System.** Block-Typen sind intern und in `packages/website-blocks` versioniert. Dritt-Blöcke erst wenn Bedarf real wird.
- **Kein Markdown-Editor-Ersatz.** RichText-Blöcke nutzen einen kuratierten Satz Tiptap-Extensions, nicht Markdown. Ein Export-zu-Markdown ist möglich, aber nicht Teil des Write-Pfades.
- **Keine E-Commerce-Primitive.** Shop, Warenkorb, Checkout: nicht Scope. Pricing-Blöcke sind Display-only.
- **Keine Versionierung auf Block-Ebene.** Sites haben `draft` und `published` als zwei konsistente Snapshots; kein per-Block-History-Browser.
## Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ Editor (auth-gated, local-first) │
│ apps/mana/apps/web/src/routes/(app)/website/… │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Seitenbaum + │ │ Block-Baum + │ │ Inspector │ │
│ │ Seite-Settings│ │ Live Preview │ │ (Zod → Form) │ │
│ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │
│ └──── Dexie (websites/pages/blocks) ───┘ │
│ │ │
│ ▼ │
│ encryptRecord(plaintext) → table.add() │
│ │ │
│ ▼ │
│ _pendingChanges (appId='website') │
└──────────────────────────┬───────────────────────────────────┘
│ (sync engine, same pipe as every module)
▼
mana-sync → Postgres (website.* schema)
│
│ read path for public visitors
▼
┌──────────────────────────────────────────────────────────────┐
│ Public renderer (no auth, SSR) │
│ apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/… │
│ │
│ +page.server.ts │
│ └─ resolveSite(siteSlug, path) │
│ └─ reads published snapshot from Postgres (no Dexie) │
│ │ │
│ ▼ │
│ +page.svelte renders │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Form submissions (no auth) │
│ POST /api/v1/website/sites/:id/submit/:blockId │
│ → validate against stored block schema (Zod) │
│ → write to target module via mana-tool-registry handler │
│ → record in websiteSubmissions for audit │
└──────────────────────────────────────────────────────────────┘
```
## Entscheidungen
Explizit, begründet, und als Ankerpunkt für spätere Zweifel.
### D1 — Ein Block-Typ, eine Svelte-Komponente, drei Render-Modi
Jeder Block-Typ (`hero`, `richText`, `image`, `gallery`, `form`, …) lebt als **genau eine** Svelte-Komponente in `packages/website-blocks/src/{type}/{Type}.svelte`. Die Komponente bekommt `{ block, mode }`, wobei `mode ∈ 'edit' | 'preview' | 'public'`. Im `edit`-Mode werden Inline-Editing-Controls sichtbar; im `public`-Mode reine Anzeige.
**Warum:** Ein Codepfad bedeutet: wenn der Editor eine Change rendert, sieht der Besucher später exakt dasselbe. Dual-Rendering (Svelte-Editor + Astro-Public) wie in `shared-landing-ui` heute erzeugt garantiert Drift.
**Konsequenz:** Der SvelteKit-Public-Renderer ist SSR, nicht statisch. Für Performance siehe D9 (Caching).
### D2 — Block-Schema ist SSOT für Rendering, Validierung, UI, AI-Tools
Pro Block-Typ ein Zod-Schema in `packages/website-blocks/src/{type}/schema.ts`. Das Schema ist gleichzeitig:
1. **Datenbank-Validierung** (Store schreibt, Server validiert)
2. **Inspector-Formular** (Auto-Generierung via `zod-to-form`-Utility)
3. **AI-Tool-Input** (über `mana-tool-registry`, siehe D7)
4. **Persistenz-Schema-Migrationen** (jedes Block-Schema hat `version`, Upgrader)
**Warum:** Schema und Renderer werden immer zusammen geändert. Wenn sie getrennt leben, bekommen wir stille UI-Abweichungen vom Datenmodell. Zod als eine Quelle schließt das aus.
**Block-Paket-Skizze:**
```
packages/website-blocks/src/
├── hero/
│ ├── schema.ts # HeroBlockSchema (Zod, v1)
│ ├── Hero.svelte # Renderer (mode-aware)
│ ├── Hero.inspector.ts # optional: custom inspector (sonst auto)
│ └── index.ts
├── richText/…
├── image/…
├── gallery/…
├── form/…
├── moduleEmbed/…
├── columns/…
├── spacer/…
├── cta/…
├── faq/…
├── registry.ts # { type → { schema, Component, icon, category } }
└── index.ts
```
### D3 — Block-Baum über `parentBlockId`, Reihenfolge über `order`
Blöcke speichern `parentBlockId` (nullable — Top-Level auf einer Seite) und `order` (double-linked via fractional indexing, kein Reindex bei Insert). Container-Blöcke (`columns`, `rows`) haben mehrere Slots; `slotKey` ist optional auf dem Child.
**Warum:** Flache Tabelle mit `parentBlockId` ist die konventionelle, gut-getestete Repräsentation eines Baums in einem CRDT-fähigen System (wir haben field-level LWW via mana-sync). Alternativen:
- *JSON-Blob für den ganzen Baum*: einfach, aber jedes Move eines Blocks schreibt den gesamten Baum. Konfliktverlust garantiert bei Co-Editing.
- *Nested Set / Path-Enumeration*: schnell für Lese-Queries, aber Writes sind teuer und Konflikte weh tun.
Flach + `parentBlockId` + fractional index = feld-weise LWW ist pro Block funktionabel, Co-Editing zweier Member am selben Block ist sicher (Timestamp entscheidet pro Feld).
### D4 — Drei Tabellen plus optional Submissions, alle space-scoped, alle plaintext
```
websites { id, spaceId, slug, name, theme, navConfig, footerConfig,
publishedVersion, draftUpdatedAt, settings }
websitePages { id, siteId, path, title, seo, order }
websiteBlocks { id, pageId, parentBlockId, type, slotKey, props, order, schemaVersion }
websiteSubmissions { id, siteId, blockId, payload, targetModule, targetRecordId,
status, createdAt, ip, userAgent }
```
Alle Felder plaintext. Begründung: Site-Content ist öffentlich — es für den Autor zu verschlüsseln wäre sinnfrei, und macht SSR im Public-Path unmöglich (der Server hat keinen MK). Form-Submissions können sensible Daten enthalten; die landen nach Validierung in den Zielmodulen (z.B. `contacts`), dort sind die existenten Encryption-Regeln gültig. Der Submission-Audit-Row (`payload`) wird nach erfolgreicher Weitergabe geleert (siehe M2).
### D5 — Publish-Modell: `draft` + `published` als zwei separate Snapshots
Jedes `website` hat einen `publishedVersion` (UUID). Editor schreibt immer gegen den Draft (= die Live-Tabellenzeilen). Auf "Publish" wird ein Snapshot erzeugt: `websitePublishedSnapshots { siteId, version, blob }` — das `blob` ist ein vollständig aufgelöster, deterministisch serialisierter Baum (JSON). Der Public-Renderer liest nur dieses Blob.
**Warum:**
- Editor kann beliebig herumspielen, ohne dass Besucher halbfertige Seiten sehen.
- Rollback ist trivial: `publishedVersion` zeigt auf älteren Snapshot.
- Public-Read ist **ein** Query (`SELECT blob WHERE siteId AND version`), kein JOIN über drei Tabellen.
- Snapshots sind unveränderlich — gut cachebar (D9).
**Alternative verworfen:** "Live-Edit = sofort live". Katastrophal für Firmen-Nutzung, wo mehrere Member über Stunden editieren. Draft/Publish ist der nicht-verhandelbare Standard.
### D6 — Public-Serving über SvelteKit-Route, nicht via `mana-landing-builder`
`apps/mana/apps/web/src/routes/s/[siteSlug]/[[...path]]/+page.server.ts` lädt Site + Page + BlockTree aus Postgres und rendert SSR. `mana-landing-builder` wird **nicht** erweitert — der Service bleibt für den separaten Org-Landing-Pages-Use-Case (admin-only), in M6 wird entschieden, ob er fusioniert oder abgelöst wird.
**Warum:**
- Astro-Static-Export in `mana-landing-builder` zwingt zu dualem Rendering (D1 verletzt).
- SvelteKit-SSR mit Caching (D9) ist für Hunderttausende User-Sites schnell genug.
- Statischer Export lohnt erst bei hohem Traffic pro Site — dann pro Site opt-in, nicht default.
**Subdomain-Handling (Phase 3):** SvelteKit-Host-Handler im Hook `hooks.server.ts` erkennt `{slug}.mana.how` und rewritet intern auf `/s/{slug}/…`. Wildcard-Cert existiert bereits.
### D7 — AI-Tools via `mana-tool-registry`, nicht separat
Sobald `mana-tool-registry` (siehe `docs/plans/mana-mcp-and-personas.md` M1) steht, registriert `website` seine Tools dort: `website.create_page`, `website.add_block`, `website.update_block`, `website.reorder_blocks`, `website.publish`, `website.apply_template`. Policy-Hint pro Tool: `write` für CRUD, `destructive` für `delete_page`/`delete_site` (nicht MCP-exponiert).
**Warum:** Alle AI-Writes laufen zwingend durch denselben Tool-Layer. Kein paralleles "AI-kann-Websites-bauen"-Subsystem.
**Reihenfolge:** `mana-tool-registry` M1 muss stehen, bevor Website-AI-Tools registriert werden. Bis dahin: Editor ohne AI. Website-AI-Tools landen als Teil von M5.
### D8 — Form-Submissions schreiben über Tool-Registry-Handler, nicht direkt in Ziel-Tabellen
Ein `form`-Block hat `targetModule` (z.B. `'contacts'`) und `targetAction` (z.B. `'create_contact'`). Der Submit-Endpoint:
1. Validiert Payload gegen das im Block gespeicherte Zod-Schema
2. Speichert Audit-Row in `websiteSubmissions` (Status: `'received'`)
3. Ruft den entsprechenden Tool-Handler aus `mana-tool-registry` auf (`ctx`: site-owner user/space)
4. Updated Audit-Row mit `targetRecordId` und `'delivered'`
**Warum:** Der Tool-Registry-Handler kennt bereits Encryption, RLS, Validation des Zielmoduls. Duplizieren wäre erzwungener Legacy-Einstiegspunkt.
**Abgrenzung:** Unauthentifizierter Submit-Endpoint → Rate-Limiting via Edge (Cloudflare) und Captcha-Block-Typ (in M6, nicht M1).
### D9 — Caching: Published-Snapshot mit Cache-Tag, Invalidation bei Publish
Published-Blob wird mit `Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400` geliefert, plus `Cache-Tag: site-{siteId}`. Bei `website.publish` → Cloudflare Purge der Tag-Gruppe.
**Warum:** Keine statische Build-Stufe nötig; Edge-Cache bei Cloudflare liefert millisekunden-Responses für populäre Sites. Bei Edits ist die neue Version nach Publish binnen weniger Sekunden live.
**Alternative verworfen:** Redis-Cache in der App. Doppelter Infrastruktur-Aufwand, CF macht es gratis.
### D10 — Multi-Tenant über Spaces, Editing-Permission über Membership
Ein `website` gehört zu einer `spaceId`. Jedes Mitglied des Spaces kann editieren + publishen. Rollen (editor-only, viewer-only) kommen später, wenn `space_members.role` nicht-trivial wird.
**Warum:** Wir verwenden, was es gibt. Spaces-RLS ist getestet. Ein eigenes `website_members` wäre parallele Permission-Ebene → Drift garantiert.
**Privat vs. Firma-Distinction:** Kein eigenes "ist eine Firma"-Flag. Ein Space mit einem Member = Privat, ein Space mit 2+ Membern = Firma. Die UI kann in Phase 2 auf `spaceMemberCount > 1` reagieren, um Team-Workflows zu zeigen.
### D11 — Slugs: space-scoped unique, reserved-Liste hart
`websites.slug` ist unique pro `spaceId`. Öffentliche URL in Phase 1 ist `/s/{siteSlug}` global unique (nicht space-scoped) — und deswegen gibt es *auch* eine globale Unique-Constraint auf `slug` wenn `isPublished=true`.
**Reserved slugs:** `app`, `api`, `auth`, `admin`, `settings`, `docs`, `blog`, `www`, `mail`, `dashboard`, plus alle existierenden Modulnamen. Liste in `apps/api/src/modules/website/reserved-slugs.ts`, erzwungen bei Write und in Migration gecheckt.
**Warum:** Eine Site mit `slug=api` würde die API-Route verschatten. Lieber strikt + reserviert Namen.
### D12 — Media-Assets über bestehendes `shared-uload`, kein eigenes `websiteAssets`
Bilder, Dateien, Cover: Upload über `shared-uload` → MinIO, Rückgabe der URL. Der `image`-Block speichert `{ url, altText, focalPoint }`. Cleanup: wir führen keine Reference-Counting-Tabelle in Phase 1. Bei `delete site` bleiben Assets liegen (ok, sie sind im Space-Bucket, Storage ist billig). GC-Job in M7.
**Warum:** Ein eigenes `websiteAssets` mit Reference-Counting wäre sauberer, aber ein GC-Job reicht als Aufräumer und verzögert erst mal Komplexität.
### D13 — Kein Legacy-Fork der `shared-landing-ui` Astro-Sections
Die 13 existierenden Astro-Sections (`HeroSection`, `FeatureSection`, …) werden **nicht** nach Svelte portiert oder geteilt. Wir schreiben die Block-Renderer neu. Die visuellen Patterns darf man inspirieren, aber Code teilen = duales Rendering (D1 verletzt).
**Warum:** Die Astro-Sections haben andere Constraints (Astro-Islands, build-time-data). Teilen würde beide Seiten einschränken.
**Konsequenz:** `shared-landing-ui` bleibt für Org-Landing-Pages. In M6 diskutieren wir die Konsolidierung ehrlich.
## Komponenten
### Komponente 1 — `packages/website-blocks`
Neues Workspace-Paket. Reine Svelte-Components + Zod-Schemata, keine Dexie/Netzwerk-Abhängigkeiten. Nutzbar vom Editor (Dexie-Kontext) **und** vom Public-Renderer (Postgres-Snapshot-Kontext) — beide Seiten geben `{ block, mode, children }`, der Renderer kümmert sich nicht um Datenquelle.
**Public API (Skizze):**
```ts
// packages/website-blocks/src/registry.ts
export interface BlockSpec {
type: string; // 'hero', 'richText', …
schema: ZodSchema;
schemaVersion: number;
Component: SvelteComponent<{
block: Block;
mode: 'edit' | 'preview' | 'public';
children?: Block[]; // nur bei Containern
onEdit?: (patch: Partial) => void; // im edit-Mode
}>;
icon: string; // Lucide-Name
category: 'content' | 'media' | 'layout' | 'form' | 'embed';
defaults: Props; // Initialwerte beim Einfügen
upgraders?: Record Props>; // v1→v2 migrations
}
export const blockRegistry: Record;
```
**Block-Coverage M1:** `hero`, `richText`, `image`, `spacer`, `cta`, `columns` (2/3-spalt), `gallery`. Sieben Typen reichen für brauchbare One-Pager.
**Block-Coverage M4 expand:** `form`, `moduleEmbed`, `pricing`, `faq`, `testimonials`, `team`, `contact`, `footer`. Fünfzehn Typen decken alle 13 `shared-landing-ui`-Sections plus neuen Bedarf.
### Komponente 2 — `apps/mana/apps/web/src/lib/modules/website`
Standard-Modul-Struktur, wie jedes andere Modul im Repo:
```
apps/mana/apps/web/src/lib/modules/website/
├── types.ts # LocalWebsite, LocalWebsitePage, LocalWebsiteBlock
├── collections.ts # websitesTable, websitePagesTable, websiteBlocksTable
├── queries.ts # useSite(id), usePage(id), useBlocks(pageId), useBlockTree(pageId)
├── stores/
│ ├── sites.svelte.ts # createSite, updateSite, deleteSite, publishSite
│ ├── pages.svelte.ts # createPage, updatePage, deletePage, reorderPages
│ └── blocks.svelte.ts # addBlock, updateBlock, deleteBlock, moveBlock
├── components/
│ ├── BlockRenderer.svelte # rekursiv, nutzt blockRegistry
│ ├── BlockTreeEditor.svelte # Seitenleiste: Baum + Insert-Palette
│ ├── BlockInspector.svelte # rechts: Zod-schema → Formular
│ ├── InsertPalette.svelte # "+" zwischen Blöcken
│ ├── PagePicker.svelte
│ ├── SiteSettingsDialog.svelte # Theme, Nav, Footer, SEO-Defaults
│ ├── PublishBar.svelte # "Unveröffentlichte Änderungen" + Publish-Button
│ └── TemplatePicker.svelte # Starter-Templates
├── views/
│ ├── SitesListView.svelte # alle Sites des Spaces
│ ├── SiteEditorView.svelte # drei-Pane Editor
│ └── SiteSettingsView.svelte
├── tools.ts # AI-Tool-Registrierungen (aktiviert erst in M5)
├── constants.ts # THEME_PRESETS, RESERVED_SLUGS (client-copy)
├── module.config.ts # { appId: 'website', tables: [...] }
└── index.ts
```
**Routes:**
```
apps/mana/apps/web/src/routes/(app)/website/
├── +page.svelte # SitesListView
├── new/+page.svelte # Template-Picker oder Blank
└── [siteId]/
├── +layout.svelte # lädt site, stellt Context
├── +page.svelte # redirect auf /edit
├── edit/
│ └── [pageId]/+page.svelte # SiteEditorView
├── settings/+page.svelte # SiteSettingsView
└── submissions/+page.svelte # Eingegangene Form-Submissions
```
### Komponente 3 — Public-Renderer-Routes
```
apps/mana/apps/web/src/routes/s/
└── [siteSlug]/
├── +layout.server.ts # resolve site, throw 404 if unpublished
├── +layout.svelte # theme vars, nav, footer
└── [[...path]]/
├── +page.server.ts # resolve page by path, 404 if missing
└── +page.svelte #
```
**Resolver-Logik (+layout.server.ts):**
```ts
export const load = async ({ params, setHeaders }) => {
const snapshot = await db
.select()
.from(publishedSnapshotsTable)
.where(and(
eq(publishedSnapshotsTable.slug, params.siteSlug),
eq(publishedSnapshotsTable.isCurrent, true)
))
.limit(1);
if (!snapshot[0]) error(404);
setHeaders({
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
'cache-tag': `site-${snapshot[0].siteId}`,
});
return { site: snapshot[0].blob };
};
```
Snapshot-Blob-Format:
```ts
interface PublishedSnapshot {
version: string;
site: { id, slug, name, theme, navConfig, footerConfig, settings };
pages: Array<{
id, path, title, seo,
blocks: BlockTreeNode[]; // rekursiver Baum, bereits auflösend
}>;
publishedAt: string;
publishedBy: string;
}
```
### Komponente 4 — `apps/api/src/modules/website`
Backend-Routes im unified `@mana/api`:
```
apps/api/src/modules/website/
├── routes.ts # Hono router
├── publish.ts # POST /sites/:id/publish
├── submit.ts # POST /sites/:id/submit/:blockId (unauth)
├── snapshots.ts # query helpers for published snapshots
├── reserved-slugs.ts # SSOT
└── tools.ts # Tool-Registry registrations (M5)
```
**Endpoints:**
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| `POST` | `/api/v1/website/sites/:id/publish` | JWT (space-member) | Snapshot erzeugen, `publishedVersion` setzen, CF-Cache purgen |
| `POST` | `/api/v1/website/sites/:id/submit/:blockId` | None | Form-Submission annehmen, validieren, weitergeben |
| `GET` | `/api/v1/website/sites/:id/submissions` | JWT (space-member) | Submissions listen |
| `DELETE` | `/api/v1/website/sites/:id/submissions/:subId` | JWT (space-member) | Submission löschen |
Keine CRUD-Endpoints für Pages/Blocks — das läuft über den normalen Sync-Pfad (Dexie → mana-sync → Postgres) wie bei allen anderen Modulen.
### Komponente 5 — Starter-Templates
Sechs handkuratierte Templates in `apps/mana/apps/web/src/lib/modules/website/templates/`:
| Template | Zielgruppe | Seiten | Blöcke |
|----------|-----------|--------|--------|
| `portfolio` | Kreative, Freelancer | Start, Über mich, Arbeiten, Kontakt | hero + gallery + richText + form |
| `personal-linktree` | Privatnutzer, Creator | Start (Single-Page) | hero + 8× cta |
| `event` | Hochzeit, Geburtstag, Konferenz | Start, Programm, Anreise, RSVP | hero + richText + form |
| `smb-corporate` | Kleinbetrieb | Start, Leistungen, Team, Kontakt | hero + 3×columns + team + contact |
| `product-landing` | Firmen-Produktseite | Start (Single-Page, lang) | hero + features + testimonials + pricing + faq + cta |
| `blank` | Fortgeschritten | 1 leere Seite | — |
Templates sind JSON in `templates/{name}.json`: `{ site, pages[], blocks[] }`. Apply-Funktion klont mit neuen UUIDs in den Ziel-Space. Templates sind statisch im Build, nicht in DB — kein Admin-Flow zum Editieren im MVP (M6 evtl.).
### Komponente 6 — Inspector-Autoform
`components/BlockInspector.svelte` rendert Formulare aus Zod-Schemas via kleiner Utility `zodToForm(schema)` in `packages/website-blocks/src/inspector/`. Mapping:
| Zod | UI |
|-----|----|
| `z.string()` | `` |
| `z.string().long()` (custom brand) | `