b2in/app/helpers.php
Kevin Adametz 451e738c8b CMS: Absatz-Tags aus dem Rich-Text-Editor erscheinen nicht mehr als Text
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>
2026-10-12 06:58:48 +00:00

292 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}