567 lines
19 KiB
PHP
567 lines
19 KiB
PHP
<?php
|
|
|
|
use Twig\Error\LoaderError;
|
|
|
|
const RESOURCE_INTEGRITY_HASHES = ['sha256', 'sha384', 'sha512'];
|
|
|
|
class skin {
|
|
|
|
public array $lang = [];
|
|
protected array $vars = [];
|
|
protected array $globalVars = [];
|
|
protected bool $globalsApplied = false;
|
|
public string $title = 'title';
|
|
/** @var (\Closure(string $title):string)[] */
|
|
protected array $titleModifiers = [];
|
|
public array $meta = [];
|
|
protected array $js = [];
|
|
public array $options = [
|
|
'full_width' => 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 id="svgicon_'.$name.'" width="'.$w.'" height="'.$h.'" fill="currentColor" viewBox="0 0 '.$w.' '.$h.'">';
|
|
$svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
|
|
$svg .= '</svg>';
|
|
return $svg;
|
|
} else {
|
|
return '<svg width="'.$w.'" height="'.$h.'"><use xlink:href="#svgicon_'.$name.'"></use></svg>';
|
|
}
|
|
}
|
|
|
|
public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string {
|
|
static $chevron = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.47 4.217a.75.75 0 0 0 0 1.06L12.185 10 7.469 14.72a.75.75 0 1 0 1.062 1.06l5.245-5.25a.75.75 0 0 0 0-1.061L8.531 4.218a.75.75 0 0 0-1.061-.001z" fill="currentColor"/></svg>';
|
|
$buf = implode(array_map(function(array $i) use ($chevron): string {
|
|
$buf = '';
|
|
$has_url = array_key_exists('url', $i);
|
|
|
|
if ($has_url)
|
|
$buf .= '<a class="bc-item" href="'.htmlescape($i['url']).'">';
|
|
else
|
|
$buf .= '<span class="bc-item">';
|
|
$buf .= htmlescape($i['text']);
|
|
|
|
if ($has_url)
|
|
$buf .= ' <span class="bc-arrow">'.$chevron.'</span></a>';
|
|
else
|
|
$buf .= '</span>';
|
|
|
|
return $buf;
|
|
}, $items));
|
|
$class = 'bc';
|
|
if ($mt)
|
|
$class .= ' mt';
|
|
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
|
|
}
|
|
|
|
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 .= '<a class="'.$class.'" href="'.htmlescape(self::pageNavGetLink($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
|
|
}
|
|
|
|
if ($min_page > 2) {
|
|
$pages_html = '<div class="pn-button-sep no-select no-drag"> </div>'.$pages_html;
|
|
}
|
|
if ($min_page > 1) {
|
|
$pages_html = '<a class="'.$base_class.'" href="'.htmlescape(self::pageNavGetLink(1, $link_template)).'" data-page="1" draggable="false">1</a>'.$pages_html;
|
|
}
|
|
|
|
if ($max_page < $pages-1) {
|
|
$pages_html .= '<div class="pn-button-sep no-select no-drag"> </div>';
|
|
}
|
|
if ($max_page < $pages) {
|
|
$pages_html .= '<a class="'.$base_class.'" href="'.htmlescape(self::pageNavGetLink($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
|
|
}
|
|
|
|
$pn_class = 'pn';
|
|
if ($pages < 2) {
|
|
$pn_class .= ' no-nav';
|
|
if (!$count) {
|
|
$pn_class .= ' no-results';
|
|
}
|
|
}
|
|
|
|
$html = <<<HTML
|
|
<div class="{$pn_class}">
|
|
<div class="pn-buttons clearfix">
|
|
{$pages_html}
|
|
</div>
|
|
</div>
|
|
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 = '<svg style="display: none">';
|
|
foreach ($this->svgDefs as $name => $icon) {
|
|
$content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg');
|
|
$buf .= "<symbol id=\"svgicon_{$name}\" viewBox=\"0 0 {$icon['width']} {$icon['height']}\" fill=\"currentColor\">$content</symbol>";
|
|
}
|
|
$buf .= '</svg>';
|
|
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 = '<meta';
|
|
foreach ($item as $k => $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 = '<script type="text/javascript">';
|
|
|
|
if (isDev())
|
|
$versions = '{}';
|
|
else {
|
|
$versions = [];
|
|
foreach ($config['static'] as $name => $v) {
|
|
list($type, $bname) = $this->getStaticNameParts($name);
|
|
$versions[$type][$bname] = $v;
|
|
}
|
|
$versions = jsonEncode($versions);
|
|
}
|
|
$html .= 'StaticManager.init('.jsonEncode($this->styleNames).', '.$versions.');';
|
|
$html .= 'ThemeSwitcher.init();';
|
|
|
|
if (!empty($this->lang)) {
|
|
$lang = [];
|
|
foreach ($this->lang as $key)
|
|
$lang[$key] = lang($key);
|
|
$html .= 'extend(__lang, '.jsonEncode($lang).');';
|
|
}
|
|
|
|
$js = $this->getJS();
|
|
if ($js)
|
|
$html .= '(function(){try{'.$js.'}catch(e){window.console&&console.error("caught exception:",e)}})();';
|
|
|
|
$html .= '</script>';
|
|
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 '<script src="'.$href.'" type="text/javascript"'.$this->getStaticIntegrityAttribute($name).'></script>';
|
|
}
|
|
|
|
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 '<link rel="stylesheet" id="'.$id.'" type="text/css" href="'.$href.'"'.$this->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 '<link rel="prefetch" href="'.$url.'"'.$integrity.' />';
|
|
}
|
|
|
|
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)).'"';
|
|
}
|
|
|
|
}
|