# 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) | `