diff --git a/.gitignore b/.gitignore
index e69de29..af68aec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# Composer
+/vendor/
+
+# iManager 2.0 runtime artefacts
+/data/imanager.db
+/data/imanager.db-wal
+/data/imanager.db-shm
+/data/uploads-2.0/
+/data/cache/sections/
+
+# Phase 14 migration backups
+/data.bak.*
diff --git a/boot.php b/boot.php
index e284245..faef454 100644
--- a/boot.php
+++ b/boot.php
@@ -1,33 +1,39 @@
config)
+ *
+ * The legacy 1.x bootstrap (Scriptor/imanager/, editor/core/) is intentionally
+ * NOT loaded — it shares the Imanager\ namespace with the new vendor package
+ * and would collide. Sub-phases reintroduce functionality piece by piece:
+ * - 14b-2: BasicTheme back on the new container
+ * - 14c: editor modules (auth, pages, users, settings, install, profile)
+ * - 14f: delete Scriptor/imanager/ entirely
+ */
-Scriptor::build($config);
\ No newline at end of file
+// Legacy config files guard their first line with `defined('IS_IM')`.
+\defined('IS_IM') || \define('IS_IM', true);
+// Legacy themes use IM_DATAPATH for theme-config files (BasicTheme in 14b-2).
+\defined('IM_DATAPATH') || \define('IM_DATAPATH', __DIR__ . '/data');
+
+/** @var array $config */
+require __DIR__ . '/data/settings/scriptor-config.php';
+if (file_exists(__DIR__ . '/data/settings/custom.scriptor-config.php')) {
+ $config = array_replace_recursive(
+ $config,
+ include __DIR__ . '/data/settings/custom.scriptor-config.php',
+ );
+}
diff --git a/boot/App.php b/boot/App.php
new file mode 100644
index 0000000..5bc7c61
--- /dev/null
+++ b/boot/App.php
@@ -0,0 +1,45 @@
+get(...)`
+ * call-sites with explicit dependency injection module by module, and
+ * this locator becomes empty at the end of Phase 14.
+ */
+final class App
+{
+ private static ?Container $container = null;
+
+ public static function set(Container $container): void
+ {
+ self::$container = $container;
+ }
+
+ public static function container(): Container
+ {
+ if (self::$container === null) {
+ throw new \RuntimeException(
+ 'iManager container has not been booted yet. Did boot.php run?',
+ );
+ }
+ return self::$container;
+ }
+
+ public static function reset(): void
+ {
+ self::$container = null;
+ }
+
+ private function __construct()
+ {
+ }
+}
diff --git a/boot/Frontend/Page.php b/boot/Frontend/Page.php
new file mode 100644
index 0000000..6ab2857
--- /dev/null
+++ b/boot/Frontend/Page.php
@@ -0,0 +1,102 @@
+page`.
+ *
+ * Adapts an `Imanager\Domain\Item` (Pages category) to the property surface
+ * the legacy Scriptor themes know: `name`, `slug`, `template`, `parent`,
+ * `pagetype`, `content`, `menu_title`, `images`, plus structural columns.
+ *
+ * Field values that aren't promoted to a top-level column live in the item's
+ * `data` bag — `__get()` reaches in lazily so theme code that asks for an
+ * uncommon field still works without us declaring every possible property.
+ */
+final readonly class Page
+{
+ public string $name;
+ public string $slug;
+ public string $template;
+ public string $pagetype;
+ public string $menu_title;
+ public string $content;
+ public int $parent;
+ /** @var list> */
+ public array $images;
+
+ public function __construct(
+ public Item $item,
+ ) {
+ $data = $item->data;
+ $this->name = $item->name ?? '';
+ $this->slug = self::str($data->get('slug'));
+ $this->template = self::str($data->get('template'));
+ $this->pagetype = self::str($data->get('pagetype'));
+ $this->menu_title = self::str($data->get('menu_title'));
+ $this->content = self::str($data->get('content'));
+ $this->parent = (int) ($data->get('parent') ?? 0);
+
+ $rawImages = $data->get('images');
+ $this->images = \is_array($rawImages) ? array_values($rawImages) : [];
+ }
+
+ public function id(): ?int
+ {
+ return $this->item->id;
+ }
+
+ public function active(): bool
+ {
+ return $this->item->active;
+ }
+
+ public function created(): int
+ {
+ return $this->item->created;
+ }
+
+ public function updated(): int
+ {
+ return $this->item->updated;
+ }
+
+ /**
+ * Lazy access to any field not promoted to a typed property, e.g.
+ * theme-specific custom fields. Returns `null` for unknown keys instead
+ * of raising — themes that probe optional fields stay quiet.
+ */
+ public function __get(string $name): mixed
+ {
+ return match ($name) {
+ 'id' => $this->item->id,
+ 'active' => $this->item->active,
+ 'created' => $this->item->created,
+ 'updated' => $this->item->updated,
+ default => $this->item->data->get($name),
+ };
+ }
+
+ public function __isset(string $name): bool
+ {
+ return match ($name) {
+ 'id', 'active', 'created', 'updated' => true,
+ default => $this->item->data->has($name),
+ };
+ }
+
+ private static function str(mixed $value): string
+ {
+ if (\is_string($value)) {
+ return $value;
+ }
+ if (\is_int($value) || \is_float($value)) {
+ return (string) $value;
+ }
+ return '';
+ }
+}
diff --git a/boot/Frontend/PageRepository.php b/boot/Frontend/PageRepository.php
new file mode 100644
index 0000000..b12df8b
--- /dev/null
+++ b/boot/Frontend/PageRepository.php
@@ -0,0 +1,262 @@
+slug`, `$page->template`, etc.
+ *
+ * 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
+{
+ public int $categoryId;
+
+ public function __construct(
+ private CategoryRepository $categories,
+ private ItemRepository $items,
+ string $categorySlug = 'pages',
+ ) {
+ $category = $this->categories->findBySlug($categorySlug);
+ if ($category === null || $category->id === null) {
+ throw new \RuntimeException(\sprintf(
+ 'Category with slug "%s" not found in the iManager database',
+ $categorySlug,
+ ));
+ }
+ $this->categoryId = $category->id;
+ }
+
+ public function find(int $id): ?Page
+ {
+ $item = $this->items->find($id);
+ return $item !== null && $item->categoryId === $this->categoryId
+ ? new Page($item)
+ : null;
+ }
+
+ public function findBySlug(string $slug): ?Page
+ {
+ $query = (new Query($this->categoryId))
+ ->where('slug', Operator::Eq, $slug)
+ ->limit(1);
+ foreach ($this->items->query($query) as $item) {
+ return new Page($item);
+ }
+ return null;
+ }
+
+ /**
+ * Finds the home page — by convention the page with id = 1 in Scriptor.
+ * Falls back to the lowest-position page if id 1 is missing.
+ */
+ public function findHome(): ?Page
+ {
+ $home = $this->find(1);
+ if ($home !== null) {
+ return $home;
+ }
+ $items = $this->items->findByCategory($this->categoryId, 0, 1);
+ return $items === [] ? null : new Page($items[0]);
+ }
+
+ /**
+ * @return list
+ */
+ public function findAll(): array
+ {
+ return self::wrap($this->items->findByCategory($this->categoryId));
+ }
+
+ /**
+ * Children of a parent page in the configured order.
+ *
+ * @return list
+ */
+ 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($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
+ {
+ 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,
+ );
+ }
+ }
+
+ /**
+ * @param list- $items
+ * @return list
+ */
+ private static function wrap(array $items): array
+ {
+ $out = [];
+ foreach ($items as $item) {
+ $out[] = new Page($item);
+ }
+ return $out;
+ }
+}
diff --git a/boot/Frontend/Sanitizer.php b/boot/Frontend/Sanitizer.php
new file mode 100644
index 0000000..bbff7ed
--- /dev/null
+++ b/boot/Frontend/Sanitizer.php
@@ -0,0 +1,66 @@
+sanitizer` that the new iManager Sanitizer
+ * doesn't ship — `templateName()`, `pageName()`, `url()` (legacy variants),
+ * `text()`. We forward the modern equivalents and add the legacy-only
+ * helpers without growing the iManager surface.
+ */
+final readonly class Sanitizer
+{
+ public function __construct(private ImanagerSanitizer $delegate) {}
+
+ public function templateName(string $value): string
+ {
+ $clean = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $value);
+ return $clean ?? '';
+ }
+
+ public function pageName(string $value): string
+ {
+ return $this->delegate->slug($value);
+ }
+
+ public function text(string $value, int $maxLength = 255): string
+ {
+ return $this->delegate->text($value, $maxLength);
+ }
+
+ public function multiline(string $value, int $maxLength = 65535): string
+ {
+ return $this->delegate->multiline($value, $maxLength);
+ }
+
+ public function slug(string $value, int $maxLength = 128): string
+ {
+ return $this->delegate->slug($value, $maxLength);
+ }
+
+ public function email(string $value): ?string
+ {
+ return $this->delegate->email($value);
+ }
+
+ public function url(string $value): string
+ {
+ return $this->delegate->url($value) ?? '';
+ }
+
+ public function entities(string $value): string
+ {
+ return $this->delegate->entities($value);
+ }
+
+ public function markdown(string $value): string
+ {
+ return $this->delegate->markdown($value);
+ }
+}
diff --git a/boot/Frontend/Site.php b/boot/Frontend/Site.php
new file mode 100644
index 0000000..c2dfbab
--- /dev/null
+++ b/boot/Frontend/Site.php
@@ -0,0 +1,334 @@
+ */
+ 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 = [];
+
+ /**
+ * Defaults for theme-config keys touched by the bundled "basic" theme's
+ * 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',
+ 'copyright_info' => '',
+ 'footer' => [
+ 'sub_heading' => '',
+ 'sub_paragraph' => '',
+ 'submit_button_label' => 'Subscribe',
+ 'middle_heading' => '',
+ 'middle_paragraph' => '',
+ ],
+ ];
+
+ /**
+ * @param array $config Scriptor config array
+ */
+ public function __construct(
+ protected Container $container,
+ array $config,
+ protected string $scriptorRoot,
+ ) {
+ $this->config = $config;
+ $this->sanitizer = new Sanitizer($container->get(ImanagerSanitizer::class));
+ $this->pages = new PageRepository(
+ $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
+ {
+ if ($this->urlSegments->isEmpty()) {
+ $this->page = $this->pages->findHome();
+ if ($this->page === null || ! $this->page->active()) {
+ $this->throw404();
+ }
+ return;
+ }
+
+ $slug = $this->urlSegments->last();
+ if ($slug === null) {
+ $this->page = $this->pages->findHome();
+ return;
+ }
+
+ $page = $this->pages->findBySlug($this->sanitizer->slug($slug));
+ if ($page === null || ! $page->active()) {
+ $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;
+ }
+
+ public function render(string $element): ?string
+ {
+ return match ($element) {
+ 'content' => $this->renderContent(),
+ 'navigation' => $this->renderNavigation(),
+ 'messages' => $this->messages,
+ // Theme-extension hooks. Theme subclasses override these by
+ // returning their own markup before delegating to parent.
+ 'hero',
+ 'mainNavItems',
+ 'footerNav',
+ 'socIcons',
+ 'archivesContent',
+ 'archiveNav',
+ 'pagination',
+ 'articleDate',
+ 'emptyCsrfFields' => '',
+ default => null,
+ };
+ }
+
+ /**
+ * Captures the output buffer started by `template.php` and returns it.
+ * Subclasses (BasicTheme) layer caching on top by overriding.
+ */
+ public function cache(): string
+ {
+ $output = ob_get_clean();
+ return $output === false ? '' : $output;
+ }
+
+ 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']`. 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;
+ }
+
+ /**
+ * 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'];
+ $notFound = $themeRoot . ($this->config['404page'] ?? '404') . '.php';
+ if (is_file($notFound)) {
+ $site = $this; // exposed to the included template
+ include $notFound;
+ } else {
+ echo '
404 — Not Found
';
+ }
+ exit;
+ }
+
+ protected function renderContent(): string
+ {
+ if ($this->page === null) {
+ return '';
+ }
+ $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; 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),
+ );
+ return $rendered;
+ }
+
+ protected function renderNavigation(): string
+ {
+ $top = $this->pages->findActiveByParent(0);
+ if ($top === []) {
+ return '';
+ }
+ $items = '';
+ foreach ($top as $entry) {
+ $url = $this->siteUrl . '/'
+ . ($entry->id() !== 1 ? $entry->slug . '/' : '');
+ $title = $entry->menu_title !== '' ? $entry->menu_title : $entry->name;
+ $items .= sprintf(
+ '%s',
+ htmlspecialchars($url, \ENT_QUOTES),
+ htmlspecialchars($title, \ENT_QUOTES),
+ );
+ }
+ return $items;
+ }
+
+ private static function detectSiteUrl(): string
+ {
+ $scheme = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ return $scheme . '://' . $host;
+ }
+}
diff --git a/boot/ImanagerBootstrap.php b/boot/ImanagerBootstrap.php
new file mode 100644
index 0000000..a0013c6
--- /dev/null
+++ b/boot/ImanagerBootstrap.php
@@ -0,0 +1,137 @@
+addShared(\PDO::class, static function () use ($databasePath, $schemaDir): \PDO {
+ $pdo = (new ConnectionFactory($databasePath))->create();
+ $loader = new MigrationLoader($schemaDir);
+ (new SchemaManager($pdo, $loader->load()))->migrate();
+ return $pdo;
+ });
+
+ $container->addShared(Sanitizer::class, static fn(): Sanitizer => new Sanitizer());
+
+ $container->addShared(SqliteStorage::class, static fn(): SqliteStorage
+ => new SqliteStorage($container->get(\PDO::class)));
+ $container->addShared(Storage::class, static fn(): Storage
+ => $container->get(SqliteStorage::class));
+
+ $container->addShared(CategoryRepository::class, static fn(): CategoryRepository
+ => $container->get(SqliteStorage::class)->categories());
+ $container->addShared(FieldRepository::class, static fn(): FieldRepository
+ => $container->get(SqliteStorage::class)->fields());
+ $container->addShared(ItemRepository::class, static fn(): ItemRepository
+ => $container->get(SqliteStorage::class)->items());
+ $container->addShared(FileRepository::class, static fn(): FileRepository
+ => $container->get(SqliteStorage::class)->files());
+
+ $container->addShared(FullTextSearch::class, static fn(): FullTextSearch
+ => new FullTextSearch($container->get(\PDO::class)));
+
+ $container->addShared(FilesystemCache::class, static fn(): FilesystemCache
+ => new FilesystemCache($cachePath));
+
+ $container->addShared(LocalFileStorage::class, static fn(): LocalFileStorage
+ => new LocalFileStorage($uploadsPath, $uploadsUrl));
+ $container->addShared(FileStorage::class, static fn(): FileStorage
+ => $container->get(LocalFileStorage::class));
+
+ $container->addShared(ImageProcessor::class, static fn(): ImageProcessor
+ => ImageProcessor::default());
+
+ $container->addShared(FieldTypeRegistry::class, static function () use ($container): FieldTypeRegistry {
+ $registry = new FieldTypeRegistry();
+ $sanitizer = $container->get(Sanitizer::class);
+ $registry->register(new TextFieldType($sanitizer));
+ $registry->register(new LongTextFieldType($sanitizer));
+ $registry->register(new EditorFieldType($sanitizer));
+ $registry->register(new SlugFieldType($sanitizer));
+ $registry->register(new PasswordFieldType($sanitizer));
+ $registry->register(new IntegerFieldType($sanitizer));
+ $registry->register(new DecimalFieldType($sanitizer));
+ $registry->register(new MoneyFieldType($sanitizer));
+ $registry->register(new CheckboxFieldType($sanitizer));
+ $registry->register(new DropdownFieldType($sanitizer));
+ $registry->register(new DatepickerFieldType($sanitizer));
+ $registry->register(new HiddenFieldType($sanitizer));
+ $registry->register(new ArrayListFieldType($sanitizer));
+ $registry->register(new FileuploadFieldType($sanitizer));
+ $registry->register(new ImageuploadFieldType($sanitizer));
+ $registry->register(new FilepickerFieldType($sanitizer));
+ return $registry;
+ });
+
+ return $container;
+ }
+
+ private function __construct()
+ {
+ }
+}
diff --git a/composer.json b/composer.json
index 4e52421..7671168 100644
--- a/composer.json
+++ b/composer.json
@@ -1,27 +1,47 @@
{
"name": "bigins/scriptor",
- "type": "library",
+ "type": "project",
"description": "Scriptor CMS/CMF",
- "keywords": [ "scriptor", "cms", "cmf", "flat file" ],
+ "keywords": ["scriptor", "cms", "cmf", "flat file"],
"license": "MIT",
"homepage": "https://scriptor-cms.info",
"authors": [
- {
- "name": "bigin",
- "email": "juri.ehret@gmail.com",
- "homepage": "https://ehret-studio.com",
- "role": "Developer"
- }
+ {
+ "name": "bigin",
+ "email": "juri.ehret@gmail.com",
+ "homepage": "https://ehret-studio.com",
+ "role": "Developer"
+ }
],
"minimum-stability": "dev",
+ "prefer-stable": true,
"require": {
- "php": ">=8.1.0",
+ "php": "^8.2",
"ext-gd": "*",
"ext-mbstring": "*",
- "ext-dom": "*",
- "ext-json": "*"
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-pdo_sqlite": "*",
+ "bigins/imanager": "2.0.x-dev"
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../imanager",
+ "options": {
+ "symlink": true
+ }
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Scriptor\\Boot\\": "boot/"
+ }
},
- "autoload": {
- "files": ["boot.php"]
- }
+ "config": {
+ "sort-packages": true,
+ "platform": {
+ "php": "8.2.0"
+ }
+ }
}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..87ea26d
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,1361 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "760ef69f60aebe89dde4b79c0d44e8ab",
+ "packages": [
+ {
+ "name": "bigins/imanager",
+ "version": "dev-main",
+ "dist": {
+ "type": "path",
+ "url": "../imanager",
+ "reference": "995d3bdefd78e620304504c4c84a4828468aae9c"
+ },
+ "require": {
+ "erusev/parsedown": "^1.7",
+ "ext-dom": "*",
+ "ext-gd": "*",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-pdo_sqlite": "*",
+ "ezyang/htmlpurifier": "^4.17",
+ "intervention/image": "^3.0",
+ "league/container": "^4.2",
+ "nikic/php-parser": "^5.0",
+ "php": "^8.2",
+ "psr/log": "^3.0",
+ "psr/simple-cache": "^3.0",
+ "symfony/console": "^6.4 || ^7.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.50",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^11.0",
+ "vimeo/psalm": "^5.20 || ^6.0"
+ },
+ "bin": [
+ "bin/imanager"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Imanager\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Imanager\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": [
+ "phpunit"
+ ],
+ "lint": [
+ "php-cs-fixer fix --dry-run --diff"
+ ],
+ "format": [
+ "php-cs-fixer fix"
+ ],
+ "stan": [
+ "phpstan analyse --memory-limit=512M"
+ ],
+ "psalm": [
+ "psalm"
+ ],
+ "ci": [
+ "@lint",
+ "@stan",
+ "@psalm",
+ "@test"
+ ]
+ },
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "bigin",
+ "email": "juri.ehret@gmail.com",
+ "homepage": "https://ehret-studio.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "iManager — embeddable SQLite-backed CMF (Content Management Framework) for PHP.",
+ "homepage": "https://scriptor-cms.info",
+ "keywords": [
+ "cmf",
+ "cms",
+ "flat-file",
+ "imanager",
+ "sqlite"
+ ],
+ "transport-options": {
+ "symlink": true,
+ "relative": true
+ }
+ },
+ {
+ "name": "erusev/parsedown",
+ "version": "1.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/erusev/parsedown.git",
+ "reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/erusev/parsedown/zipball/96baaad00f71ba04d76e45b4620f54d3beabd6f7",
+ "reference": "96baaad00f71ba04d76e45b4620f54d3beabd6f7",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5|^8.5|^9.6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Parsedown": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Emanuil Rusev",
+ "email": "hello@erusev.com",
+ "homepage": "http://erusev.com"
+ }
+ ],
+ "description": "Parser for Markdown.",
+ "homepage": "http://parsedown.org",
+ "keywords": [
+ "markdown",
+ "parser"
+ ],
+ "support": {
+ "issues": "https://github.com/erusev/parsedown/issues",
+ "source": "https://github.com/erusev/parsedown/tree/1.8.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/erusev",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-16T11:41:01+00:00"
+ },
+ {
+ "name": "ezyang/htmlpurifier",
+ "version": "v4.19.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ezyang/htmlpurifier.git",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
+ },
+ "require-dev": {
+ "cerdic/css-tidy": "^1.7 || ^2.0",
+ "simpletest/simpletest": "dev-master"
+ },
+ "suggest": {
+ "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
+ "ext-bcmath": "Used for unit conversion and imagecrash protection",
+ "ext-iconv": "Converts text to and from non-UTF-8 encodings",
+ "ext-tidy": "Used for pretty-printing HTML"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/HTMLPurifier.composer.php"
+ ],
+ "psr-0": {
+ "HTMLPurifier": "library/"
+ },
+ "exclude-from-classmap": [
+ "/library/HTMLPurifier/Language/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Edward Z. Yang",
+ "email": "admin@htmlpurifier.org",
+ "homepage": "http://ezyang.com"
+ }
+ ],
+ "description": "Standards compliant HTML filter written in PHP",
+ "homepage": "http://htmlpurifier.org/",
+ "keywords": [
+ "html"
+ ],
+ "support": {
+ "issues": "https://github.com/ezyang/htmlpurifier/issues",
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
+ },
+ "time": "2025-10-17T16:34:55+00:00"
+ },
+ {
+ "name": "intervention/gif",
+ "version": "4.2.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Intervention/gif.git",
+ "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c",
+ "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
+ "slevomat/coding-standard": "~8.0",
+ "squizlabs/php_codesniffer": "^3.8"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Intervention\\Gif\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Oliver Vogel",
+ "email": "oliver@intervention.io",
+ "homepage": "https://intervention.io/"
+ }
+ ],
+ "description": "Native PHP GIF Encoder/Decoder",
+ "homepage": "https://github.com/intervention/gif",
+ "keywords": [
+ "animation",
+ "gd",
+ "gif",
+ "image"
+ ],
+ "support": {
+ "issues": "https://github.com/Intervention/gif/issues",
+ "source": "https://github.com/Intervention/gif/tree/4.2.4"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/interventionio",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/Intervention",
+ "type": "github"
+ },
+ {
+ "url": "https://ko-fi.com/interventionphp",
+ "type": "ko_fi"
+ }
+ ],
+ "time": "2026-01-04T09:27:23+00:00"
+ },
+ {
+ "name": "intervention/image",
+ "version": "3.11.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Intervention/image.git",
+ "reference": "cf04c8dd245697f701057c13d4bfe140d584e738"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Intervention/image/zipball/cf04c8dd245697f701057c13d4bfe140d584e738",
+ "reference": "cf04c8dd245697f701057c13d4bfe140d584e738",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "intervention/gif": "^4.2",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.6",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
+ "slevomat/coding-standard": "~8.0",
+ "squizlabs/php_codesniffer": "^4"
+ },
+ "suggest": {
+ "ext-exif": "Recommended to be able to read EXIF data properly."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Intervention\\Image\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Oliver Vogel",
+ "email": "oliver@intervention.io",
+ "homepage": "https://intervention.io"
+ }
+ ],
+ "description": "PHP Image Processing",
+ "homepage": "https://image.intervention.io",
+ "keywords": [
+ "gd",
+ "image",
+ "imagick",
+ "resize",
+ "thumbnail",
+ "watermark"
+ ],
+ "support": {
+ "issues": "https://github.com/Intervention/image/issues",
+ "source": "https://github.com/Intervention/image/tree/3.11.8"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/interventionio",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/Intervention",
+ "type": "github"
+ },
+ {
+ "url": "https://ko-fi.com/interventionphp",
+ "type": "ko_fi"
+ }
+ ],
+ "time": "2026-05-01T08:20:10+00:00"
+ },
+ {
+ "name": "league/container",
+ "version": "4.2.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/container.git",
+ "reference": "d3cebb0ff4685ff61c749e54b27db49319e2ec00"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/container/zipball/d3cebb0ff4685ff61c749e54b27db49319e2ec00",
+ "reference": "d3cebb0ff4685ff61c749e54b27db49319e2ec00",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "psr/container": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "psr/container-implementation": "^1.0"
+ },
+ "replace": {
+ "orno/di": "~2.0"
+ },
+ "require-dev": {
+ "nette/php-generator": "^3.4",
+ "nikic/php-parser": "^4.10",
+ "phpstan/phpstan": "^0.12.47",
+ "phpunit/phpunit": "^8.5.17",
+ "roave/security-advisories": "dev-latest",
+ "scrutinizer/ocular": "^1.8",
+ "squizlabs/php_codesniffer": "^3.6"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev",
+ "dev-3.x": "3.x-dev",
+ "dev-4.x": "4.x-dev",
+ "dev-master": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Container\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Phil Bennett",
+ "email": "mail@philbennett.co.uk",
+ "role": "Developer"
+ }
+ ],
+ "description": "A fast and intuitive dependency injection container.",
+ "homepage": "https://github.com/thephpleague/container",
+ "keywords": [
+ "container",
+ "dependency",
+ "di",
+ "injection",
+ "league",
+ "provider",
+ "service"
+ ],
+ "support": {
+ "issues": "https://github.com/thephpleague/container/issues",
+ "source": "https://github.com/thephpleague/container/tree/4.2.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/philipobenito",
+ "type": "github"
+ }
+ ],
+ "time": "2025-05-20T12:55:37+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v7.4.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/d7d2b64a45a89d607865927b176fa51c33ddbb58",
+ "reference": "d7d2b64a45a89d607865927b176fa51c33ddbb58",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.2|^8.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.4.9"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-22T15:21:55+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-26T13:13:48+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T17:25:58+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-15T11:30:57+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.4.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "114ac57257d75df748eda23dd003878080b8e688"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
+ "reference": "114ac57257d75df748eda23dd003878080b8e688",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.33",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.4.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-24T13:12:05+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": {
+ "bigins/imanager": 20
+ },
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.2",
+ "ext-gd": "*",
+ "ext-mbstring": "*",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-pdo_sqlite": "*"
+ },
+ "platform-dev": [],
+ "platform-overrides": {
+ "php": "8.2.0"
+ },
+ "plugin-api-version": "2.3.0"
+}
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 74553a8..b29e73b 100644
--- a/index.php
+++ b/index.php
@@ -1,13 +1,27 @@
execute();
+use Scriptor\Boot\App;
+use Scriptor\Boot\Frontend\Site;
+
+require_once __DIR__ . '/boot.php';
+
+/** @var array $config */
+$themeDir = __DIR__ . '/site/themes/' . $config['theme_path'];
+$ext = $themeDir . '_ext.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 __DIR__."/site/themes/$config[theme_path]template.php";
+
+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/resources/chunks/_footer.php b/site/themes/basic/resources/chunks/_footer.php
index 9ef59e1..3b7efed 100644
--- a/site/themes/basic/resources/chunks/_footer.php
+++ b/site/themes/basic/resources/chunks/_footer.php
@@ -34,7 +34,7 @@
Copyright ©
getTCP('copyright_info') ?> | Scriptor
-
+ version, ENT_QUOTES); ?>
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();
diff --git a/test-bootstrap.php b/test-bootstrap.php
new file mode 100644
index 0000000..48c7515
--- /dev/null
+++ b/test-bootstrap.php
@@ -0,0 +1,63 @@
+get(CategoryRepository::class)->findAll();
+$fields = $container->get(FieldRepository::class);
+$items = $container->get(ItemRepository::class);
+$files = $container->get(FileRepository::class);
+
+$payload = [
+ 'imanager_version' => \Imanager\Imanager::VERSION,
+ 'services' => [
+ 'CategoryRepository' => $container->get(CategoryRepository::class)::class,
+ 'FieldRepository' => $fields::class,
+ 'ItemRepository' => $items::class,
+ 'FileRepository' => $files::class,
+ 'FieldTypeRegistry' => $container->get(FieldTypeRegistry::class)::class,
+ 'FullTextSearch' => $container->get(FullTextSearch::class)::class,
+ 'FilesystemCache' => $container->get(FilesystemCache::class)::class,
+ 'FileStorage' => $container->get(FileStorage::class)::class,
+ 'ImageProcessor' => $container->get(ImageProcessor::class)::class,
+ ],
+ 'categories' => array_map(static fn($c) => [
+ 'id' => $c->id,
+ 'name' => $c->name,
+ 'slug' => $c->slug,
+ 'position' => $c->position,
+ 'fields' => array_map(
+ static fn($f) => ['name' => $f->name, 'type' => $f->type],
+ $fields->findByCategory($c->id ?? 0),
+ ),
+ 'item_count' => count($items->findByCategory($c->id ?? 0)),
+ ], $categories),
+];
+
+header('Content-Type: application/json; charset=utf-8');
+echo json_encode($payload, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);