Technische Doku zu fluid für REDAXO 6. Einstieg und Installation: README.
- Ein eigener Block · Eigene Kits
- Extension Points
- YForm: Formulare und Datenlisten
- Blöcke in YForm-Tabellen
- Adressen, Domains, SEO
- Sprachen · Videos · Blog · Karten · Diagramme · Rechtstexte
- Versionen · MCP-Server · E-Mail
- Suche · Neu laden auf Aktion · Live-Aktualisierung · Präsenz der Redaktion
- Konsole · Rechte · Gastzugang · Bausteine · Aufbau des AddOns · Mitentwickeln
Ein Block ist ein Modul des Cores mit anderer Basisklasse: die Felder als Attribute an Eigenschaften, die Ausgabe in
output(). Der Core befüllt die Eigenschaften, speichert und prüft wie bei jedem Modul; fluid gibt Bearbeiten auf der
Website, Abschnitt (Hintergrund, Abstände, Sprungmarke), Darstellungen, Layout-Spalten und das Kit dazu. Im Projekt,
ohne das Kit zu ändern (examples/block/):
// src/Block/Teaser.php
namespace Project\Block;
use FriendsOfRedaxo\Fluid\Field\AsBlock;
use FriendsOfRedaxo\Fluid\Field\Link;
use FriendsOfRedaxo\Fluid\Kit\Fluid\FluidBlock;
use Redaxo\Core\Content\AsModule;
use Redaxo\Core\Form\Field\{Text, Textarea};
use function Redaxo\Core\View\escape;
#[AsModule('fluid.teaser', 'Teaser')]
#[AsBlock(icon: 'star', group: 'Inhalt', nestable: true)] // nestable: darf in eine Layout-Spalte
final class Teaser extends FluidBlock
{
#[Text('Überschrift', required: true, maxLength: 80)]
public string $title = '';
#[Textarea('Text', rows: 3)]
public string $text = '';
#[Link('Link')] // Feld nur von fluid
public string $link = '';
public function output(): string
{
return '<div class="wrap"><h2 class="h3"' . $this->edit('title') . '>' . escape($this->title) . '</h2>'
. '<p' . $this->edit('text', 'lines') . '>' . nl2br(escape($this->text), false) . '</p></div>';
}
}$this->edit('feld')macht ein Feld direkt auf der Seite bearbeitbar ('lines'mehrzeilig,'rich'mit Formatierung; in Listenitems.0.title).$this->context()ist der Block mit Darstellung (variant()) und Abschnitt.- Ohne eigenes
output()gibt der Block die View seines Kits aus:views/{typ}.phpmit$b(Block),$d(Werte als Array, mit Standardwerten) und$m(das Modul mit den typisierten Eigenschaften). So haben die Blöcke von fluid ihre Ausgabe im Kit, damit jedes Kit sie anders gestalten kann. Den Abschnitt (<section>) legt fluid um die Ausgabe. - Gespeichert wird wie beim Core: in der JSON-Spalte
datades Slices je Feld unter seinem Schlüssel (Name der Eigenschaft in snake_case,$buttonLink→button_link, anders mitkey:), dazuvariant(Darstellung) undtunes(Abschnitt). Echte Slices mit Rechten, Arbeitsversion, Historie und Verwendungsliste (Medien, Seiten) des Cores. Datenbanken mit dem früheren Format von fluid ({values, tunes}) stelltconsole fluid:upgrade:slicesum. - Was den Blocktyp betrifft, ist statisch:
kit(),fields(),definition(),label(),variants().BlockModule::classFor($key)liefert die Klasse eines Blocks,BlockModule::all()alle;->data()die Werte eines Slices als Array.
Vom Modul des Cores zum Block: von FluidBlock statt Module erben, Schlüssel mit fluid., #[AsBlock] an die
Klasse – Felder und output() bleiben, wie sie sind. Für das Bearbeiten direkt auf der Seite $this->edit('feld') an
die Elemente setzen. Vorhandene Slices behalten ihren Modulschlüssel; neue Inhalte kommen mit dem Block.
Felder. Alle Felder des Cores (Redaxo\Core\Form\Field\…) funktionieren in Blöcken und in der Seitenleiste – auch
in gewöhnlichen Modulen. fluid bietet dieselben Felder unter gleichem Namen in FriendsOfRedaxo\Fluid\Field\… an: als
Unterklassen mit allen Parametern des Cores und zusätzlichen, optionalen Parametern. Ohne sie verhalten sie sich
genau wie die des Cores; der Core kann sie überall verwenden (im Backend zeigt er sein eigenes Formular).
| Feld (Core) | Ergänzungen von fluid (optional) |
|---|---|
Text, Email, Url, Textarea |
placeholder, search: false (nicht in den Suchindex; Text/Textarea) |
Number, Checkbox, Choice, DatePicker, TimePicker, DateTimePicker, ArticlePicker |
– |
MediaPicker |
preview: true (Core) zeigt in fluid ein Bildfeld mit Bildbearbeitung, sonst eine Dateiauswahl |
Collection |
itemLabel (Bezeichnung eines Eintrags, „+ Karte“), titleField (Feld als Titel des zugeklappten Eintrags) |
| alle | width: 'half' (halbe Breite in der Seitenleiste), variants: ['…'] (nur in diesen Darstellungen) |
Warum Ergänzungen: Die Seitenleiste ist schmal und zeigt je Darstellung andere Felder – Breite und Darstellung gibt es
im Formular des Cores nicht. placeholder fehlt dem Core bei Textfeldern; search steuert die Suche von fluid;
itemLabel und titleField machen lange Listen in der Seitenleiste übersichtlich.
Wiederholgruppen sind die Collection des Cores: jeder Eintrag ist ein Objekt einer kleinen Klasse mit eigenen
Feldern, die Ausgabe bekommt eine Liste dieser Objekte, gespeichert wird eine Liste der Werte je Schlüssel.
final class Card
{
#[MediaPicker('Bild', preview: true)]
public ?Media $image = null;
#[Text('Titel', required: true, maxLength: 90)]
public string $title = '';
}
/** @var list<Card> */
#[Collection('Karten', item: Card::class, itemLabel: 'Karte', titleField: 'title', max: 12)]
public array $items = [];Felder nur von fluid (eigene Namen; Unterklassen von Text, Textarea bzw. Choice des Cores):
| Feld | Wert | |
|---|---|---|
RichText |
formatierter Text | bereinigtes HTML |
Link |
Seite (Linkmap), #anker, https://…, mailto:, tel: |
redaxo://12 bzw. Adresse |
Icon, Geo |
Symbol des Kits, Ort mit Adresssuche | Name, „Breite, Länge“ |
MediaCategory, Blog |
Kategorie des Medienpools, Blog | ID |
YFormTable, YFormForm, YFormField |
Tabelle, Formular-Klasse, Feld der gewählten Tabelle | Name |
Auswahlen aus Code: choices: static function (): array { return Klasse::liste(); } (wie beim Core; Pfeilfunktionen
sind in Attributen nicht erlaubt) oder ein ChoiceProvider.
Block (#[AsBlock]): icon, group, help, label (Standard: Name aus AsModule), variants (Darstellungen,
erste = Standard), variantHelp, nestable (true oder Liste der Varianten), background, requires ('yform'),
reload (Teil der Seite ohne Neuladen aktualisieren), jsonld (strukturierte Daten, z. B.
['type' => 'faq', 'items' => 'items', 'question' => 'q', 'answer' => 'a']), drags (Greifer auf der Seite:
[['field' => 'measure', 'target' => '.prose', 'class' => 'prose--m-{v}', 'label' => 'Textbreite']]).
Beschriftungen und Darstellungen ändern, ohne die Klasse anzufassen: Kit (kit.php → 'blocks') oder Extension Point
FLUID_BLOCK_DEFINITION. Neue Felder gehören als Eigenschaften in die Klasse (bzw. eine abgeleitete) – nur sie speichert
der Core.
Gewöhnliche Module in der Seitenleiste: fluid zeigt die Felder jedes Moduls mit Feld-Attributen (auch Collection
und die Felder von fluid) in seinem Formular und speichert über das Formular des Cores – mit dessen Prüfungen und
validate(). Nur Module mit eigenem input() und Mehrfachauswahlen von Medien bzw. Seiten öffnen das Formular des Cores.
Ein Kit bestimmt die Gestaltung der ganzen Website: Seitenrahmen, CSS, Ausgabe der Blöcke, Design-Optionen. fluid bringt
das Kit fluid mit. Weitere Kits liegen im Projekt oder kommen als Composer-Paket; welches gilt, wählen Admins unter
Website → Kit (der Reiter erscheint, sobald es mehr als ein Kit gibt).
Mitgeliefert sind zwei Kits: fluid (Grund-Kit) und native („NativeCSS“, kits/native/). Das zweite ist zugleich
das Beispiel für ein abgeleitetes Kit: eigener Seitenrahmen und eigene Views für Einstieg, Karten, Text + Bild,
Handlungsaufruf und FAQ mit den Komponenten von NativeCSS; alle übrigen Blöcke
und seltene Varianten kommen von fluid. Die Reihenfolge der Cascade Layers (tokens, reset, base von NativeCSS, dann
das Grund-CSS von fluid als Layer fluid, dann helpers, components, dann native) steht in kits/native/template.view.php,
die Brücke von der Design-Leiste (--f-…) zu den Tokens von NativeCSS (--ncss-…) in assets/css/native.css.
Inhalte bleiben beim Wechsel. Die Block-Module (fluid.hero, fluid.cards …) gehören zu keinem Kit, sie zeigen
sich im gewählten. Jedes Kit erbt von einem Eltern-Kit (Standard fluid) und bringt nur mit, was es ändert – alle
Blöcke sind also immer da, und Updates von fluid kommen beim abgeleiteten Kit mit an.
php bin/console fluid:kit:create nature --label="Nature" --view=hero,cards --templateLegt src/Kit/Nature/ im Projekt an:
| Datei | |
|---|---|
NatureKit.php |
Anmeldung: #[AsKit('nature', 'Nature', parent: 'fluid')] final class NatureKit extends Kit {} |
kit.php |
Definition – nur, was vom Eltern-Kit abweicht ($parent steht bereit) |
assets/css/kit.css |
eigenes CSS, geladen nach Grund- und Block-CSS des Eltern-Kits |
views/hero.php … |
mit --view kopierte Views zum Anpassen |
template.view.php |
mit --template kopierter Seitenrahmen |
Jede Datei im Kit-Ordner ersetzt die gleichnamige des Eltern-Kits, alles andere gilt von dort:
views/{typ}.php– Ausgabe eines Blocktyps ($b= Block,$d= Daten, wie beim eigenen Block); die Felder stehen in den Blockklassen (kits/fluid/src/Module/)template.view.php– Seitenrahmen (Kopf, Menü, Fuß); Vorlage istkits/fluid/src/FluidTemplate.view.phpassets/…– einzelne Dateien, z. B.assets/css/b-cards.cssstatt der des Eltern-Kitsfunctions.php– zusätzliche Hilfsfunktionen (die des Eltern-Kits sind schon geladen)kit.php– Definition, über die des Eltern-Kits gelegt:
<?php // src/Kit/Nature/kit.php
return [
'label' => 'Nature',
'css' => ['css/kit.css'], // Stylesheets für jede Seite (angehängt)
'backgrounds' => ['sand' => 'Sand'], // ergänzt die Hintergründe
'assets' => ['css/b-gallery.css' => ['gallery']], // CSS je Blocktyp (ergänzt)
'blocks' => [ // je Blocktyp über die Blockklasse gelegt
'hero' => ['variants' => ['leaf' => 'Mit Blatt']], // neue Variante, Ausgabe in views/hero.php
],
'design' => $parent['design'], // Style-Editor; eigene Vorlagen unter ['presets']
];backgrounds und assets werden je Schlüssel ergänzt, css wird angehängt, alle anderen Einträge ersetzt. blocks legt
sich über die Definition aus der Blockklasse: variants und variant_help werden ergänzt, fields angehängt, alles
andere (z. B. label, help) ersetzt.
Design-Werte speichert jedes Kit für sich. Solange ein abgeleitetes Kit keine eigenen hat, übernimmt es die seines Eltern-Kits – nach dem Wechsel sieht die Website also zunächst aus wie vorher.
Assets veröffentlicht fluid nach assets/fluid-kits/{kit}/: bei console migrate komplett, im Betrieb je Datei,
sobald sie fehlt oder sich geändert hat. Relative Pfade im CSS zeigen auf assets/ des eigenen Kits.
Ein Kit für mehrere Projekte ist ein eigenes Composer-Paket – und zwar ein AddOn (Typ redaxo-addon mit
AddOn-Klasse): REDAXO sucht Klassen mit Attributen (#[AsKit]) nur im Projekt und in aktiven AddOns, nicht in
beliebigen Paketen unter vendor/. Die Dateien des Kits (kit.php, views/, assets/ …) liegen neben der Kit-Klasse.
{
"name": "acme/fluid-kit-nature",
"type": "redaxo-addon",
"require": { "friendsofredaxo/fluid": "*@dev" },
"autoload": { "psr-4": { "Acme\\FluidKitNature\\": "src/" } },
"extra": { "redaxo": { "addon-class": "Acme\\FluidKitNature\\NatureAddon" } }
}src/NatureAddon.php ist eine leere AddOn-Klasse (final class NatureAddon extends Addon {}), src/NatureKit.php die
Kit-Klasse, daneben src/kit.php, src/views/ und src/assets/. Nach composer require und console migrate steht
das Kit in der Auswahl. Ein Kit kann auch von einem anderen Kit als fluid erben (parent: 'nature').
Standard ist ein Kit für die ganze Instanz. Per Extension Point FLUID_KIT lässt es sich je Anfrage wählen:
Extension::register('FLUID_KIT', static fn (ExtensionPoint $ep) => 'shop.example.org' === Request::server('HTTP_HOST', 'string', '') ? 'nature' : $ep->subject);| Name | Subject | Params | Zweck |
|---|---|---|---|
FLUID_BLOCK_DEFINITION |
Definition (array) | type, module, kit |
Felder ergänzen, Beschriftungen ändern |
FLUID_BLOCK_VIEW |
Pfad der View | type, variant, block |
Ausgabe eines Blocks überschreiben |
FLUID_SETTINGS_SCHEMA |
Reiter der Website-Einstellungen | – | eigene Einstellungen (lesen mit setting('name')) |
FLUID_SEARCH_DOCUMENT |
Dokument (array) oder null | article |
Suchindex ergänzen, Seiten ausschließen |
FLUID_STRUCTURED_DATA |
JSON-LD (@graph) |
article, url, org, lang |
schema.org ergänzen |
FLUID_SITE_TRANSLATIONS |
Übersetzungen (array) | code |
feste Texte des Kits für eine Sprache ergänzen/ändern |
FLUID_EDITOR_CONFIG |
Einstellungen des Editors | article, user |
Blöcke/Menüpunkte für Rollen ausblenden |
FLUID_KIT |
Schlüssel des Kits | – | Kit je Anfrage wählen, z. B. je Domain |
Das Routing hat eigene Extension Points als Klassen (wie der Core), Namespace FriendsOfRedaxo\Fluid\Routing\ExtensionPoint:
| Klasse | Subject | Eigenschaften | Zweck |
|---|---|---|---|
ArticleMeta |
PageMeta |
article, match |
Meta-Angaben der Seite, bevor Eintrag und Overrides dazukommen (fluid trägt hier die SEO-Felder ein) |
PageMetaResolved |
PageMeta |
article, match |
fertige Meta-Angaben kurz vor der Ausgabe – ändern, eigene Tags, JSON-LD |
SitemapEntries |
Einträge (loc, lastmod, alternates, images) |
landing |
Adressen der sitemap.xml ergänzen, ändern, entfernen |
RobotsTxt |
Text | part (rules bzw. text) |
Regeln ersetzen (rules, fluid setzt hier den eigenen Text der Website) bzw. fertigen Text ergänzen |
RoutesRebuilt |
– | source |
Adressen wurden neu berechnet (Seiten bzw. eine Quelle) |
Angemeldet wie im Core, z. B. als Methode mit #[AsExtension] – der Name kommt aus dem Typ des Parameters:
#[AsExtension]
public static function head(PageMetaResolved $ep): PageMeta
{
return $ep->subject->add(HeadTag::meta('theme-color', '#1f7a4d'));
}Dazu nutzt fluid die Extension Points des Cores, z. B. ArticleUrl für die Adressen.
// Eigene Website-Einstellung
Extension::register('FLUID_SETTINGS_SCHEMA', static function (ExtensionPoint $ep) {
$schema = $ep->subject;
$schema['base']['fields'][] = ['name' => 'claim_en', 'label' => 'Claim (englisch)', 'type' => 'text'];
return $schema;
});fluid arbeitet mit den Mitteln von YForm 6 – Tabellen und Formulare sind Klassen im Projekt (#[AsTable], #[AsForm]):
- Formular-Baukasten (Block „Formular“, ohne gewählte Klasse): Die Redaktion stellt Felder auf der Website zusammen
(Textzeile, E-Mail, Telefon, Auswahl, Häkchen, Datum, Hinweistext …), teilt das Formular mit „— Neuer Schritt —“ in
Schritte und wählt, was nach dem Absenden passiert: speichern, per E-Mail senden oder beides, Bestätigung an den
Absender, Text oder Weiterleitung. Technisch eine YForm-
FormDefinition(Data\BuiltForm), deren Felder aus dem Block kommen – Prüfung, CSRF, Honigtopf und Aktionen (SaveRecord,SendMail,Redirect) laufen über YForm, ebenso der EPYFORM_FORM_SUBMITTED. Schritte = Reiter des YForm-Layouts (js/formsteps.js, ohne JavaScript alle untereinander). Gespeichert wird in die YForm-Tabelle „Formular-Eingänge“ (rex_fluid_form_entry, Klasse in fluid, Postfach „Anfragen“) oder in eine eigene YForm-Tabelle (Felder mit gleichem Feldnamen). - Block „Formular“ mit Klasse und Formular im Block „Kontakt“: Die Redaktion wählt ein Formular. Prüfung, CSRF, Honigtopf
und Aktionen (
SaveRecord,SendMail,ShowMessage…) kommen aus der Formular-Klasse; fluid gibt es im Stil des Kits aus. - Block „Datenliste“: eine Tabelle als Karten, Liste oder Tabelle mit Detailansicht (
?eintrag=ID), Filter und Sortierung; „Bearbeiten“ öffnet die Maske von YForm. - Postfach „Anfragen“: Einträge der Tabellen, in die die Formulare der Website speichern; Status neu/erledigt.
- Kurzmeldungen, Presse, Termine: Datenliste mit Sortierung (z. B. Datum absteigend), Blättern („Höchstens“ = je Seite,
über YForms
paginate()) und Detailansicht mit eigener Adresse/seite/titel-ID– Titel, Beschreibung, Bild und Canonical des Eintrags, Eintrag in der Sitemap, 301 von alten?eintrag=-Links, 404 für fehlende Einträge. Wann Blog, wann Datenliste? Blog = Beiträge mit Blöcken, Entwurf und Versionen; Datenliste = gleichförmige Einträge mit festen Feldern, gepflegt in der YForm-Maske.
Jede YForm-Tabelle kann Inhalt aus den Blöcken von fluid haben – ein Feld wie jedes andere in der Tabellenklasse:
use FriendsOfRedaxo\Fluid\Data\FluidBlocks;
yield FluidBlocks::new('inhalt')->label('Inhalt');
yield FluidBlocks::new('inhalt')->label('Inhalt')->blocks('richtext', 'media_text', 'video'); // nur diese Blöcke- Bearbeiten in der YForm-Maske: Blöcke hinzufügen, bearbeiten (dieselben Felder wie auf der Website – Medienpool,
Linkmap, Text-Editor), verschieben, entfernen. Gespeichert als JSON (
longtext). - Ausgabe: Die Detailansicht der Datenliste zeigt das Feld automatisch als Blöcke. In eigenen Templates:
echo \FriendsOfRedaxo\Fluid\BlockData::render($record->get('inhalt'));– Klartext für Listen und Suche:BlockData::text(…). - Auf der Website bearbeiten: Im Bearbeiten-Modus sind die Blöcke eines Eintrags in der Detailansicht der Datenliste
bearbeitbar wie Seiten-Blöcke – Texte direkt auf der Seite, Seitenleiste, Greifer, hinzufügen (nur die im Feld
erlaubten Blöcke), verschieben (Pfeile, Ziehen), verdoppeln, kopieren/einfügen (auch zwischen Seite und Eintrag),
löschen. Gespeichert wird sofort in den Datensatz (YForm-Record-API mit Prüfung und Hooks) – Datensätze kennen keinen
Entwurf. Recht: YForm „Tabelle bearbeiten“ und Modulrecht des Blocks. Technisch: Block-IDs
r~tabelle~id~feld~uid(Data\RecordBlocks), jeder Block hat eine feste Kennunguid. - Layout-Spalten gibt es im Feld nicht (sie brauchen Slices); Rechte: Modulrecht des Blocks wie auf den Seiten.
Für eigene Ausgaben: PageSettings::override(['title' => …, 'description' => …, 'image' => …, 'canonical' => …])
setzt den Seitenkopf aus einem Block heraus (z. B. eigene Detailansichten).
Vorlagen: examples/yform/ (TerminTable, NewsTable, ProjectTable, AnfrageTable, AnfrageForm) – nach src/ kopieren, console migrate.
Der Rewriter liegt in src/Routing/ (Namespace FriendsOfRedaxo\Fluid\Routing) und kennt nichts von fluid – er lässt
sich als eigenes AddOn herauslösen oder später durch yrewrite und ein URL-AddOn ersetzen (src/Routing/README.md).
Eingestellt wird er wie REDAXO 6 selbst im Code, ein Backend braucht er nicht.
So arbeitet er
- Feste Adressen: Jede Seite hat je Sprache einen Slug (Tabelle
rex_fluid_route). Er entsteht beim Anlegen aus dem Namen und bleibt beim Umbenennen gleich; ändern lässt er sich in den Seiteneinstellungen. Adresse = Slugs der Elternseiten + eigener Slug. Gleiche Slugs auf einer Ebene werden durchnummeriert (-2,-3…). - Neu berechnet wird nach jeder Strukturänderung (Artikel, Kategorien, Status, Sprachen – per
#[AsExtension]am Router). Geänderte Adressen online stehender Seiten bekommen automatisch eine 301-Weiterleitung (redaxo://ID). - Eine Adresse je Seite: Andere Schreibweise, Schrägstrich am Ende, eine andere Endung,
/EN/statt/en/, die natürliche Adresse der Startseite (/start) und alte Links?article_id=…leiten per 301 auf die kanonische Adresse. POST-Anfragen werden nie umgeleitet. - Aufgelöst wird bei
PACKAGES_INCLUDED(früh), wenn alle AddOns geladen sind – ihre Quellen und Endpunkte sind dann bekannt, egal in welcher Reihenfolge sie starten. Ablauf: frühe Endpunkte · Alias-Domain → 301 · Endpunkte · Gates ·?article_id→ 301 · Sprachpräfix · Endung weg · Seite, Eintrag einer Quelle oder Resolver · kanonische Adresse prüfen · Weiterleitungen · 404 (mit Protokoll). - Cache: Die Adressen der Seiten liegen als PHP-Datei im Cache (
var/cache/routing/pages.php, OPcache), die Einträge jeder Quelle in einer eigenen Datei, die erst geladen wird, wenn eine Adresse der Quelle gebraucht wird. Eine Adresse, die keine Seite ist, kostet eine indizierte Abfrage.console cache:clearleert ihn, er entsteht von selbst neu.
Einstellen in Project::configure():
use FriendsOfRedaxo\Fluid\Routing\{Router, RoutingConfig, UrlFormat};
use FriendsOfRedaxo\Fluid\Routing\Seo\{Head, HeadConfig, HeadTag};
Router::$config = new RoutingConfig(
format: UrlFormat::mixed(), // none() /a/b · slash() /a/b/ · html() /a/b.html · mixed() /a/ + /a/b.html · suffix('.htm')
cache: true, // Adressen als PHP-Datei im Cache
redirectBy: 'REDAXO', // Kopfzeile X-Redirect-By ('' = keine)
enabled: true, // false: Rewriter ganz aus (z. B. wenn yrewrite die Adressen übernimmt)
);
Head::$config = new HeadConfig(
siteName: null, // og:site_name und Titel; fluid setzt den Namen aus Website → Allgemein
tags: [HeadTag::meta('theme-color', '#1f7a4d')], // auf jeder Seite
twitterCard: 'summary_large_image', // null = kein twitter:card
);Gespeichert wird immer ohne Endung – ein Wechsel des Formats braucht keinen Neuaufbau, alte Schreibweisen (auch .html
und .htm) leiten per 301 um. Startseiten (/, /en/) haben nie eine Endung. Für ganz eigene Regeln lässt sich
UrlFormat ableiten (append(), strip()).
Eigene Adressen für Einträge (Quellen)
News, Termine, Produkte … bekommen eine Adresse unter ihrer Seite – gespeichert wie Seiten (rex_fluid_route_entry),
eindeutig gegen Seiten und andere Einträge, mit 301 nach einer Änderung (route://quelle/ID), in der Sitemap (mit
hreflang und Bildern) und mit Canonical, hreflang, Open Graph und schema.org im Seitenkopf. Die Seite bekommt die
Parameter des Eintrags (Request und $_GET) und gibt ihn aus; gibt es den Eintrag nicht mehr, kommt 404.
Aus einer Tabelle (auch YForm) nur mit Attributen – REDAXO findet die Klasse selbst (examples/routing/NewsRoutes.php):
#[AsRouteSource('news', label: 'News')]
#[FromTable('rex_news', page: 12, where: 'status = 1', param: 'news_id')] // language: 'clang_id' – sonst alle Sprachen
#[Slug('title')] // withId: true → /aktuelles/titel-12
#[Title('seo_title')] // sonst die Slug-Spalte
#[Description('teaser')] // ohne HTML, gekürzt
#[Image('image')] // Datei(en) des Medienpools – die erste zählt
#[Modified('updatedate')] // Zeitstempel oder Datum
final class NewsRoutes extends TableSource {}Für alles andere eine eigene Quelle mit entries() (und optional meta(), exists(), editable(), save()):
#[AsRouteSource('events', label: 'Termine')]
final class EventRoutes extends RouteSource
{
public function entries(): iterable
{
foreach (Event::upcoming() as $e) {
yield new RouteEntry('events', (string) $e->id, $e->languageId, articleId: 7,
slug: $e->date->format('Y') . '/' . $e->title, params: ['event' => $e->id], modified: $e->updated);
}
}
public function meta(RouteEntry $entry): ?PageMeta
{
$e = Event::get((int) $entry->id);
return new PageMeta(title: $e->title, description: $e->teaser, image: $e->image, type: 'article');
}
}Router::urlFor('news', $id); // Adresse (aktuelle Sprache), Router::urlFor('news', $id, 2) in Sprache 2
Router::canonicalFor('news', $id, 1); // absolut
Routes::rebuildSource('news'); // nach dem Speichern (alle Einträge der Quelle)
Routes::put($entry); Routes::remove('news', $id); // einzelne Einträge
Router::current()?->entry; // Eintrag der aktuellen Adresse (oder null)Konsole: routing:rebuild (Seiten und alle Quellen), routing:rebuild --source=news.
Bearbeiten auf der Website: Spalten mit #[Slug], #[Title], #[Description] und #[Image] (Standard
editable: true) zeigt fluid in den Seiteneinstellungen der Detailseite über den Feldern der Seite. Gespeichert wird
bei YForm-Tabellen über die Record-API (Prüfungen und Zeitstempel der Tabelle greifen, Recht auf die Tabelle nötig),
sonst über RouteSource::save(); danach wird die Quelle neu berechnet und der Editor springt auf die neue Adresse.
Dynamische Adressen ohne Speicherung (z. B. die Detailseiten der Datenlisten /seite/titel-ID) liefern einen
RouteMatch – mit kanonischer Adresse (abweichende Anfragen → 301), Parametern, Meta-Angaben und Sprachfassungen:
Router::resolver(static function (string $path, int $lang, array $query): ?RouteMatch {
// $path ohne Sprachpräfix und Endung
return …matches… ? new RouteMatch($pageId, $lang, ['item' => $id], url: $canonical, meta: new PageMeta(title: …)) : null;
});Seitenkopf (Routing\Seo\Head, im Template Head::title() und Head::render() – bzw. die Kurzformen
PageSettings::title()/head()): Titel, Beschreibung, robots, Canonical, hreflang samt x-default, og:type,
og:url, og:title, og:description, og:site_name, og:locale (+ og:locale:alternate), og:image mit Typ,
Maßen und Alt-Text, article:published_time/modified_time, twitter:card und JSON-LD. Von X/Twitter kommt nur
twitter:card – Titel, Text und Bild liest X aus Open Graph.
Woher die Angaben kommen (leere Werte überschreiben nie): Seite (Name, Adresse, Sprachen) → ArticleMeta (fluid: SEO-Titel,
Beschreibung bzw. Standard der Website, Bild, „nicht indexieren“) → Eintrag der Quelle bzw. RouteMatch → Head::override()
(Blöcke; PageSettings::override() ist die Kurzform) → PageMetaResolved (fluid: Bild für soziale Netzwerke mit
image_editor 1200 × 630 als JPEG um den Fokuspunkt, schema.org-@graph).
Eigene Tags: HeadTag::meta(), ::property(), ::link(), ::html() (nur aus Code) und ::remove('property:og:image').
Gleicher Schlüssel ersetzt – so lässt sich jede Angabe des Rewriters überschreiben. Anmelden über HeadConfig::$tags
(jede Seite), Head::add() (diese Anfrage) oder $ep->subject->add() in PageMetaResolved.
Domains, Sitemap, robots.txt
-
Domains (Website → Domains): Hauptadresse, weitere Domains (301 auf die Hauptadresse), Landing-Domains (eigene Domain für eine Seite, optional mit Unterseiten, Canonical „eigene Domain“ oder „Spiegel“).
-
sitemap.xmlje Domain (Hauptadresse bzw. Landing-Domain): Seiten, die online sind (samt Eltern), nicht auf „nicht indexieren“ stehen und deren Canonical auf diese Domain zeigt – mitlastmod(Änderungsdatum mit Uhrzeit), hreflang samtx-default(Startsprache), bis zu fünf Bildern je Adresse (Vorschaubild der Seite, Bilder der veröffentlichten Blöcke), den Einträgen der Quellen und den Detailseiten von Datenlisten. Ab 45 000 Adressen ein Sitemap-Index mit/sitemap-1.xml,/sitemap-2.xml… Im Browser zeigt/sitemap.xsldie Adressen als Tabelle; Sitemap und robots.txt kommen mitCache-Control: no-cache. Ergänzen:Sitemap::exclude(static fn (int $id, int $lang): bool => …); // Seite weglassen Sitemap::images(static fn (int $id, int $lang): iterable => ['/media/x.jpg']); // Bilder einer Seite Sitemap::children(static fn (int $id, int $lang, string $loc): iterable => …); // dynamische Adressen unter einer Seite Extension::register(SitemapEntries::class, static fn (SitemapEntries $ep) => [...$ep->subject, ['loc' => 'https://example.org/extra', 'lastmod' => null, 'alternates' => []]]);
-
robots.txtje Domain mit Zeile „Sitemap:“; eigener Text unter Website → Recht & Suchmaschinen. Optional (Haken „Linkvorschau beim Teilen erlauben …“, Standard aus; im CodeRobots::allowPreviews()): Sperrt der Text die ganze Website (User-agent: *+Disallow: /), dürfen Linkvorschauen trotzdem lesen (Robots::PREVIEW_BOTS: Facebook, LinkedIn, WhatsApp …) – Suchmaschinen bleiben draußen. Eigene Regeln für diese Bots haben Vorrang. -
Erstes Bild im ersten Block der Seite: ohne Angabe im Kit sofort und mit
fetchpriority="high"(Einstieg, LCP);img(…, ['eager' => false])im Kit hat Vorrang. -
Weitere Sprachen bekommen ein Präfix (
/en/…); die Sprachwahl zeigt nur Sprachen, in denen die Seite online ist (Router::alternates($article)– auf der Adresse eines Eintrags dessen Sprachfassungen).
Website-Texte (Claim, Hinweisleiste, Fußtext …) pflegt man in „Website“ je Sprache; leer = Text der Startsprache.
Eigene Einstellungen werden mit 'translate' => true im Schema (EP FLUID_SETTINGS_SCHEMA) übersetzbar.
Sprachen sind die des Cores (LanguageHandler) – angelegt unter Website → Sprachen oder im Backend. Feste Texte des Kits
(„Menü“, „Öffnungszeiten“ …) schreibt das Kit mit lt('Text'); Übersetzungen liegen in kits/fluid/lang/site/{code}.php
(['Menü' => 'Menu', …]). Eigene Sprachen oder Änderungen ohne das Kit anzufassen:
Extension::register('FLUID_SITE_TRANSLATIONS', static function (ExtensionPoint $ep) {
return 'fr' === $ep->getParam('code') ? $ep->subject + ['Menü' => 'Menu', 'Suche' => 'Recherche'] : $ep->subject;
});- Vorschaubild: Media-Manager-Typ
fluid_poster– bei einem Video ein typisches Standbild per ffmpeg (bei ~10 % der Laufzeit), bei einem Bild nur verkleinert; Ausgabe AVIF/WebP, gespeichert im Cache des Media Managers. Ohne ffmpeg gibt es nur gewählte Vorschaubilder. Pfad bei Bedarf überFFMPEG_BIN/FFPROBE_BINin.env.MediaManager::getUrl('fluid_poster', 'film.mp4')funktioniert auch in eigenen Templates. - Untertitel und Kapitel:
.vttoder.srtim Medienpool, im Block je Spur Sprache und Art. fluid liefert sie geprüft als WebVTT unter/fluid-track/{datei}aus (SRT umgewandelt; nur erlaubte WebVTT-Tags). Option „gleich einschalten“ wählt die Spur in der Sprache der Seite; „Transkript“ zeigt den Text darunter (<details>). - Eigene Ausgabe:
fluid_video_embed($url, $datei, $poster, $format, $titel, $spuren, $einschalten, $transkript)bzw.FriendsOfRedaxo\Fluid\Media\Tracks(parse(),toVtt(),transcript()).
Beiträge sind normale Artikel in einer Kategorie mit „Blog / News“ (Seiteneinstellungen der Menüseite) – Editor, Entwurf,
Versionen, Rechte, Adressen, Sprachen und Suche gelten wie überall. Dazu Datum, Autor, Schlagworte (rex_fluid_page);
Teaserbild = Bild für soziale Netzwerke, Kurztext = Beschreibung. Sortierung nach Datum, neueste zuerst.
- Beitragsseiten bekommen im Template Kopf (Titel, Datum, Autor, Schlagworte, Bild) und „Älterer/Neuerer Beitrag“
(
fluid_post_header(),fluid_post_footer()im Kit), schema.orgBlogPosting, die ÜbersichtBlog. - RSS 2.0 unter
/{blog}/feed.xml(EPFLUID_BLOG_FEED),<link rel="alternate">im Kopf. - Eigene Ausgaben:
Blog::posts($blogId, ['limit' => 3, 'tag' => 'team'])liefertPost-Objekte (article,date,author,tags,image,excerpt,url);Blog::blogOf($article),Blog::tags($blogId). - Feldtyp
blog(Auswahl der Blogs) für eigene Blöcke.
MapLibre GL und OpenFreeMap über den eigenen Proxy /proxy/ofm/… (Cache im Cache-Ordner des AddOns). Besucher verbinden
sich nicht mit Dritten. Detailkacheln gibt es nur rund um Orte, die auf der Website als Karte vorkommen.
FriendsOfRedaxo\Fluid\Legal\Legal liefert Impressum, Datenschutz und Barrierefreiheit aus Website → Einstellungen →
„Recht & Suchmaschinen“ (Legal::url('privacy'), Legal::links(), Legal::article('imprint')). Ohne Einstellung gilt für
den Datenschutz eine Seite der obersten Ebene, deren Name mit „Datenschutz“ bzw. „Privacy“ beginnt.
Datenschutz im Dialog (optional, Standard aus) – in Project::configure():
Legal::$config = new LegalConfig(dialog: true);Dann öffnet der Link „Datenschutzhinweise“ unter Formularen die Erklärung im Dialog über dem Formular (Eingaben bleiben
erhalten). Eigene Links: <a href="<?= escape(Legal::url('privacy')) ?>"<?= LegalDialog::attrs() ?>>…</a><?= LegalDialog::script() ?>.
Der Endpunkt /_legal/privacy?lang=ID liefert Titel und bereinigtes HTML; das geht nur mit einer Seite der Website
(nicht mit einer externen Adresse) und ohne JavaScript bleibt es ein gewöhnlicher Link.
FriendsOfRedaxo\Fluid\Chart\Chart zeichnet Diagramme mit austauschbarer Engine (aus KLXM Studio). Aufrufer
beschreiben ein Diagramm in einem eigenen, schlichten Format – nie im Format einer Bibliothek:
echo Chart::render([
'type' => 'bar', // line | area | bar | hbar | pie | donut (Torte/Ring: eine Reihe)
'labels' => ['Jan', 'Feb', 'Mär'],
'series' => [['name' => 'Anfragen', 'data' => [12, 19, 15]]],
'description' => 'Anfragen je Monat', // Screenreader (role="img")
'format' => 'number', // number | percent | bytes | duration (Sekunden)
'unit' => '', 'stacked' => false, 'height' => 320,
], ['table' => false]); // true = Tabelle aufgeklappt, 'none' = ohne- Ausgabe:
<figure data-chart>mit den Daten als<script type="application/json">, einer Tabelle („Als Tabelle anzeigen“, auch ohne JavaScript) und einmal je Seiteassets/css/charts.cssundassets/js/charts.mjs. Der Lader holt die Engine erst in Sichtweite und zeichnet bei Hell/Dunkel neu. - Farben:
--chart-1…--chart-8(hell),--chart-d1…--chart-d8(dunkel), Text und Linien aus der Umgebung; Kits setzen--chart-c1…c6(das Kit fluid nimmt für Reihe 1 die Akzentfarbe). - Engine tauschen: Klasse mit
#[AsChartEngine('name')], dieChartEngineimplementiert (url()→ ES-Modul mitexport function render(el, spec, theme) → {update, resize, dispose, image?}), gewählt inProject::configure():Chart::$config = new ChartConfig(engine: 'name'). Vorlage:resources/js/charts-echarts.mjs(Quelle des Bundlesassets/vendor/echarts/charts-echarts.mjs, Apache ECharts, Apache-2.0, nur Linie/Balken/Torte, Canvas). - Datenquellen: statische Methode mit
#[AsChartSource('key', 'Bezeichnung')], dielabelsundseriesliefert (optionaltype,format,unit,description) – erscheint im Block „Diagramm“ unter „Daten“, Werte bei jedem Aufruf frisch. Beispiel:kits/fluid/src/ChartSources.php. - Block „Diagramm“ (
fluid.chart): Art, Daten (von Hand oder Quelle), Beschriftungen je Zeile, Reihen alsCollection(Werte mit;oder Zeilenumbruch,Chart::number()liest „1.250“, „12,5“, „3.5“), Zahlen, Einheit, Stapeln, Höhe, Tabelle, Satz für Screenreader; Darstellungen „standard“ und „split“.
Zwei Arten von Ständen in der Historie-Tabelle des Cores (rex_article_slice_history, Spalte revision), je Seite,
Sprache und Fassung die letzten 40 (Versions::KEEP):
-
Veröffentlicht (Archiv, Live-Version): Jede Live-Schaltung legt den Stand an, der live geht. Lag die bisherige Live-Version noch nicht im Archiv (z. B. im Backend direkt geändert), wird sie vorher gesichert („Vor dem Veröffentlichen“). Geht etwas unverändert noch einmal live, entsteht kein neuer Stand.
-
Entwurf (Zwischenstände der Arbeitsversion): beim Speichern (zusammengefasst, solange dieselbe Person innerhalb von 2 Minuten weiterarbeitet), vor dem Verwerfen und vor dem Wiederherstellen. Sie waren nie online und werden bei der Live-Schaltung geleert (
Versions::publish()). -
Wiederherstellen holt jeden Stand in den Entwurf; live geht er erst mit „Veröffentlichen“, Korrekturen davor sind möglich. Ohne Arbeitsversion (System → Einstellungen) schreibt es direkt live.
-
Die Historie unterscheidet Stände nur nach ihrem Zeitpunkt (Sekunde) – fluid weicht auf die nächste freie Sekunde aus.
-
Die Ansicht zeigt beide Arten getrennt (Seitenleiste: zwei Listen, Zeitleiste: Kennzeichen „Entwurf“/„Veröffentlicht“). Die Historie im Backend des Cores zeigt nur die veröffentlichten Stände.
-
Ansicht:
assets/editor/versions.js+versions.css(aus KLXM Studio übernommen), Daten von?fluid_api=version_view. -
Vorschau eines Stands:
?fluid_version=now|live|<revision>-<YmdHis>rendert die Seite aus der Historie. Dafür tauschtArticleSlicesQuerydie Tabelle gegen die Zeilen des Stands. Die Vorschau gibt es nur mit Recht auf die Seite, ohne Leiste und nie aus dem Cache.&fluid_mark=1setztdata-vdiff="new|changed"am äußersten Element geänderter Blöcke (SliceOutput). -
Vergleich je Block: erst über die Slice-ID, dann über gleichen Inhalt, dann über Blocktyp in Reihenfolge. Veröffentlichen und Wiederherstellen legen Slices mit neuen IDs an.
-
Ältere Stände mit demselben Inhalt wie ein neuerer erscheinen nicht.
src/Mcp/ – Model Context Protocol über „Streamable HTTP“, zustandslos, JSON-Antworten (Aufbau wie in KLXM Studio).
- Endpunkt
POST /fluid-mcp. Ohne schöne Adressen geht/index.php?fluid_mcp=1. Der Router reicht die Anfrage anPACKAGES_INCLUDEDweiter, damit YForm geladen ist. - Anmeldung mit
Authorization: Bearer fluid_…(alternativX-Api-Key).- In
rex_fluid_mcp_tokensteht nur der SHA-256-Hash. - Jeder Schlüssel handelt als REDAXO-Benutzer:
Core::setUser()undEditing::actAs(). Dadurch gelten Struktur- und Modulrechte, und Aktivität und Versionen nennen den Benutzer.
- In
Access: Schlüssel (read/write, Veröffentlichen nur mitversion[live_version]) oder öffentlich.- Öffentlich gibt es nur, was der Admin in
fluid→ Configmcpfreigibt: Seiten, Suche, Blog, Tabellen. - Öffentlich heißt: nur live, ohne Offline-Blöcke, ohne Optionen.
- Öffentlich gibt es nur, was der Admin in
Tools: Die Liste hängt vom Zugang ab. Werkzeuge ohne Recht erscheinen gar nicht. Fehler der Werkzeuge kommen alsisErrorzurück, Ergebnisse alsstructuredContentund als Text.Content: Schreiben geht immer in den Entwurf und nutzt dieselben Wege wie der Editor (BlockOps,Versions,Activity,Api::publish).- Es werden nur Felder übernommen, die der Blocktyp kennt.
- Rich-Text läuft durch
RichText::html, andere Texte ohne Tags.
- Grenzen: 300 Anfragen je 5 Minuten und Schlüssel, 60 öffentlich je IP (Dateien im Cache des AddOns).
- Der Origin-Header wird geprüft (Schutz vor DNS-Rebinding).
GETantwortet mit 405, es gibt keinen SSE-Stream.
Testen ohne KI-Werkzeug:
curl -s https://example.org/fluid-mcp -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"site_overview","arguments":{}}}'fluid nutzt den Mailer des Cores (Core::getMailer(), Symfony Mailer). Eingerichtet wird er in .env.local:
MAILER_DSN="smtp://benutzer:passwort@smtp.example.org:587" # null://null = Mails verwerfen (Demo, Test)
REX_MAILER_FROM="Website <noreply@example.org>" # Absender, wenn eine Mail keinen eigenen hat
REX_MAILER_RECIPIENTS="test@example.org" # Staging: alle Mails nur hierhin
REX_MAILER_LOG=errors # none | errors | all → var/log/mail.log
REX_MAILER_ARCHIVE=1 # Mails als .eml aufbewahren (auch nicht gesendete)Antwortet der Mailserver nicht, wartet PHP bis default_socket_timeout (Standard 60 s) – in der php.ini kürzer stellen.
Ab Symfony 8.2 geht das auch in der DSN: smtp://…?timeout=10.
- Statusseite (AddOns → fluid → Status → „E-Mail“): Versand, Absender, Umleitung, Protokoll, Archiv, Testmail,
die letzten Fehler und nicht gesendete Mails mit „Erneut senden“ – an die ursprünglichen Empfänger samt Bcc, sofern der
Core sie als
X-Envelope-Toim Archiv vermerkt (redaxo/core#6755), sonst an An und Kopie.BccundX-Envelope-Towerden vor dem Senden aus dem Kopf entfernt. - Formulare (Block „Formular“) senden HTML-Mails im Stil der Website – Logo (kein SVG, das zeigen Gmail und Outlook nicht), Akzentfarbe des Kits, Anschrift im Fuß – mit Textteil für Mailprogramme ohne HTML.
- Mailfehler bei Formularen verwerfen die Eingabe nicht: Ist der Eintrag gespeichert, landet der Fehler im
Systemprotokoll und in der Aktivität („Formular-Mail nicht gesendet“), die Bestätigung wird trotzdem versucht und
Besucher sehen die Erfolgsmeldung – mit Hinweis, falls die Bestätigung nicht rausging (
Data\SafeMail). - Aufbewahrung: Der Cronjob
fluid_mail_archive_purge(täglich,#[AsCronjob]) löscht archivierte Mails nach „Gesendete Mails aufbewahren (Tage)“ (Website → Recht & Suchmaschinen; Standard 90, 0 = nie). Dazu muss der System-Cron jede Minutephp bin/console cronjob:runaufrufen. - Eigene Mails im Stil der Website:
Mail::layout($titel, $html)liefert die HTML-Hülle.
Core::getMailer()->send(new Email()->to('info@example.org')->subject('Neue Bestellung')
->html(\FriendsOfRedaxo\Fluid\Mail\Mail::layout('Neue Bestellung', '<p>…</p>')));src/Search/Search.php – Loupe (SQLite) je Sprache, aus den Blockdaten der veröffentlichten Fassung. In den Index kommt
nur, was Besucher sehen können:
- Seiten, die samt Eltern online sind, in einer Sprache, die online ist. Ohne „nicht indexieren“, ohne Seitenvorlagen, ohne die Fehlerseite.
- Nur die Live-Fassung, nie Entwürfe der Arbeitsversion.
- Nur Blöcke, die online sind. Spalten eines Layouts zählen nur, wenn auch das Layout online ist.
- Nur Felder vom Typ Text, Textarea und Rich-Text. Redaktionskommentare
[# … #]werden entfernt. - Einstellungen, die nicht auf der Seite stehen, fehlen: Empfänger und Betreff von Formularen, Bestätigungstexte,
Platzhalter, Filterwerte, Hinweise für leere Listen (
Search::NO_FIELDS). - Eigene Blöcke:
'search' => falseam Feld (z. B. interne Kennungen) oder am ganzen Block im Kit.
Der Index wird neu aufgebaut, wenn sich etwas ändert: Veröffentlichen, Struktur (online/offline, verschieben, löschen),
Inhalte im Backend (ART_CONTENT_UPDATED), jede Aktion mit Eintrag in der Aktivität (Editor, MCP). Außerdem prüft
Search::search() jeden Treffer vor der Ausgabe noch einmal. Ist eine Seite inzwischen nicht mehr öffentlich, fällt
der Treffer weg und der Index wird sofort neu aufgebaut. Das gilt auch für das MCP-Werkzeug search.
Filtern, Blättern und Suchen ohne Seitenwechsel – aus KLXM Studio (Core\Live, MIT), gleiche Attribute und Skripte.
Die Blöcke Datenliste (Blättern), Beiträge (Schlagworte, Blättern) und Suche (Treffer beim Tippen) nutzen es.
Ohne JavaScript bleiben es normale Links und Formulare.
Eigener Block:
// an der Blockklasse: #[AsBlock(reload: true)]
<div class="wrap …"<?= \FriendsOfRedaxo\Fluid\Live::region($b) ?>>
<a href="?seite=2#<?= $b->domId() ?>" data-live-nav>Weiter</a>
<form method="get" data-live-nav><input name="q" id="…" data-live-input data-live-delay="600" data-live-min="3"></form>
<p role="status" data-live-count>12 Treffer</p>
</div>
<?= \FriendsOfRedaxo\Fluid\Live::assets() ?>- Eingabefelder:
data-live-delay= Pause nach dem letzten Tastendruck (Standard 350 ms),data-live-min= erst ab so vielen Zeichen. Ein geleertes Feld lädt sofort neu (zurücksetzen). Die Suche nutzt 600 ms und 3 Zeichen. Live::region($b)setztdata-live-region(=domId) unddata-live-src(Seitenadresse mit?fluid_block={slice}). Im Bearbeiten-Modus und bei Blöcken aus Daten bleibt es leer.assets/live.jsholt den Block mit den Parametern des Links bzw. Formulars und tauscht[data-live-region]aus.- Links und Absenden:
pushState, Eingabe:replaceState, Zurück/Vor lädt passend nach. - Laufende Anfragen werden abgebrochen, danach kommt das Ereignis
cms:content-replaced. - Der Fokus bleibt im Feld (mit Cursor) bzw. geht an den Block. Die Ansage für Screenreader nimmt den Text aus
[data-live-count]. - Bei Fehlern lädt es die Seite normal.
- Links und Absenden:
- Der Server (
Live::block(), überFE_OUTPUT) rendert nur Blöcke mitreload(bzw.'live'in der Definition) der veröffentlichten Seite. Die übrigen Parameter liest der Block wie beim normalen Aufruf und prüft sie selbst.fluid_blockist reserviert.
Versionen statt Inhalte über Server-Sent Events: GET /fluid-live?t={abo}, Ereignis v = {abo: version}.
- Kanäle sind Namen, deren Version steigt:
Live::touch('ext:shop:lager'). fluid selbst setztpage:{id}beim Veröffentlichen undpresence:defaultbei Kommen und Gehen in der Präsenz. - Abos sind signiert (HMAC mit einem Schlüssel in der Konfiguration des AddOns):
Live::token([...]),Live::attrs([...], $src)fürs Element.live.jshält eine Verbindung je Seite. Neue Version → Element mitdata-live-srcneu laden ([data-live-list],[data-live-id]) bzw. Ereigniscms:livefür eigenes JavaScript. - Betrieb: höchstens 16 offene Streams zu je 20 Sekunden. Darüber und auf dem Entwicklungsserver (
php -S) antwortet der Endpunkt sofort, und der Browser fragt alle 15 Sekunden nach.
Wer arbeitet gerade wo – aus KLXM Studio (Core\Presence, presence.mjs), das Modul ist unverändert übernommen.
- In der Leiste: „Anna bearbeitet gerade“ (Initialen), sonst „Zuletzt gespeichert: Ben, vor 5 Min.“ (aus der Aktivität).
- Konflikt: Hat jemand anderes seit dem Öffnen gespeichert, zeigt die Leiste ⚠. Vor dem nächsten Speichern fragt fluid nach. Ohne Bearbeitungssperre ist „Trotzdem speichern“ erlaubt, der vorige Stand bleibt als Version.
- Im Seitenbaum ein Kürzel an Seiten, die gerade jemand bearbeitet. Unter „Verlauf & Entwürfe“ → Aktivität die Liste „Gerade aktiv“.
- Nur für angemeldete Backend-Benutzer mit Recht auf die Seite. Gespeichert werden nur Name, Ort und Zeitpunkt der letzten
Meldung (Cache des AddOns, 45 Sekunden), es gibt kein Protokoll. Abschalten: Config
presencedes AddOns auffalse.
Bearbeitungssperre (EditLock, aus Studio; Website → Einstellungen → Redaktion, Standard aus, braucht die Präsenz):
Eine Seite in einer Sprache bearbeitet immer nur eine Person. Gesperrt hält, wer sie am längsten ohne Unterbrechung im
Bearbeiten-Modus hat – abgeleitet aus der Präsenz, ohne eigene Tabelle und ohne hängende Sperren (frei spätestens etwa
45 Sekunden nach dem Schließen).
- Andere sehen die Seite nur an, auch mit
?fluid_edit=1(Editing::requested()), mit Hinweiszeile unter der Leiste; wird sie frei: „Jetzt bearbeiten“. - Speichern lehnt fluid mit HTTP 423 ab – alle Aktionen des Editors, die die Seite ändern (
Api::LOCKED), die Seitenleiste gewöhnlicher Module und MCP (Schlüssel ohne Person gelten als fremd). Gäste und Abläufe ohne Person (Konsole, Cronjob) sperrt nichts; das Backend des Cores kennt die Sperre nicht. - Administratoren können übernehmen („Übernehmen“ in der Hinweiszeile): Ihr Eintrag rückt vor den bisherigen Inhaber, dem der Editor beim nächsten Herzschlag sagt, dass er nicht mehr speichern kann. Vermerk in der Aktivität.
- Im Code:
EditLock::on(),holder($article),blockedFor($article, $userId),assert($article, $userId),take($article, $userId); Selbsttest inconsole fluid:selftest.
Teile:
| Teil | Aufgabe |
|---|---|
src/Presence.php |
Speicher: beat($who, $meta), leave($who), list($filter), setSince($who, $since); Schlüssel site/type/id/user, kennt keine Artikel |
src/EditLock.php |
Bearbeitungssperre aus der Präsenz: Inhaber, Prüfung beim Speichern (423), Übernehmen |
src/PresenceApi.php |
Anbindung: Endpunkt /fluid-presence, Rechte, „zuletzt gespeichert“, Attribute für die Leiste |
assets/editor/presence.js |
Oberfläche, nur über data-Attribute und Ereignisse gekoppelt |
Live |
Kanal presence:default – Kommen und Gehen sofort statt nach 15 Sekunden |
Schema (wie in Studio):
POST /fluid-presence {type: "article", id: "{sprache}:{artikel}", label?, url?, leave?, peek?, _csrf?}
POST /fluid-presence {…, take: 1} Bearbeitungssperre übernehmen (Administration)
→ {on, me, here: [Person], last: Stand|null, lock: {on, by: Person|null, mine, canTake}, live: Abo}
GET /fluid-presence → {on, me, items: [{type, id, label, url, …Person}], live: Abo}
Person = {user, name, initials, avatar, since, at, self} Stand = {user, name, at, self}
[data-presence-here]mitdata-presence-endpoint/-type/-id/-label/-url/-csrf/-live/-texts/-editing– meldet sich alle 15 Sekunden (im Ansehen-Modus nurpeek), beim Verlassen persendBeaconab.[data-presence-list]mitdata-presence-typeund-prefix; Einträge[data-id]bekommen ein Kürzel in[data-presence-slot].[data-presence-board]– Liste „Gerade aktiv“,[data-presence-board-box]wird ohne Einträge ausgeblendet.- Später eingefügte Listen (Seitenleisten): Ereignis
cms:presence-scanamdocument. - Konflikt: Der Editor löst vor schreibenden Aktionen
cms:before-saveaus (detail.waitUntil(promise);falsebricht ab). Die Rückfrage läuft überwindow.FluidEditor.ask. Ereigniscms:presenceamdocument:{here, last, conflict, lock}. - Gesperrt:
[data-presence-here]trägtdata-presence-locked="1", die Hinweiszeile.cms-lock-note(mit[data-presence-take]) liegt in derselben Wurzel wie die Leiste.
Einbau in andere Systeme: Presence.php und presence.js kopieren, einen Endpunkt nach dem Schema oben schreiben
(Rechte prüfen, beat/leave/list, „zuletzt gespeichert“ aus der eigenen Historie) und die Attribute ausgeben.
Ohne Live-Kanal funktioniert es auch, dann mit 15 Sekunden Verzögerung.
| Befehl | |
|---|---|
fluid:demo --set=redaxo6 |
Demo-Website anlegen (nur in einer leeren Installation; YForm-Beispieldaten gleich mit) |
… --media |
Demo-Bilder des Kits in den Medienpool übernehmen |
… --design=redaxo |
Design-Vorlage setzen (redaxo zusätzlich mit REDAXO-Logo aus dem Core); andere: kanzlei, handwerk, praxis, aurora … – auf einer Website mit Seiten nur die Vorlage |
… --design=native --kit=native |
Design-Vorlage für ein bestimmtes Kit setzen (Standard: das gewählte) |
… --replace |
vorher alle Seiten löschen – nur Testinstallationen |
… --page=slug |
nur eine Demo-Seite anhängen, z. B. --page=projekte |
… --yform |
nur die Beispielzeilen der YForm-Tabellen schreiben |
fluid:kit:create name |
eigenes Kit im Projekt anlegen (--label, --parent, --view=hero,cards, --template) |
fluid:search-index |
Suchindex neu aufbauen (sonst automatisch nach dem Veröffentlichen) |
fluid:selftest |
Live-Aktualisierung und Präsenz prüfen |
fluid:upgrade:slices |
Blöcke älterer REDAXO-6-Datenbanken in die Spalte data übernehmen (--drop-old entfernt die alten Spalten) |
fluid nutzt die Rechte des Cores – eine Rolle wirkt auf der Website genau wie im Backend:
- Kategorien (Strukturrecht) und Module (welche Blöcke) bestimmen, was jemand bearbeiten und einfügen darf.
- Optionen der Rolle schalten Aktionen frei:
version[live_version]Veröffentlichen (ohne: „Entwurf – wartet auf Freigabe“),moveSlice[]Blöcke verschieben,publishSlice[]Blöcke online/offline,addArticle[]/addCategory[]neue Seiten bzw. Menüpunkte,editArticle[]/editCategory[]Seiteneinstellungen,moveArticle[]/moveCategory[],publishArticle[]/publishCategory[],deleteArticle[]/deleteCategory[],article2category[](Menüpunkt ja/nein),copyContent[](Inhalt aus anderer Sprache). Die Leiste zeigt nur, was erlaubt ist; die API prüft jede Aktion. - Design und Website-Einstellungen: Admins. YForm-Tabellen: Rechte von YForm (Tabellen ansehen/bearbeiten).
fluid selbst kennt nur angemeldete Backend-Benutzer. Ein AddOn kann Besucher ohne Anmeldung als Gäste zulassen,
zum Beispiel fluid_demo, die Demo zum Ausprobieren. Ohne ein solches AddOn gibt es keine Gäste.
FLUID_GUEST_USER(Subjektnull): Liefert ein AddOn einenUser, arbeitet der Besucher mit dessen Rechten. Admins werden abgewiesen.Editing::isGuest()ist danntrue.Editing::guestRevision(int): Revision, in der Gäste arbeiten.fluid_demonutzt die Live-Fassung, aber nur in einer Transaktion, die immer zurückgerollt wird.FLUID_API_ACTION(Parameteraction,article,user,guest,run,state): Ein AddOn kann jede Aktion des Editors übernehmen und['result' => [...], 'state' => [...]]zurückgeben.run()führt die Aktion mit dem normalen Code aus (Api::dispatch()),state()liefert den Stand des Artikels. Aktionen von Gästen führt fluid nie selbst aus. Übernimmt kein AddOn, antwortet die API mit 403.- Für Gäste aus: Präsenz, Versionen-Vorschau, Blöcke in YForm-Datensätzen, YForm-Links, Bildeditor, Medienpool, Postfach, Design, Website-Einstellungen, Seitenbaum, Verlauf, Backend-Links. Die Beitragsliste zeigt keine Entwürfe. Die Sprachwahl zeigt nur Sprachen, in denen die Seite online ist. Der Suchindex wird nicht als veraltet markiert.
- Editor: AddOns hängen sich über
FLUID_EDITOR_CONFIGein:scripts: Skripte, die vor dem Editor laden.notice:{tag, text, buttons: [{label, event, hidden}]}, ein Hinweis in der Leiste. Die Knöpfe lösen ihr Ereignis amdocumentaus.- Im Browser:
window.fluidEditorWait(Promises, auf die der Editor beim Start wartet),fluid:api-request(detail.fdergänzen),fluid:api-done(detail.json),fluid:notice(Meldung zeigen),window.FluidEditor.ask/discard. window.fluidPickerOff(Text): Statt Medienpool oder Linkmap erscheint nur dieser Hinweis.
- Interne Felder, die Gäste nicht sehen sollen, nennt
Search::NO_FIELDSbzw.'search' => falseam Feld. Die Demo leert sie, bevor Gäste etwas zu sehen bekommen.
fluid ist aus Bausteinen gebaut, die sich später einzeln als eigene AddOns herauslösen lassen – so wie der Bildeditor, der schon ein eigenes AddOn ist (image_editor). Damit das ohne Umbau geht, gelten feste Regeln:
- Abhängigkeiten zeigen nur in eine Richtung: Bausteine nutzen den Kern von fluid und das Routing, aber das
Routing kennt keinen Baustein, und der Kern kennt Bausteine nur über ihren
boot()-Aufruf inFluidAddon::boot(). - Anmelden statt Einbauen: Ein Baustein meldet seine Adressen, Sitemap-Einträge und Kopf-Angaben in
boot()an (Haken unten bzw. Extension Points). Nichts davon steht fest im Router oder im Template. - Eigene Tabellen, eigener Namespace: Jeder Baustein hat seinen Ordner unter
src/, eigene Tabellen (rex_fluid_…) und keine Abfragen auf Tabellen anderer Bausteine.
| Baustein | Ordner | Meldet an | Braucht | Stand |
|---|---|---|---|---|
| Routing (schöne Adressen, Quellen, Domains, Weiterleitungen, 404-Protokoll, sitemap.xml, robots.txt, Seitenkopf) | src/Routing/ |
ArticleUrl, Strukturänderungen, PACKAGES_INCLUDED (alles per #[AsExtension]) |
nur den Core | herauslösbar – kennt nichts von fluid |
| SEO von fluid (SEO-Felder, Bild für soziale Netzwerke, schema.org) | src/Seo/, PageSettings |
ArticleMeta, PageMetaResolved |
Routing, Einstellungen von fluid | bleibt im Kern |
| Suche (Loupe) | src/Search/ |
RoutesRebuilt |
Routing, Block-Inhalte | herauslösbar nach dem Routing |
| Karten (MapLibre, eigener Proxy) | src/Maps/ |
Endpunkt /proxy/*, Geo in schema.org |
Routing, Einstellungen | herauslösbar |
| Videos und Untertitel | src/Media/ |
Endpunkt /fluid-track/* |
Routing | herauslösbar |
| Datenlisten, Formulare, Postfach (YForm) | src/Data/ |
Resolver Detailseiten, Sitemap-Einträge | Routing, Blöcke, YForm | bleibt vorerst im Kern |
| Blog | src/Blog/ |
Resolver feed.xml, RSS im Kopf (PageMetaResolved), schema.org |
Routing, Seiteneinstellungen | herauslösbar |
| MCP-Server | src/Mcp/ |
Endpunkt /fluid-mcp |
Kern (Blöcke, Rechte) | herauslösbar |
| E-Mail (Status, erneut senden, Layout, Aufbewahrung) | src/Mail/ |
Cronjob fluid_mail_archive_purge |
Core-Mailer, Einstellungen, Kit (Farben) | herauslösbar |
| Live, Präsenz | src/Live.php, src/PresenceApi.php |
Endpunkte | Kern | bleibt im Kern |
| Demo | eigenes AddOn fluid_demo | Gastzugang | Kern | eigenständig |
| Bildeditor | eigenes AddOn image_editor | – | Core | eigenständig |
Haken des Routings (FriendsOfRedaxo\Fluid\Routing) – Attribute werden über die Klassenerkennung des Cores gefunden,
die Aufrufe gehen in boot() jedes AddOns (aufgelöst wird erst bei PACKAGES_INCLUDED):
#[AsEndpoint('/mein-dienst')] // an einer statischen Methode fn (string $path, string $rest): void
#[AsEndpoint('/dateien/*')] // Präfix, Rest als $rest
#[AsEndpoint('/mcp', early: true, param: 'mcp')] // vor der Domain-Umleitung, auch als ?mcp=…
#[AsRouteSource('news')] // Quelle eigener Adressen (RouteSource/TableSource)
Router::endpoint('/x', fn (string $path, string $rest) => …); // dasselbe ohne Attribut
Router::resolver(fn (string $path, int $lang, array $query): ?RouteMatch => …); // dynamische Adressen
Router::onPage(fn (int $id, int $lang, array $query) => …); // Seite gefunden (z. B. alte Parameter umleiten)
Router::gate(fn (string $path) => …); // vor der Seitensuche
Router::keep('mein_param'); // ?article_id=… mit diesem Parameter nicht umleiten
Sources::add(new MeineQuelle()); // Quelle ohne AttributAntworten schicken Bausteine mit Router::respond($body, $contentType), Weiterleitungen mit Router::redirectTo($url, 301).
Herauslösen eines Bausteins (Ablauf): Ordner in ein eigenes Paket verschieben (Namespace bleibt oder bekommt einen
eigenen), boot() aus FluidAddon::boot() in die Addon-Klasse des neuen Pakets, Tabellen über getOwnedSchema() des
neuen AddOns melden, Abhängigkeit in composer.json eintragen. Beim Routing: Router::boot() und Schema::install() in die
Addon-Klasse, Schema::TABLES in getOwnedSchema(); alles andere hängt schon per Attribut am Core.
| Pfad | Inhalt |
|---|---|
src/ |
Namespace FriendsOfRedaxo\Fluid: Editor-API (Api), Blöcke (BlockModule, Block, BlockData), Routing, Blog, Daten (YForm), Medien, Suche |
src/Data/ |
YForm-Anbindung: YFormBridge, Formular-Baukasten BuiltForm, Feld FluidBlocks, RecordBlocks, Detail, Postfach |
src/Field/ |
Felder: gleichnamige Unterklassen der Felder des Cores mit Ergänzungen, Felder nur von fluid (RichText, Link …), AsBlock, BlockSchema (Schema für fluid aus den Attributen) |
kits/fluid/ |
das Grund-Kit: src/Module/ (Blockklassen mit Feld-Attributen), views/ (Ausgaben), kit.php (Assets, Hintergründe), design.php (Design-Tokens, Vorlagen), functions.php, lang/ |
src/Kit.php, src/AsKit.php |
Kits: Anmeldung, Auswahl (Kit::active()), Vererbung, Assets |
kits/native/ |
Kit „NativeCSS“: template.view.php, views/, kit.php, assets/css/native.css, NativeCSS in assets/ncss/ |
assets/ |
Editor (editor/), Formulare (form.js, rte.js), Kit-CSS/JS (kits/fluid/), Schriften, MapLibre |
examples/ |
Vorlagen für eigene Blöcke und YForm-Tabellen |
pages/ |
Backend-Seite AddOns → fluid |
tools/video/ |
Aufnahme von Demo-Video und Trailer (Playwright + ffmpeg) |
git clone https://github.com/FriendsOfREDAXO/fluid.git
FLUID_SOURCE=$PWD/fluid bash fluid/install-dev.sh fluid-devFLUID_SOURCE (ebenso YFORM_SOURCE, IMAGE_EDITOR_SOURCE) bindet die lokale Arbeitskopie als Composer-Pfad-Repository
ein – als Kopie (symlink: false). Nach Änderungen: composer reinstall friendsofredaxo/fluid && php bin/console migrate.
Vorsicht mit Symlinks: Ist
public/assets/addons/fluidein Symlink auf die Arbeitskopie, löschtconsole migratebeim Neukopieren der Assets die Originale. Entweder kopieren lassen (wie oben) oder vormigrateden Symlink entfernen.
Upstream-Wächter (.github/workflows/upstream.yml): Prüft täglich redaxo/core 6.x und yakamara/yform 6.x. Bei
neuen Commits kommt ein Kommentar ins offene Issue „Neu in …“ (Label upstream). Gibt es kein offenes, entsteht ein
neues. Darin stehen die Commits mit Links. Wer benachrichtigt werden will, beobachtet das Repo oder trägt sich in der
Repo-Variable UPSTREAM_NOTIFY ein (GitHub-Namen, mit Leerzeichen getrennt). Ein Issue schließen heißt: gelesen. Starten
lässt sich der Wächter auch von Hand (Actions → Upstream-Wächter → Run workflow).
- PHPStan (Stufe 5): mit der PHPStan-Installation des Cores, Pfade
src,kits/fluid/src,kits/fluid/functions.php. - Video:
node tools/video/trailer.mjsbzw.redaxo6-demo.mjs– braucht Playwright, ffmpeg und eine lokale Anmelde-Hilfe (LOGIN=…), siehe Kopf der Skripte. Nur gegen Testinstallationen.