feat(i18n): translation files DE/EN/UK/RU + layout integration (closes #74)
This commit is contained in:
@@ -4,8 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
use App\Core\View;
|
||||
|
||||
/**
|
||||
* Base controller — injects i18n globals (locale, t() helper, locale switcher)
|
||||
* into every render() call so views can use `$t('key')` and `$locale` directly.
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
protected View $view;
|
||||
@@ -15,9 +21,28 @@ abstract class Controller
|
||||
$this->view = new View();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a view inside a layout.
|
||||
*
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
protected function render(string $view, array $data = [], string $layout = 'main'): void
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
$locale = LocaleController::current();
|
||||
$i18n = static fn (string $key, array $params = []): string => I18n::t($key, $params, $locale);
|
||||
|
||||
$globals = [
|
||||
'locale' => $locale,
|
||||
't' => $i18n,
|
||||
'locale_switcher' => static function (string $currentPath) use ($locale): string {
|
||||
$switcher = new LocaleSwitcher($locale, $currentPath);
|
||||
return $switcher->render();
|
||||
},
|
||||
];
|
||||
|
||||
$merged = array_merge($globals, $data);
|
||||
|
||||
foreach ($merged as $key => $value) {
|
||||
$this->view->assign($key, $value);
|
||||
}
|
||||
$this->view->render($view, $layout);
|
||||
|
||||
@@ -4,15 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
|
||||
class DatenschutzController extends Controller
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$locale = LocaleController::current();
|
||||
|
||||
$this->render('datenschutz/index', [
|
||||
'pageTitle' => 'Datenschutzerklärung – Haus Schleusingen',
|
||||
'pageDescription' => 'Datenschutzerklärung der Website haus-schleusingen.de',
|
||||
'pageTitle' => I18n::t('legal.privacy_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageDescription' => I18n::t('legal.privacy_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'robots' => 'noindex',
|
||||
'canonical' => 'https://haus-schleusingen.de/datenschutz',
|
||||
'canonical' => I18n::t('site.canonical_base', [], $locale) . '/datenschutz',
|
||||
'ogLocale' => Locale::toOgLocale($locale),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], $locale) . '/datenschutz',
|
||||
'ogTitle' => I18n::t('legal.privacy_h1', [], $locale),
|
||||
'ogDescription' => I18n::t('legal.privacy_h1', [], $locale),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,184 +4,183 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/** Map of interest option translation key → internal identifier. */
|
||||
private const INTEREST_KEYS = [
|
||||
'visit' => 'form.interest.visit',
|
||||
'info' => 'form.interest.info',
|
||||
'apply' => 'form.interest.apply',
|
||||
];
|
||||
|
||||
public function index(): void
|
||||
{
|
||||
session_start();
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
$normalizeContactValue = function (string $value): string {
|
||||
return trim($value);
|
||||
};
|
||||
$locale = LocaleController::current();
|
||||
|
||||
$escapeContactValue = function (string $value): string {
|
||||
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
};
|
||||
$escapeContactValue = static fn(string $value): string
|
||||
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$containsHeaderInjection = function (string $value): bool {
|
||||
$containsHeaderInjection = static function (string $value): bool {
|
||||
return (bool) preg_match('/[\r\n]/', $value);
|
||||
};
|
||||
|
||||
// --- Form processing ---
|
||||
$formErrors = [];
|
||||
$formSuccess = false;
|
||||
if (!empty($_SESSION['form_success'])) {
|
||||
$formSuccess = true;
|
||||
unset($_SESSION['form_success']);
|
||||
}
|
||||
if (!empty($_SESSION['form_errors'])) {
|
||||
$formErrors = $_SESSION['form_errors'];
|
||||
unset($_SESSION['form_errors']);
|
||||
}
|
||||
if (!empty($_SESSION['form_data'])) {
|
||||
$formData = $_SESSION['form_data'];
|
||||
unset($_SESSION['form_data']);
|
||||
} else {
|
||||
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'Besichtigung anfragen', 'message' => ''];
|
||||
// ── Pull flashed state ────────────────────────────────────────
|
||||
$formSuccess = !empty($_SESSION['form_success']);
|
||||
$formErrors = $_SESSION['form_errors'] ?? [];
|
||||
$formData = $_SESSION['form_data'] ?? null;
|
||||
unset($_SESSION['form_success'], $_SESSION['form_errors'], $_SESSION['form_data']);
|
||||
|
||||
if ($formSuccess) {
|
||||
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'visit', 'message' => ''];
|
||||
} elseif (!is_array($formData)) {
|
||||
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'visit', 'message' => ''];
|
||||
}
|
||||
|
||||
// CSRF-Token generieren (nach Session-Start)
|
||||
// ── CSRF token ────────────────────────────────────────────────
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// CSRF-Token validieren
|
||||
$csrfToken = $_POST['csrf_token'] ?? '';
|
||||
// ── Form processing ───────────────────────────────────────────
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$csrfToken = (string) ($_POST['csrf_token'] ?? '');
|
||||
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
|
||||
header('Location: /#form-result');
|
||||
$_SESSION['form_errors'] = ['Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.'];
|
||||
$_SESSION['form_errors'] = ['form.error.csrf'];
|
||||
header('Location: /#kontakt');
|
||||
exit;
|
||||
}
|
||||
|
||||
$formData['fname'] = $normalizeContactValue((string) ($_POST['fname'] ?? ''));
|
||||
$formData['lname'] = $normalizeContactValue((string) ($_POST['lname'] ?? ''));
|
||||
$formData['email'] = $normalizeContactValue((string) ($_POST['email'] ?? ''));
|
||||
$formData['phone'] = $normalizeContactValue((string) ($_POST['phone'] ?? ''));
|
||||
$formData['interest'] = $normalizeContactValue((string) ($_POST['interest'] ?? ''));
|
||||
$formData['message'] = $normalizeContactValue((string) ($_POST['message'] ?? ''));
|
||||
$formData['fname'] = trim((string) ($_POST['fname'] ?? ''));
|
||||
$formData['lname'] = trim((string) ($_POST['lname'] ?? ''));
|
||||
$formData['email'] = trim((string) ($_POST['email'] ?? ''));
|
||||
$formData['phone'] = trim((string) ($_POST['phone'] ?? ''));
|
||||
$formData['interest'] = trim((string) ($_POST['interest'] ?? 'visit'));
|
||||
$formData['message'] = trim((string) ($_POST['message'] ?? ''));
|
||||
|
||||
$honeypot = $normalizeContactValue((string) ($_POST['website'] ?? ''));
|
||||
// Honeypot: bots succeed silently.
|
||||
$honeypot = trim((string) ($_POST['website'] ?? ''));
|
||||
if ($honeypot !== '') {
|
||||
header('Location: /#form-result');
|
||||
$_SESSION['form_success'] = true;
|
||||
exit;
|
||||
} else {
|
||||
if ($formData['fname'] === '') {
|
||||
$formErrors[] = 'Bitte geben Sie Ihren Vornamen an.';
|
||||
}
|
||||
if ($formData['lname'] === '') {
|
||||
$formErrors[] = 'Bitte geben Sie Ihren Nachnamen an.';
|
||||
}
|
||||
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$formErrors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
if ($formData['message'] === '') {
|
||||
$formErrors[] = 'Bitte geben Sie eine Nachricht ein.';
|
||||
}
|
||||
|
||||
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
|
||||
$formErrors[] = 'Ungültige Zeichen in den Eingabefeldern.';
|
||||
}
|
||||
|
||||
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
|
||||
if ($formTime > 0 && (time() - $formTime) < 3) {
|
||||
$formErrors[] = 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.';
|
||||
}
|
||||
|
||||
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
|
||||
if ($lastSubmit && (time() - $lastSubmit) < 60) {
|
||||
$formErrors[] = 'Bitte warten Sie einen Moment vor der nächsten Anfrage.';
|
||||
}
|
||||
|
||||
if (empty($formErrors)) {
|
||||
$to = 'mki@kies-media.de';
|
||||
$subject = 'Kontaktanfrage: ' . $formData['interest'];
|
||||
$body = "Von: {$formData['fname']} {$formData['lname']}\n"
|
||||
. "E-Mail: {$formData['email']}\n";
|
||||
if ($formData['phone'] !== '') {
|
||||
$body .= "Telefon: {$formData['phone']}\n";
|
||||
}
|
||||
$body .= "Anliegen: {$formData['interest']}\n\n"
|
||||
. $formData['message'];
|
||||
|
||||
$headers = "From: {$formData['email']}\r\n";
|
||||
$headers .= "Reply-To: {$formData['email']}\r\n";
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
$headers .= "X-Mailer: PHP/" . phpversion();
|
||||
|
||||
$mailSent = mail($to, $subject, $body, $headers);
|
||||
|
||||
if ($mailSent) {
|
||||
$_SESSION['last_contact_submit'] = time();
|
||||
header('Location: /#form-result');
|
||||
$_SESSION['form_success'] = true;
|
||||
exit;
|
||||
} else {
|
||||
$formErrors[] = 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($formErrors)) {
|
||||
header('Location: /#form-result');
|
||||
$_SESSION['form_errors'] = $formErrors;
|
||||
$_SESSION['form_data'] = $formData;
|
||||
header('Location: /#kontakt');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($formData['fname'] === '') {
|
||||
$formErrors[] = 'form.error.fname_required';
|
||||
}
|
||||
if ($formData['lname'] === '') {
|
||||
$formErrors[] = 'form.error.lname_required';
|
||||
}
|
||||
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
$formErrors[] = 'form.error.email_invalid';
|
||||
}
|
||||
if ($formData['message'] === '') {
|
||||
$formErrors[] = 'form.error.message_required';
|
||||
}
|
||||
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
|
||||
$formErrors[] = 'form.error.header_injection';
|
||||
}
|
||||
|
||||
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
|
||||
if ($formTime > 0 && (time() - $formTime) < 3) {
|
||||
$formErrors[] = 'form.error.too_fast';
|
||||
}
|
||||
|
||||
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
|
||||
if ($lastSubmit && (time() - $lastSubmit) < 60) {
|
||||
$formErrors[] = 'form.error.rate_limit';
|
||||
}
|
||||
|
||||
if (empty($formErrors)) {
|
||||
$interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit';
|
||||
$interestLabel = I18n::t($interestKey, [], $locale);
|
||||
|
||||
$to = 'mki@kies-media.de';
|
||||
$subject = 'Kontaktanfrage: ' . $interestLabel;
|
||||
$body = sprintf(
|
||||
"Von: %s %s\nE-Mail: %s\n%sAnliegen: %s\n\n%s",
|
||||
$formData['fname'],
|
||||
$formData['lname'],
|
||||
$formData['email'],
|
||||
$formData['phone'] !== '' ? "Telefon: {$formData['phone']}\n" : '',
|
||||
$interestLabel,
|
||||
$formData['message']
|
||||
);
|
||||
|
||||
$headers = "From: {$formData['email']}\r\n";
|
||||
$headers .= "Reply-To: {$formData['email']}\r\n";
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
$headers .= "X-Mailer: PHP/" . phpversion();
|
||||
|
||||
if (mail($to, $subject, $body, $headers)) {
|
||||
$_SESSION['last_contact_submit'] = time();
|
||||
$_SESSION['form_success'] = true;
|
||||
header('Location: /#kontakt');
|
||||
exit;
|
||||
}
|
||||
|
||||
$formErrors[] = 'form.error.send_failed';
|
||||
}
|
||||
|
||||
$_SESSION['form_errors'] = $formErrors;
|
||||
$_SESSION['form_data'] = $formData;
|
||||
header('Location: /#kontakt');
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Structured data (JSON-LD) — localized ────────────────────
|
||||
$structuredData = json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'RealEstateListing',
|
||||
'name' => I18n::t('structured.listing_name', [], $locale),
|
||||
'description'=> I18n::t('structured.listing_description', [], $locale),
|
||||
'url' => I18n::t('site.canonical_base', [], $locale) . '/',
|
||||
'image' => I18n::t('site.canonical_base', [], $locale) . '/bilder/Außenansicht-2.png',
|
||||
'datePosted' => '2026-05-14',
|
||||
'address' => [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => I18n::t('address.street', [], $locale),
|
||||
'addressLocality' => I18n::t('address.city', [], $locale),
|
||||
'postalCode' => '98553',
|
||||
'addressCountry' => 'DE',
|
||||
],
|
||||
'offers' => [
|
||||
'@type' => 'Offer',
|
||||
'price' => '1300',
|
||||
'priceCurrency' => 'EUR',
|
||||
'priceSpecification' => [
|
||||
'@type' => 'UnitPriceSpecification',
|
||||
'price' => '1300',
|
||||
'priceCurrency' => 'EUR',
|
||||
'unitCode' => 'MON',
|
||||
'description' => I18n::t('structured.price_description', [], $locale),
|
||||
],
|
||||
],
|
||||
'floorSize' => [
|
||||
'@type' => 'QuantitativeValue',
|
||||
'value' => '227',
|
||||
'unitCode' => 'MTK',
|
||||
],
|
||||
'numberOfRooms' => [
|
||||
'@type' => 'QuantitativeValue',
|
||||
'value' => '6',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->render('home/index', [
|
||||
'formSuccess' => $formSuccess,
|
||||
'formErrors' => $formErrors,
|
||||
'formData' => $formData,
|
||||
'interestKeys' => self::INTEREST_KEYS,
|
||||
'escapeContactValue' => $escapeContactValue,
|
||||
'pageTitle' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
|
||||
'pageDescription' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
|
||||
'canonical' => 'https://haus-schleusingen.de/',
|
||||
'openGraph' => [
|
||||
'ogTitle' => 'Einfamilienhaus zur Miete in Schleusingen – 227 m², 6 Zimmer',
|
||||
'ogDescription' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m², 6 Zimmer, 3 Etagen + Dachterrasse. Kaltmiete 1.300 €. Ab sofort verfügbar in Schleusingen.',
|
||||
'ogImage' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
|
||||
'ogUrl' => 'https://haus-schleusingen.de/',
|
||||
],
|
||||
'structuredData' => json_encode([
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'RealEstateListing',
|
||||
'name' => 'Einfamilienhaus zur Miete in Schleusingen',
|
||||
'description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
|
||||
'url' => 'https://haus-schleusingen.de/',
|
||||
'image' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
|
||||
'datePosted' => '2026-05-14',
|
||||
'address' => [
|
||||
'@type' => 'PostalAddress',
|
||||
'streetAddress' => 'Bahnhofstraße 10',
|
||||
'addressLocality' => 'Schleusingen',
|
||||
'postalCode' => '98553',
|
||||
'addressCountry' => 'DE',
|
||||
],
|
||||
'offers' => [
|
||||
'@type' => 'Offer',
|
||||
'price' => '1300',
|
||||
'priceCurrency' => 'EUR',
|
||||
'priceSpecification' => [
|
||||
'@type' => 'UnitPriceSpecification',
|
||||
'price' => '1300',
|
||||
'priceCurrency' => 'EUR',
|
||||
'unitCode' => 'MON',
|
||||
'description' => 'Kaltmiete pro Monat',
|
||||
],
|
||||
],
|
||||
'floorSize' => [
|
||||
'@type' => 'QuantitativeValue',
|
||||
'value' => '227',
|
||||
'unitCode' => 'MTK',
|
||||
],
|
||||
'numberOfRooms' => [
|
||||
'@type' => 'QuantitativeValue',
|
||||
'value' => '6',
|
||||
],
|
||||
]),
|
||||
'structuredData' => $structuredData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
|
||||
class ImpressumController extends Controller
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
$locale = LocaleController::current();
|
||||
|
||||
$this->render('impressum/index', [
|
||||
'pageTitle' => 'Impressum – Haus Schleusingen',
|
||||
'pageDescription' => 'Impressum der Website haus-schleusingen.de',
|
||||
'pageTitle' => I18n::t('legal.imprint_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'pageDescription' => I18n::t('legal.imprint_h1', [], $locale) . ' – ' . I18n::t('site.title', [], $locale),
|
||||
'robots' => 'noindex',
|
||||
'canonical' => 'https://haus-schleusingen.de/impressum',
|
||||
'canonical' => I18n::t('site.canonical_base', [], $locale) . '/impressum',
|
||||
'ogLocale' => Locale::toOgLocale($locale),
|
||||
'ogUrl' => I18n::t('site.canonical_base', [], $locale) . '/impressum',
|
||||
'ogTitle' => I18n::t('legal.imprint_h1', [], $locale),
|
||||
'ogDescription' => I18n::t('legal.imprint_h1', [], $locale),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
71
app/Controllers/LocaleSwitcher.php
Normal file
71
app/Controllers/LocaleSwitcher.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
|
||||
/**
|
||||
* Renders the language switcher widget. Pure HTML generation — no
|
||||
* side effects, no header writing.
|
||||
*
|
||||
* Output is semantic HTML (a <ul> of links) with `aria-current` for the
|
||||
* active locale, `hreflang` for SEO, and `lang` for screen readers.
|
||||
* The basic list-of-locale-codes is the MVP. Sub-Issue D (responsive
|
||||
* SVG flag UI) refines the presentation.
|
||||
*/
|
||||
final class LocaleSwitcher
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $currentLocale,
|
||||
private readonly string $currentPath,
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$path = $this->sanitisePath($this->currentPath);
|
||||
$ariaLabel = htmlspecialchars(
|
||||
I18n::t('locale.switcher.aria', [], $this->currentLocale),
|
||||
ENT_QUOTES,
|
||||
'UTF-8',
|
||||
);
|
||||
|
||||
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
|
||||
foreach (Locale::SUPPORTED as $code) {
|
||||
$isCurrent = $code === $this->currentLocale;
|
||||
$name = htmlspecialchars(I18n::t('locale.' . $code, [], $this->currentLocale), ENT_QUOTES, 'UTF-8');
|
||||
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$html .= '<li' . ($isCurrent ? ' class="is-current" aria-current="true"' : '') . '>';
|
||||
if ($isCurrent) {
|
||||
$html .= '<span lang="' . $codeAttr . '">' . $codeAttr . '<span class="visually-hidden"> (' . $name . ')</span></span>';
|
||||
} else {
|
||||
$url = '/locale?set=' . rawurlencode($code) . '&return=' . rawurlencode($path);
|
||||
$html .= '<a href="' . $url . '" hreflang="' . $codeAttr . '" lang="' . $codeAttr . '" rel="alternate">'
|
||||
. $codeAttr
|
||||
. '<span class="visually-hidden"> (' . $name . ')</span>'
|
||||
. '</a>';
|
||||
}
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the path is safe to embed as a query string value and
|
||||
* a redirect target. Drops query/fragment, keeps only the path.
|
||||
*/
|
||||
private function sanitisePath(string $path): string
|
||||
{
|
||||
$path = parse_url($path, PHP_URL_PATH) ?: '/';
|
||||
if ($path === '' || $path[0] !== '/') {
|
||||
return '/';
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user