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'
-
- [[INFO_ROW]]
+
+ {{INFO_ROW}}
EOD,
-'article_row' =>
-<< <<<'EOD'
-
EOD,
-'article_date' =>
-<<Written on [[CREATED_DATE]] | [[MODIFIED_DATE]]
-EOD,
-
-'article_date' =>
-<<[[CREATED_DATE]][[MODIFIED_DATE]]
-EOD,
-
-'created_date' => 'Written on [[DATE]]',
+ 'article_date' =>
+ '{{CREATED_DATE}}{{MODIFIED_DATE}}
',
-'modified_date' => ' | Modified on [[DATE]]',
+ 'created_date' => 'Written on {{DATE}}',
+ 'modified_date' => ' | Modified on {{DATE}}',
-'empty_article_row' =>
-<< <<<'EOD'
-
⋅ [[TEXT]]
+
⋅ {{TEXT}}
EOD,
-'csrf_token_fields' =>
-<<
-
+ 'csrf_token_fields' => <<<'EOD'
+
+
EOD,
-'icon_nav_row' => '',
+ 'icon_nav_row' =>
+ '',
-'archive_nav_past_row' => '[[MONTH]] [[YEAR]]',
+ 'archive_nav_past_row' =>
+ '{{MONTH}} {{YEAR}}',
-'archive_nav_current_row' => '[[MONTH]]',
+ 'archive_nav_current_row' =>
+ '{{MONTH}}',
-'hero' =>
-<< <<<'EOD'
-

+
- [[INFO]]
+ {{INFO}}
EOD,
-'msg' =>
-<<
+ 'msg' => <<<'EOD'
+
- [[HEADER]]
-
[[TEXT]]
+ {{HEADER}}
+
{{TEXT}}
EOD,
-'msg_header' => '[[TEXT]]
',
+ 'msg_header' => '{{TEXT}}
',
-'footer_nav' =>
-<<[[MENU_TITLE]]
-[[INFO]]
+ 'footer_nav' => <<<'EOD'
+{{MENU_TITLE}}
+{{INFO}}
- [[ITEM_ROWS]]
+ {{ITEM_ROWS}}
EOD,
-'nav_item' => '[[ICON]][[TITLE]]',
-
-'pagination_wrapper' =>
-<<
-
-
-EOD,
-
-'pagination_inactive' => '[[counter]]',
-'pagination_active' => '[[counter]]',
-'pagination_prev' => '',
-'pagination_prev_inactive' => '',
-'pagination_next' => '',
-'pagination_next_inactive' => '',
-'pagination_ellipsis' => '...'
-
+ 'nav_item' =>
+ '{{ICON}}{{TITLE}}',
+
+ // PaginationRenderer template overrides; variables are `counter`,
+ // `href`, and `value` (lowercase, that's what the renderer emits).
+ 'pagination' => [
+ 'wrapper' => '',
+ 'link' => '{{counter}}',
+ 'current' => '{{counter}}',
+ 'prev' => '',
+ 'prev_inactive' => '',
+ 'next' => '',
+ 'next_inactive' => '',
+ 'ellipsis' => '…',
+ ],
];
diff --git a/site/themes/basic/template.php b/site/themes/basic/template.php
index 113ee5e..63a0d5e 100644
--- a/site/themes/basic/template.php
+++ b/site/themes/basic/template.php
@@ -1,6 +1,6 @@
sanitizer->templateName($site->page->template);
+$tplName = $site->sanitizer->templateName($site->currentTemplate());
$tplFile = __DIR__."/$tplName.php";
ob_start();