* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ class Page extends ModelWithContent { use HasChildren; use HasFiles; use HasMethods; use HasSiblings; use PageActions; use PageSiblings; public const CLASS_ALIAS = 'page'; /** * All registered page methods * @todo Remove when support for PHP 8.2 is dropped */ public static array $methods = []; /** * Registry with all Page models */ public static array $models = []; /** * The PageBlueprint object */ protected PageBlueprint|null $blueprint = null; /** * Nesting level */ protected int $depth; /** * Sorting number + slug */ protected string|null $dirname; /** * Path of dirnames */ protected string|null $diruri = null; /** * Draft status flag */ protected bool $isDraft; /** * The Page id */ protected string|null $id = null; /** * The template, that should be loaded * if it exists */ protected Template|null $intendedTemplate = null; protected array|null $inventory = null; /** * The sorting number */ protected int|null $num; /** * The parent page */ protected Page|null $parent; /** * Absolute path to the page directory */ protected string|null $root; /** * The URL-appendix aka slug */ protected string $slug; /** * The intended page template */ protected Template|null $template = null; /** * The page url */ protected string|null $url; /** * Creates a new page object */ public function __construct(array $props) { if (isset($props['slug']) === false) { throw new InvalidArgumentException('The page slug is required'); } parent::__construct($props); $this->slug = $props['slug']; // Sets the dirname manually, which works // more reliable in connection with the inventory // than computing the dirname afterwards $this->dirname = $props['dirname'] ?? null; $this->isDraft = $props['isDraft'] ?? false; $this->num = $props['num'] ?? null; $this->parent = $props['parent'] ?? null; $this->root = $props['root'] ?? null; $this->setBlueprint($props['blueprint'] ?? null); $this->setChildren($props['children'] ?? null); $this->setDrafts($props['drafts'] ?? null); $this->setFiles($props['files'] ?? null); $this->setTemplate($props['template'] ?? null); $this->setUrl($props['url'] ?? null); } /** * Magic caller */ public function __call(string $method, array $arguments = []): mixed { // public property access if (isset($this->$method) === true) { return $this->$method; } // page methods if ($this->hasMethod($method)) { return $this->callMethod($method, $arguments); } // return page content otherwise return $this->content()->get($method); } /** * Improved `var_dump` output * @codeCoverageIgnore */ public function __debugInfo(): array { return array_merge($this->toArray(), [ 'content' => $this->content(), 'children' => $this->children(), 'siblings' => $this->siblings(), 'translations' => $this->translations(), 'files' => $this->files(), ]); } /** * Returns the url to the api endpoint * @internal */ public function apiUrl(bool $relative = false): string { if ($relative === true) { return 'pages/' . $this->panel()->id(); } return $this->kirby()->url('api') . '/pages/' . $this->panel()->id(); } /** * Returns the blueprint object */ public function blueprint(): PageBlueprint { return $this->blueprint ??= PageBlueprint::factory( 'pages/' . $this->intendedTemplate(), 'pages/default', $this ); } /** * Returns an array with all blueprints that are available for the page */ public function blueprints(string|null $inSection = null): array { if ($inSection !== null) { return $this->blueprint()->section($inSection)->blueprints(); } if ($this->blueprints !== null) { return $this->blueprints; } $blueprints = []; $templates = $this->blueprint()->changeTemplate() ?? $this->blueprint()->options()['changeTemplate'] ?? []; $currentTemplate = $this->intendedTemplate()->name(); if (is_array($templates) === false) { $templates = []; } // add the current template to the array if it's not already there if (in_array($currentTemplate, $templates) === false) { array_unshift($templates, $currentTemplate); } // make sure every template is only included once $templates = array_unique($templates); foreach ($templates as $template) { try { $props = Blueprint::load('pages/' . $template); $blueprints[] = [ 'name' => basename($props['name']), 'title' => $props['title'], ]; } catch (Exception) { // skip invalid blueprints } } return $this->blueprints = array_values($blueprints); } /** * Builds the cache id for the page */ protected function cacheId(string $contentType): string { $cacheId = [$this->id()]; if ($this->kirby()->multilang() === true) { $cacheId[] = $this->kirby()->language()->code(); } $cacheId[] = $contentType; return implode('.', $cacheId); } /** * Prepares the content for the write method * @internal */ public function contentFileData( array $data, string|null $languageCode = null ): array { return A::prepend($data, [ 'title' => $data['title'] ?? null, 'slug' => $data['slug'] ?? null ]); } /** * Returns the content text file * which is found by the inventory method * @internal * @deprecated 4.0.0 * @todo Remove in v5 * @codeCoverageIgnore */ public function contentFileName(string|null $languageCode = null): string { Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); return $this->intendedTemplate()->name(); } /** * Call the page controller * @internal * * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page` */ public function controller( array $data = [], string $contentType = 'html' ): array { // create the template data $data = array_merge($data, [ 'kirby' => $kirby = $this->kirby(), 'site' => $site = $this->site(), 'pages' => new LazyValue(fn () => $site->children()), 'page' => new LazyValue(fn () => $site->visit($this)) ]); // call the template controller if there's one. $controllerData = $kirby->controller( $this->template()->name(), $data, $contentType ); // merge controller data with original data safely // to provide original data to template even if // it wasn't returned by the controller explicitly if (empty($controllerData) === false) { $classes = [ 'kirby' => App::class, 'site' => Site::class, 'pages' => Pages::class, 'page' => Page::class ]; foreach ($controllerData as $key => $value) { $data[$key] = match (true) { // original data wasn't overwritten array_key_exists($key, $classes) === false => $value, // original data was overwritten, but matches expected type $value instanceof $classes[$key] => $value, // throw error if data was overwritten with wrong type default => throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"') }; } } // unwrap remaining lazy values in data // (happens if the controller didn't override an original lazy Kirby object) $data = LazyValue::unwrap($data); return $data; } /** * Returns a number indicating how deep the page * is nested within the content folder */ public function depth(): int { return $this->depth ??= (substr_count($this->id(), '/') + 1); } /** * Returns the directory name (UID with optional sorting number) */ public function dirname(): string { if ($this->dirname !== null) { return $this->dirname; } if ($this->num() !== null) { return $this->dirname = $this->num() . Dir::$numSeparator . $this->uid(); } return $this->dirname = $this->uid(); } /** * Returns the directory path relative to the `content` root * (including optional sorting numbers and draft directories) */ public function diruri(): string { if (is_string($this->diruri) === true) { return $this->diruri; } if ($this->isDraft() === true) { $dirname = '_drafts/' . $this->dirname(); } else { $dirname = $this->dirname(); } if ($parent = $this->parent()) { return $this->diruri = $parent->diruri() . '/' . $dirname; } return $this->diruri = $dirname; } /** * Checks if the page exists on disk */ public function exists(): bool { return is_dir($this->root()) === true; } /** * Constructs a Page object and also * takes page models into account. * @internal */ public static function factory($props): static { return static::model($props['model'] ?? 'default', $props); } /** * Redirects to this page, * wrapper for the `go()` helper * * @since 3.4.0 * * @param array $options Options for `Kirby\Http\Uri` to create URL parts * @param int $code HTTP status code */ public function go(array $options = [], int $code = 302): void { Response::go($this->url($options), $code); } /** * Checks if the intended template * for the page exists. */ public function hasTemplate(): bool { return $this->intendedTemplate() === $this->template(); } /** * Returns the Page Id */ public function id(): string { if ($this->id !== null) { return $this->id; } // set the id, depending on the parent if ($parent = $this->parent()) { return $this->id = $parent->id() . '/' . $this->uid(); } return $this->id = $this->uid(); } /** * Returns the template that should be * loaded if it exists. */ public function intendedTemplate(): Template { if ($this->intendedTemplate !== null) { return $this->intendedTemplate; } return $this->setTemplate($this->inventory()['template'])->intendedTemplate(); } /** * Returns the inventory of files * children and content files * @internal */ public function inventory(): array { if ($this->inventory !== null) { return $this->inventory; } $kirby = $this->kirby(); return $this->inventory = Dir::inventory( $this->root(), $kirby->contentExtension(), $kirby->contentIgnore(), $kirby->multilang() ); } /** * Compares the current object with the given page object * * @param \Kirby\Cms\Page|string $page */ public function is($page): bool { if ($page instanceof self === false) { if (is_string($page) === false) { return false; } $page = $this->kirby()->page($page); } if ($page instanceof self === false) { return false; } return $this->id() === $page->id(); } /** * Checks if the page is accessible that accessible and listable. * This permission depends on the `read` option until v5 */ public function isAccessible(): bool { // TODO: remove this check when `read` option deprecated in v5 if ($this->isReadable() === false) { return false; } static $accessible = []; $role = $this->kirby()->user()?->role()->id() ?? '__none__'; $template = $this->intendedTemplate()->name(); $accessible[$role] ??= []; return $accessible[$role][$template] ??= $this->permissions()->can('access'); } /** * Checks if the page is the current page */ public function isActive(): bool { return $this->site()->page()?->is($this) === true; } /** * Checks if the page is a direct or indirect ancestor * of the given $page object */ public function isAncestorOf(Page $child): bool { return $child->parents()->has($this->id()) === true; } /** * Checks if the page can be cached in the * pages cache. This will also check if one * of the ignore rules from the config kick in. */ public function isCacheable(): bool { $kirby = $this->kirby(); $cache = $kirby->cache('pages'); $options = $cache->options(); $ignore = $options['ignore'] ?? null; // the pages cache is switched off if (($options['active'] ?? false) === false) { return false; } // inspect the current request $request = $kirby->request(); // disable the pages cache for any request types but GET or HEAD if (in_array($request->method(), ['GET', 'HEAD']) === false) { return false; } // disable the pages cache when there's request data if (empty($request->data()) === false) { return false; } // disable the pages cache when there are any params if ($request->params()->isNotEmpty()) { return false; } // check for a custom ignore rule if ($ignore instanceof Closure) { if ($ignore($this) === true) { return false; } } // ignore pages by id if (is_array($ignore) === true) { if (in_array($this->id(), $ignore) === true) { return false; } } return true; } /** * Checks if the page is a child of the given page * * @param \Kirby\Cms\Page|string $parent */ public function isChildOf($parent): bool { return $this->parent()?->is($parent) ?? false; } /** * Checks if the page is a descendant of the given page * * @param \Kirby\Cms\Page|string $parent */ public function isDescendantOf($parent): bool { if (is_string($parent) === true) { $parent = $this->site()->find($parent); } if (!$parent) { return false; } return $this->parents()->has($parent->id()) === true; } /** * Checks if the page is a descendant of the currently active page */ public function isDescendantOfActive(): bool { if ($active = $this->site()->page()) { return $this->isDescendantOf($active); } return false; } /** * Checks if the current page is a draft */ public function isDraft(): bool { return $this->isDraft; } /** * Checks if the page is the error page */ public function isErrorPage(): bool { return $this->id() === $this->site()->errorPageId(); } /** * Checks if the page is the home page */ public function isHomePage(): bool { return $this->id() === $this->site()->homePageId(); } /** * It's often required to check for the * home and error page to stop certain * actions. That's why there's a shortcut. */ public function isHomeOrErrorPage(): bool { return $this->isHomePage() === true || $this->isErrorPage() === true; } /** * Check if the page can be listable by the current user * This permission depends on the `read` option until v5 */ public function isListable(): bool { // TODO: remove this check when `read` option deprecated in v5 if ($this->isReadable() === false) { return false; } // not accessible also means not listable if ($this->isAccessible() === false) { return false; } static $listable = []; $role = $this->kirby()->user()?->role()->id() ?? '__none__'; $template = $this->intendedTemplate()->name(); $listable[$role] ??= []; return $listable[$role][$template] ??= $this->permissions()->can('list'); } /** * Checks if the page has a sorting number */ public function isListed(): bool { return $this->isPublished() && $this->num() !== null; } public function isMovableTo(Page|Site $parent): bool { try { return PageRules::move($this, $parent); } catch (Throwable) { return false; } } /** * Checks if the page is open. * Open pages are either the current one * or descendants of the current one. */ public function isOpen(): bool { if ($this->isActive() === true) { return true; } if ($this->site()->page()?->parents()->has($this->id()) === true) { return true; } return false; } /** * Checks if the page is not a draft. */ public function isPublished(): bool { return $this->isDraft() === false; } /** * Check if the page can be read by the current user * @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options. */ public function isReadable(): bool { static $readable = []; $role = $this->kirby()->user()?->role()->id() ?? '__none__'; $template = $this->intendedTemplate()->name(); $readable[$role] ??= []; return $readable[$role][$template] ??= $this->permissions()->can('read'); } /** * Checks if the page is sortable */ public function isSortable(): bool { return $this->permissions()->can('sort'); } /** * Checks if the page has no sorting number */ public function isUnlisted(): bool { return $this->isPublished() && $this->num() === null; } /** * Checks if the page access is verified. * This is only used for drafts so far. * @internal */ public function isVerified(string|null $token = null): bool { if ( $this->isPublished() === true && $this->parents()->findBy('status', 'draft') === null ) { return true; } if ($token === null) { return false; } return $this->token() === $token; } /** * Returns the root to the media folder for the page * @internal */ public function mediaRoot(): string { return $this->kirby()->root('media') . '/pages/' . $this->id(); } /** * The page's base URL for any files * @internal */ public function mediaUrl(): string { return $this->kirby()->url('media') . '/pages/' . $this->id(); } /** * Creates a page model if it has been registered * @internal */ public static function model(string $name, array $props = []): static { $class = static::$models[$name] ?? null; $class ??= static::$models['default'] ?? null; if ($class !== null) { $object = new $class($props); if ($object instanceof self) { return $object; } } return new static($props); } /** * Returns the last modification date of the page */ public function modified( string|null $format = null, string|null $handler = null, string|null $languageCode = null ): int|string|false|null { $identifier = $this->isDraft() === true ? 'changes' : 'published'; $modified = $this->storage()->modified( $identifier, $languageCode ); if ($modified === null) { return null; } return Str::date($modified, $format, $handler); } /** * Returns the sorting number */ public function num(): int|null { return $this->num; } /** * Returns the panel info object */ public function panel(): Panel { return new Panel($this); } /** * Returns the parent Page object */ public function parent(): Page|null { return $this->parent; } /** * Returns the parent id, if a parent exists * @internal */ public function parentId(): string|null { return $this->parent()?->id(); } /** * Returns the parent model, * which can either be another Page * or the Site * @internal */ public function parentModel(): Page|Site { return $this->parent() ?? $this->site(); } /** * Returns a list of all parents and their parents recursively */ public function parents(): Pages { $parents = new Pages(); $page = $this->parent(); while ($page !== null) { $parents->append($page->id(), $page); $page = $page->parent(); } return $parents; } /** * Return the permanent URL to the page using its UUID * @since 3.8.0 */ public function permalink(): string|null { return $this->uuid()?->url(); } /** * Returns the permissions object for this page */ public function permissions(): PagePermissions { return new PagePermissions($this); } /** * Draft preview Url * @internal */ public function previewUrl(): string|null { $preview = $this->blueprint()->preview(); if ($preview === false) { return null; } $url = match ($preview) { true => $this->url(), default => $preview }; if ($this->isDraft() === true) { $uri = new Uri($url); $uri->query->token = $this->token(); $url = $uri->toString(); } return $url; } /** * Renders the page with the given data. * * An optional content type can be passed to * render a content representation instead of * the default template. * * @param string $contentType * @throws \Kirby\Exception\NotFoundException If the default template cannot be found */ public function render(array $data = [], $contentType = 'html'): string { $kirby = $this->kirby(); $cache = $cacheId = $html = null; // try to get the page from cache if (empty($data) === true && $this->isCacheable() === true) { $cache = $kirby->cache('pages'); $cacheId = $this->cacheId($contentType); $result = $cache->get($cacheId); $html = $result['html'] ?? null; $response = $result['response'] ?? []; $usesAuth = $result['usesAuth'] ?? false; $usesCookies = $result['usesCookies'] ?? []; // if the request contains dynamic data that the cached response // relied on, don't use the cache to allow dynamic code to run if (Responder::isPrivate($usesAuth, $usesCookies) === true) { $html = null; } // reconstruct the response configuration if (empty($html) === false && empty($response) === false) { $kirby->response()->fromArray($response); } } // fetch the page regularly if ($html === null) { if ($contentType === 'html') { $template = $this->template(); } else { $template = $this->representation($contentType); } if ($template->exists() === false) { throw new NotFoundException([ 'key' => 'template.default.notFound' ]); } $kirby->data = $this->controller($data, $contentType); // trigger before hook and apply for `data` $kirby->data = $kirby->apply('page.render:before', [ 'contentType' => $contentType, 'data' => $kirby->data, 'page' => $this ], 'data'); // render the page $html = $template->render($kirby->data); // trigger after hook and apply for `html` $html = $kirby->apply('page.render:after', [ 'contentType' => $contentType, 'data' => $kirby->data, 'html' => $html, 'page' => $this ], 'html'); // cache the result $response = $kirby->response(); if ($cache !== null && $response->cache() === true) { $cache->set($cacheId, [ 'html' => $html, 'response' => $response->toArray(), 'usesAuth' => $response->usesAuth(), 'usesCookies' => $response->usesCookies(), ], $response->expires() ?? 0); } } return $html; } /** * @internal * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found */ public function representation(mixed $type): Template { $kirby = $this->kirby(); $template = $this->template(); $representation = $kirby->template($template->name(), $type); if ($representation->exists() === true) { return $representation; } throw new NotFoundException('The content representation cannot be found'); } /** * Returns the absolute root to the page directory * No matter if it exists or not. */ public function root(): string { return $this->root ??= $this->kirby()->root('content') . '/' . $this->diruri(); } /** * Returns the PageRules class instance * which is being used in various methods * to check for valid actions and input. */ protected function rules(): PageRules { return new PageRules(); } /** * Search all pages within the current page */ public function search(string|null $query = null, string|array $params = []): Pages { return $this->index()->search($query, $params); } /** * Sets the Blueprint object * * @return $this */ protected function setBlueprint(array|null $blueprint = null): static { if ($blueprint !== null) { $blueprint['model'] = $this; $this->blueprint = new PageBlueprint($blueprint); } return $this; } /** * Sets the intended template * * @return $this */ protected function setTemplate(string|null $template = null): static { if ($template !== null) { $this->intendedTemplate = $this->kirby()->template($template); } return $this; } /** * Sets the Url * * @return $this */ protected function setUrl(string|null $url = null): static { if (is_string($url) === true) { $url = rtrim($url, '/'); } $this->url = $url; return $this; } /** * Returns the slug of the page */ public function slug(string|null $languageCode = null): string { if ($this->kirby()->multilang() === true) { $languageCode ??= $this->kirby()->languageCode(); $defaultLanguageCode = $this->kirby()->defaultLanguage()->code(); if ( $languageCode !== $defaultLanguageCode && $translation = $this->translations()->find($languageCode) ) { return $translation->slug() ?? $this->slug; } } return $this->slug; } /** * Returns the page status, which * can be `draft`, `listed` or `unlisted` */ public function status(): string { if ($this->isDraft() === true) { return 'draft'; } if ($this->isUnlisted() === true) { return 'unlisted'; } return 'listed'; } /** * Returns the final template */ public function template(): Template { if ($this->template !== null) { return $this->template; } $intended = $this->intendedTemplate(); if ($intended->exists() === true) { return $this->template = $intended; } return $this->template = $this->kirby()->template('default'); } /** * Returns the title field or the slug as fallback */ public function title(): Field { return $this->content()->get('title')->or($this->slug()); } /** * Converts the most important * properties to array */ public function toArray(): array { return array_merge(parent::toArray(), [ 'children' => $this->children()->keys(), 'files' => $this->files()->keys(), 'id' => $this->id(), 'mediaUrl' => $this->mediaUrl(), 'mediaRoot' => $this->mediaRoot(), 'num' => $this->num(), 'parent' => $this->parent()?->id(), 'slug' => $this->slug(), 'template' => $this->template(), 'uid' => $this->uid(), 'uri' => $this->uri(), 'url' => $this->url() ]); } /** * Returns a verification token, which * is used for the draft authentication */ protected function token(): string { return $this->kirby()->contentToken( $this, $this->id() . $this->template() ); } /** * Returns the UID of the page. * The UID is basically the same as the * slug, but stays the same on * multi-language sites. Whereas the slug * can be translated. * * @see self::slug() */ public function uid(): string { return $this->slug; } /** * The uri is the same as the id, except * that it will be translated in multi-language setups */ public function uri(string|null $languageCode = null): string { // set the id, depending on the parent if ($parent = $this->parent()) { return $parent->uri($languageCode) . '/' . $this->slug($languageCode); } return $this->slug($languageCode); } /** * Returns the Url * * @param array|string|null $options */ public function url($options = null): string { if ($this->kirby()->multilang() === true) { if (is_string($options) === true) { return $this->urlForLanguage($options); } return $this->urlForLanguage(null, $options); } if ($options !== null) { return Url::to($this->url(), $options); } if (is_string($this->url) === true) { return $this->url; } if ($this->isHomePage() === true) { return $this->url = $this->site()->url(); } if ($parent = $this->parent()) { if ($parent->isHomePage() === true) { return $this->url = $this->kirby()->url('base') . '/' . $parent->uid() . '/' . $this->uid(); } return $this->url = $this->parent()->url() . '/' . $this->uid(); } return $this->url = $this->kirby()->url('base') . '/' . $this->uid(); } /** * Builds the Url for a specific language * * @internal * @param string|null $language */ public function urlForLanguage( $language = null, array|null $options = null ): string { if ($options !== null) { return Url::to($this->urlForLanguage($language), $options); } if ($this->isHomePage() === true) { return $this->url = $this->site()->urlForLanguage($language); } if ($parent = $this->parent()) { if ($parent->isHomePage() === true) { return $this->url = $this->site()->urlForLanguage($language) . '/' . $parent->slug($language) . '/' . $this->slug($language); } return $this->url = $this->parent()->urlForLanguage($language) . '/' . $this->slug($language); } return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language); } }