'English', 'ja' => '日本語']; add_filter('gatecraft_setting_defaults', fn(array $d) => $d + ['language_default' => 'en', 'language_ja' => '0', 'language_switcher' => '1']); add_filter('gatecraft_setting_rules', function (array $r, string $tab) { if ($tab !== 'regional') return $r; return $r + ['language_default' => ['enum', array_keys(GATECRAFT_LANGUAGES)], 'language_ja' => ['bool'], 'language_switcher' => ['bool']]; }, 10, 2); /** The file with the phrases of a language that ships with the plugin, or '' when there is none. */ function language_file(string $lang): string { if (!preg_match('/^[a-z]{2}$/', $lang)) return ''; $f = GATECRAFT_DIR . 'languages/' . $lang . '.php'; return is_file($f) ? $f : ''; } /** * True when the phrases for a language are installed. English is built in and Japanese ships in languages/ja.php. * Add-ons can supply more languages through the gatecraft_language_installed and gatecraft_dictionary filters. */ function language_installed(string $lang): bool { return $lang === 'en' || language_file($lang) !== '' || (bool)apply_filters('gatecraft_language_installed', false, $lang); } /** Languages people can choose on this site. English is always available. */ function languages_enabled(): array { $out = ['en' => GATECRAFT_LANGUAGES['en']]; if (setting_on('language_ja') && language_installed('ja')) $out['ja'] = GATECRAFT_LANGUAGES['ja']; return $out; } function language_valid($l): bool { return is_string($l) && isset(languages_enabled()[$l]); } /** The site default, falling back to English when the default language is switched off. */ function language_default(): string { $d = setting('language_default'); return language_valid($d) ? $d : 'en'; } /** Set by a public page about one event or booking, so its language applies when the visitor has not chosen one. */ function language_context($lang = null, bool $set = false): ?string { static $ctx = null; if ($set) $ctx = language_valid($lang) ? $lang : null; return $ctx; } /** A forced language for work done outside a page (emails to a buyer or a staff member). */ function language_force(?string $lang = null, bool $set = false): ?string { static $f = null; if ($set) $f = $lang !== null && isset(GATECRAFT_LANGUAGES[$lang]) ? $lang : null; return $f; } /** Run $fn with a language active, for example to build an email in the recipient's language. */ function with_language(?string $lang, callable $fn) { $before = language_force(); language_force(language_valid($lang) ? $lang : language_default(), true); try { return $fn(); } finally { language_force($before, true); } } function current_language(): string { if (($f = language_force()) !== null) return $f; if (count(languages_enabled()) < 2) return 'en'; static $chosen = false; if ($chosen === false) { $chosen = null; // ?lang= is a display choice for this browser, compared with the enabled languages (language_valid()). It is saved // on the signed-in account only when the link carries our nonce (language_save_from_link()). $q = isset($_GET['lang']) && is_string($_GET['lang']) ? strtolower(substr(sanitize_key(wp_unslash($_GET['lang'])), 0, 5)) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display choice for this browser; the account is only changed after the nonce check in language_save_from_link() if (language_valid($q)) { $chosen = $q; language_remember($q); language_save_from_link($q); } else { $u = PHP_SAPI === 'cli' ? null : current_user(); $pref = $u ? user_language((int)$u['id']) : null; $cookie = $GLOBALS['gatecraft_lang_chosen'] ?? (isset($_COOKIE['gc_lang']) && is_string($_COOKIE['gc_lang']) ? sanitize_key(wp_unslash($_COOKIE['gc_lang'])) : null); if (language_valid($pref)) $chosen = $pref; elseif (language_valid($cookie)) $chosen = $cookie; } } if ($chosen !== null) return $chosen; $ctx = language_context(); return $ctx ?? language_default(); } /** Remember a visitor's choice in a cookie in their browser. */ function language_remember(string $lang): void { if (PHP_SAPI !== 'cli' && !headers_sent()) { setcookie('gc_lang', $lang, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => is_https(), 'httponly' => false, 'samesite' => 'Lax']); } $GLOBALS['gatecraft_lang_chosen'] = $lang; } /** Save a language chosen from a language link on the signed-in account. The link must carry a valid nonce. */ function language_save_from_link(string $lang): void { if (PHP_SAPI === 'cli' || !is_user_logged_in()) return; // 1. The nonce, on its own. Without it the account is not changed. $nonce = isset($_GET['_gclang']) ? sanitize_text_field(wp_unslash($_GET['_gclang'])) : ''; if (!wp_verify_nonce($nonce, 'gatecraft_lang')) return; // 2. Then the change, for the person's own account only. $u = current_user(); if ($u && language_valid($lang) && user_language((int)$u['id']) !== $lang) user_language_set((int)$u['id'], $lang); } function is_https_request(): bool { return is_ssl(); } /** The language a person chose for themselves, or null. */ function user_language(int $userId): ?string { try { $v = qv('SELECT language FROM {' . user_language_table() . '} WHERE ' . user_language_key() . ' = ?', [$userId]); } catch (\Throwable $e) { return null; // before the upgrade that adds the column } return is_string($v) && $v !== '' ? $v : null; } function user_language_set(int $userId, ?string $lang): void { $lang = $lang !== null && isset(GATECRAFT_LANGUAGES[$lang]) ? $lang : null; try { q('UPDATE {' . user_language_table() . '} SET language = ? WHERE ' . user_language_key() . ' = ?', [$lang, $userId]); } catch (\Throwable $e) { // The column arrives with the database upgrade; nothing to remember until then. } } /** The language a staff member or buyer chose, looked up by email, else the site default. */ function user_language_by_email(string $email): string { try { $v = qv('SELECT a.language FROM {accounts} a JOIN {wpusers} u ON u.ID = a.user_id WHERE u.user_email = ?', [$email]); } catch (\Throwable $e) { $v = null; } return is_string($v) && language_valid($v) ? $v : language_default(); } function user_language_table(): string { return 'accounts'; } function user_language_key(): string { return 'user_id'; } /** * The language menu: in the page header for visitors (when the switcher is on), and on the account page and in the * admin for signed-in people ($force). Empty on a one-language site. */ function language_menu(bool $force = false): string { return (string)apply_filters('gatecraft_language_menu', language_menu_html($force), $force); } /** A link to the current page in another language. */ function language_link_url(string $lang): string { $q = get_all(); unset($q['lang'], $q['_gclang']); $q['lang'] = $lang; // Signed in: the link may also save the choice on the account, so it carries a nonce (checked in language_save_from_link()). if (is_user_logged_in()) $q['_gclang'] = wp_create_nonce('gatecraft_lang'); return gatecraft_current_path() . '?' . http_build_query($q); } function language_menu_html(bool $force = false): string { $langs = languages_enabled(); if (count($langs) < 2 || (!$force && !setting_on('language_switcher'))) return ''; $cur = current_language(); $out = ''; } /** Choices for a language select. $inherit adds an empty "use the default" option. */ function language_options(string $inherit = ''): array { $o = $inherit !== '' ? ['' => $inherit] : []; return $o + GATECRAFT_LANGUAGES; } // ---------------------------------------------------------------- dates /** ldate() for dates people read: month and weekday names follow the language. */ function ldate_h(string $format, ?int $ts = null): string { $ts ??= time(); if (current_language() !== 'ja') return ldate($format, $ts); static $map = ['j M Y' => 'Y年n月j日', 'j M' => 'n月j日', 'M' => 'n月', 'F Y' => 'Y年n月', 'g:i a' => 'A g:i']; $format = $map[$format] ?? $format; $days = ['日', '月', '火', '水', '木', '金', '土']; $w = (int)ldate('w', $ts); $out = ''; $len = strlen($format); for ($i = 0; $i < $len; $i++) { $c = $format[$i]; if ($c === '\\' && $i + 1 < $len) { $out .= $format[++$i]; continue; } $out .= match ($c) { 'D' => $days[$w], 'l' => $days[$w] . '曜日', 'M', 'F' => ldate('n', $ts) . '月', 'A', 'a' => (int)ldate('G', $ts) < 12 ? '午前' : '午後', default => ctype_alpha($c) ? ldate($c, $ts) : $c, }; } return $out; } // ---------------------------------------------------------------- translation function i18n_dictionary(string $lang): array { static $cache = []; if (isset($cache[$lang])) return $cache[$lang]; $own = $lang === 'en' ? '' : language_file($lang); $d = $lang === 'en' ? [] : (array)apply_filters('gatecraft_dictionary', $own !== '' ? (array)include $own : [], $lang); $d += ['phrases' => [], 'patterns' => []]; // Phrases are matched with their spacing tidied, so a line break in a template does not stop a match. $phrases = []; foreach ($d['phrases'] as $en => $tr) $phrases[i18n_norm((string)$en)] = (string)$tr; return $cache[$lang] = ['phrases' => $phrases, 'patterns' => (array)$d['patterns'], 'js' => (array)($d['js'] ?? [])]; } function i18n_norm(string $s): string { return trim((string)preg_replace('/\s+/u', ' ', $s)); } /** Translate one phrase. Returns it unchanged when there is nothing to do. */ function t(string $s, ?string $lang = null): string { $lang ??= current_language(); if ($lang === 'en' || $s === '') return $s; $key = i18n_norm($s); if ($key === '' || !preg_match('/[A-Za-z]{2}/', $key)) return $s; static $memo = []; if (isset($memo[$lang][$key])) return $memo[$lang][$key] === false ? $s : $memo[$lang][$key]; $d = i18n_dictionary($lang); if (isset($d['phrases'][$key])) return $memo[$lang][$key] = $d['phrases'][$key]; // Patterns: [regex, replacement, a fixed piece of text the phrase must contain]. In the replacement, {n} is the // n-th value, itself translated when it is a known phrase (a payment method, a status), and $n is the raw value. foreach ($d['patterns'] as $p) { [$re, $rep] = $p; $needle = $p[2] ?? ''; if ($needle !== '' && !str_contains($key, $needle)) continue; if (!preg_match($re, $key, $m)) continue; $out = (string)preg_replace_callback('/\$(\d+)|\{(\d+)\}/', function ($r) use ($m, $lang) { if ($r[1] !== '') return $m[(int)$r[1]] ?? ''; return t($m[(int)$r[2]] ?? '', $lang); }, $rep); return $memo[$lang][$key] = $out; } $memo[$lang][$key] = false; return $s; } /** Translate a text node, keeping its surrounding spaces and HTML entities intact. */ function i18n_text(string $raw, string $lang): string { if (!preg_match('/[A-Za-z]/', $raw)) return $raw; $plain = html_entity_decode($raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $tr = t($plain, $lang); if ($tr === $plain) return $raw; preg_match('/^\s*/u', $plain, $a); preg_match('/\s*$/u', $plain, $b); return $a[0] . htmlspecialchars($tr, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8', false) . $b[0]; } /** Translate a page of HTML into the current language. */ function i18n_html(string $html, ?string $lang = null): string { $lang ??= current_language(); if ($lang === 'en' || $html === '') return $html; $parts = preg_split('/(|<[^>]*>)/s', $html, -1, PREG_SPLIT_DELIM_CAPTURE); if ($parts === false) return $html; $skip = 0; // inside script, style, textarea, pre or code $skipTag = ''; $noDepth = []; // stack of element names opened with translate="no" $stack = []; static $void = ['area' => 1, 'base' => 1, 'br' => 1, 'col' => 1, 'embed' => 1, 'hr' => 1, 'img' => 1, 'input' => 1, 'link' => 1, 'meta' => 1, 'source' => 1, 'track' => 1, 'wbr' => 1]; $out = ''; foreach ($parts as $i => $p) { if ($p === '') continue; if ($i % 2 === 0) { // text $out .= ($skip || $noDepth) ? $p : i18n_text($p, $lang); continue; } if (str_starts_with($p, '