false, 'wide' => false, 'logo_path_map' => [], 'logo_link_map' => [], 'is_index' => false, 'head_section' => null, 'articles_lang' => null, 'inside_admin_interface' => false, ]; public array $static = []; protected array $styleNames = []; protected array $svgDefs = []; public \Twig\Environment $twig; protected static ?skin $instance = null; public static function getInstance(): skin { if (self::$instance === null) self::$instance = new skin(); return self::$instance; } /** * @throws LoaderError */ protected function __construct() { global $config; $cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir']; if (!file_exists($cache_dir)) { if (mkdir($cache_dir, $config['dirs_mode'], true)) setperm($cache_dir); } // must specify a second argument ($rootPath) here // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code // this is bad for templates rebuilding $twig_loader = new \Twig\Loader\FilesystemLoader(APP_ROOT.'/skin', APP_ROOT); // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg'); $env_options = []; if (!is_null($cache_dir)) { $env_options += [ 'cache' => $cache_dir, 'auto_reload' => isDev() ]; } $twig = new \Twig\Environment($twig_loader, $env_options); $twig->addExtension(new \TwigAddons\MyExtension()); $this->twig = $twig; } public function addMeta(array $data) { static $twitter_limits = [ 'title' => 70, 'description' => 200 ]; $real_meta = []; $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) { foreach (['og', 'twitter'] as $social) { if ($social == 'twitter' && isset($twitter_limits[$key])) { if (mb_strlen($value) > $twitter_limits[$key]) $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...'; } $real_meta[] = [ $social == 'twitter' ? 'name' : 'property' => $social.':'.$key, 'content' => $value ]; } }; foreach ($data as $key => $value) { if (str_starts_with($value, '@')) $value = lang(substr($value, 1)); switch ($key) { case '@url': case '@title': case '@image': $add_og_twitter(substr($key, 1), $value); break; case '@description': case '@keywords': $real_name = substr($key, 1); $add_og_twitter($real_name, $value); $real_meta[] = ['name' => $real_name, 'content' => $value]; break; default: if (str_starts_with($key, 'og:')) { $real_meta[] = ['property' => $key, 'content' => $value]; } else { logWarning("unsupported meta: $key => $value"); } break; } } $this->meta = array_merge($this->meta, $real_meta); } public function exportStrings(array|string $keys): void { global $__lang; $this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys); } public function setTitle(string $title): void { if (str_starts_with($title, '$')) $title = lang(substr($title, 1)); else if (str_starts_with($title, '\\$')) $title = substr($title, 1); $this->title = $title; } public function addPageTitleModifier(callable $callable): void { if (!is_callable($callable)) { trigger_error(__METHOD__.': argument is not callable'); } else { $this->titleModifiers[] = $callable; } } protected function getTitle(): string { $title = $this->title != '' ? $this->title : lang('site_title'); if (!empty($this->titleModifiers)) { foreach ($this->titleModifiers as $modifier) $title = $modifier($title); } return $title; } public function set($arg1, $arg2 = null) { if (is_array($arg1)) { foreach ($arg1 as $key => $value) $this->vars[$key] = $value; } elseif ($arg2 !== null) { $this->vars[$arg1] = $arg2; } } public function isSet($key): bool { return isset($this->vars[$key]); } public function setGlobal($arg1, $arg2 = null): void { if ($this->globalsApplied) logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible'); if (is_array($arg1)) { foreach ($arg1 as $key => $value) $this->globalVars[$key] = $value; } elseif ($arg2 !== null) { $this->globalVars[$arg1] = $arg2; } } public function isGlobalSet($key): bool { return isset($this->globalVars[$key]); } public function getGlobal($key) { return $this->isGlobalSet($key) ? $this->globalVars[$key] : null; } public function applyGlobals(): void { if (!empty($this->globalVars) && !$this->globalsApplied) { foreach ($this->globalVars as $key => $value) $this->twig->addGlobal($key, $value); $this->globalsApplied = true; } } public function addStatic(string ...$files): void { foreach ($files as $file) $this->static[] = $file; } public function addJS(string $js): void { if ($js != '') $this->js[] = $js; } protected function getJS(): string { if (empty($this->js)) return ''; return implode("\n", $this->js); } public function preloadSVG(string $name): void { if (isset($this->svgDefs[$name])) return; if (!preg_match_all('/\d+/', $name, $matches)) throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); $size = array_slice($matches[0], -2); $this->svgDefs[$name] = [ 'width' => $size[0], 'height' => $size[1] ?? $size[0] ]; } public function getSVG(string $name, bool $in_place = false): ?string { $this->preloadSVG($name); $w = $this->svgDefs[$name]['width']; $h = $this->svgDefs[$name]['height']; if ($in_place) { $svg = ''; $svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); $svg .= ''; return $svg; } else { return ''; } } public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string { static $chevron = ''; $buf = implode(array_map(function(array $i) use ($chevron): string { $buf = ''; $has_url = array_key_exists('url', $i); if ($has_url) $buf .= ''; else $buf .= ''; $buf .= htmlescape($i['text']); if ($has_url) $buf .= ' '.$chevron.''; else $buf .= ''; return $buf; }, $items)); $class = 'bc'; if ($mt) $class .= ' mt'; return '
'.$buf.'
'; } public function renderPageNav(int $page, int $pages, string $link_template, ?array $opts = null): string { if ($opts === null) { $count = 0; } else { $opts = array_merge(['count' => 0], $opts); $count = $opts['count']; } $min_page = max(1, $page-2); $max_page = min($pages, $page+2); $pages_html = ''; $base_class = 'pn-button no-hover no-select no-drag is-page'; for ($p = $min_page; $p <= $max_page; $p++) { $class = $base_class; if ($p == $page) $class .= ' is-page-cur'; $pages_html .= ''.$p.''; } if ($min_page > 2) { $pages_html = '
 
'.$pages_html; } if ($min_page > 1) { $pages_html = '1'.$pages_html; } if ($max_page < $pages-1) { $pages_html .= '
 
'; } if ($max_page < $pages) { $pages_html .= ''.$pages.''; } $pn_class = 'pn'; if ($pages < 2) { $pn_class .= ' no-nav'; if (!$count) { $pn_class .= ' no-results'; } } $html = <<
{$pages_html}
HTML; return $html; } protected static function pageNavGetLink($page, $link_template) { return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template); } protected function getSVGTags(): string { $buf = ''; foreach ($this->svgDefs as $name => $icon) { $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); $buf .= "$content"; } $buf .= ''; return $buf; } public function setRenderOptions(array $options): void { $this->options = array_merge($this->options, $options); } public function render($template, array $vars = []): string { $this->applyGlobals(); return $this->doRender($template, $this->vars + $vars); } public function renderPage(string $template, array $vars = []): never { $this->exportStrings(['4in1']); $this->applyGlobals(); // render body first $b = $this->renderBody($template, $vars); // then everything else $h = $this->renderHeader(); $f = $this->renderFooter(); echo $h; echo $b; echo $f; exit; } protected function renderHeader(): string { global $config; $body_class = []; if ($this->options['full_width']) $body_class[] = 'full-width'; else if ($this->options['wide']) $body_class[] = 'wide'; $title = $this->getTitle(); if (!$this->options['is_index']) $title = lang('4in1').' - '.$title; $vars = [ 'title' => $title, 'meta_html' => $this->getMetaTags(), 'static_html' => $this->getHeaderStaticTags(), 'svg_html' => $this->getSVGTags(), 'render_options' => $this->options, 'app_config' => [ 'domain' => $config['domain'], 'devMode' => $config['is_dev'], 'cookieHost' => $config['cookie_host'], ], 'body_class' => $body_class, 'theme' => themes::getUserTheme(), ]; return $this->doRender('header.twig', $vars); } protected function renderBody(string $template, array $vars): string { return $this->doRender($template, $this->vars + $vars); } protected function renderFooter(): string { global $config; $exec_time = microtime(true) - START_TIME; $exec_time = round($exec_time, 4); $footer_vars = [ 'exec_time' => $exec_time, 'render_options' => $this->options, 'admin_email' => $config['admin_email'], // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE), // 'static_config' => $this->getStaticConfig(), 'script_html' => $this->getFooterScriptTags(), 'this_page_url' => $_SERVER['REQUEST_URI'], 'theme' => themes::getUserTheme(), ]; return $this->doRender('footer.twig', $footer_vars); } protected function doRender(string $template, array $vars = []): string { $s = ''; try { $s = $this->twig->render($template, $vars); } catch (\Twig\Error\Error $e) { $error = get_class($e).": failed to render"; $source_ctx = $e->getSourceContext(); if ($source_ctx) { $path = $source_ctx->getPath(); if (str_starts_with($path, APP_ROOT)) $path = substr($path, strlen(APP_ROOT)+1); $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine(); } $error .= ": "; $error .= $e->getMessage(); logError($error); if (isDev()) $s = $error."\n"; } return $s; } protected function getMetaTags(): string { if (empty($this->meta)) return ''; return implode('', array_map(function(array $item): string { $s = ' $v) $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; $s .= '/>'; $s .= "\n"; return $s; }, $this->meta)); } protected function getHeaderStaticTags(): string { $html = []; $theme = themes::getUserTheme(); $dark = $theme == 'dark' || ($theme == 'auto' && themes::isUserSystemThemeDark()); $this->styleNames = []; foreach ($this->static as $name) { // javascript if (str_starts_with($name, 'js/')) $html[] = $this->jsLink($name); // css else if (str_starts_with($name, 'css/')) { $html[] = $this->cssLink($name, 'light', $style_name_ptr); $this->styleNames[] = $style_name_ptr; if ($dark) $html[] = $this->cssLink($name, 'dark', $style_name_ptr); else if (!isDev()) $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark'); } else logError(__FUNCTION__.': unexpected static entry: '.$name); } return implode("\n", $html); } protected function getFooterScriptTags(): string { global $config; $html = ''; return $html; } protected function jsLink(string $name): string { list (, $bname) = $this->getStaticNameParts($name); if (isDev()) { $href = '/js.php?name='.urlencode($bname).'&v='.time(); } else { $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name); } return ''; } protected function cssLink(string $name, string $theme, &$bname = null): string { list(, $bname) = $this->getStaticNameParts($name); $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'; if (isDev()) { $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); } else { $version = $this->getStaticVersion($config_name); $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version; } $id = 'style_'.$bname; if ($theme == 'dark') $id .= '_dark'; return 'getStaticIntegrityAttribute($config_name).'>'; } protected function cssPrefetchLink(string $name): string { $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css'); $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css'); return ''; } protected function getStaticNameParts(string $name): array { $dname = dirname($name); $bname = basename($name); if (($pos = strrpos($bname, '.'))) { $ext = substr($bname, $pos+1); $bname = substr($bname, 0, $pos); } else { $ext = ''; } return [$dname, $bname, $ext]; } protected function getStaticVersion(string $name): string { global $config; if (isDev()) return time(); if (str_starts_with($name, '/')) { logWarning(__FUNCTION__.': '.$name.' starts with /'); $name = substr($name, 1); } return $config['static'][$name]['version'] ?? 'notfound'; } protected function getStaticIntegrityAttribute(string $name): string { if (isDev()) return ''; global $config; return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"'; } }