* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ class Page extends ModelWithContent { use PageActions; use PageSiblings; use HasChildren; use HasFiles; use HasMethods; use HasSiblings; public const CLASS_ALIAS = 'page'; /** * All registered page methods * * @var array */ public static $methods = []; /** * Registry with all Page models * * @var array */ public static $models = []; /** * The PageBlueprint object * * @var \Kirby\Cms\PageBlueprint */ protected $blueprint; /** * Nesting level * * @var int */ protected $depth; /** * Sorting number + slug * * @var string */ protected $dirname; /** * Path of dirnames * * @var string */ protected $diruri; /** * Draft status flag * * @var bool */ protected $isDraft; /** * The Page id * * @var string */ protected $id; /** * The template, that should be loaded * if it exists * * @var \Kirby\Cms\Template */ protected $intendedTemplate; /** * @var array */ protected $inventory; /** * The sorting number * * @var int|null */ protected $num; /** * The parent page * * @var \Kirby\Cms\Page|null */ protected $parent; /** * Absolute path to the page directory * * @var string */ protected $root; /** * The parent Site object * * @var \Kirby\Cms\Site|null */ protected $site; /** * The URL-appendix aka slug * * @var string */ protected $slug; /** * The intended page template * * @var \Kirby\Cms\Template */ protected $template; /** * The page url * * @var string|null */ protected $url; /** * Magic caller * * @param string $method * @param array $arguments * @return mixed */ public function __call(string $method, array $arguments = []) { // 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); } /** * Creates a new page object * * @param array $props */ public function __construct(array $props) { // set the slug as the first property $this->slug = $props['slug'] ?? null; // add all other properties $this->setProperties($props); } /** * Improved `var_dump` output * * @return array */ 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 * @param bool $relative * @return string */ public function apiUrl(bool $relative = false): string { if ($relative === true) { return 'pages/' . $this->panel()->id(); } else { return $this->kirby()->url('api') . '/pages/' . $this->panel()->id(); } } /** * Returns the blueprint object * * @return \Kirby\Cms\PageBlueprint */ public function blueprint() { if (is_a($this->blueprint, 'Kirby\Cms\PageBlueprint') === true) { return $this->blueprint; } return $this->blueprint = PageBlueprint::factory('pages/' . $this->intendedTemplate(), 'pages/default', $this); } /** * Returns an array with all blueprints that are available for the page * * @param string|null $inSection * @return array */ public function blueprints(?string $inSection = null): array { if ($inSection !== null) { return $this->blueprint()->section($inSection)->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 $e) { // skip invalid blueprints } } return array_values($blueprints); } /** * Builds the cache id for the page * * @param string $contentType * @return string */ 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 * @param array $data * @param string|null $languageCode * @return array */ public function contentFileData(array $data, ?string $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 * @param string|null $languageCode * @return string */ public function contentFileName(?string $languageCode = null): string { return $this->intendedTemplate()->name(); } /** * Call the page controller * * @internal * @param array $data * @param string $contentType * @return array * @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' => $site->children(), 'page' => $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 if (empty($controllerData) === false) { $classes = [ 'kirby' => 'Kirby\Cms\App', 'site' => 'Kirby\Cms\Site', 'pages' => 'Kirby\Cms\Pages', 'page' => 'Kirby\Cms\Page' ]; foreach ($controllerData as $key => $value) { if (array_key_exists($key, $classes) === true) { if (is_a($value, $classes[$key]) === true) { $data[$key] = $value; } else { throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"'); } } else { $data[$key] = $value; } } } return $data; } /** * Returns a number indicating how deep the page * is nested within the content folder * * @return int */ public function depth(): int { return $this->depth ??= (substr_count($this->id(), '/') + 1); } /** * Sorting number + Slug * * @return string */ public function dirname(): string { if ($this->dirname !== null) { return $this->dirname; } if ($this->num() !== null) { return $this->dirname = $this->num() . Dir::$numSeparator . $this->uid(); } else { return $this->dirname = $this->uid(); } } /** * Sorting number + Slug * * @return string */ 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; } else { return $this->diruri = $dirname; } } /** * Checks if the page exists on disk * * @return bool */ public function exists(): bool { return is_dir($this->root()) === true; } /** * Constructs a Page object and also * takes page models into account. * * @internal * @param mixed $props * @return static */ public static function factory($props) { if (empty($props['model']) === false) { return static::model($props['model'], $props); } return new static($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) { Response::go($this->url($options), $code); } /** * Checks if the intended template * for the page exists. * * @return bool */ public function hasTemplate(): bool { return $this->intendedTemplate() === $this->template(); } /** * Returns the Page Id * * @return string */ 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. * * @return \Kirby\Cms\Template */ public function intendedTemplate() { if ($this->intendedTemplate !== null) { return $this->intendedTemplate; } return $this->setTemplate($this->inventory()['template'])->intendedTemplate(); } /** * Returns the inventory of files * children and content files * * @internal * @return array */ 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 * @return bool */ public function is($page): bool { if (is_a($page, 'Kirby\Cms\Page') === false) { if (is_string($page) === false) { return false; } $page = $this->kirby()->page($page); } if (is_a($page, 'Kirby\Cms\Page') === false) { return false; } return $this->id() === $page->id(); } /** * Checks if the page is the current page * * @return bool */ public function isActive(): bool { if ($page = $this->site()->page()) { if ($page->is($this) === true) { return true; } } return false; } /** * Checks if the page is a direct or indirect ancestor of the given $page object * * @param Page $child * @return bool */ 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. * * @return bool */ 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 (is_a($ignore, 'Closure') === true) { 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 * @return bool */ public function isChildOf($parent): bool { if ($parentObj = $this->parent()) { return $parentObj->is($parent); } return false; } /** * Checks if the page is a descendant of the given page * * @param \Kirby\Cms\Page|string $parent * @return bool */ 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 * * @return bool */ public function isDescendantOfActive(): bool { if ($active = $this->site()->page()) { return $this->isDescendantOf($active); } return false; } /** * Checks if the current page is a draft * * @return bool */ public function isDraft(): bool { return $this->isDraft; } /** * Checks if the page is the error page * * @return bool */ public function isErrorPage(): bool { return $this->id() === $this->site()->errorPageId(); } /** * Checks if the page is the home page * * @return bool */ 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. * * @return bool */ public function isHomeOrErrorPage(): bool { return $this->isHomePage() === true || $this->isErrorPage() === true; } /** * Checks if the page has a sorting number * * @return bool */ public function isListed(): bool { return $this->num() !== null; } /** * Checks if the page is open. * Open pages are either the current one * or descendants of the current one. * * @return bool */ public function isOpen(): bool { if ($this->isActive() === true) { return true; } if ($page = $this->site()->page()) { if ($page->parents()->has($this->id()) === true) { return true; } } return false; } /** * Checks if the page is not a draft. * * @return bool */ public function isPublished(): bool { return $this->isDraft() === false; } /** * Check if the page can be read by the current user * * @return bool */ public function isReadable(): bool { static $readable = []; $template = $this->intendedTemplate()->name(); if (isset($readable[$template]) === true) { return $readable[$template]; } return $readable[$template] = $this->permissions()->can('read'); } /** * Checks if the page is sortable * * @return bool */ public function isSortable(): bool { return $this->permissions()->can('sort'); } /** * Checks if the page has no sorting number * * @return bool */ public function isUnlisted(): bool { return $this->isListed() === false; } /** * Checks if the page access is verified. * This is only used for drafts so far. * * @internal * @param string|null $token * @return bool */ public function isVerified(string $token = null) { if ( $this->isDraft() === false && $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 * @return string */ public function mediaRoot(): string { return $this->kirby()->root('media') . '/pages/' . $this->id(); } /** * The page's base URL for any files * * @internal * @return string */ public function mediaUrl(): string { return $this->kirby()->url('media') . '/pages/' . $this->id(); } /** * Creates a page model if it has been registered * * @internal * @param string $name * @param array $props * @return static */ public static function model(string $name, array $props = []) { if ($class = (static::$models[$name] ?? null)) { $object = new $class($props); if (is_a($object, 'Kirby\Cms\Page') === true) { return $object; } } return new static($props); } /** * Returns the last modification date of the page * * @param string|null $format * @param string|null $handler * @param string|null $languageCode * @return int|string */ public function modified(string $format = null, string $handler = null, string $languageCode = null) { return F::modified( $this->contentFile($languageCode), $format, $handler ?? $this->kirby()->option('date.handler', 'date') ); } /** * Returns the sorting number * * @return int|null */ public function num(): ?int { return $this->num; } /** * Returns the panel info object * * @return \Kirby\Panel\Page */ public function panel() { return new Panel($this); } /** * Returns the parent Page object * * @return \Kirby\Cms\Page|null */ public function parent() { return $this->parent; } /** * Returns the parent id, if a parent exists * * @internal * @return string|null */ public function parentId(): ?string { if ($parent = $this->parent()) { return $parent->id(); } return null; } /** * Returns the parent model, * which can either be another Page * or the Site * * @internal * @return \Kirby\Cms\Page|\Kirby\Cms\Site */ public function parentModel() { return $this->parent() ?? $this->site(); } /** * Returns a list of all parents and their parents recursively * * @return \Kirby\Cms\Pages */ public function parents() { $parents = new Pages(); $page = $this->parent(); while ($page !== null) { $parents->append($page->id(), $page); $page = $page->parent(); } return $parents; } /** * Returns the permissions object for this page * * @return \Kirby\Cms\PagePermissions */ public function permissions() { return new PagePermissions($this); } /** * Draft preview Url * * @internal * @return string|null */ public function previewUrl(): ?string { $preview = $this->blueprint()->preview(); if ($preview === false) { return null; } if ($preview === true) { $url = $this->url(); } else { $url = $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 array $data * @param string $contentType * @return string * @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); // render the page $html = $template->render($kirby->data); // 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 * @param mixed $type * @return \Kirby\Cms\Template * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found */ public function representation($type) { $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. * * @return string */ 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. * * @return \Kirby\Cms\PageRules */ protected function rules() { return new PageRules(); } /** * Search all pages within the current page * * @param string|null $query * @param array $params * @return \Kirby\Cms\Pages */ public function search(string $query = null, $params = []) { return $this->index()->search($query, $params); } /** * Sets the Blueprint object * * @param array|null $blueprint * @return $this */ protected function setBlueprint(array $blueprint = null) { if ($blueprint !== null) { $blueprint['model'] = $this; $this->blueprint = new PageBlueprint($blueprint); } return $this; } /** * Sets the dirname manually, which works * more reliable in connection with the inventory * than computing the dirname afterwards * * @param string|null $dirname * @return $this */ protected function setDirname(string $dirname = null) { $this->dirname = $dirname; return $this; } /** * Sets the draft flag * * @param bool $isDraft * @return $this */ protected function setIsDraft(bool $isDraft = null) { $this->isDraft = $isDraft ?? false; return $this; } /** * Sets the sorting number * * @param int|null $num * @return $this */ protected function setNum(int $num = null) { $this->num = $num === null ? $num : (int)$num; return $this; } /** * Sets the parent page object * * @param \Kirby\Cms\Page|null $parent * @return $this */ protected function setParent(Page $parent = null) { $this->parent = $parent; return $this; } /** * Sets the absolute path to the page * * @param string|null $root * @return $this */ protected function setRoot(string $root = null) { $this->root = $root; return $this; } /** * Sets the required Page slug * * @param string $slug * @return $this */ protected function setSlug(string $slug) { $this->slug = $slug; return $this; } /** * Sets the intended template * * @param string|null $template * @return $this */ protected function setTemplate(string $template = null) { if ($template !== null) { $this->intendedTemplate = $this->kirby()->template($template); } return $this; } /** * Sets the Url * * @param string|null $url * @return $this */ protected function setUrl(string $url = null) { if (is_string($url) === true) { $url = rtrim($url, '/'); } $this->url = $url; return $this; } /** * Returns the slug of the page * * @param string|null $languageCode * @return string */ public function slug(string $languageCode = null): string { if ($this->kirby()->multilang() === true) { if ($languageCode === null) { $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` * * @return string */ public function status(): string { if ($this->isDraft() === true) { return 'draft'; } if ($this->isUnlisted() === true) { return 'unlisted'; } return 'listed'; } /** * Returns the final template * * @return \Kirby\Cms\Template */ public function 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 * * @return \Kirby\Cms\Field */ public function title() { return $this->content()->get('title')->or($this->slug()); } /** * Converts the most important * properties to array * * @return array */ public function toArray(): array { return [ 'children' => $this->children()->keys(), 'content' => $this->content()->toArray(), 'files' => $this->files()->keys(), 'id' => $this->id(), 'mediaUrl' => $this->mediaUrl(), 'mediaRoot' => $this->mediaRoot(), 'num' => $this->num(), 'parent' => $this->parent() ? $this->parent()->id() : null, 'slug' => $this->slug(), 'template' => $this->template(), 'translations' => $this->translations()->toArray(), 'uid' => $this->uid(), 'uri' => $this->uri(), 'url' => $this->url() ]; } /** * Returns a verification token, which * is used for the draft authentication * * @return string */ 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() * @return string */ 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 * * @param string|null $languageCode * @return string */ public function uri(string $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 * @return string */ public function url($options = null): string { if ($this->kirby()->multilang() === true) { if (is_string($options) === true) { return $this->urlForLanguage($options); } else { 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(); } else { 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 * @param array|null $options * @return string */ public function urlForLanguage($language = null, array $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); } else { return $this->url = $this->parent()->urlForLanguage($language) . '/' . $this->slug($language); } } return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language); } /** * Deprecated! */ /** * Provides a kirbytag or markdown * tag for the page, which will be * used in the panel, when the page * gets dragged onto a textarea * * @deprecated 3.6.0 Use `->panel()->dragText()` instead * @todo Remove in 3.8.0 * * @internal * @param string|null $type (null|auto|kirbytext|markdown) * @return string * @codeCoverageIgnore */ public function dragText(string $type = null): string { Helpers::deprecated('Cms\Page::dragText() has been deprecated and will be removed in Kirby 3.8.0. Use $page->panel()->dragText() instead.'); return $this->panel()->dragText($type); } /** * Returns the escaped Id, which is * used in the panel to make routing work properly * * @deprecated 3.6.0 Use `->panel()->id()` instead * @todo Remove in 3.8.0 * * @internal * @return string * @codeCoverageIgnore */ public function panelId(): string { Helpers::deprecated('Cms\Page::panelId() has been deprecated and will be removed in Kirby 3.8.0. Use $page->panel()->id() instead.'); return $this->panel()->id(); } /** * Returns the full path without leading slash * * @deprecated 3.6.0 Use `->panel()->path()` instead * @todo Remove in 3.8.0 * * @internal * @return string * @codeCoverageIgnore */ public function panelPath(): string { Helpers::deprecated('Cms\Page::panelPath() has been deprecated and will be removed in Kirby 3.8.0. Use $page->panel()->path() instead.'); return $this->panel()->path(); } /** * Prepares the response data for page pickers * and page fields * * @deprecated 3.6.0 Use `->panel()->pickerData()` instead * @todo Remove in 3.8.0 * * @param array|null $params * @return array * @codeCoverageIgnore */ public function panelPickerData(array $params = []): array { Helpers::deprecated('Cms\Page::panelPickerData() has been deprecated and will be removed in Kirby 3.8.0. Use $page->panel()->pickerData() instead.'); return $this->panel()->pickerData($params); } /** * Returns the url to the editing view * in the panel * * @deprecated 3.6.0 Use `->panel()->url()` instead * @todo Remove in 3.8.0 * * @internal * @param bool $relative * @return string * @codeCoverageIgnore */ public function panelUrl(bool $relative = false): string { Helpers::deprecated('Cms\Page::panelUrl() has been deprecated and will be removed in Kirby 3.8.0. Use $page->panel()->url() instead.'); return $this->panel()->url($relative); } }