$content])), (string)($vars['title'] ?? '')); return; } stream_template('layout_' . $layout, $vars + ['content' => $content]); } /** The file of a template, from this plugin or an add-on (template_dirs()). */ function template_file(string $tpl): string { if (!preg_match('#^[a-z0-9_/]+$#', $tpl)) throw new RuntimeException(esc_html('Bad template name: ' . $tpl)); foreach (template_dirs() as $dir) if (is_file($dir . $tpl . '.php')) return $dir . $tpl . '.php'; throw new RuntimeException(esc_html('Template not found: ' . $tpl)); } /** Print a template in place (like get_template_part()). The template escapes what it prints. */ function include_template(string $__tpl, array $__vars = []): void { $__file = template_file($__tpl); // The template's variables, by name. Only plain variable names; names starting with "__" and $this are never set. foreach ($__vars as $__name => $__value) { if (!is_string($__name) || $__name === 'this' || str_starts_with($__name, '__') || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $__name)) continue; ${$__name} = $__value; } unset($__name, $__value, $__vars); include $__file; } /** A template's markup as a string, for emails and for blocks a page places later (printed with wp_kses()). */ function render_partial(string $tpl, array $vars = []): string { ob_start(); include_template($tpl, $vars); return (string)ob_get_clean(); } /** Print a whole page or document from a template, with internal paths mapped to WordPress URLs. */ function stream_template(string $tpl, array $vars = []): void { ob_start(fn(string $buffer): string => gatecraft_map_html($buffer)); include_template($tpl, $vars); ob_end_flush(); } /** Rewrite internal paths in our HTML ("/e/x", "/admin/...", "/uploads/...") to real WordPress URLs. */ function gatecraft_map_html(string $html): string { $html = i18n_html($html); $html = preg_replace_callback('/\b(href|action|formaction|src|data-bg|data-endpoint|data-quote-url)="(\/(?!\/)[^"]*)"/', function ($m) { return $m[1] . '="' . esc_attr(map_url(html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'))) . '"'; }, $html); // GET forms (filters, search): the browser replaces the action's query string with the form fields, // which would drop page=... in wp-admin. Move those parameters into hidden inputs. $html = preg_replace_callback('/]*)>/i', function ($m) { $attrs = $m[1]; if (!preg_match('/\bmethod="get"/i', $attrs)) return $m[0]; $action = preg_match('/\baction="([^"]*)"/i', $attrs, $a) ? html_entity_decode($a[1], ENT_QUOTES, 'UTF-8') : map_url(gatecraft_current_path()); if ($action === '' || $action[0] === '?') $action = map_url(gatecraft_current_path()); $qs = (string)wp_parse_url($action, PHP_URL_QUERY); if ($qs === '') return $m[0]; parse_str($qs, $params); $base = strtok($action, '?'); $attrs = preg_replace('/\s*\baction="[^"]*"/i', '', $attrs); $hidden = ''; foreach ($params as $k => $v) if (is_string($v)) $hidden .= ''; return '
' . $hidden; }, $html); // Query-only links ("?edit=5#cat5") are relative to the current page. Inside wp-admin they would drop // page=... and land on an empty screen, so resolve them against the current route first. return preg_replace_callback('/\b(href|action|formaction)="(\?[^"]*)"/', function ($m) { return $m[1] . '="' . esc_attr(map_url(gatecraft_current_path() . html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'))) . '"'; }, $html); } function nonce_attr(): string { return csp_nonce() ? ' nonce="' . e(csp_nonce()) . '"' : ''; } /* * CSS output. WordPress has no esc_css(), so every dynamic value that goes into a style sheet passes through these. * Colours must be strict hex (sanitize_hex_color()); anything else falls back to a fixed default. */ /** A colour for CSS: #rgb or #rrggbb only, otherwise $fallback. */ function css_hex($color, string $fallback = '#000000'): string { $hex = sanitize_hex_color(is_string($color) ? trim($color) : ''); return $hex ? strtolower($hex) : $fallback; } /** * One custom property value: a colour, a theme variable, color-mix() of those, or a font stack. Only letters, digits, * spaces and # % ( ) , . - ' " / are allowed; anything that could end the declaration or the style element, a comment, * url(), expression() or @-rules returns ''. */ function css_value(string $value): string { $value = trim($value); if ($value === '' || strlen($value) > 300) return ''; if (preg_match('#/\*|\*/|url\s*\(|expression\s*\(|javascript\s*:#i', $value)) return ''; return preg_match('#^[a-zA-Z0-9 \#%(),.\'"/-]+$#', $value) ? $value : ''; } /** A rule of custom properties: "selector{--name:value;...}". Names must be --custom-properties; each value goes through css_value(). */ function css_vars(string $selector, array $vars): string { if (!preg_match('/^[a-zA-Z0-9 .#:()_,>-]+$/', $selector)) return ''; $out = ''; foreach ($vars as $name => $value) { if (!is_string($name) || !preg_match('/^--[a-z0-9-]{1,40}$/', $name)) continue; $value = css_value((string)$value); if ($value !== '') $out .= $name . ':' . $value . ';'; } return $out === '' ? '' : $selector . '{' . $out . '}'; } /** * Last guard on a whole generated style sheet before it is queued: removes anything that could close the style element, * open a comment, import or execute code. The rules themselves are built from values checked with css_hex() and css_value(). */ function css_clean(string $css): string { $css = str_replace(['<', '>', '\\', '@', '/*', '*/'], '', $css); return (string)preg_replace('#expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding#i', '', $css); } /** * Page-specific CSS (brand colours, category colours, layout sizes) through the WordPress style queue. Each call adds a * style handle with no file and the CSS attached with wp_add_inline_style(). Pages in the theme and in wp-admin print it * in the head, or with the late styles in the footer when the head was already sent (shortcodes, admin screens). * Standalone pages print it with print_page_styles(). Returns the handle, or '' when there is nothing to add. */ function inline_css(string $css, array $deps = []): string { $css = css_clean($css); if (trim($css) === '') return ''; static $n = 0; $handle = 'gatecraft-inline-' . (++$n); wp_register_style($handle, false, $deps, GATECRAFT_VERSION); wp_add_inline_style($handle, $css); wp_enqueue_style($handle); return $handle; } /** * Print queued styles in the of a standalone page (door scanner, ticket sheet, invoice). The door scanner sends * a strict Content-Security-Policy and so uses no inline styles: its brand colours are set by app.js from data-brand. */ function print_page_styles(array $handles): void { $handles = array_values(array_filter($handles)); if ($handles) wp_print_styles($handles); } function asset(string $path): string { return GATECRAFT_URL . 'assets/' . $path . '?ver=' . GATECRAFT_VERSION; } function upload_url(?string $name): string { return $name && preg_match('/^[a-f0-9]{32}\.(jpg|png|webp)$/', $name) ? '/uploads/' . $name : ''; } function badge(string $status, ?string $label = null): string { return '' . e($label ?? status_label($status)) . ''; } function icon(string $name): string { $p = [ 'calendar' => '', 'pin' => '', 'clock' => '', 'ticket' => '', 'home' => '', 'building' => '', 'cart' => '', 'check' => '', 'scan' => '', 'chart' => '', 'tag' => '', 'users' => '', 'settings' => '', 'shield' => '', 'bell' => '', 'inbox' => '', 'download' => '', 'plus' => '', 'menu' => '', 'logout' => '', 'list' => '', 'mail' => '', 'arrow-left' => '', 'megaphone' => '', 'star' => '', 'alert' => '', 'info' => '', 'external' => '', ][$name] ?? ''; return ''; } /** * Form field helper. $o keys: required, help, placeholder, maxlength, min, max, step, rows, * options (value => label), class, id, autocomplete, inputmode, readonly. */ function field(string $type, string $name, string $label, $value = '', array $o = []): string { $id = $o['id'] ?? 'f_' . preg_replace('/[^a-z0-9_]/i', '_', $name); $attrs = ' id="' . e($id) . '" name="' . e($name) . '"'; // Browsers let the year part of a date take up to six digits unless a range is set. if ($type === 'date') $o += ['min' => '2000-01-01', 'max' => '2099-12-31']; if ($type === 'datetime-local') $o += ['min' => '2000-01-01T00:00', 'max' => '2099-12-31T23:59']; foreach (['placeholder', 'maxlength', 'min', 'max', 'step', 'autocomplete', 'inputmode', 'pattern'] as $a) { if (isset($o[$a])) $attrs .= ' ' . $a . '="' . e($o[$a]) . '"'; } foreach ((array)($o['data'] ?? []) as $dk => $dv) if (preg_match('/^[a-z-]+$/', (string)$dk)) $attrs .= ' data-' . $dk . '="' . e($dv) . '"'; if (!empty($o['required'])) $attrs .= ' required'; if (!empty($o['readonly'])) $attrs .= ' readonly'; if (!empty($o['autofocus'])) $attrs .= ' autofocus'; $help = isset($o['help']) ? '' . e($o['help']) . '' : ''; $req = !empty($o['required']) ? ' ' : ''; $cls = 'field' . (isset($o['class']) ? ' ' . $o['class'] : ''); if ($type === 'checkbox') { return ''; } $html = '
'; if ($type === 'textarea') { $html .= '' . e($value) . ''; } elseif ($type === 'select') { $html .= ''; foreach ($o['options'] ?? [] as $v => $l) { $html .= ''; } $html .= ''; } elseif ($type === 'money') { $html .= '
' . e(cfg('currency')) . '
'; } else { $html .= ''; } return $html . $help . '
'; } /** Small POST button form (for actions like approve, delete). */ function action_button(string $action, string $label, string $class = 'btn', array $hidden = [], ?string $confirm = null): string { $h = '' . csrf_field(); foreach ($hidden as $k => $v) $h .= ''; return $h . ''; } /** A row of filter chips: [value => [label, count]]. The chosen one is marked; links keep the other filters. */ function filter_chips(string $param, string $current, array $chips, array $query, string $label = 'Filter'): string { $h = ''; } function paginate(int $total, int $perPage, int $page, array $query): string { $pages = (int)ceil($total / $perPage); if ($pages <= 1) return ''; $h = ''; } /** * The tags and attributes the plugin's own HTML helpers produce (form fields, icons, badges, charts, QR codes). * Helper output is passed through wp_kses() with this list before it is printed. */ function allowed_html(): array { static $a = null; if ($a !== null) return $a; $global = ['class', 'id', 'title', 'role', 'hidden', 'tabindex', 'lang', 'dir', 'translate', 'data-*', 'aria-label', 'aria-hidden', 'aria-current', 'aria-expanded', 'aria-pressed', 'aria-describedby', 'aria-live', 'aria-controls', 'aria-labelledby', 'aria-invalid', 'aria-required', 'aria-selected', 'aria-haspopup', 'aria-disabled', 'aria-modal', 'aria-atomic', 'aria-busy', 'aria-valuemin', 'aria-valuemax', 'aria-valuenow']; $tags = [ 'a' => ['href', 'target', 'rel', 'download', 'hreflang'], 'abbr' => [], 'article' => [], 'aside' => [], 'b' => [], 'br' => [], 'code' => [], 'em' => [], 'i' => [], 'button' => ['type', 'name', 'value', 'disabled', 'formaction', 'formnovalidate', 'form', 'autofocus'], 'details' => ['open'], 'summary' => [], 'div' => [], 'span' => [], 'p' => [], 'pre' => [], 'section' => [], 'header' => [], 'footer' => [], 'nav' => [], 'main' => [], 'strong' => [], 'small' => [], 'mark' => [], 'kbd' => [], 'sup' => [], 'sub' => [], 'hr' => [], 'time' => ['datetime'], 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [], 'ul' => [], 'ol' => ['start', 'reversed'], 'li' => ['value'], 'dl' => [], 'dt' => [], 'dd' => [], 'blockquote' => ['cite'], 'figure' => [], 'figcaption' => [], 'noscript' => [], 'table' => [], 'caption' => [], 'colgroup' => ['span'], 'col' => ['span'], 'thead' => [], 'tbody' => [], 'tfoot' => [], 'tr' => [], 'th' => ['scope', 'colspan', 'rowspan'], 'td' => ['colspan', 'rowspan'], 'img' => ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'], 'iframe' => ['src', 'name', 'width', 'height', 'allow', 'allowfullscreen', 'frameborder', 'loading', 'referrerpolicy'], 'video' => ['src', 'poster', 'width', 'height', 'controls', 'muted', 'playsinline', 'autoplay', 'loop', 'preload'], 'audio' => ['src', 'controls', 'preload', 'loop'], 'source' => ['src', 'srcset', 'type', 'media', 'sizes'], 'track' => ['src', 'kind', 'srclang', 'label', 'default'], 'form' => ['method', 'action', 'enctype', 'novalidate', 'autocomplete', 'target'], 'fieldset' => ['disabled'], 'legend' => [], 'label' => ['for'], 'input' => ['type', 'name', 'value', 'checked', 'disabled', 'readonly', 'required', 'placeholder', 'maxlength', 'minlength', 'min', 'max', 'step', 'pattern', 'autocomplete', 'inputmode', 'accept', 'capture', 'autofocus', 'autocapitalize', 'spellcheck', 'size', 'multiple', 'list', 'form', 'formnovalidate'], 'select' => ['name', 'required', 'disabled', 'multiple', 'autocomplete', 'size'], 'option' => ['value', 'selected', 'disabled', 'label'], 'optgroup' => ['label', 'disabled'], 'textarea' => ['name', 'rows', 'cols', 'required', 'placeholder', 'maxlength', 'disabled', 'readonly', 'spellcheck', 'autocomplete'], 'datalist' => [], 'output' => ['for', 'name'], 'template' => [], 'progress' => ['value', 'max'], 'meter' => ['value', 'min', 'max', 'low', 'high', 'optimum'], 'svg' => ['xmlns', 'viewbox', 'width', 'height', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'focusable', 'preserveaspectratio', 'shape-rendering'], 'g' => ['fill', 'stroke', 'transform', 'opacity'], 'title' => [], 'defs' => [], 'path' => ['d', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'fill-rule', 'clip-rule', 'transform', 'opacity'], 'rect' => ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width', 'transform', 'opacity'], 'circle' => ['cx', 'cy', 'r', 'fill', 'fill-opacity', 'opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset', 'clip-path', 'transform'], 'clippath' => [], 'line' => ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'stroke-linecap'], 'polyline' => ['points', 'fill', 'stroke', 'stroke-width'], 'polygon' => ['points', 'fill', 'stroke'], 'text' => ['x', 'y', 'text-anchor', 'dominant-baseline', 'font-size', 'fill'], ]; $a = []; foreach ($tags as $t => $attrs) $a[$t] = array_fill_keys(array_merge($global, $attrs), true); // Rich text written in the editor (event descriptions, announcements) keeps what rich_clean() allowed when it was saved. foreach (rich_allowed_html() as $t => $attrs) $a[$t] = ($a[$t] ?? []) + (is_array($attrs) ? $attrs : []); return $a; } /** How the list of events is laid out on the home page and in the events shortcode: value => label. */ function event_list_views(): array { return ['small' => 'Small', 'medium' => 'Medium', 'wide' => 'Wide', 'full' => 'Full width', 'list' => 'List']; } function event_list_view(?string $v = null): string { $v ??= setting('event_list_view'); return isset(event_list_views()[$v]) ? $v : 'medium'; } /** Colour picker with a hex box and a few presets. Empty means "use the default". Used for ticket category colours. */ function color_field(string $id, ?string $value, string $label = 'Colour', string $help = 'Pick a colour or type a hex code. Leave empty to use the event colour.'): string { $v = $value && preg_match('/^#[0-9a-f]{6}$/i', $value) ? strtolower($value) : ''; $presets = ['#b8860b' => 'Gold', '#8b939c' => 'Silver', '#a0522d' => 'Bronze', '#dc2626' => 'Red', '#ea580c' => 'Orange', '#16a34a' => 'Green', '#0d9488' => 'Teal', '#2563eb' => 'Blue', '#7c3aed' => 'Violet', '#db2777' => 'Pink', '#0f172a' => 'Black']; $h = '
'; $h .= ''; $h .= ''; $h .= '
'; foreach ($presets as $hex => $name) $h .= ''; return $h . '
' . e($help) . '
'; } /** CSS for the .cd- colour dots on a page, added with inline_css() (inline style attributes are blocked by the CSP). */ function color_dots_css(array $hexes): void { $hexes = array_merge(['#b8860b', '#8b939c', '#a0522d', '#dc2626', '#ea580c', '#16a34a', '#0d9488', '#2563eb', '#7c3aed', '#db2777', '#0f172a'], $hexes); $css = ''; foreach (array_unique(array_map('strtolower', $hexes)) as $h) if (preg_match('/^#[0-9a-f]{6}$/', $h)) $css .= '.cd-' . substr($h, 1) . '{background:' . $h . '}'; inline_css($css); }