From fc35331eca472ddc3b11b7e1da2293c492b412eb Mon Sep 17 00:00:00 2001 From: Juri Ehret Date: Sun, 3 May 2026 11:16:22 +0200 Subject: [PATCH] Phase 14b-1: Frontend skeleton on iManager 2.0 container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public site renders from the migrated SQLite DB through a brand-new, container-driven render path. Default-theme files (default.php, template.php, _head/_header/_footer.php) keep working as adapter templates — only one $site->version substitution in _footer.php. New PSR-4 namespace `Scriptor\Boot\Frontend\`: - Page — readonly DTO around `Imanager\Domain\Item`; surfaces slug/template/parent/content/menu_title/images plus any other field via __get against the FieldValueBag. - PageRepository — wrapper over CategoryRepository + ItemRepository for the Pages category; find/findBySlug/findHome/ findByParent/findActiveByParent built on the iManager Query AST. - Sanitizer — facade over `Imanager\Validation\Sanitizer` adding the legacy-only `templateName()` and `pageName()` helpers themes still call. - Site — minimal renderer: resolves the requested slug from `$_SERVER['REQUEST_URI']`, exposes the legacy `$site` surface (siteUrl, themeUrl, config, version, page, sanitizer, render(), cache(), throw404(), getTCP() stub with sane defaults). The render() switch returns '' for theme-overridable hooks (hero, mainNavItems, footerNav, …) so the default render path doesn't crash before BasicTheme reattaches in 14b-2. boot.php now loads vendor/autoload + container + the legacy scriptor-config.php (with IS_IM/IM_DATAPATH defines so the legacy file header doesn't die()). The 1.x `imanager/` library and `editor/core/scriptor.php` stay disabled — same Imanager\ namespace clash as 14a. index.php constructs Frontend\Site directly and includes the theme template. The basic-theme `_ext.php` (BasicRouter / SuperCache / BasicTheme) is intentionally NOT loaded here — comes back in 14b-2. Manual smoke (PHP built-in server): GET / → 200, 5.3 KB,

Scriptor's Demo Page

GET /scriptors-demo-page → 200, identical (home slug) GET /this-does-not-exist → 404, sane fallback Markdown content rendered via Sanitizer::markdown() (Parsedown + HTMLPurifier when enabled). --- boot.php | 38 ++-- boot/Frontend/Page.php | 102 +++++++++ boot/Frontend/PageRepository.php | 118 ++++++++++ boot/Frontend/Sanitizer.php | 66 ++++++ boot/Frontend/Site.php | 213 ++++++++++++++++++ index.php | 25 +- .../themes/basic/resources/chunks/_footer.php | 2 +- 7 files changed, 538 insertions(+), 26 deletions(-) create mode 100644 boot/Frontend/Page.php create mode 100644 boot/Frontend/PageRepository.php create mode 100644 boot/Frontend/Sanitizer.php create mode 100644 boot/Frontend/Site.php diff --git a/boot.php b/boot.php index 0dbcb23..faef454 100644 --- a/boot.php +++ b/boot.php @@ -10,18 +10,30 @@ App::set(ImanagerBootstrap::create(__DIR__)); /* - * Phase 14a status: only the iManager 2.0 container is wired up. + * Phase 14b-1 boot path: + * - vendor/autoload.php (Composer + iManager 2.0) + * - iManager container set on App::container() + * - $config loaded from data/settings/scriptor-config.php (legacy file + * format kept verbatim — themes still read it from $site->config) * - * The legacy 1.x bootstrap below is intentionally disabled — the embedded - * Scriptor/imanager/ library shares the Imanager\ namespace with the new - * vendor/bigins/imanager package, so loading both at once collides. - * - * Sub-phases re-enable functionality piece by piece: - * - 14b: Site / Page / Pages frontend on the new container - * - 14c: Editor modules (auth, pages, users, settings, install, profile) - * - 14f: delete Scriptor/imanager/ entirely - * - * Until 14b lands, the public site and editor are non-functional on this - * branch. Use /test-bootstrap.php to verify the container, or check out - * `master` for the working 1.x build. + * 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 */ + +// 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/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..8cc8d2f --- /dev/null +++ b/boot/Frontend/PageRepository.php @@ -0,0 +1,118 @@ +slug`, `$page->template`, etc. + * + * Phase 14b-1 only needs lookup + parent/children traversal. Sorting, + * pagination and richer filters land with BasicTheme in 14b-2. + */ +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)); + } + + /** + * @return list + */ + public function findByParent(int $parentId): array + { + $query = (new Query($this->categoryId)) + ->where('parent', Operator::Eq, $parentId) + ->orderBy('position'); + return self::wrap($this->items->query($query)); + } + + /** + * @return list + */ + public function findActiveByParent(int $parentId): array + { + $pages = []; + foreach ($this->findByParent($parentId) as $page) { + if ($page->active()) { + $pages[] = $page; + } + } + return $pages; + } + + /** + * @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..b4394dc --- /dev/null +++ b/boot/Frontend/Site.php @@ -0,0 +1,213 @@ + */ + public array $config; + + /** @var array Theme-config payload exposed via getTCP(). */ + protected array $themeConfig = []; + + public Sanitizer $sanitizer; + public PageRepository $pages; + + /** + * Defaults for theme-config keys touched by the bundled "basic" theme's + * default render path (header, footer, offcanvas). Lets the page render + * end-to-end even before BasicTheme reattaches in 14b-2 with real values. + */ + 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->siteUrl = self::detectSiteUrl(); + $this->themeUrl = $this->siteUrl . '/site/themes/' . $this->config['theme_path']; + $this->urlSegments = UrlSegments::fromPath($_SERVER['REQUEST_URI'] ?? '/'); + } + + /** + * Resolve the requested page from the URL or fall back to home/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; + } + $this->page = $page; + } + + public function render(string $element): ?string + { + return match ($element) { + 'content' => $this->renderContent(), + 'navigation' => $this->renderNavigation(), + 'messages' => $this->messages, + // Theme-extension hooks. Themes (BasicTheme in 14b-2) override + // these by subclassing Site and short-circuiting the parent call. + 'hero', + 'mainNavItems', + 'footerNav', + 'socIcons', + 'archivesContent', + 'archiveNav', + 'pagination', + 'articleDate', + 'emptyCsrfFields' => '', + default => null, + }; + } + + /** + * Captures the output buffer started by template.php and hands it back. + * Filesystem caching is wired up by BasicTheme in 14b-2. + */ + public function cache(): string + { + $output = ob_get_clean(); + return $output === false ? '' : $output; + } + + public function pages(): PageRepository + { + return $this->pages; + } + + /** + * Theme-config-property accessor — `$site->getTCP('site_name')`, + * `$site->getTCP('footer')['sub_heading']`. Returns the live theme-config + * value when set (BasicTheme in 14b-2), otherwise a sane default so the + * default render path doesn't crash on missing entries. + */ + public function getTCP(string $key): mixed + { + return $this->themeConfig[$key] ?? self::TCP_DEFAULTS[$key] ?? null; + } + + public function throw404(): void + { + 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; the markdown call passes it through Parsedown + // either way and lets safe-mode strip on entry. + $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/index.php b/index.php index 74553a8..3db267e 100644 --- a/index.php +++ b/index.php @@ -1,13 +1,14 @@ execute(); -} -include __DIR__."/site/themes/$config[theme_path]template.php"; + +declare(strict_types=1); + +use Scriptor\Boot\App; +use Scriptor\Boot\Frontend\Site; + +require_once __DIR__ . '/boot.php'; + +/** @var array $config */ +$site = new Site(App::container(), $config, __DIR__); +$site->execute(); + +include __DIR__ . '/site/themes/' . $config['theme_path'] . 'template.php'; diff --git a/site/themes/basic/resources/chunks/_footer.php b/site/themes/basic/resources/chunks/_footer.php index 9ef59e1..3b7efed 100644 --- a/site/themes/basic/resources/chunks/_footer.php +++ b/site/themes/basic/resources/chunks/_footer.php @@ -34,7 +34,7 @@

    Copyright © getTCP('copyright_info') ?> | Scriptor -

    + version, ENT_QUOTES); ?>