diff --git a/boot/Frontend/PageRepository.php b/boot/Frontend/PageRepository.php index 8cc8d2f..b12df8b 100644 --- a/boot/Frontend/PageRepository.php +++ b/boot/Frontend/PageRepository.php @@ -5,6 +5,7 @@ namespace Scriptor\Boot\Frontend; use Imanager\Domain\Item; +use Imanager\Query\Direction; use Imanager\Query\Operator; use Imanager\Query\Query; use Imanager\Storage\CategoryRepository; @@ -12,11 +13,12 @@ /** * Read access to the Pages category as a thin wrapper over the iManager 2.0 - * `ItemRepository` + `Query` AST. Returns Frontend\Page DTOs so themes can - * keep using `$page->slug`, `$page->template`, etc. + * `ItemRepository` + `Query` AST. Returns `Page` DTOs so themes can keep + * using `$page->slug`, `$page->template`, etc. * - * Phase 14b-1 only needs lookup + parent/children traversal. Sorting, - * pagination and richer filters land with BasicTheme in 14b-2. + * The query helpers below replace the legacy `getItems('parent=N')` selector + * strings with typed parameters; they're enough to drive the bundled basic + * theme (article list, archive, navigation, footer container). */ final readonly class PageRepository { @@ -79,28 +81,170 @@ public function findAll(): array } /** + * Children of a parent page in the configured order. + * * @return list */ - public function findByParent(int $parentId): array - { + public function findByParent( + int $parentId, + string $orderBy = 'position', + Direction $direction = Direction::Asc, + bool $activeOnly = false, + int $offset = 0, + int $limit = 0, + ): array { $query = (new Query($this->categoryId)) ->where('parent', Operator::Eq, $parentId) - ->orderBy('position'); + ->orderBy($orderBy, $direction); + if ($activeOnly) { + $query = $query->where('active', Operator::Eq, true); + } + if ($offset > 0) { + $query = $query->offset($offset); + } + if ($limit > 0) { + $query = $query->limit($limit); + } return self::wrap($this->items->query($query)); } /** + * Convenience for templates that just want the active children in + * position order without thinking about defaults. + * * @return list */ public function findActiveByParent(int $parentId): array { - $pages = []; - foreach ($this->findByParent($parentId) as $page) { - if ($page->active()) { - $pages[] = $page; + return $this->findByParent($parentId, activeOnly: true); + } + + /** + * Active pages whose `created` timestamp falls within `[$start, $end)`, + * scoped to a parent container (typically the blog `articles_page_id`). + * + * @return list + */ + public function findInTimeRange( + int $start, + int $end, + int $parentId, + string $orderBy = 'created', + Direction $direction = Direction::Desc, + ): array { + $query = (new Query($this->categoryId)) + ->where('parent', Operator::Eq, $parentId) + ->where('active', Operator::Eq, true) + ->where('created', Operator::Gte, $start) + ->where('created', Operator::Lt, $end) + ->orderBy($orderBy, $direction); + return self::wrap($this->items->query($query)); + } + + public function countByParent(int $parentId, bool $activeOnly = false): int + { + $query = (new Query($this->categoryId)) + ->where('parent', Operator::Eq, $parentId); + if ($activeOnly) { + $query = $query->where('active', Operator::Eq, true); + } + return \count($this->items->query($query)); + } + + /** + * Recursive walk that materialises a tree of pages keyed by parent id. + * Replaces 1.x `Pages::getPageLevels()` for nav-style traversals. + * + * @param list $excludeIds + * @return array> + */ + public function levels( + int $rootParent = 0, + int $maxDepth = 0, + bool $activeOnly = true, + array $excludeIds = [], + ): array { + $tree = []; + $this->walkLevels($rootParent, 1, $maxDepth, $activeOnly, $excludeIds, $tree); + return $tree; + } + + /** + * Flat list of every descendant page beneath `$parent`, parents-first. + * + * @return list + */ + public function descendants(Page $parent): array + { + $out = []; + $this->collectDescendants($parent, $out, [$parent->id() ?? 0 => true]); + return $out; + } + + /** + * @param list $out + * @param array $visited + */ + private function collectDescendants(Page $parent, array &$out, array $visited): void + { + $parentId = $parent->id() ?? 0; + foreach ($this->findActiveByParent($parentId) as $child) { + $childId = $child->id() ?? 0; + if ($childId === $parentId || isset($visited[$childId])) { + continue; } + $visited[$childId] = true; + $out[] = $child; + $this->collectDescendants($child, $out, $visited); + } + } + + /** + * @param list $excludeIds + * @param array> $tree + * @param array $visited cycle-detection accumulator + */ + private function walkLevels( + int $parent, + int $depth, + int $maxDepth, + bool $activeOnly, + array $excludeIds, + array &$tree, + array $visited = [], + ): void { + if (isset($visited[$parent])) { + return; // self- or cross-cycle in the parent chain — bail out + } + $visited[$parent] = true; + + $children = $this->findByParent($parent, activeOnly: $activeOnly); + // Filter the parent itself out (page-with-parent=self bug guard) plus + // any caller-supplied exclude ids. + $children = array_values(array_filter( + $children, + static fn(Page $p): bool => ($p->id() ?? 0) !== $parent + && ! \in_array($p->id() ?? 0, $excludeIds, true) + && ! \in_array($p->parent, $excludeIds, true), + )); + if ($children === []) { + return; + } + $tree[$parent] = $children; + if ($maxDepth > 0 && $depth >= $maxDepth) { + return; + } + foreach ($children as $child) { + $this->walkLevels( + $child->id() ?? 0, + $depth + 1, + $maxDepth, + $activeOnly, + $excludeIds, + $tree, + $visited, + ); } - return $pages; } /** diff --git a/boot/Frontend/Site.php b/boot/Frontend/Site.php index b4394dc..c2dfbab 100644 --- a/boot/Frontend/Site.php +++ b/boot/Frontend/Site.php @@ -4,26 +4,28 @@ namespace Scriptor\Boot\Frontend; +use Imanager\Cache\FilesystemCache; +use Imanager\Http\Request; use Imanager\Http\UrlSegments; +use Imanager\Templating\TemplateRenderer; use Imanager\Validation\Sanitizer as ImanagerSanitizer; use League\Container\Container; /** - * Phase 14b-1 Frontend\Site — minimal renderer for the public Scriptor site. + * Frontend renderer for the public Scriptor site, replacing the legacy + * `Scriptor\Core\Site` for the duration of Phase 14b. * - * Replaces the legacy `Scriptor\Core\Site` for the duration of Phase 14b. - * Holds the `$site` surface that the existing theme files (default.php, - * template.php, _head.php, _header.php, _footer.php, …) consume: + * Holds the `$site` surface that bundled themes consume: + * - properties: `siteUrl`, `themeUrl`, `version`, `config`, `page`, + * `messages`, `urlSegments`, `input`, `sanitizer`, `pages`, + * `templateParser`, `cache` + * - rendering: `render($element)` with overridable hooks; theme + * subclasses override the `render*()` methods. + * - utilities: `getBasePath()`, `getPageUrl()`, `addMsg()`, + * `throw404()`, `getTCP()`, `templateName()`. * - * - `siteUrl`, `themeUrl`, `version`, `config`, `messages` - * - `page` (Frontend\Page) once `execute()` resolved it - * - `render('content'|'navigation'|'messages'|hero/footerNav/…)` — the - * theme-overridable cases return empty strings until 14b-2 hooks - * `BasicTheme` back in. - * - `cache()` — captures the output buffer; persistence ist 14b-2. - * - * The class is intentionally NOT extending the legacy Module hierarchy. - * Themes that need to extend it can override `render*()` methods directly. + * Themes extend this class and override `render()`, individual + * `render*()` methods, or `init()` to set theme-specific config. */ class Site { @@ -33,20 +35,30 @@ class Site public ?Page $page = null; public string $messages = ''; public UrlSegments $urlSegments; + public Request $input; + public Sanitizer $sanitizer; + public PageRepository $pages; + public TemplateRenderer $templateParser; + public FilesystemCache $cache; /** @var array */ public array $config; + /** + * Pending in-page messages set via {@see addMsg()}; consumed by + * `renderMessages()` (or theme subclass). + * + * @var list + */ + public array $msgs = []; + /** @var array Theme-config payload exposed via getTCP(). */ protected array $themeConfig = []; - public Sanitizer $sanitizer; - public PageRepository $pages; - /** * Defaults for theme-config keys touched by the bundled "basic" theme's - * default render path (header, footer, offcanvas). Lets the page render - * end-to-end even before BasicTheme reattaches in 14b-2 with real values. + * default render path. Lets the page render end-to-end even before a + * theme subclass attaches with real values. */ private const TCP_DEFAULTS = [ 'site_name' => 'Scriptor', @@ -74,13 +86,29 @@ public function __construct( $container->get(\Imanager\Storage\CategoryRepository::class), $container->get(\Imanager\Storage\ItemRepository::class), ); + $this->cache = $container->get(FilesystemCache::class); + $this->templateParser = new TemplateRenderer(); + $this->input = Request::fromGlobals(); $this->siteUrl = self::detectSiteUrl(); $this->themeUrl = $this->siteUrl . '/site/themes/' . $this->config['theme_path']; $this->urlSegments = UrlSegments::fromPath($_SERVER['REQUEST_URI'] ?? '/'); + $this->init(); + } + + /** + * Theme-extension point — subclasses populate {@see $themeConfig} and + * any other state once construction has wired the standard services. + */ + protected function init(): void + { } /** * Resolve the requested page from the URL or fall back to home/404. + * + * The lookup honours nested page paths: when multiple pages share the + * same slug the parent chain is verified by walking back through the + * URL segments and rejecting mismatches as 404. */ public function execute(): void { @@ -103,6 +131,20 @@ public function execute(): void $this->throw404(); return; } + + // Confirm the URL fully matches the page's parent chain so a + // request like /wrong-parent/articles/ does not silently render + // the matching child page. The home page (id=1) is reachable + // both via `/` (already handled above) and via its own slug. + if ($page->id() !== 1) { + $expected = '/' . $this->getPageUrl($page); + $actual = '/' . $this->urlSegments->path(trailingSlash: true); + if ($actual !== $expected) { + $this->throw404(); + return; + } + } + $this->page = $page; } @@ -112,8 +154,8 @@ public function render(string $element): ?string 'content' => $this->renderContent(), 'navigation' => $this->renderNavigation(), 'messages' => $this->messages, - // Theme-extension hooks. Themes (BasicTheme in 14b-2) override - // these by subclassing Site and short-circuiting the parent call. + // Theme-extension hooks. Theme subclasses override these by + // returning their own markup before delegating to parent. 'hero', 'mainNavItems', 'footerNav', @@ -128,8 +170,8 @@ public function render(string $element): ?string } /** - * Captures the output buffer started by template.php and hands it back. - * Filesystem caching is wired up by BasicTheme in 14b-2. + * Captures the output buffer started by `template.php` and returns it. + * Subclasses (BasicTheme) layer caching on top by overriding. */ public function cache(): string { @@ -142,18 +184,97 @@ public function pages(): PageRepository return $this->pages; } + /** + * Template name driving `template.php`. Defaults to `$page->template`; + * theme subclasses (e.g. BasicTheme blog routing) override to inject + * a different template without mutating the page DTO. + */ + public function currentTemplate(): string + { + return $this->page?->template ?? ''; + } + /** * Theme-config-property accessor — `$site->getTCP('site_name')`, - * `$site->getTCP('footer')['sub_heading']`. Returns the live theme-config - * value when set (BasicTheme in 14b-2), otherwise a sane default so the - * default render path doesn't crash on missing entries. + * `$site->getTCP('footer')['sub_heading']`. Falls back to the bundled + * defaults so the default render path keeps working when a theme has + * not populated the relevant key. */ public function getTCP(string $key): mixed { return $this->themeConfig[$key] ?? self::TCP_DEFAULTS[$key] ?? null; } - public function throw404(): void + /** + * Append a status message to be rendered by the theme on the next + * `$site->render('messages')` call. The same shape the legacy site + * stored in `$_SESSION['msgs']`. + */ + public function addMsg(string $type, string $text, string $header = ''): void + { + $msg = ['type' => $type, 'value' => $text]; + if ($header !== '') { + $msg['header'] = $header; + } + $this->msgs[] = $msg; + } + + /** + * Reconstruct the URL prefix beneath which Scriptor is mounted — + * everything left of the first slug segment, with no query string. + * Useful when themes need to produce internal links without making + * assumptions about the deployed path. + */ + public function getBasePath(): string + { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $path = explode('?', $uri, 2)[0]; + $segmentsPath = $this->urlSegments->path(trailingSlash: true); + if ($segmentsPath !== '' && str_ends_with($path, $segmentsPath)) { + $path = substr($path, 0, -\strlen($segmentsPath)); + } + return $path === '' ? '/' : $path; + } + + /** + * Build the canonical URL path for a page by walking its parent + * chain. Mirrors the legacy `Site::getPageUrl()` shape: slugs joined + * with trailing slashes, root page (id=1) collapses to empty. + * + * Tolerates broken parent chains (self-references or cycles introduced + * by old data) by tracking visited ids and bailing out on a repeat. + */ + public function getPageUrl(Page $page): string + { + return $this->buildPageUrl($page, []); + } + + /** + * @param array $visited + */ + private function buildPageUrl(Page $page, array $visited): string + { + $id = $page->id() ?? 0; + if (isset($visited[$id])) { + // Cycle detected — collapse to the page's own slug to avoid recursion. + return $page->slug !== '' ? $page->slug . '/' : ''; + } + $visited[$id] = true; + + $url = ''; + if ($page->parent !== 0 && $page->parent !== $id) { + $parent = $this->pages->find($page->parent); + if ($parent !== null) { + $url .= $this->buildPageUrl($parent, $visited); + } + } + if ($id !== 1) { + $url .= $page->slug . '/'; + } + return $url; + } + + public function throw404(): never { header('HTTP/1.0 404 Not Found'); $themeRoot = $this->scriptorRoot . '/site/themes/' . $this->config['theme_path']; @@ -174,10 +295,10 @@ protected function renderContent(): string } $content = $this->page->content; $allowHtml = (bool) ($this->config['allowHtmlOutput'] ?? false); - // Markdown rendering through iManager's Sanitizer (Parsedown + optional - // HTMLPurifier). When allowHtmlOutput is true the input may already - // contain raw HTML; the markdown call passes it through Parsedown - // either way and lets safe-mode strip on entry. + // Markdown rendering through iManager's Sanitizer (Parsedown + + // optional HTMLPurifier). When allowHtmlOutput is true the input + // may already contain raw HTML; either way Parsedown runs and + // safe-mode strips dangerous tags on entry when html is disabled. $rendered = $this->sanitizer->markdown( $allowHtml ? $content : htmlspecialchars_decode($content), ); diff --git a/data/settings/basic-theme-config.php b/data/settings/basic-theme-config.php index 1428c82..ac0ca2a 100644 --- a/data/settings/basic-theme-config.php +++ b/data/settings/basic-theme-config.php @@ -12,13 +12,14 @@ /** * The name of the website used throughout the Basic theme. + * Overridden by BasicTheme::init() with $site->config['site_name']. */ - 'site_name' => \Scriptor\Core\Scriptor::getConfig('site_name'), + 'site_name' => 'Scriptor', /** * The version of the Basic theme being used. */ - 'theme_version' => Themes\Basic\BasicTheme::VERSION, + 'theme_version' => '2.0.0', /** * The copyright information for your website. @@ -159,7 +160,7 @@ * See /data/settings/scriptor-config.php resp. custom.scriptor-config.php * or change to a static value e.g. 262974383 */ - 'markup_cache_time' => \Scriptor\Core\Scriptor::getProperty('config')['markup_cache_time'], + 'markup_cache_time' => 0, // overridden by BasicTheme::init() with $site->config['markup_cache_time'] /** * Enter the templates of the pages where you want the output to be cached. diff --git a/index.php b/index.php index 3db267e..b29e73b 100644 --- a/index.php +++ b/index.php @@ -8,7 +8,20 @@ require_once __DIR__ . '/boot.php'; /** @var array $config */ -$site = new Site(App::container(), $config, __DIR__); -$site->execute(); +$themeDir = __DIR__ . '/site/themes/' . $config['theme_path']; +$ext = $themeDir . '_ext.php'; -include __DIR__ . '/site/themes/' . $config['theme_path'] . 'template.php'; +if (file_exists($ext)) { + // Theme installs its own $site (typically a Site subclass) and router. + $site = null; + include $ext; + if (! $site instanceof Site) { + // Cache short-circuit returned early; nothing left to render. + return; + } +} else { + $site = new Site(App::container(), $config, __DIR__); + $site->execute(); +} + +include $themeDir . 'template.php'; diff --git a/site/themes/basic/_ext.php b/site/themes/basic/_ext.php index da497a2..37a27ef 100644 --- a/site/themes/basic/_ext.php +++ b/site/themes/basic/_ext.php @@ -1,46 +1,34 @@ imanager->sectionCache->get( - md5($_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']), - $site->getTCP('markup_cache_time')) - ) { - $router->actions(); - echo $output; - exit; +/** @var array $config */ +$site = new BasicTheme(App::container(), $config, dirname(__DIR__, 3)); + +// SuperCache: short-circuit the request when a cached body is available. +$cached = $site->hitCache(); +if ($cached !== null) { + // User actions still run so a fresh subscribe/contact submission is + // processed even when the page itself comes from cache. + $router = new BasicRouter($site); + $router->actions(); + echo $cached; + return; } - - - -// Execute -$router->execute(); \ No newline at end of file +$router = new BasicRouter($site); +$router->execute(); diff --git a/site/themes/basic/lib/Basic.php b/site/themes/basic/lib/Basic.php index f3c023a..2c6da72 100644 --- a/site/themes/basic/lib/Basic.php +++ b/site/themes/basic/lib/Basic.php @@ -1,787 +1,635 @@ */ + private array $tpls; + + /** @var array */ + private array $paginationTpls; + + private ?Page $articles = null; + private string $paginationMarkup = ''; + private ?string $forcedTemplate = null; + + /** + * Loads theme-specific config + template fragments and resolves the + * "articles container" page once. Called by {@see Site::__construct()}. + */ + protected function init(): void + { + $themeConfigFile = $this->scriptorRoot . '/data/settings/basic-theme-config.php'; + $themeConfig = is_file($themeConfigFile) ? (require $themeConfigFile) : []; + $themeConfig['site_name'] = $this->config['site_name'] ?? $themeConfig['site_name'] ?? 'Scriptor'; + $themeConfig['markup_cache_time'] = $this->config['markup_cache_time'] ?? $themeConfig['markup_cache_time'] ?? 0; + $this->themeConfig = $themeConfig; + + $tpls = require dirname(__DIR__) . '/resources/_tpls.php'; + $this->paginationTpls = $tpls['pagination'] ?? []; + unset($tpls['pagination']); + /** @var array $tpls */ + $this->tpls = $tpls; + + $articlesId = (int) ($this->themeConfig['articles_page_id'] ?? 0); + if ($articlesId > 0) { + $this->articles = $this->pages->find($articlesId); + } + } + + /** + * Theme-specific render hooks. Anything not handled here delegates to + * the parent Site so the standard cases (`content`, `navigation`, + * `messages`) keep working. + */ + public function render(string $element): ?string + { + return match ($element) { + 'archivesContent' => $this->renderArchivesContent(), + 'archiveNav' => $this->renderArchiveNav(), + 'pagination' => $this->paginationMarkup, + 'hero' => $this->renderHero(), + 'footerNav' => $this->renderFooterNav(), + 'mainNavItems' => $this->renderMainNavItems(), + 'socIcons' => $this->renderSocIcons(), + 'articleDate' => $this->renderArticleDate(), + 'emptyCsrfFields' => $this->renderCsrfFields(false), + default => parent::render($element), + }; + } + + /** + * Output cache: persists rendered HTML for cacheable templates so the + * next request can short-circuit in {@see hitCache()}. Always returns + * the captured buffer so the template prints the page either way. + */ + public function cache(): string + { + $output = parent::cache(); + if ($this->page === null) { + return $output; + } + $cacheable = $this->themeConfig['cacheable_templates'] ?? []; + $ttl = (int) ($this->themeConfig['markup_cache_time'] ?? 0); + if ($ttl > 0 && \in_array($this->page->template, $cacheable, true)) { + $this->cache->set($this->cacheKey(), $output, $ttl); + } + return $output; + } + + /** + * Returns the cached body for the current request, or null if the + * cache is cold / disabled. Called by `_ext.php` before any rendering + * happens so we can shortcut on a hit. + */ + public function hitCache(): ?string + { + $ttl = (int) ($this->themeConfig['markup_cache_time'] ?? 0); + if ($ttl <= 0) { + return null; + } + $hit = $this->cache->get($this->cacheKey()); + return \is_string($hit) && $hit !== '' ? $hit : null; + } + + /** + * Routing override: when the URL targets the "articles container" + * page (with optional `/pageN/` pagination tail) we render the blog + * index, otherwise defer to the standard page resolver. Pages nested + * under the articles container get the `blog-post` template forced + * via {@see currentTemplate()} without mutating the page DTO. + */ + public function routeArticles(): void + { + if ($this->articles !== null && $this->urlSegmentsTargetArticlesContainer()) { + $this->page = $this->articles; + return; + } + + $this->execute(); + if ( + $this->articles !== null + && $this->page !== null + && $this->page->parent === $this->articles->id() + ) { + $this->forcedTemplate = 'blog-post'; + } + } + + public function currentTemplate(): string + { + return $this->forcedTemplate ?? parent::currentTemplate(); + } + + private function urlSegmentsTargetArticlesContainer(): bool + { + if ($this->articles === null) { + return false; + } + $segments = $this->urlSegments->segments; + return $segments !== [] && end($segments) === $this->articles->slug; + } + + /** + * Dispatches user actions (contact, subscribe, loadToken) when posted. + */ + public function actions(): void + { + $action = $this->input->postString('action'); + if ($action === '') { + return; + } + $allowed = $this->themeConfig['allowed_actions'] ?? []; + if (! \in_array($action, $allowed, true)) { + return; + } + $method = $action . 'Action'; + if (method_exists($this, $method)) { + $this->{$method}(); + } + } - /** - * This theme version - */ - const VERSION = '1.1.2'; - - /** - * Template pieces - */ - private $tpls; - - /** - * Config - */ - public $config; - - /** - * Articles page - * - */ - private $articles; - - /** - * Init Basic theme module and make the theme configuration (data/settings/theme-config.php) - * available inside the class. - */ - public function init() - { - parent::init(); - $this->config['theme'] = Scriptor::load(IM_DATAPATH.'/settings/basic-theme-config.php'); - $this->tpls = Scriptor::load(dirname(__DIR__).'/resources/_tpls.php'); - $this->articles = $this->pages->getPage($this->getTCP('articles_page_id')); - } - - /** - * Renders an element of the website that is responsible for displaying the - * list of articles. All other calls are forwarded to the Site::___render() - * method. - * - * NOTE: The is a hookable method of the Site class, should remain hookable. - * - * @param $element - */ - public function ___render(string $element) :?string - { - switch($element) { - case 'archivesContent': - return $this->renderContent(); - case 'archiveNav': - return $this->renderArchiveNav(); - case 'pagination': - return $this->articles->pagination; - case 'hero': - return $this->renderHero(); - case 'footerNav': - return $this->renderFooterNav(); - case 'mainNavItems': - return $this->renderMainNavItems(); - case 'messages': - return $this->articles->msgs; - case 'socIcons': - return $this->renderSocIcons(); - case 'articleDate': - return $this->renderArticleDate(); - case 'emptyCsrfFields': - return $this->renderCsrfFields(false); - } - - return parent::___render($element); - } - - /** - * Renders the archive or article content - * - * @return string - */ - private function renderContent() :string - { - // Is an archive? - if($this->input->get->archive) { - return $this->renderArchive( - $this->sanitizer->url($this->input->get->archive) - ); - } - // or article list - return $this->renderArticles(); - } - - /** - * Prepares the archives view - * - * @param string $str - * - * @return string - */ - private function renderArchive(string $str) :string - { - $data = explode('-', $str, 2); - if($data) { - if(count($data) > 1) { - $year = (int) $data[0]; - $month = $data[1]; - } else { - $year = date('Y'); - $month = $data[0]; - } - - // All datasets created within one month. - if(($end = strtotime("$month $year +1 month")) === false) { - $this->throw404(); - } - $start = strtotime("$month $year"); - $articles = $this->getArticlesWithin($start, $end); - if($articles) { - return $this->renderArticlesContent($articles); - } - $this->throw404(); - } - } - - /** - * Prepares the articles view - * - * Note that here we cache the $pagination markup under articles->pagination. - * This can be used to retrieve pagination if necessary. - * - * @return string - */ - private function renderArticles() :string - { - $perpage = $this->getTCP('articles_per_page'); - - $articles = $this->pages->getPages('parent='.(int) $this->articles->id, [ - 'sortBy' => 'created', - 'order' => 'desc', - 'length' => $perpage - ]); - - $this->articles->pagination = ''; - if ($articles && $this->pages->total > $perpage) { - $this->articles->pagination = $this->imanager->paginate($articles, [ - 'limit' => $perpage, - 'count' => (isset($this->pages->total) ? $this->pages->total : 0) - ], [ - 'wrapper' => $this->tpls['pagination_wrapper'], - 'central_inactive' => $this->tpls['pagination_inactive'], - 'central' => $this->tpls['pagination_active'], - 'prev' => $this->tpls['pagination_prev'], - 'prev_inactive' => $this->tpls['pagination_prev_inactive'], - 'next' => $this->tpls['pagination_next'], - 'next_inactive' => $this->tpls['pagination_next_inactive'], - 'ellipsis' => $this->tpls['pagination_ellipsis'] - ]); - } - - return $this->renderArticlesContent($articles); - } - - /** - * Renders archive navi in the sidebar - * - * @return string - */ - private function renderArchiveNav() :string - { - $rows = ''; - $archive = $this->archiveNavPages(); - - if($archive) { - $url = $this->getBasePath().$this->getPageUrl($this->articles, $this->pages); - $curYear = date('Y'); - $curMonth = date('F'); - foreach($archive as $year => $arr) { - foreach($arr as $month => $item) { - if($curYear != $year) { - $rows .= $this->templateParser->render($this->tpls['archive_nav_past_row'], [ - 'URL' => $url.'?archive='.$year.'-'.strtolower($month), - 'MONTH' => $month, - 'YEAR' => $year - ]); - } elseif($curMonth != $month) { - $rows .= $this->templateParser->render($this->tpls['archive_nav_current_row'], [ - 'URL' => $url.'?archive='.$year.'-'.strtolower($month), - 'MONTH' => $month - ]); - } - } - } - } - return $rows; - } - - /** - * The method retrieves archive pages. - * - * @return array - */ - private function archiveNavPages() :array - { - $rows = []; - $articles = $this->pages->getPages('parent=2', ['sortBy' => 'created', 'order' => 'DESC']); - if($articles) { - foreach($articles as $article) { - $dtz = $this->getTCP('datetime_zone'); - if($dtz) { - $date = new \DateTime('now', new \DateTimeZone($dtz)); - } else { - $date = new \DateTime(); - } - $date->setTimestamp($article->created); - - $month = $date->format('F'); - $year = $date->format('Y'); - - $rows[$year][$month] = [ - 'count' => isset($rows[$year][$month]['count']) ? ++$rows[$year][$month]['count'] : 1, - ]; - } - } - return $rows; - } - - /** - * Renders the content of the 'articles' page (articles list) - * - * @param array $articles - * - * @return string - */ - private function renderArticlesContent(array $articles = []) :string - { - if (!$articles) { - return $this->templateParser->render($this->tpls['empty_article_row'], [ - 'TEXT' => $this->getTCP('msgs')['no_articles_found'] - ]); - } - - $list = ''; - foreach ($articles as $article) { - isset($i) OR $i = 0; $i++; - - $date = $this->getFormatedPageDate($article->created); - - $articleUrl = $this->getBasePath().$this->getPageUrl($article, $this->pages); - - $figure = ''; - if (isset($article->images[0])) { - $imageUrl = $this->getBasePath().$article->images[0]->resize(800, 350, 0, 'adaptiveResize'); - $info = ''; - if($article->images[0]->title) { - $info = $this->templateParser->render($this->tpls['art_list_image_caption'], [ - 'TEXT' => $this->parsedown()->text($article->images[0]->title) - ]); - } - - $figure = $this->templateParser->render($this->tpls['art_list_figure'], [ - 'URL' => $articleUrl, - 'DATA_SRC' => $imageUrl, - 'ALT' => '', - 'INFO_ROW' => $info - ]); - } - - if ($this->config['allowHtmlOutput'] !== true) { - $this->parsedown()->setSafeMode(true); - } - - if (mb_strlen($article->content) > $this->getTCP('summary_character_len')) { - $content = $this->parsedown()->text(mb_substr(htmlspecialchars_decode($article->content), 0, - $this->getTCP('summary_character_len')).' ...'); - } else { - $content = $this->parsedown()->text(htmlspecialchars_decode($article->content)); - } - - $list .= $this->templateParser->render($this->tpls['article_row'], [ - 'HEADER_CLASS' => (($i == 1) ? ' class="uk-margin-top uk-padding-remove"' : ''), - 'URL' => $articleUrl, - 'HEADER_LINK_TITLE' => $article->name, - 'HEADER_TEXT' => $article->name, - 'CREATED_DATE' => $date, - 'FIGURE' => $figure, - 'CONTENT' => $content - ]); - } - return $list; - } - - /** - * Renders the date in the article view. - * - * @return string - */ - private function renderArticleDate() :string - { - $modified = ''; - if ($this->page->created != $this->page->updated) { - $modified .= $this->templateParser->render($this->tpls['modified_date'], [ - 'DATE' => $this->getFormatedPageDate($this->page->updated) - ]); - } - - $created = $this->templateParser->render($this->tpls['created_date'], [ - 'DATE' => $this->getFormatedPageDate($this->page->created) - ]); - - return $this->templateParser->render($this->tpls['article_date'], [ - 'CREATED_DATE' => $created, - 'MODIFIED_DATE' => $modified, - ]); - } - - /** - * Renders hero area in the template. - * We use "Parsedown" - a Markdown parser when rendering image - * description, because this may contain Markdown links. - * - * @return string - */ - private function renderHero() :string - { - $hero = ''; - if (isset($this->page->images[0])) { - $imageUrl = $this->getBasePath().$this->page->images[0]->resize(1200, 0); - $hero = $this->templateParser->render($this->tpls['hero'], [ - 'SRC' => $imageUrl, - 'INFO' => $this->parsedown()->text($this->page->images[0]->title) - ]); - } - return $hero; - } - - /** - * This method extends Module::addMsg() by adding the header parameter. - * - * @param string $type - Message type - * @param string $text - Message text - * @param string $header - Message header when needed - */ - public function addMsg(string $type, string $text, string $header = '') :void - { - $headline = ''; - if (!empty($header)) { - $headline .= $this->templateParser->render($this->tpls['msg_header'], [ - 'TEXT' => $header, - ]); - $this->msgs[] = [ - 'type' => $this->sanitizer->text($type), - 'header' => $headline, - 'value' => $text - ]; - } else { - parent::addMsg($type, $text); - } - } - - /** - * Renders messages - * - * @return string - */ - public function renderMsgs() :string - { - $messages = ''; - $msgs = $this->getProperty('msgs'); - if (!empty($msgs)) { - foreach($msgs as $msg) { - $messages .= $this->templateParser->render($this->tpls['msg'], [ - 'TYPE' => $msg['type'], - 'HEADER' => isset($msg['header']) ? $msg['header'] : '', - 'TEXT' => $msg['value'] - ]); - } - unset($_SESSION['msgs']); - $_SESSION['msgs'] = null; - } - $this->articles->msgs = $messages; - return $this->articles->msgs; - } - - /** - * The navigation elements in the footer - * - * @return string - */ - private function renderFooterNav() :string - { - $container = $this->pages->getPage($this->getTCP('footer_container_id')); - if ($container) { - return $this->templateParser->render($this->tpls['footer_nav'], [ - 'MENU_TITLE' => $container->menu_title, - 'INFO' => $container->content, - 'ITEM_ROWS' => $this->renderNavItems([ - 'parent' => $container->id, - 'icon' => '» ' - ]) - ]); - } - } - - /** - * Renders the navigation elements - * - * @param array $options - * - * @return string - */ - private function renderNavItems(array $options = []) :string - { - $setup = array_merge([ - 'parent' => 0, - 'maxLevel' => 0, - 'sortBy' => 'position', - 'order' => 'asc', - 'active' => true, - 'icon' => '' - ], $options); - - $data = $this->pages->getPageLevels($setup); - - if(empty($data[$setup['parent']])) return ''; - - $navi = ''; - foreach($data[$setup['parent']] as $page) { - $class = ($this->page->slug == $page->slug || $this->page->parent == $page->id) ? 'uk-active' : ''; - $navi .= $this->templateParser->render($this->tpls['nav_item'], [ - 'CLASS' => $class, - 'URL' => $this->getBasePath().$this->getPageUrl($page, $this->pages), - 'ICON' => !empty($setup['icon']) ? $setup['icon'] : '', - 'TITLE' => $page->menu_title - ]); - } - - return $navi; - } - - /** - * Main navi - * - * @return string - */ - private function renderMainNavItems() :string - { - return $this->renderNavItems([ - 'exclude' => $this->getTCP('main_nav_exclude_ids') - ]); - } - - /** - * Renders SOC icons - * - * @return string - */ - private function renderSocIcons() :string - { - $icons = ''; - foreach ($this->getTCP('social_media') as $name => $ref) { - $icons .= $this->templateParser->render($this->tpls['icon_nav_row'], [ - 'URL' => $ref['href'], - 'ICON_NAME' => $name - ]); - } - return $icons; - } - - /** - * Cross-site request forgery protection. - * - * It generates markup from two hidden fields containing CSRF "token" and "name". - * - * @param bool $setToken - * - * @return string - */ - private function renderCsrfFields(bool $setToken = true) :string - { - $csrf = Scriptor::getCSRF(); - return $this->templateParser->render($this->tpls['csrf_token_fields'], [ - 'NAME' => ($setToken) ? $csrf->getTokenName() : '', - 'VALUE' => ($setToken) ? $csrf->getTokenValue() : '' - ]); - } - - /** - * Executed after sending the contact form on the website. - * - * Three underscores ___ is a hookable method - */ - public function ___contactAction() :void - { - $err = false; - $csrf = Scriptor::getCSRF(); - - if ($this->config['protectCSRF']) { - if (!$csrf->isTokenValid($this->input->post->tokenName, $this->input->post->tokenValue)) { - $this->addMsg('danger', $this->getTCP('msgs')['csrf_token_mismatch']); - $this->renderMsgs(); - Helper::sendJsonResponse([ - 'msgs' => $this->articles->msgs, - ]); - } - } - - $mailData = [ - 'subject' => $this->getTCP('email')['subject_contact'], - 'to' => $this->getTCP('email')['email_to'], - 'to_name' => $this->getTCP('email')['email_to_name'], - 'from' => $this->sanitizer->email($this->input->post->replyto), - 'from_name' => $this->sanitizer->text($this->input->post->name), - 'body' => $this->sanitizer->textarea($this->input->post->text) - ]; - - if (empty($mailData['from'])) { - $this->addMsg('danger', $this->getTCP('msgs')['empty_from_field']); - $err = true; - } elseif (empty($mailData['from_name']) || empty($mailData['body'])) { - $this->addMsg('danger', $this->getTCP('msgs')['empty_mandatory_fields']); - $err = true; - } - - if ($err) { - $this->renderMsgs(); - Helper::sendJsonResponse([ - 'msgs' => $this->articles->msgs - ]); - } - - $result = $this->sendMail($mailData); - - if ($result !== true) { - $this->addMsg('danger', $this->getTCP('msgs')['error_sending_email']); - $this->renderMsgs(); - Helper::sendJsonResponse([ - 'msgs' => $this->articles->msgs - ]); - } - - $this->addMsg('success', $this->getTCP('msgs')['email_received']); - $this->renderMsgs(); - Helper::sendJsonResponse([ - 'success' => true, - 'msgs' => $this->articles->msgs - ]); - } - - /** - * Sending email - * - * This function does not validate the user input ($data) - make sure you validate - * the user input beforehand (use sanitizer). - * - * INFO: a hookable method - * - * @param $data - * @return bool - Returns true if the mail was successfully accepted for delivery, - * false otherwise. - */ - public function ___sendMail(array $data) :bool - { - $headers = "From: $data[from_name] <$data[from]>" . "\r\n" . - "Reply-To: $data[from]" . "\r\n" . - 'X-Mailer: PHP/' . phpversion(); - - return mail($data['to'], $data['subject'], $data['body'], $headers); - } - - /** - * Returns new CSRF token in JSON format. - */ - public function loadTokenAction() :void - { - $csrf = Scriptor::getCSRF(); - Helper::sendJsonResponse([ - 'success' => true, - 'csrf' => [ - 'tokenName' => $csrf->getTokenName(), - 'tokenValue' => $csrf->getTokenValue() - ] - ]); - } - - /** - * Subscribe user - * A MailChimp account must be created before this. - * The method is executed when the user has submitted the Subscribe form. - */ - public function ___subscribeAction() :void - { - $csrf = Scriptor::getCSRF(); - - if ($this->config['protectCSRF']) { - if (!$csrf->isTokenValid($this->input->post->tokenName, $this->input->post->tokenValue)) { - $this->addMsg('danger', $this->getTCP('msgs')['csrf_token_mismatch']); - Helper::sendJsonResponse([ - 'msgs' => $this->renderMsgs(), - ]); - } - } - - $subscData = [ - //'name' => $this->sanitizer->text($this->input->post->name), - // GDRP? - //'confirm' => (int) $this->input->post->confirm, - 'email' => mb_strtolower($this->sanitizer->email($this->input->post->email)) - ]; - - if (empty($subscData['email'])) { - $this->addMsg('danger', $this->getTCP('msgs')['empty_email_field']); - Helper::sendJsonResponse([ - 'success' => true, - 'msgs' => $this->renderMsgs() - ]); - } - - $mc = new MailChimp($this->getTCP('mail_chimp')); - $subscriber = $mc->get($subscData['email']); - $result = ''; - - if (isset($subscriber['email_address']) && isset($subscriber['email_address']) == $subscData['email']) { - // already subscriber? - if ($subscriber['status'] == 'subscribed') { - $this->addMsg('success', $this->getTCP('msgs')['subsc_email_exists']); - Helper::sendJsonResponse([ - 'success' => true, - 'msgs' => $this->renderMsgs() - ]); - } - - // Contact unsubscribed, change to subscribed - $result = $mc->change([ - 'email_address' => $subscData['email'], - 'status_if_new' => 'pending', - 'status' => 'pending' - ]); - if ($mc->code == 200) { - // Show notice: email sent - $sec = $this->templateParser->render($this->getTCP('msgs')['subsc_email_confirmation'], [ - 'EMAIL' => $subscData['email'] - ]); - $this->addMsg('success', $sec, $this->getTCP('msgs')['subsc_email_header']); - Helper::sendJsonResponse([ - 'success' => true, - 'msgs' => $this->renderMsgs() - ]); - } - - // The contact doesn’t exist in the mailing list - } elseif ($mc->code == 404) { - $result = $mc->add([ - 'email_address' => $subscData['email'], - 'status' => 'pending' - ]); - if ($mc->code == 200) { - // Show notice: email sent - $sec = $this->templateParser->render($this->getTCP('msgs')['subsc_email_confirmation'], [ - 'EMAIL' => $subscData['email'] - ]); - $this->addMsg('success', $sec, $this->getTCP('msgs')['subsc_email_header']); - Helper::sendJsonResponse([ - 'success' => true, - 'msgs' => $this->renderMsgs() - ]); + /* --------------------------------------------------------------- * + * Render helpers + * --------------------------------------------------------------- */ + + private function renderArchivesContent(): string + { + if ($this->articles === null) { + return ''; + } + $archive = $this->input->getString('archive'); + if ($archive !== '') { + return $this->renderArchive($this->sanitizer->slug($archive)); + } + return $this->renderArticles(); + } + + private function renderArchive(string $period): string + { + $parts = explode('-', $period, 2); + if (\count($parts) === 2) { + $year = (int) $parts[0]; + $month = $parts[1]; + } else { + $year = (int) date('Y'); + $month = $parts[0]; + } + $start = strtotime("$month $year"); + $end = strtotime("$month $year +1 month"); + if ($start === false || $end === false || $this->articles === null) { + $this->throw404(); + } + \assert($this->articles !== null && $this->articles->id() !== null); + $articles = $this->pages->findInTimeRange($start, $end, $this->articles->id()); + if ($articles === []) { + $this->throw404(); + } + return $this->renderArticleList($articles); + } + + private function renderArticles(): string + { + if ($this->articles === null || $this->articles->id() === null) { + return ''; + } + $perPage = (int) ($this->themeConfig['articles_per_page'] ?? 10); + $total = $this->pages->countByParent($this->articles->id(), activeOnly: true); + $page = max(1, $this->urlSegments->pageNumber); + + $articles = $this->pages->findByParent( + $this->articles->id(), + orderBy: 'created', + direction: \Imanager\Query\Direction::Desc, + activeOnly: true, + offset: ($page - 1) * $perPage, + limit: $perPage, + ); + + $this->paginationMarkup = ''; + if ($total > $perPage) { + $renderer = new PaginationRenderer(new TemplateRenderer(), $this->paginationTpls); + $this->paginationMarkup = $renderer->render( + new Pagination($page, $perPage, $total), + $this->getBasePath() . $this->getPageUrl($this->articles) . 'page%d/', + ); + } + + return $this->renderArticleList($articles); + } + + /** + * @param list $articles + */ + private function renderArticleList(array $articles): string + { + if ($articles === []) { + return $this->templateParser->render($this->tpls['empty_article_row'], [ + 'TEXT' => $this->themeConfig['msgs']['no_articles_found'] ?? 'No articles found', + ]); + } + + $output = ''; + $i = 0; + foreach ($articles as $article) { + $i++; + $url = $this->getBasePath() . $this->getPageUrl($article); + $date = $this->formatDate($article->created()); + $figure = $this->renderArticleFigure($article, $url); + $content = $this->renderArticleSummary($article); + + $output .= $this->templateParser->render($this->tpls['article_row'], [ + 'HEADER_CLASS' => $i === 1 ? ' class="uk-margin-top uk-padding-remove"' : '', + 'URL' => $url, + 'HEADER_LINK_TITLE' => $article->name, + 'HEADER_TEXT' => $article->name, + 'CREATED_DATE' => $date, + 'FIGURE' => $figure, + 'CONTENT' => $content, + ]); + } + return $output; + } + + private function renderArticleFigure(Page $article, string $articleUrl): string + { + $first = $article->images[0] ?? null; + if (! \is_array($first) || ! isset($first['name'], $first['path'])) { + return ''; + } + $imageUrl = $this->getBasePath() . self::imagePath($first); + $info = ''; + if (! empty($first['title'])) { + $info = $this->templateParser->render($this->tpls['art_list_image_caption'], [ + 'TEXT' => $this->sanitizer->markdown((string) $first['title']), + ]); + } + return $this->templateParser->render($this->tpls['art_list_figure'], [ + 'URL' => $articleUrl, + 'DATA_SRC' => $imageUrl, + 'ALT' => '', + 'INFO_ROW' => $info, + ]); + } + + private function renderArticleSummary(Page $article): string + { + $limit = (int) ($this->themeConfig['summary_character_len'] ?? 400); + $text = htmlspecialchars_decode($article->content); + if ($limit > 0 && mb_strlen($text) > $limit) { + $text = mb_substr($text, 0, $limit) . ' …'; + } + return $this->sanitizer->markdown($text); + } + + private function renderArchiveNav(): string + { + if ($this->articles === null || $this->articles->id() === null) { + return ''; + } + $months = $this->collectArchiveMonths($this->articles->id()); + if ($months === []) { + return ''; + } + $rows = ''; + $url = $this->getBasePath() . $this->getPageUrl($this->articles); + $curYear = (int) date('Y'); + $curMon = date('F'); + foreach ($months as $year => $byMonth) { + foreach ($byMonth as $month => $_count) { + $isCurrent = ($year === $curYear) && ($month === $curMon); + if ($year !== $curYear) { + $rows .= $this->templateParser->render($this->tpls['archive_nav_past_row'], [ + 'URL' => $url . '?archive=' . $year . '-' . strtolower($month), + 'MONTH' => $month, + 'YEAR' => (string) $year, + ]); + } elseif (! $isCurrent) { + $rows .= $this->templateParser->render($this->tpls['archive_nav_current_row'], [ + 'URL' => $url . '?archive=' . $year . '-' . strtolower($month), + 'MONTH' => $month, + ]); + } } } + return $rows; + } + + /** + * @return array> + */ + private function collectArchiveMonths(int $articlesId): array + { + $months = []; + foreach ($this->pages->findByParent($articlesId, activeOnly: true) as $article) { + $date = $this->dateTime($article->created()); + $year = (int) $date->format('Y'); + $month = $date->format('F'); + $months[$year][$month] = ($months[$year][$month] ?? 0) + 1; + } + return $months; + } + + private function renderArticleDate(): string + { + if ($this->page === null) { + return ''; + } + $modified = ''; + if ($this->page->created() !== $this->page->updated()) { + $modified = $this->templateParser->render($this->tpls['modified_date'], [ + 'DATE' => $this->formatDate($this->page->updated()), + ]); + } + $created = $this->templateParser->render($this->tpls['created_date'], [ + 'DATE' => $this->formatDate($this->page->created()), + ]); + return $this->templateParser->render($this->tpls['article_date'], [ + 'CREATED_DATE' => $created, + 'MODIFIED_DATE' => $modified, + ]); + } + + private function renderHero(): string + { + if ($this->page === null) { + return ''; + } + $first = $this->page->images[0] ?? null; + if (! \is_array($first) || ! isset($first['name'], $first['path'])) { + return ''; + } + $imageUrl = $this->getBasePath() . self::imagePath($first); + return $this->templateParser->render($this->tpls['hero'], [ + 'SRC' => $imageUrl, + 'INFO' => $this->sanitizer->markdown((string) ($first['title'] ?? '')), + ]); + } + + private function renderFooterNav(): string + { + $containerId = (int) ($this->themeConfig['footer_container_id'] ?? 0); + if ($containerId === 0) { + return ''; + } + $container = $this->pages->find($containerId); + if ($container === null) { + return ''; + } + return $this->templateParser->render($this->tpls['footer_nav'], [ + 'MENU_TITLE' => $container->menu_title, + 'INFO' => $container->content, + 'ITEM_ROWS' => $this->renderNavItems([ + 'parent' => $container->id() ?? 0, + 'icon' => '» ', + ]), + ]); + } + + private function renderMainNavItems(): string + { + return $this->renderNavItems([ + 'exclude' => $this->themeConfig['main_nav_exclude_ids'] ?? [], + ]); + } + + /** + * @param array{parent?: int, exclude?: list, icon?: string} $options + */ + private function renderNavItems(array $options = []): string + { + $parent = (int) ($options['parent'] ?? 0); + $exclude = $options['exclude'] ?? []; + $icon = $options['icon'] ?? ''; + + $tree = $this->pages->levels(rootParent: $parent, excludeIds: $exclude); + if (empty($tree[$parent])) { + return ''; + } + + $navi = ''; + foreach ($tree[$parent] as $page) { + $isActive = $this->page !== null + && ($this->page->slug === $page->slug || $this->page->parent === $page->id()); + $navi .= $this->templateParser->render($this->tpls['nav_item'], [ + 'CLASS' => $isActive ? 'uk-active' : '', + 'URL' => $this->getBasePath() . $this->getPageUrl($page), + 'ICON' => $icon, + 'TITLE' => $page->menu_title !== '' ? $page->menu_title : $page->name, + ]); + } + return $navi; + } + + private function renderSocIcons(): string + { + $icons = ''; + foreach ($this->themeConfig['social_media'] ?? [] as $name => $ref) { + $icons .= $this->templateParser->render($this->tpls['icon_nav_row'], [ + 'URL' => (string) ($ref['href'] ?? '#'), + 'ICON_NAME' => (string) $name, + ]); + } + return $icons; + } + + private function renderCsrfFields(bool $populate = true): string + { + // CSRF infrastructure ist 14c-1 (auth module). For 14b-2 we render + // empty placeholders so the form markup stays valid. + unset($populate); + return $this->templateParser->render($this->tpls['csrf_token_fields'], [ + 'NAME' => '', + 'VALUE' => '', + ]); + } + + /* --------------------------------------------------------------- * + * Action handlers (legacy bridge — Subscribe/Contact/Mail). + * Behaviour preserved verbatim; refined when Phase 14d wires real + * upload+endpoint plumbing. + * --------------------------------------------------------------- */ + + public function contactAction(): void + { + $email = $this->input->postString('replyto'); + $name = $this->input->postString('name'); + $body = $this->input->postString('text'); + if ($email === '' || $name === '' || $body === '') { + $this->jsonResponse([ + 'msgs' => $this->dumpMessages(['danger' => $this->themeConfig['msgs']['empty_mandatory_fields'] ?? 'Empty fields']), + ]); + return; + } + $cleanEmail = $this->sanitizer->email($email); + if ($cleanEmail === null) { + $this->jsonResponse([ + 'msgs' => $this->dumpMessages(['danger' => $this->themeConfig['msgs']['empty_from_field'] ?? 'Invalid email']), + ]); + return; + } + + $cfg = $this->themeConfig['email'] ?? []; + $headers = "From: {$name} <{$cleanEmail}>\r\n" + . "Reply-To: {$cleanEmail}\r\n" + . 'X-Mailer: PHP/' . PHP_VERSION; + $sent = @mail( + (string) ($cfg['email_to'] ?? ''), + (string) ($cfg['subject_contact'] ?? 'Contact'), + $body, + $headers, + ); + if (! $sent) { + $this->jsonResponse([ + 'msgs' => $this->dumpMessages(['danger' => $this->themeConfig['msgs']['error_sending_email'] ?? 'Send failed']), + ]); + return; + } + $this->jsonResponse([ + 'success' => true, + 'msgs' => $this->dumpMessages(['success' => $this->themeConfig['msgs']['email_received'] ?? 'Sent']), + ]); + } + + public function loadTokenAction(): void + { + // CSRF arrives in 14c-1; until then return empty placeholders so + // the JS subscribe/contact forms keep posting without errors. + $this->jsonResponse([ + 'success' => true, + 'csrf' => ['tokenName' => '', 'tokenValue' => ''], + ]); + } + + public function subscribeAction(): void + { + $email = $this->sanitizer->email($this->input->postString('email')); + if ($email === null || $email === '') { + $this->jsonResponse([ + 'success' => false, + 'msgs' => $this->dumpMessages(['danger' => $this->themeConfig['msgs']['empty_email_field'] ?? 'Empty email']), + ]); + return; + } + $mc = new MailChimp($this->themeConfig['mail_chimp'] ?? []); + $existing = $mc->get(mb_strtolower($email)); + if ( + \is_array($existing) + && ($existing['email_address'] ?? null) === mb_strtolower($email) + && ($existing['status'] ?? '') === 'subscribed' + ) { + $this->jsonResponse([ + 'success' => true, + 'msgs' => $this->dumpMessages(['success' => $this->themeConfig['msgs']['subsc_email_exists'] ?? 'Already subscribed']), + ]); + return; + } + $mc->add(['email_address' => mb_strtolower($email), 'status' => 'pending']); + if ($mc->code === 200) { + $confirmTpl = (string) ($this->themeConfig['msgs']['subsc_email_confirmation'] ?? ''); + $msg = $this->templateParser->render($confirmTpl, ['EMAIL' => $email]); + $this->jsonResponse([ + 'success' => true, + 'msgs' => $this->dumpMessages(['success' => $msg]), + ]); + return; + } + $this->jsonResponse([ + 'success' => false, + 'msgs' => $this->dumpMessages(['danger' => $this->themeConfig['msgs']['subsc_faild'] ?? 'Failed']), + ]); + } + + /* --------------------------------------------------------------- * + * Utilities + * --------------------------------------------------------------- */ + + private function cacheKey(): string + { + $host = $_SERVER['HTTP_HOST'] ?? 'cli'; + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + return 'page_' . md5($host . $uri); + } + + private function dateTime(int $timestamp): \DateTimeImmutable + { + $tz = (string) ($this->themeConfig['datetime_zone'] ?? ''); + $zone = $tz !== '' ? new \DateTimeZone($tz) : new \DateTimeZone(date_default_timezone_get()); + return (new \DateTimeImmutable('now', $zone))->setTimestamp($timestamp); + } + + private function formatDate(int $timestamp): string + { + $format = (string) ($this->themeConfig['datetime_format'] ?? 'd F Y'); + return $this->dateTime($timestamp)->format($format); + } + + /** + * @param array $image + */ + private static function imagePath(array $image): string + { + $path = (string) ($image['path'] ?? ''); + $name = (string) ($image['name'] ?? ''); + if ($path === '' || $name === '') { + return ''; + } + $clean = '/' . ltrim($path, '/'); + if (! str_ends_with($clean, '/')) { + $clean .= '/'; + } + return $clean . $name; + } + + /** + * @param array $extra + * @return string Rendered messages markup. + */ + private function dumpMessages(array $extra = []): string + { + foreach ($extra as $type => $text) { + $this->addMsg((string) $type, $text); + } + $rendered = ''; + foreach ($this->msgs as $msg) { + $rendered .= $this->templateParser->render($this->tpls['msg'], [ + 'TYPE' => $msg['type'], + 'HEADER' => $msg['header'] ?? '', + 'TEXT' => $msg['value'], + ]); + } + $this->msgs = []; + return $rendered; + } + + /** + * @param array $payload + */ + private function jsonResponse(array $payload): never + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; + } - $this->addMsg('danger', $this->getTCP('msgs')['subsc_faild']); - Util::dataLog('Failed to add your email address to our Newsletter mailing list. Response: ' . print_r($result, true)); - Helper::sendJsonResponse([ - 'success' => false, - 'msgs' => $this->renderMsgs() - ]); - } - - /** - * Returns articles that were created in a specified time period. - * We use this method to sort the articles by months (see "archive"). - * - * @param int $start - timestamp - * @param int $end - timestamp - * - * @return null|array - */ - public function getArticlesWithin(int $start, int $end) :?array - { - $contId = $this->getTCP('articles_page_id'); - $levels = $this->pages->getPageLevels(['parent' => $contId]); - - if (!isset($levels[$contId]) || empty($levels[$contId])) return null; - - $articles = $this->pages->getPages("created >= $start", [ - 'items' => $levels[$contId], - 'length' => 0 - ]); - - if (!$articles) return null; - - $period = $this->pages->getPages("created < $end", [ - 'items' => $articles, - 'sortBy' => 'created', - 'order' => 'desc', - 'length' => 0 - ]); - - return $period; - } - - /** - * Retrieves a theme configurations property - * - * @param string $prop - Property name - * - * @return mixed - */ - public function getTCP(string $prop) :mixed - { - return isset($this->config['theme'][$prop]) ? $this->config['theme'][$prop] : null; - } - - /** - * Use REQUEST_URI over $this->siteUrl - * - * TODO: - * The method has a disadvantage, the first segments can be manipulated - * like this ... /fake/dir/start-page/ etc. - * - * @return string - */ - public function getBasePath() :string - { - if (isset($_SERVER['REQUEST_URI']) && ! empty($_SERVER['REQUEST_URI'])) { - $urlArr = explode('?', $_SERVER['REQUEST_URI'], 2); - if (is_array($urlArr)) { - $url = $this->sanitizer->url($urlArr[0]); - return str_replace((string) $this->input->get->id, '', $url); - } - } - return $this->siteUrl.'/'; - } - - /** - * Returns formatted date. - * - * @param int $date - timestamp (numeric value) - * - * @return string - */ - public function getFormatedPageDate(int $datestamp) :string - { - $dtz = $this->getTCP('datetime_zone'); - if ($dtz) $date = new \DateTime('now', new \DateTimeZone($dtz)); - else $date = new \DateTime(); - - $date->setTimestamp($datestamp); - return $date->format($this->getTCP('datetime_format')); - } - - /** - * Page output caching - * - * Allows to cache rendered output of a complete page. - * Note that only the pages listed in the "cacheable_templates" - * configuration variable will be cached. - * - * @return string - */ - public function cache() :string - { - $output = ob_get_clean(); - if ($this->page && - $this->getTCP('markup_cache_time') && - in_array($this->page->template, $this->getTCP('cacheable_templates'))) - { - $this->imanager->sectionCache->save($output); - } - return $output; - } } diff --git a/site/themes/basic/lib/BasicRouter.php b/site/themes/basic/lib/BasicRouter.php index c9fadc5..4e04680 100644 --- a/site/themes/basic/lib/BasicRouter.php +++ b/site/themes/basic/lib/BasicRouter.php @@ -1,69 +1,30 @@ site = $site; - } - - /** - * It only affects the blog page, everything else goes to the default site::execute(). - * The pages inside your container are automatically assigned template 'blog-post'. - * - * @return void - */ - public function execute() :void - { - $this->actions(); + public function __construct(private readonly BasicTheme $site) {} - $articles = $this->site->pages()->getPage((int) $this->site->getTCP('articles_page_id')); - - if ($articles && $articles->slug != $this->site->urlSegments->getlast()) { - $this->site->execute(); - if ($this->site->page->parent == $articles->id) { - $this->site->page->template = 'blog-post'; - } - } else { - if (!$articles || !$articles->active) { - $this->site->throw404(); - } - $pageUrl = $this->site->getPageUrl($articles, $articles->pages); - if (strpos($this->site->urlSegments->getUrl(), $pageUrl) === false) { - $this->site->throw404(); - } - $this->site->page = $articles; - } - } + public function execute(): void + { + $this->actions(); + $this->site->routeArticles(); + } - /** - * Check user actions - * - * @return void - */ - public function actions() :void - { - $post = $this->site->input->post; - if ($post->action && in_array($post->action, $this->site->getTCP('allowed_actions'))) { - $name = $post->action; - $func = $name.'Action'; - $this->site->$func(); - } - } + public function actions(): void + { + $this->site->actions(); + } } diff --git a/site/themes/basic/lib/subscriber/MailChimp.php b/site/themes/basic/lib/subscriber/MailChimp.php index 95ad9c5..e3dcf29 100644 --- a/site/themes/basic/lib/subscriber/MailChimp.php +++ b/site/themes/basic/lib/subscriber/MailChimp.php @@ -2,33 +2,30 @@ namespace Themes\Basic\Subscriber; -use Imanager\Util; - /** * MailChimp Class - * - * Can be used to add new subscribers to the subscriber list - * or modify existing ones. - * + * + * Can be used to add new subscribers to the subscriber list + * or modify existing ones. */ class MailChimp { - private $username; - - private $api_key; - - private $dc; - - private $list_id; + private string $username; + private string $api_key; + private string $dc; + private string $list_id; - public $code; + public ?int $code = null; + /** + * @param array $params + */ public function __construct(array $params) { - $this->username = $params['username']; - $this->api_key = $params['api_key']; - $this->dc = $params['dc']; - $this->list_id = $params['list_id']; + $this->username = (string) ($params['username'] ?? ''); + $this->api_key = (string) ($params['api_key'] ?? ''); + $this->dc = (string) ($params['dc'] ?? ''); + $this->list_id = (string) ($params['list_id'] ?? ''); } public function getRequest() : Request diff --git a/site/themes/basic/resources/_tpls.php b/site/themes/basic/resources/_tpls.php index 63ed10e..3904a3f 100644 --- a/site/themes/basic/resources/_tpls.php +++ b/site/themes/basic/resources/_tpls.php @@ -1,120 +1,114 @@ -<<[[TEXT]] -EOD, + 'art_list_image_caption' => + '
{{TEXT}}
', -'art_list_figure' => -<< <<<'EOD'
- [[ALT]] - [[INFO_ROW]] + {{ALT}} + {{INFO_ROW}}
EOD, -'article_row' => -<< <<<'EOD' EOD, -'article_date' => -<<