", '', $this->content);
+ }
+
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * Get the contentMeta array and initialize content first if it's not already
+ *
+ * @return mixed
+ */
+ public function contentMeta()
+ {
+ if ($this->content === null) {
+ $this->content();
+ }
+
+ return $this->getContentMeta();
+ }
+
+ /**
+ * Add an entry to the page's contentMeta array
+ *
+ * @param $name
+ * @param $value
+ */
+ public function addContentMeta($name, $value)
+ {
+ $this->content_meta[$name] = $value;
+ }
+
+ /**
+ * Return the whole contentMeta array as it currently stands
+ *
+ * @param null $name
+ *
+ * @return mixed
+ */
+ public function getContentMeta($name = null)
+ {
+ if ($name) {
+ if (isset($this->content_meta[$name])) {
+ return $this->content_meta[$name];
+ }
+
+ return null;
+ }
+
+ return $this->content_meta;
+ }
+
+ /**
+ * Sets the whole content meta array in one shot
+ *
+ * @param $content_meta
+ *
+ * @return mixed
+ */
+ public function setContentMeta($content_meta)
+ {
+ return $this->content_meta = $content_meta;
+ }
+
+ /**
+ * Process the Markdown content. Uses Parsedown or Parsedown Extra depending on configuration
+ */
+ protected function processMarkdown()
+ {
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
+ $defaults = (array)$config->get('system.pages.markdown');
+ if (isset($this->header()->markdown)) {
+ $defaults = array_merge($defaults, $this->header()->markdown);
+ }
+
+ // pages.markdown_extra is deprecated, but still check it...
+ if (!isset($defaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) {
+ $defaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra');
+ }
+
+ // Initialize the preferred variant of Parsedown
+ if ($defaults['extra']) {
+ $parsedown = new ParsedownExtra($this, $defaults);
+ } else {
+ $parsedown = new Parsedown($this, $defaults);
+ }
+
+ $this->content = $parsedown->text($this->content);
+ }
+
+
+ /**
+ * Process the Twig page content.
+ */
+ private function processTwig()
+ {
+ $twig = Grav::instance()['twig'];
+ $this->content = $twig->processPage($this, $this->content);
+ }
+
+ /**
+ * Fires the onPageContentProcessed event, and caches the page content using a unique ID for the page
+ */
+ public function cachePageContent()
+ {
+ $cache = Grav::instance()['cache'];
+ $cache_id = md5('page' . $this->id());
+ $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]);
+ }
+
+ /**
+ * Needed by the onPageContentProcessed event to get the raw page content
+ *
+ * @return string the current page content
+ */
+ public function getRawContent()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Needed by the onPageContentProcessed event to set the raw page content
+ *
+ * @param $content
+ */
+ public function setRawContent($content)
+ {
+ $this->content = $content;
+ }
+
+ /**
+ * Get value from a page variable (used mostly for creating edit forms).
+ *
+ * @param string $name Variable name.
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ public function value($name, $default = null)
+ {
+ if ($name === 'content') {
+ return $this->raw_content;
+ }
+ if ($name === 'route') {
+ return $this->parent()->rawRoute();
+ }
+ if ($name === 'order') {
+ $order = $this->order();
+
+ return $order ? (int)$this->order() : '';
+ }
+ if ($name === 'ordering') {
+ return (bool)$this->order();
+ }
+ if ($name === 'folder') {
+ return preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder);
+ }
+ if ($name === 'slug') {
+ return $this->slug();
+ }
+ if ($name === 'name') {
+ $language = $this->language() ? '.' . $this->language() : '';
+ $name_val = str_replace($language . '.md', '', $this->name());
+ if ($this->modular()) {
+ return 'modular/' . $name_val;
+ }
+
+ return $name_val;
+ }
+ if ($name === 'media') {
+ return $this->media()->all();
+ }
+ if ($name === 'media.file') {
+ return $this->media()->files();
+ }
+ if ($name === 'media.video') {
+ return $this->media()->videos();
+ }
+ if ($name === 'media.image') {
+ return $this->media()->images();
+ }
+ if ($name === 'media.audio') {
+ return $this->media()->audios();
+ }
+
+ $path = explode('.', $name);
+ $scope = array_shift($path);
+
+ if ($name === 'frontmatter') {
+ return $this->frontmatter;
+ }
+
+ if ($scope === 'header') {
+ $current = $this->header();
+ foreach ($path as $field) {
+ if (is_object($current) && isset($current->{$field})) {
+ $current = $current->{$field};
+ } elseif (is_array($current) && isset($current[$field])) {
+ $current = $current[$field];
+ } else {
+ return $default;
+ }
+ }
+
+ return $current;
+ }
+
+ return $default;
+ }
+
+ /**
+ * Gets and Sets the Page raw content
+ *
+ * @param null $var
+ *
+ * @return null
+ */
+ public function rawMarkdown($var = null)
+ {
+ if ($var !== null) {
+ $this->raw_content = $var;
+ }
+
+ return $this->raw_content;
+ }
+
+ /**
+ * Get file object to the page.
+ *
+ * @return MarkdownFile|null
+ */
+ public function file()
+ {
+ if ($this->name) {
+ return MarkdownFile::instance($this->filePath());
+ }
+
+ return null;
+ }
+
+ /**
+ * Save page if there's a file assigned to it.
+ *
+ * @param bool|mixed $reorder Internal use.
+ */
+ public function save($reorder = true)
+ {
+ // Perform move, copy [or reordering] if needed.
+ $this->doRelocation();
+
+ $file = $this->file();
+ if ($file) {
+ $file->filename($this->filePath());
+ $file->header((array)$this->header());
+ $file->markdown($this->raw_content);
+ $file->save();
+ }
+
+ // Perform reorder if required
+ if ($reorder && is_array($reorder)) {
+ $this->doReorder($reorder);
+ }
+
+ $this->_original = null;
+ }
+
+ /**
+ * Prepare move page to new location. Moves also everything that's under the current page.
+ *
+ * You need to call $this->save() in order to perform the move.
+ *
+ * @param Page $parent New parent page.
+ *
+ * @return $this
+ */
+ public function move(Page $parent)
+ {
+ if (!$this->_original) {
+ $clone = clone $this;
+ $this->_original = $clone;
+ }
+
+ $this->_action = 'move';
+
+ if ($this->route() === $parent->route()) {
+ throw new Exception('Failed: Cannot set page parent to self');
+ }
+ if (Utils::startsWith($parent->rawRoute(), $this->rawRoute())) {
+ throw new Exception('Failed: Cannot set page parent to a child of current page');
+ }
+
+ $this->parent($parent);
+ $this->id(time() . md5($this->filePath()));
+
+ if ($parent->path()) {
+ $this->path($parent->path() . '/' . $this->folder());
+ }
+
+ if ($parent->route()) {
+ $this->route($parent->route() . '/' . $this->slug());
+ } else {
+ $this->route(Grav::instance()['pages']->root()->route() . '/' . $this->slug());
+ }
+
+ $this->raw_route = null;
+
+ return $this;
+ }
+
+ /**
+ * Prepare a copy from the page. Copies also everything that's under the current page.
+ *
+ * Returns a new Page object for the copy.
+ * You need to call $this->save() in order to perform the move.
+ *
+ * @param Page $parent New parent page.
+ *
+ * @return $this
+ */
+ public function copy($parent)
+ {
+ $this->move($parent);
+ $this->_action = 'copy';
+
+ return $this;
+ }
+
+ /**
+ * Get blueprints for the page.
+ *
+ * @return Blueprint
+ */
+ public function blueprints()
+ {
+ $grav = Grav::instance();
+
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
+
+ $blueprint = $pages->blueprints($this->blueprintName());
+ $fields = $blueprint->fields();
+ $edit_mode = isset($grav['admin']) ? $grav['config']->get('plugins.admin.edit_mode') : null;
+
+ // override if you only want 'normal' mode
+ if (empty($fields) && ($edit_mode === 'auto' || $edit_mode === 'normal')) {
+ $blueprint = $pages->blueprints('default');
+ }
+
+ // override if you only want 'expert' mode
+ if (!empty($fields) && $edit_mode === 'expert') {
+ $blueprint = $pages->blueprints('');
+ }
+
+ return $blueprint;
+ }
+
+ /**
+ * Get the blueprint name for this page. Use the blueprint form field if set
+ *
+ * @return string
+ */
+ public function blueprintName()
+ {
+ $blueprint_name = filter_input(INPUT_POST, 'blueprint', FILTER_SANITIZE_STRING) ?: $this->template();
+
+ return $blueprint_name;
+ }
+
+ /**
+ * Validate page header.
+ *
+ * @throws Exception
+ */
+ public function validate()
+ {
+ $blueprints = $this->blueprints();
+ $blueprints->validate($this->toArray());
+ }
+
+ /**
+ * Filter page header from illegal contents.
+ */
+ public function filter()
+ {
+ $blueprints = $this->blueprints();
+ $values = $blueprints->filter($this->toArray());
+ if ($values && isset($values['header'])) {
+ $this->header($values['header']);
+ }
+ }
+
+ /**
+ * Get unknown header variables.
+ *
+ * @return array
+ */
+ public function extra()
+ {
+ $blueprints = $this->blueprints();
+
+ return $blueprints->extra($this->toArray()['header'], 'header.');
+ }
+
+ /**
+ * Convert page to an array.
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return [
+ 'header' => (array)$this->header(),
+ 'content' => (string)$this->value('content')
+ ];
+ }
+
+ /**
+ * Convert page to YAML encoded string.
+ *
+ * @return string
+ */
+ public function toYaml()
+ {
+ return Yaml::dump($this->toArray(), 20);
+ }
+
+ /**
+ * Convert page to JSON encoded string.
+ *
+ * @return string
+ */
+ public function toJson()
+ {
+ return json_encode($this->toArray());
+ }
+
+ /**
+ * Gets and sets the associated media as found in the page folder.
+ *
+ * @param Media $var Representation of associated media.
+ *
+ * @return Media Representation of associated media.
+ */
+ public function media($var = null)
+ {
+ /** @var Cache $cache */
+ $cache = Grav::instance()['cache'];
+
+ if ($var) {
+ $this->media = $var;
+ }
+ if ($this->media === null) {
+ // Use cached media if possible.
+ $media_cache_id = md5('media' . $this->id());
+ if (!$media = $cache->fetch($media_cache_id)) {
+ $media = new Media($this->path());
+ $cache->save($media_cache_id, $media);
+ }
+ $this->media = $media;
+ }
+
+ return $this->media;
+ }
+
+ /**
+ * Gets and sets the name field. If no name field is set, it will return 'default.md'.
+ *
+ * @param string $var The name of this page.
+ *
+ * @return string The name of this page.
+ */
+ public function name($var = null)
+ {
+ if ($var !== null) {
+ $this->name = $var;
+ }
+
+ return empty($this->name) ? 'default.md' : $this->name;
+ }
+
+ /**
+ * Returns child page type.
+ *
+ * @return string
+ */
+ public function childType()
+ {
+ return isset($this->header->child_type) ? (string)$this->header->child_type : '';
+ }
+
+ /**
+ * Gets and sets the template field. This is used to find the correct Twig template file to render.
+ * If no field is set, it will return the name without the .md extension
+ *
+ * @param string $var the template name
+ *
+ * @return string the template name
+ */
+ public function template($var = null)
+ {
+ if ($var !== null) {
+ $this->template = $var;
+ }
+ if (empty($this->template)) {
+ $this->template = ($this->modular() ? 'modular/' : '') . str_replace($this->extension(), '', $this->name());
+ }
+
+ return $this->template;
+ }
+
+ /**
+ * Allows a page to override the output render format, usually the extension provided
+ * in the URL. (e.g. `html`, `json`, `xml`, etc).
+ *
+ * @param null $var
+ *
+ * @return null
+ */
+ public function templateFormat($var = null)
+ {
+ if ($var !== null) {
+ $this->template_format = $var;
+ }
+
+ if (empty($this->template_format)) {
+ $this->template_format = Grav::instance()['uri']->extension('html');
+ }
+
+ return $this->template_format;
+ }
+
+ /**
+ * Gets and sets the extension field.
+ *
+ * @param null $var
+ *
+ * @return null|string
+ */
+ public function extension($var = null)
+ {
+ if ($var !== null) {
+ $this->extension = $var;
+ }
+ if (empty($this->extension)) {
+ $this->extension = '.' . pathinfo($this->name(), PATHINFO_EXTENSION);
+ }
+
+ return $this->extension;
+ }
+
+ /**
+ * Returns the page extension, got from the page `url_extension` config and falls back to the
+ * system config `system.pages.append_url_extension`.
+ *
+ * @return string The extension of this page. For example `.html`
+ */
+ public function urlExtension()
+ {
+ if ($this->home()) {
+ return '';
+ }
+
+ // if not set in the page get the value from system config
+ if (empty($this->url_extension)) {
+ $this->url_extension = trim(isset($this->header->append_url_extension) ? $this->header->append_url_extension : Grav::instance()['config']->get('system.pages.append_url_extension',
+ false));
+ }
+
+ return $this->url_extension;
+ }
+
+ /**
+ * Gets and sets the expires field. If not set will return the default
+ *
+ * @param int $var The new expires value.
+ *
+ * @return int The expires value
+ */
+ public function expires($var = null)
+ {
+ if ($var !== null) {
+ $this->expires = $var;
+ }
+
+ return !isset($this->expires) ? Grav::instance()['config']->get('system.pages.expires') : $this->expires;
+ }
+
+ /**
+ * Gets and sets the cache-control property. If not set it will return the default value (null)
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control for more details on valid options
+ *
+ * @param null $var
+ * @return null
+ */
+ public function cacheControl($var = null)
+ {
+ if ($var !== null) {
+ $this->cache_control = $var;
+ }
+
+ return !isset($this->cache_control) ? Grav::instance()['config']->get('system.pages.cache_control') : $this->cache_control;
+ }
+
+ /**
+ * Gets and sets the title for this Page. If no title is set, it will use the slug() to get a name
+ *
+ * @param string $var the title of the Page
+ *
+ * @return string the title of the Page
+ */
+ public function title($var = null)
+ {
+ if ($var !== null) {
+ $this->title = $var;
+ }
+ if (empty($this->title)) {
+ $this->title = ucfirst($this->slug());
+ }
+
+ return $this->title;
+ }
+
+ /**
+ * Gets and sets the menu name for this Page. This is the text that can be used specifically for navigation.
+ * If no menu field is set, it will use the title()
+ *
+ * @param string $var the menu field for the page
+ *
+ * @return string the menu field for the page
+ */
+ public function menu($var = null)
+ {
+ if ($var !== null) {
+ $this->menu = $var;
+ }
+ if (empty($this->menu)) {
+ $this->menu = $this->title();
+ }
+
+ return $this->menu;
+ }
+
+ /**
+ * Gets and Sets whether or not this Page is visible for navigation
+ *
+ * @param bool $var true if the page is visible
+ *
+ * @return bool true if the page is visible
+ */
+ public function visible($var = null)
+ {
+ if ($var !== null) {
+ $this->visible = (bool)$var;
+ }
+
+ if ($this->visible === null) {
+ // Set item visibility in menu if folder is different from slug
+ // eg folder = 01.Home and slug = Home
+ if (preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder)) {
+ $this->visible = true;
+ } else {
+ $this->visible = false;
+ }
+ }
+
+ return $this->visible;
+ }
+
+ /**
+ * Gets and Sets whether or not this Page is considered published
+ *
+ * @param bool $var true if the page is published
+ *
+ * @return bool true if the page is published
+ */
+ public function published($var = null)
+ {
+ if ($var !== null) {
+ $this->published = (bool)$var;
+ }
+
+ // If not published, should not be visible in menus either
+ if ($this->published === false) {
+ $this->visible = false;
+ }
+
+ return $this->published;
+ }
+
+ /**
+ * Gets and Sets the Page publish date
+ *
+ * @param string $var string representation of a date
+ *
+ * @return int unix timestamp representation of the date
+ */
+ public function publishDate($var = null)
+ {
+ if ($var !== null) {
+ $this->publish_date = Utils::date2timestamp($var, $this->dateformat);
+ }
+
+ return $this->publish_date;
+ }
+
+ /**
+ * Gets and Sets the Page unpublish date
+ *
+ * @param string $var string representation of a date
+ *
+ * @return int|null unix timestamp representation of the date
+ */
+ public function unpublishDate($var = null)
+ {
+ if ($var !== null) {
+ $this->unpublish_date = Utils::date2timestamp($var, $this->dateformat);
+ }
+
+ return $this->unpublish_date;
+ }
+
+ /**
+ * Gets and Sets whether or not this Page is routable, ie you can reach it
+ * via a URL.
+ * The page must be *routable* and *published*
+ *
+ * @param bool $var true if the page is routable
+ *
+ * @return bool true if the page is routable
+ */
+ public function routable($var = null)
+ {
+ if ($var !== null) {
+ $this->routable = (bool)$var;
+ }
+
+ return $this->routable && $this->published();
+ }
+
+ public function ssl($var = null)
+ {
+ if ($var !== null) {
+ $this->ssl = (bool)$var;
+ }
+
+ return $this->ssl;
+ }
+
+ /**
+ * Gets and Sets the process setup for this Page. This is multi-dimensional array that consists of
+ * a simple array of arrays with the form array("markdown"=>true) for example
+ *
+ * @param array $var an Array of name value pairs where the name is the process and value is true or false
+ *
+ * @return array an Array of name value pairs where the name is the process and value is true or false
+ */
+ public function process($var = null)
+ {
+ if ($var !== null) {
+ $this->process = (array)$var;
+ }
+
+ return $this->process;
+ }
+
+ /**
+ * Returns the state of the debugger override etting for this page
+ *
+ * @return mixed
+ */
+ public function debugger()
+ {
+ if (isset($this->debugger) && $this->debugger === false) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Function to merge page metadata tags and build an array of Metadata objects
+ * that can then be rendered in the page.
+ *
+ * @param array $var an Array of metadata values to set
+ *
+ * @return array an Array of metadata values for the page
+ */
+ public function metadata($var = null)
+ {
+ if ($var !== null) {
+ $this->metadata = (array)$var;
+ }
+
+ // if not metadata yet, process it.
+ if (null === $this->metadata) {
+ $header_tag_http_equivs = ['content-type', 'default-style', 'refresh', 'x-ua-compatible'];
+
+ $this->metadata = [];
+
+ $metadata = [];
+ // Set the Generator tag
+ $metadata['generator'] = 'GravCMS';
+
+ // Get initial metadata for the page
+ $metadata = array_merge($metadata, Grav::instance()['config']->get('site.metadata'));
+
+ if (isset($this->header->metadata)) {
+ // Merge any site.metadata settings in with page metadata
+ $metadata = array_merge($metadata, $this->header->metadata);
+ }
+
+ // Build an array of meta objects..
+ foreach ((array)$metadata as $key => $value) {
+ // Lowercase the key
+ $key = strtolower($key);
+ // If this is a property type metadata: "og", "twitter", "facebook" etc
+ // Backward compatibility for nested arrays in metas
+ if (is_array($value)) {
+ foreach ($value as $property => $prop_value) {
+ $prop_key = $key . ':' . $property;
+ $this->metadata[$prop_key] = [
+ 'name' => $prop_key,
+ 'property' => $prop_key,
+ 'content' => htmlspecialchars($prop_value, ENT_QUOTES, 'UTF-8')
+ ];
+ }
+ } else {
+ // If it this is a standard meta data type
+ if ($value) {
+ if (in_array($key, $header_tag_http_equivs)) {
+ $this->metadata[$key] = [
+ 'http_equiv' => $key,
+ 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
+ ];
+ } elseif ($key === 'charset') {
+ $this->metadata[$key] = ['charset' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')];
+ } else {
+ // if it's a social metadata with separator, render as property
+ $separator = strpos($key, ':');
+ $hasSeparator = $separator && $separator < strlen($key) - 1;
+ $entry = [
+ 'content' => htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
+ ];
+
+ if ($hasSeparator && !Utils::startsWith($key, 'twitter')) {
+ $entry['property'] = $key;
+ } else {
+ $entry['name'] = $key;
+ }
+
+ $this->metadata[$key] = $entry;
+ }
+ }
+ }
+ }
+ }
+
+ return $this->metadata;
+ }
+
+ /**
+ * Gets and Sets the slug for the Page. The slug is used in the URL routing. If not set it uses
+ * the parent folder from the path
+ *
+ * @param string $var the slug, e.g. 'my-blog'
+ *
+ * @return string the slug
+ */
+ public function slug($var = null)
+ {
+ if ($var !== null && $var !== '') {
+ $this->slug = $var;
+ }
+
+ if (empty($this->slug)) {
+ $this->slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder));
+ }
+
+
+ return $this->slug;
+ }
+
+ /**
+ * Get/set order number of this page.
+ *
+ * @param int $var
+ *
+ * @return int|bool
+ */
+ public function order($var = null)
+ {
+ if ($var !== null) {
+ $order = !empty($var) ? sprintf('%02d.', (int)$var) : '';
+ $this->folder($order . preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder));
+
+ return $order;
+ }
+
+ preg_match(PAGE_ORDER_PREFIX_REGEX, $this->folder, $order);
+
+ return isset($order[0]) ? $order[0] : false;
+ }
+
+ /**
+ * Gets the URL for a page - alias of url().
+ *
+ * @param bool $include_host
+ *
+ * @return string the permalink
+ */
+ public function link($include_host = false)
+ {
+ return $this->url($include_host);
+ }
+
+ /**
+ * Gets the URL with host information, aka Permalink.
+ * @return string The permalink.
+ */
+ public function permalink()
+ {
+ return $this->url(true, false, true, true);
+ }
+
+ /**
+ * Returns the canonical URL for a page
+ *
+ * @param bool $include_lang
+ *
+ * @return string
+ */
+ public function canonical($include_lang = true)
+ {
+ return $this->url(true, true, $include_lang);
+ }
+
+ /**
+ * Gets the url for the Page.
+ *
+ * @param bool $include_host Defaults false, but true would include http://yourhost.com
+ * @param bool $canonical true to return the canonical URL
+ * @param bool $include_lang
+ * @param bool $raw_route
+ *
+ * @return string The url.
+ */
+ public function url($include_host = false, $canonical = false, $include_lang = true, $raw_route = false)
+ {
+ $grav = Grav::instance();
+
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ /** @var Uri $uri */
+ $uri = $grav['uri'];
+
+ // Override any URL when external_url is set
+ if (isset($this->external_url)) {
+ return $this->external_url;
+ }
+
+ // get pre-route
+ if ($include_lang && $language->enabled()) {
+ $pre_route = $language->getLanguageURLPrefix();
+ } else {
+ $pre_route = '';
+ }
+
+ // add full route if configured to do so
+ if ($config->get('system.absolute_urls', false)) {
+ $include_host = true;
+ }
+
+ // get canonical route if requested
+ if ($canonical) {
+ $route = $pre_route . $this->routeCanonical();
+ } elseif ($raw_route) {
+ $route = $pre_route . $this->rawRoute();
+ } else {
+ $route = $pre_route . $this->route();
+ }
+
+ $rootUrl = $uri->rootUrl($include_host) . $pages->base();
+
+ $url = $rootUrl . '/' . trim($route, '/') . $this->urlExtension();
+
+ // trim trailing / if not root
+ if ($url !== '/') {
+ $url = rtrim($url, '/');
+ }
+
+ return Uri::filterPath($url);
+ }
+
+ /**
+ * Gets the route for the page based on the route headers if available, else from
+ * the parents route and the current Page's slug.
+ *
+ * @param string $var Set new default route.
+ *
+ * @return string The route for the Page.
+ */
+ public function route($var = null)
+ {
+ if ($var !== null) {
+ $this->route = $var;
+ }
+
+ if (empty($this->route)) {
+ $baseRoute = null;
+
+ // calculate route based on parent slugs
+ $parent = $this->parent();
+ if (isset($parent)) {
+ if ($this->hide_home_route && $parent->route() === $this->home_route) {
+ $baseRoute = '';
+ } else {
+ $baseRoute = (string)$parent->route();
+ }
+ }
+
+ $this->route = isset($baseRoute) ? $baseRoute . '/' . $this->slug() : null;
+
+ if (!empty($this->routes) && isset($this->routes['default'])) {
+ $this->routes['aliases'][] = $this->route;
+ $this->route = $this->routes['default'];
+
+ return $this->route;
+ }
+ }
+
+ return $this->route;
+ }
+
+ /**
+ * Helper method to clear the route out so it regenerates next time you use it
+ */
+ public function unsetRouteSlug()
+ {
+ unset($this->route);
+ unset($this->slug);
+ }
+
+ /**
+ * Gets and Sets the page raw route
+ *
+ * @param null $var
+ *
+ * @return null|string
+ */
+ public function rawRoute($var = null)
+ {
+ if ($var !== null) {
+ $this->raw_route = $var;
+ }
+
+ if (empty($this->raw_route)) {
+ $baseRoute = $this->parent ? (string)$this->parent()->rawRoute() : null;
+
+ $slug = $this->adjustRouteCase(preg_replace(PAGE_ORDER_PREFIX_REGEX, '', $this->folder));
+
+ $this->raw_route = isset($baseRoute) ? $baseRoute . '/' . $slug : null;
+ }
+
+ return $this->raw_route;
+ }
+
+ /**
+ * Gets the route aliases for the page based on page headers.
+ *
+ * @param array $var list of route aliases
+ *
+ * @return array The route aliases for the Page.
+ */
+ public function routeAliases($var = null)
+ {
+ if ($var !== null) {
+ $this->routes['aliases'] = (array)$var;
+ }
+
+ if (!empty($this->routes) && isset($this->routes['aliases'])) {
+ return $this->routes['aliases'];
+ }
+
+ return [];
+ }
+
+ /**
+ * Gets the canonical route for this page if its set. If provided it will use
+ * that value, else if it's `true` it will use the default route.
+ *
+ * @param null $var
+ *
+ * @return bool|string
+ */
+ public function routeCanonical($var = null)
+ {
+ if ($var !== null) {
+ $this->routes['canonical'] = (array)$var;
+ }
+
+ if (!empty($this->routes) && isset($this->routes['canonical'])) {
+ return $this->routes['canonical'];
+ }
+
+ return $this->route();
+ }
+
+ /**
+ * Gets and sets the identifier for this Page object.
+ *
+ * @param string $var the identifier
+ *
+ * @return string the identifier
+ */
+ public function id($var = null)
+ {
+ if ($var !== null) {
+ // store unique per language
+ $active_lang = Grav::instance()['language']->getLanguage() ?: '';
+ $id = $active_lang . $var;
+ $this->id = $id;
+ }
+
+ return $this->id;
+ }
+
+ /**
+ * Gets and sets the modified timestamp.
+ *
+ * @param int $var modified unix timestamp
+ *
+ * @return int modified unix timestamp
+ */
+ public function modified($var = null)
+ {
+ if ($var !== null) {
+ $this->modified = $var;
+ }
+
+ return $this->modified;
+ }
+
+ /**
+ * Gets the redirect set in the header.
+ *
+ * @param string $var redirect url
+ *
+ * @return string
+ */
+ public function redirect($var = null)
+ {
+ if ($var !== null) {
+ $this->redirect = $var;
+ }
+
+ return $this->redirect;
+ }
+
+ /**
+ * Gets and sets the option to show the etag header for the page.
+ *
+ * @param boolean $var show etag header
+ *
+ * @return boolean show etag header
+ */
+ public function eTag($var = null)
+ {
+ if ($var !== null) {
+ $this->etag = $var;
+ }
+ if (!isset($this->etag)) {
+ $this->etag = (bool)Grav::instance()['config']->get('system.pages.etag');
+ }
+
+ return $this->etag;
+ }
+
+ /**
+ * Gets and sets the option to show the last_modified header for the page.
+ *
+ * @param boolean $var show last_modified header
+ *
+ * @return boolean show last_modified header
+ */
+ public function lastModified($var = null)
+ {
+ if ($var !== null) {
+ $this->last_modified = $var;
+ }
+ if (!isset($this->last_modified)) {
+ $this->last_modified = (bool)Grav::instance()['config']->get('system.pages.last_modified');
+ }
+
+ return $this->last_modified;
+ }
+
+ /**
+ * Gets and sets the path to the .md file for this Page object.
+ *
+ * @param string $var the file path
+ *
+ * @return string|null the file path
+ */
+ public function filePath($var = null)
+ {
+ if ($var !== null) {
+ // Filename of the page.
+ $this->name = basename($var);
+ // Folder of the page.
+ $this->folder = basename(dirname($var));
+ // Path to the page.
+ $this->path = dirname(dirname($var));
+ }
+
+ return $this->path . '/' . $this->folder . '/' . ($this->name ?: '');
+ }
+
+ /**
+ * Gets the relative path to the .md file
+ *
+ * @return string The relative file path
+ */
+ public function filePathClean()
+ {
+ $path = str_replace(ROOT_DIR, '', $this->filePath());
+
+ return $path;
+ }
+
+ /**
+ * Returns the clean path to the page file
+ */
+ public function relativePagePath()
+ {
+ $path = str_replace('/' . $this->name(), '', $this->filePathClean());
+
+ return $path;
+ }
+
+ /**
+ * Gets and sets the path to the folder where the .md for this Page object resides.
+ * This is equivalent to the filePath but without the filename.
+ *
+ * @param string $var the path
+ *
+ * @return string|null the path
+ */
+ public function path($var = null)
+ {
+ if ($var !== null) {
+ // Folder of the page.
+ $this->folder = basename($var);
+ // Path to the page.
+ $this->path = dirname($var);
+ }
+
+ return $this->path ? $this->path . '/' . $this->folder : null;
+ }
+
+ /**
+ * Get/set the folder.
+ *
+ * @param string $var Optional path
+ *
+ * @return string|null
+ */
+ public function folder($var = null)
+ {
+ if ($var !== null) {
+ $this->folder = $var;
+ }
+
+ return $this->folder;
+ }
+
+ /**
+ * Gets and sets the date for this Page object. This is typically passed in via the page headers
+ *
+ * @param string $var string representation of a date
+ *
+ * @return int unix timestamp representation of the date
+ */
+ public function date($var = null)
+ {
+ if ($var !== null) {
+ $this->date = Utils::date2timestamp($var, $this->dateformat);
+ }
+
+ if (!$this->date) {
+ $this->date = $this->modified;
+ }
+
+ return $this->date;
+ }
+
+ /**
+ * Gets and sets the date format for this Page object. This is typically passed in via the page headers
+ * using typical PHP date string structure - http://php.net/manual/en/function.date.php
+ *
+ * @param string $var string representation of a date format
+ *
+ * @return string string representation of a date format
+ */
+ public function dateformat($var = null)
+ {
+ if ($var !== null) {
+ $this->dateformat = $var;
+ }
+
+ return $this->dateformat;
+ }
+
+ /**
+ * Gets and sets the order by which any sub-pages should be sorted.
+ *
+ * @param string $var the order, either "asc" or "desc"
+ *
+ * @return string the order, either "asc" or "desc"
+ */
+ public function orderDir($var = null)
+ {
+ if ($var !== null) {
+ $this->order_dir = $var;
+ }
+ if (empty($this->order_dir)) {
+ $this->order_dir = 'asc';
+ }
+
+ return $this->order_dir;
+ }
+
+ /**
+ * Gets and sets the order by which the sub-pages should be sorted.
+ *
+ * default - is the order based on the file system, ie 01.Home before 02.Advark
+ * title - is the order based on the title set in the pages
+ * date - is the order based on the date set in the pages
+ * folder - is the order based on the name of the folder with any numerics omitted
+ *
+ * @param string $var supported options include "default", "title", "date", and "folder"
+ *
+ * @return string supported options include "default", "title", "date", and "folder"
+ */
+ public function orderBy($var = null)
+ {
+ if ($var !== null) {
+ $this->order_by = $var;
+ }
+
+ return $this->order_by;
+ }
+
+ /**
+ * Gets the manual order set in the header.
+ *
+ * @param string $var supported options include "default", "title", "date", and "folder"
+ *
+ * @return array
+ */
+ public function orderManual($var = null)
+ {
+ if ($var !== null) {
+ $this->order_manual = $var;
+ }
+
+ return (array)$this->order_manual;
+ }
+
+ /**
+ * Gets and sets the maxCount field which describes how many sub-pages should be displayed if the
+ * sub_pages header property is set for this page object.
+ *
+ * @param int $var the maximum number of sub-pages
+ *
+ * @return int the maximum number of sub-pages
+ */
+ public function maxCount($var = null)
+ {
+ if ($var !== null) {
+ $this->max_count = (int)$var;
+ }
+ if (empty($this->max_count)) {
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+ $this->max_count = (int)$config->get('system.pages.list.count');
+ }
+
+ return $this->max_count;
+ }
+
+ /**
+ * Gets and sets the taxonomy array which defines which taxonomies this page identifies itself with.
+ *
+ * @param array $var an array of taxonomies
+ *
+ * @return array an array of taxonomies
+ */
+ public function taxonomy($var = null)
+ {
+ if ($var !== null) {
+ $this->taxonomy = $var;
+ }
+
+ return $this->taxonomy;
+ }
+
+ /**
+ * Gets and sets the modular var that helps identify this page is a modular child
+ *
+ * @param bool $var true if modular_twig
+ *
+ * @return bool true if modular_twig
+ */
+ public function modular($var = null)
+ {
+ return $this->modularTwig($var);
+ }
+
+ /**
+ * Gets and sets the modular_twig var that helps identify this page as a modular child page that will need
+ * twig processing handled differently from a regular page.
+ *
+ * @param bool $var true if modular_twig
+ *
+ * @return bool true if modular_twig
+ */
+ public function modularTwig($var = null)
+ {
+ if ($var !== null) {
+ $this->modular_twig = (bool)$var;
+ if ($var) {
+ $this->visible(false);
+ // some routable logic
+ if (empty($this->header->routable)) {
+ $this->routable = false;
+ }
+ }
+ }
+
+ return $this->modular_twig;
+ }
+
+ /**
+ * Gets the configured state of the processing method.
+ *
+ * @param string $process the process, eg "twig" or "markdown"
+ *
+ * @return bool whether or not the processing method is enabled for this Page
+ */
+ public function shouldProcess($process)
+ {
+ return isset($this->process[$process]) ? (bool)$this->process[$process] : false;
+ }
+
+ /**
+ * Gets and Sets the parent object for this page
+ *
+ * @param Page $var the parent page object
+ *
+ * @return Page|null the parent page object if it exists.
+ */
+ public function parent(Page $var = null)
+ {
+ if ($var) {
+ $this->parent = $var->path();
+
+ return $var;
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->get($this->parent);
+ }
+
+ /**
+ * Gets the top parent object for this page
+ *
+ * @return Page|null the top parent page object if it exists.
+ */
+ public function topParent()
+ {
+ $topParent = $this->parent();
+
+ if (!$topParent) {
+ return null;
+ }
+
+ while (true) {
+ $theParent = $topParent->parent();
+ if ($theParent !== null && $theParent->parent() !== null) {
+ $topParent = $theParent;
+ } else {
+ break;
+ }
+ }
+
+ return $topParent;
+ }
+
+ /**
+ * Returns children of this page.
+ *
+ * @return \Grav\Common\Page\Collection
+ */
+ public function children()
+ {
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->children($this->path());
+ }
+
+
+ /**
+ * Check to see if this item is the first in an array of sub-pages.
+ *
+ * @return boolean True if item is first.
+ */
+ public function isFirst()
+ {
+ $collection = $this->parent()->collection('content', false);
+ if ($collection instanceof Collection) {
+ return $collection->isFirst($this->path());
+ }
+
+ return true;
+ }
+
+ /**
+ * Check to see if this item is the last in an array of sub-pages.
+ *
+ * @return boolean True if item is last
+ */
+ public function isLast()
+ {
+ $collection = $this->parent()->collection('content', false);
+ if ($collection instanceof Collection) {
+ return $collection->isLast($this->path());
+ }
+
+ return true;
+ }
+
+ /**
+ * Gets the previous sibling based on current position.
+ *
+ * @return Page the previous Page item
+ */
+ public function prevSibling()
+ {
+ return $this->adjacentSibling(-1);
+ }
+
+ /**
+ * Gets the next sibling based on current position.
+ *
+ * @return Page the next Page item
+ */
+ public function nextSibling()
+ {
+ return $this->adjacentSibling(1);
+ }
+
+ /**
+ * Returns the adjacent sibling based on a direction.
+ *
+ * @param integer $direction either -1 or +1
+ *
+ * @return Page|bool the sibling page
+ */
+ public function adjacentSibling($direction = 1)
+ {
+ $collection = $this->parent()->collection('content', false);
+ if ($collection instanceof Collection) {
+ return $collection->adjacentSibling($this->path(), $direction);
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the item in the current position.
+ *
+ * @param string $path the path the item
+ *
+ * @return Integer the index of the current page.
+ */
+ public function currentPosition()
+ {
+ $collection = $this->parent()->collection('content', false);
+ if ($collection instanceof Collection) {
+ return $collection->currentPosition($this->path());
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns whether or not this page is the currently active page requested via the URL.
+ *
+ * @return bool True if it is active
+ */
+ public function active()
+ {
+ $uri_path = rtrim(urldecode(Grav::instance()['uri']->path()), '/') ?: '/';
+ $routes = Grav::instance()['pages']->routes();
+
+ if (isset($routes[$uri_path])) {
+ if ($routes[$uri_path] === $this->path()) {
+ return true;
+ }
+
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether or not this URI's URL contains the URL of the active page.
+ * Or in other words, is this page's URL in the current URL
+ *
+ * @return bool True if active child exists
+ */
+ public function activeChild()
+ {
+ $uri = Grav::instance()['uri'];
+ $pages = Grav::instance()['pages'];
+ $uri_path = rtrim(urldecode($uri->path()), '/');
+ $routes = Grav::instance()['pages']->routes();
+
+ if (isset($routes[$uri_path])) {
+ /** @var Page $child_page */
+ $child_page = $pages->dispatch($uri->route())->parent();
+ if ($child_page) {
+ while (!$child_page->root()) {
+ if ($this->path() === $child_page->path()) {
+ return true;
+ }
+ $child_page = $child_page->parent();
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether or not this page is the currently configured home page.
+ *
+ * @return bool True if it is the homepage
+ */
+ public function home()
+ {
+ $home = Grav::instance()['config']->get('system.home.alias');
+ $is_home = ($this->route() === $home || $this->rawRoute() === $home);
+
+ return $is_home;
+ }
+
+ /**
+ * Returns whether or not this page is the root node of the pages tree.
+ *
+ * @return bool True if it is the root
+ */
+ public function root()
+ {
+ if (!$this->parent && !$this->name && !$this->visible) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Helper method to return an ancestor page.
+ *
+ * @param string $url The url of the page
+ * @param bool $lookup Name of the parent folder
+ *
+ * @return \Grav\Common\Page\Page page you were looking for if it exists
+ */
+ public function ancestor($lookup = null)
+ {
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->ancestor($this->route, $lookup);
+ }
+
+ /**
+ * Helper method to return an ancestor page to inherit from. The current
+ * page object is returned.
+ *
+ * @param string $field Name of the parent folder
+ *
+ * @return Page
+ */
+ public function inherited($field)
+ {
+ list($inherited, $currentParams) = $this->getInheritedParams($field);
+
+ $this->modifyHeader($field, $currentParams);
+
+ return $inherited;
+ }
+
+ /**
+ * Helper method to return an ancestor field only to inherit from. The
+ * first occurrence of an ancestor field will be returned if at all.
+ *
+ * @param string $field Name of the parent folder
+ *
+ * @return array
+ */
+ public function inheritedField($field)
+ {
+ list($inherited, $currentParams) = $this->getInheritedParams($field);
+
+ return $currentParams;
+ }
+
+ /**
+ * Method that contains shared logic for inherited() and inheritedField()
+ *
+ * @param string $field Name of the parent folder
+ *
+ * @return array
+ */
+ protected function getInheritedParams($field)
+ {
+ $pages = Grav::instance()['pages'];
+
+ /** @var Pages $pages */
+ $inherited = $pages->inherited($this->route, $field);
+ $inheritedParams = (array)$inherited->value('header.' . $field);
+ $currentParams = (array)$this->value('header.' . $field);
+ if ($inheritedParams && is_array($inheritedParams)) {
+ $currentParams = array_replace_recursive($inheritedParams, $currentParams);
+ }
+
+ return [$inherited, $currentParams];
+ }
+
+ /**
+ * Helper method to return a page.
+ *
+ * @param string $url the url of the page
+ * @param bool $all
+ *
+ * @return \Grav\Common\Page\Page page you were looking for if it exists
+ */
+ public function find($url, $all = false)
+ {
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ return $pages->find($url, $all);
+ }
+
+ /**
+ * Get a collection of pages in the current context.
+ *
+ * @param string|array $params
+ * @param boolean $pagination
+ *
+ * @return Collection
+ * @throws \InvalidArgumentException
+ */
+ public function collection($params = 'content', $pagination = true)
+ {
+ if (is_string($params)) {
+ $params = (array)$this->value('header.' . $params);
+ } elseif (!is_array($params)) {
+ throw new \InvalidArgumentException('Argument should be either header variable name or array of parameters');
+ }
+
+ if (!isset($params['items'])) {
+ return new Collection();
+ }
+
+ // See if require published filter is set and use that, if assume published=true
+ $only_published = true;
+ if (isset($params['filter']['published']) && $params['filter']['published']) {
+ $only_published = false;
+ } elseif (isset($params['filter']['non-published']) && $params['filter']['non-published']) {
+ $only_published = false;
+ }
+
+ $collection = $this->evaluate($params['items'], $only_published);
+ if (!$collection instanceof Collection) {
+ $collection = new Collection();
+ }
+ $collection->setParams($params);
+
+ /** @var Uri $uri */
+ $uri = Grav::instance()['uri'];
+ /** @var Config $config */
+ $config = Grav::instance()['config'];
+
+ $process_taxonomy = isset($params['url_taxonomy_filters']) ? $params['url_taxonomy_filters'] : $config->get('system.pages.url_taxonomy_filters');
+
+ if ($process_taxonomy) {
+ foreach ((array)$config->get('site.taxonomies') as $taxonomy) {
+ if ($uri->param(rawurlencode($taxonomy))) {
+ $items = explode(',', $uri->param($taxonomy));
+ $collection->setParams(['taxonomies' => [$taxonomy => $items]]);
+
+ foreach ($collection as $page) {
+ // Don't filter modular pages
+ if ($page->modular()) {
+ continue;
+ }
+ foreach ($items as $item) {
+ $item = rawurldecode($item);
+ if (empty($page->taxonomy[$taxonomy]) || !in_array(htmlspecialchars_decode($item,
+ ENT_QUOTES), $page->taxonomy[$taxonomy])
+ ) {
+ $collection->remove($page->path());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // If a filter or filters are set, filter the collection...
+ if (isset($params['filter'])) {
+
+ // remove any inclusive sets from filer:
+ $sets = ['published', 'visible', 'modular', 'routable'];
+ foreach ($sets as $type) {
+ if (isset($params['filter'][$type]) && isset($params['filter']['non-'.$type])) {
+ if ($params['filter'][$type] && $params['filter']['non-'.$type]) {
+ unset ($params['filter'][$type]);
+ unset ($params['filter']['non-'.$type]);
+ }
+
+ }
+ }
+
+ foreach ((array)$params['filter'] as $type => $filter) {
+ switch ($type) {
+ case 'published':
+ if ((bool) $filter) {
+ $collection->published();
+ }
+ break;
+ case 'non-published':
+ if ((bool) $filter) {
+ $collection->nonPublished();
+ }
+ break;
+ case 'visible':
+ if ((bool) $filter) {
+ $collection->visible();
+ }
+ break;
+ case 'non-visible':
+ if ((bool) $filter) {
+ $collection->nonVisible();
+ }
+ break;
+ case 'modular':
+ if ((bool) $filter) {
+ $collection->modular();
+ }
+ break;
+ case 'non-modular':
+ if ((bool) $filter) {
+ $collection->nonModular();
+ }
+ break;
+ case 'routable':
+ if ((bool) $filter) {
+ $collection->routable();
+ }
+ break;
+ case 'non-routable':
+ if ((bool) $filter) {
+ $collection->nonRoutable();
+ }
+ break;
+ case 'type':
+ $collection->ofType($filter);
+ break;
+ case 'types':
+ $collection->ofOneOfTheseTypes($filter);
+ break;
+ case 'access':
+ $collection->ofOneOfTheseAccessLevels($filter);
+ break;
+ }
+ }
+ }
+
+ if (isset($params['dateRange'])) {
+ $start = isset($params['dateRange']['start']) ? $params['dateRange']['start'] : 0;
+ $end = isset($params['dateRange']['end']) ? $params['dateRange']['end'] : false;
+ $field = isset($params['dateRange']['field']) ? $params['dateRange']['field'] : false;
+ $collection->dateRange($start, $end, $field);
+ }
+
+ if (isset($params['order'])) {
+ $by = isset($params['order']['by']) ? $params['order']['by'] : 'default';
+ $dir = isset($params['order']['dir']) ? $params['order']['dir'] : 'asc';
+ $custom = isset($params['order']['custom']) ? $params['order']['custom'] : null;
+ $sort_flags = isset($params['order']['sort_flags']) ? $params['order']['sort_flags'] : null;
+
+ if (is_array($sort_flags)) {
+ $sort_flags = array_map('constant', $sort_flags); //transform strings to constant value
+ $sort_flags = array_reduce($sort_flags, function ($a, $b) {
+ return $a | $b;
+ }, 0); //merge constant values using bit or
+ }
+
+ $collection->order($by, $dir, $custom, $sort_flags);
+ }
+
+ /** @var Grav $grav */
+ $grav = Grav::instance()['grav'];
+
+ // New Custom event to handle things like pagination.
+ $grav->fireEvent('onCollectionProcessed', new Event(['collection' => $collection]));
+
+ // Slice and dice the collection if pagination is required
+ if ($pagination) {
+ $params = $collection->params();
+
+ $limit = isset($params['limit']) ? $params['limit'] : 0;
+ $start = !empty($params['pagination']) ? ($uri->currentPage() - 1) * $limit : 0;
+
+ if ($limit && $collection->count() > $limit) {
+ $collection->slice($start, $limit);
+ }
+ }
+
+ return $collection;
+ }
+
+ /**
+ * @param string|array $value
+ * @param bool $only_published
+ * @return mixed
+ * @internal
+ */
+ public function evaluate($value, $only_published = true)
+ {
+ // Parse command.
+ if (is_string($value)) {
+ // Format: @command.param
+ $cmd = $value;
+ $params = [];
+ } elseif (is_array($value) && count($value) == 1 && !is_int(key($value))) {
+ // Format: @command.param: { attr1: value1, attr2: value2 }
+ $cmd = (string)key($value);
+ $params = (array)current($value);
+ } else {
+ $result = [];
+ foreach ((array)$value as $key => $val) {
+ if (is_int($key)) {
+ $result = $result + $this->evaluate($val)->toArray();
+ } else {
+ $result = $result + $this->evaluate([$key => $val])->toArray();
+ }
+
+ }
+
+ return new Collection($result);
+ }
+
+ /** @var Pages $pages */
+ $pages = Grav::instance()['pages'];
+
+ $parts = explode('.', $cmd);
+ $current = array_shift($parts);
+
+ /** @var Collection $results */
+ $results = new Collection();
+
+ switch ($current) {
+ case 'self@':
+ case '@self':
+ if (!empty($parts)) {
+ switch ($parts[0]) {
+ case 'modular':
+ // @self.modular: false (alternative to @self.children)
+ if (!empty($params) && $params[0] === false) {
+ $results = $this->children()->nonModular();
+ break;
+ }
+ $results = $this->children()->modular();
+ break;
+ case 'children':
+ $results = $this->children()->nonModular();
+ break;
+ case 'all':
+ $results = $this->children();
+ break;
+ case 'parent':
+ $collection = new Collection();
+ $results = $collection->addPage($this->parent());
+ break;
+ case 'siblings':
+ if (!$this->parent()) {
+ return new Collection();
+ }
+ $results = $this->parent()->children()->remove($this->path());
+ break;
+ case 'descendants':
+ $results = $pages->all($this)->remove($this->path())->nonModular();
+ break;
+ }
+ }
+
+
+ break;
+
+ case 'page@':
+ case '@page':
+ $page = null;
+
+ if (!empty($params)) {
+ $page = $this->find($params[0]);
+ }
+
+ // safety check in case page is not found
+ if (!isset($page)) {
+ return $results;
+ }
+
+ // Handle a @page.descendants
+ if (!empty($parts)) {
+ switch ($parts[0]) {
+ case 'modular':
+ $results = new Collection();
+ foreach ($page->children() as $child) {
+ $results = $results->addPage($child);
+ }
+ $results->modular();
+ break;
+ case 'page':
+ case 'self':
+ $results = new Collection();
+ $results = $results->addPage($page)->nonModular();
+ break;
+
+ case 'descendants':
+ $results = $pages->all($page)->remove($page->path())->nonModular();
+ break;
+
+ case 'children':
+ $results = $page->children()->nonModular();
+ break;
+ }
+ } else {
+ $results = $page->children()->nonModular();
+ }
+
+ break;
+
+ case 'root@':
+ case '@root':
+ if (!empty($parts) && $parts[0] === 'descendants') {
+ $results = $pages->all($pages->root())->nonModular();
+ } else {
+ $results = $pages->root()->children()->nonModular();
+ }
+ break;
+
+ case 'taxonomy@':
+ case '@taxonomy':
+ // Gets a collection of pages by using one of the following formats:
+ // @taxonomy.category: blog
+ // @taxonomy.category: [ blog, featured ]
+ // @taxonomy: { category: [ blog, featured ], level: 1 }
+
+ /** @var Taxonomy $taxonomy_map */
+ $taxonomy_map = Grav::instance()['taxonomy'];
+
+ if (!empty($parts)) {
+ $params = [implode('.', $parts) => $params];
+ }
+ $results = $taxonomy_map->findTaxonomy($params);
+ break;
+ }
+
+ if ($only_published) {
+ $results = $results->published();
+ }
+
+ return $results;
+ }
+
+ /**
+ * Returns whether or not this Page object has a .md file associated with it or if its just a directory.
+ *
+ * @return bool True if its a page with a .md file associated
+ */
+ public function isPage()
+ {
+ if ($this->name) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether or not this Page object is a directory or a page.
+ *
+ * @return bool True if its a directory
+ */
+ public function isDir()
+ {
+ return !$this->isPage();
+ }
+
+ /**
+ * Returns whether the page exists in the filesystem.
+ *
+ * @return bool
+ */
+ public function exists()
+ {
+ $file = $this->file();
+
+ return $file && $file->exists();
+ }
+
+ /**
+ * Returns whether or not the current folder exists
+ *
+ * @return bool
+ */
+ public function folderExists()
+ {
+ return file_exists($this->path());
+ }
+
+ /**
+ * Cleans the path.
+ *
+ * @param string $path the path
+ *
+ * @return string the path
+ */
+ protected function cleanPath($path)
+ {
+ $lastchunk = strrchr($path, DS);
+ if (strpos($lastchunk, ':') !== false) {
+ $path = str_replace($lastchunk, '', $path);
+ }
+
+ return $path;
+ }
+
+ /**
+ * Reorders all siblings according to a defined order
+ *
+ * @param $new_order
+ */
+ protected function doReorder($new_order)
+ {
+ if (!$this->_original) {
+ return;
+ }
+
+ $pages = Grav::instance()['pages'];
+ $pages->init();
+
+ $this->_original->path($this->path());
+
+ $siblings = $this->parent()->children();
+ $siblings->order('slug', 'asc', $new_order);
+
+ $counter = 0;
+
+ // Reorder all moved pages.
+ foreach ($siblings as $slug => $page) {
+ $order = (int)trim($page->order(), '.');
+ $counter++;
+
+ if ($order) {
+ if ($page->path() === $this->path() && $this->folderExists()) {
+ // Handle current page; we do want to change ordering number, but nothing else.
+ $this->order($counter);
+ $this->save(false);
+ } else {
+ // Handle all the other pages.
+ $page = $pages->get($page->path());
+ if ($page && $page->folderExists() && !$page->_action) {
+ $page = $page->move($this->parent());
+ $page->order($counter);
+ $page->save(false);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Moves or copies the page in filesystem.
+ *
+ * @internal
+ *
+ * @throws Exception
+ */
+ protected function doRelocation()
+ {
+ if (!$this->_original) {
+ return;
+ }
+
+ if (is_dir($this->_original->path())) {
+ if ($this->_action === 'move') {
+ Folder::move($this->_original->path(), $this->path());
+ } elseif ($this->_action === 'copy') {
+ Folder::copy($this->_original->path(), $this->path());
+ }
+ }
+
+ if ($this->name() !== $this->_original->name()) {
+ $path = $this->path();
+ if (is_file($path . '/' . $this->_original->name())) {
+ rename($path . '/' . $this->_original->name(), $path . '/' . $this->name());
+ }
+ }
+
+ }
+
+ protected function setPublishState()
+ {
+ // Handle publishing dates if no explicit published option set
+ if (Grav::instance()['config']->get('system.pages.publish_dates') && !isset($this->header->published)) {
+ // unpublish if required, if not clear cache right before page should be unpublished
+ if ($this->unpublishDate()) {
+ if ($this->unpublishDate() < time()) {
+ $this->published(false);
+ } else {
+ $this->published();
+ Grav::instance()['cache']->setLifeTime($this->unpublishDate());
+ }
+ }
+ // publish if required, if not clear cache right before page is published
+ if ($this->publishDate() && $this->publishDate() > time()) {
+ $this->published(false);
+ Grav::instance()['cache']->setLifeTime($this->publishDate());
+ }
+ }
+ }
+
+ protected function adjustRouteCase($route)
+ {
+ $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls');
+
+ if ($case_insensitive) {
+ return mb_strtolower($route);
+ } else {
+ return $route;
+ }
+ }
+
+ /**
+ * Gets the Page Unmodified (original) version of the page.
+ *
+ * @return Page
+ * The original version of the page.
+ */
+ public function getOriginal()
+ {
+ return $this->_original;
+ }
+
+ /**
+ * Gets the action.
+ *
+ * @return string
+ * The Action string.
+ */
+ public function getAction()
+ {
+ return $this->_action;
+ }
+}
diff --git a/system/src/Grav/Common/Page/Pages.php b/system/src/Grav/Common/Page/Pages.php
new file mode 100644
index 0000000..12afa7e
--- /dev/null
+++ b/system/src/Grav/Common/Page/Pages.php
@@ -0,0 +1,1356 @@
+grav = $c;
+ }
+
+ /**
+ * Get or set base path for the pages.
+ *
+ * @param string $path
+ *
+ * @return string
+ */
+ public function base($path = null)
+ {
+ if ($path !== null) {
+ $path = trim($path, '/');
+ $this->base = $path ? '/' . $path : null;
+ $this->baseUrl = [];
+ }
+
+ return $this->base;
+ }
+
+ /**
+ *
+ * Get base URL for Grav pages.
+ *
+ * @param string $lang Optional language code for multilingual links.
+ * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
+ *
+ * @return string
+ */
+ public function baseUrl($lang = null, $absolute = null)
+ {
+ $lang = (string) $lang;
+ $type = $absolute === null ? 'base_url' : ($absolute ? 'base_url_absolute' : 'base_url_relative');
+ $key = "{$lang} {$type}";
+
+ if (!isset($this->baseUrl[$key])) {
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ if (!$lang) {
+ $lang = $language->getActive();
+ }
+
+ $path_append = rtrim($this->grav['pages']->base(), '/');
+ if ($language->getDefault() !== $lang || $config->get('system.languages.include_default_lang') === true) {
+ $path_append .= $lang ? '/' . $lang : '';
+ }
+
+ $this->baseUrl[$key] = $this->grav[$type] . $path_append;
+ }
+
+ return $this->baseUrl[$key];
+ }
+
+ /**
+ *
+ * Get home URL for Grav site.
+ *
+ * @param string $lang Optional language code for multilingual links.
+ * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
+ *
+ * @return string
+ */
+ public function homeUrl($lang = null, $absolute = null)
+ {
+ return $this->baseUrl($lang, $absolute) ?: '/';
+ }
+
+ /**
+ *
+ * Get home URL for Grav site.
+ *
+ * @param string $route Optional route to the page.
+ * @param string $lang Optional language code for multilingual links.
+ * @param bool $absolute If true, return absolute url, if false, return relative url. Otherwise return default.
+ *
+ * @return string
+ */
+ public function url($route = '/', $lang = null, $absolute = null)
+ {
+ if ($route === '/') {
+ return $this->homeUrl($lang, $absolute);
+ }
+
+ return $this->baseUrl($lang, $absolute) . Uri::filterPath($route);
+ }
+
+ /**
+ * Class initialization. Must be called before using this class.
+ */
+ public function init()
+ {
+ $config = $this->grav['config'];
+ $this->ignore_files = $config->get('system.pages.ignore_files');
+ $this->ignore_folders = $config->get('system.pages.ignore_folders');
+ $this->ignore_hidden = $config->get('system.pages.ignore_hidden');
+
+ $this->instances = [];
+ $this->children = [];
+ $this->routes = [];
+
+ $this->buildPages();
+ }
+
+ /**
+ * Get or set last modification time.
+ *
+ * @param int $modified
+ *
+ * @return int|null
+ */
+ public function lastModified($modified = null)
+ {
+ if ($modified && $modified > $this->last_modified) {
+ $this->last_modified = $modified;
+ }
+
+ return $this->last_modified;
+ }
+
+ /**
+ * Returns a list of all pages.
+ *
+ * @return array|Page[]
+ */
+ public function instances()
+ {
+ return $this->instances;
+ }
+
+ /**
+ * Returns a list of all routes.
+ *
+ * @return array
+ */
+ public function routes()
+ {
+ return $this->routes;
+ }
+
+ /**
+ * Adds a page and assigns a route to it.
+ *
+ * @param Page $page Page to be added.
+ * @param string $route Optional route (uses route from the object if not set).
+ */
+ public function addPage(Page $page, $route = null)
+ {
+ if (!isset($this->instances[$page->path()])) {
+ $this->instances[$page->path()] = $page;
+ }
+ $route = $page->route($route);
+ if ($page->parent()) {
+ $this->children[$page->parent()->path()][$page->path()] = ['slug' => $page->slug()];
+ }
+ $this->routes[$route] = $page->path();
+
+ $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
+ }
+
+ /**
+ * Sort sub-pages in a page.
+ *
+ * @param Page $page
+ * @param string $order_by
+ * @param string $order_dir
+ *
+ * @return array
+ */
+ public function sort(Page $page, $order_by = null, $order_dir = null, $sort_flags = null)
+ {
+ if ($order_by === null) {
+ $order_by = $page->orderBy();
+ }
+ if ($order_dir === null) {
+ $order_dir = $page->orderDir();
+ }
+
+ $path = $page->path();
+ $children = isset($this->children[$path]) ? $this->children[$path] : [];
+
+ if (!$children) {
+ return $children;
+ }
+
+ if (!isset($this->sort[$path][$order_by])) {
+ $this->buildSort($path, $children, $order_by, $page->orderManual(), $sort_flags);
+ }
+
+ $sort = $this->sort[$path][$order_by];
+
+ if ($order_dir !== 'asc') {
+ $sort = array_reverse($sort);
+ }
+
+ return $sort;
+ }
+
+ /**
+ * @param Collection $collection
+ * @param $orderBy
+ * @param string $orderDir
+ * @param null $orderManual
+ *
+ * @return array
+ * @internal
+ */
+ public function sortCollection(Collection $collection, $orderBy, $orderDir = 'asc', $orderManual = null, $sort_flags = null)
+ {
+ $items = $collection->toArray();
+ if (!$items) {
+ return [];
+ }
+
+ $lookup = md5(json_encode($items) . json_encode($orderManual) . $orderBy . $orderDir);
+ if (!isset($this->sort[$lookup][$orderBy])) {
+ $this->buildSort($lookup, $items, $orderBy, $orderManual, $sort_flags);
+ }
+
+ $sort = $this->sort[$lookup][$orderBy];
+
+ if ($orderDir !== 'asc') {
+ $sort = array_reverse($sort);
+ }
+
+ return $sort;
+
+ }
+
+ /**
+ * Get a page instance.
+ *
+ * @param string $path The filesystem full path of the page
+ *
+ * @return Page
+ * @throws \Exception
+ */
+ public function get($path)
+ {
+ return isset($this->instances[(string)$path]) ? $this->instances[(string)$path] : null;
+ }
+
+ /**
+ * Get children of the path.
+ *
+ * @param string $path
+ *
+ * @return Collection
+ */
+ public function children($path)
+ {
+ $children = isset($this->children[(string)$path]) ? $this->children[(string)$path] : [];
+
+ return new Collection($children, [], $this);
+ }
+
+ /**
+ * Get a page ancestor.
+ *
+ * @param string $route The relative URL of the page
+ * @param string $path The relative path of the ancestor folder
+ *
+ * @return Page|null
+ */
+ public function ancestor($route, $path = null)
+ {
+ if ($path !== null) {
+ $page = $this->dispatch($route, true);
+
+ if ($page && $page->path() === $path) {
+ return $page;
+ }
+ if ($page && !$page->parent()->root()) {
+ return $this->ancestor($page->parent()->route(), $path);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get a page ancestor trait.
+ *
+ * @param string $route The relative route of the page
+ * @param string $field The field name of the ancestor to query for
+ *
+ * @return Page|null
+ */
+ public function inherited($route, $field = null)
+ {
+ if ($field !== null) {
+
+ $page = $this->dispatch($route, true);
+
+ if ($page && $page->parent()->value('header.' . $field) !== null) {
+ return $page->parent();
+ }
+ if ($page && !$page->parent()->root()) {
+ return $this->inherited($page->parent()->route(), $field);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * alias method to return find a page.
+ *
+ * @param string $route The relative URL of the page
+ * @param bool $all
+ *
+ * @return Page|null
+ */
+ public function find($route, $all = false)
+ {
+ return $this->dispatch($route, $all, false);
+ }
+
+ /**
+ * Dispatch URI to a page.
+ *
+ * @param string $route The relative URL of the page
+ * @param bool $all
+ *
+ * @param bool $redirect
+ * @return Page|null
+ * @throws \Exception
+ */
+ public function dispatch($route, $all = false, $redirect = true)
+ {
+ $route = urldecode($route);
+
+ // Fetch page if there's a defined route to it.
+ $page = isset($this->routes[$route]) ? $this->get($this->routes[$route]) : null;
+ // Try without trailing slash
+ if (!$page && Utils::endsWith($route, '/')) {
+ $page = isset($this->routes[rtrim($route, '/')]) ? $this->get($this->routes[rtrim($route, '/')]) : null;
+ }
+
+ // Are we in the admin? this is important!
+ $not_admin = !isset($this->grav['admin']);
+
+ // If the page cannot be reached, look into site wide redirects, routes + wildcards
+ if (!$all && $not_admin) {
+
+ // If the page is a simple redirect, just do it.
+ if ($redirect && $page && $page->redirect()) {
+ $this->grav->redirectLangSafe($page->redirect());
+ }
+
+ // fall back and check site based redirects
+ if (!$page || ($page && !$page->routable())) {
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ // See if route matches one in the site configuration
+ $site_route = $config->get("site.routes.{$route}");
+ if ($site_route) {
+ $page = $this->dispatch($site_route, $all);
+ } else {
+
+ /** @var Uri $uri */
+ $uri = $this->grav['uri'];
+ /** @var \Grav\Framework\Uri\Uri $source_url */
+ $source_url = $uri->uri(false);
+
+ // Try Regex style redirects
+ $site_redirects = $config->get("site.redirects");
+ if (is_array($site_redirects)) {
+ foreach ((array)$site_redirects as $pattern => $replace) {
+ $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
+ try {
+ $found = preg_replace($pattern, $replace, $source_url);
+ if ($found != $source_url) {
+ $this->grav->redirectLangSafe($found);
+ }
+ } catch (ErrorException $e) {
+ $this->grav['log']->error('site.redirects: ' . $pattern . '-> ' . $e->getMessage());
+ }
+ }
+ }
+
+ // Try Regex style routes
+ $site_routes = $config->get("site.routes");
+ if (is_array($site_routes)) {
+ foreach ((array)$site_routes as $pattern => $replace) {
+ $pattern = '#^' . str_replace('/', '\/', ltrim($pattern, '^')) . '#';
+ try {
+ $found = preg_replace($pattern, $replace, $source_url);
+ if ($found !== $source_url) {
+ $page = $this->dispatch($found, $all);
+ }
+ } catch (ErrorException $e) {
+ $this->grav['log']->error('site.routes: ' . $pattern . '-> ' . $e->getMessage());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return $page;
+ }
+
+ /**
+ * Get root page.
+ *
+ * @return Page
+ */
+ public function root()
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+ return $this->instances[rtrim($locator->findResource('page://'), DS)];
+ }
+
+ /**
+ * Get a blueprint for a page type.
+ *
+ * @param string $type
+ *
+ * @return Blueprint
+ */
+ public function blueprints($type)
+ {
+ if ($this->blueprints === null) {
+ $this->blueprints = new Blueprints(self::getTypes());
+ }
+
+ try {
+ $blueprint = $this->blueprints->get($type);
+ } catch (\RuntimeException $e) {
+ $blueprint = $this->blueprints->get('default');
+ }
+
+ if (empty($blueprint->initialized)) {
+ $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
+ $blueprint->initialized = true;
+ }
+
+ return $blueprint;
+ }
+
+ /**
+ * Get all pages
+ *
+ * @param \Grav\Common\Page\Page $current
+ *
+ * @return \Grav\Common\Page\Collection
+ */
+ public function all(Page $current = null)
+ {
+ $all = new Collection();
+
+ /** @var Page $current */
+ $current = $current ?: $this->root();
+
+ if (!$current->root()) {
+ $all[$current->path()] = ['slug' => $current->slug()];
+ }
+
+ foreach ($current->children() as $next) {
+ $all->append($this->all($next));
+ }
+
+ return $all;
+ }
+
+ /**
+ * Get available parents raw routes.
+ *
+ * @return array
+ */
+ public static function parentsRawRoutes()
+ {
+ $rawRoutes = true;
+
+ return self::getParents($rawRoutes);
+ }
+
+ /**
+ * Get available parents routes
+ *
+ * @param bool $rawRoutes get the raw route or the normal route
+ *
+ * @return array
+ */
+ private static function getParents($rawRoutes)
+ {
+ $grav = Grav::instance();
+
+ /** @var Pages $pages */
+ $pages = $grav['pages'];
+
+ $parents = $pages->getList(null, 0, $rawRoutes);
+
+ if (isset($grav['admin'])) {
+ // Remove current route from parents
+
+ /** @var Admin $admin */
+ $admin = $grav['admin'];
+
+ $page = $admin->getPage($admin->route);
+ $page_route = $page->route();
+ if (isset($parents[$page_route])) {
+ unset($parents[$page_route]);
+ }
+
+ }
+
+ return $parents;
+ }
+
+ /**
+ * Get list of route/title of all pages.
+ *
+ * @param Page $current
+ * @param int $level
+ * @param bool $rawRoutes
+ *
+ * @param bool $showAll
+ * @param bool $showFullpath
+ * @param bool $showSlug
+ * @param bool $showModular
+ * @param bool $limitLevels
+ * @return array
+ */
+ public function getList(Page $current = null, $level = 0, $rawRoutes = false, $showAll = true, $showFullpath = false, $showSlug = false, $showModular = false, $limitLevels = false)
+ {
+ if (!$current) {
+ if ($level) {
+ throw new \RuntimeException('Internal error');
+ }
+
+ $current = $this->root();
+ }
+
+ $list = [];
+
+ if (!$current->root()) {
+ if ($rawRoutes) {
+ $route = $current->rawRoute();
+ } else {
+ $route = $current->route();
+ }
+
+ if ($showFullpath) {
+ $option = $current->route();
+ } else {
+ $extra = $showSlug ? '(' . $current->slug() . ') ' : '';
+ $option = str_repeat('—-', $level). '▸ ' . $extra . $current->title();
+
+
+ }
+
+ $list[$route] = $option;
+
+
+ }
+
+ if ($limitLevels === false || ($level+1 < $limitLevels)) {
+ foreach ($current->children() as $next) {
+ if ($showAll || $next->routable() || ($next->modular() && $showModular)) {
+ $list = array_merge($list, $this->getList($next, $level + 1, $rawRoutes, $showAll, $showFullpath, $showSlug, $showModular, $limitLevels));
+ }
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Get available page types.
+ *
+ * @return Types
+ */
+ public static function getTypes()
+ {
+ if (!self::$types) {
+
+ $grav = Grav::instance();
+
+ $scanBlueprintsAndTemplates = function () use ($grav) {
+ // Scan blueprints
+ $event = new Event();
+ $event->types = self::$types;
+ $grav->fireEvent('onGetPageBlueprints', $event);
+
+ self::$types->scanBlueprints('theme://blueprints/');
+
+ // Scan templates
+ $event = new Event();
+ $event->types = self::$types;
+ $grav->fireEvent('onGetPageTemplates', $event);
+
+ self::$types->scanTemplates('theme://templates/');
+ };
+
+ if ($grav['config']->get('system.cache.enabled')) {
+ /** @var Cache $cache */
+ $cache = $grav['cache'];
+
+ // Use cached types if possible.
+ $types_cache_id = md5('types');
+ self::$types = $cache->fetch($types_cache_id);
+
+ if (!self::$types) {
+ self::$types = new Types();
+ $scanBlueprintsAndTemplates();
+ $cache->save($types_cache_id, self::$types);
+ }
+
+ } else {
+ self::$types = new Types();
+ $scanBlueprintsAndTemplates();
+ }
+
+ }
+
+ return self::$types;
+ }
+
+ /**
+ * Get available page types.
+ *
+ * @return array
+ */
+ public static function types()
+ {
+ $types = self::getTypes();
+
+ return $types->pageSelect();
+ }
+
+ /**
+ * Get available page types.
+ *
+ * @return array
+ */
+ public static function modularTypes()
+ {
+ $types = self::getTypes();
+
+ return $types->modularSelect();
+ }
+
+ /**
+ * Get template types based on page type (standard or modular)
+ *
+ * @return array
+ */
+ public static function pageTypes()
+ {
+ if (isset(Grav::instance()['admin'])) {
+ /** @var Admin $admin */
+ $admin = Grav::instance()['admin'];
+
+ /** @var Page $page */
+ $page = $admin->getPage($admin->route);
+
+ if ($page && $page->modular()) {
+ return static::modularTypes();
+ }
+
+ return static::types();
+ }
+
+ return [];
+ }
+
+ /**
+ * Get access levels of the site pages
+ *
+ * @return array
+ */
+ public function accessLevels()
+ {
+ $accessLevels = [];
+ foreach ($this->all() as $page) {
+ if (isset($page->header()->access)) {
+ if (is_array($page->header()->access)) {
+ foreach ($page->header()->access as $index => $accessLevel) {
+ if (is_array($accessLevel)) {
+ foreach ($accessLevel as $innerIndex => $innerAccessLevel) {
+ array_push($accessLevels, $innerIndex);
+ }
+ } else {
+ array_push($accessLevels, $index);
+ }
+ }
+ } else {
+
+ array_push($accessLevels, $page->header()->access);
+ }
+ }
+ }
+
+ return array_unique($accessLevels);
+ }
+
+ /**
+ * Get available parents routes
+ *
+ * @return array
+ */
+ public static function parents()
+ {
+ $rawRoutes = false;
+
+ return self::getParents($rawRoutes);
+ }
+
+
+
+ /**
+ * Gets the home route
+ *
+ * @return string
+ */
+ public static function getHomeRoute()
+ {
+ if (empty(self::$home_route)) {
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ $home = $config->get('system.home.alias');
+
+ if ($language->enabled()) {
+ $home_aliases = $config->get('system.home.aliases');
+ if ($home_aliases) {
+ $active = $language->getActive();
+ $default = $language->getDefault();
+
+ try {
+ if ($active) {
+ $home = $home_aliases[$active];
+ } else {
+ $home = $home_aliases[$default];
+ }
+ } catch (ErrorException $e) {
+ $home = $home_aliases[$default];
+ }
+
+ }
+ }
+
+ self::$home_route = trim($home, '/');
+ }
+
+ return self::$home_route;
+ }
+
+ /**
+ * Needed for testing where we change the home route via config
+ */
+ public static function resetHomeRoute()
+ {
+ self::$home_route = null;
+ return self::getHomeRoute();
+ }
+
+ /**
+ * Builds pages.
+ *
+ * @internal
+ */
+ protected function buildPages()
+ {
+ $this->sort = [];
+
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ $pages_dir = $locator->findResource('page://');
+
+ if ($config->get('system.cache.enabled')) {
+ /** @var Cache $cache */
+ $cache = $this->grav['cache'];
+ /** @var Taxonomy $taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
+
+ // how should we check for last modified? Default is by file
+ switch (strtolower($config->get('system.cache.check.method', 'file'))) {
+ case 'none':
+ case 'off':
+ $hash = 0;
+ break;
+ case 'folder':
+ $hash = Folder::lastModifiedFolder($pages_dir);
+ break;
+ case 'hash':
+ $hash = Folder::hashAllFiles($pages_dir);
+ break;
+ default:
+ $hash = Folder::lastModifiedFile($pages_dir);
+ }
+
+ $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum());
+
+ list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cache->fetch($this->pages_cache_id);
+ if (!$this->instances) {
+ $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
+
+ // recurse pages and cache result
+ $this->resetPages($pages_dir, $this->pages_cache_id);
+
+ } else {
+ // If pages was found in cache, set the taxonomy
+ $this->grav['debugger']->addMessage('Page cache hit.');
+ $taxonomy->taxonomy($taxonomy_map);
+ }
+ } else {
+ $this->recurse($pages_dir);
+ $this->buildRoutes();
+ }
+ }
+
+ /**
+ * Accessible method to manually reset the pages cache
+ *
+ * @param $pages_dir
+ */
+ public function resetPages($pages_dir)
+ {
+ $this->recurse($pages_dir);
+ $this->buildRoutes();
+
+ // cache if needed
+ if ($this->grav['config']->get('system.cache.enabled')) {
+ /** @var Cache $cache */
+ $cache = $this->grav['cache'];
+ /** @var Taxonomy $taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
+
+ // save pages, routes, taxonomy, and sort to cache
+ $cache->save($this->pages_cache_id, [$this->instances, $this->routes, $this->children, $taxonomy->taxonomy(), $this->sort]);
+ }
+ }
+
+ /**
+ * Recursive function to load & build page relationships.
+ *
+ * @param string $directory
+ * @param Page|null $parent
+ *
+ * @return Page
+ * @throws \RuntimeException
+ * @internal
+ */
+ protected function recurse($directory, Page $parent = null)
+ {
+ $directory = rtrim($directory, DS);
+ $page = new Page;
+
+ /** @var Config $config */
+ $config = $this->grav['config'];
+
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ // stuff to do at root page
+ if ($parent === null) {
+
+ // Fire event for memory and time consuming plugins...
+ if ($config->get('system.pages.events.page')) {
+ $this->grav->fireEvent('onBuildPagesInitialized');
+ }
+ }
+
+ $page->path($directory);
+ if ($parent) {
+ $page->parent($parent);
+ }
+
+ $page->orderDir($config->get('system.pages.order.dir'));
+ $page->orderBy($config->get('system.pages.order.by'));
+
+ // Add into instances
+ if (!isset($this->instances[$page->path()])) {
+ $this->instances[$page->path()] = $page;
+ if ($parent && $page->path()) {
+ $this->children[$parent->path()][$page->path()] = ['slug' => $page->slug()];
+ }
+ } else {
+ throw new \RuntimeException('Fatal error when creating page instances.');
+ }
+
+ // Build regular expression for all the allowed page extensions.
+ $page_extensions = $language->getFallbackPageExtensions();
+ $regex = '/^[^\.]*(' . implode('|', array_map(
+ function ($str) {
+ return preg_quote($str, '/');
+ },
+ $page_extensions
+ )) . ')$/';
+
+ $folders = [];
+ $page_found = null;
+ $page_extension = '';
+ $last_modified = 0;
+
+ $iterator = new \FilesystemIterator($directory);
+ /** @var \FilesystemIterator $file */
+ foreach ($iterator as $file) {
+ $filename = $file->getFilename();
+
+ // Ignore all hidden files if set.
+ if ($this->ignore_hidden && $filename && $filename[0] === '.') {
+ continue;
+ }
+
+ // Handle folders later.
+ if ($file->isDir()) {
+ // But ignore all folders in ignore list.
+ if (!\in_array($filename, $this->ignore_folders, true)) {
+ $folders[] = $file;
+ }
+ continue;
+ }
+
+ // Ignore all files in ignore list.
+ if (\in_array($file->getBasename(), $this->ignore_files, true)) {
+ continue;
+ }
+
+ // Update last modified date to match the last updated file in the folder.
+ $modified = $file->getMTime();
+ if ($modified > $last_modified) {
+ $last_modified = $modified;
+ }
+
+ // Page is the one that matches to $page_extensions list with the lowest index number.
+ if (preg_match($regex, $filename, $matches, PREG_OFFSET_CAPTURE)) {
+ $ext = $matches[1][0];
+
+ if ($page_found === null || array_search($ext, $page_extensions, true) < array_search($page_extension, $page_extensions, true)) {
+ $page_found = $file;
+ $page_extension = $ext;
+ }
+ }
+ }
+
+ $content_exists = false;
+ if ($parent && $page_found) {
+ $page->init($page_found, $page_extension);
+
+ $content_exists = true;
+
+ if ($config->get('system.pages.events.page')) {
+ $this->grav->fireEvent('onPageProcessed', new Event(['page' => $page]));
+ }
+ }
+
+ // Now handle all the folders under the page.
+ /** @var \FilesystemIterator $file */
+ foreach ($folders as $file) {
+ $filename = $file->getFilename();
+
+ // if folder contains separator, continue
+ if (Utils::contains($file->getFilename(), $config->get('system.param_sep', ':'))) {
+ continue;
+ }
+
+ if (!$page->path()) {
+ $page->path($file->getPath());
+ }
+
+ $path = $directory . DS . $filename;
+ $child = $this->recurse($path, $page);
+
+ if (Utils::startsWith($filename, '_')) {
+ $child->routable(false);
+ }
+
+ $this->children[$page->path()][$child->path()] = ['slug' => $child->slug()];
+
+ if ($config->get('system.pages.events.page')) {
+ $this->grav->fireEvent('onFolderProcessed', new Event(['page' => $page]));
+ }
+ }
+
+ // Set routability to false if no page found
+ if (!$content_exists) {
+ $page->routable(false);
+ }
+
+ // Override the modified time if modular
+ if ($page->template() === 'modular') {
+ foreach ($page->collection() as $child) {
+ $modified = $child->modified();
+
+ if ($modified > $last_modified) {
+ $last_modified = $modified;
+ }
+ }
+ }
+
+ // Override the modified and ID so that it takes the latest change into account
+ $page->modified($last_modified);
+ $page->id($last_modified . md5($page->filePath()));
+
+ // Sort based on Defaults or Page Overridden sort order
+ $this->children[$page->path()] = $this->sort($page);
+
+ return $page;
+ }
+
+ /**
+ * @internal
+ */
+ protected function buildRoutes()
+ {
+ /** @var $taxonomy Taxonomy */
+ $taxonomy = $this->grav['taxonomy'];
+
+ // Get the home route
+ $home = self::resetHomeRoute();
+
+ // Build routes and taxonomy map.
+ /** @var $page Page */
+ foreach ($this->instances as $page) {
+ if (!$page->root()) {
+ // process taxonomy
+ $taxonomy->addTaxonomy($page);
+
+ $route = $page->route();
+ $raw_route = $page->rawRoute();
+ $page_path = $page->path();
+
+ // add regular route
+ $this->routes[$route] = $page_path;
+
+ // add raw route
+ if ($raw_route != $route) {
+ $this->routes[$raw_route] = $page_path;
+ }
+
+ // add canonical route
+ $route_canonical = $page->routeCanonical();
+ if ($route_canonical && ($route !== $route_canonical)) {
+ $this->routes[$route_canonical] = $page_path;
+ }
+
+ // add aliases to routes list if they are provided
+ $route_aliases = $page->routeAliases();
+ if ($route_aliases) {
+ foreach ($route_aliases as $alias) {
+ $this->routes[$alias] = $page_path;
+ }
+ }
+ }
+ }
+
+ // Alias and set default route to home page.
+ if ($home && isset($this->routes['/' . $home])) {
+ $this->routes['/'] = $this->routes['/' . $home];
+ $this->get($this->routes['/' . $home])->route('/');
+ }
+ }
+
+ /**
+ * @param string $path
+ * @param array $pages
+ * @param string $order_by
+ * @param array $manual
+ * @param int $sort_flags
+ *
+ * @throws \RuntimeException
+ * @internal
+ */
+ protected function buildSort($path, array $pages, $order_by = 'default', $manual = null, $sort_flags = null)
+ {
+ $list = [];
+ $header_default = null;
+ $header_query = null;
+
+ // do this header query work only once
+ if (strpos($order_by, 'header.') === 0) {
+ $header_query = explode('|', str_replace('header.', '', $order_by));
+ if (isset($header_query[1])) {
+ $header_default = $header_query[1];
+ }
+ }
+
+ foreach ($pages as $key => $info) {
+ $child = isset($this->instances[$key]) ? $this->instances[$key] : null;
+ if (!$child) {
+ throw new \RuntimeException("Page does not exist: {$key}");
+ }
+
+ switch ($order_by) {
+ case 'title':
+ $list[$key] = $child->title();
+ break;
+ case 'date':
+ $list[$key] = $child->date();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'modified':
+ $list[$key] = $child->modified();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'publish_date':
+ $list[$key] = $child->publishDate();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'unpublish_date':
+ $list[$key] = $child->unpublishDate();
+ $sort_flags = SORT_REGULAR;
+ break;
+ case 'slug':
+ $list[$key] = $child->slug();
+ break;
+ case 'basename':
+ $list[$key] = basename($key);
+ break;
+ case 'folder':
+ $list[$key] = $child->folder();
+ break;
+ case (is_string($header_query[0])):
+ $child_header = new Header((array)$child->header());
+ $header_value = $child_header->get($header_query[0]);
+ if (is_array($header_value)) {
+ $list[$key] = implode(',',$header_value);
+ } elseif ($header_value) {
+ $list[$key] = $header_value;
+ } else {
+ $list[$key] = $header_default ?: $key;
+ }
+ $sort_flags = $sort_flags ?: SORT_REGULAR;
+ break;
+ case 'manual':
+ case 'default':
+ default:
+ $list[$key] = $key;
+ $sort_flags = $sort_flags ?: SORT_REGULAR;
+ }
+ }
+
+ if (!$sort_flags) {
+ $sort_flags = SORT_NATURAL | SORT_FLAG_CASE;
+ }
+
+ // handle special case when order_by is random
+ if ($order_by === 'random') {
+ $list = $this->arrayShuffle($list);
+ } else {
+ // else just sort the list according to specified key
+ if (extension_loaded('intl') && $this->grav['config']->get('system.intl_enabled')) {
+ $locale = setlocale(LC_COLLATE, 0); //`setlocale` with a 0 param returns the current locale set
+ $col = Collator::create($locale);
+ if ($col) {
+ if (($sort_flags & SORT_NATURAL) === SORT_NATURAL) {
+ $list = preg_replace_callback('~([0-9]+)\.~', function($number) {
+ return sprintf('%032d.', $number[0]);
+ }, $list);
+
+ $list_vals = array_values($list);
+ if (is_numeric(array_shift($list_vals))) {
+ $sort_flags = Collator::SORT_REGULAR;
+ } else {
+ $sort_flags = Collator::SORT_STRING;
+ }
+ }
+
+ $col->asort($list, $sort_flags);
+ } else {
+ asort($list, $sort_flags);
+ }
+ } else {
+ asort($list, $sort_flags);
+ }
+ }
+
+
+ // Move manually ordered items into the beginning of the list. Order of the unlisted items does not change.
+ if (is_array($manual) && !empty($manual)) {
+ $new_list = [];
+ $i = count($manual);
+
+ foreach ($list as $key => $dummy) {
+ $info = $pages[$key];
+ $order = array_search($info['slug'], $manual);
+ if ($order === false) {
+ $order = $i++;
+ }
+ $new_list[$key] = (int)$order;
+ }
+
+ $list = $new_list;
+
+ // Apply manual ordering to the list.
+ asort($list);
+ }
+
+ foreach ($list as $key => $sort) {
+ $info = $pages[$key];
+ $this->sort[$path][$order_by][$key] = $info;
+ }
+ }
+
+ /**
+ * Shuffles an associative array
+ *
+ * @param array $list
+ *
+ * @return array
+ */
+ protected function arrayShuffle($list)
+ {
+ $keys = array_keys($list);
+ shuffle($keys);
+
+ $new = [];
+ foreach ($keys as $key) {
+ $new[$key] = $list[$key];
+ }
+
+ return $new;
+ }
+
+ /**
+ * Get the Pages cache ID
+ *
+ * this is particularly useful to know if pages have changed and you want
+ * to sync another cache with pages cache - works best in `onPagesInitialized()`
+ *
+ * @return mixed
+ */
+ public function getPagesCacheId()
+ {
+ return $this->pages_cache_id;
+ }
+}
diff --git a/system/src/Grav/Common/Page/Types.php b/system/src/Grav/Common/Page/Types.php
new file mode 100644
index 0000000..436d4f8
--- /dev/null
+++ b/system/src/Grav/Common/Page/Types.php
@@ -0,0 +1,140 @@
+items[$type])) {
+ $this->items[$type] = [];
+ } elseif (!$blueprint) {
+ return;
+ }
+
+ if (!$blueprint && $this->systemBlueprints) {
+ $blueprint = isset($this->systemBlueprints[$type]) ? $this->systemBlueprints[$type] : $this->systemBlueprints['default'];
+ }
+
+ if ($blueprint) {
+ array_unshift($this->items[$type], $blueprint);
+ }
+ }
+
+ public function scanBlueprints($uri)
+ {
+ if (!is_string($uri)) {
+ throw new \InvalidArgumentException('First parameter must be URI');
+ }
+
+ if (!$this->systemBlueprints) {
+ $this->systemBlueprints = $this->findBlueprints('blueprints://pages');
+
+ // Register default by default.
+ $this->register('default');
+
+ $this->register('external');
+ }
+
+ foreach ($this->findBlueprints($uri) as $type => $blueprint) {
+ $this->register($type, $blueprint);
+ }
+ }
+
+ public function scanTemplates($uri)
+ {
+ if (!is_string($uri)) {
+ throw new \InvalidArgumentException('First parameter must be URI');
+ }
+
+ $options = [
+ 'compare' => 'Filename',
+ 'pattern' => '|\.html\.twig$|',
+ 'filters' => [
+ 'value' => '|\.html\.twig$|'
+ ],
+ 'value' => 'Filename',
+ 'recursive' => false
+ ];
+
+ foreach (Folder::all($uri, $options) as $type) {
+ $this->register($type);
+ }
+
+ $modular_uri = rtrim($uri, '/') . '/modular';
+ if (is_dir($modular_uri)) {
+ foreach (Folder::all($modular_uri, $options) as $type) {
+ $this->register('modular/' . $type);
+ }
+ }
+ }
+
+ public function pageSelect()
+ {
+ $list = [];
+ foreach ($this->items as $name => $file) {
+ if (strpos($name, '/')) {
+ continue;
+ }
+ $list[$name] = ucfirst(strtr($name, '_', ' '));
+ }
+ ksort($list);
+ return $list;
+ }
+
+ public function modularSelect()
+ {
+ $list = [];
+ foreach ($this->items as $name => $file) {
+ if (strpos($name, 'modular/') !== 0) {
+ continue;
+ }
+ $list[$name] = trim(ucfirst(strtr(basename($name), '_', ' ')));
+ }
+ ksort($list);
+ return $list;
+ }
+
+ private function findBlueprints($uri)
+ {
+ $options = [
+ 'compare' => 'Filename',
+ 'pattern' => '|\.yaml$|',
+ 'filters' => [
+ 'key' => '|\.yaml$|'
+ ],
+ 'key' => 'SubPathName',
+ 'value' => 'PathName',
+ ];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ if ($locator->isStream($uri)) {
+ $options['value'] = 'Url';
+ }
+
+ $list = Folder::all($uri, $options);
+
+ return $list;
+ }
+}
diff --git a/system/src/Grav/Common/Plugin.php b/system/src/Grav/Common/Plugin.php
new file mode 100644
index 0000000..ec5b346
--- /dev/null
+++ b/system/src/Grav/Common/Plugin.php
@@ -0,0 +1,362 @@
+name = $name;
+ $this->grav = $grav;
+ if ($config) {
+ $this->setConfig($config);
+ }
+ }
+
+ /**
+ * @param Config $config
+ * @return $this
+ */
+ public function setConfig(Config $config)
+ {
+ $this->config = $config;
+
+ return $this;
+ }
+
+ /**
+ * Get configuration of the plugin.
+ *
+ * @return array
+ */
+ public function config()
+ {
+ return $this->config["plugins.{$this->name}"];
+ }
+
+ /**
+ * Determine if this is running under the admin
+ *
+ * @return bool
+ */
+ public function isAdmin()
+ {
+ return Utils::isAdminPlugin();
+ }
+
+ /**
+ * Determine if this route is in Admin and active for the plugin
+ *
+ * @param $plugin_route
+ * @return bool
+ */
+ protected function isPluginActiveAdmin($plugin_route)
+ {
+ $should_run = false;
+
+ $uri = $this->grav['uri'];
+
+ if (strpos($uri->path(), $this->config->get('plugins.admin.route') . '/' . $plugin_route) === false) {
+ $should_run = false;
+ } elseif (isset($uri->paths()[1]) && $uri->paths()[1] === $plugin_route) {
+ $should_run = true;
+ }
+
+ return $should_run;
+ }
+
+ /**
+ * @param array $events
+ */
+ protected function enable(array $events)
+ {
+ /** @var EventDispatcher $dispatcher */
+ $dispatcher = $this->grav['events'];
+
+ foreach ($events as $eventName => $params) {
+ if (is_string($params)) {
+ $dispatcher->addListener($eventName, [$this, $params]);
+ } elseif (is_string($params[0])) {
+ $dispatcher->addListener($eventName, [$this, $params[0]], isset($params[1]) ? $params[1] : 0);
+ } else {
+ foreach ($params as $listener) {
+ $dispatcher->addListener($eventName, [$this, $listener[0]], isset($listener[1]) ? $listener[1] : 0);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param array $events
+ */
+ protected function disable(array $events)
+ {
+ /** @var EventDispatcher $dispatcher */
+ $dispatcher = $this->grav['events'];
+
+ foreach ($events as $eventName => $params) {
+ if (is_string($params)) {
+ $dispatcher->removeListener($eventName, [$this, $params]);
+ } elseif (is_string($params[0])) {
+ $dispatcher->removeListener($eventName, [$this, $params[0]]);
+ } else {
+ foreach ($params as $listener) {
+ $dispatcher->removeListener($eventName, [$this, $listener[0]]);
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether or not an offset exists.
+ *
+ * @param mixed $offset An offset to check for.
+ * @return bool Returns TRUE on success or FALSE on failure.
+ */
+ public function offsetExists($offset)
+ {
+ $this->loadBlueprint();
+
+ if ($offset === 'title') {
+ $offset = 'name';
+ }
+ return isset($this->blueprint[$offset]);
+ }
+
+ /**
+ * Returns the value at specified offset.
+ *
+ * @param mixed $offset The offset to retrieve.
+ * @return mixed Can return all value types.
+ */
+ public function offsetGet($offset)
+ {
+ $this->loadBlueprint();
+
+ if ($offset === 'title') {
+ $offset = 'name';
+ }
+ return isset($this->blueprint[$offset]) ? $this->blueprint[$offset] : null;
+ }
+
+ /**
+ * Assigns a value to the specified offset.
+ *
+ * @param mixed $offset The offset to assign the value to.
+ * @param mixed $value The value to set.
+ * @throws LogicException
+ */
+ public function offsetSet($offset, $value)
+ {
+ throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');
+ }
+
+ /**
+ * Unsets an offset.
+ *
+ * @param mixed $offset The offset to unset.
+ * @throws LogicException
+ */
+ public function offsetUnset($offset)
+ {
+ throw new LogicException(__CLASS__ . ' blueprints cannot be modified.');
+ }
+
+ /**
+ * This function will search a string for markdown links in a specific format. The link value can be
+ * optionally compared against via the $internal_regex and operated on by the callback $function
+ * provided.
+ *
+ * format: [plugin:myplugin_name](function_data)
+ *
+ * @param string $content The string to perform operations upon
+ * @param callable $function The anonymous callback function
+ * @param string $internal_regex Optional internal regex to extra data from
+ *
+ * @return string
+ */
+ protected function parseLinks($content, $function, $internal_regex = '(.*)')
+ {
+ $regex = '/\[plugin:(?:' . $this->name . ')\]\(' . $internal_regex . '\)/i';
+
+ return preg_replace_callback($regex, $function, $content);
+ }
+
+ /**
+ * Merge global and page configurations.
+ *
+ * @param Page $page The page to merge the configurations with the
+ * plugin settings.
+ * @param mixed $deep false = shallow|true = recursive|merge = recursive+unique
+ * @param array $params Array of additional configuration options to
+ * merge with the plugin settings.
+ * @param string $type Is this 'plugins' or 'themes'
+ *
+ * @return Data
+ */
+ protected function mergeConfig(Page $page, $deep = false, $params = [], $type = 'plugins')
+ {
+ $class_name = $this->name;
+ $class_name_merged = $class_name . '.merged';
+ $defaults = $this->config->get($type . '.' . $class_name, []);
+ $page_header = $page->header();
+ $header = [];
+
+ if (!isset($page_header->$class_name_merged) && isset($page_header->$class_name)) {
+ // Get default plugin configurations and retrieve page header configuration
+ $config = $page_header->$class_name;
+ if (is_bool($config)) {
+ // Overwrite enabled option with boolean value in page header
+ $config = ['enabled' => $config];
+ }
+ // Merge page header settings using deep or shallow merging technique
+ $header = $this->mergeArrays($deep, $defaults, $config);
+
+ // Create new config object and set it on the page object so it's cached for next time
+ $page->modifyHeader($class_name_merged, new Data($header));
+ } else if (isset($page_header->$class_name_merged)) {
+ $merged = $page_header->$class_name_merged;
+ $header = $merged->toArray();
+ }
+ if (empty($header)) {
+ $header = $defaults;
+ }
+ // Merge additional parameter with configuration options
+ $header = $this->mergeArrays($deep, $header, $params);
+
+ // Return configurations as a new data config class
+ return new Data($header);
+ }
+
+ /**
+ * Merge arrays based on deepness
+ *
+ * @param bool $deep
+ * @param $array1
+ * @param $array2
+ * @return array|mixed
+ */
+ private function mergeArrays($deep = false, $array1, $array2)
+ {
+ if ($deep === 'merge') {
+ return Utils::arrayMergeRecursiveUnique($array1, $array2);
+ }
+ if ($deep === true) {
+ return array_replace_recursive($array1, $array2);
+ }
+
+ return array_merge($array1, $array2);
+ }
+
+ /**
+ * Persists to disk the plugin parameters currently stored in the Grav Config object
+ *
+ * @param string $plugin_name The name of the plugin whose config it should store.
+ *
+ * @return true
+ */
+ public static function saveConfig($plugin_name)
+ {
+ if (!$plugin_name) {
+ return false;
+ }
+
+ $grav = Grav::instance();
+ $locator = $grav['locator'];
+ $filename = 'config://plugins/' . $plugin_name . '.yaml';
+ $file = YamlFile::instance($locator->findResource($filename, true, true));
+ $content = $grav['config']->get('plugins.' . $plugin_name);
+ $file->save($content);
+ $file->free();
+
+ return true;
+ }
+
+ /**
+ * Simpler getter for the plugin blueprint
+ *
+ * @return mixed
+ */
+ public function getBlueprint()
+ {
+ if (!$this->blueprint) {
+ $this->loadBlueprint();
+ }
+ return $this->blueprint;
+ }
+
+ /**
+ * Load blueprints.
+ */
+ protected function loadBlueprint()
+ {
+ if (!$this->blueprint) {
+ $grav = Grav::instance();
+ $plugins = $grav['plugins'];
+ $this->blueprint = $plugins->get($this->name)->blueprints();
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Plugins.php b/system/src/Grav/Common/Plugins.php
new file mode 100644
index 0000000..5e407a9
--- /dev/null
+++ b/system/src/Grav/Common/Plugins.php
@@ -0,0 +1,207 @@
+getIterator('plugins://');
+
+ $plugins = [];
+ foreach($iterator as $directory) {
+ if (!$directory->isDir()) {
+ continue;
+ }
+ $plugins[] = $directory->getBasename();
+ }
+
+ natsort($plugins);
+
+ foreach ($plugins as $plugin) {
+ $this->add($this->loadPlugin($plugin));
+ }
+ }
+
+ /**
+ * @return $this
+ */
+ public function setup()
+ {
+ $blueprints = [];
+ $formFields = [];
+
+ /** @var Plugin $plugin */
+ foreach ($this->items as $plugin) {
+ if (isset($plugin->features['blueprints'])) {
+ $blueprints["plugin://{$plugin->name}/blueprints"] = $plugin->features['blueprints'];
+ }
+ if (method_exists($plugin, 'getFormFieldTypes')) {
+ $formFields[get_class($plugin)] = isset($plugin->features['formfields']) ? $plugin->features['formfields'] : 0;
+ }
+ }
+
+ if ($blueprints) {
+ // Order by priority.
+ arsort($blueprints);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ $locator->addPath('blueprints', '', array_keys($blueprints), 'system/blueprints');
+ }
+
+ if ($formFields) {
+ // Order by priority.
+ arsort($formFields);
+
+ $list = [];
+ foreach ($formFields as $className => $priority) {
+ $plugin = $this->items[$className];
+ $list += $plugin->getFormFieldTypes();
+ }
+
+ $this->formFieldTypes = $list;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Registers all plugins.
+ *
+ * @return array|Plugin[] array of Plugin objects
+ * @throws \RuntimeException
+ */
+ public function init()
+ {
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ /** @var EventDispatcher $events */
+ $events = $grav['events'];
+
+ foreach ($this->items as $instance) {
+ // Register only enabled plugins.
+ if ($config["plugins.{$instance->name}.enabled"] && $instance instanceof Plugin) {
+ $instance->setConfig($config);
+ $events->addSubscriber($instance);
+ }
+ }
+
+ return $this->items;
+ }
+
+ /**
+ * Add a plugin
+ *
+ * @param $plugin
+ */
+ public function add($plugin)
+ {
+ if (is_object($plugin)) {
+ $this->items[get_class($plugin)] = $plugin;
+ }
+ }
+
+ /**
+ * Return list of all plugin data with their blueprints.
+ *
+ * @return array
+ */
+ public static function all()
+ {
+ $plugins = Grav::instance()['plugins'];
+ $list = [];
+
+ foreach ($plugins as $instance) {
+ $name = $instance->name;
+ $result = self::get($name);
+
+ if ($result) {
+ $list[$name] = $result;
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Get a plugin by name
+ *
+ * @param string $name
+ *
+ * @return Data|null
+ */
+ public static function get($name)
+ {
+ $blueprints = new Blueprints('plugins://');
+ $blueprint = $blueprints->get("{$name}/blueprints");
+
+ // Load default configuration.
+ $file = CompiledYamlFile::instance("plugins://{$name}/{$name}" . YAML_EXT);
+
+ // ensure this is a valid plugin
+ if (!$file->exists()) {
+ return null;
+ }
+
+ $obj = new Data($file->content(), $blueprint);
+
+ // Override with user configuration.
+ $obj->merge(Grav::instance()['config']->get('plugins.' . $name) ?: []);
+
+ // Save configuration always to user/config.
+ $file = CompiledYamlFile::instance("config://plugins/{$name}.yaml");
+ $obj->file($file);
+
+ return $obj;
+ }
+
+ protected function loadPlugin($name)
+ {
+ $grav = Grav::instance();
+ $locator = $grav['locator'];
+
+ $filePath = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT);
+ if (!is_file($filePath)) {
+ $grav['log']->addWarning(
+ sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name)
+ );
+ return null;
+ }
+
+ require_once $filePath;
+
+ $pluginClassName = 'Grav\\Plugin\\' . ucfirst($name) . 'Plugin';
+ if (!class_exists($pluginClassName)) {
+ $pluginClassName = 'Grav\\Plugin\\' . $grav['inflector']->camelize($name) . 'Plugin';
+ if (!class_exists($pluginClassName)) {
+ throw new \RuntimeException(sprintf("Plugin '%s' class not found! Try reinstalling this plugin.", $name));
+ }
+ }
+ return new $pluginClassName($name, $grav);
+ }
+
+}
diff --git a/system/src/Grav/Common/Processors/AssetsProcessor.php b/system/src/Grav/Common/Processors/AssetsProcessor.php
new file mode 100644
index 0000000..6ca952d
--- /dev/null
+++ b/system/src/Grav/Common/Processors/AssetsProcessor.php
@@ -0,0 +1,21 @@
+container['assets']->init();
+ $this->container->fireEvent('onAssetsInitialized');
+ }
+}
diff --git a/system/src/Grav/Common/Processors/ConfigurationProcessor.php b/system/src/Grav/Common/Processors/ConfigurationProcessor.php
new file mode 100644
index 0000000..0347853
--- /dev/null
+++ b/system/src/Grav/Common/Processors/ConfigurationProcessor.php
@@ -0,0 +1,21 @@
+container['config']->init();
+ $this->container['plugins']->setup();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php
new file mode 100644
index 0000000..643e8d1
--- /dev/null
+++ b/system/src/Grav/Common/Processors/DebuggerAssetsProcessor.php
@@ -0,0 +1,20 @@
+container['debugger']->addAssets();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/DebuggerInitProcessor.php b/system/src/Grav/Common/Processors/DebuggerInitProcessor.php
new file mode 100644
index 0000000..e3b4e1d
--- /dev/null
+++ b/system/src/Grav/Common/Processors/DebuggerInitProcessor.php
@@ -0,0 +1,20 @@
+container['debugger']->init();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/ErrorsProcessor.php b/system/src/Grav/Common/Processors/ErrorsProcessor.php
new file mode 100644
index 0000000..7c7685d
--- /dev/null
+++ b/system/src/Grav/Common/Processors/ErrorsProcessor.php
@@ -0,0 +1,20 @@
+container['errors']->resetHandlers();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/InitializeProcessor.php b/system/src/Grav/Common/Processors/InitializeProcessor.php
new file mode 100644
index 0000000..e3f2569
--- /dev/null
+++ b/system/src/Grav/Common/Processors/InitializeProcessor.php
@@ -0,0 +1,44 @@
+container['config']->debug();
+
+ // Use output buffering to prevent headers from being sent too early.
+ ob_start();
+ if ($this->container['config']->get('system.cache.gzip')) {
+ // Enable zip/deflate with a fallback in case of if browser does not support compressing.
+ if (!@ob_start("ob_gzhandler")) {
+ ob_start();
+ }
+ }
+
+ // Initialize the timezone.
+ if ($this->container['config']->get('system.timezone')) {
+ date_default_timezone_set($this->container['config']->get('system.timezone'));
+ }
+
+ // FIXME: Initialize session should happen later after plugins have been loaded. This is a workaround to fix session issues in AWS.
+ if ($this->container['config']->get('system.session.initialize', 1) && isset($this->container['session'])) {
+ $this->container['session']->init();
+ }
+
+ // Initialize uri.
+ $this->container['uri']->init();
+
+ $this->container->setLocale();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/PagesProcessor.php b/system/src/Grav/Common/Processors/PagesProcessor.php
new file mode 100644
index 0000000..b2828f7
--- /dev/null
+++ b/system/src/Grav/Common/Processors/PagesProcessor.php
@@ -0,0 +1,44 @@
+container['debugger']->addMessage($this->container['cache']->getCacheStatus());
+
+ $this->container['pages']->init();
+ $this->container->fireEvent('onPagesInitialized', new Event(['pages' => $this->container['pages']]));
+ $this->container->fireEvent('onPageInitialized', new Event(['page' => $this->container['page']]));
+
+ /** @var Page $page */
+ $page = $this->container['page'];
+
+ if (!$page->routable()) {
+ // If no page found, fire event
+ $event = $this->container->fireEvent('onPageNotFound', new Event(['page' => $page]));
+
+ if (isset($event->page)) {
+ unset ($this->container['page']);
+ $this->container['page'] = $event->page;
+ } else {
+ throw new \RuntimeException('Page Not Found', 404);
+ }
+ }
+
+ }
+}
diff --git a/system/src/Grav/Common/Processors/PluginsProcessor.php b/system/src/Grav/Common/Processors/PluginsProcessor.php
new file mode 100644
index 0000000..ee56393
--- /dev/null
+++ b/system/src/Grav/Common/Processors/PluginsProcessor.php
@@ -0,0 +1,21 @@
+container['plugins']->init();
+ $this->container->fireEvent('onPluginsInitialized');
+ }
+}
diff --git a/system/src/Grav/Common/Processors/ProcessorBase.php b/system/src/Grav/Common/Processors/ProcessorBase.php
new file mode 100644
index 0000000..056c86d
--- /dev/null
+++ b/system/src/Grav/Common/Processors/ProcessorBase.php
@@ -0,0 +1,25 @@
+container = $container;
+ }
+
+}
diff --git a/system/src/Grav/Common/Processors/ProcessorInterface.php b/system/src/Grav/Common/Processors/ProcessorInterface.php
new file mode 100644
index 0000000..0e4b169
--- /dev/null
+++ b/system/src/Grav/Common/Processors/ProcessorInterface.php
@@ -0,0 +1,14 @@
+container;
+ $output = $container['output'];
+
+ if ($output instanceof \Psr\Http\Message\ResponseInterface) {
+ // Support for custom output providers like Slim Framework.
+ } else {
+ // Use internal Grav output.
+ $container->output = $output;
+ $container->fireEvent('onOutputGenerated');
+
+ // Set the header type
+ $container->header();
+
+ echo $container->output;
+
+ // remove any output
+ $container->output = '';
+
+ $this->container->fireEvent('onOutputRendered');
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Processors/SiteSetupProcessor.php b/system/src/Grav/Common/Processors/SiteSetupProcessor.php
new file mode 100644
index 0000000..1f3f8af
--- /dev/null
+++ b/system/src/Grav/Common/Processors/SiteSetupProcessor.php
@@ -0,0 +1,21 @@
+container['setup']->init();
+ $this->container['streams'];
+ }
+}
diff --git a/system/src/Grav/Common/Processors/TasksProcessor.php b/system/src/Grav/Common/Processors/TasksProcessor.php
new file mode 100644
index 0000000..d1e7a2f
--- /dev/null
+++ b/system/src/Grav/Common/Processors/TasksProcessor.php
@@ -0,0 +1,23 @@
+container['task'];
+ if ($task) {
+ $this->container->fireEvent('onTask.' . $task);
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Processors/ThemesProcessor.php b/system/src/Grav/Common/Processors/ThemesProcessor.php
new file mode 100644
index 0000000..c9ea013
--- /dev/null
+++ b/system/src/Grav/Common/Processors/ThemesProcessor.php
@@ -0,0 +1,20 @@
+container['themes']->init();
+ }
+}
diff --git a/system/src/Grav/Common/Processors/TwigProcessor.php b/system/src/Grav/Common/Processors/TwigProcessor.php
new file mode 100644
index 0000000..392824f
--- /dev/null
+++ b/system/src/Grav/Common/Processors/TwigProcessor.php
@@ -0,0 +1,21 @@
+container['twig']->init();
+ }
+
+}
diff --git a/system/src/Grav/Common/Service/AssetsServiceProvider.php b/system/src/Grav/Common/Service/AssetsServiceProvider.php
new file mode 100644
index 0000000..9618013
--- /dev/null
+++ b/system/src/Grav/Common/Service/AssetsServiceProvider.php
@@ -0,0 +1,21 @@
+findResource('cache://compiled/blueprints', true, true);
+
+ $files = [];
+ $paths = $locator->findResources('blueprints://config');
+ $files += (new ConfigFileFinder)->locateFiles($paths);
+ $paths = $locator->findResources('plugins://');
+ $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'blueprints');
+
+ $blueprints = new CompiledBlueprints($cache, $files, GRAV_ROOT);
+
+ return $blueprints->name("master-{$setup->environment}")->load();
+ }
+
+ public static function load(Container $container)
+ {
+ /** Setup $setup */
+ $setup = $container['setup'];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $container['locator'];
+
+ $cache = $locator->findResource('cache://compiled/config', true, true);
+
+ $files = [];
+ $paths = $locator->findResources('config://');
+ $files += (new ConfigFileFinder)->locateFiles($paths);
+ $paths = $locator->findResources('plugins://');
+ $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths);
+
+ $config = new CompiledConfig($cache, $files, GRAV_ROOT);
+ $config->setBlueprints(function() use ($container) {
+ return $container['blueprints'];
+ });
+
+ return $config->name("master-{$setup->environment}")->load();
+ }
+
+ public static function languages(Container $container)
+ {
+ /** @var Setup $setup */
+ $setup = $container['setup'];
+
+ /** @var Config $config */
+ $config = $container['config'];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $container['locator'];
+
+ $cache = $locator->findResource('cache://compiled/languages', true, true);
+ $files = [];
+
+ // Process languages only if enabled in configuration.
+ if ($config->get('system.languages.translations', true)) {
+ $paths = $locator->findResources('languages://');
+ $files += (new ConfigFileFinder)->locateFiles($paths);
+ $paths = $locator->findResources('plugins://');
+ $files += (new ConfigFileFinder)->setBase('plugins')->locateInFolders($paths, 'languages');
+ $paths = static::pluginFolderPaths($paths, 'languages');
+ $files += (new ConfigFileFinder)->locateFiles($paths);
+ }
+
+ $languages = new CompiledLanguages($cache, $files, GRAV_ROOT);
+
+ return $languages->name("master-{$setup->environment}")->load();
+ }
+
+ /**
+ * Find specific paths in plugins
+ *
+ * @param $plugins
+ * @param $folder_path
+ * @return array
+ */
+ private static function pluginFolderPaths($plugins, $folder_path)
+ {
+ $paths = [];
+
+ foreach ($plugins as $path) {
+ $iterator = new \DirectoryIterator($path);
+
+ /** @var \DirectoryIterator $directory */
+ foreach ($iterator as $directory) {
+ if (!$directory->isDir() || $directory->isDot()) {
+ continue;
+ }
+
+ // Path to the languages folder
+ $lang_path = $directory->getPathName() . '/' . $folder_path;
+
+ // If this folder exists, add it to the list of paths
+ if (file_exists($lang_path)) {
+ $paths []= $lang_path;
+ }
+ }
+ }
+ return $paths;
+ }
+
+}
diff --git a/system/src/Grav/Common/Service/ErrorServiceProvider.php b/system/src/Grav/Common/Service/ErrorServiceProvider.php
new file mode 100644
index 0000000..139de54
--- /dev/null
+++ b/system/src/Grav/Common/Service/ErrorServiceProvider.php
@@ -0,0 +1,22 @@
+findResource('log://grav.log', true, true);
+
+ $log->pushHandler(new StreamHandler($log_file, Logger::DEBUG));
+
+ return $log;
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Service/OutputServiceProvider.php b/system/src/Grav/Common/Service/OutputServiceProvider.php
new file mode 100644
index 0000000..89322ee
--- /dev/null
+++ b/system/src/Grav/Common/Service/OutputServiceProvider.php
@@ -0,0 +1,30 @@
+processSite($page->templateFormat());
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Service/PageServiceProvider.php b/system/src/Grav/Common/Service/PageServiceProvider.php
new file mode 100644
index 0000000..bf55f79
--- /dev/null
+++ b/system/src/Grav/Common/Service/PageServiceProvider.php
@@ -0,0 +1,102 @@
+path(); // Don't trim to support trailing slash default routes
+ $path = $path ?: '/';
+
+ $page = $pages->dispatch($path);
+
+ // Redirection tests
+ if ($page) {
+ /** @var Language $language */
+ $language = $c['language'];
+
+ // some debugger override logic
+ if ($page->debugger() === false) {
+ $c['debugger']->enabled(false);
+ }
+
+ if ($c['config']->get('system.force_ssl')) {
+ if (!isset($_SERVER['HTTPS']) || $_SERVER["HTTPS"] != "on") {
+ $url = "https://" . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"];
+ $c->redirect($url);
+ }
+ }
+
+ $url = $page->route();
+
+ if ($uri->params()) {
+ if ($url == '/') { //Avoid double slash
+ $url = $uri->params();
+ } else {
+ $url .= $uri->params();
+ }
+ }
+ if ($uri->query()) {
+ $url .= '?' . $uri->query();
+ }
+ if ($uri->fragment()) {
+ $url .= '#' . $uri->fragment();
+ }
+
+ // Language-specific redirection scenarios
+ if ($language->enabled()) {
+ if ($language->isLanguageInUrl() && !$language->isIncludeDefaultLanguage()) {
+ $c->redirect($url);
+ }
+ if (!$language->isLanguageInUrl() && $language->isIncludeDefaultLanguage()) {
+ $c->redirectLangSafe($url);
+ }
+ }
+ // Default route test and redirect
+ if ($c['config']->get('system.pages.redirect_default_route') && $page->route() != $path) {
+ $c->redirectLangSafe($url);
+ }
+ }
+
+ // if page is not found, try some fallback stuff
+ if (!$page || !$page->routable()) {
+
+ // Try fallback URL stuff...
+ $page = $c->fallbackUrl($path);
+
+ if (!$page) {
+ $path = $c['locator']->findResource('system://pages/notfound.md');
+ $page = new Page();
+ $page->init(new \SplFileInfo($path));
+ $page->routable(false);
+ }
+ }
+
+ return $page;
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Service/SessionServiceProvider.php b/system/src/Grav/Common/Service/SessionServiceProvider.php
new file mode 100644
index 0000000..52e107b
--- /dev/null
+++ b/system/src/Grav/Common/Service/SessionServiceProvider.php
@@ -0,0 +1,105 @@
+get('system.session.timeout', 1800);
+ $session_path = $config->get('system.session.path');
+ if (null === $session_path) {
+ $session_path = '/' . ltrim(Uri::filterPath($uri->rootUrl(false)), '/');
+ }
+ $domain = $uri->host();
+ if ($domain === 'localhost') {
+ $domain = '';
+ }
+
+ // Get session options.
+ $secure = (bool)$config->get('system.session.secure', false);
+ $httponly = (bool)$config->get('system.session.httponly', true);
+ $enabled = (bool)$config->get('system.session.enabled', false);
+
+ // Activate admin if we're inside the admin path.
+ $is_admin = false;
+ if ($config->get('plugins.admin.enabled')) {
+ $base = '/' . trim($config->get('plugins.admin.route'), '/');
+
+ // Uri::route() is not processed yet, let's quickly get what we need.
+ $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH));
+
+ // Check no language, simple language prefix (en) and region specific language prefix (en-US).
+ $pos = strpos($current_route, $base);
+ if ($pos === 0 || $pos === 3 || $pos === 6) {
+ $session_timeout = $config->get('plugins.admin.session.timeout', 1800);
+ $enabled = $is_admin = true;
+ }
+ }
+
+ // Fix for HUGE session timeouts.
+ if ($session_timeout > 99999999999) {
+ $session_timeout = 9999999999;
+ }
+
+ $inflector = new Inflector();
+ $session_name = $inflector->hyphenize($config->get('system.session.name', 'grav_site')) . '-' . substr(md5(GRAV_ROOT), 0, 7);
+ if ($is_admin && $config->get('system.session.split', true)) {
+ $session_name .= '-admin';
+ }
+
+ // Define session service.
+ $session = new Session($session_timeout, $session_path, $domain);
+ $session->setName($session_name);
+ $session->setSecure($secure);
+ $session->setHttpOnly($httponly);
+ $session->setAutoStart($enabled);
+
+ return $session;
+ };
+
+ // Define session message service.
+ $container['messages'] = function ($c) {
+ if (!isset($c['session']) || !$c['session']->started()) {
+ /** @var Debugger $debugger */
+ $debugger = $c['debugger'];
+ $debugger->addMessage('Inactive session: session messages may disappear', 'warming');
+
+ return new Message;
+ }
+
+ /** @var Session $session */
+ $session = $c['session'];
+
+ if (!isset($session->messages)) {
+ $session->messages = new Message;
+ }
+
+ return $session->messages;
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Service/StreamsServiceProvider.php b/system/src/Grav/Common/Service/StreamsServiceProvider.php
new file mode 100644
index 0000000..a2ebcc6
--- /dev/null
+++ b/system/src/Grav/Common/Service/StreamsServiceProvider.php
@@ -0,0 +1,47 @@
+initializeLocator($locator);
+
+ return $locator;
+ };
+
+ $container['streams'] = function($c) {
+ /** @var Setup $setup */
+ $setup = $c['setup'];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $c['locator'];
+
+ // Set locator to both streams.
+ Stream::setLocator($locator);
+ ReadOnlyStream::setLocator($locator);
+
+ return new StreamBuilder($setup->getStreams());
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Service/TaskServiceProvider.php b/system/src/Grav/Common/Service/TaskServiceProvider.php
new file mode 100644
index 0000000..40b9696
--- /dev/null
+++ b/system/src/Grav/Common/Service/TaskServiceProvider.php
@@ -0,0 +1,24 @@
+param('task');
+ };
+ }
+}
diff --git a/system/src/Grav/Common/Session.php b/system/src/Grav/Common/Session.php
new file mode 100644
index 0000000..7573f08
--- /dev/null
+++ b/system/src/Grav/Common/Session.php
@@ -0,0 +1,153 @@
+lifetime = $lifetime;
+ $this->path = $path;
+ $this->domain = $domain;
+
+ if (php_sapi_name() !== 'cli') {
+ parent::__construct($lifetime, $path, $domain);
+ }
+ }
+
+ /**
+ * Initialize session.
+ *
+ * Code in this function has been moved into SessionServiceProvider class.
+ */
+ public function init()
+ {
+ if ($this->autoStart) {
+ $this->start();
+
+ // TODO: This setcookie shouldn't be here, session should by itself be able to update its cookie.
+ setcookie(session_name(), session_id(), $this->lifetime ? time() + $this->lifetime : 0, $this->path, $this->domain, $this->secure, $this->httpOnly);
+
+ $this->autoStart = false;
+ }
+ }
+
+ /**
+ * @param bool $auto
+ * @return $this
+ */
+ public function setAutoStart($auto)
+ {
+ $this->autoStart = (bool)$auto;
+
+ return $this;
+ }
+
+ /**
+ * @param bool $secure
+ * @return $this
+ */
+ public function setSecure($secure)
+ {
+ $this->secure = $secure;
+ ini_set('session.cookie_secure', (bool)$secure);
+
+ return $this;
+ }
+
+ /**
+ * @param bool $httpOnly
+ * @return $this
+ */
+ public function setHttpOnly($httpOnly)
+ {
+ $this->httpOnly = $httpOnly;
+ ini_set('session.cookie_httponly', (bool)$httpOnly);
+
+ return $this;
+ }
+
+ /**
+ * Store something in session temporarily.
+ *
+ * @param string $name
+ * @param mixed $object
+ * @return $this
+ */
+ public function setFlashObject($name, $object)
+ {
+ $this->{$name} = serialize($object);
+
+ return $this;
+ }
+
+ /**
+ * Return object and remove it from session.
+ *
+ * @param string $name
+ * @return mixed
+ */
+ public function getFlashObject($name)
+ {
+ $object = unserialize($this->{$name});
+
+ $this->{$name} = null;
+
+ return $object;
+ }
+
+ /**
+ * Store something in cookie temporarily.
+ *
+ * @param string $name
+ * @param mixed $object
+ * @param int $time
+ * @return $this
+ */
+ public function setFlashCookieObject($name, $object, $time = 60)
+ {
+ setcookie($name, json_encode($object), time() + $time, '/');
+
+ return $this;
+ }
+
+ /**
+ * Return object and remove it from the cookie.
+ *
+ * @param string $name
+ * @return mixed|null
+ */
+ public function getFlashCookieObject($name)
+ {
+ if (isset($_COOKIE[$name])) {
+ $object = json_decode($_COOKIE[$name]);
+ setcookie($name, '', time() - 3600, '/');
+ return $object;
+ }
+
+ return null;
+ }
+}
diff --git a/system/src/Grav/Common/Taxonomy.php b/system/src/Grav/Common/Taxonomy.php
new file mode 100644
index 0000000..dda2844
--- /dev/null
+++ b/system/src/Grav/Common/Taxonomy.php
@@ -0,0 +1,149 @@
+taxonomy_map = [];
+ $this->grav = $grav;
+ }
+
+ /**
+ * Takes an individual page and processes the taxonomies configured in its header. It
+ * then adds those taxonomies to the map
+ *
+ * @param Page $page the page to process
+ * @param array $page_taxonomy
+ */
+ public function addTaxonomy(Page $page, $page_taxonomy = null)
+ {
+ if (!$page_taxonomy) {
+ $page_taxonomy = $page->taxonomy();
+ }
+
+ if (!$page->published() || empty($page_taxonomy)) {
+ return;
+ }
+
+ /** @var Config $config */
+ $config = $this->grav['config'];
+ if ($config->get('site.taxonomies')) {
+ foreach ((array)$config->get('site.taxonomies') as $taxonomy) {
+ if (isset($page_taxonomy[$taxonomy])) {
+ foreach ((array)$page_taxonomy[$taxonomy] as $item) {
+ $this->taxonomy_map[$taxonomy][(string)$item][$page->path()] = ['slug' => $page->slug()];
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns a new Page object with the sub-pages containing all the values set for a
+ * particular taxonomy.
+ *
+ * @param array $taxonomies taxonomies to search, eg ['tag'=>['animal','cat']]
+ * @param string $operator can be 'or' or 'and' (defaults to 'and')
+ *
+ * @return Collection Collection object set to contain matches found in the taxonomy map
+ */
+ public function findTaxonomy($taxonomies, $operator = 'and')
+ {
+ $matches = [];
+ $results = [];
+
+ foreach ((array)$taxonomies as $taxonomy => $items) {
+ foreach ((array)$items as $item) {
+ if (isset($this->taxonomy_map[$taxonomy][$item])) {
+ $matches[] = $this->taxonomy_map[$taxonomy][$item];
+ } else {
+ $matches[] = [];
+ }
+ }
+ }
+
+ if (strtolower($operator) == 'or') {
+ foreach ($matches as $match) {
+ $results = array_merge($results, $match);
+ }
+ } else {
+ $results = $matches ? array_pop($matches) : [];
+ foreach ($matches as $match) {
+ $results = array_intersect_key($results, $match);
+ }
+ }
+
+ return new Collection($results, ['taxonomies' => $taxonomies]);
+ }
+
+ /**
+ * Gets and Sets the taxonomy map
+ *
+ * @param array $var the taxonomy map
+ *
+ * @return array the taxonomy map
+ */
+ public function taxonomy($var = null)
+ {
+ if ($var) {
+ $this->taxonomy_map = $var;
+ }
+
+ return $this->taxonomy_map;
+ }
+
+ /**
+ * Gets item keys per taxonomy
+ *
+ * @param string $taxonomy taxonomy name
+ *
+ * @return array keys of this taxonomy
+ */
+ public function getTaxonomyItemKeys($taxonomy) {
+ if (isset($this->taxonomy_map[$taxonomy])) {
+
+ $results = array_keys($this->taxonomy_map[$taxonomy]);
+
+ return $results;
+ }
+
+ return [];
+ }
+}
diff --git a/system/src/Grav/Common/Theme.php b/system/src/Grav/Common/Theme.php
new file mode 100644
index 0000000..537df11
--- /dev/null
+++ b/system/src/Grav/Common/Theme.php
@@ -0,0 +1,94 @@
+config["themes.{$this->name}"];
+ }
+
+ /**
+ * Persists to disk the theme parameters currently stored in the Grav Config object
+ *
+ * @param string $theme_name The name of the theme whose config it should store.
+ *
+ * @return true
+ */
+ public static function saveConfig($theme_name)
+ {
+ if (!$theme_name) {
+ return false;
+ }
+
+ $grav = Grav::instance();
+ $locator = $grav['locator'];
+ $filename = 'config://themes/' . $theme_name . '.yaml';
+ $file = YamlFile::instance($locator->findResource($filename, true, true));
+ $content = $grav['config']->get('themes.' . $theme_name);
+ $file->save($content);
+ $file->free();
+
+ return true;
+ }
+
+ /**
+ * Override the mergeConfig method to work for themes
+ */
+ protected function mergeConfig(Page $page, $deep = 'merge', $params = [], $type = 'themes') {
+ return parent::mergeConfig($page, $deep, $params, $type);
+ }
+
+ /**
+ * Simpler getter for the theme blueprint
+ *
+ * @return mixed
+ */
+ public function getBlueprint()
+ {
+ if (!$this->blueprint) {
+ $this->loadBlueprint();
+ }
+ return $this->blueprint;
+ }
+
+ /**
+ * Load blueprints.
+ */
+ protected function loadBlueprint()
+ {
+ if (!$this->blueprint) {
+ $grav = Grav::instance();
+ $themes = $grav['themes'];
+ $this->blueprint = $themes->get($this->name)->blueprints();
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Themes.php b/system/src/Grav/Common/Themes.php
new file mode 100644
index 0000000..8f29037
--- /dev/null
+++ b/system/src/Grav/Common/Themes.php
@@ -0,0 +1,359 @@
+grav = $grav;
+ $this->config = $grav['config'];
+
+ // Register instance as autoloader for theme inheritance
+ spl_autoload_register([$this, 'autoloadTheme']);
+ }
+
+ public function init()
+ {
+ /** @var Themes $themes */
+ $themes = $this->grav['themes'];
+ $themes->configure();
+
+ $this->initTheme();
+ }
+
+ public function initTheme()
+ {
+ if ($this->inited === false) {
+ /** @var Themes $themes */
+ $themes = $this->grav['themes'];
+
+ try {
+ $instance = $themes->load();
+ } catch (\InvalidArgumentException $e) {
+ throw new \RuntimeException($this->current() . ' theme could not be found');
+ }
+
+ if ($instance instanceof EventSubscriberInterface) {
+ /** @var EventDispatcher $events */
+ $events = $this->grav['events'];
+
+ $events->addSubscriber($instance);
+ }
+
+ $this->grav['theme'] = $instance;
+
+ $this->grav->fireEvent('onThemeInitialized');
+
+ $this->inited = true;
+ }
+ }
+
+ /**
+ * Return list of all theme data with their blueprints.
+ *
+ * @return array
+ */
+ public function all()
+ {
+ $list = [];
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ $iterator = $locator->getIterator('themes://');
+
+ /** @var \DirectoryIterator $directory */
+ foreach ($iterator as $directory) {
+ if (!$directory->isDir() || $directory->isDot()) {
+ continue;
+ }
+
+ $theme = $directory->getBasename();
+ $result = self::get($theme);
+
+ if ($result) {
+ $list[$theme] = $result;
+ }
+ }
+ ksort($list);
+
+ return $list;
+ }
+
+ /**
+ * Get theme configuration or throw exception if it cannot be found.
+ *
+ * @param string $name
+ *
+ * @return Data
+ * @throws \RuntimeException
+ */
+ public function get($name)
+ {
+ if (!$name) {
+ throw new \RuntimeException('Theme name not provided.');
+ }
+
+ $blueprints = new Blueprints('themes://');
+ $blueprint = $blueprints->get("{$name}/blueprints");
+
+ // Load default configuration.
+ $file = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT);
+
+ // ensure this is a valid theme
+ if (!$file->exists()) {
+ return null;
+ }
+
+ // Find thumbnail.
+ $thumb = "themes://{$name}/thumbnail.jpg";
+ $path = $this->grav['locator']->findResource($thumb, false);
+
+ if ($path) {
+ $blueprint->set('thumbnail', $this->grav['base_url'] . '/' . $path);
+ }
+
+ $obj = new Data($file->content(), $blueprint);
+
+ // Override with user configuration.
+ $obj->merge($this->config->get('themes.' . $name) ?: []);
+
+ // Save configuration always to user/config.
+ $file = CompiledYamlFile::instance("config://themes/{$name}" . YAML_EXT);
+ $obj->file($file);
+
+ return $obj;
+ }
+
+ /**
+ * Return name of the current theme.
+ *
+ * @return string
+ */
+ public function current()
+ {
+ return (string)$this->config->get('system.pages.theme');
+ }
+
+ /**
+ * Load current theme.
+ *
+ * @return Theme
+ */
+ public function load()
+ {
+ // NOTE: ALL THE LOCAL VARIABLES ARE USED INSIDE INCLUDED FILE, DO NOT REMOVE THEM!
+ $grav = $this->grav;
+ $config = $this->config;
+ $name = $this->current();
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+ $file = $locator('theme://theme.php') ?: $locator("theme://{$name}.php");
+
+ $inflector = $grav['inflector'];
+
+ if ($file) {
+ // Local variables available in the file: $grav, $config, $name, $file
+ $class = include $file;
+
+ if (!is_object($class)) {
+ $themeClassFormat = [
+ 'Grav\\Theme\\' . ucfirst($name),
+ 'Grav\\Theme\\' . $inflector->camelize($name)
+ ];
+
+ foreach ($themeClassFormat as $themeClass) {
+ if (class_exists($themeClass)) {
+ $themeClassName = $themeClass;
+ $class = new $themeClassName($grav, $config, $name);
+ break;
+ }
+ }
+ }
+ } elseif (!$locator('theme://') && !defined('GRAV_CLI')) {
+ exit("Theme '$name' does not exist, unable to display page.");
+ }
+
+ $this->config->set('theme', $config->get('themes.' . $name));
+
+ if (empty($class)) {
+ $class = new Theme($grav, $config, $name);
+ }
+
+ return $class;
+ }
+
+ /**
+ * Configure and prepare streams for current template.
+ *
+ * @throws \InvalidArgumentException
+ */
+ public function configure()
+ {
+ $name = $this->current();
+ $config = $this->config;
+
+ $this->loadConfiguration($name, $config);
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ $registered = stream_get_wrappers();
+
+ $schemes = $config->get("themes.{$name}.streams.schemes", []);
+ $schemes += [
+ 'theme' => [
+ 'type' => 'ReadOnlyStream',
+ 'paths' => $locator->findResources("themes://{$name}", false)
+ ]
+ ];
+
+ foreach ($schemes as $scheme => $config) {
+ if (isset($config['paths'])) {
+ $locator->addPath($scheme, '', $config['paths']);
+ }
+ if (isset($config['prefixes'])) {
+ foreach ($config['prefixes'] as $prefix => $paths) {
+ $locator->addPath($scheme, $prefix, $paths);
+ }
+ }
+
+ if (in_array($scheme, $registered)) {
+ stream_wrapper_unregister($scheme);
+ }
+ $type = !empty($config['type']) ? $config['type'] : 'ReadOnlyStream';
+ if ($type[0] !== '\\') {
+ $type = '\\RocketTheme\\Toolbox\\StreamWrapper\\' . $type;
+ }
+
+ if (!stream_wrapper_register($scheme, $type)) {
+ throw new \InvalidArgumentException("Stream '{$type}' could not be initialized.");
+ }
+ }
+
+ // Load languages after streams has been properly initialized
+ $this->loadLanguages($this->config);
+ }
+
+ /**
+ * Load theme configuration.
+ *
+ * @param string $name Theme name
+ * @param Config $config Configuration class
+ */
+ protected function loadConfiguration($name, Config $config)
+ {
+ $themeConfig = CompiledYamlFile::instance("themes://{$name}/{$name}" . YAML_EXT)->content();
+ $config->joinDefaults("themes.{$name}", $themeConfig);
+ }
+
+ /**
+ * Load theme languages.
+ *
+ * @param Config $config Configuration class
+ */
+ protected function loadLanguages(Config $config)
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($config->get('system.languages.translations', true)) {
+ $language_file = $locator->findResource("theme://languages" . YAML_EXT);
+ if ($language_file) {
+ $language = CompiledYamlFile::instance($language_file)->content();
+ $this->grav['languages']->mergeRecursive($language);
+ }
+ $languages_folder = $locator->findResource("theme://languages/");
+ if (file_exists($languages_folder)) {
+ $languages = [];
+ $iterator = new \DirectoryIterator($languages_folder);
+
+ /** @var \DirectoryIterator $directory */
+ foreach ($iterator as $file) {
+ if ($file->getExtension() !== 'yaml') {
+ continue;
+ }
+ $languages[$file->getBasename('.yaml')] = CompiledYamlFile::instance($file->getPathname())->content();
+ }
+ $this->grav['languages']->mergeRecursive($languages);
+ }
+ }
+ }
+
+ /**
+ * Autoload theme classes for inheritance
+ *
+ * @param string $class Class name
+ *
+ * @return mixed false FALSE if unable to load $class; Class name if
+ * $class is successfully loaded
+ */
+ protected function autoloadTheme($class)
+ {
+ $prefix = 'Grav\\Theme\\';
+ if (false !== strpos($class, $prefix)) {
+ // Remove prefix from class
+ $class = substr($class, strlen($prefix));
+ $locator = $this->grav['locator'];
+
+ // First try lowercase version of the classname.
+ $path = strtolower($class);
+ $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php");
+
+ if ($file) {
+ return include_once $file;
+ }
+
+ // Replace namespace tokens to directory separators
+ $path = $this->grav['inflector']->hyphenize($class);
+ $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php");
+
+ // Load class
+ if ($file) {
+ return include_once $file;
+ }
+
+ // Try Old style theme classes
+ $path = strtolower(preg_replace('#\\\|_(?!.+\\\)#', '/', $class));
+ $file = $locator("themes://{$path}/theme.php") ?: $locator("themes://{$path}/{$path}.php");
+
+ // Load class
+ if ($file) {
+ return include_once $file;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php
new file mode 100644
index 0000000..5943c95
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php
@@ -0,0 +1,35 @@
+ $body), array(), $lineno, $tag);
+ }
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig_Compiler A Twig_Compiler instance
+ */
+ public function compile(\Twig_Compiler $compiler)
+ {
+ $compiler
+ ->addDebugInfo($this)
+ ->write('ob_start();' . PHP_EOL)
+ ->subcompile($this->getNode('body'))
+ ->write('$content = ob_get_clean();' . PHP_EOL)
+ ->write('preg_match("/^\s*/", $content, $matches);' . PHP_EOL)
+ ->write('$lines = explode("\n", $content);' . PHP_EOL)
+ ->write('$content = preg_replace(\'/^\' . $matches[0]. \'/\', "", $lines);' . PHP_EOL)
+ ->write('$content = join("\n", $content);' . PHP_EOL)
+ ->write('echo $this->env->getExtension(\'Grav\Common\Twig\TwigExtension\')->markdownFunction($content);' . PHP_EOL);
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeScript.php b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php
new file mode 100644
index 0000000..35ca20f
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Node/TwigNodeScript.php
@@ -0,0 +1,102 @@
+ $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag);
+ }
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig_Compiler $compiler A Twig_Compiler instance
+ * @throws \LogicException
+ */
+ public function compile(\Twig_Compiler $compiler)
+ {
+ $compiler->addDebugInfo($this);
+
+ if ($this->getNode('attributes') !== null) {
+ $compiler
+ ->write('$attributes = ')
+ ->subcompile($this->getNode('attributes'))
+ ->raw(";\n")
+ ->write("if (\$attributes !== null && !is_array(\$attributes)) {\n")
+ ->indent()
+ ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n")
+ ->outdent()
+ ->write("}\n");
+ } else {
+ $compiler->write('$attributes = [];' . "\n");
+ }
+
+ if ($this->getNode('group') !== null) {
+ $compiler
+ ->write('$group = ')
+ ->subcompile($this->getNode('group'))
+ ->raw(";\n")
+ ->write("if (\$group !== null && !is_string(\$group)) {\n")
+ ->indent()
+ ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n")
+ ->outdent()
+ ->write("}\n");
+ } else {
+ $compiler->write('$group = null;' . "\n");
+ }
+
+ if ($this->getNode('priority') !== null) {
+ $compiler
+ ->write('$priority = (int)(')
+ ->subcompile($this->getNode('priority'))
+ ->raw(");\n");
+ } else {
+ $compiler->write('$priority = null;' . "\n");
+ }
+
+ $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n");
+
+ if ($this->getNode('file') !== null) {
+ $compiler
+ ->write('$file = ')
+ ->subcompile($this->getNode('file'))
+ ->write(";\n")
+ ->write("\$pipeline = !empty(\$attributes['pipeline']);\n")
+ ->write("\$loading = !empty(\$attributes['defer']) ? 'defer' : (!empty(\$attributes['async']) ? 'async' : null);\n")
+ ->write("\$assets->addJs(\$file, \$priority, \$pipeline, \$loading, \$group);\n");
+ } else {
+ $compiler
+ ->write("ob_start();\n")
+ ->subcompile($this->getNode('body'))
+ ->write("\$content = ob_get_clean();")
+ ->write("\$assets->addInlineJs(\$content, \$priority, \$group, \$attributes);\n");
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php
new file mode 100644
index 0000000..8c0b3b8
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Node/TwigNodeStyle.php
@@ -0,0 +1,98 @@
+ $body, 'file' => $file, 'group' => $group, 'priority' => $priority, 'attributes' => $attributes], [], $lineno, $tag);
+ }
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig_Compiler $compiler A Twig_Compiler instance
+ * @throws \LogicException
+ */
+ public function compile(\Twig_Compiler $compiler)
+ {
+ $compiler->addDebugInfo($this);
+
+ if ($this->getNode('attributes') !== null) {
+ $compiler
+ ->write('$attributes = ')
+ ->subcompile($this->getNode('attributes'))
+ ->raw(";\n")
+ ->write("if (\$attributes !== null && !is_array(\$attributes)) {\n")
+ ->indent()
+ ->write("throw new UnexpectedValueException('{% {$this->tagName} with x %}: x is not an array');\n")
+ ->outdent()
+ ->write("}\n");
+ } else {
+ $compiler->write('$attributes = [];' . "\n");
+ }
+
+ if ($this->getNode('group') !== null) {
+ $compiler
+ ->write('$group = ')
+ ->subcompile($this->getNode('group'))
+ ->raw(";\n")
+ ->write("if (\$group !== null && !is_string(\$group)) {\n")
+ ->indent()
+ ->write("throw new UnexpectedValueException('{% {$this->tagName} in x %}: x is not a string');\n")
+ ->outdent()
+ ->write("}\n");
+ } else {
+ $compiler->write('$group = null;' . "\n");
+ }
+
+ if ($this->getNode('priority') !== null) {
+ $compiler
+ ->write('$priority = (int)(')
+ ->subcompile($this->getNode('priority'))
+ ->raw(");\n");
+ } else {
+ $compiler->write('$priority = null;' . "\n");
+ }
+
+ $compiler->write("\$assets = \\Grav\\Common\\Grav::instance()['assets'];\n");
+
+ if ($this->getNode('file') !== null) {
+ $compiler
+ ->write('$file = ')
+ ->subcompile($this->getNode('file'))
+ ->write(";\n")
+ ->write("\$pipeline = !empty(\$attributes['pipeline']);\n")
+ ->write("\$assets->addCss(\$file, \$priority, \$pipeline, \$group);\n");
+ } else {
+ $compiler
+ ->write("ob_start();\n")
+ ->subcompile($this->getNode('body'))
+ ->write("\$content = ob_get_clean();")
+ ->write("\$assets->addInlineCss(\$content, \$priority, \$group);\n");
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php
new file mode 100644
index 0000000..e0e8127
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Node/TwigNodeSwitch.php
@@ -0,0 +1,71 @@
+ $value, 'cases' => $cases, 'default' => $default), array(), $lineno, $tag);
+ }
+
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig_Compiler A Twig_Compiler instance
+ */
+ public function compile(\Twig_Compiler $compiler)
+ {
+ $compiler
+ ->addDebugInfo($this)
+ ->write("switch (")
+ ->subcompile($this->getNode('value'))
+ ->raw(") {\n")
+ ->indent();
+
+ foreach ($this->getNode('cases') as $case)
+ {
+ if (!$case->hasNode('body'))
+ {
+ continue;
+ }
+
+ foreach ($case->getNode('values') as $value)
+ {
+ $compiler
+ ->write('case ')
+ ->subcompile($value)
+ ->raw(":\n");
+ }
+
+ $compiler
+ ->write("{\n")
+ ->indent()
+ ->subcompile($case->getNode('body'))
+ ->write("break;\n")
+ ->outdent()
+ ->write("}\n");
+ }
+
+ if ($this->hasNode('default') && $this->getNode('default') !== null)
+ {
+ $compiler
+ ->write("default:\n")
+ ->write("{\n")
+ ->indent()
+ ->subcompile($this->getNode('default'))
+ ->outdent()
+ ->write("}\n");
+ }
+
+ $compiler
+ ->outdent()
+ ->write("}\n");
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php
new file mode 100644
index 0000000..e37c319
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Node/TwigNodeTryCatch.php
@@ -0,0 +1,52 @@
+ $try, 'catch' => $catch), array(), $lineno, $tag);
+ }
+
+ /**
+ * Compiles the node to PHP.
+ *
+ * @param \Twig_Compiler $compiler A Twig_Compiler instance
+ * @throws \LogicException
+ */
+ public function compile(\Twig_Compiler $compiler)
+ {
+ $compiler->addDebugInfo($this);
+
+ $compiler
+ ->write('try {')
+ ;
+
+ $compiler
+ ->indent()
+ ->subcompile($this->getNode('try'))
+ ;
+
+ if ($this->hasNode('catch') && null !== $this->getNode('catch')) {
+ $compiler
+ ->outdent()
+ ->write('} catch (\Exception $e) {' . "\n")
+ ->indent()
+ ->write('if (isset($context[\'grav\'][\'debugger\'])) $context[\'grav\'][\'debugger\']->addException($e);' . "\n")
+ ->write('$context[\'e\'] = $e;' . "\n")
+ ->subcompile($this->getNode('catch'))
+ ;
+ }
+
+ $compiler
+ ->outdent()
+ ->write("}\n");
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php
new file mode 100644
index 0000000..a1d4135
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserMarkdown.php
@@ -0,0 +1,53 @@
+getLine();
+ $this->parser->getStream()->expect(\Twig_Token::BLOCK_END_TYPE);
+ $body = $this->parser->subparse(array($this, 'decideMarkdownEnd'), true);
+ $this->parser->getStream()->expect(\Twig_Token::BLOCK_END_TYPE);
+ return new TwigNodeMarkdown($body, $lineno, $this->getTag());
+ }
+ /**
+ * Decide if current token marks end of Markdown block.
+ *
+ * @param \Twig_Token $token
+ * @return bool
+ */
+ public function decideMarkdownEnd(\Twig_Token $token)
+ {
+ return $token->test('endmarkdown');
+ }
+ /**
+ * {@inheritdoc}
+ */
+ public function getTag()
+ {
+ return 'markdown';
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php
new file mode 100644
index 0000000..98c72e2
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserScript.php
@@ -0,0 +1,100 @@
+getLine();
+ $stream = $this->parser->getStream();
+
+ list($file, $group, $priority, $attributes) = $this->parseArguments($token);
+
+ $content = null;
+ if ($file === null) {
+ $content = $this->parser->subparse([$this, 'decideBlockEnd'], true);
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ }
+
+ return new TwigNodeScript($content, $file, $group, $priority, $attributes, $lineno, $this->getTag());
+ }
+
+ /**
+ * @param \Twig_Token $token
+ * @return array
+ */
+ protected function parseArguments(\Twig_Token $token)
+ {
+ $stream = $this->parser->getStream();
+
+ $file = null;
+ if (!$stream->test(\Twig_Token::NAME_TYPE) && !$stream->test(\Twig_Token::OPERATOR_TYPE) && !$stream->test(\Twig_Token::BLOCK_END_TYPE)) {
+ $file = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $group = null;
+ if ($stream->nextIf(\Twig_Token::OPERATOR_TYPE, 'in')) {
+ $group = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $priority = null;
+ if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'priority')) {
+ $stream->expect(\Twig_Token::PUNCTUATION_TYPE, ':');
+ $priority = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $attributes = null;
+ if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'with')) {
+ $attributes = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+
+ return [$file, $group, $priority, $attributes];
+ }
+
+ /**
+ * @param \Twig_Token $token
+ * @return bool
+ */
+ public function decideBlockEnd(\Twig_Token $token)
+ {
+ return $token->test('endscript');
+ }
+
+ /**
+ * Gets the tag name associated with this token parser.
+ *
+ * @return string The tag name
+ */
+ public function getTag()
+ {
+ return 'script';
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php
new file mode 100644
index 0000000..f207686
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserStyle.php
@@ -0,0 +1,99 @@
+getLine();
+ $stream = $this->parser->getStream();
+
+ list ($file, $group, $priority, $attributes) = $this->parseArguments($token);
+
+ $content = null;
+ if (!$file) {
+ $content = $this->parser->subparse([$this, 'decideBlockEnd'], true);
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ }
+
+ return new TwigNodeStyle($content, $file, $group, $priority, $attributes, $lineno, $this->getTag());
+ }
+
+ /**
+ * @param \Twig_Token $token
+ * @return array
+ */
+ protected function parseArguments(\Twig_Token $token)
+ {
+ $stream = $this->parser->getStream();
+
+ $file = null;
+ if (!$stream->test(\Twig_Token::NAME_TYPE) && !$stream->test(\Twig_Token::OPERATOR_TYPE) && !$stream->test(\Twig_Token::BLOCK_END_TYPE)) {
+ $file = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $group = null;
+ if ($stream->nextIf(\Twig_Token::OPERATOR_TYPE, 'in')) {
+ $group = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $priority = null;
+ if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'priority')) {
+ $stream->expect(\Twig_Token::PUNCTUATION_TYPE, ':');
+ $priority = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $attributes = null;
+ if ($stream->nextIf(\Twig_Token::NAME_TYPE, 'with')) {
+ $attributes = $this->parser->getExpressionParser()->parseExpression();
+ }
+
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+
+ return [$file, $group, $priority, $attributes];
+ }
+
+ /**
+ * @param \Twig_Token $token
+ * @return bool
+ */
+ public function decideBlockEnd(\Twig_Token $token)
+ {
+ return $token->test('endstyle');
+ }
+
+ /**
+ * Gets the tag name associated with this token parser.
+ *
+ * @return string The tag name
+ */
+ public function getTag()
+ {
+ return 'style';
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php
new file mode 100644
index 0000000..2da932d
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserSwitch.php
@@ -0,0 +1,138 @@
+getLine();
+ $stream = $this->parser->getStream();
+
+ $name = $this->parser->getExpressionParser()->parseExpression();
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+
+ // There can be some whitespace between the {% switch %} and first {% case %} tag.
+ while ($stream->getCurrent()->getType() == \Twig_Token::TEXT_TYPE && trim($stream->getCurrent()->getValue()) == '')
+ {
+ $stream->next();
+ }
+
+ $stream->expect(\Twig_Token::BLOCK_START_TYPE);
+
+ $expressionParser = $this->parser->getExpressionParser();
+
+ $default = null;
+ $cases = array();
+ $end = false;
+
+ while (!$end)
+ {
+ $next = $stream->next();
+
+ switch ($next->getValue())
+ {
+ case 'case':
+ {
+ $values = array();
+
+ while (true)
+ {
+ $values[] = $expressionParser->parsePrimaryExpression();
+ // Multiple allowed values?
+ if ($stream->test(\Twig_Token::OPERATOR_TYPE, 'or'))
+ {
+ $stream->next();
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ $body = $this->parser->subparse(array($this, 'decideIfFork'));
+ $cases[] = new \Twig_Node(array(
+ 'values' => new \Twig_Node($values),
+ 'body' => $body
+ ));
+ break;
+ }
+ case 'default':
+ {
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ $default = $this->parser->subparse(array($this, 'decideIfEnd'));
+ break;
+ }
+ case 'endswitch':
+ {
+ $end = true;
+ break;
+ }
+ default:
+ {
+ throw new \Twig_Error_Syntax(sprintf('Unexpected end of template. Twig was looking for the following tags "case", "default", or "endswitch" to close the "switch" block started at line %d)', $lineno), -1);
+ }
+ }
+ }
+
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+
+ return new TwigNodeSwitch($name, new \Twig_Node($cases), $default, $lineno, $this->getTag());
+ }
+
+ /**
+ * Decide if current token marks switch logic.
+ *
+ * @param \Twig_Token $token
+ * @return bool
+ */
+ public function decideIfFork(\Twig_Token $token)
+ {
+ return $token->test(array('case', 'default', 'endswitch'));
+ }
+
+ /**
+ * Decide if current token marks end of swtich block.
+ *
+ * @param \Twig_Token $token
+ * @return bool
+ */
+ public function decideIfEnd(\Twig_Token $token)
+ {
+ return $token->test(array('endswitch'));
+ }
+
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getTag()
+ {
+ return 'switch';
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php
new file mode 100644
index 0000000..d639c57
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TokenParser/TwigTokenParserTryCatch.php
@@ -0,0 +1,68 @@
+
+ * {% try %}
+ *
{{ user.get('name') }}
+ * {% catch %}
+ * {{ e.message }}
+ * {% endcatch %}
+ *
+ */
+class TwigTokenParserTryCatch extends \Twig_TokenParser
+{
+ /**
+ * Parses a token and returns a node.
+ *
+ * @param \Twig_Token $token A Twig_Token instance
+ *
+ * @return \Twig_NodeInterface A Twig_NodeInterface instance
+ */
+ public function parse(\Twig_Token $token)
+ {
+ $lineno = $token->getLine();
+ $stream = $this->parser->getStream();
+
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ $try = $this->parser->subparse([$this, 'decideCatch']);
+ $stream->next();
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+ $catch = $this->parser->subparse([$this, 'decideEnd']);
+ $stream->next();
+ $stream->expect(\Twig_Token::BLOCK_END_TYPE);
+
+ return new TwigNodeTryCatch($try, $catch, $lineno, $this->getTag());
+ }
+
+ public function decideCatch(\Twig_Token $token)
+ {
+ return $token->test(array('catch'));
+ }
+
+ public function decideEnd(\Twig_Token $token)
+ {
+ return $token->test(array('endtry')) || $token->test(array('endcatch'));
+ }
+
+ /**
+ * Gets the tag name associated with this token parser.
+ *
+ * @return string The tag name
+ */
+ public function getTag()
+ {
+ return 'try';
+ }
+}
diff --git a/system/src/Grav/Common/Twig/Twig.php b/system/src/Grav/Common/Twig/Twig.php
new file mode 100644
index 0000000..a47a2b7
--- /dev/null
+++ b/system/src/Grav/Common/Twig/Twig.php
@@ -0,0 +1,415 @@
+grav = $grav;
+ $this->twig_paths = [];
+ }
+
+ /**
+ * Twig initialization that sets the twig loader chain, then the environment, then extensions
+ * and also the base set of twig vars
+ */
+ public function init()
+ {
+ if (!isset($this->twig)) {
+ /** @var Config $config */
+ $config = $this->grav['config'];
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ /** @var Language $language */
+ $language = $this->grav['language'];
+
+ $active_language = $language->getActive();
+
+ // handle language templates if available
+ if ($language->enabled()) {
+ $lang_templates = $locator->findResource('theme://templates/' . ($active_language ? $active_language : $language->getDefault()));
+ if ($lang_templates) {
+ $this->twig_paths[] = $lang_templates;
+ }
+ }
+
+ $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('theme://templates'));
+
+ $this->grav->fireEvent('onTwigTemplatePaths');
+
+ // Add Grav core templates location
+ $this->twig_paths = array_merge($this->twig_paths, $locator->findResources('system://templates'));
+
+ $this->loader = new \Twig_Loader_Filesystem($this->twig_paths);
+
+ $this->grav->fireEvent('onTwigLoader');
+
+ $this->loaderArray = new \Twig_Loader_Array([]);
+ $loader_chain = new \Twig_Loader_Chain([$this->loaderArray, $this->loader]);
+
+ $params = $config->get('system.twig');
+ if (!empty($params['cache'])) {
+ $cachePath = $locator->findResource('cache://twig', true, true);
+ $params['cache'] = new \Twig_Cache_Filesystem($cachePath, \Twig_Cache_Filesystem::FORCE_BYTECODE_INVALIDATION);
+ }
+
+ if (!empty($this->autoescape)) {
+ $params['autoescape'] = $this->autoescape;
+ }
+
+ $this->twig = new TwigEnvironment($loader_chain, $params);
+
+ if ($config->get('system.twig.undefined_functions')) {
+ $this->twig->registerUndefinedFunctionCallback(function ($name) {
+ if (function_exists($name)) {
+ return new \Twig_Function_Function($name);
+ }
+
+ return new \Twig_Function_Function(function () {
+ });
+ });
+ }
+
+ if ($config->get('system.twig.undefined_filters')) {
+ $this->twig->registerUndefinedFilterCallback(function ($name) {
+ if (function_exists($name)) {
+ return new \Twig_Filter_Function($name);
+ }
+
+ return new \Twig_Filter_Function(function () {
+ });
+ });
+ }
+
+ $this->grav->fireEvent('onTwigInitialized');
+
+ // set default date format if set in config
+ if ($config->get('system.pages.dateformat.long')) {
+ $this->twig->getExtension('core')->setDateFormat($config->get('system.pages.dateformat.long'));
+ }
+ // enable the debug extension if required
+ if ($config->get('system.twig.debug')) {
+ $this->twig->addExtension(new \Twig_Extension_Debug());
+ }
+ $this->twig->addExtension(new TwigExtension());
+
+ $this->grav->fireEvent('onTwigExtensions');
+
+ /** @var Pages $pages */
+ $pages = $this->grav['pages'];
+
+ // Set some standard variables for twig
+ $this->twig_vars = $this->twig_vars + [
+ 'config' => $config,
+ 'system' => $config->get('system'),
+ 'theme' => $config->get('theme'),
+ 'site' => $config->get('site'),
+ 'uri' => $this->grav['uri'],
+ 'assets' => $this->grav['assets'],
+ 'taxonomy' => $this->grav['taxonomy'],
+ 'browser' => $this->grav['browser'],
+ 'base_dir' => rtrim(ROOT_DIR, '/'),
+ 'home_url' => $pages->homeUrl($active_language),
+ 'base_url' => $pages->baseUrl($active_language),
+ 'base_url_absolute' => $pages->baseUrl($active_language, true),
+ 'base_url_relative' => $pages->baseUrl($active_language, false),
+ 'base_url_simple' => $this->grav['base_url'],
+ 'theme_dir' => $locator->findResource('theme://'),
+ 'theme_url' => $this->grav['base_url'] . '/' . $locator->findResource('theme://', false),
+ 'html_lang' => $this->grav['language']->getActive() ?: $config->get('site.default_lang', 'en'),
+ 'language_codes' => new LanguageCodes,
+ ];
+ }
+ }
+
+ /**
+ * @return \Twig_Environment
+ */
+ public function twig()
+ {
+ return $this->twig;
+ }
+
+ /**
+ * @return \Twig_Loader_Filesystem
+ */
+ public function loader()
+ {
+ return $this->loader;
+ }
+
+ /**
+ * Adds or overrides a template.
+ *
+ * @param string $name The template name
+ * @param string $template The template source
+ */
+ public function setTemplate($name, $template)
+ {
+ $this->loaderArray->setTemplate($name, $template);
+ }
+
+ /**
+ * Twig process that renders a page item. It supports two variations:
+ * 1) Handles modular pages by rendering a specific page based on its modular twig template
+ * 2) Renders individual page items for twig processing before the site rendering
+ *
+ * @param Page $item The page item to render
+ * @param string $content Optional content override
+ *
+ * @return string The rendered output
+ * @throws \Twig_Error_Loader
+ */
+ public function processPage(Page $item, $content = null)
+ {
+ $content = $content !== null ? $content : $item->content();
+
+ // override the twig header vars for local resolution
+ $this->grav->fireEvent('onTwigPageVariables', new Event(['page' => $item]));
+ $twig_vars = $this->twig_vars;
+
+ $twig_vars['page'] = $item;
+ $twig_vars['media'] = $item->media();
+ $twig_vars['header'] = $item->header();
+
+ $local_twig = clone($this->twig);
+
+ try {
+ // Process Modular Twig
+ if ($item->modularTwig()) {
+ $twig_vars['content'] = $content;
+ $extension = $this->grav['uri']->extension();
+ $extension = $extension ? ".{$extension}.twig" : TEMPLATE_EXT;
+ $template = $item->template() . $extension;
+ $output = $content = $local_twig->render($template, $twig_vars);
+ }
+
+ // Process in-page Twig
+ if ($item->shouldProcess('twig')) {
+ $name = '@Page:' . $item->path();
+ $this->setTemplate($name, $content);
+ $output = $local_twig->render($name, $twig_vars);
+ }
+
+ } catch (\Twig_Error_Loader $e) {
+ throw new \RuntimeException($e->getRawMessage(), 404, $e);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Process a Twig template directly by using a template name
+ * and optional array of variables
+ *
+ * @param string $template template to render with
+ * @param array $vars Optional variables
+ *
+ * @return string
+ */
+ public function processTemplate($template, $vars = [])
+ {
+ // override the twig header vars for local resolution
+ $this->grav->fireEvent('onTwigTemplateVariables');
+ $vars += $this->twig_vars;
+
+ try {
+ $output = $this->twig->render($template, $vars);
+ } catch (\Twig_Error_Loader $e) {
+ throw new \RuntimeException($e->getRawMessage(), 404, $e);
+ }
+
+ return $output;
+
+ }
+
+
+ /**
+ * Process a Twig template directly by using a Twig string
+ * and optional array of variables
+ *
+ * @param string $string string to render.
+ * @param array $vars Optional variables
+ *
+ * @return string
+ */
+ public function processString($string, array $vars = [])
+ {
+ // override the twig header vars for local resolution
+ $this->grav->fireEvent('onTwigStringVariables');
+ $vars += $this->twig_vars;
+
+ $name = '@Var:' . $string;
+ $this->setTemplate($name, $string);
+
+ try {
+ $output = $this->twig->render($name, $vars);
+ } catch (\Twig_Error_Loader $e) {
+ throw new \RuntimeException($e->getRawMessage(), 404, $e);
+ }
+
+ return $output;
+ }
+
+ /**
+ * Twig process that renders the site layout. This is the main twig process that renders the overall
+ * page and handles all the layout for the site display.
+ *
+ * @param string $format Output format (defaults to HTML).
+ *
+ * @return string the rendered output
+ * @throws \RuntimeException
+ */
+ public function processSite($format = null, array $vars = [])
+ {
+ // set the page now its been processed
+ $this->grav->fireEvent('onTwigSiteVariables');
+ $pages = $this->grav['pages'];
+ $page = $this->grav['page'];
+ $content = $page->content();
+
+ $twig_vars = $this->twig_vars;
+
+ $twig_vars['theme'] = $this->grav['config']->get('theme');
+ $twig_vars['pages'] = $pages->root();
+ $twig_vars['page'] = $page;
+ $twig_vars['header'] = $page->header();
+ $twig_vars['media'] = $page->media();
+ $twig_vars['content'] = $content;
+ $ext = '.' . ($format ? $format : 'html') . TWIG_EXT;
+
+ // determine if params are set, if so disable twig cache
+ $params = $this->grav['uri']->params(null, true);
+ if (!empty($params)) {
+ $this->twig->setCache(false);
+ }
+
+ // Get Twig template layout
+ $template = $this->template($page->template() . $ext);
+
+ try {
+ $output = $this->twig->render($template, $vars + $twig_vars);
+ } catch (\Twig_Error_Loader $e) {
+ $error_msg = $e->getMessage();
+ // Try html version of this template if initial template was NOT html
+ if ($ext != '.html' . TWIG_EXT) {
+ try {
+ $page->templateFormat('html');
+ $output = $this->twig->render($page->template() . '.html' . TWIG_EXT, $vars + $twig_vars);
+ } catch (\Twig_Error_Loader $e) {
+ throw new \RuntimeException($error_msg, 400, $e);
+ }
+ } else {
+ throw new \RuntimeException($error_msg, 400, $e);
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Wraps the Twig_Loader_Filesystem addPath method (should be used only in `onTwigLoader()` event
+ * @param $template_path
+ * @param null $namespace
+ */
+ public function addPath($template_path, $namespace = '__main__')
+ {
+ $this->loader->addPath($template_path, $namespace);
+ }
+
+ /**
+ * Wraps the Twig_Loader_Filesystem prependPath method (should be used only in `onTwigLoader()` event
+ * @param $template_path
+ * @param null $namespace
+ */
+ public function prependPath($template_path, $namespace = '__main__')
+ {
+ $this->loader->prependPath($template_path, $namespace);
+ }
+
+ /**
+ * Simple helper method to get the twig template if it has already been set, else return
+ * the one being passed in
+ *
+ * @param string $template the template name
+ *
+ * @return string the template name
+ */
+ public function template($template)
+ {
+ if (isset($this->template)) {
+ return $this->template;
+ } else {
+ return $template;
+ }
+ }
+
+ /**
+ * Overrides the autoescape setting
+ *
+ * @param boolean $state
+ */
+ public function setAutoescape($state) {
+ $this->autoescape = (bool) $state;
+ }
+}
diff --git a/system/src/Grav/Common/Twig/TwigEnvironment.php b/system/src/Grav/Common/Twig/TwigEnvironment.php
new file mode 100644
index 0000000..66ca8bf
--- /dev/null
+++ b/system/src/Grav/Common/Twig/TwigEnvironment.php
@@ -0,0 +1,14 @@
+grav = Grav::instance();
+ $this->debugger = isset($this->grav['debugger']) ? $this->grav['debugger'] : null;
+ $this->config = $this->grav['config'];
+ }
+
+ /**
+ * Register some standard globals
+ *
+ * @return array
+ */
+ public function getGlobals()
+ {
+ return [
+ 'grav' => $this->grav,
+ ];
+ }
+
+ /**
+ * Return a list of all filters.
+ *
+ * @return array
+ */
+ public function getFilters()
+ {
+ return [
+ new \Twig_SimpleFilter('*ize', [$this, 'inflectorFilter']),
+ new \Twig_SimpleFilter('absolute_url', [$this, 'absoluteUrlFilter']),
+ new \Twig_SimpleFilter('contains', [$this, 'containsFilter']),
+ new \Twig_SimpleFilter('chunk_split', [$this, 'chunkSplitFilter']),
+ new \Twig_SimpleFilter('nicenumber', [$this, 'niceNumberFunc']),
+ new \Twig_SimpleFilter('nicefilesize', [$this, 'niceFilesizeFunc']),
+ new \Twig_SimpleFilter('nicetime', [$this, 'nicetimeFunc']),
+ new \Twig_SimpleFilter('defined', [$this, 'definedDefaultFilter']),
+ new \Twig_SimpleFilter('ends_with', [$this, 'endsWithFilter']),
+ new \Twig_SimpleFilter('fieldName', [$this, 'fieldNameFilter']),
+ new \Twig_SimpleFilter('ksort', [$this, 'ksortFilter']),
+ new \Twig_SimpleFilter('ltrim', [$this, 'ltrimFilter']),
+ new \Twig_SimpleFilter('markdown', [$this, 'markdownFunction']),
+ new \Twig_SimpleFilter('md5', [$this, 'md5Filter']),
+ new \Twig_SimpleFilter('base32_encode', [$this, 'base32EncodeFilter']),
+ new \Twig_SimpleFilter('base32_decode', [$this, 'base32DecodeFilter']),
+ new \Twig_SimpleFilter('base64_encode', [$this, 'base64EncodeFilter']),
+ new \Twig_SimpleFilter('base64_decode', [$this, 'base64DecodeFilter']),
+ new \Twig_SimpleFilter('randomize', [$this, 'randomizeFilter']),
+ new \Twig_SimpleFilter('modulus', [$this, 'modulusFilter']),
+ new \Twig_SimpleFilter('rtrim', [$this, 'rtrimFilter']),
+ new \Twig_SimpleFilter('pad', [$this, 'padFilter']),
+ new \Twig_SimpleFilter('regex_replace', [$this, 'regexReplace']),
+ new \Twig_SimpleFilter('safe_email', [$this, 'safeEmailFilter']),
+ new \Twig_SimpleFilter('safe_truncate', ['\Grav\Common\Utils', 'safeTruncate']),
+ new \Twig_SimpleFilter('safe_truncate_html', ['\Grav\Common\Utils', 'safeTruncateHTML']),
+ new \Twig_SimpleFilter('sort_by_key', [$this, 'sortByKeyFilter']),
+ new \Twig_SimpleFilter('starts_with', [$this, 'startsWithFilter']),
+ new \Twig_SimpleFilter('t', [$this, 'translate']),
+ new \Twig_SimpleFilter('tl', [$this, 'translateLanguage']),
+ new \Twig_SimpleFilter('ta', [$this, 'translateArray']),
+ new \Twig_SimpleFilter('truncate', ['\Grav\Common\Utils', 'truncate']),
+ new \Twig_SimpleFilter('truncate_html', ['\Grav\Common\Utils', 'truncateHTML']),
+ new \Twig_SimpleFilter('json_decode', [$this, 'jsonDecodeFilter']),
+ new \Twig_SimpleFilter('array_unique', 'array_unique'),
+ new \Twig_SimpleFilter('basename', 'basename'),
+ new \Twig_SimpleFilter('dirname', 'dirname'),
+ new \Twig_SimpleFilter('print_r', 'print_r'),
+ new \Twig_SimpleFilter('yaml_encode', [$this, 'yamlEncodeFilter']),
+ new \Twig_SimpleFilter('yaml_decode', [$this, 'yamlDecodeFilter']),
+ ];
+ }
+
+ /**
+ * Return a list of all functions.
+ *
+ * @return array
+ */
+ public function getFunctions()
+ {
+ return [
+ new \Twig_SimpleFunction('array', [$this, 'arrayFunc']),
+ new \Twig_SimpleFunction('array_key_value', [$this, 'arrayKeyValueFunc']),
+ new \Twig_SimpleFunction('array_key_exists', 'array_key_exists'),
+ new \Twig_SimpleFunction('array_unique', 'array_unique'),
+ new \Twig_SimpleFunction('array_intersect', [$this, 'arrayIntersectFunc']),
+ new \Twig_simpleFunction('authorize', [$this, 'authorize']),
+ new \Twig_SimpleFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+ new \Twig_SimpleFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+ new \Twig_SimpleFunction('vardump', [$this, 'vardumpFunc']),
+ new \Twig_SimpleFunction('print_r', 'print_r'),
+ new \Twig_SimpleFunction('http_response_code', 'http_response_code'),
+ new \Twig_SimpleFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]),
+ new \Twig_SimpleFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]),
+ new \Twig_SimpleFunction('gist', [$this, 'gistFunc']),
+ new \Twig_SimpleFunction('nonce_field', [$this, 'nonceFieldFunc']),
+ new \Twig_SimpleFunction('pathinfo', 'pathinfo'),
+ new \Twig_simpleFunction('random_string', [$this, 'randomStringFunc']),
+ new \Twig_SimpleFunction('repeat', [$this, 'repeatFunc']),
+ new \Twig_SimpleFunction('regex_replace', [$this, 'regexReplace']),
+ new \Twig_SimpleFunction('regex_filter', [$this, 'regexFilter']),
+ new \Twig_SimpleFunction('string', [$this, 'stringFunc']),
+ new \Twig_simpleFunction('t', [$this, 'translate']),
+ new \Twig_simpleFunction('tl', [$this, 'translateLanguage']),
+ new \Twig_simpleFunction('ta', [$this, 'translateArray']),
+ new \Twig_SimpleFunction('url', [$this, 'urlFunc']),
+ new \Twig_SimpleFunction('json_decode', [$this, 'jsonDecodeFilter']),
+ new \Twig_SimpleFunction('get_cookie', [$this, 'getCookie']),
+ new \Twig_SimpleFunction('redirect_me', [$this, 'redirectFunc']),
+ new \Twig_SimpleFunction('range', [$this, 'rangeFunc']),
+ new \Twig_SimpleFunction('isajaxrequest', [$this, 'isAjaxFunc']),
+ new \Twig_SimpleFunction('exif', [$this, 'exifFunc']),
+ new \Twig_SimpleFunction('media_directory', [$this, 'mediaDirFunc']),
+ new \Twig_SimpleFunction('body_class', [$this, 'bodyClassFunc']),
+ new \Twig_SimpleFunction('theme_var', [$this, 'themeVarFunc']),
+ new \Twig_SimpleFunction('header_var', [$this, 'pageHeaderVarFunc']),
+ new \Twig_SimpleFunction('read_file', [$this, 'readFileFunc']),
+ new \Twig_SimpleFunction('nicenumber', [$this, 'niceNumberFunc']),
+ new \Twig_SimpleFunction('nicefilesize', [$this, 'niceFilesizeFunc']),
+ new \Twig_SimpleFunction('nicetime', [$this, 'nicetimeFilter']),
+
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getTokenParsers()
+ {
+ return [
+ new TwigTokenParserTryCatch(),
+ new TwigTokenParserScript(),
+ new TwigTokenParserStyle(),
+ new TwigTokenParserMarkdown(),
+ new TwigTokenParserSwitch(),
+ ];
+ }
+
+ /**
+ * Filters field name by changing dot notation into array notation.
+ *
+ * @param string $str
+ *
+ * @return string
+ */
+ public function fieldNameFilter($str)
+ {
+ $path = explode('.', rtrim($str, '.'));
+
+ return array_shift($path) . ($path ? '[' . implode('][', $path) . ']' : '');
+ }
+
+ /**
+ * Protects email address.
+ *
+ * @param string $str
+ *
+ * @return string
+ */
+ public function safeEmailFilter($str)
+ {
+ $email = '';
+ for ( $i = 0, $len = strlen( $str ); $i < $len; $i++ ) {
+ $j = mt_rand( 0, 1);
+ if ( $j === 0 ) {
+ $email .= '' . ord( $str[$i] ) . ';';
+ } elseif ( $j === 1 ) {
+ $email .= $str[$i];
+ }
+ }
+
+ return str_replace( '@', '@', $email );
+ }
+
+ /**
+ * Returns array in a random order.
+ *
+ * @param array $original
+ * @param int $offset Can be used to return only slice of the array.
+ *
+ * @return array
+ */
+ public function randomizeFilter($original, $offset = 0)
+ {
+ if (!is_array($original)) {
+ return $original;
+ }
+
+ if ($original instanceof \Traversable) {
+ $original = iterator_to_array($original, false);
+ }
+
+ $sorted = [];
+ $random = array_slice($original, $offset);
+ shuffle($random);
+
+ $sizeOf = count($original);
+ for ($x = 0; $x < $sizeOf; $x++) {
+ if ($x < $offset) {
+ $sorted[] = $original[$x];
+ } else {
+ $sorted[] = array_shift($random);
+ }
+ }
+
+ return $sorted;
+ }
+
+ /**
+ * Returns the modulus of an integer
+ *
+ * @param string|int $number
+ * @param int $divider
+ * @param array $items array of items to select from to return
+ *
+ * @return int
+ */
+ public function modulusFilter($number, $divider, $items = null)
+ {
+ if (is_string($number)) {
+ $number = strlen($number);
+ }
+
+ $remainder = $number % $divider;
+
+ if (is_array($items)) {
+ if (isset($items[$remainder])) {
+ return $items[$remainder];
+ }
+
+ return $items[0];
+ }
+
+ return $remainder;
+ }
+
+ /**
+ * Inflector supports following notations:
+ *
+ * `{{ 'person'|pluralize }} => people`
+ * `{{ 'shoes'|singularize }} => shoe`
+ * `{{ 'welcome page'|titleize }} => "Welcome Page"`
+ * `{{ 'send_email'|camelize }} => SendEmail`
+ * `{{ 'CamelCased'|underscorize }} => camel_cased`
+ * `{{ 'Something Text'|hyphenize }} => something-text`
+ * `{{ 'something_text_to_read'|humanize }} => "Something text to read"`
+ * `{{ '181'|monthize }} => 5`
+ * `{{ '10'|ordinalize }} => 10th`
+ *
+ * @param string $action
+ * @param string $data
+ * @param int $count
+ *
+ * @return mixed
+ */
+ public function inflectorFilter($action, $data, $count = null)
+ {
+ $action = $action . 'ize';
+
+ $inflector = $this->grav['inflector'];
+
+ if (\in_array(
+ $action,
+ ['titleize', 'camelize', 'underscorize', 'hyphenize', 'humanize', 'ordinalize', 'monthize'],
+ true
+ )) {
+ return $inflector->$action($data);
+ }
+
+ if (\in_array($action, ['pluralize', 'singularize'], true)) {
+ if ($count) {
+ return $inflector->$action($data, $count);
+ }
+
+ return $inflector->$action($data);
+ }
+
+ return $data;
+ }
+
+ /**
+ * Return MD5 hash from the input.
+ *
+ * @param string $str
+ *
+ * @return string
+ */
+ public function md5Filter($str)
+ {
+ return md5($str);
+ }
+
+ /**
+ * Return Base32 encoded string
+ *
+ * @param $str
+ * @return string
+ */
+ public function base32EncodeFilter($str)
+ {
+ return Base32::encode($str);
+ }
+
+ /**
+ * Return Base32 decoded string
+ *
+ * @param $str
+ * @return bool|string
+ */
+ public function base32DecodeFilter($str)
+ {
+ return Base32::decode($str);
+ }
+
+ /**
+ * Return Base64 encoded string
+ *
+ * @param $str
+ * @return string
+ */
+ public function base64EncodeFilter($str)
+ {
+ return base64_encode($str);
+ }
+
+ /**
+ * Return Base64 decoded string
+ *
+ * @param $str
+ * @return bool|string
+ */
+ public function base64DecodeFilter($str)
+ {
+ return base64_decode($str);
+ }
+
+
+ /**
+ * Sorts a collection by key
+ *
+ * @param array $input
+ * @param string $filter
+ * @param int $direction
+ * @param int $sort_flags
+ *
+ * @return array
+ */
+ public function sortByKeyFilter($input, $filter, $direction = SORT_ASC, $sort_flags = SORT_REGULAR)
+ {
+ return Utils::sortArrayByKey($input, $filter, $direction, $sort_flags);
+ }
+
+ /**
+ * Return ksorted collection.
+ *
+ * @param array $array
+ *
+ * @return array
+ */
+ public function ksortFilter($array)
+ {
+ if (null === $array) {
+ $array = [];
+ }
+ ksort($array);
+
+ return $array;
+ }
+
+ /**
+ * Wrapper for chunk_split() function
+ *
+ * @param $value
+ * @param $chars
+ * @param string $split
+ * @return string
+ */
+ public function chunkSplitFilter($value, $chars, $split = '-')
+ {
+ return chunk_split($value, $chars, $split);
+ }
+
+ /**
+ * determine if a string contains another
+ *
+ * @param String $haystack
+ * @param String $needle
+ *
+ * @return boolean
+ */
+ public function containsFilter($haystack, $needle)
+ {
+ return (strpos($haystack, $needle) !== false);
+ }
+
+ /**
+ * displays a facebook style 'time ago' formatted date/time
+ *
+ * @param $date
+ * @param $long_strings
+ *
+ * @return boolean
+ */
+ public function nicetimeFunc($date, $long_strings = true)
+ {
+ if (empty($date)) {
+ return $this->grav['language']->translate('NICETIME.NO_DATE_PROVIDED', null, true);
+ }
+
+ if ($long_strings) {
+ $periods = [
+ "NICETIME.SECOND",
+ "NICETIME.MINUTE",
+ "NICETIME.HOUR",
+ "NICETIME.DAY",
+ "NICETIME.WEEK",
+ "NICETIME.MONTH",
+ "NICETIME.YEAR",
+ "NICETIME.DECADE"
+ ];
+ } else {
+ $periods = [
+ "NICETIME.SEC",
+ "NICETIME.MIN",
+ "NICETIME.HR",
+ "NICETIME.DAY",
+ "NICETIME.WK",
+ "NICETIME.MO",
+ "NICETIME.YR",
+ "NICETIME.DEC"
+ ];
+ }
+
+ $lengths = ["60", "60", "24", "7", "4.35", "12", "10"];
+
+ $now = time();
+
+ // check if unix timestamp
+ if ((string)(int)$date == $date) {
+ $unix_date = $date;
+ } else {
+ $unix_date = strtotime($date);
+ }
+
+ // check validity of date
+ if (empty($unix_date)) {
+ return $this->grav['language']->translate('NICETIME.BAD_DATE', null, true);
+ }
+
+ // is it future date or past date
+ if ($now > $unix_date) {
+ $difference = $now - $unix_date;
+ $tense = $this->grav['language']->translate('NICETIME.AGO', null, true);
+
+ } else if ($now == $unix_date) {
+ $difference = $now - $unix_date;
+ $tense = $this->grav['language']->translate('NICETIME.JUST_NOW', null, false);
+
+ } else {
+ $difference = $unix_date - $now;
+ $tense = $this->grav['language']->translate('NICETIME.FROM_NOW', null, true);
+ }
+
+ for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) {
+ $difference /= $lengths[$j];
+ }
+
+ $difference = round($difference);
+
+ if ($difference != 1) {
+ $periods[$j] .= '_PLURAL';
+ }
+
+ if ($this->grav['language']->getTranslation($this->grav['language']->getLanguage(),
+ $periods[$j] . '_MORE_THAN_TWO')
+ ) {
+ if ($difference > 2) {
+ $periods[$j] .= '_MORE_THAN_TWO';
+ }
+ }
+
+ $periods[$j] = $this->grav['language']->translate($periods[$j], null, true);
+
+ if ($now == $unix_date) {
+ return "{$tense}";
+ }
+
+ return "$difference $periods[$j] {$tense}";
+ }
+
+ /**
+ * @param $string
+ *
+ * @return mixed
+ */
+ public function absoluteUrlFilter($string)
+ {
+ $url = $this->grav['uri']->base();
+ $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string);
+
+ return $string;
+
+ }
+
+ /**
+ * @param $string
+ *
+ * @param bool $block Block or Line processing
+ * @return mixed|string
+ */
+ public function markdownFunction($string, $block = true)
+ {
+ $page = $this->grav['page'];
+ $defaults = $this->config->get('system.pages.markdown');
+
+ // Initialize the preferred variant of Parsedown
+ if ($defaults['extra']) {
+ $parsedown = new ParsedownExtra($page, $defaults);
+ } else {
+ $parsedown = new Parsedown($page, $defaults);
+ }
+
+ if ($block) {
+ $string = $parsedown->text($string);
+ } else {
+ $string = $parsedown->line($string);
+ }
+
+
+ return $string;
+ }
+
+ /**
+ * @param $haystack
+ * @param $needle
+ *
+ * @return bool
+ */
+ public function startsWithFilter($haystack, $needle)
+ {
+ return Utils::startsWith($haystack, $needle);
+ }
+
+ /**
+ * @param $haystack
+ * @param $needle
+ *
+ * @return bool
+ */
+ public function endsWithFilter($haystack, $needle)
+ {
+ return Utils::endsWith($haystack, $needle);
+ }
+
+ /**
+ * @param $value
+ * @param null $default
+ *
+ * @return null
+ */
+ public function definedDefaultFilter($value, $default = null)
+ {
+ return null !== $value ? $value : $default;
+ }
+
+ /**
+ * @param $value
+ * @param null $chars
+ *
+ * @return string
+ */
+ public function rtrimFilter($value, $chars = null)
+ {
+ return rtrim($value, $chars);
+ }
+
+ /**
+ * @param $value
+ * @param null $chars
+ *
+ * @return string
+ */
+ public function ltrimFilter($value, $chars = null)
+ {
+ return ltrim($value, $chars);
+ }
+
+ /**
+ * @return mixed
+ */
+ public function translate()
+ {
+ return $this->grav['language']->translate(func_get_args());
+ }
+
+ /**
+ * Translate Strings
+ *
+ * @param $args
+ * @param array|null $languages
+ * @param bool $array_support
+ * @param bool $html_out
+ * @return mixed
+ */
+ public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false)
+ {
+ return $this->grav['language']->translate($args, $languages, $array_support, $html_out);
+ }
+
+ /**
+ * @param $key
+ * @param $index
+ * @param null $lang
+ *
+ * @return mixed
+ */
+ public function translateArray($key, $index, $lang = null)
+ {
+ return $this->grav['language']->translateArray($key, $index, $lang);
+ }
+
+ /**
+ * Repeat given string x times.
+ *
+ * @param string $input
+ * @param int $multiplier
+ *
+ * @return string
+ */
+ public function repeatFunc($input, $multiplier)
+ {
+ return str_repeat($input, $multiplier);
+ }
+
+ /**
+ * Return URL to the resource.
+ *
+ * @example {{ url('theme://images/logo.png')|default('http://www.placehold.it/150x100/f4f4f4') }}
+ *
+ * @param string $input Resource to be located.
+ * @param bool $domain True to include domain name.
+ *
+ * @return string|null Returns url to the resource or null if resource was not found.
+ */
+ public function urlFunc($input, $domain = false)
+ {
+ return Utils::url($input, $domain);
+ }
+
+ /**
+ * This function will evaluate Twig $twig through the $environment, and return its results.
+ *
+ * @param array $context
+ * @param string $twig
+ * @return mixed
+ */
+ public function evaluateTwigFunc($context, $twig ) {
+
+ $loader = new \Twig_Loader_Filesystem('.');
+ $env = new \Twig_Environment($loader);
+
+ $template = $env->createTemplate($twig);
+ return $template->render($context);
+;
+ }
+
+ /**
+ * This function will evaluate a $string through the $environment, and return its results.
+ *
+ * @param $context
+ * @param $string
+ * @return mixed
+ */
+ public function evaluateStringFunc($context, $string )
+ {
+ return $this->evaluateTwigFunc($context, "{{ $string }}");
+ }
+
+
+ /**
+ * Based on Twig_Extension_Debug / twig_var_dump
+ * (c) 2011 Fabien Potencier
+ *
+ * @param \Twig_Environment $env
+ * @param $context
+ */
+ public function dump(\Twig_Environment $env, $context)
+ {
+ if (!$env->isDebug() || !$this->debugger) {
+ return;
+ }
+
+ $count = func_num_args();
+ if (2 === $count) {
+ $data = [];
+ foreach ($context as $key => $value) {
+ if (is_object($value)) {
+ if (method_exists($value, 'toArray')) {
+ $data[$key] = $value->toArray();
+ } else {
+ $data[$key] = "Object (" . get_class($value) . ")";
+ }
+ } else {
+ $data[$key] = $value;
+ }
+ }
+ $this->debugger->addMessage($data, 'debug');
+ } else {
+ for ($i = 2; $i < $count; $i++) {
+ $this->debugger->addMessage(func_get_arg($i), 'debug');
+ }
+ }
+ }
+
+ /**
+ * Output a Gist
+ *
+ * @param string $id
+ * @param string $file
+ *
+ * @return string
+ */
+ public function gistFunc($id, $file = false)
+ {
+ $url = 'https://gist.github.com/' . $id . '.js';
+ if ($file) {
+ $url .= '?file=' . $file;
+ }
+ return '';
+ }
+
+ /**
+ * Generate a random string
+ *
+ * @param int $count
+ *
+ * @return string
+ */
+ public function randomStringFunc($count = 5)
+ {
+ return Utils::generateRandomString($count);
+ }
+
+ /**
+ * Pad a string to a certain length with another string
+ *
+ * @param $input
+ * @param $pad_length
+ * @param string $pad_string
+ * @param int $pad_type
+ *
+ * @return string
+ */
+ public static function padFilter($input, $pad_length, $pad_string = " ", $pad_type = STR_PAD_RIGHT)
+ {
+ return str_pad($input, (int)$pad_length, $pad_string, $pad_type);
+ }
+
+
+ /**
+ * Cast a value to array
+ *
+ * @param $value
+ *
+ * @return array
+ */
+ public function arrayFunc($value)
+ {
+ return (array)$value;
+ }
+
+ /**
+ * Workaround for twig associative array initialization
+ * Returns a key => val array
+ *
+ * @param string $key key of item
+ * @param string $val value of item
+ * @param array $current_array optional array to add to
+ *
+ * @return array
+ */
+ public function arrayKeyValueFunc($key, $val, $current_array = null)
+ {
+ if (empty($current_array)) {
+ return array($key => $val);
+ }
+
+ $current_array[$key] = $val;
+ return $current_array;
+ }
+
+ /**
+ * Wrapper for array_intersect() method
+ *
+ * @param $array1
+ * @param $array2
+ * @return array
+ */
+ public function arrayIntersectFunc($array1, $array2)
+ {
+ if ($array1 instanceof Collection && $array2 instanceof Collection) {
+ return $array1->intersect($array2);
+ }
+
+ return array_intersect($array1, $array2);
+ }
+
+ /**
+ * Returns a string from a value. If the value is array, return it json encoded
+ *
+ * @param $value
+ *
+ * @return string
+ */
+ public function stringFunc($value)
+ {
+ if (is_array($value)) { //format the array as a string
+ return json_encode($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Translate a string
+ *
+ * @return string
+ */
+ public function translateFunc()
+ {
+ return $this->grav['language']->translate(func_get_args());
+ }
+
+ /**
+ * Authorize an action. Returns true if the user is logged in and
+ * has the right to execute $action.
+ *
+ * @param string|array $action An action or a list of actions. Each
+ * entry can be a string like 'group.action'
+ * or without dot notation an associative
+ * array.
+ * @return bool Returns TRUE if the user is authorized to
+ * perform the action, FALSE otherwise.
+ */
+ public function authorize($action)
+ {
+ /** @var User $user */
+ $user = $this->grav['user'];
+
+ if (!$user->authenticated || (isset($user->authorized) && !$user->authorized)) {
+ return false;
+ }
+
+ $action = (array) $action;
+ foreach ($action as $key => $perms) {
+ $prefix = is_int($key) ? '' : $key . '.';
+ $perms = $prefix ? (array) $perms : [$perms => true];
+ foreach ($perms as $action2 => $authenticated) {
+ if ($user->authorize($prefix . $action2)) {
+ return $authenticated;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Used to add a nonce to a form. Call {{ nonce_field('action') }} specifying a string representing the action.
+ *
+ * For maximum protection, ensure that the string representing the action is as specific as possible
+ *
+ * @param string $action the action
+ * @param string $nonceParamName a custom nonce param name
+ *
+ * @return string the nonce input field
+ */
+ public function nonceFieldFunc($action, $nonceParamName = 'nonce')
+ {
+ $string = '';
+
+ return $string;
+ }
+
+ /**
+ * Decodes string from JSON.
+ *
+ * @param string $str
+ * @param bool $assoc
+ * @param int $depth
+ * @param int $options
+ * @return array
+ */
+ public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0)
+ {
+ return json_decode(html_entity_decode($str), $assoc, $depth, $options);
+ }
+
+ /**
+ * Used to retrieve a cookie value
+ *
+ * @param string $key The cookie name to retrieve
+ *
+ * @return mixed
+ */
+ public function getCookie($key)
+ {
+ return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING);
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_replace method
+ *
+ * @param mixed $subject the content to perform the replacement on
+ * @param mixed $pattern the regex pattern to use for matches
+ * @param mixed $replace the replacement value either as a string or an array of replacements
+ * @param int $limit the maximum possible replacements for each pattern in each subject
+ *
+ * @return mixed the resulting content
+ */
+ public function regexReplace($subject, $pattern, $replace, $limit = -1)
+ {
+ return preg_replace($pattern, $replace, $subject, $limit);
+ }
+
+ /**
+ * Twig wrapper for PHP's preg_grep method
+ *
+ * @param $array
+ * @param $regex
+ * @param int $flags
+ * @return array
+ */
+ public function regexFilter($array, $regex, $flags = 0) {
+ return preg_grep($regex, $array, $flags);
+ }
+
+ /**
+ * redirect browser from twig
+ *
+ * @param string $url the url to redirect to
+ * @param int $statusCode statusCode, default 303
+ */
+ public function redirectFunc($url, $statusCode = 303)
+ {
+ header('Location: ' . $url, true, $statusCode);
+ die();
+ }
+
+ /**
+ * Generates an array containing a range of elements, optionally stepped
+ *
+ * @param int $start Minimum number, default 0
+ * @param int $end Maximum number, default `getrandmax()`
+ * @param int $step Increment between elements in the sequence, default 1
+ *
+ * @return array
+ */
+ public function rangeFunc($start = 0, $end = 100, $step = 1)
+ {
+ return range($start, $end, $step);
+ }
+
+ /**
+ * Check if HTTP_X_REQUESTED_WITH has been set to xmlhttprequest,
+ * in which case we may unsafely assume ajax. Non critical use only.
+ *
+ * @return true if HTTP_X_REQUESTED_WITH exists and has been set to xmlhttprequest
+ */
+ public function isAjaxFunc()
+ {
+ return (
+ !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
+ && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
+ }
+
+ /**
+ * Get's the Exif data for a file
+ *
+ * @param $image
+ * @param bool $raw
+ * @return mixed
+ */
+ public function exifFunc($image, $raw = false)
+ {
+ if (isset($this->grav['exif'])) {
+
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($image)) {
+ $image = $locator->findResource($image);
+ }
+
+ $exif_reader = $this->grav['exif']->getReader();
+
+ if (file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) {
+
+ $exif_data = $exif_reader->read($image);
+
+ if ($exif_data) {
+ if ($raw) {
+ return $exif_data->getRawData();
+ }
+
+ return $exif_data->getData();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Simple function to read a file based on a filepath and output it
+ *
+ * @param $filepath
+ * @return bool|string
+ */
+ public function readFileFunc($filepath)
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($filepath)) {
+ $filepath = $locator->findResource($filepath);
+ }
+
+ if (file_exists($filepath)) {
+ return file_get_contents($filepath);
+ }
+
+ return false;
+ }
+
+ /**
+ * Process a folder as Media and return a media object
+ *
+ * @param $media_dir
+ * @return Media|null
+ */
+ public function mediaDirFunc($media_dir)
+ {
+ /** @var UniformResourceLocator $locator */
+ $locator = $this->grav['locator'];
+
+ if ($locator->isStream($media_dir)) {
+ $media_dir = $locator->findResource($media_dir);
+ }
+
+ if (file_exists($media_dir)) {
+ return new Media($media_dir);
+ }
+
+ return null;
+ }
+
+ /**
+ * Dump a variable to the browser
+ *
+ * @param $var
+ */
+ public function vardumpFunc($var)
+ {
+ var_dump($var);
+ }
+
+ /**
+ * Returns a nicer more readable filesize based on bytes
+ *
+ * @param $bytes
+ * @return string
+ */
+ public function niceFilesizeFunc($bytes)
+ {
+ if ($bytes >= 1073741824)
+ {
+ $bytes = number_format($bytes / 1073741824, 2) . ' GB';
+ }
+ elseif ($bytes >= 1048576)
+ {
+ $bytes = number_format($bytes / 1048576, 2) . ' MB';
+ }
+ elseif ($bytes >= 1024)
+ {
+ $bytes = number_format($bytes / 1024, 1) . ' KB';
+ }
+ elseif ($bytes > 1)
+ {
+ $bytes = $bytes . ' bytes';
+ }
+ elseif ($bytes == 1)
+ {
+ $bytes = $bytes . ' byte';
+ }
+ else
+ {
+ $bytes = '0 bytes';
+ }
+
+ return $bytes;
+ }
+
+
+ /**
+ * Returns a nicer more readable number
+ *
+ * @param int|float $n
+ * @return bool|string
+ */
+ public function niceNumberFunc($n)
+ {
+ // first strip any formatting;
+ $n = 0 + str_replace(',', '', $n);
+
+ // is this a number?
+ if (!is_numeric($n)) {
+ return false;
+ }
+
+ // now filter it;
+ if ($n > 1000000000000) {
+ return round(($n/1000000000000), 2).' t';
+ }
+ if ($n > 1000000000) {
+ return round(($n/1000000000), 2).' b';
+ }
+ if ($n > 1000000) {
+ return round(($n/1000000), 2).' m';
+ }
+ if ($n > 1000) {
+ return round(($n/1000), 2).' k';
+ }
+
+ return number_format($n);
+ }
+
+ /**
+ * Get a theme variable
+ *
+ * @param $var
+ * @param bool $default
+ * @return string
+ */
+ public function themeVarFunc($var, $default = null)
+ {
+ $header = $this->grav['page']->header();
+ $header_classes = isset($header->$var) ? $header->$var : null;
+ return $header_classes ?: $this->config->get('theme.' . $var, $default);
+ }
+
+ /**
+ * takes an array of classes, and if they are not set on body_classes
+ * look to see if they are set in theme config
+ *
+ * @param $classes
+ * @return string
+ */
+ public function bodyClassFunc($classes)
+ {
+
+ $header = $this->grav['page']->header();
+ $body_classes = isset($header->body_classes) ? $header->body_classes : '';
+
+ foreach ((array)$classes as $class) {
+ if (!empty($body_classes) && Utils::contains($body_classes, $class)) {
+ continue;
+ }
+
+ $val = $this->config->get('theme.' . $class, false) ? $class : false;
+ $body_classes .= $val ? ' ' . $val : '';
+ }
+
+ return $body_classes;
+ }
+
+ /**
+ * Look for a page header variable in an array of pages working its way through until a value is found
+ *
+ * @param $var
+ * @param null $pages
+ * @return mixed
+ */
+ public function pageHeaderVarFunc($var, $pages = null)
+ {
+ if ($pages === null) {
+ $pages = $this->grav['page'];
+ }
+
+ // Make sure pages are an array
+ if (!is_array($pages)) {
+ $pages = array($pages);
+ }
+
+ // Loop over pages and look for header vars
+ foreach ($pages as $page) {
+ if (is_string($page)) {
+ $page = $this->grav['pages']->find($page);
+ }
+
+ if ($page) {
+ $header = $page->header();
+ if (isset($header->$var)) {
+ return $header->$var;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Dump/Encode data into YAML format
+ *
+ * @param $data
+ * @return mixed
+ */
+ public function yamlEncodeFilter($data)
+ {
+ return Yaml::dump($data, 10);
+ }
+
+ /**
+ * Decode/Parse data from YAML format
+ *
+ * @param $data
+ * @return mixed
+ */
+ public function yamlDecodeFilter($data)
+ {
+ return Yaml::parse($data);
+ }
+}
diff --git a/system/src/Grav/Common/Twig/WriteCacheFileTrait.php b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php
new file mode 100644
index 0000000..c413ca6
--- /dev/null
+++ b/system/src/Grav/Common/Twig/WriteCacheFileTrait.php
@@ -0,0 +1,46 @@
+get('system.twig.umask_fix', false);
+ }
+
+ if (self::$umask) {
+ if (!is_dir(dirname($file))) {
+ $old = umask(0002);
+ mkdir(dirname($file), 0777, true);
+ umask($old);
+ }
+ parent::writeCacheFile($file, $content);
+ chmod($file, 0775);
+ } else {
+ parent::writeCacheFile($file, $content);
+ }
+ }
+}
diff --git a/system/src/Grav/Common/Uri.php b/system/src/Grav/Common/Uri.php
new file mode 100644
index 0000000..8017ecb
--- /dev/null
+++ b/system/src/Grav/Common/Uri.php
@@ -0,0 +1,1365 @@
+createFromString($env);
+ } else {
+ $this->createFromEnvironment(is_array($env) ? $env : $_SERVER);
+ }
+ }
+
+ /**
+ * Initialize the URI class with a url passed via parameter.
+ * Used for testing purposes.
+ *
+ * @param string $url the URL to use in the class
+ *
+ * @return $this
+ */
+ public function initializeWithUrl($url = '')
+ {
+ if ($url) {
+ $this->createFromString($url);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Initialize the URI class by providing url and root_path arguments
+ *
+ * @param string $url
+ * @param string $root_path
+ *
+ * @return $this
+ */
+ public function initializeWithUrlAndRootPath($url, $root_path)
+ {
+ $this->initializeWithUrl($url);
+ $this->root_path = $root_path;
+
+ return $this;
+ }
+
+ /**
+ * Validate a hostname
+ *
+ * @param string $hostname The hostname
+ *
+ * @return boolean
+ */
+ public function validateHostname($hostname)
+ {
+ return (bool)preg_match(static::HOSTNAME_REGEX, $hostname);
+ }
+
+ /**
+ * Initializes the URI object based on the url set on the object
+ */
+ public function init()
+ {
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ /** @var Language $language */
+ $language = $grav['language'];
+
+ // add the port to the base for non-standard ports
+ if ($this->port !== null && $config->get('system.reverse_proxy_setup') === false) {
+ $this->base .= ':' . (string)$this->port;
+ }
+
+ // Handle custom base
+ $custom_base = rtrim($grav['config']->get('system.custom_base_url'), '/');
+
+ if ($custom_base) {
+ $custom_parts = parse_url($custom_base);
+ $orig_root_path = $this->root_path;
+ $this->root_path = isset($custom_parts['path']) ? rtrim($custom_parts['path'], '/') : '';
+ if (isset($custom_parts['scheme'])) {
+ $this->base = $custom_parts['scheme'] . '://' . $custom_parts['host'];
+ $this->root = $custom_base;
+ } else {
+ $this->root = $this->base . $this->root_path;
+ }
+ $this->uri = Utils::replaceFirstOccurrence($orig_root_path, $this->root_path, $this->uri);
+ } else {
+ $this->root = $this->base . $this->root_path;
+ }
+
+ $this->url = $this->base . $this->uri;
+
+ $uri = str_replace(static::filterPath($this->root), '', $this->url);
+
+ // remove the setup.php based base if set:
+ $setup_base = $grav['pages']->base();
+ if ($setup_base) {
+ $uri = preg_replace('|^' . preg_quote($setup_base, '|') . '|', '', $uri);
+ }
+
+ // If configured to, redirect trailing slash URI's with a 302 redirect
+ $redirect = str_replace($this->root, '', rtrim($uri, '/'));
+ if ($redirect && $uri !== '/' && $redirect !== $this->base() && $config->get('system.pages.redirect_trailing_slash', false) && Utils::endsWith($uri, '/')) {
+ $grav->redirect($redirect, 302);
+ }
+
+ // process params
+ $uri = $this->processParams($uri, $config->get('system.param_sep'));
+
+ // set active language
+ $uri = $language->setActiveFromUri($uri);
+
+ // split the URL and params
+ $bits = parse_url($uri);
+
+ //process fragment
+ if (isset($bits['fragment'])) {
+ $this->fragment = $bits['fragment'];
+ }
+
+ // Get the path. If there's no path, make sure pathinfo() still returns dirname variable
+ $path = isset($bits['path']) ? $bits['path'] : '/';
+
+ // remove the extension if there is one set
+ $parts = pathinfo($path);
+
+ // set the original basename
+ $this->basename = $parts['basename'];
+
+ // set the extension
+ if (isset($parts['extension'])) {
+ $this->extension = $parts['extension'];
+ }
+
+ $valid_page_types = implode('|', $config->get('system.pages.types'));
+
+ // Strip the file extension for valid page types
+ if (preg_match('/\.(' . $valid_page_types . ')$/', $parts['basename'])) {
+ $path = rtrim(str_replace(DIRECTORY_SEPARATOR, DS, $parts['dirname']), DS) . '/' . $parts['filename'];
+ }
+
+ // set the new url
+ $this->url = $this->root . $path;
+ $this->path = static::cleanPath($path);
+ $this->content_path = trim(str_replace($this->base, '', $this->path), '/');
+ if ($this->content_path !== '') {
+ $this->paths = explode('/', $this->content_path);
+ }
+
+ // Set some Grav stuff
+ $grav['base_url_absolute'] = $grav['config']->get('system.custom_base_url') ?: $this->rootUrl(true);
+ $grav['base_url_relative'] = $this->rootUrl(false);
+ $grav['base_url'] = $grav['config']->get('system.absolute_urls') ? $grav['base_url_absolute'] : $grav['base_url_relative'];
+
+ RouteFactory::setRoot($this->root_path);
+ RouteFactory::setLanguage($language->getLanguageURLPrefix());
+ }
+
+ /**
+ * Return URI path.
+ *
+ * @param string $id
+ *
+ * @return string|string[]
+ */
+ public function paths($id = null)
+ {
+ if ($id !== null) {
+ return $this->paths[$id];
+ }
+
+ return $this->paths;
+ }
+
+ /**
+ * Return route to the current URI. By default route doesn't include base path.
+ *
+ * @param bool $absolute True to include full path.
+ * @param bool $domain True to include domain. Works only if first parameter is also true.
+ *
+ * @return string
+ */
+ public function route($absolute = false, $domain = false)
+ {
+ return ($absolute ? $this->rootUrl($domain) : '') . '/' . implode('/', $this->paths);
+ }
+
+ /**
+ * Return full query string or a single query attribute.
+ *
+ * @param string $id Optional attribute. Get a single query attribute if set
+ * @param bool $raw If true and $id is not set, return the full query array. Otherwise return the query string
+ *
+ * @return string|array Returns an array if $id = null and $raw = true
+ */
+ public function query($id = null, $raw = false)
+ {
+ if ($id !== null) {
+ return isset($this->queries[$id]) ? $this->queries[$id] : null;
+ }
+
+ if ($raw) {
+ return $this->queries;
+ }
+
+ if (!$this->queries) {
+ return '';
+ }
+
+ return http_build_query($this->queries);
+ }
+
+ /**
+ * Return all or a single query parameter as a URI compatible string.
+ *
+ * @param string $id Optional parameter name.
+ * @param boolean $array return the array format or not
+ *
+ * @return null|string|array
+ */
+ public function params($id = null, $array = false)
+ {
+ $config = Grav::instance()['config'];
+ $sep = $config->get('system.param_sep');
+
+ $params = null;
+ if ($id === null) {
+ if ($array) {
+ return $this->params;
+ }
+ $output = [];
+ foreach ($this->params as $key => $value) {
+ $output[] = "{$key}{$sep}{$value}";
+ $params = '/' . implode('/', $output);
+ }
+ } elseif (isset($this->params[$id])) {
+ if ($array) {
+ return $this->params[$id];
+ }
+ $params = "/{$id}{$sep}{$this->params[$id]}";
+ }
+
+ return $params;
+ }
+
+ /**
+ * Get URI parameter.
+ *
+ * @param string $id
+ *
+ * @return bool|string
+ */
+ public function param($id)
+ {
+ if (isset($this->params[$id])) {
+ return html_entity_decode(rawurldecode($this->params[$id]));
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets the Fragment portion of a URI (eg #target)
+ *
+ * @param string $fragment
+ *
+ * @return string|null
+ */
+ public function fragment($fragment = null)
+ {
+ if ($fragment !== null) {
+ $this->fragment = $fragment;
+ }
+ return $this->fragment;
+ }
+
+ /**
+ * Return URL.
+ *
+ * @param bool $include_host Include hostname.
+ *
+ * @return string
+ */
+ public function url($include_host = false)
+ {
+ if ($include_host) {
+ return $this->url;
+ }
+
+ $url = str_replace($this->base, '', rtrim($this->url, '/'));
+
+ return $url ?: '/';
+ }
+
+ /**
+ * Return the Path
+ *
+ * @return String The path of the URI
+ */
+ public function path()
+ {
+ return $this->path;
+ }
+
+ /**
+ * Return the Extension of the URI
+ *
+ * @param string|null $default
+ *
+ * @return string The extension of the URI
+ */
+ public function extension($default = null)
+ {
+ if (!$this->extension) {
+ $this->extension = $default;
+ }
+
+ return $this->extension;
+ }
+
+ /**
+ * Return the scheme of the URI
+ *
+ * @param bool $raw
+ * @return string The scheme of the URI
+ */
+ public function scheme($raw = false)
+ {
+ if (!$raw) {
+ $scheme = '';
+ if ($this->scheme) {
+ $scheme = $this->scheme . '://';
+ } elseif ($this->host) {
+ $scheme = '//';
+ }
+
+ return $scheme;
+ }
+
+ return $this->scheme;
+ }
+
+
+ /**
+ * Return the host of the URI
+ *
+ * @return string|null The host of the URI
+ */
+ public function host()
+ {
+ return $this->host;
+ }
+
+ /**
+ * Return the port number if it can be figured out
+ *
+ * @param bool $raw
+ * @return int|null
+ */
+ public function port($raw = false)
+ {
+ $port = $this->port;
+ // If not in raw mode and port is not set, figure it out from scheme.
+ if (!$raw && $port === null) {
+ if ($this->scheme === 'http') {
+ $this->port = 80;
+ } elseif ($this->scheme === 'https') {
+ $this->port = 443;
+ }
+ }
+
+ return $this->port;
+ }
+
+ /**
+ * Return user
+ *
+ * @return string|null
+ */
+ public function user()
+ {
+ return $this->user;
+ }
+
+ /**
+ * Return password
+ *
+ * @return string|null
+ */
+ public function password()
+ {
+ return $this->password;
+ }
+
+ /**
+ * Gets the environment name
+ *
+ * @return String
+ */
+ public function environment()
+ {
+ return $this->env;
+ }
+
+
+ /**
+ * Return the basename of the URI
+ *
+ * @return String The basename of the URI
+ */
+ public function basename()
+ {
+ return $this->basename;
+ }
+
+ /**
+ * Return the full uri
+ *
+ * @param bool $include_root
+ * @return mixed
+ */
+ public function uri($include_root = true)
+ {
+ if ($include_root) {
+ return $this->uri;
+ } else {
+ $uri = str_replace($this->root_path, '', $this->uri);
+ return $uri;
+ }
+
+ }
+
+ /**
+ * Return the base of the URI
+ *
+ * @return String The base of the URI
+ */
+ public function base()
+ {
+ return $this->base;
+ }
+
+ /**
+ * Return the base relative URL including the language prefix
+ * or the base relative url if multi-language is not enabled
+ *
+ * @return String The base of the URI
+ */
+ public function baseIncludingLanguage()
+ {
+ $grav = Grav::instance();
+
+ // Link processing should prepend language
+ $language = $grav['language'];
+ $language_append = '';
+ if ($language->enabled()) {
+ $language_append = $language->getLanguageURLPrefix();
+ }
+
+ $base = $grav['base_url_relative'];
+
+ return rtrim($base . $grav['pages']->base(), '/') . $language_append;
+ }
+
+ /**
+ * Return root URL to the site.
+ *
+ * @param bool $include_host Include hostname.
+ *
+ * @return mixed
+ */
+ public function rootUrl($include_host = false)
+ {
+ if ($include_host) {
+ return $this->root;
+ }
+
+ return str_replace($this->base, '', $this->root);
+ }
+
+ /**
+ * Return current page number.
+ *
+ * @return int
+ */
+ public function currentPage()
+ {
+ return isset($this->params['page']) ? $this->params['page'] : 1;
+ }
+
+ /**
+ * Return relative path to the referrer defaulting to current or given page.
+ *
+ * @param string $default
+ * @param string $attributes
+ *
+ * @return string
+ */
+ public function referrer($default = null, $attributes = null)
+ {
+ $referrer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : null;
+
+ // Check that referrer came from our site.
+ $root = $this->rootUrl(true);
+ if ($referrer) {
+ // Referrer should always have host set and it should come from the same base address.
+ if (stripos($referrer, $root) !== 0) {
+ $referrer = null;
+ }
+ }
+
+ if (!$referrer) {
+ $referrer = $default ?: $this->route(true, true);
+ }
+
+ if ($attributes) {
+ $referrer .= $attributes;
+ }
+
+ // Return relative path.
+ return substr($referrer, strlen($root));
+ }
+
+ public function __toString()
+ {
+ return static::buildUrl($this->toArray());
+ }
+
+ public function toArray()
+ {
+ return [
+ 'scheme' => $this->scheme,
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'user' => $this->user,
+ 'pass' => $this->password,
+ 'path' => $this->path,
+ 'params' => $this->params,
+ 'query' => $this->query,
+ 'fragment' => $this->fragment
+ ];
+ }
+
+ /**
+ * Calculate the parameter regex based on the param_sep setting
+ *
+ * @return string
+ */
+ public static function paramsRegex()
+ {
+ return '/\/([^\:\#\/\?]*' . Grav::instance()['config']->get('system.param_sep') . '[^\:\#\/\?]*)/';
+ }
+
+ /**
+ * Return the IP address of the current user
+ *
+ * @return string ip address
+ */
+ public static function ip()
+ {
+ if (getenv('HTTP_CLIENT_IP')) {
+ $ip = getenv('HTTP_CLIENT_IP');
+ } elseif (getenv('HTTP_X_FORWARDED_FOR')) {
+ $ip = getenv('HTTP_X_FORWARDED_FOR');
+ } elseif (getenv('HTTP_X_FORWARDED')) {
+ $ip = getenv('HTTP_X_FORWARDED');
+ } elseif (getenv('HTTP_FORWARDED_FOR')) {
+ $ip = getenv('HTTP_FORWARDED_FOR');
+ } elseif (getenv('HTTP_FORWARDED')) {
+ $ip = getenv('HTTP_FORWARDED');
+ } elseif (getenv('REMOTE_ADDR')){
+ $ip = getenv('REMOTE_ADDR');
+ } else {
+ $ip = 'UNKNOWN';
+ }
+
+ return $ip;
+
+ }
+ /**
+
+ * Returns current Uri.
+ *
+ * @return \Grav\Framework\Uri\Uri
+ */
+ public static function getCurrentUri()
+ {
+ if (!static::$currentUri) {
+ static::$currentUri = UriFactory::createFromEnvironment($_SERVER);
+ }
+
+ return static::$currentUri;
+ }
+
+ /**
+ * Returns current route.
+ *
+ * @return \Grav\Framework\Route\Route
+ */
+ public static function getCurrentRoute()
+ {
+ if (!static::$currentRoute) {
+ $uri = Grav::instance()['uri'];
+ static::$currentRoute = RouteFactory::createFromParts($uri->toArray());
+ }
+
+ return static::$currentRoute;
+ }
+
+ /**
+ * Is this an external URL? if it starts with `http` then yes, else false
+ *
+ * @param string $url the URL in question
+ *
+ * @return boolean is eternal state
+ */
+ public static function isExternal($url)
+ {
+ return Utils::startsWith($url, 'http');
+ }
+
+ /**
+ * The opposite of built-in PHP method parse_url()
+ *
+ * @param array $parsed_url
+ *
+ * @return string
+ */
+ public static function buildUrl($parsed_url)
+ {
+ $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . ':' : '';
+ $authority = isset($parsed_url['host']) ? '//' : '';
+ $host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
+ $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '';
+ $user = isset($parsed_url['user']) ? $parsed_url['user'] : '';
+ $pass = isset($parsed_url['pass']) ? ':' . $parsed_url['pass'] : '';
+ $pass = ($user || $pass) ? "{$pass}@" : '';
+ $path = isset($parsed_url['path']) ? $parsed_url['path'] : '';
+ $path = !empty($parsed_url['params']) ? rtrim($path, '/') . static::buildParams($parsed_url['params']) : $path;
+ $query = !empty($parsed_url['query']) ? '?' . $parsed_url['query'] : '';
+ $fragment = isset($parsed_url['fragment']) ? '#' . $parsed_url['fragment'] : '';
+
+ return "{$scheme}{$authority}{$user}{$pass}{$host}{$port}{$path}{$query}{$fragment}";
+ }
+
+ /**
+ * @param array $params
+ * @return string
+ */
+ public static function buildParams(array $params)
+ {
+ if (!$params) {
+ return '';
+ }
+
+ $grav = Grav::instance();
+ $sep = $grav['config']->get('system.param_sep');
+
+ $output = [];
+ foreach ($params as $key => $value) {
+ $output[] = "{$key}{$sep}{$value}";
+ }
+
+ return '/' . implode('/', $output);
+ }
+
+ /**
+ * Converts links from absolute '/' or relative (../..) to a Grav friendly format
+ *
+ * @param Page $page the current page to use as reference
+ * @param string|array $url the URL as it was written in the markdown
+ * @param string $type the type of URL, image | link
+ * @param bool $absolute if null, will use system default, if true will use absolute links internally
+ * @param bool $route_only only return the route, not full URL path
+ * @return string the more friendly formatted url
+ */
+ public static function convertUrl(Page $page, $url, $type = 'link', $absolute = false, $route_only = false)
+ {
+ $grav = Grav::instance();
+
+ $uri = $grav['uri'];
+
+ // Link processing should prepend language
+ $language = $grav['language'];
+ $language_append = '';
+ if ($type === 'link' && $language->enabled()) {
+ $language_append = $language->getLanguageURLPrefix();
+ }
+
+ // Handle Excerpt style $url array
+ $url_path = is_array($url) ? $url['path'] : $url;
+
+ $external = false;
+ $base = $grav['base_url_relative'];
+ $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append;
+ $pages_dir = $grav['locator']->findResource('page://');
+
+ // if absolute and starts with a base_url move on
+ if (isset($url['scheme']) && Utils::startsWith($url['scheme'], 'http')) {
+ $external = true;
+ } elseif ($url_path === '' && isset($url['fragment'])) {
+ $external = true;
+ } elseif ($url_path === '/' || ($base_url !== '' && Utils::startsWith($url_path, $base_url))) {
+ $url_path = $base_url . $url_path;
+ } else {
+
+ // see if page is relative to this or absolute
+ if (Utils::startsWith($url_path, '/')) {
+ $normalized_url = Utils::normalizePath($base_url . $url_path);
+ $normalized_path = Utils::normalizePath($pages_dir . $url_path);
+ } else {
+ $page_route = ($page->home() && !empty($url_path)) ? $page->rawRoute() : $page->route();
+ $normalized_url = $base_url . Utils::normalizePath($page_route . '/' . $url_path);
+ $normalized_path = Utils::normalizePath($page->path() . '/' . $url_path);
+ }
+
+ // special check to see if path checking is required.
+ $just_path = str_replace($normalized_url, '', $normalized_path);
+ if ($normalized_url === '/' || $just_path === $page->path()) {
+ $url_path = $normalized_url;
+ } else {
+ $url_bits = static::parseUrl($normalized_path);
+ $full_path = $url_bits['path'];
+ $raw_full_path = rawurldecode($full_path);
+
+ if (file_exists($raw_full_path)) {
+ $full_path = $raw_full_path;
+ } elseif (!file_exists($full_path)) {
+ $full_path = false;
+ }
+
+ if ($full_path) {
+ $path_info = pathinfo($full_path);
+ $page_path = $path_info['dirname'];
+ $filename = '';
+
+ if ($url_path === '..') {
+ $page_path = $full_path;
+ } else {
+ // save the filename if a file is part of the path
+ if (is_file($full_path)) {
+ if ($path_info['extension'] !== 'md') {
+ $filename = '/' . $path_info['basename'];
+ }
+ } else {
+ $page_path = $full_path;
+ }
+ }
+
+ // get page instances and try to find one that fits
+ $instances = $grav['pages']->instances();
+ if (isset($instances[$page_path])) {
+ /** @var Page $target */
+ $target = $instances[$page_path];
+ $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename;
+
+ $url_path = Uri::buildUrl($url_bits);
+ } else {
+ $url_path = $normalized_url;
+ }
+ } else {
+ $url_path = $normalized_url;
+ }
+ }
+ }
+
+ // handle absolute URLs
+ if (is_array($url) && !$external && ($absolute === true || $grav['config']->get('system.absolute_urls', false))) {
+
+ $url['scheme'] = $uri->scheme(true);
+ $url['host'] = $uri->host();
+ $url['port'] = $uri->port(true);
+
+ // check if page exists for this route, and if so, check if it has SSL enabled
+ $pages = $grav['pages'];
+ $routes = $pages->routes();
+
+ // if this is an image, get the proper path
+ $url_bits = pathinfo($url_path);
+ if (isset($url_bits['extension'])) {
+ $target_path = $url_bits['dirname'];
+ } else {
+ $target_path = $url_path;
+ }
+
+ // strip base from this path
+ $target_path = str_replace($uri->rootUrl(), '', $target_path);
+
+ // set to / if root
+ if (empty($target_path)) {
+ $target_path = '/';
+ }
+
+ // look to see if this page exists and has ssl enabled
+ if (isset($routes[$target_path])) {
+ $target_page = $pages->get($routes[$target_path]);
+ if ($target_page) {
+ $ssl_enabled = $target_page->ssl();
+ if ($ssl_enabled !== null) {
+ if ($ssl_enabled) {
+ $url['scheme'] = 'https';
+ } else {
+ $url['scheme'] = 'http';
+ }
+ }
+ }
+ }
+ }
+
+ // Handle route only
+ if ($route_only) {
+ $url_path = str_replace(static::filterPath($base_url), '', $url_path);
+ }
+
+ // transform back to string/array as needed
+ if (is_array($url)) {
+ $url['path'] = $url_path;
+ } else {
+ $url = $url_path;
+ }
+
+ return $url;
+ }
+
+ public static function parseUrl($url)
+ {
+ $grav = Grav::instance();
+ $parts = parse_url($url);
+
+ list($stripped_path, $params) = static::extractParams($parts['path'], $grav['config']->get('system.param_sep'));
+
+ if (!empty($params)) {
+ $parts['path'] = $stripped_path;
+ $parts['params'] = $params;
+ }
+
+ return $parts;
+ }
+
+ public static function extractParams($uri, $delimiter)
+ {
+ $params = [];
+
+ if (strpos($uri, $delimiter) !== false) {
+ preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER);
+
+ foreach ($matches as $match) {
+ $param = explode($delimiter, $match[1]);
+ if (count($param) === 2) {
+ $plain_var = filter_var(rawurldecode($param[1]), FILTER_SANITIZE_STRING);
+ $params[$param[0]] = $plain_var;
+ $uri = str_replace($match[0], '', $uri);
+ }
+ }
+ }
+
+ return [$uri, $params];
+ }
+
+ /**
+ * Converts links from absolute '/' or relative (../..) to a Grav friendly format
+ *
+ * @param Page $page the current page to use as reference
+ * @param string $markdown_url the URL as it was written in the markdown
+ * @param string $type the type of URL, image | link
+ * @param null $relative if null, will use system default, if true will use relative links internally
+ *
+ * @return string the more friendly formatted url
+ */
+ public static function convertUrlOld(Page $page, $markdown_url, $type = 'link', $relative = null)
+ {
+ $grav = Grav::instance();
+
+ $language = $grav['language'];
+
+ // Link processing should prepend language
+ $language_append = '';
+ if ($type === 'link' && $language->enabled()) {
+ $language_append = $language->getLanguageURLPrefix();
+ }
+ $pages_dir = $grav['locator']->findResource('page://');
+ if ($relative === null) {
+ $base = $grav['base_url'];
+ } else {
+ $base = $relative ? $grav['base_url_relative'] : $grav['base_url_absolute'];
+ }
+
+ $base_url = rtrim($base . $grav['pages']->base(), '/') . $language_append;
+
+ // if absolute and starts with a base_url move on
+ if (pathinfo($markdown_url, PATHINFO_DIRNAME) === '.' && $page->url() === '/') {
+ return '/' . $markdown_url;
+ }
+ // no path to convert
+ if ($base_url !== '' && Utils::startsWith($markdown_url, $base_url)) {
+ return $markdown_url;
+ }
+ // if contains only a fragment
+ if (Utils::startsWith($markdown_url, '#')) {
+ return $markdown_url;
+ }
+
+ $target = null;
+ // see if page is relative to this or absolute
+ if (Utils::startsWith($markdown_url, '/')) {
+ $normalized_url = Utils::normalizePath($base_url . $markdown_url);
+ $normalized_path = Utils::normalizePath($pages_dir . $markdown_url);
+ } else {
+ $normalized_url = $base_url . Utils::normalizePath($page->route() . '/' . $markdown_url);
+ $normalized_path = Utils::normalizePath($page->path() . '/' . $markdown_url);
+ }
+
+ // special check to see if path checking is required.
+ $just_path = str_replace($normalized_url, '', $normalized_path);
+ if ($just_path === $page->path()) {
+ return $normalized_url;
+ }
+
+ $url_bits = parse_url($normalized_path);
+ $full_path = $url_bits['path'];
+
+ if (file_exists($full_path)) {
+ // do nothing
+ } elseif (file_exists(rawurldecode($full_path))) {
+ $full_path = rawurldecode($full_path);
+ } else {
+ return $normalized_url;
+ }
+
+ $path_info = pathinfo($full_path);
+ $page_path = $path_info['dirname'];
+ $filename = '';
+
+ if ($markdown_url === '..') {
+ $page_path = $full_path;
+ } else {
+ // save the filename if a file is part of the path
+ if (is_file($full_path)) {
+ if ($path_info['extension'] !== 'md') {
+ $filename = '/' . $path_info['basename'];
+ }
+ } else {
+ $page_path = $full_path;
+ }
+ }
+
+ // get page instances and try to find one that fits
+ $instances = $grav['pages']->instances();
+ if (isset($instances[$page_path])) {
+ /** @var Page $target */
+ $target = $instances[$page_path];
+ $url_bits['path'] = $base_url . rtrim($target->route(), '/') . $filename;
+
+ return static::buildUrl($url_bits);
+ }
+
+ return $normalized_url;
+ }
+
+ /**
+ * Adds the nonce to a URL for a specific action
+ *
+ * @param string $url the url
+ * @param string $action the action
+ * @param string $nonceParamName the param name to use
+ *
+ * @return string the url with the nonce
+ */
+ public static function addNonce($url, $action, $nonceParamName = 'nonce')
+ {
+ $fake = $url && $url[0] === '/';
+
+ if ($fake) {
+ $url = 'http://domain.com' . $url;
+ }
+ $uri = new static($url);
+ $parts = $uri->toArray();
+ $nonce = Utils::getNonce($action);
+ $parts['params'] = (isset($parts['params']) ? $parts['params'] : []) + [$nonceParamName => $nonce];
+
+ if ($fake) {
+ unset($parts['scheme'], $parts['host']);
+ }
+
+ return static::buildUrl($parts);
+ }
+
+ /**
+ * Is the passed in URL a valid URL?
+ *
+ * @param $url
+ * @return bool
+ */
+ public static function isValidUrl($url)
+ {
+ $regex = '/^(?:(https?|ftp|telnet):)?\/\/((?:[a-z0-9@:.-]|%[0-9A-F]{2}){3,})(?::(\d+))?((?:\/(?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\@]|%[0-9A-F]{2})*)*)(?:\?((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?(?:#((?:[a-z0-9-._~!$&\'\(\)\*\+\,\;\=\:\/?@]|%[0-9A-F]{2})*))?/';
+ if (preg_match($regex, $url)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Removes extra double slashes and fixes back-slashes
+ *
+ * @param $path
+ * @return mixed|string
+ */
+ public static function cleanPath($path)
+ {
+ $regex = '/(\/)\/+/';
+ $path = str_replace(['\\', '/ /'], '/', $path);
+ $path = preg_replace($regex,'$1',$path);
+
+ return $path;
+ }
+
+ /**
+ * Filters the user info string.
+ *
+ * @param string $info The raw user or password.
+ * @return string The percent-encoded user or password string.
+ */
+ public static function filterUserInfo($info)
+ {
+ return $info !== null ? UriPartsFilter::filterUserInfo($info) : '';
+ }
+
+ /**
+ * Filter Uri path.
+ *
+ * This method percent-encodes all reserved
+ * characters in the provided path string. This method
+ * will NOT double-encode characters that are already
+ * percent-encoded.
+ *
+ * @param string $path The raw uri path.
+ * @return string The RFC 3986 percent-encoded uri path.
+ * @link http://www.faqs.org/rfcs/rfc3986.html
+ */
+ public static function filterPath($path)
+ {
+ return $path !== null ? UriPartsFilter::filterPath($path) : '';
+ }
+
+ /**
+ * Filters the query string or fragment of a URI.
+ *
+ * @param string $query The raw uri query string.
+ * @return string The percent-encoded query string.
+ */
+ public static function filterQuery($query)
+ {
+ return $query !== null ? UriPartsFilter::filterQueryOrFragment($query) : '';
+ }
+
+ /**
+ * @param array $env
+ */
+ protected function createFromEnvironment(array $env)
+ {
+ // Build scheme.
+ if (isset($env['HTTP_X_FORWARDED_PROTO'])) {
+ $this->scheme = $env['HTTP_X_FORWARDED_PROTO'];
+ } elseif (isset($env['X-FORWARDED-PROTO'])) {
+ $this->scheme = $env['X-FORWARDED-PROTO'];
+ } elseif (isset($env['REQUEST_SCHEME'])) {
+ $this->scheme = $env['REQUEST_SCHEME'];
+ } else {
+ $https = isset($env['HTTPS']) ? $env['HTTPS'] : '';
+ $this->scheme = (empty($https) || strtolower($https) === 'off') ? 'http' : 'https';
+ }
+
+ // Build user and password.
+ $this->user = isset($env['PHP_AUTH_USER']) ? $env['PHP_AUTH_USER'] : null;
+ $this->password = isset($env['PHP_AUTH_PW']) ? $env['PHP_AUTH_PW'] : null;
+
+ // Build host.
+ $hostname = 'localhost';
+ if (isset($env['HTTP_HOST'])) {
+ $hostname = $env['HTTP_HOST'];
+ } elseif (isset($env['SERVER_NAME'])) {
+ $hostname = $env['SERVER_NAME'];
+ }
+ // Remove port from HTTP_HOST generated $hostname
+ $hostname = Utils::substrToString($hostname, ':');
+ // Validate the hostname
+ $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown';
+
+ // Build port.
+ if (isset($env['HTTP_X_FORWARDED_PORT'])) {
+ $this->port = (int)$env['HTTP_X_FORWARDED_PORT'];
+ } elseif (isset($env['X-FORWARDED-PORT'])) {
+ $this->port = (int)$env['X-FORWARDED-PORT'];
+ } elseif (isset($env['SERVER_PORT'])) {
+ $this->port = (int)$env['SERVER_PORT'];
+ } else {
+ $this->port = null;
+ }
+
+ if ($this->hasStandardPort()) {
+ $this->port = null;
+ }
+
+ // Build path.
+ $request_uri = isset($env['REQUEST_URI']) ? $env['REQUEST_URI'] : '';
+ $this->path = rawurldecode(parse_url('http://example.com' . $request_uri, PHP_URL_PATH));
+
+ // Build query string.
+ $this->query = isset($env['QUERY_STRING']) ? $env['QUERY_STRING'] : '';
+ if ($this->query === '') {
+ $this->query = parse_url('http://example.com' . $request_uri, PHP_URL_QUERY);
+ }
+
+ // Support ngnix routes.
+ if (strpos($this->query, '_url=') === 0) {
+ parse_str($this->query, $query);
+ unset($query['_url']);
+ $this->query = http_build_query($query);
+ }
+
+ // Build fragment.
+ $this->fragment = null;
+
+ // Filter userinfo, path and query string.
+ $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null;
+ $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null;
+ $this->path = empty($this->path) ? '/' : static::filterPath($this->path);
+ $this->query = static::filterQuery($this->query);
+
+ $this->reset();
+ }
+
+ /**
+ * Does this Uri use a standard port?
+ *
+ * @return bool
+ */
+ protected function hasStandardPort()
+ {
+ return ($this->scheme === 'http' && $this->port === 80) || ($this->scheme === 'https' && $this->port === 443);
+ }
+
+ /**
+ * @param string $url
+ */
+ protected function createFromString($url)
+ {
+ // Set Uri parts.
+ $parts = parse_url($url);
+ if ($parts === false) {
+ throw new \RuntimeException('Malformed URL: ' . $url);
+ }
+ $this->scheme = isset($parts['scheme']) ? $parts['scheme'] : null;
+ $this->user = isset($parts['user']) ? $parts['user'] : null;
+ $this->password = isset($parts['pass']) ? $parts['pass'] : null;
+ $this->host = isset($parts['host']) ? $parts['host'] : null;
+ $this->port = isset($parts['port']) ? (int)$parts['port'] : null;
+ $this->path = isset($parts['path']) ? $parts['path'] : '';
+ $this->query = isset($parts['query']) ? $parts['query'] : '';
+ $this->fragment = isset($parts['fragment']) ? $parts['fragment'] : null;
+
+ // Validate the hostname
+ if ($this->host) {
+ $this->host = $this->validateHostname($this->host) ? $this->host : 'unknown';
+ }
+ // Filter userinfo, path, query string and fragment.
+ $this->user = $this->user !== null ? static::filterUserInfo($this->user) : null;
+ $this->password = $this->password !== null ? static::filterUserInfo($this->password) : null;
+ $this->path = empty($this->path) ? '/' : static::filterPath($this->path);
+ $this->query = static::filterQuery($this->query);
+ $this->fragment = $this->fragment !== null ? static::filterQuery($this->fragment) : null;
+
+ $this->reset();
+ }
+
+ protected function reset()
+ {
+ // resets
+ parse_str($this->query, $this->queries);
+ $this->extension = null;
+ $this->basename = null;
+ $this->paths = [];
+ $this->params = [];
+ $this->env = $this->buildEnvironment();
+ $this->uri = $this->path . (!empty($this->query) ? '?' . $this->query : '');
+
+ $this->base = $this->buildBaseUrl();
+ $this->root_path = $this->buildRootPath();
+ $this->root = $this->base . $this->root_path;
+ $this->url = $this->base . $this->uri;
+ }
+
+ /**
+ * Get's post from either $_POST or JSON response object
+ * By default returns all data, or can return a single item
+ *
+ * @param string $element
+ * @param string $filter_type
+ * @return array|mixed|null
+ */
+ public function post($element = null, $filter_type = null)
+ {
+ if (!$this->post) {
+ $content_type = $this->getContentType();
+ if ($content_type == 'application/json') {
+ $json = file_get_contents('php://input');
+ $this->post = json_decode($json, true);
+ } elseif (!empty($_POST)) {
+ $this->post = (array)$_POST;
+ }
+ }
+
+ if ($this->post && !is_null($element)) {
+ $item = Utils::getDotNotation($this->post, $element);
+ if ($filter_type) {
+ $item = filter_var($item, $filter_type);
+ }
+ return $item;
+ }
+
+ return $this->post;
+ }
+
+ /**
+ * Get content type from request
+ *
+ * @param bool $short
+ * @return null|string
+ */
+ private function getContentType($short = true)
+ {
+ if (isset($_SERVER['CONTENT_TYPE'])) {
+ $content_type = $_SERVER['CONTENT_TYPE'];
+ if ($short) {
+ return Utils::substrToString($content_type,';');
+ }
+ return $content_type;
+ }
+ return null;
+ }
+
+ /**
+ * Get the base URI with port if needed
+ *
+ * @return string
+ */
+ private function buildBaseUrl()
+ {
+ return $this->scheme() . $this->host;
+ }
+
+ /**
+ * Get the Grav Root Path
+ *
+ * @return string
+ */
+ private function buildRootPath()
+ {
+ // In Windows script path uses backslash, convert it:
+ $scriptPath = str_replace('\\', '/', $_SERVER['PHP_SELF']);
+ $rootPath = str_replace(' ', '%20', rtrim(substr($scriptPath, 0, strpos($scriptPath, 'index.php')), '/'));
+
+ // check if userdir in the path and workaround PHP bug with PHP_SELF
+ if (strpos($this->uri, '/~') !== false && strpos($scriptPath, '/~') === false) {
+ $rootPath = substr($this->uri, 0, strpos($this->uri, '/', 1)) . $rootPath;
+ }
+
+ return $rootPath;
+ }
+
+ private function buildEnvironment()
+ {
+ // check for localhost variations
+ if ($this->host === '127.0.0.1' || $this->host === '::1') {
+ return 'localhost';
+ }
+
+ return $this->host ?: 'unknown';
+ }
+
+ /**
+ * Process any params based in this URL, supports any valid delimiter
+ *
+ * @param $uri
+ * @param string $delimiter
+ *
+ * @return string
+ */
+ private function processParams($uri, $delimiter = ':')
+ {
+ if (strpos($uri, $delimiter) !== false) {
+ preg_match_all(static::paramsRegex(), $uri, $matches, PREG_SET_ORDER);
+
+ foreach ($matches as $match) {
+ $param = explode($delimiter, $match[1]);
+ if (count($param) === 2) {
+ $plain_var = filter_var($param[1], FILTER_SANITIZE_STRING);
+ $this->params[$param[0]] = $plain_var;
+ $uri = str_replace($match[0], '', $uri);
+ }
+ }
+ }
+ return $uri;
+ }
+}
diff --git a/system/src/Grav/Common/User/Authentication.php b/system/src/Grav/Common/User/Authentication.php
new file mode 100644
index 0000000..b46750c
--- /dev/null
+++ b/system/src/Grav/Common/User/Authentication.php
@@ -0,0 +1,54 @@
+get('groups', []);
+ }
+
+ /**
+ * Get the groups list
+ *
+ * @return array
+ */
+ public static function groupNames()
+ {
+ $groups = [];
+
+ foreach(static::groups() as $groupname => $group) {
+ $groups[$groupname] = isset($group['readableName']) ? $group['readableName'] : $groupname;
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Checks if a group exists
+ *
+ * @param string $groupname
+ *
+ * @return bool
+ */
+ public static function groupExists($groupname)
+ {
+ return isset(self::groups()[$groupname]);
+ }
+
+ /**
+ * Get a group by name
+ *
+ * @param string $groupname
+ *
+ * @return object
+ */
+ public static function load($groupname)
+ {
+ $groups = self::groups();
+
+ $content = isset($groups[$groupname]) ? $groups[$groupname] : [];
+ $content += ['groupname' => $groupname];
+
+ $blueprints = new Blueprints;
+ $blueprint = $blueprints->get('user/group');
+
+ return new Group($content, $blueprint);
+ }
+
+ /**
+ * Save a group
+ */
+ public function save()
+ {
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ $blueprints = new Blueprints;
+ $blueprint = $blueprints->get('user/group');
+
+ $config->set("groups.{$this->groupname}", []);
+
+ $fields = $blueprint->fields();
+ foreach ($fields as $field) {
+ if ($field['type'] === 'text') {
+ $value = $field['name'];
+ if (isset($this->items['data'][$value])) {
+ $config->set("groups.{$this->groupname}.{$value}", $this->items['data'][$value]);
+ }
+ }
+ if ($field['type'] === 'array' || $field['type'] === 'permissions') {
+ $value = $field['name'];
+ $arrayValues = Utils::getDotNotation($this->items['data'], $field['name']);
+
+ if ($arrayValues) {
+ foreach ($arrayValues as $arrayIndex => $arrayValue) {
+ $config->set("groups.{$this->groupname}.{$value}.{$arrayIndex}", $arrayValue);
+ }
+ }
+ }
+ }
+
+ $type = 'groups';
+ $blueprints = $this->blueprints("config/{$type}");
+
+ $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml"));
+
+ $obj = new Data($config->get($type), $blueprints);
+ $obj->file($filename);
+ $obj->save();
+ }
+
+ /**
+ * Remove a group
+ *
+ * @param string $groupname
+ *
+ * @return bool True if the action was performed
+ */
+ public static function remove($groupname)
+ {
+ $grav = Grav::instance();
+
+ /** @var Config $config */
+ $config = $grav['config'];
+
+ $blueprints = new Blueprints;
+ $blueprint = $blueprints->get('user/group');
+
+ $type = 'groups';
+
+ $groups = $config->get($type);
+ unset($groups[$groupname]);
+ $config->set($type, $groups);
+
+ $filename = CompiledYamlFile::instance($grav['locator']->findResource("config://{$type}.yaml"));
+
+ $obj = new Data($groups, $blueprint);
+ $obj->file($filename);
+ $obj->save();
+
+ return true;
+ }
+}
diff --git a/system/src/Grav/Common/User/User.php b/system/src/Grav/Common/User/User.php
new file mode 100644
index 0000000..512b292
--- /dev/null
+++ b/system/src/Grav/Common/User/User.php
@@ -0,0 +1,287 @@
+exists().
+ *
+ * @param string $username
+ * @param bool $setConfig
+ *
+ * @return User
+ */
+ public static function load($username)
+ {
+ $grav = Grav::instance();
+ /** @var UniformResourceLocator $locator */
+ $locator = $grav['locator'];
+
+ // force lowercase of username
+ $username = strtolower($username);
+
+ $blueprints = new Blueprints;
+ $blueprint = $blueprints->get('user/account');
+
+ $file_path = $locator->findResource('account://' . $username . YAML_EXT);
+ $file = CompiledYamlFile::instance($file_path);
+ $content = (array)$file->content() + ['username' => $username, 'state' => 'enabled'];
+
+ $user = new User($content, $blueprint);
+ $user->file($file);
+
+ return $user;
+ }
+
+ /**
+ * Find a user by username, email, etc
+ *
+ * @param string $query the query to search for
+ * @param array $fields the fields to search
+ * @return User
+ */
+ public static function find($query, $fields = ['username', 'email'])
+ {
+ $account_dir = Grav::instance()['locator']->findResource('account://');
+ $files = $account_dir ? array_diff(scandir($account_dir), ['.', '..']) : [];
+
+ // Try with username first, you never know!
+ if (in_array('username', $fields, true)) {
+ $user = User::load($query);
+ unset($fields[array_search('username', $fields, true)]);
+ } else {
+ $user = User::load('');
+ }
+
+ // If not found, try the fields
+ if (!$user->exists()) {
+ foreach ($files as $file) {
+ if (Utils::endsWith($file, YAML_EXT)) {
+ $find_user = User::load(trim(pathinfo($file, PATHINFO_FILENAME)));
+ foreach ($fields as $field) {
+ if ($find_user[$field] === $query) {
+ return $find_user;
+ }
+ }
+ }
+ }
+ }
+ return $user;
+ }
+
+ /**
+ * Remove user account.
+ *
+ * @param string $username
+ *
+ * @return bool True if the action was performed
+ */
+ public static function remove($username)
+ {
+ $file_path = Grav::instance()['locator']->findResource('account://' . $username . YAML_EXT);
+
+ return $file_path && unlink($file_path);
+ }
+
+ /**
+ * @param string $offset
+ * @return bool
+ */
+ public function offsetExists($offset)
+ {
+ $value = parent::offsetExists($offset);
+
+ // Handle special case where user was logged in before 'authorized' was added to the user object.
+ if (false === $value && $offset === 'authorized') {
+ $value = $this->offsetExists('authenticated');
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param string $offset
+ * @return mixed
+ */
+ public function offsetGet($offset)
+ {
+ $value = parent::offsetGet($offset);
+
+ // Handle special case where user was logged in before 'authorized' was added to the user object.
+ if (null === $value && $offset === 'authorized') {
+ $value = $this->offsetGet('authenticated');
+ $this->offsetSet($offset, $value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Authenticate user.
+ *
+ * If user password needs to be updated, new information will be saved.
+ *
+ * @param string $password Plaintext password.
+ *
+ * @return bool
+ */
+ public function authenticate($password)
+ {
+ $save = false;
+
+ // Plain-text is still stored
+ if ($this->password) {
+ if ($password !== $this->password) {
+ // Plain-text passwords do not match, we know we should fail but execute
+ // verify to protect us from timing attacks and return false regardless of
+ // the result
+ Authentication::verify(
+ $password,
+ Grav::instance()['config']->get('system.security.default_hash')
+ );
+
+ return false;
+ }
+
+ // Plain-text does match, we can update the hash and proceed
+ $save = true;
+
+ $this->hashed_password = Authentication::create($this->password);
+ unset($this->password);
+
+ }
+
+ $result = Authentication::verify($password, $this->hashed_password);
+
+ // Password needs to be updated, save the file.
+ if ($result === 2) {
+ $save = true;
+ $this->hashed_password = Authentication::create($password);
+ }
+
+ if ($save) {
+ $this->save();
+ }
+
+ return (bool)$result;
+ }
+
+ /**
+ * Save user without the username
+ */
+ public function save()
+ {
+ $file = $this->file();
+
+ if ($file) {
+ $username = $this->get('username');
+
+ if (!$file->filename()) {
+ $locator = Grav::instance()['locator'];
+ $file->filename($locator->findResource('account://') . DS . strtolower($username) . YAML_EXT);
+ }
+
+ // if plain text password, hash it and remove plain text
+ if ($this->password) {
+ $this->hashed_password = Authentication::create($this->password);
+ unset($this->password);
+ }
+
+ unset($this->username);
+ $file->save($this->items);
+ $this->set('username', $username);
+ }
+ }
+
+ /**
+ * Checks user authorization to the action.
+ *
+ * @param string $action
+ *
+ * @return bool
+ */
+ public function authorize($action)
+ {
+ if (empty($this->items)) {
+ return false;
+ }
+
+ if (!$this->authenticated) {
+ return false;
+ }
+
+ if (isset($this->state) && $this->state !== 'enabled') {
+ return false;
+ }
+
+ $return = false;
+
+ //Check group access level
+ $groups = $this->get('groups');
+ if ($groups) {
+ foreach ((array)$groups as $group) {
+ $permission = Grav::instance()['config']->get("groups.{$group}.access.{$action}");
+ $return = Utils::isPositive($permission);
+ if ($return === true) {
+ break;
+ }
+ }
+ }
+
+ //Check user access level
+ if ($this->get('access')) {
+ if (Utils::getDotNotation($this->get('access'), $action) !== null) {
+ $permission = $this->get("access.{$action}");
+ $return = Utils::isPositive($permission);
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * Checks user authorization to the action.
+ * Ensures backwards compatibility
+ *
+ * @param string $action
+ *
+ * @deprecated use authorize()
+ * @return bool
+ */
+ public function authorise($action)
+ {
+ return $this->authorize($action);
+ }
+
+ /**
+ * Return the User's avatar URL
+ *
+ * @return string
+ */
+ public function avatarUrl()
+ {
+ if ($this->avatar) {
+ $avatar = $this->avatar;
+ $avatar = array_shift($avatar);
+ return Grav::instance()['base_url'] . '/' . $avatar['path'];
+ }
+
+ return 'https://www.gravatar.com/avatar/' . md5($this->email);
+ }
+}
diff --git a/system/src/Grav/Common/Utils.php b/system/src/Grav/Common/Utils.php
new file mode 100644
index 0000000..02d5e15
--- /dev/null
+++ b/system/src/Grav/Common/Utils.php
@@ -0,0 +1,1109 @@
+get('system.absolute_urls', false)) {
+ $domain = true;
+ }
+
+ if (Grav::instance()['uri']->isExternal($input)) {
+ return $input;
+ }
+
+ $input = ltrim((string)$input, '/');
+
+ if (Utils::contains((string)$input, '://')) {
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+
+ // Get relative path to the resource (or false if not found).
+ $resource = $locator->findResource($input, false);
+ } else {
+ $resource = $input;
+ }
+
+ /** @var Uri $uri */
+ $uri = Grav::instance()['uri'];
+
+ return $resource ? rtrim($uri->rootUrl($domain), '/') . '/' . $resource : null;
+ }
+
+ /**
+ * Check if the $haystack string starts with the substring $needle
+ *
+ * @param string $haystack
+ * @param string|string[] $needle
+ *
+ * @return bool
+ */
+ public static function startsWith($haystack, $needle)
+ {
+ $status = false;
+
+ foreach ((array)$needle as $each_needle) {
+ $status = $each_needle === '' || strpos($haystack, $each_needle) === 0;
+ if ($status) {
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check if the $haystack string ends with the substring $needle
+ *
+ * @param string $haystack
+ * @param string|string[] $needle
+ *
+ * @return bool
+ */
+ public static function endsWith($haystack, $needle)
+ {
+ $status = false;
+
+ foreach ((array)$needle as $each_needle) {
+ $status = $each_needle === '' || substr($haystack, -strlen($each_needle)) === $each_needle;
+ if ($status) {
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Check if the $haystack string contains the substring $needle
+ *
+ * @param string $haystack
+ * @param string|string[] $needle
+ *
+ * @return bool
+ */
+ public static function contains($haystack, $needle)
+ {
+ $status = false;
+
+ foreach ((array)$needle as $each_needle) {
+ $status = $each_needle === '' || strpos($haystack, $each_needle) !== false;
+ if ($status) {
+ break;
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Returns the substring of a string up to a specified needle. if not found, return the whole haystack
+ *
+ * @param $haystack
+ * @param $needle
+ *
+ * @return string
+ */
+ public static function substrToString($haystack, $needle)
+ {
+ if (static::contains($haystack, $needle)) {
+ return substr($haystack, 0, strpos($haystack, $needle));
+ }
+
+ return $haystack;
+ }
+
+ /**
+ * Utility method to replace only the first occurrence in a string
+ *
+ * @param $search
+ * @param $replace
+ * @param $subject
+ * @return mixed
+ */
+ public static function replaceFirstOccurrence($search, $replace, $subject)
+ {
+ if (!$search) {
+ return $subject;
+ }
+ $pos = strpos($subject, $search);
+ if ($pos !== false) {
+ $subject = substr_replace($subject, $replace, $pos, strlen($search));
+ }
+ return $subject;
+ }
+
+ /**
+ * Utility method to replace only the last occurrence in a string
+ *
+ * @param $search
+ * @param $replace
+ * @param $subject
+ * @return mixed
+ */
+ public static function replaceLastOccurrence($search, $replace, $subject)
+ {
+ $pos = strrpos($subject, $search);
+
+ if($pos !== false)
+ {
+ $subject = substr_replace($subject, $replace, $pos, strlen($search));
+ }
+
+ return $subject;
+ }
+
+ /**
+ * Merge two objects into one.
+ *
+ * @param object $obj1
+ * @param object $obj2
+ *
+ * @return object
+ */
+ public static function mergeObjects($obj1, $obj2)
+ {
+ return (object)array_merge((array)$obj1, (array)$obj2);
+ }
+
+ /**
+ * Recursive Merge with uniqueness
+ *
+ * @param $array1
+ * @param $array2
+ * @return mixed
+ */
+ public static function arrayMergeRecursiveUnique($array1, $array2)
+ {
+ if (empty($array1)) {
+ // Optimize the base case
+ return $array2;
+ }
+
+ foreach ($array2 as $key => $value) {
+ if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) {
+ $value = static::arrayMergeRecursiveUnique($array1[$key], $value);
+ }
+ $array1[$key] = $value;
+ }
+
+ return $array1;
+ }
+
+ /**
+ * Return the Grav date formats allowed
+ *
+ * @return array
+ */
+ public static function dateFormats()
+ {
+ $now = new DateTime();
+
+ $date_formats = [
+ 'd-m-Y H:i' => 'd-m-Y H:i (e.g. '.$now->format('d-m-Y H:i').')',
+ 'Y-m-d H:i' => 'Y-m-d H:i (e.g. '.$now->format('Y-m-d H:i').')',
+ 'm/d/Y h:i a' => 'm/d/Y h:i a (e.g. '.$now->format('m/d/Y h:i a').')',
+ 'H:i d-m-Y' => 'H:i d-m-Y (e.g. '.$now->format('H:i d-m-Y').')',
+ 'h:i a m/d/Y' => 'h:i a m/d/Y (e.g. '.$now->format('h:i a m/d/Y').')',
+ ];
+ $default_format = Grav::instance()['config']->get('system.pages.dateformat.default');
+ if ($default_format) {
+ $date_formats = array_merge([$default_format => $default_format.' (e.g. '.$now->format($default_format).')'], $date_formats);
+ }
+
+ return $date_formats;
+ }
+
+ /**
+ * Truncate text by number of characters but can cut off words.
+ *
+ * @param string $string
+ * @param int $limit Max number of characters.
+ * @param bool $up_to_break truncate up to breakpoint after char count
+ * @param string $break Break point.
+ * @param string $pad Appended padding to the end of the string.
+ *
+ * @return string
+ */
+ public static function truncate($string, $limit = 150, $up_to_break = false, $break = " ", $pad = "…")
+ {
+ // return with no change if string is shorter than $limit
+ if (mb_strlen($string) <= $limit) {
+ return $string;
+ }
+
+ // is $break present between $limit and the end of the string?
+ if ($up_to_break && false !== ($breakpoint = mb_strpos($string, $break, $limit))) {
+ if ($breakpoint < mb_strlen($string) - 1) {
+ $string = mb_substr($string, 0, $breakpoint) . $break;
+ }
+ } else {
+ $string = mb_substr($string, 0, $limit) . $pad;
+ }
+
+ return $string;
+ }
+
+ /**
+ * Truncate text by number of characters in a "word-safe" manor.
+ *
+ * @param string $string
+ * @param int $limit
+ *
+ * @return string
+ */
+ public static function safeTruncate($string, $limit = 150)
+ {
+ return static::truncate($string, $limit, true);
+ }
+
+
+ /**
+ * Truncate HTML by number of characters. not "word-safe"!
+ *
+ * @param string $text
+ * @param int $length in characters
+ * @param string $ellipsis
+ *
+ * @return string
+ */
+ public static function truncateHtml($text, $length = 100, $ellipsis = '...')
+ {
+ return Truncator::truncateLetters($text, $length, $ellipsis);
+ }
+
+ /**
+ * Truncate HTML by number of characters in a "word-safe" manor.
+ *
+ * @param string $text
+ * @param int $length in words
+ * @param string $ellipsis
+ *
+ * @return string
+ */
+ public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...')
+ {
+ return Truncator::truncateWords($text, $length, $ellipsis);
+ }
+
+ /**
+ * Generate a random string of a given length
+ *
+ * @param int $length
+ *
+ * @return string
+ */
+ public static function generateRandomString($length = 5)
+ {
+ return substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, $length);
+ }
+
+ /**
+ * Provides the ability to download a file to the browser
+ *
+ * @param string $file the full path to the file to be downloaded
+ * @param bool $force_download as opposed to letting browser choose if to download or render
+ * @param int $sec Throttling, try 0.1 for some speed throttling of downloads
+ * @param int $bytes Size of chunks to send in bytes. Default is 1024
+ * @throws \Exception
+ */
+ public static function download($file, $force_download = true, $sec = 0, $bytes = 1024)
+ {
+ if (file_exists($file)) {
+ // fire download event
+ Grav::instance()->fireEvent('onBeforeDownload', new Event(['file' => $file]));
+
+ $file_parts = pathinfo($file);
+ $mimetype = static::getMimeByExtension($file_parts['extension']);
+ $size = filesize($file); // File size
+
+ // clean all buffers
+ while (ob_get_level()) {
+ ob_end_clean();
+ }
+
+ // required for IE, otherwise Content-Disposition may be ignored
+ if (ini_get('zlib.output_compression')) {
+ ini_set('zlib.output_compression', 'Off');
+ }
+
+ header('Content-Type: ' . $mimetype);
+ header('Accept-Ranges: bytes');
+
+ if ($force_download) {
+ // output the regular HTTP headers
+ header('Content-Disposition: attachment; filename="' . $file_parts['basename'] . '"');
+ }
+
+ // multipart-download and download resuming support
+ if (isset($_SERVER['HTTP_RANGE'])) {
+ list($a, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
+ list($range) = explode(',', $range, 2);
+ list($range, $range_end) = explode('-', $range);
+ $range = (int)$range;
+ if (!$range_end) {
+ $range_end = $size - 1;
+ } else {
+ $range_end = (int)$range_end;
+ }
+ $new_length = $range_end - $range + 1;
+ header('HTTP/1.1 206 Partial Content');
+ header("Content-Length: {$new_length}");
+ header("Content-Range: bytes {$range}-{$range_end}/{$size}");
+ } else {
+ $range = 0;
+ $new_length = $size;
+ header('Content-Length: ' . $size);
+
+ if (Grav::instance()['config']->get('system.cache.enabled')) {
+ $expires = Grav::instance()['config']->get('system.pages.expires');
+ if ($expires > 0) {
+ $expires_date = gmdate('D, d M Y H:i:s T', time() + $expires);
+ header('Cache-Control: max-age=' . $expires);
+ header('Expires: ' . $expires_date);
+ header('Pragma: cache');
+ }
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s T', filemtime($file)));
+
+ // Return 304 Not Modified if the file is already cached in the browser
+ if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
+ strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= filemtime($file))
+ {
+ header('HTTP/1.1 304 Not Modified');
+ exit();
+ }
+ }
+ }
+
+ /* output the file itself */
+ $chunksize = $bytes * 8; //you may want to change this
+ $bytes_send = 0;
+
+ $fp = @fopen($file, 'rb');
+ if ($fp) {
+ if ($range) {
+ fseek($fp, $range);
+ }
+ while (!feof($fp) && (!connection_aborted()) && ($bytes_send < $new_length) ) {
+ $buffer = fread($fp, $chunksize);
+ echo($buffer); //echo($buffer); // is also possible
+ flush();
+ usleep($sec * 1000000);
+ $bytes_send += strlen($buffer);
+ }
+ fclose($fp);
+ } else {
+ throw new \RuntimeException('Error - can not open file.');
+ }
+
+ exit;
+ }
+ }
+
+ /**
+ * Return the mimetype based on filename extension
+ *
+ * @param string $extension Extension of file (eg "txt")
+ * @param string $default
+ *
+ * @return string
+ */
+ public static function getMimeByExtension($extension, $default = 'application/octet-stream')
+ {
+ $extension = strtolower($extension);
+
+ // look for some standard types
+ switch ($extension) {
+ case null:
+ return $default;
+ case 'json':
+ return 'application/json';
+ case 'html':
+ return 'text/html';
+ case 'atom':
+ return 'application/atom+xml';
+ case 'rss':
+ return 'application/rss+xml';
+ case 'xml':
+ return 'application/xml';
+ }
+
+ $media_types = Grav::instance()['config']->get('media.types');
+
+ if (isset($media_types[$extension])) {
+ if (isset($media_types[$extension]['mime'])) {
+ return $media_types[$extension]['mime'];
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Return the mimetype based on filename extension
+ *
+ * @param string $mime mime type (eg "text/html")
+ * @param string $default default value
+ *
+ * @return string
+ */
+ public static function getExtensionByMime($mime, $default = 'html')
+ {
+ $mime = strtolower($mime);
+
+ // look for some standard mime types
+ switch ($mime) {
+ case '*/*':
+ case 'text/*':
+ case 'text/html':
+ return 'html';
+ case 'application/json':
+ return 'json';
+ case 'application/atom+xml':
+ return 'atom';
+ case 'application/rss+xml':
+ return 'rss';
+ case 'application/xml':
+ return 'xml';
+ }
+
+ $media_types = (array)Grav::instance()['config']->get('media.types');
+
+ foreach ($media_types as $extension => $type) {
+ if ($extension === 'defaults') {
+ continue;
+ }
+ if (isset($type['mime']) && $type['mime'] === $mime) {
+ return $extension;
+ }
+ }
+
+ return $default;
+ }
+
+ /**
+ * Normalize path by processing relative `.` and `..` syntax and merging path
+ *
+ * @param string $path
+ *
+ * @return string
+ */
+ public static function normalizePath($path)
+ {
+ $root = ($path[0] === '/') ? '/' : '';
+
+ $segments = explode('/', trim($path, '/'));
+ $ret = [];
+ foreach ($segments as $segment) {
+ if (($segment === '.') || $segment === '') {
+ continue;
+ }
+ if ($segment === '..') {
+ array_pop($ret);
+ } else {
+ $ret[] = $segment;
+ }
+ }
+
+ return $root . implode('/', $ret);
+ }
+
+ /**
+ * Check whether a function is disabled in the PHP settings
+ *
+ * @param string $function the name of the function to check
+ *
+ * @return bool
+ */
+ public static function isFunctionDisabled($function)
+ {
+ return in_array($function, explode(',', ini_get('disable_functions')), true);
+ }
+
+ /**
+ * Get the formatted timezones list
+ *
+ * @return array
+ */
+ public static function timezones()
+ {
+ $timezones = \DateTimeZone::listIdentifiers(\DateTimeZone::ALL);
+ $offsets = [];
+ $testDate = new \DateTime;
+
+ foreach ($timezones as $zone) {
+ $tz = new \DateTimeZone($zone);
+ $offsets[$zone] = $tz->getOffset($testDate);
+ }
+
+ asort($offsets);
+
+ $timezone_list = [];
+ foreach ($offsets as $timezone => $offset) {
+ $offset_prefix = $offset < 0 ? '-' : '+';
+ $offset_formatted = gmdate('H:i', abs($offset));
+
+ $pretty_offset = "UTC${offset_prefix}${offset_formatted}";
+
+ $timezone_list[$timezone] = "(${pretty_offset}) ".str_replace('_', ' ', $timezone);
+ }
+
+ return $timezone_list;
+ }
+
+ /**
+ * Recursively filter an array, filtering values by processing them through the $fn function argument
+ *
+ * @param array $source the Array to filter
+ * @param callable $fn the function to pass through each array item
+ *
+ * @return array
+ */
+ public static function arrayFilterRecursive(Array $source, $fn)
+ {
+ $result = [];
+ foreach ($source as $key => $value) {
+ if (is_array($value)) {
+ $result[$key] = static::arrayFilterRecursive($value, $fn);
+ continue;
+ }
+ if ($fn($key, $value)) {
+ $result[$key] = $value; // KEEP
+ continue;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Flatten an array
+ *
+ * @param array $array
+ * @return array
+ */
+ public static function arrayFlatten($array)
+ {
+ $flatten = array();
+ foreach ($array as $key => $inner){
+ if (is_array($inner)) {
+ foreach ($inner as $inner_key => $value) {
+ $flatten[$inner_key] = $value;
+ }
+ } else {
+ $flatten[$key] = $inner;
+ }
+ }
+ return $flatten;
+ }
+
+ /**
+ * Checks if the passed path contains the language code prefix
+ *
+ * @param string $string The path
+ *
+ * @return bool
+ */
+ public static function pathPrefixedByLangCode($string)
+ {
+ if (strlen($string) <= 3) {
+ return false;
+ }
+
+ $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []);
+
+ if ($string[0] === '/' && $string[3] === '/' && in_array(substr($string, 1, 2), $languages_enabled)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the timestamp of a date
+ *
+ * @param string $date a String expressed in the system.pages.dateformat.default format, with fallback to a
+ * strtotime argument
+ * @param string $format a date format to use if possible
+ * @return int the timestamp
+ */
+ public static function date2timestamp($date, $format = null)
+ {
+ $config = Grav::instance()['config'];
+ $dateformat = $format ?: $config->get('system.pages.dateformat.default');
+
+ // try to use DateTime and default format
+ if ($dateformat) {
+ $datetime = DateTime::createFromFormat($dateformat, $date);
+ } else {
+ $datetime = new DateTime($date);
+ }
+
+ // fallback to strtotime() if DateTime approach failed
+ if ($datetime !== false) {
+ return $datetime->getTimestamp();
+ }
+
+ return strtotime($date);
+ }
+
+ /**
+ * @param array $array
+ * @param string $path
+ * @param null $default
+ * @return mixed
+ *
+ * @deprecated Use getDotNotation() method instead
+ */
+ public static function resolve(array $array, $path, $default = null)
+ {
+ return static::getDotNotation($array, $path, $default);
+ }
+
+ /**
+ * Checks if a value is positive
+ *
+ * @param string $value
+ *
+ * @return boolean
+ */
+ public static function isPositive($value)
+ {
+ return in_array($value, [true, 1, '1', 'yes', 'on', 'true'], true);
+ }
+
+ /**
+ * Generates a nonce string to be hashed. Called by self::getNonce()
+ * We removed the IP portion in this version because it causes too many inconsistencies
+ * with reverse proxy setups.
+ *
+ * @param string $action
+ * @param bool $plusOneTick if true, generates the token for the next tick (the next 12 hours)
+ *
+ * @return string the nonce string
+ */
+ private static function generateNonceString($action, $plusOneTick = false)
+ {
+ $username = '';
+ if (isset(Grav::instance()['user'])) {
+ $user = Grav::instance()['user'];
+ $username = $user->username;
+ }
+
+ $token = session_id();
+ $i = self::nonceTick();
+
+ if ($plusOneTick) {
+ $i++;
+ }
+
+ return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . Grav::instance()['config']->get('security.salt'));
+ }
+
+ //Added in version 1.0.8 to ensure that existing nonces are not broken.
+ private static function generateNonceStringOldStyle($action, $plusOneTick = false)
+ {
+ if (isset(Grav::instance()['user'])) {
+ $user = Grav::instance()['user'];
+ $username = $user->username;
+ if (isset($_SERVER['REMOTE_ADDR'])) {
+ $username .= $_SERVER['REMOTE_ADDR'];
+ }
+ } else {
+ $username = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
+ }
+ $token = session_id();
+ $i = self::nonceTick();
+ if ($plusOneTick) {
+ $i++;
+ }
+
+ return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . Grav::instance()['config']->get('security.salt'));
+ }
+
+ /**
+ * Get the time-dependent variable for nonce creation.
+ *
+ * Now a tick lasts a day. Once the day is passed, the nonce is not valid any more. Find a better way
+ * to ensure nonces issued near the end of the day do not expire in that small amount of time
+ *
+ * @return int the time part of the nonce. Changes once every 24 hours
+ */
+ private static function nonceTick()
+ {
+ $secondsInHalfADay = 60 * 60 * 12;
+
+ return (int)ceil(time() / $secondsInHalfADay);
+ }
+
+ /**
+ * Creates a hashed nonce tied to the passed action. Tied to the current user and time. The nonce for a given
+ * action is the same for 12 hours.
+ *
+ * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage)
+ * @param bool $plusOneTick if true, generates the token for the next tick (the next 12 hours)
+ *
+ * @return string the nonce
+ */
+ public static function getNonce($action, $plusOneTick = false)
+ {
+ // Don't regenerate this again if not needed
+ if (isset(static::$nonces[$action])) {
+ return static::$nonces[$action];
+ }
+ $nonce = md5(self::generateNonceString($action, $plusOneTick));
+ static::$nonces[$action] = $nonce;
+
+ return static::$nonces[$action];
+ }
+
+ //Added in version 1.0.8 to ensure that existing nonces are not broken.
+ public static function getNonceOldStyle($action, $plusOneTick = false)
+ {
+ // Don't regenerate this again if not needed
+ if (isset(static::$nonces[$action])) {
+ return static::$nonces[$action];
+ }
+ $nonce = md5(self::generateNonceStringOldStyle($action, $plusOneTick));
+ static::$nonces[$action] = $nonce;
+
+ return static::$nonces[$action];
+ }
+
+ /**
+ * Verify the passed nonce for the give action
+ *
+ * @param string|string[] $nonce the nonce to verify
+ * @param string $action the action to verify the nonce to
+ *
+ * @return boolean verified or not
+ */
+ public static function verifyNonce($nonce, $action)
+ {
+ //Safety check for multiple nonces
+ if (is_array($nonce)) {
+ $nonce = array_shift($nonce);
+ }
+
+ //Nonce generated 0-12 hours ago
+ if ($nonce === self::getNonce($action)) {
+ return true;
+ }
+
+ //Nonce generated 12-24 hours ago
+ $plusOneTick = true;
+ if ($nonce === self::getNonce($action, $plusOneTick)) {
+ return true;
+ }
+
+ //Added in version 1.0.8 to ensure that existing nonces are not broken.
+ //Nonce generated 0-12 hours ago
+ if ($nonce === self::getNonceOldStyle($action)) {
+ return true;
+ }
+
+ //Nonce generated 12-24 hours ago
+ $plusOneTick = true;
+ if ($nonce === self::getNonceOldStyle($action, $plusOneTick)) {
+ return true;
+ }
+
+ //Invalid nonce
+ return false;
+ }
+
+ /**
+ * Simple helper method to get whether or not the admin plugin is active
+ *
+ * @return bool
+ */
+ public static function isAdminPlugin()
+ {
+ if (isset(Grav::instance()['admin'])) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get a portion of an array (passed by reference) with dot-notation key
+ *
+ * @param $array
+ * @param $key
+ * @param null $default
+ * @return mixed
+ */
+ public static function getDotNotation($array, $key, $default = null)
+ {
+ if (null === $key) {
+ return $array;
+ }
+
+ if (isset($array[$key])) {
+ return $array[$key];
+ }
+
+ foreach (explode('.', $key) as $segment) {
+ if (!is_array($array) || !array_key_exists($segment, $array)) {
+ return $default;
+ }
+
+ $array = $array[$segment];
+ }
+
+ return $array;
+ }
+
+ /**
+ * Set portion of array (passed by reference) for a dot-notation key
+ * and set the value
+ *
+ * @param $array
+ * @param $key
+ * @param $value
+ * @param bool $merge
+ *
+ * @return mixed
+ */
+ public static function setDotNotation(&$array, $key, $value, $merge = false)
+ {
+ if (null === $key) {
+ return $array = $value;
+ }
+
+ $keys = explode('.', $key);
+
+ while (count($keys) > 1) {
+ $key = array_shift($keys);
+
+ if ( ! isset($array[$key]) || ! is_array($array[$key]))
+ {
+ $array[$key] = array();
+ }
+
+ $array =& $array[$key];
+ }
+
+ $key = array_shift($keys);
+
+ if (!$merge || !isset($array[$key])) {
+ $array[$key] = $value;
+ } else {
+ $array[$key] = array_merge($array[$key], $value);
+ }
+
+
+ return $array;
+ }
+
+ /**
+ * Utility method to determine if the current OS is Windows
+ *
+ * @return bool
+ */
+ public static function isWindows()
+ {
+ return strncasecmp(PHP_OS, 'WIN', 3) === 0;
+ }
+
+ /**
+ * Utility to determine if the server running PHP is Apache
+ *
+ * @return bool
+ */
+ public static function isApache() {
+ return isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== false;
+ }
+
+ /**
+ * Sort a multidimensional array by another array of ordered keys
+ *
+ * @param array $array
+ * @param array $orderArray
+ * @return array
+ */
+ public static function sortArrayByArray(array $array, array $orderArray)
+ {
+ $ordered = array();
+ foreach ($orderArray as $key) {
+ if (array_key_exists($key, $array)) {
+ $ordered[$key] = $array[$key];
+ unset($array[$key]);
+ }
+ }
+ return $ordered + $array;
+ }
+
+ /**
+ * Sort an array by a key value in the array
+ *
+ * @param $array
+ * @param $array_key
+ * @param int $direction
+ * @param int $sort_flags
+ * @return array
+ */
+ public static function sortArrayByKey($array, $array_key, $direction = SORT_DESC, $sort_flags = SORT_REGULAR )
+ {
+ $output = [];
+
+ if (!is_array($array) || !$array) {
+ return $output;
+ }
+
+ foreach ($array as $key => $row) {
+ $output[$key] = $row[$array_key];
+ }
+
+ array_multisort($output, $direction, $sort_flags, $array);
+
+ return $array;
+ }
+
+ /**
+ * Get's path based on a token
+ *
+ * @param $path
+ * @param Page|null $page
+ * @return string
+ * @throws \RuntimeException
+ */
+ public static function getPagePathFromToken($path, $page = null)
+ {
+ $path_parts = pathinfo($path);
+ $grav = Grav::instance();
+
+ $basename = '';
+ if (isset($path_parts['extension'])) {
+ $basename = '/' . $path_parts['basename'];
+ $path = rtrim($path_parts['dirname'], ':');
+ }
+
+ $regex = '/(@self|self@)|((?:@page|page@):(?:.*))|((?:@theme|theme@):(?:.*))/';
+ preg_match($regex, $path, $matches);
+
+ if ($matches) {
+ if ($matches[1]) {
+ if (null === $page) {
+ throw new \RuntimeException('Page not available for this self@ reference');
+ }
+ } elseif ($matches[2]) {
+ // page@
+ $parts = explode(':', $path);
+ $route = $parts[1];
+ $page = $grav['page']->find($route);
+ } elseif ($matches[3]) {
+ // theme@
+ $parts = explode(':', $path);
+ $route = $parts[1];
+ $theme = str_replace(ROOT_DIR, '', $grav['locator']->findResource("theme://"));
+
+ return $theme . $route . $basename;
+ }
+ } else {
+ return $path . $basename;
+ }
+
+ if (!$page) {
+ throw new \RuntimeException('Page route not found: ' . $path);
+ }
+
+ $path = str_replace($matches[0], rtrim($page->relativePagePath(), '/'), $path);
+
+ return $path . $basename;
+ }
+
+ public static function getUploadLimit()
+ {
+ static $max_size = -1;
+
+ if ($max_size < 0) {
+ $post_max_size = static::parseSize(ini_get('post_max_size'));
+ if ($post_max_size > 0) {
+ $max_size = $post_max_size;
+ }
+
+ $upload_max = static::parseSize(ini_get('upload_max_filesize'));
+ if ($upload_max > 0 && $upload_max < $max_size) {
+ $max_size = $upload_max;
+ }
+ }
+
+ return $max_size;
+ }
+
+ /**
+ * Parse a readable file size and return a value in bytes
+ *
+ * @param $size
+ * @return int
+ */
+ public static function parseSize($size)
+ {
+ $unit = preg_replace('/[^bkmgtpezy]/i', '', $size);
+ $size = preg_replace('/[^0-9\.]/', '', $size);
+ if ($unit) {
+ return (int)($size * pow(1024, stripos('bkmgtpezy', $unit[0])));
+ }
+
+ return (int)$size;
+ }
+
+ /**
+ * Multibyte-safe Parse URL function
+ *
+ * @param $url
+ * @return mixed
+ * @throws \InvalidArgumentException
+ */
+ public static function multibyteParseUrl($url)
+ {
+ $enc_url = preg_replace_callback(
+ '%[^:/@?&=#]+%usD',
+ function ($matches) {
+ return urlencode($matches[0]);
+ },
+ $url
+ );
+
+ $parts = parse_url($enc_url);
+
+ if($parts === false) {
+ throw new \InvalidArgumentException('Malformed URL: ' . $url);
+ }
+
+ foreach($parts as $name => $value) {
+ $parts[$name] = urldecode($value);
+ }
+
+ return $parts;
+ }
+}
diff --git a/system/src/Grav/Console/Cli/BackupCommand.php b/system/src/Grav/Console/Cli/BackupCommand.php
new file mode 100644
index 0000000..4a53869
--- /dev/null
+++ b/system/src/Grav/Console/Cli/BackupCommand.php
@@ -0,0 +1,90 @@
+setName("backup")
+ ->addArgument(
+ 'destination',
+ InputArgument::OPTIONAL,
+ 'Where to store the backup (/backup is default)'
+
+ )
+ ->setDescription("Creates a backup of the Grav instance")
+ ->setHelp('The backup creates a zipped backup. Optionally can be saved in a different destination.');
+
+ $this->source = getcwd();
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->progress = new ProgressBar($this->output);
+ $this->progress->setFormat('Archiving %current% files [%bar%] %elapsed:6s% %memory:6s%');
+
+ Grav::instance()['config']->init();
+
+ $destination = ($this->input->getArgument('destination')) ? $this->input->getArgument('destination') : null;
+ $log = JsonFile::instance(Grav::instance()['locator']->findResource("log://backup.log", true, true));
+ $backup = ZipBackup::backup($destination, [$this, 'output']);
+
+ $log->content([
+ 'time' => time(),
+ 'location' => $backup
+ ]);
+ $log->save();
+
+ $this->output->writeln('');
+ $this->output->writeln('');
+
+ }
+
+ /**
+ * @param $args
+ */
+ public function output($args)
+ {
+ switch ($args['type']) {
+ case 'message':
+ $this->output->writeln($args['message']);
+ break;
+ case 'progress':
+ if ($args['complete']) {
+ $this->progress->finish();
+ } else {
+ $this->progress->advance();
+ }
+ break;
+ }
+ }
+
+}
+
diff --git a/system/src/Grav/Console/Cli/CleanCommand.php b/system/src/Grav/Console/Cli/CleanCommand.php
new file mode 100644
index 0000000..32e2b75
--- /dev/null
+++ b/system/src/Grav/Console/Cli/CleanCommand.php
@@ -0,0 +1,278 @@
+setName("clean")
+ ->setDescription("Handles cleaning chores for Grav distribution")
+ ->setHelp('The clean clean extraneous folders and data');
+ }
+
+ /**
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ *
+ * @return int|null|void
+ */
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $this->setupConsole($input, $output);
+
+ $this->cleanPaths();
+ }
+
+ private function cleanPaths()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('DELETING');
+ $anything = false;
+ foreach ($this->paths_to_remove as $path) {
+ $path = ROOT_DIR . $path;
+ if (is_dir($path) && @Folder::delete($path)) {
+ $anything = true;
+ $this->output->writeln('dir: ' . $path);
+ } elseif (is_file($path) && @unlink($path)) {
+ $anything = true;
+ $this->output->writeln('file: ' . $path);
+ }
+ }
+ if (!$anything) {
+ $this->output->writeln('');
+ $this->output->writeln('Nothing to clean...');
+ }
+ }
+
+ /**
+ * Set colors style definition for the formatter.
+ *
+ * @param InputInterface $input
+ * @param OutputInterface $output
+ */
+ public function setupConsole(InputInterface $input, OutputInterface $output)
+ {
+ $this->input = $input;
+ $this->output = $output;
+
+ $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white'));
+ $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, ['bold']));
+ $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, ['bold']));
+ $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, ['bold']));
+ $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, ['bold']));
+ $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, ['bold']));
+ $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, ['bold']));
+ }
+
+}
diff --git a/system/src/Grav/Console/Cli/ClearCacheCommand.php b/system/src/Grav/Console/Cli/ClearCacheCommand.php
new file mode 100644
index 0000000..cb5ffe8
--- /dev/null
+++ b/system/src/Grav/Console/Cli/ClearCacheCommand.php
@@ -0,0 +1,70 @@
+setName('clear-cache')
+ ->setAliases(['clearcache'])
+ ->setDescription('Clears Grav cache')
+ ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches')
+ ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*')
+ ->addOption('images-only', null, InputOption::VALUE_NONE, 'If set will remove only images/*')
+ ->addOption('cache-only', null, InputOption::VALUE_NONE, 'If set will remove only cache/*')
+ ->addOption('tmp-only', null, InputOption::VALUE_NONE, 'If set will remove only tmp/*')
+ ->setHelp('The clear-cache deletes all cache files');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->cleanPaths();
+ }
+
+ /**
+ * loops over the array of paths and deletes the files/folders
+ */
+ private function cleanPaths()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Clearing cache');
+ $this->output->writeln('');
+
+ if ($this->input->getOption('all')) {
+ $remove = 'all';
+ } elseif ($this->input->getOption('assets-only')) {
+ $remove = 'assets-only';
+ } elseif ($this->input->getOption('images-only')) {
+ $remove = 'images-only';
+ } elseif ($this->input->getOption('cache-only')) {
+ $remove = 'cache-only';
+ } elseif ($this->input->getOption('tmp-only')) {
+ $remove = 'tmp-only';
+ } else {
+ $remove = 'standard';
+ }
+
+ foreach (Cache::clearCache($remove) as $result) {
+ $this->output->writeln($result);
+ }
+ }
+}
+
diff --git a/system/src/Grav/Console/Cli/ComposerCommand.php b/system/src/Grav/Console/Cli/ComposerCommand.php
new file mode 100644
index 0000000..4f9c18f
--- /dev/null
+++ b/system/src/Grav/Console/Cli/ComposerCommand.php
@@ -0,0 +1,72 @@
+setName("composer")
+ ->addOption(
+ 'install',
+ 'i',
+ InputOption::VALUE_NONE,
+ 'install the dependencies'
+ )
+ ->addOption(
+ 'update',
+ 'u',
+ InputOption::VALUE_NONE,
+ 'update the dependencies'
+ )
+ ->setDescription("Updates the composer vendor dependencies needed by Grav.")
+ ->setHelp('The composer command updates the composer vendor dependencies needed by Grav');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $action = $this->input->getOption('install') ? 'install' : ($this->input->getOption('update') ? 'update' : 'install');
+
+ if ($this->input->getOption('install')) {
+ $action = 'install';
+ }
+
+ // Updates composer first
+ $this->output->writeln("\nInstalling vendor dependencies");
+ $this->output->writeln($this->composerUpdate(GRAV_ROOT, $action));
+ }
+
+}
diff --git a/system/src/Grav/Console/Cli/InstallCommand.php b/system/src/Grav/Console/Cli/InstallCommand.php
new file mode 100644
index 0000000..ac00256
--- /dev/null
+++ b/system/src/Grav/Console/Cli/InstallCommand.php
@@ -0,0 +1,175 @@
+setName("install")
+ ->addOption(
+ 'symlink',
+ 's',
+ InputOption::VALUE_NONE,
+ 'Symlink the required bits'
+ )
+ ->addArgument(
+ 'destination',
+ InputArgument::OPTIONAL,
+ 'Where to install the required bits (default to current project)'
+ )
+ ->setDescription("Installs the dependencies needed by Grav. Optionally can create symbolic links")
+ ->setHelp('The install command installs the dependencies needed by Grav. Optionally can create symbolic links');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $dependencies_file = '.dependencies';
+ $this->destination = ($this->input->getArgument('destination')) ? $this->input->getArgument('destination') : ROOT_DIR;
+
+ // fix trailing slash
+ $this->destination = rtrim($this->destination, DS) . DS;
+ $this->user_path = $this->destination . USER_PATH;
+ if ($local_config_file = $this->loadLocalConfig()) {
+ $this->output->writeln('Read local config from ' . $local_config_file . '');
+ }
+
+ // Look for dependencies file in ROOT and USER dir
+ if (file_exists($this->user_path . $dependencies_file)) {
+ $this->config = Yaml::parse(file_get_contents($this->user_path . $dependencies_file));
+ } elseif (file_exists($this->destination . $dependencies_file)) {
+ $this->config = Yaml::parse(file_get_contents($this->destination . $dependencies_file));
+ } else {
+ $this->output->writeln('ERROR Missing .dependencies file in user/ folder');
+ if ($this->input->getArgument('destination')) {
+ $this->output->writeln('HINTAre you trying to install a plugin or a theme? Make sure you use bin/gpm install , not bin/grav install. This command is only used to install Grav skeletons.');
+ } else {
+ $this->output->writeln('HINTAre you trying to install Grav? Grav is already installed. You need to run this command only if you download a skeleton from GitHub directly.');
+ }
+
+ return;
+ }
+
+ // If yaml config, process
+ if ($this->config) {
+ if (!$this->input->getOption('symlink')) {
+ // Updates composer first
+ $this->output->writeln("\nInstalling vendor dependencies");
+ $this->output->writeln($this->composerUpdate(GRAV_ROOT, 'install'));
+
+ $this->gitclone();
+ } else {
+ $this->symlink();
+ }
+ } else {
+ $this->output->writeln('ERROR invalid YAML in ' . $dependencies_file);
+ }
+
+
+ }
+
+ /**
+ * Clones from Git
+ */
+ private function gitclone()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Cloning Bits');
+ $this->output->writeln('============');
+ $this->output->writeln('');
+
+ foreach ($this->config['git'] as $repo => $data) {
+ $this->destination = rtrim($this->destination, DS);
+ $path = $this->destination . DS . $data['path'];
+ if (!file_exists($path)) {
+ exec('cd "' . $this->destination . '" && git clone -b ' . $data['branch'] . ' --depth 1 ' . $data['url'] . ' ' . $data['path'], $output, $return);
+
+ if (!$return) {
+ $this->output->writeln('SUCCESS cloned ' . $data['url'] . ' -> ' . $path . '');
+ } else {
+ $this->output->writeln('ERROR cloning ' . $data['url']);
+
+ }
+
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln('' . $path . ' already exists, skipping...');
+ $this->output->writeln('');
+ }
+
+ }
+ }
+
+ /**
+ * Symlinks
+ */
+ private function symlink()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Symlinking Bits');
+ $this->output->writeln('===============');
+ $this->output->writeln('');
+
+ if (!$this->local_config) {
+ $this->output->writeln('No local configuration available, aborting...');
+ $this->output->writeln('');
+ return;
+ }
+
+ exec('cd ' . $this->destination);
+ foreach ($this->config['links'] as $repo => $data) {
+ $from = $this->local_config[$data['scm'] . '_repos'] . $data['src'];
+ $to = $this->destination . $data['path'];
+
+ if (file_exists($from)) {
+ if (!file_exists($to)) {
+ symlink($from, $to);
+ $this->output->writeln('SUCCESS symlinked ' . $data['src'] . ' -> ' . $data['path'] . '');
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln('destination: ' . $to . ' already exists, skipping...');
+ $this->output->writeln('');
+ }
+ } else {
+ $this->output->writeln('source: ' . $from . ' does not exists, skipping...');
+ $this->output->writeln('');
+ }
+
+ }
+ }
+}
diff --git a/system/src/Grav/Console/Cli/NewProjectCommand.php b/system/src/Grav/Console/Cli/NewProjectCommand.php
new file mode 100644
index 0000000..5a8f3d1
--- /dev/null
+++ b/system/src/Grav/Console/Cli/NewProjectCommand.php
@@ -0,0 +1,65 @@
+setName('new-project')
+ ->setAliases(['newproject'])
+ ->addArgument(
+ 'destination',
+ InputArgument::REQUIRED,
+ 'The destination directory of your new Grav project'
+ )
+ ->addOption(
+ 'symlink',
+ 's',
+ InputOption::VALUE_NONE,
+ 'Symlink the required bits'
+ )
+ ->setDescription('Creates a new Grav project with all the dependencies installed')
+ ->setHelp("The new-project command is a combination of the `setup` and `install` commands.\nCreates a new Grav instance and performs the installation of all the required dependencies.");
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $sandboxCommand = $this->getApplication()->find('sandbox');
+ $installCommand = $this->getApplication()->find('install');
+
+ $sandboxArguments = new ArrayInput([
+ 'command' => 'sandbox',
+ 'destination' => $this->input->getArgument('destination'),
+ '-s' => $this->input->getOption('symlink')
+ ]);
+
+ $installArguments = new ArrayInput([
+ 'command' => 'install',
+ 'destination' => $this->input->getArgument('destination'),
+ '-s' => $this->input->getOption('symlink')
+ ]);
+
+ $sandboxCommand->run($sandboxArguments, $this->output);
+ $installCommand->run($installArguments, $this->output);
+
+ }
+}
diff --git a/system/src/Grav/Console/Cli/SandboxCommand.php b/system/src/Grav/Console/Cli/SandboxCommand.php
new file mode 100644
index 0000000..284878e
--- /dev/null
+++ b/system/src/Grav/Console/Cli/SandboxCommand.php
@@ -0,0 +1,304 @@
+ '/.gitignore',
+ '/CHANGELOG.md' => '/CHANGELOG.md',
+ '/LICENSE.txt' => '/LICENSE.txt',
+ '/README.md' => '/README.md',
+ '/CONTRIBUTING.md' => '/CONTRIBUTING.md',
+ '/index.php' => '/index.php',
+ '/composer.json' => '/composer.json',
+ '/bin' => '/bin',
+ '/system' => '/system',
+ '/vendor' => '/vendor',
+ '/webserver-configs' => '/webserver-configs',
+ ];
+
+ /**
+ * @var string
+ */
+
+ protected $default_file = "---\ntitle: HomePage\n---\n# HomePage\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque porttitor eu felis sed ornare. Sed a mauris venenatis, pulvinar velit vel, dictum enim. Phasellus ac rutrum velit. Nunc lorem purus, hendrerit sit amet augue aliquet, iaculis ultricies nisl. Suspendisse tincidunt euismod risus, quis feugiat arcu tincidunt eget. Nulla eros mi, commodo vel ipsum vel, aliquet congue odio. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Pellentesque velit orci, laoreet at adipiscing eu, interdum quis nibh. Nunc a accumsan purus.";
+
+ protected $source;
+ protected $destination;
+
+ /**
+ *
+ */
+ protected function configure()
+ {
+ $this
+ ->setName('sandbox')
+ ->setDescription('Setup of a base Grav system in your webroot, good for development, playing around or starting fresh')
+ ->addArgument(
+ 'destination',
+ InputArgument::REQUIRED,
+ 'The destination directory to symlink into'
+ )
+ ->addOption(
+ 'symlink',
+ 's',
+ InputOption::VALUE_NONE,
+ 'Symlink the base grav system'
+ )
+ ->setHelp("The sandbox command help create a development environment that can optionally use symbolic links to link the core of grav to the git cloned repository.\nGood for development, playing around or starting fresh");
+ $this->source = getcwd();
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->destination = $this->input->getArgument('destination');
+
+ // Symlink the Core Stuff
+ if ($this->input->getOption('symlink')) {
+ // Create Some core stuff if it doesn't exist
+ $this->createDirectories();
+
+ // Loop through the symlink mappings and create the symlinks
+ $this->symlink();
+
+ // Copy the Core STuff
+ } else {
+ // Create Some core stuff if it doesn't exist
+ $this->createDirectories();
+
+ // Loop through the symlink mappings and copy what otherwise would be symlinks
+ $this->copy();
+ }
+
+ $this->pages();
+ $this->initFiles();
+ $this->perms();
+ }
+
+ /**
+ *
+ */
+ private function createDirectories()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Creating Directories');
+ $dirs_created = false;
+
+ if (!file_exists($this->destination)) {
+ mkdir($this->destination, 0777, true);
+ }
+
+ foreach ($this->directories as $dir) {
+ if (!file_exists($this->destination . $dir)) {
+ $dirs_created = true;
+ $this->output->writeln(' ' . $dir . '');
+ mkdir($this->destination . $dir, 0777, true);
+ }
+ }
+
+ if (!$dirs_created) {
+ $this->output->writeln(' Directories already exist');
+ }
+ }
+
+ /**
+ *
+ */
+ private function copy()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Copying Files');
+
+
+ foreach ($this->mappings as $source => $target) {
+ if ((int)$source == $source) {
+ $source = $target;
+ }
+
+ $from = $this->source . $source;
+ $to = $this->destination . $target;
+
+ $this->output->writeln(' ' . $source . '-> ' . $to);
+ @Folder::rcopy($from, $to);
+ }
+ }
+
+ /**
+ *
+ */
+ private function symlink()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Resetting Symbolic Links');
+
+
+ foreach ($this->mappings as $source => $target) {
+ if ((int)$source == $source) {
+ $source = $target;
+ }
+
+ $from = $this->source . $source;
+ $to = $this->destination . $target;
+
+ $this->output->writeln(' ' . $source . '-> ' . $to);
+
+ if (is_dir($to)) {
+ @Folder::delete($to);
+ } else {
+ @unlink($to);
+ }
+ symlink($from, $to);
+ }
+ }
+
+ /**
+ *
+ */
+ private function initFiles()
+ {
+ $this->check();
+
+ $this->output->writeln('');
+ $this->output->writeln('File Initializing');
+ $files_init = false;
+
+ // Copy files if they do not exist
+ foreach ($this->files as $source => $target) {
+ if ((int)$source == $source) {
+ $source = $target;
+ }
+
+ $from = $this->source . $source;
+ $to = $this->destination . $target;
+
+ if (!file_exists($to)) {
+ $files_init = true;
+ copy($from, $to);
+ $this->output->writeln(' ' . $target . '-> Created');
+ }
+ }
+
+ if (!$files_init) {
+ $this->output->writeln(' Files already exist');
+ }
+ }
+
+ /**
+ *
+ */
+ private function pages()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Pages Initializing');
+
+ // get pages files and initialize if no pages exist
+ $pages_dir = $this->destination . '/user/pages';
+ $pages_files = array_diff(scandir($pages_dir), ['..', '.']);
+
+ if (count($pages_files) == 0) {
+ $destination = $this->source . '/user/pages';
+ Folder::rcopy($destination, $pages_dir);
+ $this->output->writeln(' ' . $destination . '-> Created');
+
+ }
+ }
+
+ /**
+ *
+ */
+ private function perms()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('Permissions Initializing');
+
+ $dir_perms = 0755;
+
+ $binaries = glob($this->destination . DS . 'bin' . DS . '*');
+
+ foreach ($binaries as $bin) {
+ chmod($bin, $dir_perms);
+ $this->output->writeln(' bin/' . basename($bin) . ' permissions reset to ' . decoct($dir_perms));
+ }
+
+ $this->output->writeln("");
+ }
+
+ /**
+ *
+ */
+ private function check()
+ {
+ $success = true;
+
+ if (!file_exists($this->destination)) {
+ $this->output->writeln(' file: $this->destination does not exist!');
+ $success = false;
+ }
+
+ foreach ($this->directories as $dir) {
+ if (!file_exists($this->destination . $dir)) {
+ $this->output->writeln(' directory: ' . $dir . ' does not exist!');
+ $success = false;
+ }
+ }
+
+ foreach ($this->mappings as $target => $link) {
+ if (!file_exists($this->destination . $target)) {
+ $this->output->writeln(' mappings: ' . $target . ' does not exist!');
+ $success = false;
+ }
+ }
+
+ if (!$success) {
+ $this->output->writeln('');
+ $this->output->writeln('install should be run with --symlink|--s to symlink first');
+ exit;
+ }
+ }
+}
diff --git a/system/src/Grav/Console/ConsoleCommand.php b/system/src/Grav/Console/ConsoleCommand.php
new file mode 100644
index 0000000..a9c2217
--- /dev/null
+++ b/system/src/Grav/Console/ConsoleCommand.php
@@ -0,0 +1,47 @@
+setupConsole($input, $output);
+ $this->serve();
+ }
+
+ /**
+ *
+ */
+ protected function serve()
+ {
+
+ }
+
+ protected function displayGPMRelease()
+ {
+ $this->output->writeln('');
+ $this->output->writeln('GPM Releases Configuration: ' . ucfirst(Grav::instance()['config']->get('system.gpm.releases')) . '');
+ $this->output->writeln('');
+ }
+
+}
diff --git a/system/src/Grav/Console/ConsoleTrait.php b/system/src/Grav/Console/ConsoleTrait.php
new file mode 100644
index 0000000..b6ab3bf
--- /dev/null
+++ b/system/src/Grav/Console/ConsoleTrait.php
@@ -0,0 +1,132 @@
+set('system.cache.cli_compatibility', true);
+ Grav::instance()['cache'];
+
+ $this->argv = $_SERVER['argv'][0];
+ $this->input = $input;
+ $this->output = $output;
+
+ $this->output->getFormatter()->setStyle('normal', new OutputFormatterStyle('white'));
+ $this->output->getFormatter()->setStyle('yellow', new OutputFormatterStyle('yellow', null, array('bold')));
+ $this->output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold')));
+ $this->output->getFormatter()->setStyle('cyan', new OutputFormatterStyle('cyan', null, array('bold')));
+ $this->output->getFormatter()->setStyle('green', new OutputFormatterStyle('green', null, array('bold')));
+ $this->output->getFormatter()->setStyle('magenta', new OutputFormatterStyle('magenta', null, array('bold')));
+ $this->output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold')));
+ }
+
+ /**
+ * @param $path
+ */
+ public function isGravInstance($path)
+ {
+ if (!file_exists($path)) {
+ $this->output->writeln('');
+ $this->output->writeln("ERROR: Destination doesn't exist:");
+ $this->output->writeln(" $path");
+ $this->output->writeln('');
+ exit;
+ }
+
+ if (!is_dir($path)) {
+ $this->output->writeln('');
+ $this->output->writeln("ERROR: Destination chosen to install is not a directory:");
+ $this->output->writeln(" $path");
+ $this->output->writeln('');
+ exit;
+ }
+
+ if (!file_exists($path . DS . 'index.php') || !file_exists($path . DS . '.dependencies') || !file_exists($path . DS . 'system' . DS . 'config' . DS . 'system.yaml')) {
+ $this->output->writeln('');
+ $this->output->writeln("ERROR: Destination chosen to install does not appear to be a Grav instance:");
+ $this->output->writeln(" $path");
+ $this->output->writeln('');
+ exit;
+ }
+ }
+
+ public function composerUpdate($path, $action = 'install')
+ {
+ $composer = Composer::getComposerExecutor();
+
+ return system($composer . ' --working-dir="'.$path.'" --no-interaction --no-dev --prefer-dist -o '. $action);
+ }
+
+ /**
+ * @param array $all
+ *
+ * @return int
+ * @throws \Exception
+ */
+ public function clearCache($all = [])
+ {
+ if ($all) {
+ $all = ['--all' => true];
+ }
+
+ $command = new ClearCacheCommand();
+ $input = new ArrayInput($all);
+ return $command->run($input, $this->output);
+ }
+
+ /**
+ * Load the local config file
+ *
+ * @return mixed string the local config file name. false if local config does not exist
+ */
+ public function loadLocalConfig()
+ {
+ $home_folder = getenv('HOME') ?: getenv('HOMEDRIVE') . getenv('HOMEPATH');
+ $local_config_file = $home_folder . '/.grav/config';
+
+ if (file_exists($local_config_file)) {
+ $this->local_config = Yaml::parse(file_get_contents($local_config_file));
+ return $local_config_file;
+ }
+
+ return false;
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/DirectInstallCommand.php b/system/src/Grav/Console/Gpm/DirectInstallCommand.php
new file mode 100644
index 0000000..60c5fa8
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/DirectInstallCommand.php
@@ -0,0 +1,267 @@
+setName("direct-install")
+ ->setAliases(['directinstall'])
+ ->addArgument(
+ 'package-file',
+ InputArgument::REQUIRED,
+ 'Installable package local or remote . Can install specific version'
+ )
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addOption(
+ 'destination',
+ 'd',
+ InputOption::VALUE_OPTIONAL,
+ 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',
+ GRAV_ROOT
+ )
+ ->setDescription("Installs Grav, plugin, or theme directly from a file or a URL")
+ ->setHelp('The direct-install command installs Grav, plugin, or theme directly from a file or a URL');
+ }
+
+ /**
+ * @return bool
+ */
+ protected function serve()
+ {
+ // Making sure the destination is usable
+ $this->destination = realpath($this->input->getOption('destination'));
+
+ if (
+ !Installer::isGravInstance($this->destination) ||
+ !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])
+ ) {
+ $this->output->writeln("ERROR: " . Installer::lastErrorMsg());
+ exit;
+ }
+
+
+ $this->all_yes = $this->input->getOption('all-yes');
+
+ $package_file = $this->input->getArgument('package-file');
+
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Are you sure you want to direct-install '.$package_file.' [y|N] ', false);
+
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln("exiting...");
+ $this->output->writeln('');
+ exit;
+ }
+
+ $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
+ $tmp_zip = $tmp_dir . '/Grav-' . uniqid();
+
+ $this->output->writeln("");
+ $this->output->writeln("Preparing to install " . $package_file . "");
+
+
+ if (Response::isRemote($package_file)) {
+ $this->output->write(" |- Downloading package... 0%");
+ try {
+ $zip = GPM::downloadPackage($package_file, $tmp_zip);
+ } catch (\RuntimeException $e) {
+ $this->output->writeln('');
+ $this->output->writeln(" `- ERROR: " . $e->getMessage() . "");
+ $this->output->writeln('');
+ exit;
+ }
+
+ if ($zip) {
+ $this->output->write("\x0D");
+ $this->output->write(" |- Downloading package... 100%");
+ $this->output->writeln('');
+ }
+ } else {
+ $this->output->write(" |- Copying package... 0%");
+ $zip = GPM::copyPackage($package_file, $tmp_zip);
+ if ($zip) {
+ $this->output->write("\x0D");
+ $this->output->write(" |- Copying package... 100%");
+ $this->output->writeln('');
+ }
+ }
+
+ if (file_exists($zip)) {
+ $tmp_source = $tmp_dir . '/Grav-' . uniqid();
+
+ $this->output->write(" |- Extracting package... ");
+ $extracted = Installer::unZip($zip, $tmp_source);
+
+ if (!$extracted) {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Extracting package... failed");
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+ }
+
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Extracting package... ok");
+
+
+ $type = GPM::getPackageType($extracted);
+
+ if (!$type) {
+ $this->output->writeln(" '- ERROR: Not a valid Grav package");
+ $this->output->writeln('');
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+ }
+
+ $blueprint = GPM::getBlueprints($extracted);
+ if ($blueprint) {
+ if (isset($blueprint['dependencies'])) {
+ $depencencies = [];
+ foreach ($blueprint['dependencies'] as $dependency) {
+ if (is_array($dependency)){
+ if (isset($dependency['name'])) {
+ $depencencies[] = $dependency['name'];
+ }
+ if (isset($dependency['github'])) {
+ $depencencies[] = $dependency['github'];
+ }
+ } else {
+ $depencencies[] = $dependency;
+ }
+ }
+ $this->output->writeln(" |- Dependencies found... [" . implode(',', $depencencies) . "]");
+
+ $question = new ConfirmationQuestion(" | '- Dependencies will not be satisfied. Continue ? [y|N] ", false);
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln("exiting...");
+ $this->output->writeln('');
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+ }
+ }
+ }
+
+ if ($type == 'grav') {
+
+ $this->output->write(" |- Checking destination... ");
+ Installer::isValidDestination(GRAV_ROOT . '/system');
+ if (Installer::IS_LINK === Installer::lastErrorCode()) {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... symbolic link");
+ $this->output->writeln(" '- ERROR: symlinks found..." . GRAV_ROOT."");
+ $this->output->writeln('');
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+ }
+
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... ok");
+
+ $this->output->write(" |- Installing package... ");
+ Installer::install($zip, GRAV_ROOT, ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true], $extracted);
+ } else {
+ $name = GPM::getPackageName($extracted);
+
+ if (!$name) {
+ $this->output->writeln("ERROR: Name could not be determined. Please specify with --name|-n");
+ $this->output->writeln('');
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+ }
+
+ $install_path = GPM::getInstallPath($type, $name);
+ $is_update = file_exists($install_path);
+
+ $this->output->write(" |- Checking destination... ");
+
+ Installer::isValidDestination(GRAV_ROOT . DS . $install_path);
+ if (Installer::lastErrorCode() == Installer::IS_LINK) {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... symbolic link");
+ $this->output->writeln(" '- ERROR: symlink found..." . GRAV_ROOT . DS . $install_path . '');
+ $this->output->writeln('');
+ Folder::delete($tmp_source);
+ Folder::delete($tmp_zip);
+ exit;
+
+ } else {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... ok");
+ }
+
+ $this->output->write(" |- Installing package... ");
+
+ Installer::install(
+ $zip,
+ $this->destination,
+ $options = [
+ 'install_path' => $install_path,
+ 'theme' => (($type == 'theme')),
+ 'is_update' => $is_update
+ ],
+ $extracted
+ );
+ }
+
+ Folder::delete($tmp_source);
+
+ $this->output->write("\x0D");
+
+ if(Installer::lastErrorCode()) {
+ $this->output->writeln(" '- " . Installer::lastErrorMsg() . "");
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln(" |- Installing package... ok");
+ $this->output->writeln(" '- Success! ");
+ $this->output->writeln('');
+ }
+
+ } else {
+ $this->output->writeln(" '- ERROR: ZIP package could not be found");
+ }
+
+ Folder::delete($tmp_zip);
+
+ // clear cache after successful upgrade
+ $this->clearCache();
+
+ return true;
+
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php
new file mode 100644
index 0000000..8c45706
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/IndexCommand.php
@@ -0,0 +1,272 @@
+setName("index")
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force re-fetching the data from remote'
+ )
+ ->addOption(
+ 'filter',
+ 'F',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'Allows to limit the results based on one or multiple filters input. This can be either portion of a name/slug or a regex'
+ )
+ ->addOption(
+ 'themes-only',
+ 'T',
+ InputOption::VALUE_NONE,
+ 'Filters the results to only Themes'
+ )
+ ->addOption(
+ 'plugins-only',
+ 'P',
+ InputOption::VALUE_NONE,
+ 'Filters the results to only Plugins'
+ )
+ ->addOption(
+ 'updates-only',
+ 'U',
+ InputOption::VALUE_NONE,
+ 'Filters the results to Updatable Themes and Plugins only'
+ )
+ ->addOption(
+ 'installed-only',
+ 'I',
+ InputOption::VALUE_NONE,
+ 'Filters the results to only the Themes and Plugins you have installed'
+ )
+ ->addOption(
+ 'sort',
+ 's',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'Allows to sort (ASC) the results based on one or multiple keys. SORT can be either "name", "slug", "author", "date"',
+ ['date']
+ )
+ ->addOption(
+ 'desc',
+ 'D',
+ InputOption::VALUE_NONE,
+ 'Reverses the order of the output.'
+ )
+ ->setDescription("Lists the plugins and themes available for installation")
+ ->setHelp('The index command lists the plugins and themes available for installation')
+ ;
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->options = $this->input->getOptions();
+ $this->gpm = new GPM($this->options['force']);
+ $this->displayGPMRelease();
+ $this->data = $this->gpm->getRepository();
+
+ $data = $this->filter($this->data);
+
+ $climate = new CLImate;
+ $climate->extend('Grav\Console\TerminalObjects\Table');
+
+ if (!$data) {
+ $this->output->writeln('No data was found in the GPM repository stored locally.');
+ $this->output->writeln('Please try clearing cache and running the bin/gpm index -f command again');
+ $this->output->writeln('If this doesn\'t work try tweaking your GPM system settings.');
+ $this->output->writeln('');
+ $this->output->writeln('For more help go to:');
+ $this->output->writeln(' -> https://learn.getgrav.org/troubleshooting/common-problems#cannot-connect-to-the-gpm');
+
+ die;
+ }
+
+ foreach ($data as $type => $packages) {
+ $this->output->writeln("" . strtoupper($type) . " [ " . count($packages) . " ]");
+ $packages = $this->sort($packages);
+
+ if (!empty($packages)) {
+
+ $table = [];
+ $index = 0;
+
+ foreach ($packages as $slug => $package) {
+ $row = [
+ 'Count' => $index++ + 1,
+ 'Name' => "" . Utils::truncate($package->name, 20, false, ' ', '...') . " ",
+ 'Slug' => $slug,
+ 'Version'=> $this->version($package),
+ 'Installed' => $this->installed($package)
+ ];
+ $table[] = $row;
+ }
+
+ $climate->table($table);
+ }
+
+ $this->output->writeln('');
+ }
+
+ $this->output->writeln('You can either get more informations about a package by typing:');
+ $this->output->writeln(' ' . $this->argv . ' info ');
+ $this->output->writeln('');
+ $this->output->writeln('Or you can install a package by typing:');
+ $this->output->writeln(' ' . $this->argv . ' install ');
+ $this->output->writeln('');
+ }
+
+ /**
+ * @param $package
+ *
+ * @return string
+ */
+ private function version($package)
+ {
+ $list = $this->gpm->{'getUpdatable' . ucfirst($package->package_type)}();
+ $package = isset($list[$package->slug]) ? $list[$package->slug] : $package;
+ $type = ucfirst(preg_replace("/s$/", '', $package->package_type));
+ $updatable = $this->gpm->{'is' . $type . 'Updatable'}($package->slug);
+ $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug);
+ $local = $this->gpm->{'getInstalled' . $type}($package->slug);
+
+ if (!$installed || !$updatable) {
+ $version = $installed ? $local->version : $package->version;
+ return "v" . $version . "";
+ }
+
+ if ($updatable) {
+ return "v" . $package->version . "-> v" . $package->available . "";
+ }
+
+ return '';
+ }
+
+ /**
+ * @param $package
+ *
+ * @return string
+ */
+ private function installed($package)
+ {
+ $package = isset($list[$package->slug]) ? $list[$package->slug] : $package;
+ $type = ucfirst(preg_replace("/s$/", '', $package->package_type));
+ $installed = $this->gpm->{'is' . $type . 'Installed'}($package->slug);
+
+ return !$installed ? 'not installed' : 'installed';
+ }
+
+ /**
+ * @param $data
+ *
+ * @return mixed
+ */
+ public function filter($data)
+ {
+ // filtering and sorting
+ if ($this->options['plugins-only']) {
+ unset($data['themes']);
+ }
+ if ($this->options['themes-only']) {
+ unset($data['plugins']);
+ }
+
+ $filter = [
+ $this->options['filter'],
+ $this->options['installed-only'],
+ $this->options['updates-only'],
+ $this->options['desc']
+ ];
+
+ if (count(array_filter($filter))) {
+ foreach ($data as $type => $packages) {
+ foreach ($packages as $slug => $package) {
+ $filter = true;
+
+ // Filtering by string
+ if ($this->options['filter']) {
+ $filter = preg_grep('/(' . (implode('|', $this->options['filter'])) . ')/i', [$slug, $package->name]);
+ }
+
+ // Filtering updatables only
+ if ($this->options['installed-only'] && $filter) {
+ $method = ucfirst(preg_replace("/s$/", '', $package->package_type));
+ $filter = $this->gpm->{'is' . $method . 'Installed'}($package->slug);
+ }
+
+ // Filtering updatables only
+ if ($this->options['updates-only'] && $filter) {
+ $method = ucfirst(preg_replace("/s$/", '', $package->package_type));
+ $filter = $this->gpm->{'is' . $method . 'Updatable'}($package->slug);
+ }
+
+ if (!$filter) {
+ unset($data[$type][$slug]);
+ }
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param $packages
+ */
+ public function sort($packages)
+ {
+ foreach ($this->options['sort'] as $key) {
+ $packages = $packages->sort(function ($a, $b) use ($key) {
+ switch ($key) {
+ case 'author':
+ return strcmp($a->{$key}['name'], $b->{$key}['name']);
+ break;
+ default:
+ return strcmp($a->$key, $b->$key);
+ }
+ }, $this->options['desc'] ? true : false);
+ }
+
+ return $packages;
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php
new file mode 100644
index 0000000..8c1d50e
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/InfoCommand.php
@@ -0,0 +1,181 @@
+setName("info")
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force fetching the new data remotely'
+ )
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addArgument(
+ 'package',
+ InputArgument::REQUIRED,
+ 'The package of which more informations are desired. Use the "index" command for a list of packages'
+ )
+ ->setDescription("Shows more informations about a package")
+ ->setHelp('The info shows more informations about a package');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->gpm = new GPM($this->input->getOption('force'));
+
+ $this->all_yes = $this->input->getOption('all-yes');
+
+ $this->displayGPMRelease();
+
+ $foundPackage = $this->gpm->findPackage($this->input->getArgument('package'));
+
+ if (!$foundPackage) {
+ $this->output->writeln("The package '" . $this->input->getArgument('package') . "' was not found in the Grav repository.");
+ $this->output->writeln('');
+ $this->output->writeln("You can list all the available packages by typing:");
+ $this->output->writeln(" " . $this->argv . " index");
+ $this->output->writeln('');
+ exit;
+ }
+
+ $this->output->writeln("Found package '" . $this->input->getArgument('package') . "' under the '" . ucfirst($foundPackage->package_type) . "' section");
+ $this->output->writeln('');
+ $this->output->writeln("" . $foundPackage->name . " [" . $foundPackage->slug . "]");
+ $this->output->writeln(str_repeat('-', strlen($foundPackage->name) + strlen($foundPackage->slug) + 3));
+ $this->output->writeln("" . strip_tags($foundPackage->description_plain) . "");
+ $this->output->writeln('');
+
+ $packageURL = '';
+ if (isset($foundPackage->author['url'])) {
+ $packageURL = '<' . $foundPackage->author['url'] . '>';
+ }
+
+ $this->output->writeln("" . str_pad("Author",
+ 12) . ": " . $foundPackage->author['name'] . ' <' . $foundPackage->author['email'] . '> ' . $packageURL);
+
+ foreach ([
+ 'version',
+ 'keywords',
+ 'date',
+ 'homepage',
+ 'demo',
+ 'docs',
+ 'guide',
+ 'repository',
+ 'bugs',
+ 'zipball_url',
+ 'license'
+ ] as $info) {
+ if (isset($foundPackage->$info)) {
+ $name = ucfirst($info);
+ $data = $foundPackage->$info;
+
+ if ($info == 'zipball_url') {
+ $name = "Download";
+ }
+
+ if ($info == 'date') {
+ $name = "Last Update";
+ $data = date('D, j M Y, H:i:s, P ', strtotime('2014-09-16T00:07:16Z'));
+ }
+
+ $name = str_pad($name, 12);
+ $this->output->writeln("" . $name . ": " . $data);
+ }
+ }
+
+ $type = rtrim($foundPackage->package_type, 's');
+ $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
+ $installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug);
+
+ // display current version if installed and different
+ if ($installed && $updatable) {
+ $local = $this->gpm->{'getInstalled'. $type}($foundPackage->slug);
+ $this->output->writeln('');
+ $this->output->writeln("Currently installed version: " . $local->version . "");
+ $this->output->writeln('');
+ }
+
+ // display changelog information
+ $questionHelper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Would you like to read the changelog? [y|N] ",
+ false);
+ $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ $changelog = $foundPackage->changelog;
+
+ $this->output->writeln("");
+ foreach ($changelog as $version => $log) {
+ $title = $version . ' [' . $log['date'] . ']';
+ $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) {
+ return "\n" . ucfirst($match[1]) . ":";
+ }, $log['content']);
+
+ $this->output->writeln(''.$title.'');
+ $this->output->writeln(str_repeat('-', strlen($title)));
+ $this->output->writeln($content);
+ $this->output->writeln("");
+
+ $question = new ConfirmationQuestion("Press [ENTER] to continue or [q] to quit ", true);
+ $answer = $this->all_yes ? false : $questionHelper->ask($this->input, $this->output, $question);
+ if (!$answer) {
+ break;
+ }
+ $this->output->writeln("");
+ }
+ }
+
+ $this->output->writeln('');
+
+ if ($installed && $updatable) {
+ $this->output->writeln("You can update this package by typing:");
+ $this->output->writeln(" " . $this->argv . " update" . $foundPackage->slug . "");
+ } else {
+ $this->output->writeln("You can install this package by typing:");
+ $this->output->writeln(" " . $this->argv . " install" . $foundPackage->slug . "");
+ }
+
+ $this->output->writeln('');
+
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/InstallCommand.php b/system/src/Grav/Console/Gpm/InstallCommand.php
new file mode 100644
index 0000000..d7e932e
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/InstallCommand.php
@@ -0,0 +1,693 @@
+setName("install")
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force re-fetching the data from remote'
+ )
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addOption(
+ 'destination',
+ 'd',
+ InputOption::VALUE_OPTIONAL,
+ 'The destination where the package should be installed at. By default this would be where the grav instance has been launched from',
+ GRAV_ROOT
+ )
+ ->addArgument(
+ 'package',
+ InputArgument::IS_ARRAY | InputArgument::REQUIRED,
+ 'Package(s) to install. Use "bin/gpm index" to list packages. Use "bin/gpm direct-install" to install a specific version'
+ )
+ ->setDescription("Performs the installation of plugins and themes")
+ ->setHelp('The install command allows to install plugins and themes');
+ }
+
+ /**
+ * Allows to set the GPM object, used for testing the class
+ *
+ * @param $gpm
+ */
+ public function setGpm($gpm)
+ {
+ $this->gpm = $gpm;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function serve()
+ {
+ $this->gpm = new GPM($this->input->getOption('force'));
+
+ $this->all_yes = $this->input->getOption('all-yes');
+
+ $this->displayGPMRelease();
+
+ $this->destination = realpath($this->input->getOption('destination'));
+
+ $packages = array_map('strtolower', $this->input->getArgument('package'));
+ $this->data = $this->gpm->findPackages($packages);
+ $this->loadLocalConfig();
+
+ if (
+ !Installer::isGravInstance($this->destination) ||
+ !Installer::isValidDestination($this->destination, [Installer::EXISTS, Installer::IS_LINK])
+ ) {
+ $this->output->writeln("ERROR: " . Installer::lastErrorMsg());
+ exit;
+ }
+
+ $this->output->writeln('');
+
+ if (!$this->data['total']) {
+ $this->output->writeln("Nothing to install.");
+ $this->output->writeln('');
+ exit;
+ }
+
+ if (count($this->data['not_found'])) {
+ $this->output->writeln("These packages were not found on Grav: " . implode(', ',
+ array_keys($this->data['not_found'])) . "");
+ }
+
+ unset($this->data['not_found']);
+ unset($this->data['total']);
+
+
+ if (isset($this->local_config)) {
+ // Symlinks available, ask if Grav should use them
+ $this->use_symlinks = false;
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Should Grav use the symlinks if available? [y|N] ', false);
+
+ $answer = $this->all_yes ? false : $helper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ $this->use_symlinks = true;
+ }
+
+
+ }
+
+ $this->output->writeln('');
+
+ try {
+ $dependencies = $this->gpm->getDependencies($packages);
+ } catch (\Exception $e) {
+ //Error out if there are incompatible packages requirements and tell which ones, and what to do
+ //Error out if there is any error in parsing the dependencies and their versions, and tell which one is broken
+ $this->output->writeln("" . $e->getMessage() . "");
+ return false;
+ }
+
+ if ($dependencies) {
+ try {
+ $this->installDependencies($dependencies, 'install', "The following dependencies need to be installed...");
+ $this->installDependencies($dependencies, 'update', "The following dependencies need to be updated...");
+ $this->installDependencies($dependencies, 'ignore', "The following dependencies can be updated as there is a newer version, but it's not mandatory...", false);
+ } catch (\Exception $e) {
+ $this->output->writeln("Installation aborted");
+ return false;
+ }
+
+ $this->output->writeln("Dependencies are OK");
+ $this->output->writeln("");
+ }
+
+
+ //We're done installing dependencies. Install the actual packages
+ foreach ($this->data as $data) {
+ foreach ($data as $package_name => $package) {
+ if (array_key_exists($package_name, $dependencies)) {
+ $this->output->writeln("Package " . $package_name . " already installed as dependency");
+ } else {
+ $is_valid_destination = Installer::isValidDestination($this->destination . DS . $package->install_path);
+ if ($is_valid_destination || Installer::lastErrorCode() == Installer::NOT_FOUND) {
+ $this->processPackage($package, false);
+ } else {
+ if (Installer::lastErrorCode() == Installer::EXISTS) {
+
+ try {
+ $this->askConfirmationIfMajorVersionUpdated($package);
+ $this->gpm->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package->slug, $package->available, array_keys($data));
+ } catch (\Exception $e) {
+ $this->output->writeln("" . $e->getMessage() . "");
+ return false;
+ }
+
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("The package $package_name is already installed, overwrite? [y|N] ", false);
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ $is_update = true;
+ $this->processPackage($package, $is_update);
+ } else {
+ $this->output->writeln("Package " . $package_name . " not overwritten");
+ }
+ } else {
+ if (Installer::lastErrorCode() == Installer::IS_LINK) {
+ $this->output->writeln("Cannot overwrite existing symlink for $package_name");
+ $this->output->writeln("");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (count($this->demo_processing) > 0) {
+ foreach ($this->demo_processing as $package) {
+ $this->installDemoContent($package);
+ }
+ }
+
+ // clear cache after successful upgrade
+ $this->clearCache();
+
+ return true;
+ }
+
+ /**
+ * If the package is updated from an older major release, show warning and ask confirmation
+ *
+ * @param $package
+ */
+ public function askConfirmationIfMajorVersionUpdated($package)
+ {
+ $helper = $this->getHelper('question');
+ $package_name = $package->name;
+ $new_version = $package->available ? $package->available : $this->gpm->getLatestVersionOfPackage($package->slug);
+ $old_version = $package->version;
+
+ $major_version_changed = explode('.', $new_version)[0] !== explode('.', $old_version)[0];
+
+ if ($major_version_changed) {
+ if ($this->all_yes) {
+ $this->output->writeln("The package $package_name will be updated to a new major version $new_version, from $old_version");
+ return;
+ }
+
+ $question = new ConfirmationQuestion("The package $package_name will be updated to a new major version $new_version, from $old_version. Be sure to read what changed with the new major release. Continue? [y|N] ", false);
+
+ if (!$helper->ask($this->input, $this->output, $question)) {
+ $this->output->writeln("Package " . $package_name . " not updated");
+ exit;
+ }
+ }
+ }
+
+ /**
+ * Given a $dependencies list, filters their type according to $type and
+ * shows $message prior to listing them to the user. Then asks the user a confirmation prior
+ * to installing them.
+ *
+ * @param array $dependencies The dependencies array
+ * @param string $type The type of dependency to show: install, update, ignore
+ * @param string $message A message to be shown prior to listing the dependencies
+ * @param bool $required A flag that determines if the installation is required or optional
+ *
+ * @throws \Exception
+ */
+ public function installDependencies($dependencies, $type, $message, $required = true)
+ {
+ $packages = array_filter($dependencies, function ($action) use ($type) { return $action === $type; });
+ if (count($packages) > 0) {
+ $this->output->writeln($message);
+
+ foreach ($packages as $dependencyName => $dependencyVersion) {
+ $this->output->writeln(" |- Package " . $dependencyName . "");
+ }
+
+ $this->output->writeln("");
+
+ $helper = $this->getHelper('question');
+
+ if ($type == 'install') {
+ $questionAction = 'Install';
+ } else {
+ $questionAction = 'Update';
+ }
+
+ if (count($packages) == 1) {
+ $questionArticle = 'this';
+ } else {
+ $questionArticle = 'these';
+ }
+
+ if (count($packages) == 1) {
+ $questionNoun = 'package';
+ } else {
+ $questionNoun = 'packages';
+ }
+
+ $question = new ConfirmationQuestion("$questionAction $questionArticle $questionNoun? [Y|n] ", true);
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ foreach ($packages as $dependencyName => $dependencyVersion) {
+ $package = $this->gpm->findPackage($dependencyName);
+ $this->processPackage($package, ($type == 'update') ? true : false);
+ }
+ $this->output->writeln('');
+ } else {
+ if ($required) {
+ throw new \Exception();
+ }
+ }
+ }
+ }
+
+ /**
+ * @param $package
+ * @param bool $is_update True if the package is an update
+ */
+ private function processPackage($package, $is_update = false)
+ {
+ if (!$package) {
+ $this->output->writeln("Package not found on the GPM! ");
+ $this->output->writeln('');
+ return;
+ }
+
+ $symlink = false;
+ if ($this->use_symlinks) {
+ if ($this->getSymlinkSource($package) || !isset($package->version)) {
+ $symlink = true;
+ }
+ }
+
+ $symlink ? $this->processSymlink($package) : $this->processGpm($package, $is_update);
+
+ $this->processDemo($package);
+ }
+
+ /**
+ * Add package to the queue to process the demo content, if demo content exists
+ *
+ * @param $package
+ */
+ private function processDemo($package)
+ {
+ $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';
+ if (file_exists($demo_dir)) {
+ $this->demo_processing[] = $package;
+ }
+ }
+
+ /**
+ * Prompt to install the demo content of a package
+ *
+ * @param $package
+ */
+ private function installDemoContent($package)
+ {
+ $demo_dir = $this->destination . DS . $package->install_path . DS . '_demo';
+
+ if (file_exists($demo_dir)) {
+ $dest_dir = $this->destination . DS . 'user';
+ $pages_dir = $dest_dir . DS . 'pages';
+
+ // Demo content exists, prompt to install it.
+ $this->output->writeln("Attention: " . $package->name . " contains demo content");
+ $helper = $this->getHelper('question');
+ $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false);
+
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln(" '- Skipped! ");
+ $this->output->writeln('');
+
+ return;
+ }
+
+ // if pages folder exists in demo
+ if (file_exists($demo_dir . DS . 'pages')) {
+ $pages_backup = 'pages.' . date('m-d-Y-H-i-s');
+ $question = new ConfirmationQuestion('This will backup your current `user/pages` folder to `user/' . $pages_backup . '`, continue? [y|N]', false);
+ $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln(" '- Skipped! ");
+ $this->output->writeln('');
+
+ return;
+ }
+
+ // backup current pages folder
+ if (file_exists($dest_dir)) {
+ if (rename($pages_dir, $dest_dir . DS . $pages_backup)) {
+ $this->output->writeln(" |- Backing up pages... ok");
+ } else {
+ $this->output->writeln(" |- Backing up pages... failed");
+ }
+ }
+ }
+
+ // Confirmation received, copy over the data
+ $this->output->writeln(" |- Installing demo content... ok ");
+ Folder::rcopy($demo_dir, $dest_dir);
+ $this->output->writeln(" '- Success! ");
+ $this->output->writeln('');
+ }
+ }
+
+ /**
+ * @param $package
+ *
+ * @return array|bool
+ */
+ private function getGitRegexMatches($package)
+ {
+ if (isset($package->repository)) {
+ $repository = $package->repository;
+ } else {
+ return false;
+ }
+
+ preg_match(GIT_REGEX, $repository, $matches);
+
+ return $matches;
+ }
+
+ /**
+ * @param $package
+ *
+ * @return bool|string
+ */
+ private function getSymlinkSource($package)
+ {
+ $matches = $this->getGitRegexMatches($package);
+
+ foreach ($this->local_config as $path) {
+ if (Utils::endsWith($matches[2], '.git')) {
+ $repo_dir = preg_replace('/\.git$/', '', $matches[2]);
+ } else {
+ $repo_dir = $matches[2];
+ }
+
+ $from = rtrim($path, '/') . '/' . $repo_dir;
+
+ if (file_exists($from)) {
+ return $from;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param $package
+ */
+ private function processSymlink($package)
+ {
+
+ exec('cd ' . $this->destination);
+
+ $to = $this->destination . DS . $package->install_path;
+ $from = $this->getSymlinkSource($package);
+
+ $this->output->writeln("Preparing to Symlink " . $package->name . "");
+ $this->output->write(" |- Checking source... ");
+
+ if (file_exists($from)) {
+ $this->output->writeln("ok");
+
+ $this->output->write(" |- Checking destination... ");
+ $checks = $this->checkDestination($package);
+
+ if (!$checks) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+ } else {
+ if (file_exists($to)) {
+ $this->output->writeln(" '- Symlink cannot overwrite an existing package, please remove first");
+ $this->output->writeln('');
+ } else {
+ symlink($from, $to);
+
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Symlinking package... ok ");
+ $this->output->writeln(" '- Success! ");
+ $this->output->writeln('');
+ }
+ }
+
+ return;
+ }
+
+ $this->output->writeln("not found!");
+ $this->output->writeln(" '- Installation failed or aborted.");
+ }
+
+ /**
+ * @param $package
+ * @param bool $is_update
+ *
+ * @return bool
+ */
+ private function processGpm($package, $is_update = false)
+ {
+ $version = isset($package->available) ? $package->available : $package->version;
+ $license = Licenses::get($package->slug);
+
+ $this->output->writeln("Preparing to install " . $package->name . " [v" . $version . "]");
+
+ $this->output->write(" |- Downloading package... 0%");
+ $this->file = $this->downloadPackage($package, $license);
+
+ if (!$this->file) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+
+ return false;
+ }
+
+ $this->output->write(" |- Checking destination... ");
+ $checks = $this->checkDestination($package);
+
+ if (!$checks) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+ } else {
+ $this->output->write(" |- Installing package... ");
+ $installation = $this->installPackage($package, $is_update);
+ if (!$installation) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln(" '- Success! ");
+ $this->output->writeln('');
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Package $package
+ *
+ * @param string $license
+ *
+ * @return string
+ */
+ private function downloadPackage($package, $license = null)
+ {
+ $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
+ $this->tmp = $tmp_dir . '/Grav-' . uniqid();
+ $filename = $package->slug . basename($package->zipball_url);
+ $filename = preg_replace('/[\\\\\/:"*?&<>|]+/mi', '-', $filename);
+ $query = '';
+
+ if ($package->premium) {
+ $query = \json_encode(array_merge(
+ $package->premium,
+ [
+ 'slug' => $package->slug,
+ 'filename' => $package->premium['filename'],
+ 'license_key' => $license
+ ]
+ ));
+
+ $query = '?d=' . base64_encode($query);
+ }
+
+ try {
+ $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']);
+ } catch (\Exception $e) {
+ $error = str_replace("\n", "\n | '- ", $e->getMessage());
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Downloading package... error ");
+ $this->output->writeln(" | '- " . $error);
+
+ return false;
+ }
+
+ Folder::mkdir($this->tmp);
+
+ $this->output->write("\x0D");
+ $this->output->write(" |- Downloading package... 100%");
+ $this->output->writeln('');
+
+ file_put_contents($this->tmp . DS . $filename, $output);
+
+ return $this->tmp . DS . $filename;
+ }
+
+ /**
+ * @param $package
+ *
+ * @return bool
+ */
+ private function checkDestination($package)
+ {
+ $question_helper = $this->getHelper('question');
+
+ Installer::isValidDestination($this->destination . DS . $package->install_path);
+
+ if (Installer::lastErrorCode() == Installer::IS_LINK) {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... symbolic link");
+
+ if ($this->all_yes) {
+ $this->output->writeln(" | '- Skipped automatically.");
+
+ return false;
+ }
+
+ $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ",
+ false);
+ $answer = $question_helper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln(" | '- You decided to not delete the symlink automatically.");
+
+ return false;
+ } else {
+ unlink($this->destination . DS . $package->install_path);
+ }
+ }
+
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... ok");
+
+ return true;
+ }
+
+ /**
+ * Install a package
+ *
+ * @param Package $package
+ * @param bool $is_update True if it's an update. False if it's an install
+ *
+ * @return bool
+ */
+ private function installPackage($package, $is_update = false)
+ {
+ $type = $package->package_type;
+
+ Installer::install($this->file, $this->destination, ['install_path' => $package->install_path, 'theme' => (($type == 'themes')), 'is_update' => $is_update]);
+ $error_code = Installer::lastErrorCode();
+ Folder::delete($this->tmp);
+
+ if ($error_code) {
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Installing package... error ");
+ $this->output->writeln(" | '- " . Installer::lastErrorMsg());
+
+ return false;
+ }
+
+ $message = Installer::getMessage();
+ if ($message) {
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- " . $message);
+ }
+
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Installing package... ok ");
+
+ return true;
+ }
+
+ /**
+ * @param $progress
+ */
+ public function progress($progress)
+ {
+ $this->output->write("\x0D");
+ $this->output->write(" |- Downloading package... " . str_pad($progress['percent'], 5, " ",
+ STR_PAD_LEFT) . '%');
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php
new file mode 100644
index 0000000..d406a95
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php
@@ -0,0 +1,262 @@
+setName("self-upgrade")
+ ->setAliases(['selfupgrade', 'selfupdate'])
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force re-fetching the data from remote'
+ )
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addOption(
+ 'overwrite',
+ 'o',
+ InputOption::VALUE_NONE,
+ 'Option to overwrite packages if they already exist'
+ )
+ ->setDescription("Detects and performs an update of Grav itself when available")
+ ->setHelp('The update command updates Grav itself when a new version is available');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->upgrader = new Upgrader($this->input->getOption('force'));
+ $this->all_yes = $this->input->getOption('all-yes');
+ $this->overwrite = $this->input->getOption('overwrite');
+
+ $this->displayGPMRelease();
+
+ $update = $this->upgrader->getAssets()['grav-update'];
+
+ $local = $this->upgrader->getLocalVersion();
+ $remote = $this->upgrader->getRemoteVersion();
+ $release = strftime('%c', strtotime($this->upgrader->getReleaseDate()));
+
+ if (!$this->upgrader->meetsRequirements()) {
+ $this->output->writeln("ATTENTION:");
+ $this->output->writeln(" Grav has increased the minimum PHP requirement.");
+ $this->output->writeln(" You are currently running PHP " . phpversion() . ", but PHP " . $this->upgrader->minPHPVersion() . " is required.");
+ $this->output->writeln(" Additional information: http://getgrav.org/blog/changing-php-requirements");
+ $this->output->writeln("");
+ $this->output->writeln("Selfupgrade aborted.");
+ $this->output->writeln("");
+ exit;
+ }
+
+ if (!$this->overwrite && !$this->upgrader->isUpgradable()) {
+ $this->output->writeln("You are already running the latest version of Grav (v" . $local . ") released on " . $release);
+ exit;
+ }
+
+ Installer::isValidDestination(GRAV_ROOT . '/system');
+ if (Installer::IS_LINK === Installer::lastErrorCode()) {
+ $this->output->writeln("ATTENTION: Grav is symlinked, cannot upgrade, aborting...");
+ $this->output->writeln('');
+ $this->output->writeln("You are currently running a symbolically linked Grav v" . $local . ". Latest available is v". $remote . ".");
+ exit;
+ }
+
+ // not used but preloaded just in case!
+ new ArrayInput([]);
+
+ $questionHelper = $this->getHelper('question');
+
+
+ $this->output->writeln("Grav v$remote is now available [release date: $release].");
+ $this->output->writeln("You are currently using v" . GRAV_VERSION . ".");
+
+ if (!$this->all_yes) {
+ $question = new ConfirmationQuestion("Would you like to read the changelog before proceeding? [y|N] ",
+ false);
+ $answer = $questionHelper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ $changelog = $this->upgrader->getChangelog(GRAV_VERSION);
+
+ $this->output->writeln("");
+ foreach ($changelog as $version => $log) {
+ $title = $version . ' [' . $log['date'] . ']';
+ $content = preg_replace_callback('/\d\.\s\[\]\(#(.*)\)/', function ($match) {
+ return "\n" . ucfirst($match[1]) . ":";
+ }, $log['content']);
+
+ $this->output->writeln($title);
+ $this->output->writeln(str_repeat('-', strlen($title)));
+ $this->output->writeln($content);
+ $this->output->writeln("");
+ }
+
+ $question = new ConfirmationQuestion("Press [ENTER] to continue.", true);
+ $questionHelper->ask($this->input, $this->output, $question);
+ }
+
+ $question = new ConfirmationQuestion("Would you like to upgrade now? [y|N] ", false);
+ $answer = $questionHelper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln("Aborting...");
+
+ exit;
+ }
+ }
+
+ $this->output->writeln("");
+ $this->output->writeln("Preparing to upgrade to v$remote..");
+
+ $this->output->write(" |- Downloading upgrade [" . $this->formatBytes($update['size']) . "]... 0%");
+ $this->file = $this->download($update);
+
+ $this->output->write(" |- Installing upgrade... ");
+ $installation = $this->upgrade();
+
+ if (!$installation) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln(" '- Success! ");
+ $this->output->writeln('');
+ }
+
+ // clear cache after successful upgrade
+ $this->clearCache('all');
+ }
+
+ /**
+ * @param $package
+ *
+ * @return string
+ */
+ private function download($package)
+ {
+ $tmp_dir = Grav::instance()['locator']->findResource('tmp://', true, true);
+ $this->tmp = $tmp_dir . '/Grav-' . uniqid();
+ $output = Response::get($package['download'], [], [$this, 'progress']);
+
+ Folder::mkdir($this->tmp);
+
+ $this->output->write("\x0D");
+ $this->output->write(" |- Downloading upgrade [" . $this->formatBytes($package['size']) . "]... 100%");
+ $this->output->writeln('');
+
+ file_put_contents($this->tmp . DS . $package['name'], $output);
+
+ return $this->tmp . DS . $package['name'];
+ }
+
+ /**
+ * @return bool
+ */
+ private function upgrade()
+ {
+ Installer::install($this->file, GRAV_ROOT,
+ ['sophisticated' => true, 'overwrite' => true, 'ignore_symlinks' => true]);
+ $errorCode = Installer::lastErrorCode();
+ Folder::delete($this->tmp);
+
+ if ($errorCode & (Installer::ZIP_OPEN_ERROR | Installer::ZIP_EXTRACT_ERROR)) {
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Installing upgrade... error ");
+ $this->output->writeln(" | '- " . Installer::lastErrorMsg());
+
+ return false;
+ }
+
+ $this->output->write("\x0D");
+ // extra white spaces to clear out the buffer properly
+ $this->output->writeln(" |- Installing upgrade... ok ");
+
+ return true;
+ }
+
+ /**
+ * @param $progress
+ */
+ public function progress($progress)
+ {
+ $this->output->write("\x0D");
+ $this->output->write(" |- Downloading upgrade [" . $this->formatBytes($progress["filesize"]) . "]... " . str_pad($progress['percent'],
+ 5, " ", STR_PAD_LEFT) . '%');
+ }
+
+ /**
+ * @param $size
+ * @param int $precision
+ *
+ * @return string
+ */
+ public function formatBytes($size, $precision = 2)
+ {
+ $base = log($size) / log(1024);
+ $suffixes = array('', 'k', 'M', 'G', 'T');
+
+ return round(pow(1024, $base - floor($base)), $precision) . $suffixes[(int)floor($base)];
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/UninstallCommand.php b/system/src/Grav/Console/Gpm/UninstallCommand.php
new file mode 100644
index 0000000..34b04ee
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/UninstallCommand.php
@@ -0,0 +1,302 @@
+setName("uninstall")
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addArgument(
+ 'package',
+ InputArgument::IS_ARRAY | InputArgument::REQUIRED,
+ 'The package(s) that are desired to be removed. Use the "index" command for a list of packages'
+ )
+ ->setDescription("Performs the uninstallation of plugins and themes")
+ ->setHelp('The uninstall command allows to uninstall plugins and themes');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->gpm = new GPM();
+
+ $this->all_yes = $this->input->getOption('all-yes');
+
+ $packages = array_map('strtolower', $this->input->getArgument('package'));
+ $this->data = ['total' => 0, 'not_found' => []];
+
+ foreach ($packages as $package) {
+ $plugin = $this->gpm->getInstalledPlugin($package);
+ $theme = $this->gpm->getInstalledTheme($package);
+ if ($plugin || $theme) {
+ $this->data[strtolower($package)] = $plugin ?: $theme;
+ $this->data['total']++;
+ } else {
+ $this->data['not_found'][] = $package;
+ }
+ }
+
+ $this->output->writeln('');
+
+ if (!$this->data['total']) {
+ $this->output->writeln("Nothing to uninstall.");
+ $this->output->writeln('');
+ exit;
+ }
+
+ if (count($this->data['not_found'])) {
+ $this->output->writeln("These packages were not found installed: " . implode(', ',
+ $this->data['not_found']) . "");
+ }
+
+ unset($this->data['not_found']);
+ unset($this->data['total']);
+
+ foreach ($this->data as $slug => $package) {
+ $this->output->writeln("Preparing to uninstall " . $package->name . " [v" . $package->version . "]");
+
+ $this->output->write(" |- Checking destination... ");
+ $checks = $this->checkDestination($slug, $package);
+
+ if (!$checks) {
+ $this->output->writeln(" '- Installation failed or aborted.");
+ $this->output->writeln('');
+ } else {
+ $uninstall = $this->uninstallPackage($slug, $package);
+
+ if (!$uninstall) {
+ $this->output->writeln(" '- Uninstallation failed or aborted.");
+ } else {
+ $this->output->writeln(" '- Success! ");
+ }
+ }
+
+ }
+
+ // clear cache after successful upgrade
+ $this->clearCache();
+ }
+
+
+ /**
+ * @param $slug
+ * @param $package
+ *
+ * @return bool
+ */
+ private function uninstallPackage($slug, $package, $is_dependency = false)
+ {
+ if (!$slug) {
+ return false;
+ }
+
+ //check if there are packages that have this as a dependency. Abort and show list
+ $dependent_packages = $this->gpm->getPackagesThatDependOnPackage($slug);
+ if (count($dependent_packages) > ($is_dependency ? 1 : 0)) {
+ $this->output->writeln('');
+ $this->output->writeln('');
+ $this->output->writeln("Uninstallation failed.");
+ $this->output->writeln('');
+ if (count($dependent_packages) > ($is_dependency ? 2 : 1)) {
+ $this->output->writeln("The installed packages " . implode(', ', $dependent_packages) . " depends on this package. Please remove those first.");
+ } else {
+ $this->output->writeln("The installed package " . implode(', ', $dependent_packages) . " depends on this package. Please remove it first.");
+ }
+
+ $this->output->writeln('');
+ return false;
+ }
+
+ if (isset($package->dependencies)) {
+
+ $dependencies = $package->dependencies;
+
+ if ($is_dependency) {
+ foreach ($dependencies as $key => $dependency) {
+ if (in_array($dependency['name'], $this->dependencies)) {
+ unset($dependencies[$key]);
+ }
+ }
+ } else {
+ if (count($dependencies) > 0) {
+ $this->output->writeln(' `- Dependencies found...');
+ $this->output->writeln('');
+ }
+ }
+
+ $questionHelper = $this->getHelper('question');
+
+ foreach ($dependencies as $dependency) {
+
+ $this->dependencies[] = $dependency['name'];
+
+ if (is_array($dependency)) {
+ $dependency = $dependency['name'];
+ }
+ if ($dependency === 'grav' || $dependency === 'php') {
+ continue;
+ }
+
+ $dependencyPackage = $this->gpm->findPackage($dependency);
+
+ $dependency_exists = $this->packageExists($dependency, $dependencyPackage);
+
+ if ($dependency_exists == Installer::EXISTS) {
+ $this->output->writeln("A dependency on " . $dependencyPackage->name . " [v" . $dependencyPackage->version . "] was found");
+
+ $question = new ConfirmationQuestion(" |- Uninstall " . $dependencyPackage->name . "? [y|N] ", false);
+ $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question);
+
+ if ($answer) {
+ $uninstall = $this->uninstallPackage($dependency, $dependencyPackage, true);
+
+ if (!$uninstall) {
+ $this->output->writeln(" '- Uninstallation failed or aborted.");
+ } else {
+ $this->output->writeln(" '- Success! ");
+
+ }
+ $this->output->writeln('');
+ } else {
+ $this->output->writeln(" '- You decided not to uninstall " . $dependencyPackage->name . ".");
+ $this->output->writeln('');
+ }
+ }
+
+ }
+ }
+
+
+ $locator = Grav::instance()['locator'];
+ $path = $locator->findResource($package->package_type . '://' . $slug);
+ Installer::uninstall($path);
+ $errorCode = Installer::lastErrorCode();
+
+ if ($errorCode && $errorCode !== Installer::IS_LINK && $errorCode !== Installer::EXISTS) {
+ $this->output->writeln(" |- Uninstalling " . $package->name . " package... error ");
+ $this->output->writeln(" | '- " . Installer::lastErrorMsg()."");
+
+ return false;
+ }
+
+ $message = Installer::getMessage();
+ if ($message) {
+ $this->output->writeln(" |- " . $message);
+ }
+
+ if (!$is_dependency && $this->dependencies) {
+ $this->output->writeln("Finishing up uninstalling " . $package->name . "");
+ }
+ $this->output->writeln(" |- Uninstalling " . $package->name . " package... ok ");
+
+
+
+ return true;
+ }
+
+ /**
+ * @param $slug
+ * @param $package
+ *
+ * @return bool
+ */
+
+ private function checkDestination($slug, $package)
+ {
+ $questionHelper = $this->getHelper('question');
+
+ $exists = $this->packageExists($slug, $package);
+
+ if ($exists == Installer::IS_LINK) {
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... symbolic link");
+
+ if ($this->all_yes) {
+ $this->output->writeln(" | '- Skipped automatically.");
+
+ return false;
+ }
+
+ $question = new ConfirmationQuestion(" | '- Destination has been detected as symlink, delete symbolic link first? [y|N] ",
+ false);
+ $answer = $this->all_yes ? true : $questionHelper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln(" | '- You decided not to delete the symlink automatically.");
+
+ return false;
+ }
+ }
+
+ $this->output->write("\x0D");
+ $this->output->writeln(" |- Checking destination... ok");
+
+ return true;
+ }
+
+ /**
+ * Check if package exists
+ *
+ * @param $slug
+ * @param $package
+ * @return int
+ */
+ private function packageExists($slug, $package)
+ {
+ $path = Grav::instance()['locator']->findResource($package->package_type . '://' . $slug);
+ Installer::isValidDestination($path);
+ return Installer::lastErrorCode();
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php
new file mode 100644
index 0000000..06913b1
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/UpdateCommand.php
@@ -0,0 +1,285 @@
+setName("update")
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force re-fetching the data from remote'
+ )
+ ->addOption(
+ 'destination',
+ 'd',
+ InputOption::VALUE_OPTIONAL,
+ 'The grav instance location where the updates should be applied to. By default this would be where the grav cli has been launched from',
+ GRAV_ROOT
+ )
+ ->addOption(
+ 'all-yes',
+ 'y',
+ InputOption::VALUE_NONE,
+ 'Assumes yes (or best approach) instead of prompting'
+ )
+ ->addOption(
+ 'overwrite',
+ 'o',
+ InputOption::VALUE_NONE,
+ 'Option to overwrite packages if they already exist'
+ )
+ ->addOption(
+ 'plugins',
+ 'p',
+ InputOption::VALUE_NONE,
+ 'Update only plugins'
+ )
+ ->addOption(
+ 'themes',
+ 't',
+ InputOption::VALUE_NONE,
+ 'Update only themes'
+ )
+ ->addArgument(
+ 'package',
+ InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
+ 'The package or packages that is desired to update. By default all available updates will be applied.'
+ )
+ ->setDescription("Detects and performs an update of plugins and themes when available")
+ ->setHelp('The update command updates plugins and themes when a new version is available');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->upgrader = new Upgrader($this->input->getOption('force'));
+ $local = $this->upgrader->getLocalVersion();
+ $remote = $this->upgrader->getRemoteVersion();
+ if ($local !== $remote) {
+ $this->output->writeln("WARNING: A new version of Grav is available. You should update Grav before updating plugins and themes. If you continue without updating Grav, some plugins or themes may stop working.");
+ $this->output->writeln("");
+ $questionHelper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Continue with the update process? [Y|n] ", true);
+ $answer = $questionHelper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln("Update aborted. Exiting...");
+ exit;
+ }
+ }
+
+ $this->gpm = new GPM($this->input->getOption('force'));
+
+ $this->all_yes = $this->input->getOption('all-yes');
+ $this->overwrite = $this->input->getOption('overwrite');
+
+ $this->displayGPMRelease();
+
+ $this->destination = realpath($this->input->getOption('destination'));
+
+ if (!Installer::isGravInstance($this->destination)) {
+ $this->output->writeln("ERROR: " . Installer::lastErrorMsg());
+ exit;
+ }
+ if ($this->input->getOption('plugins') === false && $this->input->getOption('themes') === false) {
+ $list_type = ['plugins' => true, 'themes' => true];
+ } else {
+ $list_type['plugins'] = $this->input->getOption('plugins');
+ $list_type['themes'] = $this->input->getOption('themes');
+ }
+
+ if ($this->overwrite) {
+ $this->data = $this->gpm->getInstallable($list_type);
+ $description = " can be overwritten";
+ } else {
+ $this->data = $this->gpm->getUpdatable($list_type);
+ $description = " need updating";
+ }
+
+ $only_packages = array_map('strtolower', $this->input->getArgument('package'));
+
+ if (!$this->overwrite && !$this->data['total']) {
+ $this->output->writeln("Nothing to update.");
+ exit;
+ }
+
+ $this->output->write("Found " . $this->gpm->countInstalled() . " packages installed of which " . $this->data['total'] . "" . $description);
+
+ $limit_to = $this->userInputPackages($only_packages);
+
+ $this->output->writeln('');
+
+ unset($this->data['total']);
+ unset($limit_to['total']);
+
+
+ // updates review
+ $slugs = [];
+
+ $index = 0;
+ foreach ($this->data as $packages) {
+ foreach ($packages as $slug => $package) {
+ if (count($only_packages) && !array_key_exists($slug, $limit_to)) {
+ continue;
+ }
+
+ if (!$package->available) {
+ $package->available = $package->version;
+ }
+
+ $this->output->writeln(
+ // index
+ str_pad($index++ + 1, 2, '0', STR_PAD_LEFT) . ". " .
+ // name
+ "" . str_pad($package->name, 15) . " " .
+ // version
+ "[v" . $package->version . " -> v" . $package->available . "]"
+ );
+ $slugs[] = $slug;
+ }
+ }
+
+ if (!$this->all_yes) {
+ // prompt to continue
+ $this->output->writeln("");
+ $questionHelper = $this->getHelper('question');
+ $question = new ConfirmationQuestion("Continue with the update process? [Y|n] ", true);
+ $answer = $questionHelper->ask($this->input, $this->output, $question);
+
+ if (!$answer) {
+ $this->output->writeln("Update aborted. Exiting...");
+ exit;
+ }
+ }
+
+ // finally update
+ $install_command = $this->getApplication()->find('install');
+
+ $args = new ArrayInput([
+ 'command' => 'install',
+ 'package' => $slugs,
+ '-f' => $this->input->getOption('force'),
+ '-d' => $this->destination,
+ '-y' => true
+ ]);
+ $command_exec = $install_command->run($args, $this->output);
+
+ if ($command_exec != 0) {
+ $this->output->writeln("Error: An error occurred while trying to install the packages");
+ exit;
+ }
+ }
+
+ /**
+ * @param $only_packages
+ *
+ * @return array
+ */
+ private function userInputPackages($only_packages)
+ {
+ $found = ['total' => 0];
+ $ignore = [];
+
+ if (!count($only_packages)) {
+ $this->output->writeln('');
+ } else {
+ foreach ($only_packages as $only_package) {
+ $find = $this->gpm->findPackage($only_package);
+
+ if (!$find || (!$this->overwrite && !$this->gpm->isUpdatable($find->slug))) {
+ $name = isset($find->slug) ? $find->slug : $only_package;
+ $ignore[$name] = $name;
+ } else {
+ $found[$find->slug] = $find;
+ $found['total']++;
+ }
+ }
+
+ if ($found['total']) {
+ $list = $found;
+ unset($list['total']);
+ $list = array_keys($list);
+
+ if ($found['total'] !== $this->data['total']) {
+ $this->output->write(", only " . $found['total'] . " will be updated");
+ }
+
+ $this->output->writeln('');
+ $this->output->writeln("Limiting updates for only " . implode(', ',
+ $list) . "");
+ }
+
+ if (count($ignore)) {
+ $this->output->writeln('');
+ $this->output->writeln("Packages not found or not requiring updates: " . implode(', ',
+ $ignore) . "");
+
+ }
+ }
+
+ return $found;
+ }
+}
diff --git a/system/src/Grav/Console/Gpm/VersionCommand.php b/system/src/Grav/Console/Gpm/VersionCommand.php
new file mode 100644
index 0000000..c828106
--- /dev/null
+++ b/system/src/Grav/Console/Gpm/VersionCommand.php
@@ -0,0 +1,113 @@
+setName("version")
+ ->addOption(
+ 'force',
+ 'f',
+ InputOption::VALUE_NONE,
+ 'Force re-fetching the data from remote'
+ )
+ ->addArgument(
+ 'package',
+ InputArgument::IS_ARRAY | InputArgument::OPTIONAL,
+ 'The package or packages that is desired to know the version of. By default and if not specified this would be grav'
+ )
+ ->setDescription("Shows the version of an installed package. If available also shows pending updates.")
+ ->setHelp('The version command displays the current version of a package installed and, if available, the available version of pending updates');
+ }
+
+ /**
+ * @return int|null|void
+ */
+ protected function serve()
+ {
+ $this->gpm = new GPM($this->input->getOption('force'));
+ $packages = $this->input->getArgument('package');
+
+ $installed = false;
+
+ if (!count($packages)) {
+ $packages = ['grav'];
+ }
+
+ foreach ($packages as $package) {
+ $package = strtolower($package);
+ $name = null;
+ $version = null;
+ $updatable = false;
+
+ if ($package == 'grav') {
+ $name = 'Grav';
+ $version = GRAV_VERSION;
+ $upgrader = new Upgrader();
+
+ if ($upgrader->isUpgradable()) {
+ $updatable = ' [upgradable: v' . $upgrader->getRemoteVersion() . ']';
+ }
+
+ } else {
+ // get currently installed version
+ $locator = \Grav\Common\Grav::instance()['locator'];
+ $blueprints_path = $locator->findResource('plugins://' . $package . DS . 'blueprints.yaml');
+ if (!file_exists($blueprints_path)) { // theme?
+ $blueprints_path = $locator->findResource('themes://' . $package . DS . 'blueprints.yaml');
+ if (!file_exists($blueprints_path)) {
+ continue;
+ }
+ }
+
+ $package_yaml = Yaml::parse(file_get_contents($blueprints_path));
+ $version = $package_yaml['version'];
+
+ if (!$version) {
+ continue;
+ }
+
+ $installed = $this->gpm->findPackage($package);
+ if ($installed) {
+ $name = $installed->name;
+
+ if ($this->gpm->isUpdatable($package)) {
+ $updatable = ' [updatable: v' . $installed->available . ']';
+ }
+ }
+ }
+
+ $updatable = $updatable ?: '';
+
+ if ($installed || $package == 'grav') {
+ $this->output->writeln('You are running ' . $name . ' v' . $version . '' . $updatable);
+ } else {
+ $this->output->writeln('Package ' . $package . ' not found');
+ }
+ }
+ }
+}
diff --git a/system/src/Grav/Console/TerminalObjects/Table.php b/system/src/Grav/Console/TerminalObjects/Table.php
new file mode 100644
index 0000000..c3078a9
--- /dev/null
+++ b/system/src/Grav/Console/TerminalObjects/Table.php
@@ -0,0 +1,29 @@
+column_widths = $this->getColumnWidths();
+ $this->table_width = $this->getWidth();
+ $this->border = $this->getBorder();
+
+ $this->buildHeaderRow();
+
+ foreach ($this->data as $key => $columns) {
+ $this->rows[] = $this->buildRow($columns);
+ }
+
+ $this->rows[] = $this->border;
+
+ return $this->rows;
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/AbstractCache.php b/system/src/Grav/Framework/Cache/AbstractCache.php
new file mode 100644
index 0000000..de749a1
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/AbstractCache.php
@@ -0,0 +1,30 @@
+init($namespace, $defaultLifetime);
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Adapter/ChainCache.php b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php
new file mode 100644
index 0000000..041b676
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Adapter/ChainCache.php
@@ -0,0 +1,197 @@
+caches = array_values($caches);
+ $this->count = count($caches);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doGet($key, $miss)
+ {
+ foreach ($this->caches as $i => $cache) {
+ $value = $cache->doGet($key, $miss);
+ if ($value !== $miss) {
+ while (--$i >= 0) {
+ // Update all the previous caches with missing value.
+ $this->caches[$i]->doSet($key, $value, $this->getDefaultLifetime());
+ }
+
+ return $value;
+ }
+ }
+
+ return $miss;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doSet($key, $value, $ttl)
+ {
+ $success = true;
+ $i = $this->count;
+
+ while ($i--) {
+ $success = $this->caches[$i]->doSet($key, $value, $ttl) && $success;
+ }
+
+ return $success;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doDelete($key)
+ {
+ $success = true;
+ $i = $this->count;
+
+ while ($i--) {
+ $success = $this->caches[$i]->doDelete($key) && $success;
+ }
+
+ return $success;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doClear()
+ {
+ $success = true;
+ $i = $this->count;
+
+ while ($i--) {
+ $success = $this->caches[$i]->doClear() && $success;
+ }
+ return $success;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doGetMultiple($keys, $miss)
+ {
+ $list = [];
+ foreach ($this->caches as $i => $cache) {
+ $list[$i] = $cache->doGetMultiple($keys, $miss);
+
+ $keys = array_diff_key($keys, $list[$i]);
+
+ if (!$keys) {
+ break;
+ }
+ }
+
+ $values = [];
+ // Update all the previous caches with missing values.
+ foreach (array_reverse($list) as $i => $items) {
+ $values += $items;
+ if ($i && $values) {
+ $this->caches[$i-1]->doSetMultiple($values, $this->getDefaultLifetime());
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doSetMultiple($values, $ttl)
+ {
+ $success = true;
+ $i = $this->count;
+
+ while ($i--) {
+ $success = $this->caches[$i]->doSetMultiple($values, $ttl) && $success;
+ }
+
+ return $success;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doDeleteMultiple($keys)
+ {
+ $success = true;
+ $i = $this->count;
+
+ while ($i--) {
+ $success = $this->caches[$i]->doDeleteMultiple($keys) && $success;
+ }
+
+ return $success;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doHas($key)
+ {
+ foreach ($this->caches as $cache) {
+ if ($cache->doHas($key)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php
new file mode 100644
index 0000000..3a69c1f
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Adapter/DoctrineCache.php
@@ -0,0 +1,123 @@
+getNamespace();
+ $namespace && $doctrineCache->setNamespace($namespace);
+
+ $this->driver = $doctrineCache;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doGet($key, $miss)
+ {
+ $value = $this->driver->fetch($key);
+
+ // Doctrine cache does not differentiate between no result and cached 'false'. Make sure that we do.
+ return $value !== false || $this->driver->contains($key) ? $value : $miss;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doSet($key, $value, $ttl)
+ {
+ return $this->driver->save($key, $value, (int) $ttl);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doDelete($key)
+ {
+ return $this->driver->delete($key);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doClear()
+ {
+ return $this->driver->deleteAll();
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doGetMultiple($keys, $miss)
+ {
+ return $this->driver->fetchMultiple($keys);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doSetMultiple($values, $ttl)
+ {
+ return $this->driver->saveMultiple($values, (int) $ttl);
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function doDeleteMultiple($keys)
+ {
+ // TODO: Remove when Doctrine Cache has been updated to support the feature.
+ if (!method_exists($this->driver, 'deleteMultiple')) {
+ $success = true;
+ foreach ($keys as $key) {
+ $success = $this->delete($key) && $success;
+ }
+
+ return $success;
+ }
+
+ return $this->driver->deleteMultiple($keys);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doHas($key)
+ {
+ return $this->driver->contains($key);
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Adapter/FileCache.php b/system/src/Grav/Framework/Cache/Adapter/FileCache.php
new file mode 100644
index 0000000..389b626
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Adapter/FileCache.php
@@ -0,0 +1,210 @@
+getFile($key);
+
+ if (!file_exists($file) || !$h = @fopen($file, 'rb')) {
+ return $miss;
+ }
+
+ if ($now >= (int) $expiresAt = fgets($h)) {
+ fclose($h);
+ @unlink($file);
+ } else {
+ $i = rawurldecode(rtrim(fgets($h)));
+ $value = stream_get_contents($h);
+ fclose($h);
+
+ if ($i === $key) {
+ return unserialize($value);
+ }
+ }
+
+ return $miss;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\CacheException
+ */
+ public function doSet($key, $value, $ttl)
+ {
+ $expiresAt = time() + (int)$ttl;
+
+ $result = $this->write(
+ $this->getFile($key, true),
+ $expiresAt . "\n" . rawurlencode($key) . "\n" . serialize($value),
+ $expiresAt
+ );
+
+ if (!$result && !is_writable($this->directory)) {
+ throw new CacheException(sprintf('Cache directory is not writable (%s)', $this->directory));
+ }
+
+ return $result;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doDelete($key)
+ {
+ $file = $this->getFile($key);
+
+ return (!file_exists($file) || @unlink($file) || !file_exists($file));
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doClear()
+ {
+ $result = true;
+ $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS));
+
+ foreach ($iterator as $file) {
+ $result = ($file->isDir() || @unlink($file) || !file_exists($file)) && $result;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function doHas($key)
+ {
+ $file = $this->getFile($key);
+
+ return file_exists($file) && (@filemtime($file) > time() || $this->doGet($key, null));
+ }
+
+ /**
+ * @param string $key
+ * @param bool $mkdir
+ * @return string
+ */
+ protected function getFile($key, $mkdir = false)
+ {
+ $hash = str_replace('/', '-', base64_encode(hash('sha256', static::class . $key, true)));
+ $dir = $this->directory . $hash[0] . DIRECTORY_SEPARATOR . $hash[1] . DIRECTORY_SEPARATOR;
+
+ if ($mkdir && !file_exists($dir)) {
+ @mkdir($dir, 0777, true);
+ }
+
+ return $dir . substr($hash, 2, 20);
+ }
+
+ /**
+ * @param string $namespace
+ * @param string $directory
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ private function init($namespace, $directory)
+ {
+ if (!isset($directory[0])) {
+ $directory = sys_get_temp_dir() . '/grav-cache';
+ } else {
+ $directory = realpath($directory) ?: $directory;
+ }
+
+ if (isset($namespace[0])) {
+ if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) {
+ throw new InvalidArgumentException(sprintf('Namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0]));
+ }
+ $directory .= DIRECTORY_SEPARATOR . $namespace;
+ }
+
+ if (!file_exists($directory)) {
+ @mkdir($directory, 0777, true);
+ }
+
+ $directory .= DIRECTORY_SEPARATOR;
+ // On Windows the whole path is limited to 258 chars
+ if ('\\' === DIRECTORY_SEPARATOR && strlen($directory) > 234) {
+ throw new InvalidArgumentException(sprintf('Cache folder is too long (%s)', $directory));
+ }
+ $this->directory = $directory;
+ }
+
+ /**
+ * @param string $file
+ * @param string $data
+ * @param int|null $expiresAt
+ * @return bool
+ */
+ private function write($file, $data, $expiresAt = null)
+ {
+ set_error_handler(__CLASS__.'::throwError');
+
+ try {
+ if ($this->tmp === null) {
+ $this->tmp = $this->directory . uniqid('', true);
+ }
+
+ file_put_contents($this->tmp, $data);
+
+ if ($expiresAt !== null) {
+ touch($this->tmp, $expiresAt);
+ }
+
+ return rename($this->tmp, $file);
+ } finally {
+ restore_error_handler();
+ }
+ }
+
+ /**
+ * @internal
+ * @throws \ErrorException
+ */
+ public static function throwError($type, $message, $file, $line)
+ {
+ throw new \ErrorException($message, 0, $type, $file, $line);
+ }
+
+ public function __destruct()
+ {
+ if ($this->tmp !== null && file_exists($this->tmp)) {
+ unlink($this->tmp);
+ }
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php
new file mode 100644
index 0000000..84911fb
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Adapter/MemoryCache.php
@@ -0,0 +1,61 @@
+cache)) {
+ return $miss;
+ }
+
+ return $this->cache[$key];
+ }
+
+ public function doSet($key, $value, $ttl)
+ {
+ $this->cache[$key] = $value;
+
+ return true;
+ }
+
+ public function doDelete($key)
+ {
+ unset($this->cache[$key]);
+
+ return true;
+ }
+
+ public function doClear()
+ {
+ $this->cache = [];
+
+ return true;
+ }
+
+ public function doHas($key)
+ {
+ return array_key_exists($key, $this->cache);
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Adapter/SessionCache.php b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php
new file mode 100644
index 0000000..b1e9e9e
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Adapter/SessionCache.php
@@ -0,0 +1,78 @@
+doGetStored($key);
+
+ return $stored ? $stored[self::VALUE] : $miss;
+ }
+
+ public function doSet($key, $value, $ttl)
+ {
+ $stored = [self::VALUE => $value];
+ if (null !== $ttl) {
+ $stored[self::LIFETIME] = time() + $ttl;
+
+ }
+
+ $_SESSION[$this->getNamespace()][$key] = $stored;
+
+ return true;
+ }
+
+ public function doDelete($key)
+ {
+ unset($_SESSION[$this->getNamespace()][$key]);
+
+ return true;
+ }
+
+ public function doClear()
+ {
+ unset($_SESSION[$this->getNamespace()]);
+
+ return true;
+ }
+
+ public function doHas($key)
+ {
+ return $this->doGetStored($key) !== null;
+ }
+
+ public function getNamespace()
+ {
+ return 'cache-' . parent::getNamespace();
+ }
+
+ protected function doGetStored($key)
+ {
+ $stored = isset($_SESSION[$this->getNamespace()][$key]) ? $_SESSION[$this->getNamespace()][$key] : null;
+
+ if (isset($stored[self::LIFETIME]) && $stored[self::LIFETIME] < time()) {
+ unset($_SESSION[$this->getNamespace()][$key]);
+ $stored = null;
+ }
+
+ return $stored ?: null;
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/CacheInterface.php b/system/src/Grav/Framework/Cache/CacheInterface.php
new file mode 100644
index 0000000..54af40c
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/CacheInterface.php
@@ -0,0 +1,27 @@
+namespace = (string) $namespace;
+ $this->defaultLifetime = $this->convertTtl($defaultLifetime);
+ $this->miss = new \stdClass;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getNamespace()
+ {
+ return $this->namespace;
+ }
+
+ /**
+ * @return int|null
+ */
+ protected function getDefaultLifetime()
+ {
+ return $this->defaultLifetime;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function get($key, $default = null)
+ {
+ $this->validateKey($key);
+
+ $value = $this->doGet($key, $this->miss);
+
+ return $value !== $this->miss ? $value : $default;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function set($key, $value, $ttl = null)
+ {
+ $this->validateKey($key);
+
+ $ttl = $this->convertTtl($ttl);
+
+ // If a negative or zero TTL is provided, the item MUST be deleted from the cache.
+ return null !== $ttl && $ttl <= 0 ? $this->doDelete($key) : $this->doSet($key, $value, $ttl);
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function delete($key)
+ {
+ $this->validateKey($key);
+
+ return $this->doDelete($key);
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function clear()
+ {
+ return $this->doClear();
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function getMultiple($keys, $default = null)
+ {
+ if ($keys instanceof \Traversable) {
+ $keys = iterator_to_array($keys, false);
+ } elseif (!is_array($keys)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cache keys must be array or Traversable, "%s" given',
+ is_object($keys) ? get_class($keys) : gettype($keys)
+ )
+ );
+ }
+
+ if (empty($keys)) {
+ return [];
+ }
+
+ $this->validateKeys($keys);
+ $keys = array_unique($keys);
+ $keys = array_combine($keys, $keys);
+
+ $list = $this->doGetMultiple($keys, $this->miss);
+
+ // Make sure that values are returned in the same order as the keys were given.
+ $values = [];
+ foreach ($keys as $key) {
+ if (!array_key_exists($key, $list) || $list[$key] === $this->miss) {
+ $values[$key] = $default;
+ } else {
+ $values[$key] = $list[$key];
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function setMultiple($values, $ttl = null)
+ {
+ if ($values instanceof \Traversable) {
+ $values = iterator_to_array($values, true);
+ } elseif (!is_array($values)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cache values must be array or Traversable, "%s" given',
+ is_object($values) ? get_class($values) : gettype($values)
+ )
+ );
+ }
+
+ $keys = array_keys($values);
+
+ if (empty($keys)) {
+ return true;
+ }
+
+ $this->validateKeys($keys);
+
+ $ttl = $this->convertTtl($ttl);
+
+ // If a negative or zero TTL is provided, the item MUST be deleted from the cache.
+ return null !== $ttl && $ttl <= 0 ? $this->doDeleteMultiple($keys) : $this->doSetMultiple($values, $ttl);
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function deleteMultiple($keys)
+ {
+ if ($keys instanceof \Traversable) {
+ $keys = iterator_to_array($keys, false);
+ } elseif (!is_array($keys)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cache keys must be array or Traversable, "%s" given',
+ is_object($keys) ? get_class($keys) : gettype($keys)
+ )
+ );
+ }
+
+ if (empty($keys)) {
+ return true;
+ }
+
+ $this->validateKeys($keys);
+
+ return $this->doDeleteMultiple($keys);
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ public function has($key)
+ {
+ $this->validateKey($key);
+
+ return $this->doHas($key);
+ }
+
+ abstract public function doGet($key, $miss);
+ abstract public function doSet($key, $value, $ttl);
+ abstract public function doDelete($key);
+ abstract public function doClear();
+
+ /**
+ * @param array $keys
+ * @param mixed $miss
+ * @return array
+ */
+ public function doGetMultiple($keys, $miss)
+ {
+ $results = [];
+
+ foreach ($keys as $key) {
+ $value = $this->doGet($key, $miss);
+ if ($value !== $miss) {
+ $results[$key] = $value;
+ }
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param array $values
+ * @param int $ttl
+ * @return bool
+ */
+ public function doSetMultiple($values, $ttl)
+ {
+ $success = true;
+
+ foreach ($values as $key => $value) {
+ $success = $this->doSet($key, $value, $ttl) && $success;
+ }
+
+ return $success;
+ }
+
+ /**
+ * @param array $keys
+ * @return bool
+ */
+ public function doDeleteMultiple($keys)
+ {
+ $success = true;
+
+ foreach ($keys as $key) {
+ $success = $this->doDelete($key) && $success;
+ }
+
+ return $success;
+ }
+
+ abstract public function doHas($key);
+
+ /**
+ * @param string $key
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ protected function validateKey($key)
+ {
+ if (!is_string($key)) {
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Cache key must be string, "%s" given',
+ is_object($key) ? get_class($key) : gettype($key)
+ )
+ );
+ }
+ if (!isset($key[0])) {
+ throw new InvalidArgumentException('Cache key length must be greater than zero');
+ }
+ if (strlen($key) > 64) {
+ throw new InvalidArgumentException(
+ sprintf('Cache key length must be less than 65 characters, key had %s characters', strlen($key))
+ );
+ }
+ if (strpbrk($key, '{}()/\@:') !== false) {
+ throw new InvalidArgumentException(
+ sprintf('Cache key "%s" contains reserved characters {}()/\@:', $key)
+ );
+ }
+ }
+
+ /**
+ * @param array $keys
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ protected function validateKeys($keys)
+ {
+ foreach ($keys as $key) {
+ $this->validateKey($key);
+ }
+ }
+
+ /**
+ * @param null|int|\DateInterval $ttl
+ * @return int|null
+ * @throws \Psr\SimpleCache\InvalidArgumentException
+ */
+ protected function convertTtl($ttl)
+ {
+ if ($ttl === null) {
+ return $this->getDefaultLifetime();
+ }
+
+ if (is_int($ttl)) {
+ return $ttl;
+ }
+
+ if ($ttl instanceof \DateInterval) {
+ $ttl = (int) \DateTime::createFromFormat('U', 0)->add($ttl)->format('U');
+ }
+
+ throw new InvalidArgumentException(
+ sprintf(
+ 'Expiration date must be an integer, a DateInterval or null, "%s" given',
+ is_object($ttl) ? get_class($ttl) : gettype($ttl)
+ )
+ );
+ }
+}
diff --git a/system/src/Grav/Framework/Cache/Exception/CacheException.php b/system/src/Grav/Framework/Cache/Exception/CacheException.php
new file mode 100644
index 0000000..71caff7
--- /dev/null
+++ b/system/src/Grav/Framework/Cache/Exception/CacheException.php
@@ -0,0 +1,19 @@
+path = $path;
+ $this->flags = self::INCLUDE_FILES | self::INCLUDE_FOLDERS;
+ $this->nestingLimit = 0;
+ $this->createObjectFunction = [$this, 'createObject'];
+
+ $this->setIterator();
+ }
+
+ /**
+ * @return string
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ /**
+ * @param Criteria $criteria
+ * @return ArrayCollection
+ * @todo Implement lazy matching
+ */
+ public function matching(Criteria $criteria)
+ {
+ $expr = $criteria->getWhereExpression();
+
+ $oldFilter = $this->filterFunction;
+ if ($expr) {
+ $visitor = new ClosureExpressionVisitor();
+ $filter = $visitor->dispatch($expr);
+ $this->addFilter($filter);
+ }
+
+ $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit);
+ $this->filterFunction = $oldFilter;
+
+ if ($orderings = $criteria->getOrderings()) {
+ $next = null;
+ foreach (array_reverse($orderings) as $field => $ordering) {
+ $next = ClosureExpressionVisitor::sortByField($field, $ordering == Criteria::DESC ? -1 : 1, $next);
+ }
+
+ uasort($filtered, $next);
+ } else {
+ ksort($filtered);
+ }
+
+ $offset = $criteria->getFirstResult();
+ $length = $criteria->getMaxResults();
+
+ if ($offset || $length) {
+ $filtered = array_slice($filtered, (int)$offset, $length);
+ }
+
+ return new ArrayCollection($filtered);
+ }
+
+ protected function setIterator()
+ {
+ $iteratorFlags = \RecursiveDirectoryIterator::SKIP_DOTS + \FilesystemIterator::UNIX_PATHS
+ + \FilesystemIterator::CURRENT_AS_SELF + \FilesystemIterator::FOLLOW_SYMLINKS;
+
+ if (strpos($this->path, '://')) {
+ /** @var UniformResourceLocator $locator */
+ $locator = Grav::instance()['locator'];
+ $this->iterator = $locator->getRecursiveIterator($this->path, $iteratorFlags);
+ } else {
+ $this->iterator = new \RecursiveDirectoryIterator($this->path, $iteratorFlags);
+ }
+ }
+
+ /**
+ * @param callable $filterFunction
+ * @return $this
+ */
+ protected function addFilter(callable $filterFunction)
+ {
+ if ($this->filterFunction) {
+ $oldFilterFunction = $this->filterFunction;
+ $this->filterFunction = function ($expr) use ($oldFilterFunction, $filterFunction) {
+ return $oldFilterFunction($expr) && $filterFunction($expr);
+ };
+ } else {
+ $this->filterFunction = $filterFunction;
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function doInitialize()
+ {
+ $filtered = $this->doInitializeByIterator($this->iterator, $this->nestingLimit);
+ ksort($filtered);
+
+ $this->collection = new ArrayCollection($filtered);
+ }
+
+ protected function doInitializeByIterator(\SeekableIterator $iterator, $nestingLimit)
+ {
+ $children = [];
+ $objects = [];
+ $filter = $this->filterFunction;
+ $objectFunction = $this->createObjectFunction;
+
+ /** @var \RecursiveDirectoryIterator $file */
+ foreach ($iterator as $file) {
+ // Skip files if they shouldn't be included.
+ if (!($this->flags & static::INCLUDE_FILES) && $file->isFile()) {
+ continue;
+ }
+
+ // Apply main filter.
+ if ($filter && !$filter($file)) {
+ continue;
+ }
+
+ // Include children if the recursive flag is set.
+ if (($this->flags & static::RECURSIVE) && $nestingLimit > 0 && $file->hasChildren()) {
+ $children[] = $file->getChildren();
+ }
+
+ // Skip folders if they shouldn't be included.
+ if (!($this->flags & static::INCLUDE_FOLDERS) && $file->isDir()) {
+ continue;
+ }
+
+ $object = $objectFunction($file);
+ $objects[$object->key] = $object;
+ }
+
+ if ($children) {
+ $objects += $this->doInitializeChildren($children, $nestingLimit - 1);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param \RecursiveDirectoryIterator[] $children
+ * @return array
+ */
+ protected function doInitializeChildren(array $children, $nestingLimit)
+ {
+ $objects = [];
+
+ foreach ($children as $iterator) {
+ $objects += $this->doInitializeByIterator($iterator, $nestingLimit);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param \RecursiveDirectoryIterator $file
+ * @return object
+ */
+ protected function createObject($file)
+ {
+ return (object) [
+ 'key' => $file->getSubPathname(),
+ 'type' => $file->isDir() ? 'folder' : 'file:' . $file->getExtension(),
+ 'url' => method_exists($file, 'getUrl') ? $file->getUrl() : null,
+ 'pathname' => $file->getPathname(),
+ 'mtime' => $file->getMTime()
+ ];
+ }
+}
diff --git a/system/src/Grav/Framework/Collection/AbstractLazyCollection.php b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php
new file mode 100644
index 0000000..af2f696
--- /dev/null
+++ b/system/src/Grav/Framework/Collection/AbstractLazyCollection.php
@@ -0,0 +1,62 @@
+initialize();
+ return $this->collection->reverse();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function shuffle()
+ {
+ $this->initialize();
+ return $this->collection->shuffle();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function chunk($size)
+ {
+ $this->initialize();
+ return $this->collection->chunk($size);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function jsonSerialize()
+ {
+ $this->initialize();
+ return $this->collection->jsonSerialize();
+ }
+}
diff --git a/system/src/Grav/Framework/Collection/ArrayCollection.php b/system/src/Grav/Framework/Collection/ArrayCollection.php
new file mode 100644
index 0000000..bf6dda0
--- /dev/null
+++ b/system/src/Grav/Framework/Collection/ArrayCollection.php
@@ -0,0 +1,73 @@
+toArray()));
+ }
+
+ return $this->createFrom(array_reverse($this->toArray()));
+ }
+
+ /**
+ * Shuffle items.
+ *
+ * @return static
+ */
+ public function shuffle()
+ {
+ $keys = $this->getKeys();
+ shuffle($keys);
+
+ // TODO: remove when PHP 5.6 is minimum (with doctrine/collections v1.4).
+ if (!method_exists($this, 'createFrom')) {
+ return new static(array_replace(array_flip($keys), $this->toArray()));
+ }
+
+ return $this->createFrom(array_replace(array_flip($keys), $this->toArray()));
+ }
+
+ /**
+ * Split collection into chunks.
+ *
+ * @param int $size Size of each chunk.
+ * @return array
+ */
+ public function chunk($size)
+ {
+ return array_chunk($this->toArray(), $size, true);
+ }
+
+ /**
+ * Implementes JsonSerializable interface.
+ *
+ * @return array
+ */
+ public function jsonSerialize()
+ {
+ return $this->toArray();
+ }
+}
diff --git a/system/src/Grav/Framework/Collection/CollectionInterface.php b/system/src/Grav/Framework/Collection/CollectionInterface.php
new file mode 100644
index 0000000..5f5e1fc
--- /dev/null
+++ b/system/src/Grav/Framework/Collection/CollectionInterface.php
@@ -0,0 +1,41 @@
+flags = (int)($flags ?: self::INCLUDE_FILES | self::INCLUDE_FOLDERS | self::RECURSIVE);
+
+ $this->setIterator();
+ $this->setFilter();
+ $this->setObjectBuilder();
+ $this->setNestingLimit();
+ }
+
+ /**
+ * @return int
+ */
+ public function getFlags()
+ {
+ return $this->flags;
+ }
+
+ /**
+ * @return int
+ */
+ public function getNestingLimit()
+ {
+ return $this->nestingLimit;
+ }
+
+ /**
+ * @param int $limit
+ * @return $this
+ */
+ public function setNestingLimit($limit = 99)
+ {
+ $this->nestingLimit = (int) $limit;
+
+ return $this;
+ }
+
+ /**
+ * @param callable|null $filterFunction
+ * @return $this
+ */
+ public function setFilter(callable $filterFunction = null)
+ {
+ $this->filterFunction = $filterFunction;
+
+ return $this;
+ }
+
+ /**
+ * @param callable $filterFunction
+ * @return $this
+ */
+ public function addFilter(callable $filterFunction)
+ {
+ parent::addFilter($filterFunction);
+
+ return $this;
+ }
+
+ /**
+ * @param callable|null $objectFunction
+ * @return $this
+ */
+ public function setObjectBuilder(callable $objectFunction = null)
+ {
+ $this->createObjectFunction = $objectFunction ?: [$this, 'createObject'];
+
+ return $this;
+ }
+}
diff --git a/system/src/Grav/Framework/Collection/FileCollectionInterface.php b/system/src/Grav/Framework/Collection/FileCollectionInterface.php
new file mode 100644
index 0000000..37f63d2
--- /dev/null
+++ b/system/src/Grav/Framework/Collection/FileCollectionInterface.php
@@ -0,0 +1,28 @@
+setContent('my inner content');
+ * $outerBlock = ContentBlock::create();
+ * $outerBlock->setContent(sprintf('Inside my outer block I have %s.', $innerBlock->getToken()));
+ * $outerBlock->addBlock($innerBlock);
+ * echo $outerBlock;
+ *
+ * @package Grav\Framework\ContentBlock
+ */
+class ContentBlock implements ContentBlockInterface
+{
+ protected $version = 1;
+ protected $id;
+ protected $tokenTemplate = '@@BLOCK-%s@@';
+ protected $content = '';
+ protected $blocks = [];
+
+ /**
+ * @param string $id
+ * @return static
+ */
+ public static function create($id = null)
+ {
+ return new static($id);
+ }
+
+ /**
+ * @param array $serialized
+ * @return ContentBlockInterface
+ */
+ public static function fromArray(array $serialized)
+ {
+ try {
+ $type = isset($serialized['_type']) ? $serialized['_type'] : null;
+ $id = isset($serialized['id']) ? $serialized['id'] : null;
+
+ if (!$type || !$id || !is_a($type, 'Grav\Framework\ContentBlock\ContentBlockInterface', true)) {
+ throw new \RuntimeException('Bad data');
+ }
+
+ /** @var ContentBlockInterface $instance */
+ $instance = new $type($id);
+ $instance->build($serialized);
+ } catch (\Exception $e) {
+ throw new \RuntimeException(sprintf('Cannot unserialize Block: %s', $e->getMessage()), $e->getCode(), $e);
+ }
+
+ return $instance;
+ }
+
+ /**
+ * Block constructor.
+ *
+ * @param string $id
+ */
+ public function __construct($id = null)
+ {
+ $this->id = $id ? (string) $id : $this->generateId();
+ }
+
+ /**
+ * @return string
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * @return string
+ */
+ public function getToken()
+ {
+ return sprintf($this->tokenTemplate, $this->getId());
+ }
+
+ /**
+ * @return array
+ */
+ public function toArray()
+ {
+ $blocks = [];
+ /**
+ * @var string $id
+ * @var ContentBlockInterface $block
+ */
+ foreach ($this->blocks as $block) {
+ $blocks[$block->getId()] = $block->toArray();
+ }
+
+ $array = [
+ '_type' => get_class($this),
+ '_version' => $this->version,
+ 'id' => $this->id,
+ ];
+
+ if ($this->content) {
+ $array['content'] = $this->content;
+ }
+
+ if ($blocks) {
+ $array['blocks'] = $blocks;
+ }
+
+ return $array;
+ }
+
+ /**
+ * @return string
+ */
+ public function toString()
+ {
+ if (!$this->blocks) {
+ return (string) $this->content;
+ }
+
+ $tokens = [];
+ $replacements = [];
+ foreach ($this->blocks as $block) {
+ $tokens[] = $block->getToken();
+ $replacements[] = $block->toString();
+ }
+
+ return str_replace($tokens, $replacements, (string) $this->content);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ try {
+ return $this->toString();
+ } catch (\Exception $e) {
+ return sprintf('Error while rendering block: %s', $e->getMessage());
+ }
+ }
+
+ /**
+ * @param array $serialized
+ * @throws \RuntimeException
+ */
+ public function build(array $serialized)
+ {
+ $this->checkVersion($serialized);
+
+ $this->id = isset($serialized['id']) ? $serialized['id'] : $this->generateId();
+
+ if (isset($serialized['content'])) {
+ $this->setContent($serialized['content']);
+ }
+
+ $blocks = isset($serialized['blocks']) ? (array) $serialized['blocks'] : [];
+ foreach ($blocks as $block) {
+ $this->addBlock(self::fromArray($block));
+ }
+ }
+
+ /**
+ * @param string $content
+ * @return $this
+ */
+ public function setContent($content)
+ {
+ $this->content = $content;
+
+ return $this;
+ }
+
+ /**
+ * @param ContentBlockInterface $block
+ * @return $this
+ */
+ public function addBlock(ContentBlockInterface $block)
+ {
+ $this->blocks[$block->getId()] = $block;
+
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function serialize()
+ {
+ return serialize($this->toArray());
+ }
+
+ /**
+ * @param string $serialized
+ */
+ public function unserialize($serialized)
+ {
+ $array = unserialize($serialized);
+ $this->build($array);
+ }
+
+ /**
+ * @return string
+ */
+ protected function generateId()
+ {
+ return uniqid('', true);
+ }
+
+ /**
+ * @param array $serialized
+ * @throws \RuntimeException
+ */
+ protected function checkVersion(array $serialized)
+ {
+ $version = isset($serialized['_version']) ? (string) $serialized['_version'] : '1';
+ if ($version !== $this->version) {
+ throw new \RuntimeException(sprintf('Unsupported version %s', $version));
+ }
+ }
+}
diff --git a/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php
new file mode 100644
index 0000000..4f2434c
--- /dev/null
+++ b/system/src/Grav/Framework/ContentBlock/ContentBlockInterface.php
@@ -0,0 +1,75 @@
+getAssetsFast();
+
+ $this->sortAssets($assets['styles']);
+ $this->sortAssets($assets['scripts']);
+ $this->sortAssets($assets['html']);
+
+ return $assets;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFrameworks()
+ {
+ $assets = $this->getAssetsFast();
+
+ return array_keys($assets['frameworks']);
+ }
+
+ /**
+ * @param string $location
+ * @return array
+ */
+ public function getStyles($location = 'head')
+ {
+ return $this->getAssetsInLocation('styles', $location);
+ }
+
+ /**
+ * @param string $location
+ * @return array
+ */
+ public function getScripts($location = 'head')
+ {
+ return $this->getAssetsInLocation('scripts', $location);
+ }
+
+ /**
+ * @param string $location
+ * @return array
+ */
+ public function getHtml($location = 'bottom')
+ {
+ return $this->getAssetsInLocation('html', $location);
+ }
+
+ /**
+ * @return array[]
+ */
+ public function toArray()
+ {
+ $array = parent::toArray();
+
+ if ($this->frameworks) {
+ $array['frameworks'] = $this->frameworks;
+ }
+ if ($this->styles) {
+ $array['styles'] = $this->styles;
+ }
+ if ($this->scripts) {
+ $array['scripts'] = $this->scripts;
+ }
+ if ($this->html) {
+ $array['html'] = $this->html;
+ }
+
+ return $array;
+ }
+
+ /**
+ * @param array $serialized
+ * @throws \RuntimeException
+ */
+ public function build(array $serialized)
+ {
+ parent::build($serialized);
+
+ $this->frameworks = isset($serialized['frameworks']) ? (array) $serialized['frameworks'] : [];
+ $this->styles = isset($serialized['styles']) ? (array) $serialized['styles'] : [];
+ $this->scripts = isset($serialized['scripts']) ? (array) $serialized['scripts'] : [];
+ $this->html = isset($serialized['html']) ? (array) $serialized['html'] : [];
+ }
+
+ /**
+ * @param string $framework
+ * @return $this
+ */
+ public function addFramework($framework)
+ {
+ $this->frameworks[$framework] = 1;
+
+ return $this;
+ }
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ *
+ * @example $block->addStyle('assets/js/my.js');
+ * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']);
+ */
+ public function addStyle($element, $priority = 0, $location = 'head')
+ {
+ if (!is_array($element)) {
+ $element = ['href' => (string) $element];
+ }
+ if (empty($element['href'])) {
+ return false;
+ }
+ if (!isset($this->styles[$location])) {
+ $this->styles[$location] = [];
+ }
+
+ $id = !empty($element['id']) ? ['id' => (string) $element['id']] : [];
+ $href = $element['href'];
+ $type = !empty($element['type']) ? (string) $element['type'] : 'text/css';
+ $media = !empty($element['media']) ? (string) $element['media'] : null;
+ unset(
+ $element['tag'],
+ $element['id'],
+ $element['rel'],
+ $element['content'],
+ $element['href'],
+ $element['type'],
+ $element['media']
+ );
+
+ $this->styles[$location][md5($href) . sha1($href)] = [
+ ':type' => 'file',
+ ':priority' => (int) $priority,
+ 'href' => $href,
+ 'type' => $type,
+ 'media' => $media,
+ 'element' => $element
+ ] + $id;
+
+ return true;
+ }
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addInlineStyle($element, $priority = 0, $location = 'head')
+ {
+ if (!is_array($element)) {
+ $element = ['content' => (string) $element];
+ }
+ if (empty($element['content'])) {
+ return false;
+ }
+ if (!isset($this->styles[$location])) {
+ $this->styles[$location] = [];
+ }
+
+ $content = (string) $element['content'];
+ $type = !empty($element['type']) ? (string) $element['type'] : 'text/css';
+
+ $this->styles[$location][md5($content) . sha1($content)] = [
+ ':type' => 'inline',
+ ':priority' => (int) $priority,
+ 'content' => $content,
+ 'type' => $type
+ ];
+
+ return true;
+ }
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addScript($element, $priority = 0, $location = 'head')
+ {
+ if (!is_array($element)) {
+ $element = ['src' => (string) $element];
+ }
+ if (empty($element['src'])) {
+ return false;
+ }
+ if (!isset($this->scripts[$location])) {
+ $this->scripts[$location] = [];
+ }
+
+ $src = $element['src'];
+ $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript';
+ $defer = isset($element['defer']) ? true : false;
+ $async = isset($element['async']) ? true : false;
+ $handle = !empty($element['handle']) ? (string) $element['handle'] : '';
+
+ $this->scripts[$location][md5($src) . sha1($src)] = [
+ ':type' => 'file',
+ ':priority' => (int) $priority,
+ 'src' => $src,
+ 'type' => $type,
+ 'defer' => $defer,
+ 'async' => $async,
+ 'handle' => $handle
+ ];
+
+ return true;
+ }
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addInlineScript($element, $priority = 0, $location = 'head')
+ {
+ if (!is_array($element)) {
+ $element = ['content' => (string) $element];
+ }
+ if (empty($element['content'])) {
+ return false;
+ }
+ if (!isset($this->scripts[$location])) {
+ $this->scripts[$location] = [];
+ }
+
+ $content = (string) $element['content'];
+ $type = !empty($element['type']) ? (string) $element['type'] : 'text/javascript';
+
+ $this->scripts[$location][md5($content) . sha1($content)] = [
+ ':type' => 'inline',
+ ':priority' => (int) $priority,
+ 'content' => $content,
+ 'type' => $type
+ ];
+
+ return true;
+ }
+
+ /**
+ * @param string $html
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addHtml($html, $priority = 0, $location = 'bottom')
+ {
+ if (empty($html) || !is_string($html)) {
+ return false;
+ }
+ if (!isset($this->html[$location])) {
+ $this->html[$location] = [];
+ }
+
+ $this->html[$location][md5($html) . sha1($html)] = [
+ ':priority' => (int) $priority,
+ 'html' => $html
+ ];
+
+ return true;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getAssetsFast()
+ {
+ $assets = [
+ 'frameworks' => $this->frameworks,
+ 'styles' => $this->styles,
+ 'scripts' => $this->scripts,
+ 'html' => $this->html
+ ];
+
+ foreach ($this->blocks as $block) {
+ if ($block instanceof HtmlBlock) {
+ $blockAssets = $block->getAssetsFast();
+ $assets['frameworks'] += $blockAssets['frameworks'];
+
+ foreach ($blockAssets['styles'] as $location => $styles) {
+ if (!isset($assets['styles'][$location])) {
+ $assets['styles'][$location] = $styles;
+ } elseif ($styles) {
+ $assets['styles'][$location] += $styles;
+ }
+ }
+
+ foreach ($blockAssets['scripts'] as $location => $scripts) {
+ if (!isset($assets['scripts'][$location])) {
+ $assets['scripts'][$location] = $scripts;
+ } elseif ($scripts) {
+ $assets['scripts'][$location] += $scripts;
+ }
+ }
+
+ foreach ($blockAssets['html'] as $location => $htmls) {
+ if (!isset($assets['html'][$location])) {
+ $assets['html'][$location] = $htmls;
+ } elseif ($htmls) {
+ $assets['html'][$location] += $htmls;
+ }
+ }
+ }
+ }
+
+ return $assets;
+ }
+
+ /**
+ * @param string $type
+ * @param string $location
+ * @return array
+ */
+ protected function getAssetsInLocation($type, $location)
+ {
+ $assets = $this->getAssetsFast();
+
+ if (empty($assets[$type][$location])) {
+ return [];
+ }
+
+ $styles = $assets[$type][$location];
+ $this->sortAssetsInLocation($styles);
+
+ return $styles;
+ }
+
+ /**
+ * @param array $items
+ */
+ protected function sortAssetsInLocation(array &$items)
+ {
+ $count = 0;
+ foreach ($items as &$item) {
+ $item[':order'] = ++$count;
+ }
+ unset($item);
+
+ uasort(
+ $items,
+ function ($a, $b) {
+ return ($a[':priority'] === $b[':priority'])
+ ? $a[':order'] - $b[':order'] : $a[':priority'] - $b[':priority'];
+ }
+ );
+ }
+
+ /**
+ * @param array $array
+ */
+ protected function sortAssets(array &$array)
+ {
+ foreach ($array as $location => &$items) {
+ $this->sortAssetsInLocation($items);
+ }
+ }
+}
diff --git a/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php
new file mode 100644
index 0000000..204e199
--- /dev/null
+++ b/system/src/Grav/Framework/ContentBlock/HtmlBlockInterface.php
@@ -0,0 +1,94 @@
+addStyle('assets/js/my.js');
+ * @example $block->addStyle(['href' => 'assets/js/my.js', 'media' => 'screen']);
+ */
+ public function addStyle($element, $priority = 0, $location = 'head');
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addInlineStyle($element, $priority = 0, $location = 'head');
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addScript($element, $priority = 0, $location = 'head');
+
+
+ /**
+ * @param string|array $element
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addInlineScript($element, $priority = 0, $location = 'head');
+
+ /**
+ * @param string $html
+ * @param int $priority
+ * @param string $location
+ * @return bool
+ */
+ public function addHtml($html, $priority = 0, $location = 'bottom');
+}
diff --git a/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php
new file mode 100644
index 0000000..37e7328
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Access/ArrayAccessTrait.php
@@ -0,0 +1,64 @@
+hasProperty($offset);
+ }
+
+ /**
+ * Returns the value at specified offset.
+ *
+ * @param mixed $offset The offset to retrieve.
+ * @return mixed Can return all value types.
+ */
+ public function offsetGet($offset)
+ {
+ return $this->getProperty($offset);
+ }
+
+ /**
+ * Assigns a value to the specified offset.
+ *
+ * @param mixed $offset The offset to assign the value to.
+ * @param mixed $value The value to set.
+ */
+ public function offsetSet($offset, $value)
+ {
+ $this->setProperty($offset, $value);
+ }
+
+ /**
+ * Unsets an offset.
+ *
+ * @param mixed $offset The offset to unset.
+ */
+ public function offsetUnset($offset)
+ {
+ $this->unsetProperty($offset);
+ }
+
+ abstract public function hasProperty($property);
+ abstract public function getProperty($property, $default = null);
+ abstract public function setProperty($property, $value);
+ abstract public function unsetProperty($property);
+}
diff --git a/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php
new file mode 100644
index 0000000..256a1ce
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Access/NestedArrayAccessTrait.php
@@ -0,0 +1,64 @@
+hasNestedProperty($offset);
+ }
+
+ /**
+ * Returns the value at specified offset.
+ *
+ * @param mixed $offset The offset to retrieve.
+ * @return mixed Can return all value types.
+ */
+ public function offsetGet($offset)
+ {
+ return $this->getNestedProperty($offset);
+ }
+
+ /**
+ * Assigns a value to the specified offset.
+ *
+ * @param mixed $offset The offset to assign the value to.
+ * @param mixed $value The value to set.
+ */
+ public function offsetSet($offset, $value)
+ {
+ $this->setNestedProperty($offset, $value);
+ }
+
+ /**
+ * Unsets an offset.
+ *
+ * @param mixed $offset The offset to unset.
+ */
+ public function offsetUnset($offset)
+ {
+ $this->unsetNestedProperty($offset);
+ }
+
+ abstract public function hasNestedProperty($property, $separator = null);
+ abstract public function getNestedProperty($property, $default = null, $separator = null);
+ abstract public function setNestedProperty($property, $value, $separator = null);
+ abstract public function unsetNestedProperty($property, $separator = null);
+}
diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php
new file mode 100644
index 0000000..65b155d
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Access/NestedPropertyCollectionTrait.php
@@ -0,0 +1,124 @@
+getIterator() as $id => $element) {
+ $list[$id] = $element->hasNestedProperty($property, $separator);
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if not set.
+ * @param string $separator Separator, defaults to '.'
+ * @return array Key/Value pairs of the properties.
+ */
+ public function getNestedProperty($property, $default = null, $separator = null)
+ {
+ $list = [];
+
+ /** @var NestedObjectInterface $element */
+ foreach ($this->getIterator() as $id => $element) {
+ $list[$id] = $element->getNestedProperty($property, $default, $separator);
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $value New value.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function setNestedProperty($property, $value, $separator = null)
+ {
+ /** @var NestedObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->setNestedProperty($property, $value, $separator);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function unsetNestedProperty($property, $separator = null)
+ {
+ /** @var NestedObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->unsetNestedProperty($property, $separator);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $default Default value.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ */
+ public function defNestedProperty($property, $default, $separator = null)
+ {
+ /** @var NestedObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->defNestedProperty($property, $default, $separator);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Group items in the collection by a field.
+ *
+ * @param string $property Object property to be used to make groups.
+ * @param string $separator Separator, defaults to '.'
+ * @return array
+ */
+ public function group($property, $separator = null)
+ {
+ $list = [];
+
+ /** @var NestedObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $list[(string) $element->getNestedProperty($property, null, $separator)][] = $element;
+ }
+
+ return $list;
+ }
+
+ /**
+ * @return \Traversable
+ */
+ abstract public function getIterator();
+}
diff --git a/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php
new file mode 100644
index 0000000..e01f425
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Access/NestedPropertyTrait.php
@@ -0,0 +1,182 @@
+getNestedProperty($property, $test, $separator) !== $test;
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @param string $separator Separator, defaults to '.'
+ * @return mixed Property value.
+ */
+ public function getNestedProperty($property, $default = null, $separator = null)
+ {
+ $separator = $separator ?: '.';
+ $path = explode($separator, $property);
+ $offset = array_shift($path);
+
+ if (!$this->hasProperty($offset)) {
+ return $default;
+ }
+
+ $current = $this->getProperty($offset);
+
+ while ($path) {
+ // Get property of nested Object.
+ if ($current instanceof ObjectInterface) {
+ if (method_exists($current, 'getNestedProperty')) {
+ return $current->getNestedProperty(implode($separator, $path), $default, $separator);
+ }
+ return $current->getProperty(implode($separator, $path), $default);
+ }
+
+ $offset = array_shift($path);
+
+ if ((is_array($current) || is_a($current, 'ArrayAccess')) && isset($current[$offset])) {
+ $current = $current[$offset];
+ } elseif (is_object($current) && isset($current->{$offset})) {
+ $current = $current->{$offset};
+ } else {
+ return $default;
+ }
+ };
+
+ return $current;
+ }
+
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $value New value.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ * @throws \RuntimeException
+ */
+ public function setNestedProperty($property, $value, $separator = null)
+ {
+ $separator = $separator ?: '.';
+ $path = explode($separator, $property);
+ $offset = array_shift($path);
+
+ if (!$path) {
+ $this->setProperty($offset, $value);
+
+ return $this;
+ }
+
+ $current = &$this->doGetProperty($offset, null, true);
+
+ while ($path) {
+ $offset = array_shift($path);
+
+ // Handle arrays and scalars.
+ if ($current === null) {
+ $current = [$offset => []];
+ } elseif (is_array($current)) {
+ if (!isset($current[$offset])) {
+ $current[$offset] = [];
+ }
+ } else {
+ throw new \RuntimeException('Cannot set nested property on non-array value');
+ }
+
+ $current = &$current[$offset];
+ };
+
+ $current = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ * @throws \RuntimeException
+ */
+ public function unsetNestedProperty($property, $separator = null)
+ {
+ $separator = $separator ?: '.';
+ $path = explode($separator, $property);
+ $offset = array_shift($path);
+
+ if (!$path) {
+ $this->unsetProperty($offset);
+
+ return $this;
+ }
+
+ $last = array_pop($path);
+ $current = &$this->doGetProperty($offset, null, true);
+
+ while ($path) {
+ $offset = array_shift($path);
+
+ // Handle arrays and scalars.
+ if ($current === null) {
+ return $this;
+ }
+ if (is_array($current)) {
+ if (!isset($current[$offset])) {
+ return $this;
+ }
+ } else {
+ throw new \RuntimeException('Cannot set nested property on non-array value');
+ }
+
+ $current = &$current[$offset];
+ };
+
+ unset($current[$last]);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $default Default value.
+ * @param string $separator Separator, defaults to '.'
+ * @return $this
+ * @throws \RuntimeException
+ */
+ public function defNestedProperty($property, $default, $separator = null)
+ {
+ if (!$this->hasNestedProperty($property, $separator)) {
+ $this->setNestedProperty($property, $default, $separator);
+ }
+
+ return $this;
+ }
+
+
+ abstract public function hasProperty($property);
+ abstract public function getProperty($property, $default = null);
+ abstract public function setProperty($property, $value);
+ abstract public function unsetProperty($property);
+ abstract protected function &doGetProperty($property, $default = null, $doCreate = false);
+}
diff --git a/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php
new file mode 100644
index 0000000..1524bd6
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Access/OverloadedPropertyTrait.php
@@ -0,0 +1,64 @@
+hasProperty($offset);
+ }
+
+ /**
+ * Returns the value at specified offset.
+ *
+ * @param mixed $offset The offset to retrieve.
+ * @return mixed Can return all value types.
+ */
+ public function __get($offset)
+ {
+ return $this->getProperty($offset);
+ }
+
+ /**
+ * Assigns a value to the specified offset.
+ *
+ * @param mixed $offset The offset to assign the value to.
+ * @param mixed $value The value to set.
+ */
+ public function __set($offset, $value)
+ {
+ $this->setProperty($offset, $value);
+ }
+
+ /**
+ * Magic method to unset the attribute
+ *
+ * @param mixed $offset The name value to unset
+ */
+ public function __unset($offset)
+ {
+ $this->unsetProperty($offset);
+ }
+
+ abstract public function hasProperty($property);
+ abstract public function getProperty($property, $default = null);
+ abstract public function setProperty($property, $value);
+ abstract public function unsetProperty($property);
+}
diff --git a/system/src/Grav/Framework/Object/ArrayObject.php b/system/src/Grav/Framework/Object/ArrayObject.php
new file mode 100644
index 0000000..0804133
--- /dev/null
+++ b/system/src/Grav/Framework/Object/ArrayObject.php
@@ -0,0 +1,26 @@
+getIterator() as $key => $value) {
+ $list[$key] = is_object($value) ? clone $value : $value;
+ }
+
+ // TODO: remove when PHP 5.6 is minimum (with doctrine/collections v1.4).
+ if (!method_exists($this, 'createFrom')) {
+ return new static($list);
+ }
+
+ return $this->createFrom($list);
+ }
+
+ /**
+ * @return array
+ */
+ public function getObjectKeys()
+ {
+ return $this->call('getKey');
+ }
+
+ /**
+ * @param string $property Object property to be matched.
+ * @return array Key/Value pairs of the properties.
+ */
+ public function doHasProperty($property)
+ {
+ $list = [];
+
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $id => $element) {
+ $list[$id] = $element->hasProperty($property);
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if not set.
+ * @return array Key/Value pairs of the properties.
+ */
+ public function doGetProperty($property, $default = null)
+ {
+ $list = [];
+
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $id => $element) {
+ $list[$id] = $element->getProperty($property, $default);
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $value New value.
+ * @return $this
+ */
+ public function doSetProperty($property, $value)
+ {
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->setProperty($property, $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @return $this
+ */
+ public function doUnsetProperty($property)
+ {
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->unsetProperty($property);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $default Default value.
+ * @return $this
+ */
+ public function doDefProperty($property, $default)
+ {
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $element->defProperty($property, $default);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $method Method name.
+ * @param array $arguments List of arguments passed to the function.
+ * @return array Return values.
+ */
+ public function call($method, array $arguments = [])
+ {
+ $list = [];
+
+ foreach ($this->getIterator() as $id => $element) {
+ $list[$id] = method_exists($element, $method)
+ ? call_user_func_array([$element, $method], $arguments) : null;
+ }
+
+ return $list;
+ }
+
+ /**
+ * Group items in the collection by a field and return them as associated array.
+ *
+ * @param string $property
+ * @return array
+ */
+ public function group($property)
+ {
+ $list = [];
+
+ /** @var ObjectInterface $element */
+ foreach ($this->getIterator() as $element) {
+ $list[(string) $element->getProperty($property)][] = $element;
+ }
+
+ return $list;
+ }
+
+ /**
+ * Group items in the collection by a field and return them as associated array of collections.
+ *
+ * @param string $property
+ * @return static[]
+ */
+ public function collectionGroup($property)
+ {
+ $collections = [];
+ foreach ($this->group($property) as $id => $elements) {
+ // TODO: remove when PHP 5.6 is minimum (with doctrine/collections v1.4).
+ if (!method_exists($this, 'createFrom')) {
+ $collection = new static($elements);
+ } else {
+ $collection = $this->createFrom($elements);
+ }
+
+ $collections[$id] = $collection;
+ }
+
+ return $collections;
+ }
+
+ /**
+ * @return \Traversable
+ */
+ abstract public function getIterator();
+}
diff --git a/system/src/Grav/Framework/Object/Base/ObjectTrait.php b/system/src/Grav/Framework/Object/Base/ObjectTrait.php
new file mode 100644
index 0000000..6522e8e
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Base/ObjectTrait.php
@@ -0,0 +1,174 @@
+_key ?: $this->getType() . '@' . spl_object_hash($this);
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been defined (can be null).
+ */
+ public function hasProperty($property)
+ {
+ return $this->doHasProperty($property);
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @return mixed Property value.
+ */
+ public function getProperty($property, $default = null)
+ {
+ return $this->doGetProperty($property, $default);
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param string $value New value.
+ * @return $this
+ */
+ public function setProperty($property, $value)
+ {
+ $this->doSetProperty($property, $value);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be unset.
+ * @return $this
+ */
+ public function unsetProperty($property)
+ {
+ $this->doUnsetProperty($property);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be defined.
+ * @param mixed $default Default value.
+ * @return $this
+ */
+ public function defProperty($property, $default)
+ {
+ if (!$this->hasProperty($property)) {
+ $this->setProperty($property, $default);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Implements Serializable interface.
+ *
+ * @return string
+ */
+ public function serialize()
+ {
+ return serialize($this->jsonSerialize());
+ }
+
+ /**
+ * @param string $serialized
+ */
+ public function unserialize($serialized)
+ {
+ $data = unserialize($serialized);
+
+ if (method_exists($this, 'initObjectProperties')) {
+ $this->initObjectProperties();
+ }
+ $this->doUnserialize($data);
+ }
+
+ /**
+ * @param array $serialized
+ */
+ protected function doUnserialize(array $serialized)
+ {
+ if (!isset($serialized['key'], $serialized['type'], $serialized['elements']) || $serialized['type'] !== $this->getType()) {
+ throw new \InvalidArgumentException("Cannot unserialize '{$this->getType()}': Bad data");
+ }
+
+ $this->setKey($serialized['key']);
+ $this->setElements($serialized['elements']);
+ }
+
+ /**
+ * Implements JsonSerializable interface.
+ *
+ * @return array
+ */
+ public function jsonSerialize()
+ {
+ return ['key' => $this->getKey(), 'type' => $this->getType(), 'elements' => $this->getElements()];
+ }
+
+ /**
+ * Returns a string representation of this object.
+ *
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->getKey();
+ }
+
+ /**
+ * @param string $key
+ */
+ protected function setKey($key)
+ {
+ $this->_key = (string) $key;
+ }
+
+ abstract protected function doHasProperty($property);
+ abstract protected function &doGetProperty($property, $default = null, $doCreate = false);
+ abstract protected function doSetProperty($property, $value);
+ abstract protected function doUnsetProperty($property);
+ abstract protected function getElements();
+ abstract protected function setElements(array $elements);
+}
diff --git a/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php
new file mode 100644
index 0000000..3f8d784
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Interfaces/NestedObjectInterface.php
@@ -0,0 +1,57 @@
+setElements($elements));
+
+ $this->setKey($key);
+ }
+
+ protected function getElements()
+ {
+ return $this->toArray();
+ }
+
+ protected function setElements(array $elements)
+ {
+ return $elements;
+ }
+}
diff --git a/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php
new file mode 100644
index 0000000..400b163
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Property/ArrayPropertyTrait.php
@@ -0,0 +1,109 @@
+setElements($elements);
+ $this->setKey($key);
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been defined (can be null).
+ */
+ protected function doHasProperty($property)
+ {
+ return array_key_exists($property, $this->_elements);
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @param bool $doCreate Set true to create variable.
+ * @return mixed Property value.
+ */
+ protected function &doGetProperty($property, $default = null, $doCreate = false)
+ {
+ if (!array_key_exists($property, $this->_elements)) {
+ if ($doCreate) {
+ $this->_elements[$property] = null;
+ } else {
+ return $default;
+ }
+ }
+
+ return $this->_elements[$property];
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param mixed $value New value.
+ */
+ protected function doSetProperty($property, $value)
+ {
+ $this->_elements[$property] = $value;
+ }
+
+ /**
+ * @param string $property Object property to be unset.
+ */
+ protected function doUnsetProperty($property)
+ {
+ unset($this->_elements[$property]);
+ }
+
+ /**
+ * @param string $property
+ * @param mixed|null $default
+ * @return mixed|null
+ */
+ protected function getElement($property, $default = null)
+ {
+ return array_key_exists($property, $this->_elements) ? $this->_elements[$property] : $default;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getElements()
+ {
+ return $this->_elements;
+ }
+
+ /**
+ * @param array $elements
+ */
+ protected function setElements(array $elements)
+ {
+ $this->_elements = $elements;
+ }
+
+ abstract protected function setKey($key);
+}
diff --git a/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php
new file mode 100644
index 0000000..28b9432
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Property/LazyPropertyTrait.php
@@ -0,0 +1,117 @@
+offsetLoad($offset, $value)` called first time object property gets accessed
+ * - `$this->offsetPrepare($offset, $value)` called on every object property set
+ * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed
+ *
+ * @package Grav\Framework\Object\Property
+ */
+trait LazyPropertyTrait
+{
+ use ArrayPropertyTrait, ObjectPropertyTrait {
+ ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait;
+ ArrayPropertyTrait::doHasProperty as hasArrayProperty;
+ ArrayPropertyTrait::doGetProperty as getArrayProperty;
+ ArrayPropertyTrait::doSetProperty as setArrayProperty;
+ ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty;
+ ArrayPropertyTrait::getElement as getArrayElement;
+ ArrayPropertyTrait::getElements as getArrayElements;
+ ArrayPropertyTrait::setElements insteadof ObjectPropertyTrait;
+ ObjectPropertyTrait::doHasProperty as hasObjectProperty;
+ ObjectPropertyTrait::doGetProperty as getObjectProperty;
+ ObjectPropertyTrait::doSetProperty as setObjectProperty;
+ ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty;
+ ObjectPropertyTrait::getElement as getObjectElement;
+ ObjectPropertyTrait::getElements as getObjectElements;
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been defined (can be null).
+ */
+ protected function doHasProperty($property)
+ {
+ return $this->hasArrayProperty($property) || $this->hasObjectProperty($property);
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @return mixed Property value.
+ */
+ protected function &doGetProperty($property, $default = null, $doCreate = false)
+ {
+ if ($this->hasObjectProperty($property)) {
+ return $this->getObjectProperty($property, $default, function ($default = null) use ($property) {
+ return $this->getArrayProperty($property, $default);
+ });
+ }
+
+ return $this->getArrayProperty($property, $default, $doCreate);
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param mixed $value New value.
+ * @return $this
+ */
+ protected function doSetProperty($property, $value)
+ {
+ if ($this->hasObjectProperty($property)) {
+ $this->setObjectProperty($property, $value);
+ } else {
+ $this->setArrayProperty($property, $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be unset.
+ * @return $this
+ */
+ protected function doUnsetProperty($property)
+ {
+ $this->hasObjectProperty($property) ?
+ $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property
+ * @param mixed|null $default
+ * @return mixed|null
+ */
+ protected function getElement($property, $default = null)
+ {
+ if ($this->isPropertyLoaded($property)) {
+ return $this->getObjectElement($property, $default);
+ }
+
+ return $this->getArrayElement($property, $default);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getElements()
+ {
+ return $this->getObjectElements() + $this->getArrayElements();
+ }
+}
diff --git a/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php
new file mode 100644
index 0000000..2c86376
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Property/MixedPropertyTrait.php
@@ -0,0 +1,122 @@
+offsetLoad($offset, $value)` called first time object property gets accessed
+ * - `$this->offsetPrepare($offset, $value)` called on every object property set
+ * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed
+
+ *
+ * @package Grav\Framework\Object\Property
+ */
+trait MixedPropertyTrait
+{
+ use ArrayPropertyTrait, ObjectPropertyTrait {
+ ObjectPropertyTrait::__construct insteadof ArrayPropertyTrait;
+ ArrayPropertyTrait::doHasProperty as hasArrayProperty;
+ ArrayPropertyTrait::doGetProperty as getArrayProperty;
+ ArrayPropertyTrait::doSetProperty as setArrayProperty;
+ ArrayPropertyTrait::doUnsetProperty as unsetArrayProperty;
+ ArrayPropertyTrait::getElement as getArrayElement;
+ ArrayPropertyTrait::getElements as getArrayElements;
+ ArrayPropertyTrait::setElements as setArrayElements;
+ ObjectPropertyTrait::doHasProperty as hasObjectProperty;
+ ObjectPropertyTrait::doGetProperty as getObjectProperty;
+ ObjectPropertyTrait::doSetProperty as setObjectProperty;
+ ObjectPropertyTrait::doUnsetProperty as unsetObjectProperty;
+ ObjectPropertyTrait::getElement as getObjectElement;
+ ObjectPropertyTrait::getElements as getObjectElements;
+ ObjectPropertyTrait::setElements as setObjectElements;
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been defined (can be null).
+ */
+ protected function doHasProperty($property)
+ {
+ return $this->hasArrayProperty($property) || $this->hasObjectProperty($property);
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @return mixed Property value.
+ */
+ protected function &doGetProperty($property, $default = null, $doCreate = false)
+ {
+ if ($this->hasObjectProperty($property)) {
+ return $this->getObjectProperty($property);
+ }
+
+ return $this->getArrayProperty($property, $default, $doCreate);
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param mixed $value New value.
+ * @return $this
+ */
+ protected function doSetProperty($property, $value)
+ {
+ $this->hasObjectProperty($property)
+ ? $this->setObjectProperty($property, $value) : $this->setArrayProperty($property, $value);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property Object property to be unset.
+ * @return $this
+ */
+ protected function doUnsetProperty($property)
+ {
+ $this->hasObjectProperty($property) ?
+ $this->unsetObjectProperty($property) : $this->unsetArrayProperty($property);
+
+ return $this;
+ }
+
+ /**
+ * @param string $property
+ * @param mixed|null $default
+ * @return mixed|null
+ */
+ protected function getElement($property, $default = null)
+ {
+ if ($this->hasObjectProperty($property)) {
+ return $this->getObjectElement($property, $default);
+ }
+
+ return $this->getArrayElement($property, $default);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getElements()
+ {
+ return $this->getObjectElements() + $this->getArrayElements();
+ }
+
+ /**
+ * @param array $elements
+ */
+ protected function setElements(array $elements)
+ {
+ $this->setObjectElements(array_intersect_key($elements, $this->_definedProperties));
+ $this->setArrayElements(array_diff_key($elements, $this->_definedProperties));
+ }
+}
diff --git a/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php
new file mode 100644
index 0000000..c26903f
--- /dev/null
+++ b/system/src/Grav/Framework/Object/Property/ObjectPropertyTrait.php
@@ -0,0 +1,203 @@
+offsetLoad($offset, $value)` called first time object property gets accessed
+ * - `$this->offsetPrepare($offset, $value)` called on every object property set
+ * - `$this->offsetSerialize($offset, $value)` called when the raw or serialized object property value is needed
+ *
+ * @package Grav\Framework\Object\Property
+ */
+trait ObjectPropertyTrait
+{
+ /**
+ * @var array
+ */
+ private $_definedProperties;
+
+ /**
+ * @param array $elements
+ * @param string $key
+ * @throws \InvalidArgumentException
+ */
+ public function __construct(array $elements = [], $key = null)
+ {
+ $this->initObjectProperties();
+ $this->setElements($elements);
+ $this->setKey($key);
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been loaded.
+ */
+ protected function isPropertyLoaded($property)
+ {
+ return !empty($this->_definedProperties[$property]);
+ }
+
+ /**
+ * @param string $offset
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function offsetLoad($offset, $value)
+ {
+ $methodName = "offsetLoad_{$offset}";
+
+ return method_exists($this, $methodName)? $this->{$methodName}($value) : $value;
+ }
+
+ /**
+ * @param string $offset
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function offsetPrepare($offset, $value)
+ {
+ $methodName = "offsetPrepare_{$offset}";
+
+ return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value;
+ }
+
+ /**
+ * @param string $offset
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function offsetSerialize($offset, $value)
+ {
+ $methodName = "offsetSerialize_{$offset}";
+
+ return method_exists($this, $methodName) ? $this->{$methodName}($value) : $value;
+ }
+
+ /**
+ * @param string $property Object property name.
+ * @return bool True if property has been defined (can be null).
+ */
+ protected function doHasProperty($property)
+ {
+ return array_key_exists($property, $this->_definedProperties);
+ }
+
+ /**
+ * @param string $property Object property to be fetched.
+ * @param mixed $default Default value if property has not been set.
+ * @param bool $doCreate Set true to create variable.
+ * @return mixed Property value.
+ */
+ protected function &doGetProperty($property, $default = null, $doCreate = false)
+ {
+ if (!array_key_exists($property, $this->_definedProperties)) {
+ throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!");
+ }
+
+ if (empty($this->_definedProperties[$property])) {
+ if ($doCreate === true) {
+ $this->_definedProperties[$property] = true;
+ $this->{$property} = null;
+ } elseif (is_callable($doCreate)) {
+ $this->_definedProperties[$property] = true;
+ $this->{$property} = $this->offsetLoad($property, $doCreate());
+ } else {
+ return $default;
+ }
+ }
+
+ return $this->{$property};
+ }
+
+ /**
+ * @param string $property Object property to be updated.
+ * @param mixed $value New value.
+ * @throws \InvalidArgumentException
+ */
+ protected function doSetProperty($property, $value)
+ {
+ if (!array_key_exists($property, $this->_definedProperties)) {
+ throw new \InvalidArgumentException("Property '{$property}' does not exist in the object!");
+ }
+
+ $this->_definedProperties[$property] = true;
+ $this->{$property} = $this->offsetPrepare($property, $value);
+ }
+
+ /**
+ * @param string $property Object property to be unset.
+ */
+ protected function doUnsetProperty($property)
+ {
+ if (!array_key_exists($property, $this->_definedProperties)) {
+ return;
+ }
+
+ $this->_definedProperties[$property] = false;
+ unset($this->{$property});
+ }
+
+ protected function initObjectProperties()
+ {
+ $this->_definedProperties = [];
+ foreach (get_object_vars($this) as $property => $value) {
+ if ($property[0] !== '_') {
+ $this->_definedProperties[$property] = ($value !== null);
+ }
+ }
+ }
+
+ /**
+ * @param string $property
+ * @param mixed|null $default
+ * @return mixed|null
+ */
+ protected function getElement($property, $default = null)
+ {
+ if (empty($this->_definedProperties[$property])) {
+ return $default;
+ }
+
+ return $this->offsetSerialize($property, $this->{$property});
+ }
+
+ /**
+ * @return array
+ */
+ protected function getElements()
+ {
+ $properties = array_intersect_key(get_object_vars($this), array_filter($this->_definedProperties));
+
+ $elements = [];
+ foreach ($properties as $offset => $value) {
+ $elements[$offset] = $this->offsetSerialize($offset, $value);
+ }
+
+ return $elements;
+ }
+
+ /**
+ * @param array $elements
+ */
+ protected function setElements(array $elements)
+ {
+ foreach ($elements as $property => $value) {
+ $this->setProperty($property, $value);
+ }
+ }
+
+ abstract public function setProperty($property, $value);
+ abstract protected function setKey($key);
+}
diff --git a/system/src/Grav/Framework/Object/PropertyObject.php b/system/src/Grav/Framework/Object/PropertyObject.php
new file mode 100644
index 0000000..99f7e7f
--- /dev/null
+++ b/system/src/Grav/Framework/Object/PropertyObject.php
@@ -0,0 +1,26 @@
+ 80,
+ 'https' => 443
+ ];
+
+ /** @var string Uri scheme. */
+ private $scheme = '';
+
+ /** @var string Uri user. */
+ private $user = '';
+
+ /** @var string Uri password. */
+ private $password = '';
+
+ /** @var string Uri host. */
+ private $host = '';
+
+ /** @var int|null Uri port. */
+ private $port;
+
+ /** @var string Uri path. */
+ private $path = '';
+
+ /** @var string Uri query string (without ?). */
+ private $query = '';
+
+ /** @var string Uri fragment (without #). */
+ private $fragment = '';
+
+ /**
+ * Please define constructor which calls $this->init().
+ */
+ abstract public function __construct();
+
+ /**
+ * @inheritdoc
+ */
+ public function getScheme()
+ {
+ return $this->scheme;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getAuthority()
+ {
+ $authority = $this->host;
+
+ $userInfo = $this->getUserInfo();
+ if ($userInfo !== '') {
+ $authority = $userInfo . '@' . $authority;
+ }
+
+ if ($this->port !== null) {
+ $authority .= ':' . $this->port;
+ }
+
+ return $authority;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getUserInfo()
+ {
+ $userInfo = $this->user;
+
+ if ($this->password !== '') {
+ $userInfo .= ':' . $this->password;
+ }
+
+ return $userInfo;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getHost()
+ {
+ return $this->host;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getPort()
+ {
+ return $this->port;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getQuery()
+ {
+ return $this->query;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function getFragment()
+ {
+ return $this->fragment;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function withScheme($scheme)
+ {
+ $scheme = UriPartsFilter::filterScheme($scheme);
+
+ if ($this->scheme === $scheme) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->scheme = $scheme;
+ $new->unsetDefaultPort();
+ $new->validate();
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \InvalidArgumentException
+ */
+ public function withUserInfo($user, $password = '')
+ {
+ $user = UriPartsFilter::filterUserInfo($user);
+ $password = UriPartsFilter::filterUserInfo($password);
+
+ if ($this->user === $user && $this->password === $password) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->user = $user;
+ $new->password = $user !== '' ? $password : '';
+ $new->validate();
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function withHost($host)
+ {
+ $host = UriPartsFilter::filterHost($host);
+
+ if ($this->host === $host) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->host = $host;
+ $new->validate();
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function withPort($port)
+ {
+ $port = UriPartsFilter::filterPort($port);
+
+ if ($this->port === $port) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->port = $port;
+ $new->unsetDefaultPort();
+ $new->validate();
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function withPath($path)
+ {
+ $path = UriPartsFilter::filterPath($path);
+
+ if ($this->path === $path) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->path = $path;
+ $new->validate();
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function withQuery($query)
+ {
+ $query = UriPartsFilter::filterQueryOrFragment($query);
+
+ if ($this->query === $query) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->query = $query;
+
+ return $new;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \InvalidArgumentException
+ */
+ public function withFragment($fragment)
+ {
+ $fragment = UriPartsFilter::filterQueryOrFragment($fragment);
+
+ if ($this->fragment === $fragment) {
+ return $this;
+ }
+
+ $new = clone $this;
+ $new->fragment = $fragment;
+
+ return $new;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return $this->getUrl();
+ }
+
+ /**
+ * @return array
+ */
+ protected function getParts()
+ {
+ return [
+ 'scheme' => $this->scheme,
+ 'host' => $this->host,
+ 'port' => $this->port,
+ 'user' => $this->user,
+ 'pass' => $this->password,
+ 'path' => $this->path,
+ 'query' => $this->query,
+ 'fragment' => $this->fragment
+ ];
+ }
+
+ /**
+ * Return the fully qualified base URL ( like http://getgrav.org ).
+ *
+ * Note that this method never includes a trailing /
+ *
+ * @return string
+ */
+ protected function getBaseUrl()
+ {
+ $uri = '';
+
+ $scheme = $this->getScheme();
+ if ($scheme !== '') {
+ $uri .= $scheme . ':';
+ }
+
+ $authority = $this->getAuthority();
+ if ($authority !== '' || $scheme === 'file') {
+ $uri .= '//' . $authority;
+ }
+
+ return $uri;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getUrl()
+ {
+ $uri = $this->getBaseUrl() . $this->getPath();
+
+ $query = $this->getQuery();
+ if ($query !== '') {
+ $uri .= '?' . $query;
+ }
+
+ $fragment = $this->getFragment();
+ if ($fragment !== '') {
+ $uri .= '#' . $fragment;
+ }
+
+ return $uri;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getUser()
+ {
+ return $this->user;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getPassword()
+ {
+ return $this->password;
+ }
+
+ /**
+ * @param array $parts
+ * @throws \InvalidArgumentException
+ */
+ protected function initParts(array $parts)
+ {
+ $this->scheme = isset($parts['scheme']) ? UriPartsFilter::filterScheme($parts['scheme']) : '';
+ $this->user = isset($parts['user']) ? UriPartsFilter::filterUserInfo($parts['user']) : '';
+ $this->password = isset($parts['pass']) ? UriPartsFilter::filterUserInfo($parts['pass']) : '';
+ $this->host = isset($parts['host']) ? UriPartsFilter::filterHost($parts['host']) : '';
+ $this->port = isset($parts['port']) ? UriPartsFilter::filterPort((int)$parts['port']) : null;
+ $this->path = isset($parts['path']) ? UriPartsFilter::filterPath($parts['path']) : '';
+ $this->query = isset($parts['query']) ? UriPartsFilter::filterQueryOrFragment($parts['query']) : '';
+ $this->fragment = isset($parts['fragment']) ? UriPartsFilter::filterQueryOrFragment($parts['fragment']) : '';
+
+ $this->unsetDefaultPort();
+ $this->validate();
+ }
+
+ /**
+ * @throws \InvalidArgumentException
+ */
+ private function validate()
+ {
+ if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {
+ throw new \InvalidArgumentException('Uri with a scheme must have a host');
+ }
+
+ if ($this->getAuthority() === '') {
+ if (0 === strpos($this->path, '//')) {
+ throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes \'//\'');
+ }
+ if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {
+ throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon');
+ }
+ } elseif (isset($this->path[0]) && $this->path[0] !== '/') {
+ throw new \InvalidArgumentException('The path of a URI with an authority must start with a slash \'/\' or be empty');
+ }
+ }
+
+ protected function isDefaultPort()
+ {
+ $scheme = $this->scheme;
+ $port = $this->port;
+
+ return $this->port === null
+ || (isset(static::$defaultPorts[$scheme]) && $port === static::$defaultPorts[$scheme]);
+ }
+
+ private function unsetDefaultPort()
+ {
+ if ($this->isDefaultPort()) {
+ $this->port = null;
+ }
+ }
+}
diff --git a/system/src/Grav/Framework/Route/Route.php b/system/src/Grav/Framework/Route/Route.php
new file mode 100644
index 0000000..503d2a8
--- /dev/null
+++ b/system/src/Grav/Framework/Route/Route.php
@@ -0,0 +1,301 @@
+initParts($parts);
+ }
+
+ /**
+ * @return array
+ */
+ public function getParts()
+ {
+ return [
+ 'path' => $this->getUriPath(),
+ 'query' => $this->getUriQuery(),
+ 'grav' => [
+ 'root' => $this->root,
+ 'language' => $this->language,
+ 'route' => $this->route,
+ 'grav_params' => $this->gravParams,
+ 'query_params' => $this->queryParams,
+ ],
+ ];
+ }
+
+ /**
+ * @return string
+ */
+ public function getRootPrefix()
+ {
+ return $this->root;
+ }
+
+ /**
+ * @return string
+ */
+ public function getLanguagePrefix()
+ {
+ return $this->language !== '' ? '/' . $this->language : '';
+ }
+
+ /**
+ * @param int $offset
+ * @param int|null $length
+ * @return string
+ */
+ public function getRoute($offset = 0, $length = null)
+ {
+ if ($offset !== 0 || $length !== null) {
+ return ($offset === 0 ? '/' : '') . implode('/', $this->getRouteParts($offset, $length));
+ }
+
+ return '/' . $this->route;
+ }
+
+ /**
+ * @param int $offset
+ * @param int|null $length
+ * @return array
+ */
+ public function getRouteParts($offset = 0, $length = null)
+ {
+ $parts = explode('/', $this->route);
+
+ if ($offset !== 0 || $length !== null) {
+ $parts = array_slice($parts, $offset, $length);
+ }
+
+ return $parts;
+ }
+
+ /**
+ * Return array of both query and Grav parameters.
+ *
+ * If a parameter exists in both, prefer Grav parameter.
+ *
+ * @return array
+ */
+ public function getParams()
+ {
+ return $this->gravParams + $this->queryParams;
+ }
+
+ /**
+ * @return array
+ */
+ public function getGravParams()
+ {
+ return $this->gravParams;
+ }
+
+ /**
+ * @return array
+ */
+ public function getQueryParams()
+ {
+ return $this->queryParams;
+ }
+
+ /**
+ * Return value of the parameter, looking into both Grav parameters and query parameters.
+ *
+ * If the parameter exists in both, return Grav parameter.
+ *
+ * @param string $param
+ * @return string|null
+ */
+ public function getParam($param)
+ {
+ $value = $this->getGravParam($param);
+ if ($value === null) {
+ $value = $this->getQueryParam($param);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param string $param
+ * @return string|null
+ */
+ public function getGravParam($param)
+ {
+ return isset($this->gravParams[$param]) ? $this->gravParams[$param] : null;
+ }
+
+ /**
+ * @param string $param
+ * @return string|null
+ */
+ public function getQueryParam($param)
+ {
+ return isset($this->queryParams[$param]) ? $this->queryParams[$param] : null;
+ }
+
+ /**
+ * @param string $param
+ * @param mixed $value
+ * @return Route
+ */
+ public function withGravParam($param, $value)
+ {
+ return $this->withParam('gravParams', $param, $value);
+ }
+
+ /**
+ * @param string $param
+ * @param mixed $value
+ * @return Route
+ */
+ public function withQueryParam($param, $value)
+ {
+ return $this->withParam('queryParams', $param, $value);
+ }
+
+ /**
+ * @return \Grav\Framework\Uri\Uri
+ */
+ public function getUri()
+ {
+ return UriFactory::createFromParts($this->getParts());
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ $url = $this->getUriPath();
+
+ if ($this->queryParams) {
+ $url .= '?' . $this->getUriQuery();
+ }
+
+ return $url;
+ }
+
+ /**
+ * @param string $type
+ * @param string $param
+ * @param mixed $value
+ * @return static
+ */
+ protected function withParam($type, $param, $value)
+ {
+ $oldValue = isset($this->{$type}[$param]) ? $this->{$type}[$param] : null;
+ $newValue = null !== $value ? (string)$value : null;
+
+ if ($oldValue === $newValue) {
+ return $this;
+ }
+
+ $new = clone $this;
+ if ($newValue === null) {
+ unset($new->{$type}[$param]);
+ } else {
+ $new->{$type}[$param] = $newValue;
+ }
+
+ return $new;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getUriPath()
+ {
+ $parts = [$this->root];
+
+ if ($this->language !== '') {
+ $parts[] = $this->language;
+ }
+
+ if ($this->route !== '') {
+ $parts[] = $this->route;
+ }
+
+ if ($this->gravParams) {
+ $parts[] = RouteFactory::buildParams($this->gravParams);
+ }
+
+ return implode('/', $parts);
+ }
+
+ /**
+ * @return string
+ */
+ protected function getUriQuery()
+ {
+ return UriFactory::buildQuery($this->queryParams);
+ }
+
+ /**
+ * @param array $parts
+ */
+ protected function initParts(array $parts)
+ {
+ if (isset($parts['grav'])) {
+ $gravParts = $parts['grav'];
+ $this->root = $gravParts['root'];
+ $this->language = $gravParts['language'];
+ $this->route = $gravParts['route'];
+ $this->gravParams = $gravParts['params'];
+ $this->queryParams = $parts['query_params'];
+
+ } else {
+ $this->root = RouteFactory::getRoot();
+ $this->language = RouteFactory::getLanguage();
+
+ $path = isset($parts['path']) ? $parts['path'] : '/';
+ if (isset($parts['params'])) {
+ $this->route = trim(rawurldecode($path), '/');
+ $this->gravParams = $parts['params'];
+ } else {
+ $this->route = trim(RouteFactory::stripParams($path, true), '/');
+ $this->gravParams = RouteFactory::getParams($path);
+ }
+ if (isset($parts['query'])) {
+ $this->queryParams = UriFactory::parseQuery($parts['query']);
+ }
+ }
+ }
+}
diff --git a/system/src/Grav/Framework/Route/RouteFactory.php b/system/src/Grav/Framework/Route/RouteFactory.php
new file mode 100644
index 0000000..a406926
--- /dev/null
+++ b/system/src/Grav/Framework/Route/RouteFactory.php
@@ -0,0 +1,131 @@
+ $value) {
+ $output[] = "{$key}{$delimiter}{$value}";
+ }
+
+ return implode('/', $output);
+ }
+
+ /**
+ * @param string $path
+ * @param bool $decode
+ * @return string
+ */
+ public static function stripParams($path, $decode = false)
+ {
+ $pos = strpos($path, self::$delimiter);
+
+ if ($pos === false) {
+ return $path;
+ }
+
+ $path = dirname(substr($path, 0, $pos));
+ if ($path === '.') {
+ return '';
+ }
+
+ return $decode ? rawurldecode($path) : $path;
+ }
+
+ /**
+ * @param string $path
+ * @return array
+ */
+ public static function getParams($path)
+ {
+ $params = ltrim(substr($path, strlen(static::stripParams($path))), '/');
+
+ return $params !== '' ? static::parseParams($params) : [];
+ }
+
+ /**
+ * @param string $str
+ * @return array
+ */
+ public static function parseParams($str)
+ {
+ $delimiter = self::$delimiter;
+
+ $params = explode('/', $str);
+ foreach ($params as &$param) {
+ $parts = explode($delimiter, $param, 2);
+ if (isset($parts[1])) {
+ $param[rawurldecode($parts[0])] = rawurldecode($parts[1]);
+ }
+ }
+
+ return $params;
+ }
+}
diff --git a/system/src/Grav/Framework/Uri/Uri.php b/system/src/Grav/Framework/Uri/Uri.php
new file mode 100644
index 0000000..2821360
--- /dev/null
+++ b/system/src/Grav/Framework/Uri/Uri.php
@@ -0,0 +1,213 @@
+initParts($parts);
+ }
+
+ /**
+ * @return string
+ */
+ public function getUser()
+ {
+ return parent::getUser();
+ }
+
+ /**
+ * @return string
+ */
+ public function getPassword()
+ {
+ return parent::getPassword();
+ }
+
+ /**
+ * @return array
+ */
+ public function getParts()
+ {
+ return parent::getParts();
+ }
+
+ /**
+ * @return string
+ */
+ public function getUrl()
+ {
+ return parent::getUrl();
+ }
+
+ /**
+ * @return string
+ */
+ public function getBaseUrl()
+ {
+ return parent::getBaseUrl();
+ }
+
+ /**
+ * @param string $key
+ * @return string|null
+ */
+ public function getQueryParam($key)
+ {
+ $queryParams = $this->getQueryParams();
+
+ return isset($queryParams[$key]) ? $queryParams[$key] : null;
+ }
+
+ /**
+ * @param string $key
+ * @return UriInterface
+ */
+ public function withoutQueryParam($key)
+ {
+ return GuzzleUri::withoutQueryValue($this, $key);
+ }
+
+ /**
+ * @param string $key
+ * @param string|null $value
+ * @return UriInterface
+ */
+ public function withQueryParam($key, $value)
+ {
+ return GuzzleUri::withQueryValue($this, $key, $value);
+ }
+
+ /**
+ * @return array
+ */
+ public function getQueryParams()
+ {
+ if ($this->queryParams === null) {
+ $this->queryParams = UriFactory::parseQuery($this->getQuery());
+ }
+
+ return $this->queryParams;
+ }
+
+ /**
+ * @param array $params
+ * @return UriInterface
+ */
+ public function withQueryParams(array $params)
+ {
+ $query = UriFactory::buildQuery($params);
+
+ return $this->withQuery($query);
+ }
+
+ /**
+ * Whether the URI has the default port of the current scheme.
+ *
+ * `$uri->getPort()` may return the standard port. This method can be used for some non-http/https Uri.
+ *
+ * @return bool
+ */
+ public function isDefaultPort()
+ {
+ return $this->getPort() === null || GuzzleUri::isDefaultPort($this);
+ }
+
+ /**
+ * Whether the URI is absolute, i.e. it has a scheme.
+ *
+ * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true
+ * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative
+ * to another URI, the base URI. Relative references can be divided into several forms:
+ * - network-path references, e.g. '//example.com/path'
+ * - absolute-path references, e.g. '/path'
+ * - relative-path references, e.g. 'subpath'
+ *
+ * @return bool
+ * @link https://tools.ietf.org/html/rfc3986#section-4
+ */
+ public function isAbsolute()
+ {
+ return GuzzleUri::isAbsolute($this);
+ }
+
+ /**
+ * Whether the URI is a network-path reference.
+ *
+ * A relative reference that begins with two slash characters is termed an network-path reference.
+ *
+ * @return bool
+ * @link https://tools.ietf.org/html/rfc3986#section-4.2
+ */
+ public function isNetworkPathReference()
+ {
+ return GuzzleUri::isNetworkPathReference($this);
+ }
+
+ /**
+ * Whether the URI is a absolute-path reference.
+ *
+ * A relative reference that begins with a single slash character is termed an absolute-path reference.
+ *
+ * @return bool
+ * @link https://tools.ietf.org/html/rfc3986#section-4.2
+ */
+ public function isAbsolutePathReference()
+ {
+ return GuzzleUri::isAbsolutePathReference($this);
+ }
+
+ /**
+ * Whether the URI is a relative-path reference.
+ *
+ * A relative reference that does not begin with a slash character is termed a relative-path reference.
+ *
+ * @return bool
+ * @link https://tools.ietf.org/html/rfc3986#section-4.2
+ */
+ public function isRelativePathReference()
+ {
+ return GuzzleUri::isRelativePathReference($this);
+ }
+
+ /**
+ * Whether the URI is a same-document reference.
+ *
+ * A same-document reference refers to a URI that is, aside from its fragment
+ * component, identical to the base URI. When no base URI is given, only an empty
+ * URI reference (apart from its fragment) is considered a same-document reference.
+ *
+ * @param UriInterface|null $base An optional base URI to compare against
+ * @return bool
+ * @link https://tools.ietf.org/html/rfc3986#section-4.4
+ */
+ public function isSameDocumentReference(UriInterface $base = null)
+ {
+ return GuzzleUri::isSameDocumentReference($this, $base);
+ }
+}
diff --git a/system/src/Grav/Framework/Uri/UriFactory.php b/system/src/Grav/Framework/Uri/UriFactory.php
new file mode 100644
index 0000000..c6bd428
--- /dev/null
+++ b/system/src/Grav/Framework/Uri/UriFactory.php
@@ -0,0 +1,159 @@
+ $scheme,
+ 'user' => $user,
+ 'pass' => $pass,
+ 'host' => $host,
+ 'port' => $port,
+ 'path' => $path,
+ 'query' => $query
+ ];
+ }
+
+ /**
+ * UTF-8 aware parse_url() implementation.
+ *
+ * @param string $url
+ * @return array
+ * @throws \InvalidArgumentException
+ */
+ public static function parseUrl($url)
+ {
+ if (!is_string($url)) {
+ throw new \InvalidArgumentException('URL must be a string');
+ }
+
+ $encodedUrl = preg_replace_callback(
+ '%[^:/@?&=#]+%u',
+ function ($matches) { return rawurlencode($matches[0]); },
+ $url
+ );
+
+ $parts = parse_url($encodedUrl);
+ if ($parts === false) {
+ throw new \InvalidArgumentException('Malformed URL: ' . $encodedUrl);
+ }
+
+ return $parts;
+ }
+
+ /**
+ * Parse query string and return it as an array.
+ *
+ * @param string $query
+ * @return mixed
+ */
+ public static function parseQuery($query)
+ {
+ parse_str($query, $params);
+
+ return $params;
+ }
+
+ /**
+ * Build query string from variables.
+ *
+ * @param array $params
+ * @return string
+ */
+ public static function buildQuery(array $params)
+ {
+ return $params ? http_build_query($params, null, ini_get('arg_separator.output'), PHP_QUERY_RFC3986) : '';
+ }
+}
diff --git a/system/src/Grav/Framework/Uri/UriPartsFilter.php b/system/src/Grav/Framework/Uri/UriPartsFilter.php
new file mode 100644
index 0000000..36efa16
--- /dev/null
+++ b/system/src/Grav/Framework/Uri/UriPartsFilter.php
@@ -0,0 +1,140 @@
+= 1 && $port <= 65535))) {
+ return $port;
+ }
+
+ throw new \InvalidArgumentException('Uri port must be null or an integer between 1 and 65535');
+ }
+
+ /**
+ * Filter Uri path.
+ *
+ * This method percent-encodes all reserved characters in the provided path string. This method
+ * will NOT double-encode characters that are already percent-encoded.
+ *
+ * @param string $path The raw uri path.
+ * @return string The RFC 3986 percent-encoded uri path.
+ * @throws \InvalidArgumentException If the path is invalid.
+ * @link http://www.faqs.org/rfcs/rfc3986.html
+ */
+ public static function filterPath($path)
+ {
+ if (!is_string($path)) {
+ throw new \InvalidArgumentException('Uri path must be a string');
+ }
+
+ return preg_replace_callback(
+ '/(?:[^a-zA-Z0-9_\-\.~:@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/u',
+ function ($match) {
+ return rawurlencode($match[0]);
+ },
+ $path
+ );
+ }
+
+ /**
+ * Filters the query string or fragment of a URI.
+ *
+ * @param string $query The raw uri query string.
+ * @return string The percent-encoded query string.
+ * @throws \InvalidArgumentException If the query is invalid.
+ */
+ public static function filterQueryOrFragment($query)
+ {
+ if (!is_string($query)) {
+ throw new \InvalidArgumentException('Uri query string and fragment must be a string');
+ }
+
+ return preg_replace_callback(
+ '/(?:[^a-zA-Z0-9_\-\.~!\$&\'\(\)\*\+,;=%:@\/\?]+|%(?![A-Fa-f0-9]{2}))/u',
+ function ($match) {
+ return rawurlencode($match[0]);
+ },
+ $query
+ );
+ }
+}
diff --git a/system/templates/partials/messages.html.twig b/system/templates/partials/messages.html.twig
new file mode 100644
index 0000000..261b3fc
--- /dev/null
+++ b/system/templates/partials/messages.html.twig
@@ -0,0 +1,14 @@
+{% set status_mapping = {'info':'green', 'error': 'red', 'warning': 'yellow'} %}
+
+{% if grav.messages.all %}
+
+ {% for message in grav.messages.fetch %}
+
+ {% set scope = message.scope|e %}
+ {% set color = status_mapping[scope] %}
+
+
{{ message.message|raw }}
+
+ {% endfor %}
+
+{% endif %}
diff --git a/system/templates/partials/metadata.html.twig b/system/templates/partials/metadata.html.twig
new file mode 100644
index 0000000..bf323e7
--- /dev/null
+++ b/system/templates/partials/metadata.html.twig
@@ -0,0 +1,3 @@
+{% for meta in page.metadata %}
+
+{% endfor %}
\ No newline at end of file
diff --git a/tmp/.gitkeep b/tmp/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/user/accounts/.gitkeep b/user/accounts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/user/config/site.yaml b/user/config/site.yaml
new file mode 100644
index 0000000..4be78ed
--- /dev/null
+++ b/user/config/site.yaml
@@ -0,0 +1,7 @@
+title: Grav
+author:
+ name: Joe Bloggs
+ email: 'joe@example.com'
+metadata:
+ description: 'Grav is an easy to use, yet powerful, open source flat-file CMS'
+
diff --git a/user/config/system.yaml b/user/config/system.yaml
new file mode 100644
index 0000000..99a627e
--- /dev/null
+++ b/user/config/system.yaml
@@ -0,0 +1,45 @@
+absolute_urls: false
+
+home:
+ alias: '/home'
+
+pages:
+ theme: quark
+ markdown:
+ extra: false
+ process:
+ markdown: true
+ twig: false
+
+cache:
+ enabled: true
+ check:
+ method: file
+ driver: auto
+ prefix: 'g'
+
+twig:
+ cache: true
+ debug: true
+ auto_reload: true
+ autoescape: false
+
+assets:
+ css_pipeline: false
+ css_minify: true
+ css_rewrite: true
+ js_pipeline: false
+ js_minify: true
+
+errors:
+ display: true
+ log: true
+
+debugger:
+ enabled: false
+ twig: true
+ shutdown:
+ close_connection: true
+gpm:
+ releases: stable
+ verify_peer: true
diff --git a/user/data/.gitkeep b/user/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/user/pages/01.home/default.md b/user/pages/01.home/default.md
new file mode 100644
index 0000000..a8119bd
--- /dev/null
+++ b/user/pages/01.home/default.md
@@ -0,0 +1,42 @@
+---
+title: Home
+body_classes: title-center title-h1h2
+---
+
+# Say Hello to Grav!
+## installation successful...
+
+Congratulations! You have installed the **Base Grav Package** that provides a **simple page** and the default **Quark** theme to get you started.
+
+!! If you see a **404 Error** when you click `Typography` in the menu, please refer to the [troubleshooting guide](http://learn.getgrav.org/troubleshooting/page-not-found).
+
+### Find out all about Grav
+
+* Learn about **Grav** by checking out our dedicated [Learn Grav](http://learn.getgrav.org) site.
+* Download **plugins**, **themes**, as well as other Grav **skeleton** packages from the [Grav Downloads](http://getgrav.org/downloads) page.
+* Check out our [Grav Development Blog](http://getgrav.org/blog) to find out the latest goings on in the Grav-verse.
+
+!!! If you want a more **full-featured** base install, you should check out [**Skeleton** packages available in the downloads](http://getgrav.org/downloads).
+
+### Edit this Page
+
+To edit this page, simply navigate to the folder you installed **Grav** into, and then browse to the `user/pages/01.home` folder and open the `default.md` file in your [editor of choice](http://learn.getgrav.org/basics/requirements). You will see the content of this page in [Markdown format](http://learn.getgrav.org/content/markdown).
+
+### Create a New Page
+
+Creating a new page is a simple affair in **Grav**. Simply follow these simple steps:
+
+1. Navigate to your pages folder: `user/pages/` and create a new folder. In this example, we will use [explicit default ordering](http://learn.getgrav.org/content/content-pages) and call the folder `03.mypage`.
+2. Launch your text editor and paste in the following sample code:
+
+ ---
+ title: My New Page
+ ---
+ # My New Page!
+
+ This is the body of **my new page** and I can easily use _Markdown_ syntax here.
+
+3. Save this file in the `user/pages/03.mypage/` folder as `default.md`. This will tell **Grav** to render the page using the **default** template.
+4. That is it! Reload your browser to see your new page in the menu.
+
+! NOTE: The page will automatically show up in the Menu after the "Home" menu item. If you wish to change the name that shows up in the Menu, simple add: `menu: My Page` between the dashes in the page content. This is called the YAML front matter, and it is where you configure page-specific options.
diff --git a/user/pages/02.typography/default.md b/user/pages/02.typography/default.md
new file mode 100644
index 0000000..88691f6
--- /dev/null
+++ b/user/pages/02.typography/default.md
@@ -0,0 +1,155 @@
+---
+title: Typography
+---
+
+! Details on the full capabilities of Spectre.css can be found in the [Official Spectre Documentation](https://picturepan2.github.io/spectre/elements.html)
+
+The [Quark theme](https://github.com/getgrav/grav-theme-quark) is the new default theme for Grav built with [Spectre.css](https://picturepan2.github.io/spectre/) the lightweight, responsive and modern CSS framework. Spectre provides basic styles for typography, elements, and a responsive layout system that utilizes best practices and consistent language design.
+
+### Headings
+
+# H1 Heading `40px`
+
+## H2 Heading `32px`
+
+### H3 Heading `28px`
+
+#### H4 Heading `24px`
+
+##### H5 Heading `20px`
+
+###### H6 Heading `16px`
+
+```html
+# H1 Heading
+# H1 Heading `40px``
+
+H1 Heading
+```
+
+### Paragraphs
+
+Lorem ipsum dolor sit amet, consectetur [adipiscing elit. Praesent risus leo, dictum in vehicula sit amet](#), feugiat tempus tellus. Duis quis sodales risus. Etiam euismod ornare consequat.
+
+Climb leg rub face on everything give attitude nap all day for under the bed. Chase mice attack feet but rub face on everything hopped up on goofballs.
+
+### Markdown Semantic Text Elements
+
+**Bold** `**Bold**`
+
+_Italic_ `_Italic_`
+
+~~Deleted~~ `~~Deleted~~`
+
+`Inline Code` `` `Inline Code` ``
+
+### HTML Semantic Text Elements
+
+I18N ``
+
+Citation ``
+
+Ctrl + S ``
+
+TextSuperscripted ``
+
+TextSubscxripted ``
+
+Underlined ``
+
+Highlighted ``
+
+ `