Files
tsv08kulmbach-website/app/helpers.php
fs 8304095dc3 Design-System: Token-Ebene für Spacing, Fokus und Tap-Targets
Vorbereitung für die Konsolidierung. tokens.css war für Farben und Typo schon
die Single Source of Truth, für Abstände aber nur mit --space-section und
--container-pad besetzt — dem standen ~450 Spacing-Deklarationen mit 39
verschiedenen rem-Werten gegenüber.

Neu: 14-stufige Spacing-Skala auf 4px-Raster, aus dem Ist-Bestand geclustert
statt frei erfunden. Die neun häufigsten Werte der Codebase (1, 1.5, 1.25, 2.5,
0.75, 0.5, 1.75, 2, 3rem — zusammen ~60% aller Deklarationen) liegen bereits
exakt auf Stufen, die Migration ist dort verlustfrei. Nach oben wird das Raster
gröber (3 → 4 → 5 → 7rem), weil große Abstände das vertragen. --space-4xs (2px)
ist ausdrücklich nur für optische Korrekturen an Icon-/Baseline-Nudges.

Dazu --tap-min (44px, WCAG 2.5.5) und --focus-ring/--focus-offset. Der Fokus
wird auf Weiß festgeschrieben: 15,9:1 auf --clr-bg gegenüber 3,2:1 für Rot,
das WCAG 1.4.11 nur knapp schafft und als einziger Indikator zu schwach ist.

--container-pad und --space-section verweisen jetzt auf die Skala statt eigene
Werte zu führen. Entfernt: --clr-bg-light und --clr-surface-raised, beide
projektweit 0× verwendet.

Consumer folgen in den nächsten Commits — dieser Schritt ändert die Darstellung
noch nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:07:31 +02:00

