From e629e1bbf2010e51f437ce375bd3a579d320213d Mon Sep 17 00:00:00 2001 From: Giuseppe Criscione <18699708+giuscris@users.noreply.github.com> Date: Sun, 4 Dec 2022 20:46:46 +0100 Subject: [PATCH] Rewrite `Page`, `Site` and related classes --- formwork/src/Controllers/PageController.php | 10 +- formwork/src/Files/File.php | 33 +- .../Files/{Files.php => FileCollection.php} | 2 +- formwork/src/Formwork.php | 6 +- formwork/src/Languages/Language.php | 60 +++ formwork/src/Languages/LanguageCollection.php | 18 + formwork/src/Languages/Languages.php | 78 ++- formwork/src/Pages/ContentFile.php | 70 +++ formwork/src/Pages/Page.php | 500 +++++++++++------- formwork/src/Pages/PageCollection.php | 13 +- formwork/src/Pages/Site.php | 291 +++++++--- formwork/src/Pages/Traits/PageData.php | 25 +- formwork/src/Pages/Traits/PageStatus.php | 12 +- formwork/src/Pages/Traits/PageTraversal.php | 39 +- formwork/src/Pages/Traits/PageUid.php | 10 +- formwork/src/Pages/Traits/PageUri.php | 17 +- .../Panel/Controllers/DashboardController.php | 6 +- .../Panel/Controllers/OptionsController.php | 4 +- .../src/Panel/Controllers/PagesController.php | 85 ++- formwork/src/Panel/Panel.php | 8 +- formwork/src/Panel/Uploader.php | 22 +- formwork/src/Panel/Users/Permissions.php | 2 +- formwork/src/Panel/Users/User.php | 2 +- .../Users/{Users.php => UserCollection.php} | 2 +- formwork/src/Router/Router.php | 4 +- panel/schemes/user.yml | 2 +- panel/views/pages/editor.php | 6 +- panel/views/pages/list.php | 6 +- site/config/schemes/site.yml | 6 +- site/schemes/default.yml | 25 + site/templates/default.php | 7 + 31 files changed, 928 insertions(+), 443 deletions(-) rename formwork/src/Files/{Files.php => FileCollection.php} (95%) create mode 100644 formwork/src/Languages/Language.php create mode 100644 formwork/src/Languages/LanguageCollection.php create mode 100644 formwork/src/Pages/ContentFile.php rename formwork/src/Panel/Users/{Users.php => UserCollection.php} (96%) create mode 100644 site/schemes/default.yml create mode 100644 site/templates/default.php diff --git a/formwork/src/Controllers/PageController.php b/formwork/src/Controllers/PageController.php index 004575b4..77f2fb1c 100644 --- a/formwork/src/Controllers/PageController.php +++ b/formwork/src/Controllers/PageController.php @@ -18,13 +18,13 @@ class PageController extends AbstractController $route = $params->get('page', $formwork->config()->get('pages.index')); - if ($resolvedAlias = $formwork->site()->resolveAlias($route)) { + if ($resolvedAlias = $formwork->site()->resolveRouteAlias($route)) { $route = $resolvedAlias; } if ($page = $formwork->site()->findPage($route)) { - if ($page->canonical() !== null) { - $canonical = $page->canonical(); + if ($page->canonicalRoute() !== null) { + $canonical = $page->canonicalRoute(); if ($params->get('page', '/') !== $canonical) { $route = $formwork->router()->rewrite(['page' => $canonical]); @@ -41,7 +41,7 @@ class PageController extends AbstractController || (!$page->isPublished() && !$formwork->site()->modifiedSince($page->unpublishDate()->toTimestamp()))) { // Clear cache if the site was not modified since the page has been published or unpublished $formwork->cache()->clear(); - FileSystem::touch($formwork->config()->get('content.path')); + FileSystem::touch($formwork->site()->path()); } } @@ -80,7 +80,7 @@ class PageController extends AbstractController $cache = $formwork->cache(); - $cacheKey = $page->route(); + $cacheKey = $page->uri(includeLanguage: true); if ($config->get('cache.enabled') && $cache->has($cacheKey)) { // Validate cached response diff --git a/formwork/src/Files/File.php b/formwork/src/Files/File.php index 1718265b..f5c289b1 100644 --- a/formwork/src/Files/File.php +++ b/formwork/src/Files/File.php @@ -2,12 +2,9 @@ namespace Formwork\Files; -use Formwork\Formwork; use Formwork\Utils\FileSystem; -use Formwork\Utils\HTTPRequest; use Formwork\Utils\MimeType; use Formwork\Utils\Str; -use Formwork\Utils\Uri; class File { @@ -26,11 +23,6 @@ class File */ protected string $extension; - /** - * File uri - */ - protected string $uri; - /** * File MIME type */ @@ -46,6 +38,11 @@ class File */ protected string $size; + /** + * File last modified time + */ + protected int $lastModifiedTime; + /** * File hash */ @@ -60,7 +57,6 @@ class File $this->name = basename($path); $this->extension = FileSystem::extension($path); $this->mimeType = FileSystem::mimeType($path); - $this->uri = Uri::resolveRelative($this->name, HTTPRequest::root() . ltrim(Formwork::instance()->request(), '/')); $this->size = FileSystem::formatSize(FileSystem::fileSize($path)); } @@ -93,14 +89,6 @@ class File return $this->extension; } - /** - * Get file URI - */ - public function uri(): string - { - return $this->uri; - } - /** * Get file MIME type */ @@ -155,6 +143,17 @@ class File return $this->size; } + /** + * Get file last modified time + */ + public function lastModifiedTime(): int + { + if (isset($this->lastModifiedTime)) { + return $this->lastModifiedTime; + } + return FileSystem::lastModifiedTime($this->path); + } + /** * Get file hash */ diff --git a/formwork/src/Files/Files.php b/formwork/src/Files/FileCollection.php similarity index 95% rename from formwork/src/Files/Files.php rename to formwork/src/Files/FileCollection.php index b743eb7c..27b20671 100644 --- a/formwork/src/Files/Files.php +++ b/formwork/src/Files/FileCollection.php @@ -5,7 +5,7 @@ namespace Formwork\Files; use Formwork\Data\AbstractCollection; use Formwork\Utils\FileSystem; -class Files extends AbstractCollection +class FileCollection extends AbstractCollection { protected bool $associative = true; diff --git a/formwork/src/Formwork.php b/formwork/src/Formwork.php index 38e72b1a..c35767a4 100644 --- a/formwork/src/Formwork.php +++ b/formwork/src/Formwork.php @@ -255,8 +255,10 @@ final class Formwork protected function loadSite(): void { $config = YAML::parseFile(CONFIG_PATH . 'site.yml'); - $this->site = new Site($config); - $this->site->setLanguages($this->languages); + $this->site = Site::fromPath( + $this->config()->get('content.path'), + ['languages' => $this->languages] + $config + ); } /** diff --git a/formwork/src/Languages/Language.php b/formwork/src/Languages/Language.php new file mode 100644 index 00000000..0cc9096b --- /dev/null +++ b/formwork/src/Languages/Language.php @@ -0,0 +1,60 @@ +code = $code; + + if (LanguageCodes::hasCode($code)) { + $this->name = LanguageCodes::codeToName($code); + $this->nativeName = LanguageCodes::codeToNativeName($code); + } + } + + public function __toString(): string + { + return $this->code; + } + + /** + * Get language code + */ + public function code(): string + { + return $this->code; + } + + /** + * Get language name + */ + public function name(): ?string + { + return $this->name; + } + + /** + * Get language native name + */ + public function nativeName(): ?string + { + return $this->nativeName; + } +} diff --git a/formwork/src/Languages/LanguageCollection.php b/formwork/src/Languages/LanguageCollection.php new file mode 100644 index 00000000..8377b0ba --- /dev/null +++ b/formwork/src/Languages/LanguageCollection.php @@ -0,0 +1,18 @@ + [$code, new Language($code)]))); + } +} diff --git a/formwork/src/Languages/Languages.php b/formwork/src/Languages/Languages.php index 576e96f9..bbf1d4b0 100644 --- a/formwork/src/Languages/Languages.php +++ b/formwork/src/Languages/Languages.php @@ -10,41 +10,48 @@ class Languages /** * Array containing available languages */ - protected array $available = []; + protected LanguageCollection $available; /** * Default language code */ - protected ?string $default = null; + protected ?Language $default = null; /** * Current language code */ - protected ?string $current = null; + protected ?Language $current = null; /** * Requested language code */ - protected ?string $requested = null; + protected ?Language $requested = null; /** * Preferred language code */ - protected ?string $preferred = null; + protected ?Language $preferred = null; /** * Create a new Languages instance */ - public function __construct() + public function __construct(array $options = []) { - $this->available = (array) Formwork::instance()->config()->get('languages.available'); - $this->current = $this->default = Formwork::instance()->config()->get('languages.default', $this->available[0] ?? null); + $this->available = new LanguageCollection($options['available'] ?? []); + + $this->default = $this->resolveLanguage($options['default'] ?? null); + + $this->current = $this->resolveLanguage($options['current'] ?? $this->default); + + $this->requested = $this->resolveLanguage($options['requested'] ?? null); + + $this->preferred = $this->resolveLanguage($options['preferred'] ?? null); } /** * Get available languages */ - public function available(): array + public function available(): LanguageCollection { return $this->available; } @@ -52,7 +59,7 @@ class Languages /** * Get default language code */ - public function default(): ?string + public function default(): ?Language { return $this->default; } @@ -60,7 +67,7 @@ class Languages /** * Get current language code */ - public function current(): ?string + public function current(): ?Language { return $this->current; } @@ -68,7 +75,7 @@ class Languages /** * Get requested language code */ - public function requested(): ?string + public function requested(): ?Language { return $this->requested; } @@ -76,7 +83,7 @@ class Languages /** * Get preferred language code */ - public function preferred(): ?string + public function preferred(): ?Language { return $this->preferred; } @@ -94,21 +101,52 @@ class Languages */ public static function fromRequest(string $request): self { - $languages = new static(); + $config = Formwork::instance()->config(); - if (preg_match('~^/(' . implode('|', $languages->available) . ')/~i', $request, $matches)) { - $languages->requested = $languages->current = $matches[1]; + /** + * @var array + */ + $available = (array) $config->get('languages.available'); + + if (preg_match('~^/(' . implode('|', $available) . ')/~i', $request, $matches)) { + $requested = $current = $matches[1]; } - if (Formwork::instance()->config()->get('languages.http_preferred')) { + /** + * @var bool + */ + if ($config->get('languages.http_preferred')) { foreach (array_keys(HTTPNegotiation::language()) as $code) { - if (in_array($code, $languages->available, true)) { - $languages->preferred = $code; + if (in_array($code, $available, true)) { + $preferred = $available[$code]; break; } } } - return $languages; + return new static([ + 'available' => $available, + 'default' => $config->get('languages.default', $available[0] ?? null), + 'current' => $current ?? null, + 'requested' => $requested ?? null, + 'preferred' => $preferred ?? null + ]); + } + + /** + * Get the proper `Language` instance + */ + protected function resolveLanguage(Language|string|null $language): ?Language + { + switch (true) { + case $language instanceof Language: + return $language; + + case is_string($language): + return $this->available->get($language, null); + + default: + return null; + } } } diff --git a/formwork/src/Pages/ContentFile.php b/formwork/src/Pages/ContentFile.php new file mode 100644 index 00000000..866242af --- /dev/null +++ b/formwork/src/Pages/ContentFile.php @@ -0,0 +1,70 @@ +load(); + } + + /** + * Return whether the content file is empty + */ + public function isEmpty() + { + return $this->frontmatter === []; + } + + /** + * Get the data from the YAML frontmatter + */ + public function frontmatter(): array + { + return $this->frontmatter; + } + + /** + * Get the content + */ + public function content(): string + { + return $this->content; + } + + /** + * Load data from the content file + */ + protected function load() + { + $contents = FileSystem::read($this->path); + + if (!preg_match('/(?:\s|^)-{3}\s*(.+?)\s*-{3}\s*(.*?)\s*$/s', $contents, $matches)) { + throw new UnexpectedValueException('Invalid page format'); + } + + [, $frontmatter, $content] = $matches; + + $this->frontmatter = YAML::parse($frontmatter); + + $this->content = str_replace("\r\n", "\n", $content); + } +} diff --git a/formwork/src/Pages/Page.php b/formwork/src/Pages/Page.php index 376cee2a..8f2f4c19 100644 --- a/formwork/src/Pages/Page.php +++ b/formwork/src/Pages/Page.php @@ -4,8 +4,10 @@ namespace Formwork\Pages; use Formwork\Data\Contracts\Arrayable; use Formwork\Fields\FieldCollection; -use Formwork\Files\Files; +use Formwork\Files\FileCollection; use Formwork\Formwork; +use Formwork\Languages\Language; +use Formwork\Languages\Languages; use Formwork\Metadata\MetadataCollection; use Formwork\Pages\Templates\Template; use Formwork\Pages\Traits\PageData; @@ -13,14 +15,14 @@ use Formwork\Pages\Traits\PageStatus; use Formwork\Pages\Traits\PageTraversal; use Formwork\Pages\Traits\PageUid; use Formwork\Pages\Traits\PageUri; -use Formwork\Parsers\YAML; use Formwork\Schemes\Scheme; use Formwork\Utils\Arr; -use Formwork\Utils\Date; use Formwork\Utils\FileSystem; use Formwork\Utils\Path; use Formwork\Utils\Str; use Formwork\Utils\Uri; +use InvalidArgumentException; +use ReflectionClass; use RuntimeException; class Page implements Arrayable @@ -37,114 +39,79 @@ class Page implements Arrayable public const NUM_REGEX = '/^(\d+)-/'; /** - * Page 'published' status + * Page `published` status */ public const PAGE_STATUS_PUBLISHED = 'published'; /** - * Page 'not published' status + * Page `not published` status */ public const PAGE_STATUS_NOT_PUBLISHED = 'not-published'; /** - * Page 'not routable' status + * Page `not routable` status */ public const PAGE_STATUS_NOT_ROUTABLE = 'not-routable'; /** - * Page path relative to content path + * Page path */ - protected string $relativePath; + protected ?string $path = null; /** - * Page unique identifier + * Page path relative to the content path */ - protected string $uid; + protected ?string $relativePath = null; + + /** + * Page content file + */ + protected ?ContentFile $contentFile = null; /** * Page route */ - protected string $route; + protected ?string $route = null; /** - * Page data + * Page canonical route */ - protected array $data = []; - - /** - * Page uri - */ - protected string $uri; - - /** - * Page absolute uri - */ - protected string $absoluteUri; - - /** - * Page last modified time - */ - protected int $lastModifiedTime; - - /** - * Page modified date - */ - protected string $timestamp; - - /** - * Page name (the name of the containing directory) - */ - protected string $name; + protected ?string $canonicalRoute = null; /** * Page slug */ - protected string $slug; + protected ?string $slug = null; /** - * Page language + * Page num used to order pages */ - protected ?string $language; + protected ?int $num = null; /** * Available page languages */ - protected array $availableLanguages; + protected Languages $languages; /** - * Page filename + * Current page language */ - protected string $filename; - - /** - * Page template - */ - protected Template $template; + protected ?Language $language = null; /** * Page scheme */ protected Scheme $scheme; - /** - * Page files - */ - protected Files $files; - - /** - * Page frontmatter - */ - protected array $frontmatter; - /** * Page fields */ protected FieldCollection $fields; /** - * Page canonical URI + * Page template */ - protected ?string $canonical; + protected Template $template; /** * Page metadata @@ -152,39 +119,37 @@ class Page implements Arrayable protected MetadataCollection $metadata; /** - * Page response status + * Page files + */ + protected FileCollection $files; + + /** + * Page HTTP response status */ protected int $responseStatus; /** - * Page num (used to order pages) + * Page loading state */ - protected ?int $num; + protected bool $loaded = false; - /** - * Create a new Page instance - */ - public function __construct(string $path) + public function __construct(array $data = []) { - $this->path = FileSystem::normalizePath($path . DS); - - $this->relativePath = Str::prepend(Path::makeRelative($this->path, Formwork::instance()->site()->path(), DS), DS); - - $this->route = Uri::normalize(preg_replace('~[/\\\\](\d+-)~', '/', $this->relativePath)); - - $this->name = basename($this->relativePath); - - $this->slug = basename($this->route); - - $this->language = null; - - $this->availableLanguages = []; + $this->setMultiple($data); $this->loadFiles(); - if (!$this->isEmpty()) { - $this->loadContents(); + if ($this->hasContentFile() && !$this->contentFile->isEmpty()) { + $this->data = array_merge( + $this->data, + $this->contentFile->frontmatter(), + ['content' => $this->contentFile->content()] + ); } + + $this->fields->validate($this->data); + + $this->loaded = true; } public function __toString(): string @@ -192,6 +157,14 @@ class Page implements Arrayable return $this->title() ?? $this->slug(); } + /** + * Create page from the given path + */ + public static function fromPath(string $path, array $data = []): static + { + return new static(['path' => $path] + $data); + } + /** * Return page default data */ @@ -204,21 +177,28 @@ class Page implements Arrayable 'searchable' => true, 'cacheable' => true, 'orderable' => true, - 'canonical' => null, + 'canonicalRoute' => null, 'headers' => [], 'responseStatus' => 200, - 'metadata' => [] + 'metadata' => [], + 'content' => '' ]; // Merge with scheme default field values $defaults = array_merge($defaults, Arr::reject($this->fields()->pluck('default'), fn ($value) => $value === null)); - // If the page hasn't a num, by default it won't be listed + // If the page doesn't have a route, by default it won't be routable nor cacheable + if ($this->route() === null) { + $defaults['routable'] = false; + $defaults['cacheable'] = false; + } + + // If the page doesn't have a num, by default it won't be listed if ($this->num() === null) { $defaults['listed'] = false; } - // If the page hasn't a num or numbering is 'date', by default it won't be orderable + // If the page doesn't have a num or numbering is `date`, by default it won't be orderable if ($this->num() === null || $this->scheme->get('num') === 'date') { $defaults['orderable'] = false; } @@ -226,20 +206,113 @@ class Page implements Arrayable return $defaults; } + /** + * Get page path + */ + public function path(): ?string + { + return $this->path; + } + + /** + * Get page relative path + */ + public function relativePath(): ?string + { + return $this->relativePath; + } + + /** + * Get page filename + */ + public function contentFile(): ?ContentFile + { + return $this->contentFile; + } + + /** + * Get page route + */ + public function route(): ?string + { + return $this->route; + } + /** * Get the canonical page URI, or `null` if not available */ - public function canonical(): ?string + public function canonicalRoute(): ?string { - if (isset($this->canonical)) { - return $this->canonical; + if (isset($this->canonicalRoute)) { + return $this->canonicalRoute; } - return $this->canonical = !empty($this->data['canonical']) - ? Path::normalize($this->data['canonical']) + return $this->canonicalRoute = !empty($this->data['canonicalRoute']) + ? Path::normalize($this->data['canonicalRoute']) : null; } + /** + * Get page slug + */ + public function slug(): ?string + { + return $this->slug; + } + + /** + * Get page num + */ + public function num(): ?int + { + if (isset($this->num)) { + return $this->num; + } + + preg_match(self::NUM_REGEX, basename($this->relativePath()), $matches); + return $this->num = isset($matches[1]) ? (int) $matches[1] : null; + } + + /** + * Get page languages + */ + public function languages(): Languages + { + return $this->languages; + } + + /** + * Get page language + */ + public function language(): ?Language + { + return $this->language; + } + + /** + * Get page scheme + */ + public function scheme(): Scheme + { + return $this->scheme; + } + + /** + * Get page fields + */ + public function fields(): FieldCollection + { + return $this->fields; + } + + /** + * Get page template + */ + public function template(): Template + { + return $this->template; + } + /** * Get page metadata */ @@ -255,7 +328,15 @@ class Page implements Arrayable } /** - * Get the page response status + * Get page files + */ + public function files(): FileCollection + { + return $this->files; + } + + /** + * Get page HTTP response status */ public function responseStatus(): int { @@ -267,78 +348,40 @@ class Page implements Arrayable $this->responseStatus = (int) $this->data['responseStatus']; // Get a default 404 Not Found status for the error page - if ($this->isErrorPage() && $this->responseStatus() === 200 && !isset($this->frontmatter['responseStatus'])) { + if ($this->isErrorPage() && $this->responseStatus() === 200 + && !isset($this->contentFile, $this->contentFile->frontmatter()['responseStatus'])) { $this->responseStatus = 404; } return $this->responseStatus; } - /** - * Page last modified time - */ - public function lastModifiedTime(): int - { - if (isset($this->lastModifiedTime)) { - return $this->lastModifiedTime; - } - - return $this->lastModifiedTime = FileSystem::lastModifiedTime($this->path . $this->filename); - } - - /** - * Timestamp representing the publication date (modification time as fallback) - */ - public function timestamp(): int - { - if (isset($this->timestamp)) { - return $this->timestamp; - } - - return $this->timestamp = isset($this->data['publishDate']) - ? Date::toTimestamp($this->data['publishDate']) - : $this->lastModifiedTime(); - } - - /** - * Get page num - */ - public function num(): ?int - { - if (isset($this->num)) { - return $this->num; - } - - preg_match(self::NUM_REGEX, $this->name, $matches); - return $this->num = isset($matches[1]) ? (int) $matches[1] : null; - } - /** * Set page language */ - public function setLanguage(string $language): void + public function setLanguage(Language|string $language): void { - if (!$this->hasLanguage($language)) { - throw new RuntimeException(sprintf('Invalid page language "%s"', $language)); + if (is_string($language)) { + $language = new Language($language); } - $path = $this->path; - $this->resetProperties(); - $this->language = $language; - $this->__construct($path); - } - /** - * Get page files - */ - public function files(): Files - { - return $this->files; + if (!$this->hasLoaded()) { + $this->language = $language; + return; + } + + if ($this->languages()->current()->code() !== ($code = $language->code())) { + if (!$this->languages()->available()->has($code)) { + throw new InvalidArgumentException(sprintf('Invalid page language "%s"', $code)); + } + $this->reload(['language' => $language]); + } } /** * Return a Files collection containing only images */ - public function images(): Files + public function images(): FileCollection { return $this->files()->filterByType('image'); } @@ -352,13 +395,24 @@ class Page implements Arrayable } /** - * Return whether page is empty + * Return whether the page has a content file + */ + public function hasContentFile(): bool + { + return $this->contentFile !== null; + } + + /** + * Return whether the page content data is empty */ public function isEmpty(): bool { - return !isset($this->filename); + return $this->contentFile?->frontmatter() !== []; } + /** + * Return whether the page is published + */ public function isPublished(): bool { return $this->status() === self::PAGE_STATUS_PUBLISHED; @@ -405,11 +459,11 @@ class Page implements Arrayable } /** - * Return whether the page has the specified language + * Return whether the page has loaded */ - public function hasLanguage(string $language): bool + public function hasLoaded(): bool { - return in_array($language, $this->availableLanguages, true); + return $this->loaded; } /** @@ -417,11 +471,28 @@ class Page implements Arrayable * * @internal */ - public function reload(): void + public function reload(array $data = []): void { + if (!$this->hasLoaded()) { + throw new RuntimeException('Unable to reload, the page has not been loaded yet'); + } $path = $this->path; $this->resetProperties(); - $this->__construct($path); + $this->__construct($data + ['path' => $path]); + } + + /** + * Set page path + */ + protected function setPath(string $path): void + { + $this->path = FileSystem::normalizePath($path . DS); + + $this->relativePath = Str::prepend(Path::makeRelative($this->path, Formwork::instance()->site()->path(), DS), DS); + + $this->route ??= Uri::normalize(preg_replace('~[/\\\\](\d+-)~', '/', $this->relativePath)); + + $this->slug ??= basename($this->route); } /** @@ -429,33 +500,52 @@ class Page implements Arrayable */ protected function loadFiles(): void { + /** + * @var array + */ $contentFiles = []; + + /** + * @var array + */ $files = []; - foreach (FileSystem::listFiles($this->path) as $file) { - $name = FileSystem::name($file); + /** + * @var array + */ + $languages = []; - $extension = '.' . FileSystem::extension($file); + $config = Formwork::instance()->config(); - if ($extension === Formwork::instance()->config()->get('content.extension')) { - $language = null; + $site = Formwork::instance()->site(); - if (preg_match('/([a-z0-9]+)\.([a-z]+)/', $name, $matches)) { - // Parse double extension - [$match, $name, $language] = $matches; - } + if (isset($this->path) && FileSystem::isDirectory($this->path, assertExists: false)) { + foreach (FileSystem::listFiles($this->path) as $file) { + $name = FileSystem::name($file); - if (Formwork::instance()->site()->templates()->has($name)) { - $contentFiles[$language] = [ - 'filename' => $file, - 'template' => $name - ]; - if ($language !== null && !in_array($language, $this->availableLanguages, true)) { - $this->availableLanguages[] = $language; + $extension = '.' . FileSystem::extension($file); + + if ($extension === $config->get('content.extension')) { + $language = null; + + if (preg_match('/([a-z0-9]+)\.([a-z]+)/', $name, $matches)) { + // Parse double extension + [, $name, $language] = $matches; } + + if ($site->templates()->has($name)) { + $contentFiles[$language] = [ + 'path' => FileSystem::joinPaths($this->path, $file), + 'filename' => $file, + 'template' => $name + ]; + if ($language !== null && !in_array($language, $languages, true)) { + $languages[] = $language; + } + } + } elseif (in_array($extension, $config->get('files.allowed_extensions'), true)) { + $files[] = $file; } - } elseif (in_array($extension, Formwork::instance()->config()->get('files.allowed_extensions'), true)) { - $files[] = $file; } } @@ -463,45 +553,47 @@ class Page implements Arrayable // Get correct content file based on current language ksort($contentFiles); - $currentLanguage = $this->language ?? Formwork::instance()->site()->languages()->current(); + // Language may already be set + $currentLanguage = $this->language ?? $site->languages()->current(); - $key = isset($contentFiles[$currentLanguage]) ? $currentLanguage : array_keys($contentFiles)[0]; + /** + * @var string + */ + $key = isset($currentLanguage, $contentFiles[$currentLanguage->code()]) + ? $currentLanguage->code() + : array_keys($contentFiles)[0]; // Set actual language - $this->language = $key ?: null; + $this->language ??= $key ? new Language($key) : null; - $this->filename = $contentFiles[$key]['filename']; + $this->contentFile ??= new ContentFile($contentFiles[$key]['path']); - $this->template = new Template($contentFiles[$key]['template'], $this); + $this->template ??= new Template($contentFiles[$key]['template'], $this); - $this->scheme = Formwork::instance()->schemes()->get('pages', $this->template); + $this->scheme ??= Formwork::instance()->schemes()->get('pages', $this->template); + } else { + $this->template ??= new Template('default', $this); - $this->fields = $this->scheme()->fields(); + $this->scheme ??= Formwork::instance()->schemes()->get('pages', 'default'); } - $this->files = Files::fromPath($this->path, $files); - } + $this->fields ??= $this->scheme()->fields(); - /** - * Parse page content - */ - protected function loadContents(): void - { - $contents = FileSystem::read($this->path . $this->filename); + $defaultLanguage = in_array((string) $site->languages()->default(), $languages, true) + ? $site->languages()->default() + : null; - if (!preg_match('/(?:\s|^)-{3}\s*(.+?)\s*-{3}\s*(.*?)\s*$/s', $contents, $matches)) { - throw new RuntimeException('Invalid page format'); - } + $this->languages ??= new Languages([ + 'available' => $languages, + 'default' => $defaultLanguage, + 'current' => $this->language ?? null, + 'requested' => $site->languages()->requested(), + 'preferred' => $site->languages()->preferred() + ]); - [, $rawFrontmatter, $rawContent] = $matches; + $this->files ??= isset($this->path) ? FileCollection::fromPath($this->path, $files) : new FileCollection(); - $this->frontmatter = YAML::parse($rawFrontmatter); - - $rawContent = str_replace("\r\n", "\n", $rawContent); - - $this->data = array_merge($this->defaults(), $this->frontmatter, ['content' => $rawContent]); - - $this->fields->validate($this->data); + $this->data = array_merge($this->defaults(), $this->data); } /** @@ -509,8 +601,14 @@ class Page implements Arrayable */ protected function resetProperties(): void { - foreach (array_keys(get_class_vars(static::class)) as $property) { - unset($this->$property); + $reflectionClass = new ReflectionClass($this); + + foreach ($reflectionClass->getProperties() as $property) { + unset($this->{$property->getName()}); + + if ($property->hasDefaultValue()) { + $this->{$property->getName()} = $property->getDefaultValue(); + } } } } diff --git a/formwork/src/Pages/PageCollection.php b/formwork/src/Pages/PageCollection.php index ae0103cd..bec1b4b0 100644 --- a/formwork/src/Pages/PageCollection.php +++ b/formwork/src/Pages/PageCollection.php @@ -12,6 +12,8 @@ class PageCollection extends AbstractCollection implements Paginable { protected ?string $dataType = Page::class . '|' . Site::class; + protected bool $associative = true; + /** * Pagination related to the collection */ @@ -111,6 +113,9 @@ class PageCollection extends AbstractCollection implements Paginable */ public static function fromPath(string $path, bool $recursive = false): self { + /** + * @var array + */ $pages = []; foreach (FileSystem::listDirectories($path) as $dir) { @@ -119,8 +124,8 @@ class PageCollection extends AbstractCollection implements Paginable if ($dir[0] !== '_' && FileSystem::isDirectory($pagePath)) { $page = Formwork::instance()->site()->retrievePage($pagePath); - if (!$page->isEmpty()) { - $pages[] = $page; + if ($page->hasContentFile()) { + $pages[$page->route()] = $page; } if ($recursive) { @@ -129,8 +134,8 @@ class PageCollection extends AbstractCollection implements Paginable } } - $pages = new static($pages); + $pageCollection = new static($pages); - return $pages->sortBy('relativePath'); + return $pageCollection->sortBy('relativePath'); } } diff --git a/formwork/src/Pages/Site.php b/formwork/src/Pages/Site.php index 562a011d..c744f4b4 100644 --- a/formwork/src/Pages/Site.php +++ b/formwork/src/Pages/Site.php @@ -13,6 +13,7 @@ use Formwork\Pages\Traits\PageTraversal; use Formwork\Pages\Traits\PageUid; use Formwork\Pages\Traits\PageUri; use Formwork\Schemes\Scheme; +use Formwork\Utils\Arr; use Formwork\Utils\FileSystem; class Site implements Arrayable @@ -22,30 +23,35 @@ class Site implements Arrayable use PageUid; use PageUri; + /** + * Site path + */ + protected ?string $path = null; + /** * Site relative path */ - protected string $relativePath; + protected ?string $relativePath = null; + + /** + * Site content file + */ + protected ?ContentFile $contentFile = null; /** * Site route */ - protected string $route; + protected ?string $route = null; /** - * Site last modified time + * Site canonical route */ - protected int $lastModifiedTime; + protected ?string $canonicalRoute = null; /** - * Site storage (loaded pages) + * Site slug */ - protected array $storage = []; - - /** - * Site current page - */ - protected ?Page $currentPage = null; + protected ?string $slug = null; /** * Site languages @@ -67,36 +73,36 @@ class Site implements Arrayable */ protected TemplateCollection $templates; - /** - * Site aliases - */ - protected array $aliases; - /** * Site metadata */ protected MetadataCollection $metadata; + /** + * Site storage (loaded pages) + */ + protected array $storage = []; + + /** + * Site current page + */ + protected ?Page $currentPage = null; + + /** + * Site aliases + */ + protected array $routeAliases; + /** * Create a new Site instance */ - public function __construct(array $data) + public function __construct(array $data = []) { - $this->path = FileSystem::normalizePath(Formwork::instance()->config()->get('content.path')); + $this->setMultiple($data); - $this->relativePath = DS; + $this->load(); - $this->route = '/'; - - $this->scheme = Formwork::instance()->schemes()->get('config', 'site'); - - $this->data = array_replace_recursive($this->defaults(), $data); - - $this->fields = $this->scheme->fields()->validate($this->data); - - $this->templates = TemplateCollection::fromPath(Formwork::instance()->config()->get('templates.path')); - - $this->loadAliases(); + $this->fields->validate($this->data); } public function __toString() @@ -104,29 +110,150 @@ class Site implements Arrayable return $this->title(); } + public static function fromPath(string $path, array $data = []): static + { + return new static(['path' => $path] + $data); + } + /** * Return site default data */ public function defaults(): array { - // Formwork::instance()->schemes()->get('config', 'site')->fields(); - return [ - 'title' => 'Formwork', - 'aliases' => [], - 'metadata' => [], - 'canonical' => null + $defaults = [ + 'title' => 'Formwork', + 'author' => '', + 'description' => '', + 'metadata' => [], + 'canonicalRoute' => null, + 'routeAliases' => [] ]; + + $defaults = array_merge($defaults, Arr::reject($this->fields()->pluck('default'), fn ($value) => $value === null)); + + return $defaults; } /** - * Get the site last modified time + * Get site path */ - public function lastModifiedTime(): int + public function path(): ?string { - if (isset($this->lastModifiedTime)) { - return $this->lastModifiedTime; + return $this->path; + } + + /** + * Get site relative path + */ + public function relativePath(): string + { + return $this->relativePath; + } + + /** + * Get site filename + */ + public function contentFile(): ?ContentFile + { + return $this->contentFile; + } + + /** + * Get site route + */ + public function route(): string + { + return $this->route; + } + + /** + * Get site canonical route + */ + public function canonicalRoute(): ?string + { + return $this->canonicalRoute; + } + + /** + * Get site slug + */ + public function slug(): string + { + return $this->slug; + } + + /** + * Get site languages + */ + public function languages(): Languages + { + return $this->languages; + } + + /** + * Get site scheme + */ + public function scheme(): Scheme + { + return $this->scheme; + } + + /** + * Get site fields + */ + public function fields(): FieldCollection + { + return $this->fields; + } + + /** + * Get site templates + */ + public function templates(): TemplateCollection + { + return $this->templates; + } + + /** + * Get the current page of the site + */ + public function currentPage(): ?Page + { + return $this->currentPage; + } + + /** + * Get site route aliases + */ + public function routeAliases(): array + { + return $this->routeAliases; + } + + /** + * Get site metadata + */ + public function metadata(): MetadataCollection + { + if (isset($this->metadata)) { + return $this->metadata; } - return $this->lastModifiedTime = FileSystem::lastModifiedTime($this->path); + + $defaults = [ + 'charset' => Formwork::instance()->config()->get('charset'), + 'author' => $this->get('author'), + 'description' => $this->get('description'), + 'generator' => 'Formwork', + 'routeAliases' => [] + ]; + + $data = array_filter(array_merge($defaults, $this->data['metadata'])); + + if (!Formwork::instance()->config()->get('metadata.set_generator')) { + unset($data['generator']); + } + + return $this->metadata = new MetadataCollection($data); } /** @@ -161,7 +288,7 @@ class Site implements Arrayable if (isset($this->storage[$path])) { return $this->storage[$path]; } - return $this->storage[$path] = new Page($path); + return $this->storage[$path] = Page::fromPath($path); } /** @@ -192,7 +319,7 @@ class Site implements Arrayable $page = $this->retrievePage($path); - return !$page->isEmpty() ? $page : null; + return $page->hasContentFile() ? $page : null; } /** @@ -203,45 +330,12 @@ class Site implements Arrayable return $this->currentPage = $page; } - /** - * Set site languages - */ - public function setLanguages(Languages $languages): void - { - $this->languages = $languages; - } - /** * Return alias of a given route */ - public function resolveAlias(string $route): ?string + public function resolveRouteAlias(string $route): ?string { - return $this->aliases[$route] ?? null; - } - - /** - * Get site metadata - */ - public function metadata(): MetadataCollection - { - if (isset($this->metadata)) { - return $this->metadata; - } - - $defaults = [ - 'charset' => Formwork::instance()->config()->get('charset'), - 'author' => $this->get('author'), - 'description' => $this->get('description'), - 'generator' => 'Formwork' - ]; - - $data = array_filter(array_merge($defaults, $this->data['metadata'])); - - if (!Formwork::instance()->config()->get('metadata.set_generator')) { - unset($data['generator']); - } - - return $this->metadata = new MetadataCollection($data); + return $this->routeAliases[$route] ?? null; } /** @@ -292,21 +386,46 @@ class Site implements Arrayable return false; } - /** - * Return whether the page has the specified language - */ - public function hasLanguage(string $language): bool + protected function load() { - return in_array($language, $this->availableLanguages, true); + $this->scheme = Formwork::instance()->schemes()->get('config', 'site'); + + $this->fields = $this->scheme->fields(); + + $this->templates = TemplateCollection::fromPath(Formwork::instance()->config()->get('templates.path')); + + $this->data = array_merge($this->defaults(), $this->data); + + $this->loadRouteAliases(); + } + + /** + * Site storage + */ + protected function storage(): array + { + return $this->storage; + } + + protected function setPath(string $path): void + { + $this->path = FileSystem::normalizePath($path . DS); + + $this->relativePath = DS; + + $this->route = '/'; + + $this->slug = ''; } /** * Load site aliases */ - protected function loadAliases(): void + protected function loadRouteAliases(): void { - foreach ($this->data['aliases'] as $from => $to) { - $this->aliases[trim($from, '/')] = trim($to, '/'); + $this->routeAliases = []; + foreach ($this->data['routeAliases'] as $from => $to) { + $this->routeAliases[trim($from, '/')] = trim($to, '/'); } } } diff --git a/formwork/src/Pages/Traits/PageData.php b/formwork/src/Pages/Traits/PageData.php index e724174f..abf9734b 100644 --- a/formwork/src/Pages/Traits/PageData.php +++ b/formwork/src/Pages/Traits/PageData.php @@ -39,10 +39,10 @@ trait PageData // Call getter method if exists. We check property existence before // to avoid using get to call methods arbitrarily if (method_exists($this, $key)) { - return $this->$key(); + return $this->{$key}(); } - return $this->$key; + return $this->{$key} ?? $default; } // Get values from fields @@ -67,10 +67,17 @@ trait PageData public function set(string $key, $value): void { if (property_exists($this, $key)) { - $this->$key = $value; - } else { - Arr::set($this->data, $key, $value); + // If defined use a setter + if (method_exists($this, $setter = 'set' . ucfirst($key))) { + $this->{$setter}($value); + return; + } + + $this->{$key} = $value; + return; } + + Arr::set($this->data, $key, $value); } /** @@ -88,4 +95,12 @@ trait PageData return $data; } + + /** + * Get page data + */ + public function data(): array + { + return $this->data; + } } diff --git a/formwork/src/Pages/Traits/PageStatus.php b/formwork/src/Pages/Traits/PageStatus.php index 69fa10c9..9451938d 100644 --- a/formwork/src/Pages/Traits/PageStatus.php +++ b/formwork/src/Pages/Traits/PageStatus.php @@ -7,6 +7,11 @@ use Formwork\Utils\Date; trait PageStatus { + /** + * Page data + */ + protected array $data = []; + /** * Page status */ @@ -21,14 +26,19 @@ trait PageStatus return $this->status; } - $published = $this->data['published']; + /** + * @var bool + */ + $published = $this->get('published', true); $now = time(); + /** @var ?string */ if ($publishDate = $this->data['publishDate'] ?? null) { $published = $published && Date::toTimestamp($publishDate) < $now; } + /** @var ?string */ if ($unpublishDate = $this->data['unpublishDate'] ?? null) { $published = $published && Date::toTimestamp($unpublishDate) > $now; } diff --git a/formwork/src/Pages/Traits/PageTraversal.php b/formwork/src/Pages/Traits/PageTraversal.php index f64dfbd9..03f30cbb 100644 --- a/formwork/src/Pages/Traits/PageTraversal.php +++ b/formwork/src/Pages/Traits/PageTraversal.php @@ -9,11 +9,6 @@ use Formwork\Pages\Site; trait PageTraversal { - /** - * Page path - */ - protected string $path; - /** * Parent page */ @@ -44,6 +39,16 @@ trait PageTraversal */ protected PageCollection $inclusiveSiblings; + /** + * Get page or site path + */ + abstract public function path(): ?string; + + /** + * Get page or site route + */ + abstract public function route(): ?string; + /** * Return whether the page is site */ @@ -58,13 +63,13 @@ trait PageTraversal return $this->parent; } - if ($this->isSite()) { + if ($this->isSite() || $this->path() === null) { return $this->parent = null; } - $parentPath = dirname($this->path) . DS; + $parentPath = dirname($this->path()) . DS; - if ($parentPath === Formwork::instance()->config()->get('content.path')) { + if ($parentPath === Formwork::instance()->site()->path()) { return $this->parent = Formwork::instance()->site(); } @@ -96,7 +101,11 @@ trait PageTraversal return $this->children; } - return $this->children = PageCollection::fromPath($this->path); + if ($this->path() === null) { + return $this->children = new PageCollection(); + } + + return $this->children = PageCollection::fromPath($this->path()); } /** @@ -124,7 +133,11 @@ trait PageTraversal return $this->descendants; } - return $this->descendants = PageCollection::fromPath($this->path, recursive: true); + if ($this->path() === null) { + return $this->descendants = new PageCollection(); + } + + return $this->descendants = PageCollection::fromPath($this->path(), recursive: true); } /** @@ -157,7 +170,7 @@ trait PageTraversal $page = $this; while (($parent = $page->parent()) !== null) { - $ancestors[] = $parent; + $ancestors[$parent->route()] = $parent; $page = $parent; } @@ -205,8 +218,8 @@ trait PageTraversal return $this->inclusiveSiblings; } - if ($this->isSite()) { - return $this->inclusiveSiblings = new PageCollection([$this]); + if ($this->isSite() || $this->path() === null) { + return $this->inclusiveSiblings = new PageCollection([$this->route() => $this]); } return $this->inclusiveSiblings = $this->parent()->children(); diff --git a/formwork/src/Pages/Traits/PageUid.php b/formwork/src/Pages/Traits/PageUid.php index 3ab4166a..c32a1d22 100644 --- a/formwork/src/Pages/Traits/PageUid.php +++ b/formwork/src/Pages/Traits/PageUid.php @@ -11,6 +11,11 @@ trait PageUid */ protected string $uid; + /** + * Get page or site relative path + */ + abstract public function relativePath(): ?string; + /** * Get the page unique identifier */ @@ -19,6 +24,9 @@ trait PageUid if (isset($this->uid)) { return $this->uid; } - return $this->uid = Str::chunk(substr(hash('sha256', $this->relativePath), 0, 32), 8, '-'); + + $id = $this->relativePath() ?: spl_object_hash($this); + + return $this->uid = Str::chunk(substr(hash('sha256', (string) $id), 0, 32), 8, '-'); } } diff --git a/formwork/src/Pages/Traits/PageUri.php b/formwork/src/Pages/Traits/PageUri.php index 3f43bdce..6c171d62 100644 --- a/formwork/src/Pages/Traits/PageUri.php +++ b/formwork/src/Pages/Traits/PageUri.php @@ -10,14 +10,14 @@ use Formwork\Utils\Uri; trait PageUri { /** - * Page route + * Get page or site route */ - protected string $route; + abstract public function route(): ?string; /** - * Page absolute URI + * Get page or site canonical route */ - protected string $absoluteUri; + abstract public function canonicalRoute(): ?string; /** * Return a URI relative to page @@ -26,7 +26,7 @@ trait PageUri { $base = HTTPRequest::root(); - $route = $this->canonical() ?? $this->route; + $route = $this->canonicalRoute() ?? $this->route(); if ($includeLanguage) { $language = is_string($includeLanguage) ? $includeLanguage : Formwork::instance()->site()->languages()->current(); @@ -45,11 +45,8 @@ trait PageUri /** * Get page absolute URI */ - public function absoluteUri(): string + public function absoluteUri(string $path = '', bool|string $includeLanguage = true): string { - if (isset($this->absoluteUri)) { - return $this->absoluteUri; - } - return $this->absoluteUri = Uri::resolveRelative($this->uri()); + return Uri::resolveRelative($this->uri($path, $includeLanguage)); } } diff --git a/formwork/src/Panel/Controllers/DashboardController.php b/formwork/src/Panel/Controllers/DashboardController.php index 22bc7e9d..13d1d1fe 100644 --- a/formwork/src/Panel/Controllers/DashboardController.php +++ b/formwork/src/Panel/Controllers/DashboardController.php @@ -24,10 +24,14 @@ class DashboardController extends AbstractController $this->modal('deletePage'); + $timestamps = $this->site()->descendants() + ->everyItem()->contentFile() + ->everyItem()->lastModifiedTime(); + return new Response($this->view('dashboard.index', [ 'title' => $this->translate('panel.dashboard.dashboard'), 'lastModifiedPages' => $this->view('pages.list', [ - 'pages' => $this->site()->descendants()->sortBy('lastModifiedTime', direction: SORT_DESC)->slice(0, 5), + 'pages' => $this->site()->descendants()->sort(direction: SORT_DESC, sortBy: $timestamps->toArray())->limit(5), 'subpages' => false, 'class' => 'pages-list-top', 'parent' => null, diff --git a/formwork/src/Panel/Controllers/OptionsController.php b/formwork/src/Panel/Controllers/OptionsController.php index 7deefafb..b596a97f 100644 --- a/formwork/src/Panel/Controllers/OptionsController.php +++ b/formwork/src/Panel/Controllers/OptionsController.php @@ -49,7 +49,7 @@ class OptionsController extends AbstractController // Touch content folder to invalidate cache if ($differ) { - FileSystem::touch(Formwork::instance()->config()->get('content.path')); + FileSystem::touch(Formwork::instance()->site()->path()); } $this->panel()->notify($this->translate('panel.options.updated'), 'success'); @@ -89,7 +89,7 @@ class OptionsController extends AbstractController // Touch content folder to invalidate cache if ($differ) { - FileSystem::touch(Formwork::instance()->config()->get('content.path')); + FileSystem::touch(Formwork::instance()->site()->path()); } $this->panel()->notify($this->translate('panel.options.updated'), 'success'); diff --git a/formwork/src/Panel/Controllers/PagesController.php b/formwork/src/Panel/Controllers/PagesController.php index 70037671..6f665096 100644 --- a/formwork/src/Panel/Controllers/PagesController.php +++ b/formwork/src/Panel/Controllers/PagesController.php @@ -5,9 +5,9 @@ namespace Formwork\Panel\Controllers; use Formwork\Data\DataGetter; use Formwork\Exceptions\TranslatedException; use Formwork\Fields\FieldCollection; +use Formwork\Files\File; use Formwork\Files\Image; use Formwork\Formwork; -use Formwork\Languages\LanguageCodes; use Formwork\Pages\Page; use Formwork\Pages\Site; use Formwork\Panel\Uploader; @@ -16,6 +16,7 @@ use Formwork\Response\JSONResponse; use Formwork\Response\RedirectResponse; use Formwork\Response\Response; use Formwork\Router\RouteParams; +use Formwork\Utils\Date; use Formwork\Utils\FileSystem; use Formwork\Utils\HTTPRequest; use Formwork\Utils\Str; @@ -111,7 +112,7 @@ class PagesController extends AbstractController return $this->redirect('/pages/' . trim($page->route(), '/') . '/edit/language/' . $this->site()->languages()->default() . '/'); } - if ($page->hasLanguage($language)) { + if ($page->languages()->available()->has($language)) { $page->setLanguage($language); } } elseif ($page->language() !== null) { @@ -150,6 +151,7 @@ class PagesController extends AbstractController if (HTTPRequest::hasFiles()) { try { $this->processPageUploads($page); + $page->reload(); } catch (TranslatedException $e) { $this->panel()->notify($this->translate('panel.uploader.error', $e->getTranslatedMessage()), 'error'); } @@ -176,13 +178,12 @@ class PagesController extends AbstractController $this->modal('deleteFile'); return new Response($this->view('pages.editor', [ - 'title' => $this->translate('panel.pages.edit-page', $page->title()), - 'page' => $page, - 'fields' => $fields, - 'templates' => $this->site()->templates()->keys(), - 'parents' => $this->site()->descendants()->sortBy('relativePath'), - 'currentLanguage' => $params->get('language', $page->language()), - 'availableLanguages' => $this->availableSiteLanguages() + 'title' => $this->translate('panel.pages.edit-page', $page->title()), + 'page' => $page, + 'fields' => $fields, + 'templates' => $this->site()->templates()->keys(), + 'parents' => $this->site()->descendants()->sortBy('relativePath'), + 'currentLanguage' => $params->get('language', $page->language()?->code()) ], true)); } @@ -208,6 +209,9 @@ class PagesController extends AbstractController return JSONResponse::error($this->translate('panel.pages.page.cannot-move')); } + /** + * @var array + */ $pages = $parent->children()->toArray(); $from = max(0, $data->get('from')); @@ -218,8 +222,8 @@ class PagesController extends AbstractController array_splice($pages, $to, 0, array_splice($pages, $from, 1)); - foreach ($pages as $i => $page) { - $name = $page->name(); + foreach (array_values($pages) as $i => $page) { + $name = basename($page->relativePath()); if ($name === null) { continue; } @@ -248,7 +252,7 @@ class PagesController extends AbstractController if ($params->has('language')) { $language = $params->get('language'); - if ($page->hasLanguage($language)) { + if ($page->languages()->available()->has($language)) { $page->setLanguage($language); } else { $this->panel()->notify($this->translate('panel.pages.page.cannot-delete.invalid-language', $language), 'error'); @@ -262,8 +266,8 @@ class PagesController extends AbstractController } // Delete just the content file only if there are more than one language - if ($params->has('language') && count($page->availableLanguages()) > 1) { - FileSystem::delete($page->path() . $page->filename()); + if ($params->has('language') && count($page->languages()->available()) > 1) { + FileSystem::delete($page->contentFile()->path()); } else { FileSystem::delete($page->path(), true); } @@ -376,16 +380,16 @@ class PagesController extends AbstractController FileSystem::createFile($path . $filename); - $frontmatter = [ + $contentData = [ 'title' => $data->get('title'), 'published' => false ]; - $fileContent = Str::wrap(YAML::encode($frontmatter), '---' . PHP_EOL); + $fileContent = Str::wrap(YAML::encode($contentData), '---' . PHP_EOL); FileSystem::write($path . $filename, $fileContent); - return new Page($path); + return Page::fromPath($path); } /** @@ -399,7 +403,7 @@ class PagesController extends AbstractController } // Load current page frontmatter - $frontmatter = $page->frontmatter(); + $frontmatter = $page->contentFile()->frontmatter(); // Preserve the title if not given if (!empty($data->get('title'))) { @@ -432,7 +436,7 @@ class PagesController extends AbstractController throw new TranslatedException('Invalid page language', 'panel.pages.page.cannot-edit.invalid-language'); } - $differ = $frontmatter !== $page->frontmatter() || $content !== $page->data()['content'] || $language !== $page->language(); + $differ = $frontmatter !== $page->contentFile()->frontmatter() || $content !== $page->data()['content'] || $language !== $page->language(); if ($differ) { $filename = $data->get('template'); @@ -442,19 +446,24 @@ class PagesController extends AbstractController $fileContent = Str::wrap(YAML::encode($frontmatter), '---' . PHP_EOL) . $content; FileSystem::write($page->path() . $filename, $fileContent); - FileSystem::touch(Formwork::instance()->config()->get('content.path')); + FileSystem::touch(Formwork::instance()->site()->path()); // Update page with the new data $page->reload(); // Set correct page language if it has changed - if ($language !== $page->language()) { + if ($language !== $page->language()?->code()) { $page->setLanguage($language); } // Check if page number has to change - if ($page->scheme()->get('num') === 'date' && $page->num() !== ($num = (int) date(self::DATE_NUM_FORMAT, $page->timestamp()))) { - $name = preg_replace(Page::NUM_REGEX, $num . '-', $page->name()); + + $timestamp = isset($page->data()['publishDate']) + ? Date::toTimestamp($page->data()['publishDate']) + : $page->contentFile()->lastModifiedTime(); + + if ($page->scheme()->get('num') === 'date' && $page->num() !== ($num = (int) date(self::DATE_NUM_FORMAT, $timestamp))) { + $name = preg_replace(Page::NUM_REGEX, $num . '-', basename($page->relativePath())); try { $page = $this->changePageName($page, $name); } catch (RuntimeException $e) { @@ -504,16 +513,15 @@ class PagesController extends AbstractController { $uploader = new Uploader($page->path()); $uploader->upload(); - $page->reload(); + /** + * @var File + */ foreach ($uploader->uploadedFiles() as $file) { - $file = $page->files()->get($file); - // Process JPEG and PNG images according to system options (e.g. quality) if (Formwork::instance()->config()->get('images.process_uploads') && in_array($file->mimeType(), ['image/jpeg', 'image/png'], true)) { $image = new Image($file->path()); $image->saveOptimized(); - $page->reload(); } } } @@ -551,7 +559,7 @@ class PagesController extends AbstractController $directory = dirname($page->path()); $destination = FileSystem::joinPaths($directory, $name, DS); FileSystem::moveDirectory($page->path(), $destination); - return new Page($destination); + return Page::fromPath($destination); } /** @@ -559,9 +567,9 @@ class PagesController extends AbstractController */ protected function changePageParent(Page $page, Page|Site $parent): Page { - $destination = FileSystem::joinPaths($parent->path(), $page->name(), DS); + $destination = FileSystem::joinPaths($parent->path(), basename($page->relativePath()), DS); FileSystem::moveDirectory($page->path(), $destination); - return new Page($destination); + return Page::fromPath($destination); } /** @@ -570,9 +578,8 @@ class PagesController extends AbstractController protected function changePageTemplate(Page $page, string $template): Page { $destination = $page->path() . $template . Formwork::instance()->config()->get('content.extension'); - FileSystem::move($page->path() . $page->filename(), $destination); - $page->reload(); - return $page; + FileSystem::move($page->contentFile()->path(), $destination); + return Page::fromPath($page->path()); } /** @@ -595,16 +602,4 @@ class PagesController extends AbstractController { return (bool) preg_match(self::SLUG_REGEX, $slug); } - - /** - * Return an array containing the available site languages as keys with proper labels as values - */ - protected function availableSiteLanguages(): array - { - $languages = []; - foreach (Formwork::instance()->config()->get('languages.available') as $code) { - $languages[$code] = LanguageCodes::hasCode($code) ? LanguageCodes::codeToNativeName($code) . ' (' . $code . ')' : $code; - } - return $languages; - } } diff --git a/formwork/src/Panel/Panel.php b/formwork/src/Panel/Panel.php index 8108f432..609b47c3 100644 --- a/formwork/src/Panel/Panel.php +++ b/formwork/src/Panel/Panel.php @@ -7,7 +7,7 @@ use Formwork\Formwork; use Formwork\Languages\LanguageCodes; use Formwork\Panel\Controllers\ErrorsController; use Formwork\Panel\Users\User; -use Formwork\Panel\Users\Users; +use Formwork\Panel\Users\UserCollection; use Formwork\Utils\FileSystem; use Formwork\Utils\HTTPRequest; use Formwork\Utils\Notification; @@ -21,7 +21,7 @@ final class Panel /** * All the registered users */ - protected Users $users; + protected UserCollection $users; /** * Errors controller @@ -44,7 +44,7 @@ final class Panel public function load(): void { $this->loadSchemes(); - $this->users = Users::load(); + $this->users = UserCollection::load(); $this->loadTranslations(); $this->loadErrorHandler(); } @@ -61,7 +61,7 @@ final class Panel /** * Return all registered users */ - public function users(): Users + public function users(): UserCollection { return $this->users; } diff --git a/formwork/src/Panel/Uploader.php b/formwork/src/Panel/Uploader.php index 316e1249..4e1d9abd 100644 --- a/formwork/src/Panel/Uploader.php +++ b/formwork/src/Panel/Uploader.php @@ -3,6 +3,7 @@ namespace Formwork\Panel; use Formwork\Exceptions\TranslatedException; +use Formwork\Files\FileCollection; use Formwork\Formwork; use Formwork\Utils\FileSystem; use Formwork\Utils\HTTPRequest; @@ -49,7 +50,7 @@ class Uploader /** * Array containing uploaded files */ - protected array $uploadedFiles = []; + protected FileCollection $uploadedFiles; /** * Create a new Uploader instance @@ -85,19 +86,25 @@ class Uploader if (!HTTPRequest::hasFiles()) { return false; } + $count = count(HTTPRequest::files()); + $filenames = []; + foreach (HTTPRequest::files() as $file) { if ($file['error'] === 0) { if ($name === null || $count > 1) { $name = $file['name']; } - $this->move($file['tmp_name'], $this->destination, $name); + + $filenames[] = $this->move($file['tmp_name'], $this->destination, $name); } else { throw new TranslatedException(self::ERROR_MESSAGES[$file['error']], self::ERROR_LANGUAGE_STRINGS[$file['error']]); } } + $this->uploadedFiles = FileCollection::fromPath($this->destination, $filenames); + return true; } @@ -115,17 +122,15 @@ class Uploader /** * Return uploaded files */ - public function uploadedFiles(): array + public function uploadedFiles(): FileCollection { return $this->uploadedFiles; } /** * Move uploaded file to a destination - * - * @return bool Whether file was successfully moved or not */ - protected function move(string $source, string $destination, string $filename): bool + protected function move(string $source, string $destination, string $filename): string { $mimeType = FileSystem::mimeType($source); @@ -165,12 +170,9 @@ class Uploader } if (@move_uploaded_file($source, $destinationPath)) { - $this->uploadedFiles[] = $filename; - return true; + return $filename; } throw new TranslatedException('Cannot move uploaded file to destination', 'panel.uploader.error.cannot-move-to-destination'); - - return false; } } diff --git a/formwork/src/Panel/Users/Permissions.php b/formwork/src/Panel/Users/Permissions.php index 05b80bc9..8f04db1e 100644 --- a/formwork/src/Panel/Users/Permissions.php +++ b/formwork/src/Panel/Users/Permissions.php @@ -26,7 +26,7 @@ class Permissions */ public function __construct(string $name) { - $this->permissions = array_merge($this->permissions, Users::getRolePermissions($name)); + $this->permissions = array_merge($this->permissions, UserCollection::getRolePermissions($name)); } /** diff --git a/formwork/src/Panel/Users/User.php b/formwork/src/Panel/Users/User.php index e0d16d11..6821da29 100644 --- a/formwork/src/Panel/Users/User.php +++ b/formwork/src/Panel/Users/User.php @@ -81,7 +81,7 @@ class User implements Arrayable { $this->data = array_merge($this->defaults, $data); foreach (['username', 'fullname', 'hash', 'email', 'language', 'role'] as $var) { - $this->$var = $this->data[$var]; + $this->{$var} = $this->data[$var]; } $this->permissions = new Permissions($this->role); diff --git a/formwork/src/Panel/Users/Users.php b/formwork/src/Panel/Users/UserCollection.php similarity index 96% rename from formwork/src/Panel/Users/Users.php rename to formwork/src/Panel/Users/UserCollection.php index cfbe0b09..61853997 100644 --- a/formwork/src/Panel/Users/Users.php +++ b/formwork/src/Panel/Users/UserCollection.php @@ -7,7 +7,7 @@ use Formwork\Formwork; use Formwork\Parsers\YAML; use Formwork\Utils\FileSystem; -class Users extends AbstractCollection +class UserCollection extends AbstractCollection { protected bool $associative = true; diff --git a/formwork/src/Router/Router.php b/formwork/src/Router/Router.php index 215aea07..e9cbc80e 100644 --- a/formwork/src/Router/Router.php +++ b/formwork/src/Router/Router.php @@ -66,7 +66,7 @@ class Router /** * Route params */ - protected ?RouteParams $params; + protected RouteParams $params; public function __construct(?string $request = null) { @@ -75,6 +75,8 @@ class Router // Ensure requested route is wrapped in slashes $this->request = Str::wrap($request ?? Uri::path(HTTPRequest::uri()), '/'); + + $this->params = new RouteParams([]); } /** diff --git a/panel/schemes/user.yml b/panel/schemes/user.yml index e7bbd0d9..ef7735e9 100644 --- a/panel/schemes/user.yml +++ b/panel/schemes/user.yml @@ -40,7 +40,7 @@ fields: label: '{{panel.user.role}}' disabled: true import: - options: 'Formwork\Panel\Users\Users::availableRoles' + options: 'Formwork\Panel\Users\UserCollection::availableRoles' color-scheme: diff --git a/panel/views/pages/editor.php b/panel/views/pages/editor.php index 1c3da6a7..17ef42b7 100644 --- a/panel/views/pages/editor.php +++ b/panel/views/pages/editor.php @@ -31,15 +31,15 @@ endif; ?> languages()->available()->isEmpty()): ?>