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'
    - [[ALT]] - [[INFO_ROW]] + {{ALT}} + {{INFO_ROW}}
    EOD, -'article_row' => -<< <<<'EOD' EOD, -'article_date' => -<<