Der Editor packt eingefügten Text in <p>; Felder wie die FAQ-Antwort gibt die Website als Text aus, die Tags waren sichtbar. Beim Speichern entfernt unwrapParagraphs() die Absätze (ein Absatz ohne <p>, mehrere mit <br><br>, Blockinhalte unverändert). cms_inline() gibt die Felder als bereinigtes Inline-HTML aus – auch schon gespeicherte Texte mit <p> erscheinen sauber, Fett und Hervorhebung werden dargestellt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
9.7 KiB
PHP
292 lines
9.7 KiB
PHP
<?php
|
||
|
||
use App\Models\Setting;
|
||
use FluxCms\Core\Models\CmsMedia;
|
||
use FluxCms\Core\Services\CmsContentService;
|
||
|
||
if (! function_exists('legal_page')) {
|
||
/**
|
||
* Rechtstexte: zuerst CMS (Gruppe „legal“), sonst Lang-Datei b2in_legal.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
function legal_page(string $key): array
|
||
{
|
||
$fromCms = app(CmsContentService::class)->get('legal.'.$key);
|
||
|
||
if (is_array($fromCms) && isset($fromCms['content'])) {
|
||
return $fromCms;
|
||
}
|
||
|
||
return trans('b2in_legal.'.$key);
|
||
}
|
||
}
|
||
|
||
if (! function_exists('cms')) {
|
||
/**
|
||
* Resolve a CMS content value by dot-notation key.
|
||
*
|
||
* First segment is the group, rest is the key: "home.hero.title"
|
||
* Falls back to __() if no DB entry exists.
|
||
*
|
||
* @param array<string, string> $replace
|
||
*/
|
||
function cms(string $key, array $replace = [], ?string $locale = null): mixed
|
||
{
|
||
return app(CmsContentService::class)->get($key, $replace, $locale);
|
||
}
|
||
}
|
||
|
||
if (! function_exists('cms_theme_section')) {
|
||
/**
|
||
* Theme-Sektion: zuerst CMS (gemäß cms_section_map), sonst Lang b2in.themes.{theme}.{section}.
|
||
*
|
||
* @return array<string, mixed>|string|mixed
|
||
*/
|
||
function cms_theme_section(string $sectionKey, ?string $theme = null): mixed
|
||
{
|
||
$theme = $theme ?? config('app.theme', 'b2in');
|
||
|
||
/** @var array<string, array{0: string, 1: string}> $sections */
|
||
$sections = config('cms_section_map.sections', []);
|
||
|
||
if (isset($sections[$sectionKey])) {
|
||
[$group, $key] = $sections[$sectionKey];
|
||
$fromCms = app(CmsContentService::class)->getIfExists("{$group}.{$key}");
|
||
|
||
if ($fromCms !== null) {
|
||
return $fromCms;
|
||
}
|
||
}
|
||
|
||
return trans("b2in.themes.{$theme}.{$sectionKey}");
|
||
}
|
||
}
|
||
|
||
if (! function_exists('references_page_enabled')) {
|
||
/**
|
||
* Ob die Seite /referenzen samt Menüpunkt sichtbar ist – Schalter im CMS-Modul „Referenzen“
|
||
* (Einstellung pages.referenzen_enabled). Ohne Eintrag ist die Seite sichtbar.
|
||
*/
|
||
function references_page_enabled(): bool
|
||
{
|
||
return (bool) Setting::getValue('pages', 'referenzen_enabled', true);
|
||
}
|
||
}
|
||
|
||
if (! function_exists('cms_inline')) {
|
||
/**
|
||
* CMS-Text für die Ausgabe innerhalb einer Zeile oder eines Absatzes (12.10.2026): Klartext
|
||
* wird escaped, Editor-HTML auf Inline-Auszeichnung reduziert (fett, kursiv, Hervorhebung,
|
||
* Links, Zeilenumbrüche) – Absatz-Tags aus dem Editor erscheinen so nicht als Text.
|
||
*/
|
||
function cms_inline(mixed $value): \Illuminate\Support\HtmlString
|
||
{
|
||
$text = trim((string) $value);
|
||
|
||
if (! str_contains($text, '<')) {
|
||
return new \Illuminate\Support\HtmlString(e(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
||
}
|
||
|
||
$html = \App\Services\CmsFluxEditorHtmlTransformer::unwrapParagraphs(
|
||
\App\Services\CmsFluxEditorHtmlTransformer::fromEditor($text)
|
||
);
|
||
|
||
if (! str_contains($html, '<')) {
|
||
return new \Illuminate\Support\HtmlString(e($html));
|
||
}
|
||
$html = (string) preg_replace('#</(p|div|li|h[1-6])>\s*<\1[^>]*>#i', '<br><br>', $html);
|
||
$html = strip_tags($html, '<strong><b><em><i><span><br><a>');
|
||
// Nur harmlose Attribute: keine Event-Handler, keine javascript:-Links.
|
||
$html = (string) preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
|
||
$html = (string) preg_replace('#href\s*=\s*(["\'])\s*javascript:[^"\']*\1#i', 'href="#"', $html);
|
||
|
||
return new \Illuminate\Support\HtmlString(trim($html));
|
||
}
|
||
}
|
||
|
||
if (! function_exists('whatsapp_link')) {
|
||
/**
|
||
* Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp).
|
||
*
|
||
* Liefert einen leeren String, solange keine Nummer bestaetigt ist – der Button erscheint dann
|
||
* gar nicht erst, statt auf eine erfundene Nummer zu zeigen.
|
||
*/
|
||
function whatsapp_link(): string
|
||
{
|
||
$service = app(CmsContentService::class);
|
||
|
||
// Die Nummer kann als eigener Eintrag oder als Feld der Sektion `global.contact` liegen.
|
||
$number = $service->getIfExists('global.contact.whatsapp');
|
||
|
||
if ($number === null) {
|
||
$contact = $service->getIfExists('global.contact');
|
||
$number = is_array($contact) ? ($contact['whatsapp'] ?? null) : null;
|
||
}
|
||
|
||
if (! is_string($number)) {
|
||
return '';
|
||
}
|
||
|
||
$digits = preg_replace('/\D+/', '', $number);
|
||
|
||
return $digits === '' ? '' : 'https://wa.me/'.$digits;
|
||
}
|
||
}
|
||
|
||
if (! function_exists('theme_image_url')) {
|
||
/**
|
||
* Bild-URL für Theme-/CMS-Inhalte: zuerst Medienbibliothek (CmsMedia, oft nur Dateiname z. B. *.webp),
|
||
* sonst statische Assets unter public/img/assets/ (Legacy-Pfade wie b2in/…).
|
||
*/
|
||
function theme_image_url(?string $value): string
|
||
{
|
||
if ($value === null) {
|
||
return '';
|
||
}
|
||
|
||
$trimmed = ltrim(trim($value), '/');
|
||
if ($trimmed === '') {
|
||
return '';
|
||
}
|
||
|
||
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
|
||
return $trimmed;
|
||
}
|
||
|
||
if (str_starts_with($trimmed, 'storage/')) {
|
||
return asset($trimmed);
|
||
}
|
||
|
||
if (str_starts_with($trimmed, 'immobile/')) {
|
||
return asset('storage/'.$trimmed);
|
||
}
|
||
|
||
// Liegt die Datei unter public/img/assets/, kommt sie von dort – auch wenn sie inzwischen in
|
||
// der Mediathek steht (AssetMediaImportSeeder). Nur so findet <x-web-picture> die WebP-Fassung
|
||
// neben JPG/PNG.
|
||
if (str_contains($trimmed, '/') && is_file(public_path('img/assets/'.$trimmed))) {
|
||
return asset('img/assets/'.$trimmed);
|
||
}
|
||
|
||
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
|
||
foreach ($candidates as $candidate) {
|
||
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
|
||
return media_url($candidate);
|
||
}
|
||
}
|
||
|
||
if (str_contains($trimmed, '/')) {
|
||
return asset('img/assets/'.$trimmed);
|
||
}
|
||
|
||
return media_url($trimmed);
|
||
}
|
||
}
|
||
|
||
if (! function_exists('tcms')) {
|
||
/**
|
||
* Typed CMS – always returns a string.
|
||
*
|
||
* @param array<string, string> $replace
|
||
*/
|
||
function tcms(string $key, array $replace = [], ?string $locale = null): string
|
||
{
|
||
$text = cms($key, $replace, $locale);
|
||
|
||
return is_string($text) ? $text : (string) $text;
|
||
}
|
||
}
|
||
|
||
if (! function_exists('media_url')) {
|
||
/**
|
||
* Resolve a CmsMedia filename to its full storage URL.
|
||
* Cached per request to avoid repeated DB queries.
|
||
*
|
||
* Ohne Eintrag in der Mediathek zeigt die URL auf public/img/assets/ – dort legen die Seeder
|
||
* Marken-, Magazin- und Slideshow-Bilder ab (wie theme_image_url() im Frontend). Der frühere
|
||
* Rückfall assets/images/ existierte nicht und lieferte im Admin 404 (07.10.2026).
|
||
*/
|
||
function media_url(?string $filename, string $profile = ''): string
|
||
{
|
||
if (! $filename || $filename === '') {
|
||
return '';
|
||
}
|
||
|
||
// once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit.
|
||
return once(function () use ($filename, $profile): string {
|
||
$media = CmsMedia::where('filename', $filename)->first();
|
||
|
||
if (! $media) {
|
||
return asset('img/assets/'.ltrim($filename, '/'));
|
||
}
|
||
|
||
return $profile && $media->hasConversion($profile)
|
||
? $media->getConversionUrl($profile)
|
||
: $media->getUrl();
|
||
});
|
||
}
|
||
}
|
||
|
||
if (! function_exists('b2in_world')) {
|
||
/**
|
||
* Farbwelt der aktuellen Seite: `interior` (Standard) oder `immobilien`.
|
||
*
|
||
* Die Immobilien-Welt umfasst die Immobilien-Routen samt Detailseiten und den
|
||
* Entwicklungs-Vorschauen. Das Layout setzt den Wert als Body-Klasse
|
||
* (`world-interior` / `world-immobilien`), theme-b2in.css schaltet daran die Farben um.
|
||
* Livewire-Komponenten merken sich die Welt in mount(), weil ein späterer
|
||
* Livewire-Request keine Seitenroute mehr trägt.
|
||
*/
|
||
function b2in_world(): string
|
||
{
|
||
if (app()->bound('b2in.world')) {
|
||
return (string) app('b2in.world');
|
||
}
|
||
|
||
$routeName = request()->route()?->getName();
|
||
|
||
if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) {
|
||
return 'immobilien';
|
||
}
|
||
|
||
return 'interior';
|
||
}
|
||
}
|
||
|
||
if (! function_exists('b2in_worlds')) {
|
||
/**
|
||
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile.
|
||
*
|
||
* @return array<string, array{label: string, url: string, active: bool}>
|
||
*/
|
||
function b2in_worlds(?string $current = null): array
|
||
{
|
||
$current = $current ?? b2in_world();
|
||
|
||
return [
|
||
'interior' => [
|
||
'label' => __('ui.world_interior'),
|
||
'url' => url('/'),
|
||
'active' => $current === 'interior',
|
||
],
|
||
'immobilien' => [
|
||
'label' => __('ui.world_immobilien'),
|
||
'url' => route('immobilien'),
|
||
'active' => $current === 'immobilien',
|
||
],
|
||
];
|
||
}
|
||
}
|
||
|
||
if (! function_exists('b2in_world_set')) {
|
||
/**
|
||
* Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt –
|
||
* etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert.
|
||
* Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest).
|
||
*/
|
||
function b2in_world_set(string $world): void
|
||
{
|
||
app()->instance('b2in.world', $world);
|
||
}
|
||
}
|