WebOrbiton
v2.0.0.1

Publisium

1,250 lines · 51.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/icons.php';
  7. require_once __DIR__ . '/social-icons.php';
  8. require_once __DIR__ . '/article-scheduler.php';
  9. require_once __DIR__ . '/languages.php';
  10. require_once __DIR__ . '/clean-urls.php';
  11. ​
  12. final class SiteFront
  13. {
  14. private static ?array $settings = null;
  15. ​
  16. public const AD_PLACEMENTS = [
  17. 'header' => 'Header, below the menu on every page',
  18. 'homepage-top' => 'Home page, near the top',
  19. 'homepage-bottom' => 'Home page, at the bottom',
  20. 'in-feed' => 'Between articles in lists (home page and categories)',
  21. 'category-top' => 'Category pages, above the articles',
  22. 'search-top' => 'Search page, above the results',
  23. 'article-top' => 'Articles, below the title',
  24. 'in-article' => 'Inside articles, between the cover image and the text',
  25. 'article-middle' => 'Articles, in the middle of the text',
  26. 'article-bottom' => 'Below the article text',
  27. 'page-top' => 'Pages, below the title',
  28. 'page-bottom' => 'Pages, below the content',
  29. 'footer' => 'Just above the footer',
  30. 'sticky-bottom' => 'Bar stuck to the bottom of the screen (visitors can close it)',
  31. ];
  32. ​
  33. public const AD_AUDIENCES = [
  34. 'all' => 'Everyone',
  35. 'guests' => 'Only visitors who aren’t logged in',
  36. 'members' => 'Only logged-in readers',
  37. 'paid' => 'Only paying subscribers',
  38. 'unpaid' => 'Only people who don’t pay (including visitors who aren’t logged in)',
  39. ];
  40. ​
  41. public const AD_CONSENTS = [
  42. 'none' => 'Show it right away, no cookie consent needed',
  43. 'marketing' => 'Only after the visitor allows marketing (usual for ad networks)',
  44. 'analytics' => 'Only after the visitor allows analytics',
  45. 'both' => 'Only after the visitor allows both analytics and marketing',
  46. ];
  47. ​
  48. public const ADBLOCK_MODES = [
  49. 'once' => 'User-Friendly: shows once, then never again in that browser',
  50. 'session' => 'Session: shows once per visit (until the tab is closed)',
  51. 'extended' => 'Extended: shows on every page, visitors can close it',
  52. 'lock' => 'ViewLocker: can’t be closed and the page can’t be scrolled',
  53. 'lock_max' => 'ViewLocker Max: can’t be closed and everything except the message is blurred out',
  54. ];
  55. ​
  56. public static function adblockMode(): string
  57. {
  58. $mode = (string) (self::settings()['adblock_notice_mode'] ?? 'session');
  59. $mode = ['gentle' => 'session', 'wall' => 'lock_max'][$mode] ?? $mode;
  60. ​
  61. return array_key_exists($mode, self::ADBLOCK_MODES) ? $mode : 'session';
  62. }
  63. ​
  64. private static array $adContext = ['type' => '', 'id' => 0];
  65. ​
  66. // Tells the ad system what is being shown, so ads can skip excluded articles and pages.
  67. public static function setAdContext(string $type, int $id = 0): void
  68. {
  69. self::$adContext = ['type' => $type, 'id' => $id];
  70. }
  71. ​
  72. public static function adExclusions(array $ad): array
  73. {
  74. $decoded = json_decode((string) ($ad['excluded_items'] ?? ''), true);
  75. $decoded = is_array($decoded) ? $decoded : [];
  76. ​
  77. return [
  78. 'home' => !empty($decoded['home']),
  79. 'articles' => array_values(array_unique(array_filter(array_map('intval', is_array($decoded['articles'] ?? null) ? $decoded['articles'] : [])))),
  80. 'pages' => array_values(array_unique(array_filter(array_map('intval', is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [])))),
  81. ];
  82. }
  83. ​
  84. // A date in the site's language (for example "28 września 2026"), optionally with the time
  85. // in the 24-hour or 12-hour format picked in Layout. Without the intl extension it falls back to English.
  86. public static function formatNumber(int $number): string
  87. {
  88. if (class_exists('NumberFormatter')) {
  89. $formatted = (new NumberFormatter(str_replace('-', '_', Languages::htmlLang()), NumberFormatter::DECIMAL))->format($number);
  90. if (is_string($formatted) && $formatted !== '') {
  91. return $formatted;
  92. }
  93. }
  94. ​
  95. return number_format($number);
  96. }
  97. ​
  98. public static function formatDate(string|int $when, bool $withTime = false): string
  99. {
  100. $timestamp = is_int($when) ? $when : strtotime($when);
  101. if ($timestamp === false) {
  102. return '';
  103. }
  104. ​
  105. $date = false;
  106. if (class_exists('IntlDateFormatter')) {
  107. $formatter = new IntlDateFormatter(str_replace('-', '_', Languages::htmlLang()), IntlDateFormatter::LONG, IntlDateFormatter::NONE, date_default_timezone_get());
  108. $date = $formatter->format($timestamp);
  109. }
  110. if (!is_string($date) || $date === '') {
  111. $date = date('F j, Y', $timestamp);
  112. }
  113. ​
  114. if (!$withTime) {
  115. return $date;
  116. }
  117. ​
  118. return $date . ', ' . date(self::settings()['article_time_format'] === '12' ? 'g:i A' : 'H:i', $timestamp);
  119. }
  120. ​
  121. // The payment link, with the reader's email filled in on Polar and Stripe payment links.
  122. // The webhook links a payment to an account by email, so this keeps them matching.
  123. public static function subscriptionCheckoutUrl(?string $email = null): string
  124. {
  125. $url = trim((string) self::settings()['subscription_checkout_url']);
  126. $email = strtolower(trim((string) $email));
  127. if ($url === '' || $email === '' || filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
  128. return $url;
  129. }
  130. ​
  131. $host = strtolower((string) parse_url($url, PHP_URL_HOST));
  132. $param = match (true) {
  133. $host === 'polar.sh' || str_ends_with($host, '.polar.sh') => 'customer_email',
  134. $host === 'buy.stripe.com' => 'prefilled_email',
  135. default => '',
  136. };
  137. if ($param === '' || preg_match('/[?&]' . $param . '=/', $url) === 1) {
  138. return $url;
  139. }
  140. ​
  141. [$base, $fragment] = array_pad(explode('#', $url, 2), 2, null);
  142. ​
  143. return $base . (str_contains($base, '?') ? '&' : '?') . $param . '=' . rawurlencode($email) . ($fragment !== null ? '#' . $fragment : '');
  144. }
  145. ​
  146. public static function renderAds(string $placement): string
  147. {
  148. $html = '';
  149. foreach (self::activeAds($placement) as $ad) {
  150. $html .= self::renderAd($ad);
  151. }
  152. ​
  153. return $html;
  154. }
  155. ​
  156. public static function articleUrl(string $slug, ?string $lang = null): string
  157. {
  158. $basePath = rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  159. $prefix = $lang === null ? Languages::prefix() : ($lang === '' ? '' : '/' . $lang);
  160. if ((self::settings()['clean_article_urls'] ?? '0') === '1') {
  161. return $basePath . $prefix . '/' . rawurlencode(CleanUrls::slugFor('article', $slug) ?? $slug);
  162. }
  163. ​
  164. return $basePath . $prefix . '/article.php?slug=' . rawurlencode($slug);
  165. }
  166. ​
  167. // Link to the home page: "/" when the short home address is on, otherwise "/index.php".
  168. public static function homeUrl(?string $lang = null): string
  169. {
  170. $basePath = rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  171. $prefix = $lang === null ? Languages::prefix() : ($lang === '' ? '' : '/' . $lang);
  172. ​
  173. return $basePath . $prefix . (self::cleanHomeUrl() ? '/' : '/index.php');
  174. }
  175. ​
  176. public static function cleanHomeUrl(): bool
  177. {
  178. return (self::settings()['clean_home_url'] ?? '0') === '1';
  179. }
  180. ​
  181. public static function pageUrl(string $slug, ?string $lang = null): string
  182. {
  183. return self::entityUrl('page', 'page.php', $slug, $lang);
  184. }
  185. ​
  186. public static function authorUrl(string $slug, ?string $lang = null): string
  187. {
  188. return self::entityUrl('author', 'artist.php', $slug, $lang);
  189. }
  190. ​
  191. private static function entityUrl(string $type, string $script, string $slug, ?string $lang): string
  192. {
  193. $basePath = rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  194. $prefix = $lang === null ? Languages::prefix() : ($lang === '' ? '' : '/' . $lang);
  195. $cleanSlug = CleanUrls::slugFor($type, $slug);
  196. if ($cleanSlug !== null) {
  197. return $basePath . $prefix . '/' . rawurlencode($cleanSlug);
  198. }
  199. ​
  200. return $basePath . $prefix . '/' . $script . '?slug=' . rawurlencode($slug);
  201. }
  202. ​
  203. public static function resetSettings(): void
  204. {
  205. self::$settings = null;
  206. }
  207. ​
  208. public static function settings(): array
  209. {
  210. if (self::$settings !== null) {
  211. return self::$settings;
  212. }
  213. ​
  214. $defaults = [
  215. 'site_name' => Config::get('APP_NAME', 'Publisium'),
  216. 'site_description' => '',
  217. 'seo_title' => '',
  218. 'seo_description' => '',
  219. 'seo_keywords' => '',
  220. 'seo_author' => '',
  221. 'seo_reply_to' => '',
  222. 'seo_application_name' => '',
  223. 'seo_robots_index' => '1',
  224. 'seo_robots_follow' => '1',
  225. 'seo_og_article_image_enabled' => '0',
  226. 'seo_article_schema_enabled' => '0',
  227. 'seo_social_tags_enabled' => '0',
  228. 'seo_twitter_handle' => '',
  229. 'llms_txt_enabled' => '0',
  230. 'site_logo_url' => '',
  231. 'site_show_name_with_logo' => '0',
  232. 'site_short_name' => '',
  233. 'site_short_name_enabled' => '0',
  234. 'registration_enabled' => '1',
  235. 'login_enabled' => '1',
  236. 'media_webp_enabled' => '1',
  237. 'auth_legal_links_enabled' => '0',
  238. 'auth_terms_url' => '',
  239. 'auth_privacy_url' => '',
  240. 'custom_css' => '',
  241. 'custom_js' => '',
  242. 'custom_head_html' => '',
  243. 'custom_head_html_index' => '0',
  244. 'custom_head_html_article' => '0',
  245. 'custom_head_html_category' => '0',
  246. 'custom_head_html_pages' => '0',
  247. 'custom_body_html' => '',
  248. 'custom_body_html_index' => '0',
  249. 'custom_body_html_article' => '0',
  250. 'custom_body_html_category' => '0',
  251. 'custom_body_html_pages' => '0',
  252. 'menu_position' => 'top',
  253. 'banner_enabled' => '1',
  254. 'featured_banner_enabled' => '0',
  255. 'featured_banner_mode' => 'static',
  256. 'featured_banner_hide_duplicates' => '0',
  257. 'featured_banner_article_1' => '0',
  258. 'featured_banner_article_2' => '0',
  259. 'featured_banner_article_3' => '0',
  260. 'featured_banner_article_4' => '0',
  261. 'featured_banner_article_5' => '0',
  262. 'featured_banner_auto_window_value' => '24',
  263. 'featured_banner_auto_window_unit' => 'hours',
  264. 'featured_banner_auto_category_mode' => 'mixed',
  265. 'related_articles_enabled' => '1',
  266. 'article_reading_time_enabled' => '1',
  267. 'article_views_enabled' => '0',
  268. 'article_time_enabled' => '0',
  269. 'article_time_format' => '24',
  270. 'back_to_top_enabled' => '0',
  271. 'adblock_notice_enabled' => '0',
  272. 'adblock_notice_mode' => 'session',
  273. 'back_to_top_progress_enabled' => '0',
  274. 'toc_enabled' => '0',
  275. 'breadcrumbs_enabled' => '0',
  276. 'author_box_enabled' => '0',
  277. 'account_consents_enabled' => '1',
  278. 'author_pages_enabled' => '0',
  279. 'tags_enabled' => '0',
  280. 'activity_log_enabled' => '0',
  281. 'seo_preview_enabled' => '0',
  282. 'indexnow_enabled' => '0',
  283. 'indexnow_key' => '',
  284. 'sitemap_rss_enabled' => '1',
  285. 'rss_category_feeds_enabled' => '0',
  286. 'comments_enabled' => '0',
  287. 'comments_provider' => 'disqus',
  288. 'comments_shortname' => '',
  289. 'comments_server_url' => '',
  290. 'comments_site_id' => '',
  291. 'comments_repo' => '',
  292. 'comments_repo_id' => '',
  293. 'comments_category' => '',
  294. 'comments_category_id' => '',
  295. 'comments_mapping' => 'pathname',
  296. 'comments_custom_html' => '',
  297. 'comments_consent' => 'marketing',
  298. 'comments_load' => 'auto',
  299. 'comments_who' => 'users',
  300. 'comments_moderation' => 'all',
  301. 'comments_email' => 'off',
  302. 'consent_manager_enabled' => '0',
  303. 'consent_footer_icon_enabled' => '0',
  304. 'consent_show_analytics' => 'auto',
  305. 'consent_show_marketing' => 'auto',
  306. 'heading_font' => 'Plus Jakarta Sans',
  307. 'body_font' => 'PT Serif',
  308. 'interface_font' => 'Plus Jakarta Sans',
  309. 'subscription_name' => 'Full access',
  310. 'subscription_price_cents' => '0',
  311. 'subscription_interval' => 'month',
  312. 'subscription_currency' => 'USD',
  313. 'subscription_description' => '',
  314. 'subscription_checkout_url' => '',
  315. 'payment_provider' => 'stripe',
  316. 'payment_webhook_secret' => '',
  317. 'footer_columns' => '[]',
  318. 'footer_social_links' => '[]',
  319. 'article_loading_mode' => 'scroll',
  320. 'pagination_style' => 'numbered',
  321. 'articles_per_page_home' => '12',
  322. 'articles_per_page_category' => '12',
  323. 'search_enabled' => '0',
  324. 'search_noindex_enabled' => '1',
  325. 'search_match_title' => '1',
  326. 'search_match_excerpt' => '1',
  327. 'search_match_content' => '0',
  328. 'search_style' => 'icon',
  329. 'search_show_in_nav' => '0',
  330. 'search_show_in_footer' => '0',
  331. 'share_enabled' => '0',
  332. 'share_facebook' => '1',
  333. 'share_twitter' => '1',
  334. 'share_linkedin' => '1',
  335. 'share_whatsapp' => '1',
  336. 'share_telegram' => '0',
  337. 'share_email' => '1',
  338. 'share_copy_link' => '1',
  339. 'pwa_enabled' => '0',
  340. 'pwa_name' => '',
  341. 'pwa_short_name' => '',
  342. 'pwa_description' => '',
  343. 'pwa_icon_url' => '',
  344. 'pwa_theme_color' => '#ffffff',
  345. 'pwa_background_color' => '#ffffff',
  346. 'app_banner_enabled' => '0',
  347. 'app_android_url' => '',
  348. 'app_ios_url' => '',
  349. 'multilang_enabled' => '0',
  350. 'multilang_default_code' => 'en',
  351. 'multilang_default_name' => 'English',
  352. 'multilang_switcher' => '1',
  353. ];
  354. ​
  355. $rows = Database::site()->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll();
  356. $values = [];
  357. foreach ($rows as $row) {
  358. $values[$row['setting_key']] = $row['setting_value'];
  359. }
  360. ​
  361. self::$settings = array_merge($defaults, $values);
  362. self::$settings = Languages::applySiteTexts(self::$settings);
  363. ArticleScheduler::publishDue();
  364. ​
  365. return self::$settings;
  366. }
  367. ​
  368. public static function navItems(): array
  369. {
  370. try {
  371. $rows = Database::site()->query(
  372. 'SELECT * FROM nav_items ORDER BY sort_order ASC, id ASC'
  373. )->fetchAll();
  374. } catch (PDOException $e) {
  375. return self::fallbackNavItems();
  376. }
  377. ​
  378. if (empty($rows)) {
  379. return self::fallbackNavItems();
  380. }
  381. ​
  382. $items = [];
  383. $navTitles = Languages::titles('nav', array_map('intval', array_column($rows, 'id')));
  384. foreach ($rows as $row) {
  385. if ($row['item_type'] === 'category') {
  386. $statement = Database::site()->prepare(
  387. 'SELECT id, name, slug FROM categories WHERE id = :id AND show_in_menu = 1 LIMIT 1'
  388. );
  389. $statement->execute(['id' => $row['ref_id']]);
  390. $category = $statement->fetch();
  391. if (!$category) {
  392. continue;
  393. }
  394. $category = Languages::categories([$category])[0];
  395. $items[] = [
  396. 'label' => $navTitles[(int) $row['id']] ?? ($row['label'] !== null && $row['label'] !== '' ? $row['label'] : $category['name']),
  397. 'url' => 'category.php?slug=' . urlencode($category['slug']),
  398. 'target' => '_self',
  399. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  400. ];
  401. continue;
  402. }
  403. ​
  404. if ($row['item_type'] === 'page') {
  405. $statement = Database::site()->prepare(
  406. "SELECT id, title, slug FROM pages WHERE id = :id AND show_in_menu = 1 AND status = 'published' LIMIT 1"
  407. );
  408. $statement->execute(['id' => $row['ref_id']]);
  409. $page = $statement->fetch();
  410. if (!$page) {
  411. continue;
  412. }
  413. $page = Languages::pages([$page])[0];
  414. $items[] = [
  415. 'label' => $navTitles[(int) $row['id']] ?? ($row['label'] !== null && $row['label'] !== '' ? $row['label'] : $page['title']),
  416. 'url' => self::pageUrl((string) $page['slug']),
  417. 'target' => '_self',
  418. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  419. ];
  420. continue;
  421. }
  422. ​
  423. if ($row['item_type'] === 'custom') {
  424. if (empty($row['label']) || empty($row['url'])) {
  425. continue;
  426. }
  427. $items[] = [
  428. 'label' => $navTitles[(int) $row['id']] ?? $row['label'],
  429. 'url' => $row['url'],
  430. 'target' => $row['target'] === '_blank' ? '_blank' : '_self',
  431. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  432. ];
  433. }
  434. }
  435. ​
  436. return $items;
  437. }
  438. ​
  439. public static function appendToCustomNav(string $itemType, int $refId): void
  440. {
  441. if ($refId <= 0 || !in_array($itemType, ['category', 'page'], true)) {
  442. return;
  443. }
  444. ​
  445. try {
  446. $db = Database::site();
  447. if ((int) $db->query('SELECT COUNT(*) FROM nav_items')->fetchColumn() === 0) {
  448. return;
  449. }
  450. ​
  451. $existing = $db->prepare('SELECT id FROM nav_items WHERE item_type = :type AND ref_id = :ref_id LIMIT 1');
  452. $existing->execute(['type' => $itemType, 'ref_id' => $refId]);
  453. if ($existing->fetch()) {
  454. return;
  455. }
  456. ​
  457. $next = (int) $db->query('SELECT COALESCE(MAX(sort_order), -1) + 1 FROM nav_items')->fetchColumn();
  458. $db->prepare('INSERT INTO nav_items (item_type, ref_id, sort_order) VALUES (:type, :ref_id, :sort_order)')
  459. ->execute(['type' => $itemType, 'ref_id' => $refId, 'sort_order' => $next]);
  460. } catch (PDOException $e) {
  461. error_log('Publisium: could not add ' . $itemType . ' #' . $refId . ' to the menu: ' . $e->getMessage());
  462. }
  463. }
  464. ​
  465. private static function navItemIconHtml(?string $iconKey, ?string $iconSvg): string
  466. {
  467. if ($iconKey === 'custom' && !empty($iconSvg)) {
  468. return '<span class="nav-item-icon" aria-hidden="true">' . $iconSvg . '</span>';
  469. }
  470. ​
  471. if (!empty($iconKey) && Icons::isNavIcon($iconKey)) {
  472. return Icons::icon($iconKey, 'icon icon-sm nav-item-icon');
  473. }
  474. ​
  475. return '';
  476. }
  477. ​
  478. private static function fallbackNavItems(): array
  479. {
  480. $items = [];
  481. ​
  482. $categories = Database::site()->query(
  483. 'SELECT id, name, slug FROM categories WHERE show_in_menu = 1 ORDER BY sort_order ASC, name ASC'
  484. )->fetchAll();
  485. $categories = Languages::categories($categories);
  486. foreach ($categories as $category) {
  487. $items[] = [
  488. 'label' => $category['name'],
  489. 'url' => 'category.php?slug=' . urlencode($category['slug']),
  490. 'target' => '_self',
  491. ];
  492. }
  493. ​
  494. try {
  495. $pages = Database::site()->query(
  496. "SELECT id, title, slug FROM pages WHERE show_in_menu = 1 AND status = 'published' ORDER BY sort_order ASC, title ASC"
  497. )->fetchAll();
  498. } catch (PDOException $e) {
  499. $pages = [];
  500. }
  501. $pages = Languages::pages($pages);
  502. foreach ($pages as $page) {
  503. $items[] = [
  504. 'label' => $page['title'],
  505. 'url' => self::pageUrl((string) $page['slug']),
  506. 'target' => '_self',
  507. ];
  508. }
  509. ​
  510. return $items;
  511. }
  512. ​
  513. public static function renderSearchControl(string $context): string
  514. {
  515. $settings = self::settings();
  516. ​
  517. if ($settings['search_enabled'] !== '1') {
  518. return '';
  519. }
  520. ​
  521. $placementKey = $context === 'footer' ? 'search_show_in_footer' : 'search_show_in_nav';
  522. if (($settings[$placementKey] ?? '0') !== '1') {
  523. return '';
  524. }
  525. ​
  526. $searchLabel = Language::get('nav_search', 'Search');
  527. $searchPlaceholder = Language::get('search_placeholder', 'Search');
  528. $magnifierSvg = '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';
  529. ​
  530. if ($settings['search_style'] === 'field') {
  531. return '<form action="search.php" method="get" class="site-search-form site-search-form-' . htmlspecialchars($context, ENT_QUOTES)
  532. . '" role="search">'
  533. . '<span class="site-search-form-icon">' . $magnifierSvg . '</span>'
  534. . '<input type="text" name="q" class="site-search-input" placeholder="' . htmlspecialchars($searchPlaceholder, ENT_QUOTES) . '" aria-label="' . htmlspecialchars($searchLabel, ENT_QUOTES) . '">'
  535. . '</form>';
  536. }
  537. ​
  538. return '<a href="search.php" class="site-search-icon-link" aria-label="' . htmlspecialchars($searchLabel, ENT_QUOTES) . '">' . $magnifierSvg . '</a>';
  539. }
  540. ​
  541. public static function renderShareControl(array $article): string
  542. {
  543. $settings = self::settings();
  544. if (($settings['share_enabled'] ?? '0') !== '1') {
  545. return '';
  546. }
  547. ​
  548. $url = self::currentUrl();
  549. $encodedUrl = urlencode($url);
  550. $encodedTitle = urlencode((string) ($article['title'] ?? ''));
  551. ​
  552. $platformDefs = [
  553. 'facebook' => [
  554. 'label' => Language::get('share_facebook', 'Facebook'),
  555. 'url' => 'https://www.facebook.com/sharer/sharer.php?u=' . $encodedUrl,
  556. 'svg' => SocialIcons::builtinSvg('facebook'),
  557. ],
  558. 'twitter' => [
  559. 'label' => Language::get('share_twitter', 'Twitter / X'),
  560. 'url' => 'https://twitter.com/intent/tweet?url=' . $encodedUrl . '&text=' . $encodedTitle,
  561. 'svg' => SocialIcons::builtinSvg('twitter'),
  562. ],
  563. 'linkedin' => [
  564. 'label' => Language::get('share_linkedin', 'LinkedIn'),
  565. 'url' => 'https://www.linkedin.com/sharing/share-offsite/?url=' . $encodedUrl,
  566. 'svg' => SocialIcons::builtinSvg('linkedin'),
  567. ],
  568. 'whatsapp' => [
  569. 'label' => Language::get('share_whatsapp', 'WhatsApp'),
  570. 'url' => 'https://wa.me/?text=' . $encodedTitle . '%20' . $encodedUrl,
  571. 'svg' => '<svg viewBox="0 0 24 24"><path d="M12.04 2c-5.52 0-10 4.48-10 10 0 1.77.46 3.5 1.34 5.02L2 22l5.13-1.35a9.96 9.96 0 0 0 4.91 1.29h.01c5.52 0 10-4.48 10-10S17.56 2 12.04 2zm5.87 14.24c-.25.7-1.45 1.34-2 1.42-.51.08-1.16.12-1.87-.12-.43-.14-.98-.32-1.69-.63-2.97-1.28-4.9-4.27-5.05-4.47-.15-.2-1.21-1.61-1.21-3.07s.76-2.18 1.03-2.48c.27-.3.59-.37.79-.37.2 0 .4 0 .57.01.18.01.43-.07.67.51.25.6.85 2.08.92 2.23.07.15.12.33.02.53-.1.2-.15.33-.3.5-.15.18-.31.4-.45.54-.15.15-.3.31-.13.61.17.3.77 1.27 1.65 2.06 1.13 1.01 2.09 1.32 2.39 1.47.3.15.48.13.65-.08.18-.2.75-.87.95-1.17.2-.3.4-.25.67-.15.27.1 1.73.82 2.03.97.3.15.5.22.57.35.08.13.08.73-.17 1.43z"/></svg>',
  572. ],
  573. 'telegram' => [
  574. 'label' => Language::get('share_telegram', 'Telegram'),
  575. 'url' => 'https://t.me/share/url?url=' . $encodedUrl . '&text=' . $encodedTitle,
  576. 'svg' => '<svg viewBox="0 0 24 24"><path d="M21.9 4.3 18.8 19.5c-.23 1.05-.85 1.3-1.72.81l-4.75-3.5-2.29 2.2c-.25.25-.47.47-.96.47l.34-4.85 8.8-7.95c.38-.34-.09-.53-.6-.19L6.9 12.6l-4.7-1.47c-1.02-.32-1.04-1.02.21-1.5L20.6 2.9c.85-.32 1.6.2 1.3 1.4z"/></svg>',
  577. ],
  578. 'email' => [
  579. 'label' => Language::get('share_email', 'Email'),
  580. 'url' => 'mailto:?subject=' . $encodedTitle . '&body=' . $encodedUrl,
  581. 'svg' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M3 7l9 6 9-6"></path></svg>',
  582. ],
  583. ];
  584. ​
  585. $platforms = [];
  586. foreach ($platformDefs as $key => $platform) {
  587. if (($settings['share_' . $key] ?? '0') === '1') {
  588. $platforms[$key] = $platform;
  589. }
  590. }
  591. ​
  592. $copyLinkEnabled = ($settings['share_copy_link'] ?? '0') === '1';
  593. if (empty($platforms) && !$copyLinkEnabled) {
  594. return '';
  595. }
  596. ​
  597. $shareLabel = Language::get('article_share', 'Share');
  598. $shareSvg = '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.6" y1="10.6" x2="15.4" y2="6.4"></line><line x1="8.6" y1="13.4" x2="15.4" y2="17.6"></line></svg>';
  599. ​
  600. $html = '<div class="article-share">';
  601. $html .= '<button type="button" class="share-toggle-btn" id="share-toggle-btn" aria-expanded="false" aria-haspopup="true">' . $shareSvg . '<span>' . htmlspecialchars($shareLabel) . '</span></button>';
  602. $html .= '<div class="share-panel" id="share-panel">';
  603. ​
  604. foreach ($platforms as $key => $platform) {
  605. $html .= '<a href="' . htmlspecialchars($platform['url'], ENT_QUOTES) . '" class="share-panel-item" target="_blank" rel="noopener noreferrer">'
  606. . '<span class="share-panel-item-icon share-panel-item-icon-' . htmlspecialchars($key, ENT_QUOTES) . '" aria-hidden="true">' . $platform['svg'] . '</span>'
  607. . '<span>' . htmlspecialchars($platform['label']) . '</span>'
  608. . '</a>';
  609. }
  610. ​
  611. if ($copyLinkEnabled) {
  612. $linkSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07l-1.42 1.41"></path><path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07l1.41-1.41"></path></svg>';
  613. $html .= '<button type="button" class="share-panel-item share-panel-item-copy" id="share-copy-btn" data-share-url="' . htmlspecialchars($url, ENT_QUOTES) . '" data-default-text="' . htmlspecialchars(Language::get('share_copy_link', 'Copy link'), ENT_QUOTES) . '" data-copied-text="' . htmlspecialchars(Language::get('share_link_copied', 'Link copied!'), ENT_QUOTES) . '">'
  614. . '<span class="share-panel-item-icon" aria-hidden="true">' . $linkSvg . '</span>'
  615. . '<span class="js-share-copy-label">' . htmlspecialchars(Language::get('share_copy_link', 'Copy link')) . '</span>'
  616. . '</button>';
  617. }
  618. ​
  619. $html .= '</div></div>';
  620. ​
  621. return $html;
  622. }
  623. ​
  624. public static function menuPages(): array
  625. {
  626. return Database::site()->query(
  627. "SELECT title, slug FROM pages WHERE status = 'published' AND show_in_menu = 1 ORDER BY sort_order ASC, title ASC"
  628. )->fetchAll();
  629. }
  630. ​
  631. public static function categories(): array
  632. {
  633. return Database::site()->query('SELECT id, name, slug FROM categories ORDER BY sort_order ASC, name ASC')->fetchAll();
  634. }
  635. ​
  636. public static function activeAds(string $placement): array
  637. {
  638. $statement = Database::site()->prepare(
  639. 'SELECT * FROM ads WHERE is_active = 1 AND placement = :placement ORDER BY created_at DESC'
  640. );
  641. $statement->execute(['placement' => $placement]);
  642. ​
  643. return array_values(array_filter($statement->fetchAll(), static fn(array $ad): bool => self::adFitsVisitor($ad)));
  644. }
  645. ​
  646. private static function adFitsVisitor(array $ad): bool
  647. {
  648. $audience = (string) ($ad['audience'] ?? 'all');
  649. ​
  650. // "Hide from paying subscribers" would contradict an ad meant only for subscribers, so it's ignored there.
  651. if ($audience !== 'paid' && (int) ($ad['hide_for_subscribers'] ?? 0) === 1 && self::visitorHasSubscription()) {
  652. return false;
  653. }
  654. ​
  655. if ($audience !== 'all') {
  656. $loggedIn = class_exists('UserAuth') && UserAuth::check();
  657. if (($audience === 'guests' && $loggedIn) || ($audience === 'members' && !$loggedIn)) {
  658. return false;
  659. }
  660. if (($audience === 'paid' && !self::visitorHasSubscription()) || ($audience === 'unpaid' && self::visitorHasSubscription())) {
  661. return false;
  662. }
  663. }
  664. ​
  665. $context = self::$adContext;
  666. if ($context['type'] === '') {
  667. return true;
  668. }
  669. ​
  670. $excluded = self::adExclusions($ad);
  671. if ($context['type'] === 'home') {
  672. return !$excluded['home'];
  673. }
  674. if ($context['type'] === 'article') {
  675. return !in_array($context['id'], $excluded['articles'], true);
  676. }
  677. if ($context['type'] === 'page') {
  678. return !in_array($context['id'], $excluded['pages'], true);
  679. }
  680. ​
  681. return true;
  682. }
  683. ​
  684. private static ?bool $visitorHasSubscription = null;
  685. ​
  686. public static function visitorHasSubscription(): bool
  687. {
  688. if (self::$visitorHasSubscription !== null) {
  689. return self::$visitorHasSubscription;
  690. }
  691. ​
  692. self::$visitorHasSubscription = false;
  693. if (!class_exists('UserAuth') || !UserAuth::check()) {
  694. return false;
  695. }
  696. ​
  697. try {
  698. $reader = UserAuth::user();
  699. self::$visitorHasSubscription = $reader !== null && UserAuth::hasActiveSubscription((int) $reader['id']);
  700. } catch (PDOException $e) {
  701. error_log('Publisium: could not check the reader subscription for ads: ' . $e->getMessage());
  702. }
  703. ​
  704. return self::$visitorHasSubscription;
  705. }
  706. ​
  707. public static function renderAd(array $ad): string
  708. {
  709. switch ($ad['type']) {
  710. case 'static_text':
  711. $html = '<div class="ad-slot ad-text">' . nl2br(htmlspecialchars($ad['content'])) . '</div>';
  712. break;
  713. case 'static_image':
  714. $img = '<img src="' . htmlspecialchars($ad['content']) . '" alt="' . htmlspecialchars($ad['name']) . '">';
  715. $html = '<div class="ad-slot ad-image">' . ($ad['link_url'] ? '<a href="' . htmlspecialchars($ad['link_url']) . '" rel="sponsored noopener" target="_blank">' . $img . '</a>' : $img) . '</div>';
  716. break;
  717. case 'script':
  718. $html = '<div class="ad-slot ad-script" data-requires-consent="0">' . $ad['content'] . '</div>';
  719. break;
  720. default:
  721. $html = '';
  722. }
  723. ​
  724. // With consent required, the ad waits in an inert <template> inside its slot until site.js gets consent.
  725. $consent = (string) ($ad['consent_category'] ?? 'none');
  726. if ($html !== '' && $consent !== 'none' && array_key_exists($consent, self::AD_CONSENTS) && self::settings()['consent_manager_enabled'] === '1') {
  727. $open = strpos($html, '>') + 1;
  728. $close = strrpos($html, '</div>');
  729. $inner = str_ireplace('</template', '<\/template', substr($html, $open, $close - $open));
  730. $html = substr($html, 0, $open)
  731. . '<template class="consent-gated" data-consent-category="' . implode(' ', self::scriptConsentCategories($consent)) . '">' . $inner . '</template>'
  732. . '</div>';
  733. }
  734. ​
  735. return $html;
  736. }
  737. ​
  738. public static function activeAnalyticsScripts(string $placement): array
  739. {
  740. $statement = Database::site()->prepare(
  741. 'SELECT * FROM analytics_scripts WHERE is_active = 1 AND placement = :placement ORDER BY created_at ASC'
  742. );
  743. $statement->execute(['placement' => $placement]);
  744. return $statement->fetchAll();
  745. }
  746. ​
  747. public const CONSENT_CATEGORIES = ['analytics', 'marketing'];
  748. ​
  749. public const CONSENT_SHOW = [
  750. 'auto' => 'Show it when a script, an ad or the comments need it (recommended)',
  751. 'always' => 'Always show it',
  752. 'never' => 'Never show it (code that needs it won’t run)',
  753. ];
  754. ​
  755. // Optional consent categories that something active on the site waits for.
  756. public static function consentNeededCategories(): array
  757. {
  758. $needed = [];
  759. try {
  760. $rows = Database::site()->query('SELECT DISTINCT consent_category FROM analytics_scripts WHERE is_active = 1 AND requires_consent = 1')->fetchAll(PDO::FETCH_COLUMN);
  761. foreach ($rows as $category) {
  762. $needed = array_merge($needed, self::scriptConsentCategories((string) $category));
  763. }
  764. } catch (PDOException $exception) {
  765. }
  766. ​
  767. try {
  768. $rows = Database::site()->query("SELECT DISTINCT consent_category FROM ads WHERE is_active = 1 AND consent_category IN ('analytics', 'marketing', 'both')")->fetchAll(PDO::FETCH_COLUMN);
  769. foreach ($rows as $category) {
  770. $needed = array_merge($needed, self::scriptConsentCategories((string) $category));
  771. }
  772. } catch (PDOException $exception) {
  773. }
  774. ​
  775. $settings = self::settings();
  776. if ($settings['comments_enabled'] === '1' && $settings['comments_provider'] !== 'builtin' && in_array($settings['comments_consent'], self::CONSENT_CATEGORIES, true)) {
  777. require_once __DIR__ . '/comments.php';
  778. if (Comments::isConfigured($settings)) {
  779. $needed[] = $settings['comments_consent'];
  780. }
  781. }
  782. ​
  783. return array_values(array_intersect(self::CONSENT_CATEGORIES, $needed));
  784. }
  785. ​
  786. // The optional categories the cookie banner offers. Empty means the banner isn't shown at all.
  787. public static function consentCategories(): array
  788. {
  789. $settings = self::settings();
  790. if ($settings['consent_manager_enabled'] !== '1') {
  791. return [];
  792. }
  793. ​
  794. $needed = null;
  795. $visible = [];
  796. foreach (self::CONSENT_CATEGORIES as $category) {
  797. $mode = $settings['consent_show_' . $category] ?? 'auto';
  798. if ($mode === 'auto') {
  799. $needed ??= self::consentNeededCategories();
  800. $show = in_array($category, $needed, true);
  801. } else {
  802. $show = $mode === 'always';
  803. }
  804. if ($show) {
  805. $visible[] = $category;
  806. }
  807. }
  808. ​
  809. return $visible;
  810. }
  811. ​
  812. public static function scriptConsentCategories(string $category): array
  813. {
  814. return match ($category) {
  815. 'marketing' => ['marketing'],
  816. 'both' => ['analytics', 'marketing'],
  817. default => ['analytics'],
  818. };
  819. }
  820. ​
  821. public static function renderAnalyticsScripts(string $placement): string
  822. {
  823. $scripts = self::activeAnalyticsScripts($placement);
  824. $consentManagerEnabled = self::settings()['consent_manager_enabled'] === '1';
  825. $html = '';
  826. ​
  827. foreach ($scripts as $script) {
  828. $code = $script['script_code'];
  829. ​
  830. $looksLikeHtml = str_contains($code, '<script') || str_contains($code, '<style') || str_contains($code, '<link') || str_contains($code, '<noscript');
  831. ​
  832. if (!$looksLikeHtml) {
  833. $code = '<script>' . $code . '</script>';
  834. }
  835. ​
  836. if ($script['requires_consent'] && $consentManagerEnabled) {
  837. $safeCode = str_replace('</script>', '<\/script>', $code);
  838. $html .= '<script type="text/plain" class="consent-gated-script" data-consent-category="' . implode(' ', self::scriptConsentCategories((string) ($script['consent_category'] ?? 'analytics'))) . '" data-placement="' . htmlspecialchars($placement) . '">' . $safeCode . '</script>';
  839. } else {
  840. $html .= $code;
  841. }
  842. }
  843. ​
  844. return $html;
  845. }
  846. ​
  847. public static function renderFontFaces(): string
  848. {
  849. require_once __DIR__ . '/font-library.php';
  850. ​
  851. $settings = self::settings();
  852. $emitted = [];
  853. $css = '';
  854. ​
  855. foreach ([$settings['heading_font'], $settings['body_font'], $settings['interface_font']] as $fontName) {
  856. if ($fontName === '' || in_array($fontName, $emitted, true)) {
  857. continue;
  858. }
  859. $css .= FontLibrary::fontFaceCss($fontName, $fontName);
  860. $emitted[] = $fontName;
  861. }
  862. ​
  863. return $css;
  864. }
  865. ​
  866. public static function fontVariablesCss(): string
  867. {
  868. $settings = self::settings();
  869. $headingFamily = addslashes($settings['heading_font']);
  870. $bodyFamily = addslashes($settings['body_font']);
  871. $interfaceFamily = addslashes($settings['interface_font']);
  872. ​
  873. return ":root{"
  874. . "--font-heading:\"{$headingFamily}\", -apple-system, BlinkMacSystemFont, sans-serif;"
  875. . "--font-body:\"{$bodyFamily}\", Georgia, serif;"
  876. . "--font-ui:\"{$interfaceFamily}\", -apple-system, BlinkMacSystemFont, sans-serif;"
  877. . "}";
  878. }
  879. ​
  880. private const TRACKING_PARAMS = ['fbclid', 'gclid', 'msclkid', 'yclid', 'igshid', 'mc_cid', 'mc_eid', '_ga', 'ref_src'];
  881. ​
  882. private static function configuredUrl(): string
  883. {
  884. $url = trim((string) Config::get('APP_URL', ''));
  885. if ($url === '') {
  886. return '';
  887. }
  888. ​
  889. if (!preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)) {
  890. $url = 'https://' . $url;
  891. }
  892. ​
  893. return rtrim($url, '/');
  894. }
  895. ​
  896. private static function normalizeHost(string $host, string $scheme = ''): string
  897. {
  898. $host = strtolower(rtrim(trim($host), '.'));
  899. if ($host === '') {
  900. return '';
  901. }
  902. ​
  903. $port = '';
  904. if (preg_match('/^(.*):(\d+)$/', $host, $matches) && !str_ends_with($matches[1], ']')) {
  905. $host = $matches[1];
  906. $port = $matches[2];
  907. } elseif (preg_match('/^(\[.*\]):(\d+)$/', $host, $matches)) {
  908. $host = $matches[1];
  909. $port = $matches[2];
  910. }
  911. ​
  912. $defaultPorts = ['80', '443'];
  913. if ($scheme === 'http') {
  914. $defaultPorts = ['80'];
  915. } elseif ($scheme === 'https') {
  916. $defaultPorts = ['443'];
  917. }
  918. ​
  919. return in_array($port, $defaultPorts, true) || $port === '' ? $host : $host . ':' . $port;
  920. }
  921. ​
  922. private static function requestHost(): string
  923. {
  924. $forwarded = trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''))[0]);
  925. $host = $forwarded !== '' ? $forwarded : (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '');
  926. ​
  927. return self::normalizeHost($host);
  928. }
  929. ​
  930. private static function requestScheme(): string
  931. {
  932. $forwarded = strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0]));
  933. if ($forwarded === 'https' || $forwarded === 'http') {
  934. return $forwarded;
  935. }
  936. ​
  937. $https = strtolower((string) ($_SERVER['HTTPS'] ?? ''));
  938. if (($https !== '' && $https !== 'off') || (string) ($_SERVER['SERVER_PORT'] ?? '') === '443') {
  939. return 'https';
  940. }
  941. ​
  942. return 'http';
  943. }
  944. ​
  945. public static function isConfiguredHost(): bool
  946. {
  947. $configured = self::configuredUrl();
  948. if ($configured === '') {
  949. return true;
  950. }
  951. ​
  952. $parts = parse_url($configured);
  953. $configuredHost = self::normalizeHost((string) ($parts['host'] ?? '') . (isset($parts['port']) ? ':' . $parts['port'] : ''), (string) ($parts['scheme'] ?? ''));
  954. $currentHost = self::requestHost();
  955. ​
  956. if ($configuredHost === '' || $currentHost === '') {
  957. return true;
  958. }
  959. ​
  960. return $configuredHost === $currentHost;
  961. }
  962. ​
  963. private static function cleanRequestUri(): string
  964. {
  965. $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
  966. $path = (string) parse_url($uri, PHP_URL_PATH);
  967. $query = (string) parse_url($uri, PHP_URL_QUERY);
  968. ​
  969. if ($path === '') {
  970. $path = '/';
  971. }
  972. ​
  973. if (self::cleanHomeUrl() && str_ends_with($path, '/index.php')) {
  974. $path = substr($path, 0, -strlen('index.php'));
  975. }
  976. ​
  977. if ($query === '') {
  978. return $path;
  979. }
  980. ​
  981. $kept = [];
  982. foreach (explode('&', $query) as $pair) {
  983. if ($pair === '') {
  984. continue;
  985. }
  986. $name = strtolower(urldecode(explode('=', $pair, 2)[0]));
  987. if (str_starts_with($name, 'utm_') || in_array($name, self::TRACKING_PARAMS, true)) {
  988. continue;
  989. }
  990. $kept[] = $pair;
  991. }
  992. ​
  993. return $kept === [] ? $path : $path . '?' . implode('&', $kept);
  994. }
  995. ​
  996. public static function currentUrl(): string
  997. {
  998. $baseUrl = self::configuredUrl();
  999. if ($baseUrl === '') {
  1000. $host = self::requestHost();
  1001. $baseUrl = $host === '' ? '' : self::requestScheme() . '://' . $host;
  1002. }
  1003. ​
  1004. return $baseUrl . self::cleanRequestUri();
  1005. }
  1006. ​
  1007. public static function renderSeoMeta(bool $noindex = false): string
  1008. {
  1009. $settings = self::settings();
  1010. $html = '';
  1011. ​
  1012. if ($settings['seo_keywords'] !== '') {
  1013. $html .= '<meta name="keywords" content="' . htmlspecialchars($settings['seo_keywords'], ENT_QUOTES) . '">' . "\n";
  1014. }
  1015. ​
  1016. if ($settings['seo_author'] !== '') {
  1017. $html .= '<meta name="author" content="' . htmlspecialchars($settings['seo_author'], ENT_QUOTES) . '">' . "\n";
  1018. }
  1019. ​
  1020. if ($settings['seo_reply_to'] !== '') {
  1021. $html .= '<meta name="reply-to" content="' . htmlspecialchars($settings['seo_reply_to'], ENT_QUOTES) . '">' . "\n";
  1022. }
  1023. ​
  1024. if ($settings['seo_application_name'] !== '') {
  1025. $html .= '<meta name="application-name" content="' . htmlspecialchars($settings['seo_application_name'], ENT_QUOTES) . '">' . "\n";
  1026. }
  1027. ​
  1028. $robotsIndex = !$noindex && $settings['seo_robots_index'] === '1' && self::isConfiguredHost() ? 'index' : 'noindex';
  1029. $robotsFollow = $settings['seo_robots_follow'] === '1' ? 'follow' : 'nofollow';
  1030. $html .= '<meta name="robots" content="' . $robotsIndex . ',' . $robotsFollow . '">' . "\n";
  1031. ​
  1032. $html .= '<link rel="canonical" href="' . htmlspecialchars(self::currentUrl(), ENT_QUOTES) . '">';
  1033. ​
  1034. return $html;
  1035. }
  1036. ​
  1037. public static function renderOpenGraph(string $title, string $description, string $type = 'website', ?string $imagePath = null, ?array $articleMeta = null): string
  1038. {
  1039. $settings = self::settings();
  1040. ​
  1041. $html = '<meta property="og:title" content="' . htmlspecialchars($title, ENT_QUOTES) . '">' . "\n";
  1042. $html .= '<meta property="og:type" content="' . htmlspecialchars($type, ENT_QUOTES) . '">' . "\n";
  1043. $html .= '<meta property="og:url" content="' . htmlspecialchars(self::currentUrl(), ENT_QUOTES) . '">' . "\n";
  1044. $html .= '<meta property="og:site_name" content="' . htmlspecialchars($settings['site_name'], ENT_QUOTES) . '">' . "\n";
  1045. ​
  1046. if ($description !== '') {
  1047. $html .= '<meta property="og:description" content="' . htmlspecialchars($description, ENT_QUOTES) . '">' . "\n";
  1048. }
  1049. ​
  1050. $ogImage = null;
  1051. if ($imagePath !== null && $settings['seo_og_article_image_enabled'] === '1') {
  1052. $ogImage = $imagePath;
  1053. } elseif ($settings['site_logo_url'] !== '') {
  1054. $ogImage = $settings['site_logo_url'];
  1055. }
  1056. $ogImage = $ogImage !== null ? self::absoluteUrl($ogImage) : '';
  1057. ​
  1058. if ($ogImage !== '') {
  1059. $html .= '<meta property="og:image" content="' . htmlspecialchars($ogImage, ENT_QUOTES) . '">' . "\n";
  1060. }
  1061. ​
  1062. if ($settings['seo_social_tags_enabled'] === '1') {
  1063. $html .= '<meta property="og:locale" content="' . htmlspecialchars(str_replace('-', '_', Languages::htmlLang()), ENT_QUOTES) . '">' . "\n";
  1064. ​
  1065. if ($ogImage !== '') {
  1066. $html .= '<meta property="og:image:alt" content="' . htmlspecialchars($title, ENT_QUOTES) . '">' . "\n";
  1067. }
  1068. ​
  1069. $published = self::isoDate($articleMeta['published'] ?? null);
  1070. $modified = self::isoDate($articleMeta['modified'] ?? null);
  1071. if ($published !== null) {
  1072. $html .= '<meta property="article:published_time" content="' . $published . '">' . "\n";
  1073. }
  1074. if ($modified !== null) {
  1075. $html .= '<meta property="article:modified_time" content="' . $modified . '">' . "\n";
  1076. }
  1077. if (!empty($articleMeta['author'])) {
  1078. $html .= '<meta property="article:author" content="' . htmlspecialchars((string) $articleMeta['author'], ENT_QUOTES) . '">' . "\n";
  1079. }
  1080. if (!empty($articleMeta['section'])) {
  1081. $html .= '<meta property="article:section" content="' . htmlspecialchars((string) $articleMeta['section'], ENT_QUOTES) . '">' . "\n";
  1082. }
  1083. ​
  1084. $html .= '<meta name="twitter:card" content="' . ($ogImage !== '' ? 'summary_large_image' : 'summary') . '">' . "\n";
  1085. $html .= '<meta name="twitter:title" content="' . htmlspecialchars($title, ENT_QUOTES) . '">' . "\n";
  1086. if ($description !== '') {
  1087. $html .= '<meta name="twitter:description" content="' . htmlspecialchars($description, ENT_QUOTES) . '">' . "\n";
  1088. }
  1089. if ($ogImage !== '') {
  1090. $html .= '<meta name="twitter:image" content="' . htmlspecialchars($ogImage, ENT_QUOTES) . '">' . "\n";
  1091. $html .= '<meta name="twitter:image:alt" content="' . htmlspecialchars($title, ENT_QUOTES) . '">' . "\n";
  1092. }
  1093. if ($settings['seo_twitter_handle'] !== '') {
  1094. $html .= '<meta name="twitter:site" content="' . htmlspecialchars($settings['seo_twitter_handle'], ENT_QUOTES) . '">' . "\n";
  1095. }
  1096. }
  1097. ​
  1098. return rtrim($html, "\n");
  1099. }
  1100. ​
  1101. public static function renderArticleSchema(array $article, string $authorType, ?string $authorUrl): string
  1102. {
  1103. $settings = self::settings();
  1104. $homeUrl = self::absoluteUrl(self::homeUrl());
  1105. ​
  1106. $schema = [
  1107. '@context' => 'https://schema.org',
  1108. '@type' => 'NewsArticle',
  1109. 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => self::currentUrl()],
  1110. 'headline' => (string) $article['title'],
  1111. ];
  1112. ​
  1113. $description = (string) (($article['seo_description'] ?? '') ?: ($article['excerpt'] ?? ''));
  1114. if ($description !== '') {
  1115. $schema['description'] = $description;
  1116. }
  1117. ​
  1118. $image = !empty($article['cover_image_path']) ? (string) $article['cover_image_path'] : $settings['site_logo_url'];
  1119. if ($image !== '') {
  1120. $schema['image'] = [self::absoluteUrl($image)];
  1121. }
  1122. ​
  1123. $published = self::isoDate($article['published_at'] ?? null);
  1124. $modified = self::isoDate($article['updated_at'] ?? null);
  1125. if ($published !== null) {
  1126. $schema['datePublished'] = $published;
  1127. $schema['dateModified'] = $modified !== null && $modified > $published ? $modified : $published;
  1128. }
  1129. ​
  1130. $authorName = trim((string) ($article['author_name'] ?? ''));
  1131. if ($authorName !== '') {
  1132. $author = ['@type' => $authorType === 'Organization' ? 'Organization' : 'Person', 'name' => $authorName];
  1133. if ($authorUrl !== null && $authorUrl !== '') {
  1134. $author['url'] = self::absoluteUrl($authorUrl);
  1135. }
  1136. } else {
  1137. $author = ['@type' => 'Organization', 'name' => $settings['site_name'], 'url' => $homeUrl];
  1138. }
  1139. $schema['author'] = [$author];
  1140. ​
  1141. $publisher = ['@type' => 'Organization', 'name' => $settings['site_name'], 'url' => $homeUrl];
  1142. if ($settings['site_logo_url'] !== '') {
  1143. $publisher['logo'] = ['@type' => 'ImageObject', 'url' => self::absoluteUrl($settings['site_logo_url'])];
  1144. }
  1145. $schema['publisher'] = $publisher;
  1146. ​
  1147. if (($article['access_type'] ?? 'free') === 'paid') {
  1148. $schema['isAccessibleForFree'] = false;
  1149. }
  1150. ​
  1151. return '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>';
  1152. }
  1153. ​
  1154. public static function absoluteUrl(string $url): string
  1155. {
  1156. $url = trim($url);
  1157. if ($url === '' || preg_match('#^https?://#i', $url) === 1) {
  1158. return $url;
  1159. }
  1160. ​
  1161. if (str_starts_with($url, '//')) {
  1162. return self::requestScheme() . ':' . $url;
  1163. }
  1164. ​
  1165. $origin = self::configuredUrl();
  1166. if ($origin === '') {
  1167. $host = self::requestHost();
  1168. $origin = $host === '' ? '' : self::requestScheme() . '://' . $host;
  1169. }
  1170. ​
  1171. if (str_starts_with($url, '/')) {
  1172. return $origin . $url;
  1173. }
  1174. ​
  1175. return $origin . rtrim((string) Config::get('APP_BASE_PATH', ''), '/') . '/' . $url;
  1176. }
  1177. ​
  1178. private static function isoDate(?string $value): ?string
  1179. {
  1180. if ($value === null || $value === '') {
  1181. return null;
  1182. }
  1183. ​
  1184. $timestamp = strtotime($value);
  1185. ​
  1186. return $timestamp === false ? null : date(DATE_ATOM, $timestamp);
  1187. }
  1188. ​
  1189. public static function renderOrganizationSchema(): string
  1190. {
  1191. $settings = self::settings();
  1192. ​
  1193. $schema = [
  1194. '@context' => 'https://schema.org',
  1195. '@graph' => [
  1196. [
  1197. '@type' => 'Organization',
  1198. 'name' => $settings['site_name'],
  1199. 'url' => rtrim(Config::get('APP_URL', ''), '/') . '/',
  1200. ],
  1201. [
  1202. '@type' => 'WebSite',
  1203. 'name' => $settings['site_name'],
  1204. 'url' => rtrim(Config::get('APP_URL', ''), '/') . '/',
  1205. ],
  1206. ],
  1207. ];
  1208. ​
  1209. if ($settings['site_logo_url'] !== '') {
  1210. $schema['@graph'][0]['logo'] = self::absoluteUrl($settings['site_logo_url']);
  1211. }
  1212. ​
  1213. if ($settings['site_description'] !== '') {
  1214. $schema['@graph'][1]['description'] = $settings['site_description'];
  1215. }
  1216. ​
  1217. return '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . '</script>';
  1218. }
  1219. ​
  1220. private static function isCustomHtmlEnabledFor(string $slot, ?string $pageType): bool
  1221. {
  1222. if ($pageType === null || !in_array($pageType, ['index', 'article', 'category', 'pages'], true)) {
  1223. return false;
  1224. }
  1225. ​
  1226. $settings = self::settings();
  1227. $key = 'custom_' . $slot . '_html_' . $pageType;
  1228. ​
  1229. return ($settings[$key] ?? '0') === '1';
  1230. }
  1231. ​
  1232. public static function renderCustomHeadHtml(?string $pageType): string
  1233. {
  1234. if (!self::isCustomHtmlEnabledFor('head', $pageType)) {
  1235. return '';
  1236. }
  1237. ​
  1238. return self::settings()['custom_head_html'];
  1239. }
  1240. ​
  1241. public static function renderCustomBodyHtml(?string $pageType): string
  1242. {
  1243. if (!self::isCustomHtmlEnabledFor('body', $pageType)) {
  1244. return '';
  1245. }
  1246. ​
  1247. return self::settings()['custom_body_html'];
  1248. }
  1249. }
  1250. ​