617 lines
21 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
declare(strict_types=1);
/**
* Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
*/
function config(?string $key = null, mixed $default = null): mixed
{
$value = $GLOBALS['__config'];
if ($key === null) {
return $value;
}
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
/**
* Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
* unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
* als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
* auf config.example.php weiter — deren app_secret steht öffentlich im Repo, die
* Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
*/
function config_problems(): array
{
$problems = [];
if (!is_file(ROOT_PATH . '/config/config.php')) {
$problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
}
$secret = (string) config('app_secret');
if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
$problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
}
$smtp = (array) config('smtp', []);
foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
if (empty($smtp[$key])) {
$problems[] = "smtp.{$key} ist nicht gesetzt.";
}
}
if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
$problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
}
return $problems;
}
/**
* HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
*/
function e(string|int|float|null $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
/**
* Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
*/
function url(string $slug = ''): string
{
return '/' . trim($slug, '/');
}
/**
* Absolute URL für Canonical, OG und Sitemap.
*/
function abs_url(string $slug = ''): string
{
return rtrim((string) config('base_url'), '/') . url($slug);
}
/**
* Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
*/
function asset(string $path): string
{
$path = ltrim($path, '/');
$file = PUBLIC_PATH . '/assets/' . $path;
$version = is_file($file) ? (string) filemtime($file) : '0';
return '/assets/' . $path . '?v=' . $version;
}
/**
* Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
* Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
* (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
* Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen.
*/
function icon(string $name, string $class = '', ?string $label = null): string
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
$cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
}
if ($cache[$name] === '') {
return '';
}
$attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
$attrs .= $label !== null && $label !== ''
? ' role="img" aria-label="' . e($label) . '"'
: ' aria-hidden="true" focusable="false"';
return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}
/**
* JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
* Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise.
*/
function json_load(string $name): array
{
static $cache = [];
if (!array_key_exists($name, $cache)) {
$file = DATA_PATH . '/' . $name . '.json';
if (!is_file($file)) {
return [];
}
$cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
}
return $cache[$name];
}
/**
* Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
* $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
* viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert —
* fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
*/
function img_intrinsic_attrs(string $rel): string
{
static $cache = [];
if (!isset($cache[$rel])) {
$file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
$w = $h = 0;
if (is_file($file)) {
if (str_ends_with(strtolower($file), '.svg')) {
$svg = (string) file_get_contents($file);
if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
} elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
[$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
}
} else {
[$w, $h] = (getimagesize($file) ?: [0, 0]);
}
}
$cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
}
return $cache[$rel];
}
/**
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
* Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
*/
function match_when(string $kickoff): array
{
if ($kickoff === '') {
return ['iso' => '', 'countdown' => ''];
}
$tz = new DateTimeZone('Europe/Berlin');
try {
$start = new DateTimeImmutable($kickoff, $tz);
} catch (Exception) {
return ['iso' => '', 'countdown' => ''];
}
$now = new DateTimeImmutable('now', $tz);
if ($start <= $now) {
return ['iso' => $start->format('c'), 'countdown' => ''];
}
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
$time = $start->format('H:i') . ' Uhr';
return [
'iso' => $start->format('c'),
'countdown' => match (true) {
$days === 0 => 'heute, ' . $time,
$days === 1 => 'morgen, ' . $time,
$days < 14 => 'in ' . $days . ' Tagen',
default => 'in ' . (int) round($days / 7) . ' Wochen',
},
];
}
/**
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
* Tabelle zählt.
*/
function match_competition_label(string $competition, string $league = ''): string
{
return match ($competition) {
'Liga' => $league,
'Freundschaft' => 'Freundschaftsspiel',
default => $competition,
};
}
/**
* Komponente rendern: component('hero', ['title' => …]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
*/
function component(string $name, array $props = []): void
{
extract($props, EXTR_SKIP);
require APP_PATH . '/components/' . $name . '.php';
}
/**
* Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
* Rückgabe: [$meta, $html].
*/
function render_page(string $file): array
{
$meta = [];
ob_start();
require $file;
return [$meta, (string) ob_get_clean()];
}
/**
* Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
*/
function form_token(): string
{
$ts = (string) time();
return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
}
/**
* Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
* Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
* grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
*/
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
{
$parts = explode('.', $token);
if (count($parts) !== 2) {
return false;
}
[$ts, $sig] = $parts;
if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
return false;
}
$age = time() - (int) $ts;
return $age >= $min && $age <= $max;
}
/**
* Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For
* ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet.
*/
function client_ip(): string
{
return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}
/**
* Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein
* APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den
* letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst
* false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt
* (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt.
*/
function rate_limit_ok(string $key, int $max, int $window): bool
{
$dir = STORAGE_PATH . '/ratelimit';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return true;
}
// Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen.
if (random_int(1, 100) === 1) {
foreach (glob($dir . '/*.json') ?: [] as $stale) {
if ((int) @filemtime($stale) < time() - 86400) {
@unlink($stale);
}
}
}
$file = $dir . '/' . hash('sha256', $key) . '.json';
$fh = @fopen($file, 'c+');
if ($fh === false) {
return true;
}
try {
flock($fh, LOCK_EX);
$raw = (string) stream_get_contents($fh);
$hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : [];
$now = time();
$hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window));
if (count($hits) >= $max) {
return false;
}
$hits[] = $now;
rewind($fh);
ftruncate($fh, 0);
fwrite($fh, (string) json_encode($hits));
return true;
} finally {
flock($fh, LOCK_UN);
fclose($fh);
}
}
/**
* Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine
* Beobachtbarkeit zum Tunen der Schwellen. Datensparsam: nur ein gekürzter,
* gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay.
*/
function log_spam(string $route, string $reason): void
{
$ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12);
error_log('[' . date('c') . "] {$route} {$reason} ip={$ipHash}\n", 3, STORAGE_PATH . '/logs/spam.log');
}
/**
* Formular-Mail über All-Inkl-SMTP verschicken — EINZIGE Versandstelle der Seite.
* Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
* an genau einem Ort stehen.
*
* $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
* reply_to ['email'=>…, 'name'=>…] · context (Präfix im Log).
*
* Gibt true/false zurück und wirft nie — Versandfehler landen in
* storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
* Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
* SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
*/
function send_mail(array $opts): bool
{
$smtp = (array) config('smtp', []);
$context = (string) ($opts['context'] ?? 'Mailversand');
try {
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = (string) $smtp['host'];
$mail->Port = (int) $smtp['port'];
$mail->SMTPAuth = true;
$mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = (string) $smtp['username'];
$mail->Password = (string) $smtp['password'];
$mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
$mail->Timeout = 10;
$mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)
$mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
$mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
if (!empty($opts['reply_to']['email'])) {
$mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
}
$mail->Subject = (string) $opts['subject'];
$mail->Body = (string) $opts['body'];
$mail->send();
return true;
} catch (Throwable $e) {
error_log('[' . date('c') . "] {$context} fehlgeschlagen: " . $e->getMessage() . "\n", 3, STORAGE_PATH . '/logs/mail.log');
return false;
}
}
/**
* Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
* der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
* $old: Feldname → Wert. $errors: Feld-ID (wie im Markup) → Fehlermeldung.
*/
function form_state(?array $old = null, ?array $errors = null): array
{
static $state = ['old' => [], 'errors' => []];
if ($old !== null) {
$state = ['old' => $old, 'errors' => $errors ?? []];
}
return $state;
}
/**
* Zuvor eingegebener Wert eines Feldes ('' wenn keiner) — für value/selected/checked.
*/
function form_old(string $name): string
{
return (string) (form_state()['old'][$name] ?? '');
}
/**
* Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
*/
function form_field_error(string $id): string
{
return (string) (form_state()['errors'][$id] ?? '');
}
/**
* True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
*/
function form_has_errors(): bool
{
return form_state()['errors'] !== [];
}
/**
* Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
* Seite mit HTTP 422 direkt neu rendern — mit erhaltenen Eingaben und Feldfehlern.
* Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
* Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
* Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
*/
function render_form_invalid(string $slug, array $old, array $errors): never
{
form_state($old, $errors);
$routes = require APP_PATH . '/routes.php';
$current = isset($routes[$slug]) ? $slug : '';
http_response_code(422);
header('Cache-Control: no-store');
[$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
require APP_PATH . '/layout.php';
exit;
}
/**
* BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
*/
function breadcrumb_schema(array $items): array
{
$list = [];
foreach (array_values($items) as $i => $item) {
$list[] = [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $item['name'],
'item' => abs_url($item['slug']),
];
}
return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
}
/**
* FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen;
* ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
*/
function faq_schema(array $faq): array
{
$questions = [];
foreach ($faq as $item) {
if (empty($item['q']) || empty($item['a'])) {
continue;
}
$questions[] = [
'@type' => 'Question',
'name' => $item['q'],
'acceptedAnswer' => ['@type' => 'Answer', 'text' => $item['a']],
];
}
return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
}
/**
* Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
* Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
*/
function page_schema(array $breadcrumb, array $faq = []): array
{
$nodes = [];
if ($breadcrumb !== []) {
$nodes[] = breadcrumb_schema($breadcrumb);
}
if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
$nodes[] = $faqNode;
}
return $nodes;
}
/**
* WebSite-Knoten (für die Startseite): definiert die Site als Entität und
* verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
* keine Site-Suche.
*/
function website_schema(): array
{
$club = json_load('club');
return [
'@type' => 'WebSite',
'@id' => abs_url() . '#website',
'url' => abs_url(),
'name' => $club['name'],
'inLanguage' => 'de-DE',
'publisher' => ['@id' => abs_url() . '#club'],
];
}
/**
* Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
* via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
* $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
*/
function team_schema(array $team, string $slug): array
{
$teamNode = [
'@type' => 'SportsTeam',
'name' => $team['name'],
'sport' => 'Fußball',
'url' => abs_url($slug),
'memberOf' => ['@id' => abs_url() . '#club'],
];
if (!empty($team['hero']['text'])) {
$teamNode['description'] = $team['hero']['text'];
}
$nodes = [$teamNode];
$nodes = array_merge($nodes, page_schema(
[
['name' => 'Startseite', 'slug' => ''],
['name' => 'Fußball', 'slug' => 'fussball'],
['name' => $team['name'], 'slug' => $slug],
],
$team['faq'] ?? []
));
return $nodes;
}
/**
* SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
* data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
* der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
*/
function sportsevent_nodes(array $upcoming): array
{
$nodes = [];
foreach ($upcoming as $m) {
if (empty($m['home']) || empty($m['away'])) {
continue;
}
$node = [
'@type' => 'SportsEvent',
'name' => $m['home'] . ' ' . $m['away'],
'sport' => 'Fußball',
'homeTeam' => ['@type' => 'SportsTeam', 'name' => $m['home']],
'awayTeam' => ['@type' => 'SportsTeam', 'name' => $m['away']],
'eventStatus' => 'https://schema.org/EventScheduled',
];
if (!empty($m['kickoff'])) {
$node['startDate'] = $m['kickoff'];
}
$nodes[] = $node;
}
return $nodes;
}
/**
* JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
* data/mitmachen.json → positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
* employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
* jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
* optional — ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
* deshalb nur bei echt befristeten Stellen setzen. Ohne title → leerer Array (Aufrufer filtert).
*/
function job_posting_schema(array $job, string $pageSlug): array
{
if (empty($job['title'])) {
return [];
}
$club = json_load('club');
$node = [
'@type' => 'JobPosting',
'title' => $job['title'],
'description' => $job['description'] ?? ($job['summary'] ?? $job['title']),
'employmentType' => 'VOLUNTEER',
'hiringOrganization' => ['@id' => abs_url() . '#club'],
'jobLocation' => [
'@type' => 'Place',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => $club['address']['street'],
'postalCode' => $club['address']['zip'],
'addressLocality' => $club['address']['city'],
'addressCountry' => $club['address']['country'],
],
],
];
if (!empty($job['id'])) {
$node['identifier'] = [
'@type' => 'PropertyValue',
'name' => $club['name'],
'value' => $job['id'],
];
$node['url'] = abs_url($pageSlug) . '#' . $job['id'];
}
if (!empty($job['posted'])) {
$node['datePosted'] = $job['posted'];
}
if (!empty($job['valid_through'])) {
$node['validThrough'] = $job['valid_through'];
}
return $node;
}