ouidade 3 سال پیش
والد
کامیت
ffb5dbdaae
100فایلهای تغییر یافته به همراه3789 افزوده شده و 2229 حذف شده
  1. 52 0
      CHANGELOG.md
  2. 118 34
      README.md
  3. 3 17
      composer.json
  4. 125 113
      composer.lock
  5. 1 0
      system/blueprints/flex/user-groups.yaml
  6. 1 1
      system/blueprints/pages/default.yaml
  7. 2 0
      system/config/system.yaml
  8. 28 17
      system/defines.php
  9. 1 1
      system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php
  10. 1 1
      system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php
  11. 2 1
      system/src/Grav/Common/Data/Validation.php
  12. 10 0
      system/src/Grav/Common/Flex/Types/Pages/PageCollection.php
  13. 150 0
      system/src/Grav/Common/Flex/Types/Pages/PageIndex.php
  14. 14 3
      system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php
  15. 0 5
      system/src/Grav/Common/Flex/Types/Users/UserObject.php
  16. 1 1
      system/src/Grav/Common/GPM/GPM.php
  17. 4 1
      system/src/Grav/Common/GPM/Installer.php
  18. 37 13
      system/src/Grav/Common/Media/Traits/MediaUploadTrait.php
  19. 7 21
      system/src/Grav/Common/Page/Markdown/Excerpts.php
  20. 1 1
      system/src/Grav/Common/Page/Page.php
  21. 1 1
      system/src/Grav/Common/Plugins.php
  22. 8 8
      system/src/Grav/Common/Processors/InitializeProcessor.php
  23. 1 1
      system/src/Grav/Common/Security.php
  24. 58 1
      system/src/Grav/Common/Service/RequestServiceProvider.php
  25. 351 0
      system/src/Grav/Common/Twig/Extension/FilesystemExtension.php
  26. 1597 0
      system/src/Grav/Common/Twig/Extension/GravExtension.php
  27. 1 1
      system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php
  28. 48 16
      system/src/Grav/Common/Twig/Twig.php
  29. 3 1581
      system/src/Grav/Common/Twig/TwigExtension.php
  30. 309 72
      system/src/Grav/Common/Utils.php
  31. 2 2
      system/src/Grav/Console/Cli/InstallCommand.php
  32. 7 1
      system/src/Grav/Console/Gpm/InstallCommand.php
  33. 1 1
      system/src/Grav/Console/Gpm/UninstallCommand.php
  34. 1 0
      system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php
  35. 1 1
      system/src/Grav/Framework/Flex/FlexForm.php
  36. 24 7
      system/src/Grav/Framework/Flex/FlexObject.php
  37. 15 8
      system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php
  38. 3 0
      user/config/themes/antimatter.yaml
  39. 1 1
      user/pages/01.home/01._accueil/accueil.md
  40. 2 2
      user/pages/01.home/02._programmes/programmes.md
  41. BIN
      user/pages/01.home/03._ressources/Couv note orientation.PNG
  42. BIN
      user/pages/01.home/03._ressources/Couv rapport activités.PNG
  43. BIN
      user/pages/01.home/03._ressources/Note d'orientation EPAU 2021-2023.pdf
  44. BIN
      user/pages/01.home/03._ressources/Rapport d'activité EPAU 2020.pdf
  45. 10 25
      user/pages/01.home/03._ressources/ressources.md
  46. 0 0
      user/pages/01.home/04._gouvernance/01._presidence/personnes.md
  47. BIN
      user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/Alain Maugard.jpg
  48. BIN
      user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/catherine chevillot 274 (1).jpg
  49. 0 0
      user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/personnes.md
  50. 0 0
      user/pages/01.home/04._gouvernance/03._equipe-epau/01._direction-generale-epau/personnes.md
  51. BIN
      user/pages/01.home/04._gouvernance/03._equipe-epau/02._direction-europan-france/Louis Vitalis.png
  52. 0 0
      user/pages/01.home/04._gouvernance/03._equipe-epau/02._direction-europan-france/personnes.md
  53. 1 1
      user/pages/01.home/04._gouvernance/03._equipe-epau/04._direction-coubertin/personnes.md
  54. 1 2
      user/pages/01.home/04._gouvernance/gouvernance.md
  55. 3 2
      user/pages/01.home/05._contact/text.md
  56. 2 0
      user/themes/antimatter/.gitignore
  57. 81 1
      user/themes/antimatter/CHANGELOG.md
  58. 0 7
      user/themes/antimatter/antimatter.yaml
  59. 6 3
      user/themes/antimatter/blueprints.yaml
  60. 2 2
      user/themes/antimatter/blueprints/asset/file.yaml
  61. 20 7
      user/themes/antimatter/blueprints/blog.yaml
  62. 1 1
      user/themes/antimatter/blueprints/form.yaml
  63. 5 5
      user/themes/antimatter/blueprints/item.yaml
  64. 3 3
      user/themes/antimatter/blueprints/modular/features.yaml
  65. 6 5
      user/themes/antimatter/blueprints/modular/showcase.yaml
  66. 3 3
      user/themes/antimatter/blueprints/modular/text.yaml
  67. 0 4
      user/themes/antimatter/css-compiled/nucleus.css
  68. 0 1
      user/themes/antimatter/css-compiled/nucleus.css.map
  69. 0 2
      user/themes/antimatter/css-compiled/particles.css
  70. 0 1
      user/themes/antimatter/css-compiled/particles.css.map
  71. 86 31
      user/themes/antimatter/css-compiled/template.css
  72. 0 0
      user/themes/antimatter/css-compiled/template.css.map
  73. 1 1
      user/themes/antimatter/css/font-awesome.min.css
  74. BIN
      user/themes/antimatter/fonts/fontawesome-webfont.eot
  75. 6 34
      user/themes/antimatter/fonts/fontawesome-webfont.svg
  76. BIN
      user/themes/antimatter/fonts/fontawesome-webfont.ttf
  77. BIN
      user/themes/antimatter/fonts/fontawesome-webfont.woff
  78. BIN
      user/themes/antimatter/fonts/fontawesome-webfont.woff2
  79. 15 0
      user/themes/antimatter/hebe.json
  80. BIN
      user/themes/antimatter/images/favicon.png
  81. BIN
      user/themes/antimatter/images/logo.png
  82. 294 1
      user/themes/antimatter/languages.yaml
  83. 1 1
      user/themes/antimatter/scss/configuration/template/_colors.scss
  84. 92 43
      user/themes/antimatter/scss/template/_header.scss
  85. 11 0
      user/themes/antimatter/scss/template/_simplesearch.scss
  86. 2 2
      user/themes/antimatter/templates/blog.html.twig
  87. 1 1
      user/themes/antimatter/templates/default.html.twig
  88. 2 2
      user/themes/antimatter/templates/error.html.twig
  89. 1 1
      user/themes/antimatter/templates/form.html.twig
  90. 1 1
      user/themes/antimatter/templates/item.html.twig
  91. 3 3
      user/themes/antimatter/templates/modular.html.twig
  92. 1 1
      user/themes/antimatter/templates/modular/features.html.twig
  93. 2 2
      user/themes/antimatter/templates/modular/showcase.html.twig
  94. 2 2
      user/themes/antimatter/templates/modular/text.html.twig
  95. 31 27
      user/themes/antimatter/templates/partials/base.html.twig
  96. 34 23
      user/themes/antimatter/templates/partials/blog_item.html.twig
  97. 21 10
      user/themes/antimatter/templates/partials/navigation.html.twig
  98. 8 8
      user/themes/antimatter/templates/partials/sidebar.html.twig
  99. 15 10
      user/themes/epau-antimatter/css-compiled/template.css
  100. 23 16
      user/themes/epau-antimatter/scss/template/_custom.scss

+ 52 - 0
CHANGELOG.md

@@ -1,3 +1,55 @@
+# v1.7.14
+## 04/29/2021
+
+1. [](#new)
+    * Added `MediaUploadTrait::checkFileMetadata()` method
+1. [](#improved)
+    * Updating a theme should always keep the custom files [getgrav/grav-plugin-admin#2135](https://github.com/getgrav/grav-plugin-admin/issues/2135)
+1. [](#bugfix)
+    * Fixed broken numeric language codes in Flex Pages [#3332](https://github.com/getgrav/grav/issues/3332)
+    * Fixed broken `exif_imagetype()` twig function
+
+# v1.7.13
+## 04/23/2021
+
+1. [](#new)
+    * Added support for getting translated collection of Flex Pages using `$collection->withTranslated('de')`
+1. [](#improved)
+    * Moved `gregwar/Image` and `gregwar/Cache` in-house to official `getgrav/Image` and `getgrav/Cache` packagist packages. This will help environments with very strict proxy setups that don't allow VCS setup. [#3289](https://github.com/getgrav/grav/issues/3289)
+    * Improved XSS Invalid Protocol detection regex [#3298](https://github.com/getgrav/grav/issues/3298)
+    * Added support for user provided folder in Flex `$page->copy()`
+1. [](#bugfix)
+    * Fixed `The "Grav/Common/Twig/TwigExtension" extension is not enabled` when using markdown twig tag [#3317](https://github.com/getgrav/grav/issues/3317)
+    * Fixed text field maxlength validation newline issue [#3324](https://github.com/getgrav/grav/issues/3324)
+    * Fixed a bug in Flex Object `refresh()` method
+
+# v1.7.12
+## 04/15/2021
+
+1. [](#improved)
+    * Improve JSON support for the request
+1. [](#bugfix)
+    * Fixed absolute path support for Windows [#3297](https://github.com/getgrav/grav/issues/3297)
+    * Fixed adding tags in admin after upgrading Grav [#3315](https://github.com/getgrav/grav/issues/3315)
+
+# v1.7.11
+## 04/13/2021
+
+1. [](#new)
+    * Added configuration options to allow PHP methods to be used in Twig functions (`system.twig.safe_functions`) and filters (`system.twig.safe_filters`)
+    * Deprecated using PHP methods in Twig without them being in the safe lists
+    * Prevent dangerous PHP methods from being used as Twig functions and filters
+    * Restrict filesystem Twig functions to accept only local filesystem and grav streams
+1. [](#improved)
+    * Better GPM detection of unauthorized installations
+1. [](#bugfix)
+  * **IMPORTANT** Fixed security vulnerability with Twig allowing dangerous PHP functions by default [GHSA-g8r4-p96j-xfxc](https://github.com/getgrav/grav/security/advisories/GHSA-g8r4-p96j-xfxc)
+    * Fixed nxinx appending repeating `?_url=` in some redirects
+    * Fixed deleting page with language code not removing the folder if it was the last language [#3305](https://github.com/getgrav/grav/issues/3305)
+    * Fixed fatal error when using markdown links with `image://` stream [#3285](https://github.com/getgrav/grav/issues/3285)
+    * Fixed `system.languages.session_store_active` not having any effect [#3269](https://github.com/getgrav/grav/issues/3269)
+    * Fixed fatal error if `system.pages.types` is not an array [#2984](https://github.com/getgrav/grav/issues/2984)
+
 # v1.7.10
 ## 04/06/2021
 

+ 118 - 34
README.md

@@ -1,66 +1,150 @@
+# ![](https://avatars1.githubusercontent.com/u/8237355?v=2&s=50) Grav
 
-#Installation site GIP EPAU
+[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan)
+[![SensioLabsInsight](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad/mini.png)](https://insight.sensiolabs.com/projects/cfd20465-d0f8-4a0a-8444-467f5b5f16ad)
+[![Discord](https://img.shields.io/discord/501836936584101899.svg?logo=discord&colorB=728ADA&label=Discord%20Chat)](https://chat.getgrav.org)
+ [![PHP Tests](https://github.com/getgrav/grav/workflows/PHP%20Tests/badge.svg?branch=develop)](https://github.com/getgrav/grav/actions?query=workflow%3A%22PHP+Tests%22) [![OpenCollective](https://opencollective.com/grav/backers/badge.svg)](#backers) [![OpenCollective](https://opencollective.com/grav/sponsors/badge.svg)](#sponsors)
 
-### intall grav avec composer
+Grav is a **Fast**, **Simple**, and **Flexible**, file-based Web-platform.  There is **Zero** installation required.  Just extract the ZIP archive, and you are already up and running.  It follows similar principles to other flat-file CMS platforms, but has a different design philosophy than most. Grav comes with a powerful **Package Management System** to allow for simple installation and upgrading of plugins and themes, as well as simple updating of Grav itself.
+
+The underlying architecture of Grav is designed to use well-established and _best-in-class_ technologies to ensure that Grav is simple to use and easy to extend. Some of these key technologies include:
+
+* [Twig Templating](https://twig.sensiolabs.org/): for powerful control of the user interface
+* [Markdown](https://en.wikipedia.org/wiki/Markdown): for easy content creation
+* [YAML](https://yaml.org): for simple configuration
+* [Parsedown](https://parsedown.org/): for fast Markdown and Markdown Extra support
+* [Doctrine Cache](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/caching.html): layer for performance
+* [Pimple Dependency Injection Container](https://pimple.sensiolabs.org/): for extensibility and maintainability
+* [Symfony Event Dispatcher](https://symfony.com/doc/current/components/event_dispatcher/introduction.html): for plugin event handling
+* [Symfony Console](https://symfony.com/doc/current/components/console/introduction.html): for CLI interface
+* [Gregwar Image Library](https://github.com/Gregwar/Image): for dynamic image manipulation
+
+# Requirements
+
+- PHP 7.3.6 or higher. Check the [required modules list](https://learn.getgrav.org/basics/requirements#php-requirements)
+- Check the [Apache](https://learn.getgrav.org/basics/requirements#apache-requirements) or [IIS](https://learn.getgrav.org/basics/requirements#iis-requirements) requirements
+
+# Documentation
+
+The full documentation can be found from [learn.getgrav.org](https://learn.getgrav.org).
+
+# QuickStart
+
+These are the options to get Grav:
+
+### Downloading a Grav Package
+
+You can download a **ready-built** package from the [Downloads page on https://getgrav.org](https://getgrav.org/downloads)
+
+### With Composer
+
+You can create a new project with the latest **stable** Grav release with the following command:
 
 ```
-composer create-project getgrav/grav ~/webroot/grav
+$ composer create-project getgrav/grav ~/webroot/grav
 ```
 
+### From GitHub
+
+1. Clone the Grav repository from [https://github.com/getgrav/grav]() to a folder in the webroot of your server, e.g. `~/webroot/grav`. Launch a **terminal** or **console** and navigate to the webroot folder:
+   ```
+   $ cd ~/webroot
+   $ git clone https://github.com/getgrav/grav.git
+   ```
+
+2. Install the **plugin** and **theme dependencies** by using the [Grav CLI application](https://learn.getgrav.org/advanced/grav-cli) `bin/grav`:
+   ```
+   $ cd ~/webroot/grav
+   $ bin/grav install
+   ```
 
+Check out the [install procedures](https://learn.getgrav.org/basics/installation) for more information.
 
-#### installer version admin
+# Adding Functionality
+
+You can download [plugins](https://getgrav.org/downloads/plugins) or [themes](https://getgrav.org/downloads/themes) manually from the appropriate tab on the [Downloads page on https://getgrav.org](https://getgrav.org/downloads), but the preferred solution is to use the [Grav Package Manager](https://learn.getgrav.org/advanced/grav-gpm) or `GPM`:
 
-mise à jours
 ```
-cd ~/webroot/nomdusite
-bin/gpm selfupgrade  
+$ bin/gpm index
 ```
-installe l’admin
+
+This will display all the available plugins and then you can install one or more with:
+
 ```
-bin/gpm install admin   
+$ bin/gpm install <plugin/theme>
 ```
-créer un nouvel utilisateur
+
+# Updating
+
+To update Grav you should use the [Grav Package Manager](https://learn.getgrav.org/advanced/grav-gpm) or `GPM`:
+
 ```
-bin/plugin login newuser
+$ bin/gpm selfupgrade
 ```
 
-#### installe  devtools et créer nouveau theme
+To update plugins and themes:
 
 ```
-bin/gpm install devtools
-
-bin/plugin devtools new-theme
+$ bin/gpm update
 ```
 
+## Upgrading from older version
 
-# Dans le navigateur
+* [Upgrading to Grav 1.7](https://learn.getgrav.org/16/advanced/grav-development/grav-17-upgrade-guide)
+* [Upgrading to Grav 1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-16-upgrade-guide)
+* [Upgrading from Grav <1.6](https://learn.getgrav.org/16/advanced/grav-development/grav-15-upgrade-guide)
 
-aller sur http://localhost:8000/
-pour voir le site
+# Contributing
+We appreciate any contribution to Grav, whether it is related to bugs, grammar, or simply a suggestion or improvement! Please refer to the [Contributing guide](CONTRIBUTING.md) for more guidance on this topic.
 
-aller sur http://localhost:8000/admin
-pour accéder au back office
+## Security issues
+If you discover a possible security issue related to Grav or one of its plugins, please email the core team at contact@getgrav.org and we'll address it as soon as possible.
 
+# Getting Started
 
+* [What is Grav?](https://learn.getgrav.org/basics/what-is-grav)
+* [Install](https://learn.getgrav.org/basics/installation) Grav in few seconds
+* Understand the [Configuration](https://learn.getgrav.org/basics/grav-configuration)
+* Take a peek at our available free [Skeletons](https://getgrav.org/downloads/skeletons)
+* If you have questions, jump on our [Discord Chat Server](https://chat.getgrav.org)!
+* Have fun!
 
-\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
+# Exploring More
 
+* Have a look at our [Basic Tutorial](https://learn.getgrav.org/basics/basic-tutorial)
+* Dive into more [advanced](https://learn.getgrav.org/advanced) functions
+* Learn about the [Grav CLI](https://learn.getgrav.org/cli-console/grav-cli)
+* Review examples in the [Grav Cookbook](https://learn.getgrav.org/cookbook)
+* More [Awesome Grav Stuff](https://github.com/getgrav/awesome-grav)
 
+# Backers
+Support Grav with a monthly donation to help us continue development. [[Become a backer](https://opencollective.com/grav#backer)]
 
-# Compilé les SCSS
+<img src="https://opencollective.com/grav/tiers/backers.svg?avatarHeight=36&width=600" />
 
-```
-$ cd chemin/du/dossier/dev-epau.archi.fr
-```
-```
-$ npm install
-```
-(uniquement la première fois)
+# Sponsors
+Become a sponsor and get your logo on our README on Github with a link to your site. [[Become a sponsor](https://opencollective.com/grav#sponsor)]
 
-puis à chaque fois, dans une 2e fenêtre de Terminal, qui doit également restée ouverte:
+<img src="https://opencollective.com/grav/tiers/sponsors.svg?avatarHeight=36&width=600" />
 
-```
-$ cd chemin/du/dossier/dev-epau.archi.fr/user/theme/epau-antimatter
-$ npm run sass
-```
+# License
+
+See [LICENSE](LICENSE.txt)
+
+
+[gitflow-model]: http://nvie.com/posts/a-successful-git-branching-model/
+[gitflow-extensions]: https://github.com/nvie/gitflow
+
+# Running Tests
+
+First install the dev dependencies by running `composer install` from the Grav root.
+
+Then `composer test` will run the Unit Tests, which should be always executed successfully on any site.
+Windows users should use the `composer test-windows` command.
+You can also run a single unit test file, e.g. `composer test tests/unit/Grav/Common/AssetsTest.php`
+
+To run phpstan tests, you should run:
+
+* `composer phpstan` for global tests
+* `composer phpstan-framework` for more strict tests
+* `composer phpstan-plugins` to test all installed plugins

+ 3 - 17
composer.json

@@ -44,8 +44,8 @@
         "filp/whoops": "~2.9",
         "matthiasmullie/minify": "^1.3",
         "monolog/monolog": "~1.25",
-        "gregwar/image": "dev-php8",
-        "gregwar/cache": "dev-php8",
+        "getgrav/image": "^3.0",
+        "getgrav/cache": "^2.0",
         "donatj/phpuseragentparser": "~1.1",
         "pimple/pimple": "~3.3.0",
         "rockettheme/toolbox": "~1.5",
@@ -67,7 +67,7 @@
         "phpstan/phpstan": "^0.12",
         "phpstan/phpstan-deprecation-rules": "^0.12",
         "phpunit/php-code-coverage": "~9.2",
-        "victorjonsson/markdowndocs": "dev-master",
+        "getgrav/markdowndocs": "^2.0",
         "codeception/module-asserts": "^1.3",
         "codeception/module-phpbrowser": "^1.0",
         "symfony/service-contracts": "*"
@@ -91,20 +91,6 @@
             "php": "7.3.6"
         }
     },
-    "repositories": [
-        {
-            "type": "vcs",
-            "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator"
-        },
-        {
-            "type": "vcs",
-            "url": "https://github.com/getgrav/Cache"
-        },
-        {
-            "type": "vcs",
-            "url": "https://github.com/getgrav/Image"
-        }
-    ],
     "autoload": {
         "psr-4": {
             "Grav\\": "system/src/Grav"

+ 125 - 113
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "4ae6fc7274c018b1bb34bb1b80bd62c5",
+    "content-hash": "36375d8a5daf3aef0341f9a2b023f4e9",
     "packages": [
         {
             "name": "antoligy/dom-string-iterators",
@@ -212,40 +212,39 @@
         },
         {
             "name": "doctrine/cache",
-            "version": "1.10.2",
+            "version": "1.11.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/cache.git",
-                "reference": "13e3381b25847283a91948d04640543941309727"
+                "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/cache/zipball/13e3381b25847283a91948d04640543941309727",
-                "reference": "13e3381b25847283a91948d04640543941309727",
+                "url": "https://api.github.com/repos/doctrine/cache/zipball/a9c1b59eba5a08ca2770a76eddb88922f504e8e0",
+                "reference": "a9c1b59eba5a08ca2770a76eddb88922f504e8e0",
                 "shasum": ""
             },
             "require": {
                 "php": "~7.1 || ^8.0"
             },
             "conflict": {
-                "doctrine/common": ">2.2,<2.4"
+                "doctrine/common": ">2.2,<2.4",
+                "psr/cache": ">=3"
             },
             "require-dev": {
                 "alcaeus/mongo-php-adapter": "^1.1",
-                "doctrine/coding-standard": "^6.0",
+                "cache/integration-tests": "dev-master",
+                "doctrine/coding-standard": "^8.0",
                 "mongodb/mongodb": "^1.1",
-                "phpunit/phpunit": "^7.0",
-                "predis/predis": "~1.0"
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "predis/predis": "~1.0",
+                "psr/cache": "^1.0 || ^2.0",
+                "symfony/cache": "^4.4 || ^5.2"
             },
             "suggest": {
                 "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.9.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache"
@@ -292,7 +291,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/cache/issues",
-                "source": "https://github.com/doctrine/cache/tree/1.10.x"
+                "source": "https://github.com/doctrine/cache/tree/1.11.0"
             },
             "funding": [
                 {
@@ -308,7 +307,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-07-07T18:54:01+00:00"
+            "time": "2021-04-13T14:46:17+00:00"
         },
         {
             "name": "doctrine/collections",
@@ -642,16 +641,16 @@
         },
         {
             "name": "filp/whoops",
-            "version": "2.12.0",
+            "version": "2.12.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/filp/whoops.git",
-                "reference": "d501fd2658d55491a2295ff600ae5978eaad7403"
+                "reference": "c13c0be93cff50f88bbd70827d993026821914dd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/filp/whoops/zipball/d501fd2658d55491a2295ff600ae5978eaad7403",
-                "reference": "d501fd2658d55491a2295ff600ae5978eaad7403",
+                "url": "https://api.github.com/repos/filp/whoops/zipball/c13c0be93cff50f88bbd70827d993026821914dd",
+                "reference": "c13c0be93cff50f88bbd70827d993026821914dd",
                 "shasum": ""
             },
             "require": {
@@ -701,7 +700,7 @@
             ],
             "support": {
                 "issues": "https://github.com/filp/whoops/issues",
-                "source": "https://github.com/filp/whoops/tree/2.12.0"
+                "source": "https://github.com/filp/whoops/tree/2.12.1"
             },
             "funding": [
                 {
@@ -709,21 +708,21 @@
                     "type": "github"
                 }
             ],
-            "time": "2021-03-30T12:00:00+00:00"
+            "time": "2021-04-25T12:00:00+00:00"
         },
         {
-            "name": "gregwar/cache",
-            "version": "dev-php8",
+            "name": "getgrav/cache",
+            "version": "v2.0.0",
             "target-dir": "Gregwar/Cache",
             "source": {
                 "type": "git",
                 "url": "https://github.com/getgrav/Cache.git",
-                "reference": "49ccdf9ae760b009a192bc3c7b417980c8a8cc2e"
+                "reference": "56fd63f752779928fcd1074ab7d12f406dde8861"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/getgrav/Cache/zipball/49ccdf9ae760b009a192bc3c7b417980c8a8cc2e",
-                "reference": "49ccdf9ae760b009a192bc3c7b417980c8a8cc2e",
+                "url": "https://api.github.com/repos/getgrav/Cache/zipball/56fd63f752779928fcd1074ab7d12f406dde8861",
+                "reference": "56fd63f752779928fcd1074ab7d12f406dde8861",
                 "shasum": ""
             },
             "require": {
@@ -735,6 +734,7 @@
                     "Gregwar\\Cache": ""
                 }
             },
+            "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
@@ -742,6 +742,11 @@
                 {
                     "name": "Gregwar",
                     "email": "g.passault@gmail.com"
+                },
+                {
+                    "name": "Grav CMS",
+                    "email": "hello@getgrav.org",
+                    "homepage": "https://getgrav.org"
                 }
             ],
             "description": "A lightweight file-system cache system",
@@ -752,28 +757,28 @@
                 "system"
             ],
             "support": {
-                "source": "https://github.com/getgrav/Cache/tree/php8"
+                "source": "https://github.com/getgrav/Cache/tree/v2.0.0"
             },
-            "time": "2020-12-02T10:54:35+00:00"
+            "time": "2021-04-20T05:48:00+00:00"
         },
         {
-            "name": "gregwar/image",
-            "version": "dev-php8",
+            "name": "getgrav/image",
+            "version": "v3.0.0",
             "target-dir": "Gregwar/Image",
             "source": {
                 "type": "git",
                 "url": "https://github.com/getgrav/Image.git",
-                "reference": "ea23859700f32447a85e79d96f331e3d6c8897a8"
+                "reference": "02c1bb2c179dd894c4f6610c9c49da364ee7d264"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/getgrav/Image/zipball/ea23859700f32447a85e79d96f331e3d6c8897a8",
-                "reference": "ea23859700f32447a85e79d96f331e3d6c8897a8",
+                "url": "https://api.github.com/repos/getgrav/Image/zipball/02c1bb2c179dd894c4f6610c9c49da364ee7d264",
+                "reference": "02c1bb2c179dd894c4f6610c9c49da364ee7d264",
                 "shasum": ""
             },
             "require": {
                 "ext-gd": "*",
-                "gregwar/cache": "dev-php8",
+                "getgrav/cache": "^2.0",
                 "php": "^5.6 || ^7.0 || ^8.0"
             },
             "require-dev": {
@@ -789,6 +794,7 @@
                     "Gregwar\\Image": ""
                 }
             },
+            "notification-url": "https://packagist.org/downloads/",
             "license": [
                 "MIT"
             ],
@@ -797,6 +803,11 @@
                     "name": "Grégoire Passault",
                     "email": "g.passault@gmail.com",
                     "homepage": "http://www.gregwar.com/"
+                },
+                {
+                    "name": "Grav CMS",
+                    "email": "hello@getgrav.org",
+                    "homepage": "https://getgrav.org"
                 }
             ],
             "description": "Image handling",
@@ -806,9 +817,9 @@
                 "image"
             ],
             "support": {
-                "source": "https://github.com/getgrav/Image/tree/php8"
+                "source": "https://github.com/getgrav/Image/tree/v3.0.0"
             },
-            "time": "2021-03-15T17:03:52+00:00"
+            "time": "2021-04-20T05:50:18+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
@@ -2135,16 +2146,16 @@
         },
         {
             "name": "rockettheme/toolbox",
-            "version": "1.5.7",
+            "version": "1.5.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/rockettheme/toolbox.git",
-                "reference": "8d3ebc4d982595d6eac90e851f2b4d5c0cec0399"
+                "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/8d3ebc4d982595d6eac90e851f2b4d5c0cec0399",
-                "reference": "8d3ebc4d982595d6eac90e851f2b4d5c0cec0399",
+                "url": "https://api.github.com/repos/rockettheme/toolbox/zipball/2d6693235aaca2efaadb61c84dac927aaf4eabfa",
+                "reference": "2d6693235aaca2efaadb61c84dac927aaf4eabfa",
                 "shasum": ""
             },
             "require": {
@@ -2185,9 +2196,9 @@
             ],
             "support": {
                 "issues": "https://github.com/rockettheme/toolbox/issues",
-                "source": "https://github.com/rockettheme/toolbox/tree/1.5.7"
+                "source": "https://github.com/rockettheme/toolbox/tree/1.5.9"
             },
-            "time": "2021-02-17T17:58:36+00:00"
+            "time": "2021-04-14T19:52:40+00:00"
         },
         {
             "name": "seld/cli-prompt",
@@ -3415,16 +3426,16 @@
         },
         {
             "name": "codeception/codeception",
-            "version": "4.1.19",
+            "version": "4.1.20",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Codeception/Codeception.git",
-                "reference": "138dc9345a81ec994dcd6b9680c501a752a37b00"
+                "reference": "d8b16e13e1781dbc3a7ae8292117d520c89a9c5a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Codeception/Codeception/zipball/138dc9345a81ec994dcd6b9680c501a752a37b00",
-                "reference": "138dc9345a81ec994dcd6b9680c501a752a37b00",
+                "url": "https://api.github.com/repos/Codeception/Codeception/zipball/d8b16e13e1781dbc3a7ae8292117d520c89a9c5a",
+                "reference": "d8b16e13e1781dbc3a7ae8292117d520c89a9c5a",
                 "shasum": ""
             },
             "require": {
@@ -3498,7 +3509,7 @@
             ],
             "support": {
                 "issues": "https://github.com/Codeception/Codeception/issues",
-                "source": "https://github.com/Codeception/Codeception/tree/4.1.19"
+                "source": "https://github.com/Codeception/Codeception/tree/4.1.20"
             },
             "funding": [
                 {
@@ -3506,7 +3517,7 @@
                     "type": "open_collective"
                 }
             ],
-            "time": "2021-03-28T13:26:08+00:00"
+            "time": "2021-04-02T16:41:51+00:00"
         },
         {
             "name": "codeception/lib-asserts",
@@ -3564,16 +3575,16 @@
         },
         {
             "name": "codeception/lib-innerbrowser",
-            "version": "1.4.1",
+            "version": "1.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Codeception/lib-innerbrowser.git",
-                "reference": "693e116f81ef98eae98c43ef785a726faf87394e"
+                "reference": "4b0d89b37fe454e060a610a85280a87ab4f534f1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/693e116f81ef98eae98c43ef785a726faf87394e",
-                "reference": "693e116f81ef98eae98c43ef785a726faf87394e",
+                "url": "https://api.github.com/repos/Codeception/lib-innerbrowser/zipball/4b0d89b37fe454e060a610a85280a87ab4f534f1",
+                "reference": "4b0d89b37fe454e060a610a85280a87ab4f534f1",
                 "shasum": ""
             },
             "require": {
@@ -3618,9 +3629,9 @@
             ],
             "support": {
                 "issues": "https://github.com/Codeception/lib-innerbrowser/issues",
-                "source": "https://github.com/Codeception/lib-innerbrowser/tree/1.4.1"
+                "source": "https://github.com/Codeception/lib-innerbrowser/tree/1.5.0"
             },
-            "time": "2021-03-02T08:01:54+00:00"
+            "time": "2021-04-23T06:18:29+00:00"
         },
         {
             "name": "codeception/module-asserts",
@@ -3891,6 +3902,58 @@
             ],
             "time": "2020-11-10T18:47:58+00:00"
         },
+        {
+            "name": "getgrav/markdowndocs",
+            "version": "2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator.git",
+                "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/getgrav/PHP-Markdown-Documentation-Generator/zipball/4a24d1b64a88da17e8f1696dc64969f5ca769064",
+                "reference": "4a24d1b64a88da17e8f1696dc64969f5ca769064",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.5.0",
+                "symfony/console": ">=2.6"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "3.7.23"
+            },
+            "bin": [
+                "bin/phpdoc-md"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "PHPDocsMD": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Victor Jonsson",
+                    "email": "kontakt@victorjonsson.se"
+                },
+                {
+                    "name": "Grav CMS",
+                    "email": "hello@getgrav.org",
+                    "homepage": "https://getgrav.org"
+                }
+            ],
+            "description": "Command line tool for generating markdown-formatted class documentation",
+            "homepage": "https://github.com/victorjonsson/PHP-Markdown-Documentation-Generator",
+            "support": {
+                "source": "https://github.com/getgrav/PHP-Markdown-Documentation-Generator/tree/2.0.1"
+            },
+            "time": "2021-04-20T06:04:42+00:00"
+        },
         {
             "name": "guzzlehttp/guzzle",
             "version": "7.3.0",
@@ -4501,16 +4564,16 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "0.12.82",
+            "version": "0.12.84",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "3920f0fb0aff39263d3a4cb0bca120a67a1a6a11"
+                "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/3920f0fb0aff39263d3a4cb0bca120a67a1a6a11",
-                "reference": "3920f0fb0aff39263d3a4cb0bca120a67a1a6a11",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c43f15da8798c8f30a4b099e6a94530a558cfd5",
+                "reference": "9c43f15da8798c8f30a4b099e6a94530a558cfd5",
                 "shasum": ""
             },
             "require": {
@@ -4541,7 +4604,7 @@
             "description": "PHPStan - PHP Static Analysis Tool",
             "support": {
                 "issues": "https://github.com/phpstan/phpstan/issues",
-                "source": "https://github.com/phpstan/phpstan/tree/0.12.82"
+                "source": "https://github.com/phpstan/phpstan/tree/0.12.84"
             },
             "funding": [
                 {
@@ -4557,7 +4620,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2021-03-19T06:08:17+00:00"
+            "time": "2021-04-19T17:10:54+00:00"
         },
         {
             "name": "phpstan/phpstan-deprecation-rules",
@@ -6368,53 +6431,6 @@
             ],
             "time": "2020-07-12T23:59:07+00:00"
         },
-        {
-            "name": "victorjonsson/markdowndocs",
-            "version": "dev-master",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator.git",
-                "reference": "c9fa153b28a79f5da89ec32aa501be92db212aed"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/trilbymedia/PHP-Markdown-Documentation-Generator/zipball/c9fa153b28a79f5da89ec32aa501be92db212aed",
-                "reference": "c9fa153b28a79f5da89ec32aa501be92db212aed",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.5.0",
-                "symfony/console": ">=2.6"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "3.7.23"
-            },
-            "default-branch": true,
-            "bin": [
-                "bin/phpdoc-md"
-            ],
-            "type": "library",
-            "autoload": {
-                "psr-0": {
-                    "PHPDocsMD": "src/"
-                }
-            },
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Victor Jonsson",
-                    "email": "kontakt@victorjonsson.se"
-                }
-            ],
-            "description": "Command line tool for generating markdown-formatted class documentation",
-            "homepage": "https://github.com/victorjonsson/PHP-Markdown-Documentation-Generator",
-            "support": {
-                "source": "https://github.com/trilbymedia/PHP-Markdown-Documentation-Generator/tree/master"
-            },
-            "time": "2017-09-20T13:29:22+00:00"
-        },
         {
             "name": "webmozart/assert",
             "version": "1.10.0",
@@ -6476,11 +6492,7 @@
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {
-        "gregwar/image": 20,
-        "gregwar/cache": 20,
-        "victorjonsson/markdowndocs": 20
-    },
+    "stability-flags": [],
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": {

+ 1 - 0
system/blueprints/flex/user-groups.yaml

@@ -18,6 +18,7 @@ config:
         configure:
           path: '/accounts/configure'
       redirects:
+        '/groups': '/accounts/groups'
         '/accounts': '/accounts/groups'
 
     # Permissions

+ 1 - 1
system/blueprints/pages/default.yaml

@@ -121,7 +121,7 @@ form:
                       underline: true
 
                     folder:
-                      type: text
+                      type: folder-slug
                       label: PLUGIN_ADMIN.FOLDER_NAME
                       validate:
                         rule: slug

+ 2 - 0
system/config/system.yaml

@@ -113,6 +113,8 @@ twig:
   autoescape: true                               # Autoescape Twig vars (DEPRECATED, always enabled in strict mode)
   undefined_functions: true                      # Allow undefined functions
   undefined_filters: true                        # Allow undefined filters
+  safe_functions: []                             # List of PHP functions which are allowed to be used as Twig functions
+  safe_filters: []                               # List of PHP functions which are allowed to be used as Twig filters
   umask_fix: false                               # By default Twig creates cached files as 755, fix switches this to 775
 
 assets:                                          # Configuration for Assets Manager (JS, CSS)

+ 28 - 17
system/defines.php

@@ -1,4 +1,5 @@
 <?php
+
 /**
  * @package    Grav\Core
  *
@@ -8,7 +9,7 @@
 
 // Some standard defines
 define('GRAV', true);
-define('GRAV_VERSION', '1.7.10');
+define('GRAV_VERSION', '1.7.14');
 define('GRAV_SCHEMA', '1.7.0_2020-11-20_1');
 define('GRAV_TESTING', false);
 
@@ -22,58 +23,68 @@ if (!defined('DS')) {
     define('DS', '/');
 }
 
-// Directories and Paths
+// Absolute path to Grav root. This is where Grav is installed into.
 if (!defined('GRAV_ROOT')) {
     $path = rtrim(str_replace(DIRECTORY_SEPARATOR, DS, getenv('GRAV_ROOT') ?: getcwd()), DS);
     define('GRAV_ROOT', $path);
 }
+// Absolute path to Grav webroot. This is the path where your site is located in.
 if (!defined('GRAV_WEBROOT')) {
-    define('GRAV_WEBROOT', GRAV_ROOT);
+    $path = rtrim(getenv('GRAV_WEBROOT') ?: GRAV_ROOT, DS);
+    define('GRAV_WEBROOT', $path);
 }
+// Relative path to user folder. This path needs to be located under GRAV_WEBROOT.
 if (!defined('GRAV_USER_PATH')) {
     $path = rtrim(getenv('GRAV_USER_PATH') ?: 'user', DS);
     define('GRAV_USER_PATH', $path);
 }
+// Absolute or relative path to system folder. Defaults to GRAV_ROOT/system
+// If system folder is outside of webroot, see https://github.com/getgrav/grav/issues/3297#issuecomment-810294972
 if (!defined('GRAV_SYSTEM_PATH')) {
     $path = rtrim(getenv('GRAV_SYSTEM_PATH') ?: 'system', DS);
     define('GRAV_SYSTEM_PATH', $path);
 }
+// Absolute or relative path to cache folder. Defaults to GRAV_ROOT/cache
 if (!defined('GRAV_CACHE_PATH')) {
     $path = rtrim(getenv('GRAV_CACHE_PATH') ?: 'cache', DS);
     define('GRAV_CACHE_PATH', $path);
 }
+// Absolute or relative path to logs folder. Defaults to GRAV_ROOT/logs
 if (!defined('GRAV_LOG_PATH')) {
     $path = rtrim(getenv('GRAV_LOG_PATH') ?: 'logs', DS);
     define('GRAV_LOG_PATH', $path);
 }
+// Absolute or relative path to tmp folder. Defaults to GRAV_ROOT/tmp
 if (!defined('GRAV_TMP_PATH')) {
     $path = rtrim(getenv('GRAV_TMP_PATH') ?: 'tmp', DS);
     define('GRAV_TMP_PATH', $path);
 }
+// Absolute or relative path to backup folder. Defaults to GRAV_ROOT/backup
 if (!defined('GRAV_BACKUP_PATH')) {
     $path = rtrim(getenv('GRAV_BACKUP_PATH') ?: 'backup', DS);
     define('GRAV_BACKUP_PATH', $path);
 }
 unset($path);
 
-define('USER_PATH', GRAV_USER_PATH . DS);
-define('CACHE_PATH', GRAV_CACHE_PATH . DS);
-define('ROOT_DIR', GRAV_ROOT . DS);
-define('USER_DIR', (!str_starts_with(USER_PATH, '/') ? GRAV_WEBROOT . '/' : '') . USER_PATH);
-define('CACHE_DIR', (!str_starts_with(CACHE_PATH, '/') ? ROOT_DIR : '') . CACHE_PATH);
+// INTERNAL: Do not use!
+define('USER_DIR', GRAV_WEBROOT . '/' . GRAV_USER_PATH . '/');
+define('CACHE_DIR', (!preg_match('`^(/|[a-z]:[\\\/])`ui', GRAV_CACHE_PATH) ? GRAV_ROOT . '/' : '') . GRAV_CACHE_PATH . '/');
 
 // DEPRECATED: Do not use!
+define('CACHE_PATH', GRAV_CACHE_PATH . DS);
+define('USER_PATH', GRAV_USER_PATH . DS);
+define('ROOT_DIR', GRAV_ROOT . DS);
 define('ASSETS_DIR', GRAV_WEBROOT . '/assets/');
 define('IMAGES_DIR', GRAV_WEBROOT . '/images/');
-define('ACCOUNTS_DIR', USER_DIR .'accounts/');
-define('PAGES_DIR', USER_DIR .'pages/');
-define('DATA_DIR', USER_DIR .'data/');
-define('PLUGINS_DIR', USER_DIR .'plugins/');
-define('THEMES_DIR', USER_DIR .'themes/');
-define('SYSTEM_DIR', (!str_starts_with(GRAV_SYSTEM_PATH, '/') ? ROOT_DIR : '') . GRAV_SYSTEM_PATH);
-define('LIB_DIR', SYSTEM_DIR .'src/');
-define('VENDOR_DIR', ROOT_DIR .'vendor/');
-define('LOG_DIR', (!str_starts_with(GRAV_LOG_PATH, '/') ? ROOT_DIR : '') . GRAV_LOG_PATH . DS);
+define('ACCOUNTS_DIR', USER_DIR . 'accounts/');
+define('PAGES_DIR', USER_DIR . 'pages/');
+define('DATA_DIR', USER_DIR . 'data/');
+define('PLUGINS_DIR', USER_DIR . 'plugins/');
+define('THEMES_DIR', USER_DIR . 'themes/');
+define('SYSTEM_DIR', (!preg_match('`^(/|[a-z]:[\\\/])`ui', GRAV_SYSTEM_PATH) ? GRAV_ROOT . '/' : '') . GRAV_SYSTEM_PATH . '/');
+define('LIB_DIR', SYSTEM_DIR . 'src/');
+define('VENDOR_DIR', GRAV_ROOT . '/vendor/');
+define('LOG_DIR', (!preg_match('`^(/|[a-z]:[\\\/])`ui', GRAV_LOG_PATH) ? GRAV_ROOT . '/' : '') . GRAV_LOG_PATH . '/');
 // END DEPRECATED
 
 // Some extensions

+ 1 - 1
system/src/Grav/Common/Assets/Traits/AssetUtilsTrait.php

@@ -90,7 +90,7 @@ trait AssetUtilsTrait
                 }
 
                 $relative_dir = dirname($relative_path);
-                $link = ROOT_DIR . $relative_path;
+                $link = GRAV_ROOT . '/' . $relative_path;
             }
 
             // TODO: looks like this is not being used.

+ 1 - 1
system/src/Grav/Common/Assets/Traits/TestingAssetsTrait.php

@@ -252,7 +252,7 @@ trait TestingAssetsTrait
      */
     public function addDir($directory, $pattern = self::DEFAULT_REGEX)
     {
-        $root_dir = rtrim(ROOT_DIR, '/');
+        $root_dir = GRAV_ROOT;
 
         // Check if $directory is a stream.
         if (strpos($directory, '://')) {

+ 2 - 1
system/src/Grav/Common/Data/Validation.php

@@ -238,6 +238,7 @@ class Validation
             $value = trim($value);
         }
 
+        $value = preg_replace("/\r\n|\r/um", "\n", $value);
         $len = mb_strlen($value);
 
         $min = (int)($params['min'] ?? 0);
@@ -280,7 +281,7 @@ class Validation
             $value = trim($value);
         }
 
-        return $value;
+        return preg_replace("/\r\n|\r/um", "\n", $value);
     }
 
     /**

+ 10 - 0
system/src/Grav/Common/Flex/Types/Pages/PageCollection.php

@@ -746,6 +746,16 @@ class PageCollection extends FlexPageCollection implements PageCollectionInterfa
         return $bool ? $this->select($list) : $this->unselect($list);
     }
 
+    /**
+     * @param string|null $languageCode
+     * @param bool|null $fallback
+     * @return PageIndex
+     */
+    public function withTranslated(string $languageCode = null, bool $fallback = null)
+    {
+        return $this->getIndex()->withTranslated($languageCode, $fallback);
+    }
+
     /**
      * Filter pages by given filters.
      *

+ 150 - 0
system/src/Grav/Common/Flex/Types/Pages/PageIndex.php

@@ -18,6 +18,7 @@ use Grav\Common\File\CompiledYamlFile;
 use Grav\Common\Flex\Traits\FlexGravTrait;
 use Grav\Common\Flex\Traits\FlexIndexTrait;
 use Grav\Common\Grav;
+use Grav\Common\Language\Language;
 use Grav\Common\Page\Header;
 use Grav\Common\Page\Interfaces\PageCollectionInterface;
 use Grav\Common\Page\Interfaces\PageInterface;
@@ -164,6 +165,31 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         return $root;
     }
 
+    /**
+     * @param string|null $languageCode
+     * @param bool|null $fallback
+     * @return PageIndex
+     */
+    public function withTranslated(string $languageCode = null, bool $fallback = null)
+    {
+        if (null === $languageCode) {
+            return $this;
+        }
+
+        $entries = $this->translateEntries($this->getEntries(), $languageCode, $fallback);
+        $params = ['language' => $languageCode, 'language_fallback' => $fallback] + $this->getParams();
+
+        return $this->createFrom($entries)->setParams($params);
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getLanguage(): ?string
+    {
+        return $this->_params['language'] ?? null;
+    }
+
     /**
      * Get the collection params
      *
@@ -174,6 +200,17 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         return $this->_params ?? [];
     }
 
+    /**
+     * Get the collection param
+     *
+     * @param string $name
+     * @return mixed
+     */
+    public function getParam(string $name)
+    {
+        return $this->_params[$name] ?? null;
+    }
+
     /**
      * Set parameters to the Collection
      *
@@ -187,6 +224,20 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         return $this;
     }
 
+    /**
+     * Set a parameter to the Collection
+     *
+     * @param string $name
+     * @param mixed $value
+     * @return $this
+     */
+    public function setParam(string $name, $value)
+    {
+        $this->_params[$name] = $value;
+
+        return $this;
+    }
+
     /**
      * Get the collection params
      *
@@ -197,6 +248,15 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         return $this->getParams();
     }
 
+        /**
+     * {@inheritdoc}
+     * @see FlexCollectionInterface::getCacheKey()
+     */
+    public function getCacheKey(): string
+    {
+        return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1(json_encode($this->getKeys()) . $this->getKeyField() . $this->getLanguage());
+    }
+
     /**
      * Filter pages by given filters.
      *
@@ -345,6 +405,96 @@ class PageIndex extends FlexPageIndex implements PageCollectionInterface
         return $index;
     }
 
+    /**
+     * @param array $entries
+     * @param string $lang
+     * @param bool|null $fallback
+     * @return array
+     */
+    protected function translateEntries(array $entries, string $lang, bool $fallback = null): array
+    {
+        $languages = $this->getFallbackLanguages($lang, $fallback);
+        foreach ($entries as $key => &$entry) {
+            // Find out which version of the page we should load.
+            $translations = $this->getLanguageTemplates((string)$key);
+            if (!$translations) {
+                // No translations found, is this a folder?
+                continue;
+            }
+
+            // Find a translation.
+            $template = null;
+            foreach ($languages as $code) {
+                if (isset($translations[$code])) {
+                    $template = $translations[$code];
+                    break;
+                }
+            }
+
+            // We couldn't find a translation, remove entry from the list.
+            if (!isset($code, $template)) {
+                unset($entries['key']);
+                continue;
+            }
+
+            // Get the main key without template and langauge.
+            [$main_key,] = explode('|', $entry['storage_key'] . '|', 2);
+
+            // Update storage key and language.
+            $entry['storage_key'] = $main_key . '|' . $template . '.' . $code;
+            $entry['lang'] = $code;
+        }
+        unset($entry);
+
+        return $entries;
+    }
+
+    /**
+     * @return array
+     */
+    protected function getLanguageTemplates(string $key): array
+    {
+        $meta = $this->getMetaData($key);
+        $template = $meta['template'] ?? 'folder';
+        $translations = $meta['markdown'] ?? [];
+        $list = [];
+        foreach ($translations as $code => $search) {
+            if (isset($search[$template])) {
+                // Use main template if possible.
+                $list[$code] = $template;
+            } elseif (!empty($search)) {
+                // Fall back to first matching template.
+                $list[$code] = key($search);
+            }
+        }
+
+        return $list;
+    }
+
+    /**
+     * @param string|null $languageCode
+     * @param bool|null $fallback
+     * @return array
+     */
+    protected function getFallbackLanguages(string $languageCode = null, bool $fallback = null): array
+    {
+        $fallback = $fallback ?? true;
+        if (!$fallback && null !== $languageCode) {
+            return [$languageCode];
+        }
+
+        $grav = Grav::instance();
+
+        /** @var Language $language */
+        $language = $grav['language'];
+        $languageCode = $languageCode ?? '';
+        if ($languageCode === '' && $fallback) {
+            return $language->getFallbackLanguages(null, true);
+        }
+
+        return $fallback ? $language->getFallbackLanguages($languageCode, true) : [$languageCode];
+    }
+
     /**
      * @param array $options
      * @return array

+ 14 - 3
system/src/Grav/Common/Flex/Types/Pages/Storage/PageStorage.php

@@ -474,17 +474,28 @@ class PageStorage extends FolderStorage
     }
 
     /**
+     * Check if page folder should be deleted.
+     *
+     * Deleting page can be done either by deleting everything or just a single language.
+     * If key contains the language, delete only it, unless it is the last language.
+     *
      * @param string $key
      * @return bool
      */
     protected function canDeleteFolder(string $key): bool
     {
+        // Return true if there's no language in the key.
         $keys = $this->extractKeysFromStorageKey($key);
-        if ($keys['lang']) {
-            return false;
+        if (!$keys['lang']) {
+            return true;
         }
 
-        return true;
+        // Get the main key and reload meta.
+        $key = $this->buildStorageKey($keys);
+        $meta = $this->getObjectMeta($key, true);
+
+        // Return true if there aren't any markdown files left.
+        return empty($meta['markdown'] ?? []);
     }
 
     /**

+ 0 - 5
system/src/Grav/Common/Flex/Types/Users/UserObject.php

@@ -22,7 +22,6 @@ use Grav\Common\Grav;
 use Grav\Common\Media\Interfaces\MediaCollectionInterface;
 use Grav\Common\Media\Interfaces\MediaUploadInterface;
 use Grav\Common\Page\Media;
-use Grav\Common\Page\Medium\Medium;
 use Grav\Common\Page\Medium\MediumFactory;
 use Grav\Common\User\Access;
 use Grav\Common\User\Authentication;
@@ -78,16 +77,12 @@ class UserObject extends FlexObject implements UserInterface, Countable
 
     /** @var array|null */
     protected $_uploads_original;
-
     /** @var FileInterface|null */
     protected $_storage;
-
     /** @var UserGroupIndex */
     protected $_groups;
-
     /** @var Access */
     protected $_access;
-
     /** @var array|null */
     protected $access;
 

+ 1 - 1
system/src/Grav/Common/GPM/GPM.php

@@ -527,7 +527,7 @@ class GPM extends Iterator
         $plugins = $this->getRepositoryPlugins();
 
         if (null === $themes || null === $plugins) {
-            if (!is_writable(ROOT_DIR . '/cache/gpm')) {
+            if (!is_writable(GRAV_ROOT . '/cache/gpm')) {
                 throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.');
             }
 

+ 4 - 1
system/src/Grav/Common/GPM/Installer.php

@@ -135,7 +135,10 @@ class Installer
         }
 
         if (!$options['sophisticated']) {
-            if ($options['theme']) {
+            $isTheme = $options['theme'] ?? false;
+            // Make sure that themes are always being copied, even if option was not set!
+            $isTheme = $isTheme || preg_match('|/themes/[^/]+|ui', $install_path);
+            if ($isTheme) {
                 self::copyInstall($extracted, $install_path);
             } else {
                 self::moveInstall($extracted, $install_path);

+ 37 - 13
system/src/Grav/Common/Media/Traits/MediaUploadTrait.php

@@ -71,15 +71,6 @@ trait MediaUploadTrait
      */
     public function checkUploadedFile(UploadedFileInterface $uploadedFile, string $filename = null, array $settings = null): string
     {
-        // Add the defaults to the settings.
-        $settings = $this->getUploadSettings($settings);
-
-        // Destination is always needed (but it can be set in defaults).
-        $self = $settings['self'] ?? false;
-        if (!isset($settings['destination']) && $self === false) {
-            throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400);
-        }
-
         // Check if there is an upload error.
         switch ($uploadedFile->getError()) {
             case UPLOAD_ERR_OK:
@@ -101,10 +92,38 @@ trait MediaUploadTrait
                 throw new RuntimeException($this->translate('PLUGIN_ADMIN.UNKNOWN_ERRORS'), 400);
         }
 
+        $metadata = [
+            'filename' => $uploadedFile->getClientFilename(),
+            'mime' => $uploadedFile->getClientMediaType(),
+            'size' => $uploadedFile->getSize(),
+        ];
+
+        return $this->checkFileMetadata($metadata, $filename, $settings);
+    }
+
+    /**
+     * Checks that file metadata meets the requirements. Returns new filename.
+     *
+     * @param array $metadata
+     * @param array|null $settings
+     * @return string|null
+     * @throws RuntimeException
+     */
+    public function checkFileMetadata(array $metadata, string $filename = null, array $settings = null): string
+    {
+        // Add the defaults to the settings.
+        $settings = $this->getUploadSettings($settings);
+
+        // Destination is always needed (but it can be set in defaults).
+        $self = $settings['self'] ?? false;
+        if (!isset($settings['destination']) && $self === false) {
+            throw new RuntimeException($this->translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED'), 400);
+        }
+
         if (null === $filename) {
             // If no filename is given, use the filename from the uploaded file (path is not allowed).
             $folder = '';
-            $filename = $uploadedFile->getClientFilename() ?? '';
+            $filename = $metadata['filename'] ?? '';
         } else {
             // If caller sets the filename, we will accept any custom path.
             $folder = dirname($filename);
@@ -128,7 +147,7 @@ trait MediaUploadTrait
                 $filename = date('YmdHis') . '-' . $filename;
             }
         }
-        $filepath = $folder !== '' ? $folder . $filename : $filename;
+        $filepath = $folder . $filename;
 
         // Check if the filename is allowed.
         if (!Utils::checkFilename($filename)) {
@@ -148,14 +167,14 @@ trait MediaUploadTrait
         $filesize = $settings['filesize'];
         if ($filesize) {
             $max_filesize = $filesize * 1048576;
-            if ($uploadedFile->getSize() > $max_filesize) {
+            if ($metadata['size'] > $max_filesize) {
                 // TODO: use own language string
                 throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
             }
         } elseif (null === $filesize) {
             // Check size against the Grav upload limit.
             $grav_limit = Utils::getUploadLimit();
-            if ($grav_limit > 0 && $uploadedFile->getSize() > $grav_limit) {
+            if ($grav_limit > 0 && $metadata['size'] > $grav_limit) {
                 throw new RuntimeException($this->translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT'), 400);
             }
         }
@@ -165,6 +184,11 @@ trait MediaUploadTrait
         $errors = [];
         // Do not trust mime type sent by the browser.
         $mime = Utils::getMimeByFilename($filename);
+        $mimeTest = $metadata['mime'] ?? $mime;
+        if ($mime !== $mimeTest) {
+            throw new RuntimeException('The mime type does not match to file extension', 400);
+        }
+
         foreach ((array)$settings['accept'] as $type) {
             // Force acceptance of any file when star notation
             if ($type === '*') {

+ 7 - 21
system/src/Grav/Common/Page/Markdown/Excerpts.php

@@ -95,8 +95,8 @@ class Excerpts
      */
     public function processLinkExcerpt(array $excerpt, string $type = 'link'): array
     {
+        $grav = Grav::instance();
         $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href']));
-
         $url_parts = $this->parseUrl($url);
 
         // If there is a query, then parse it and build action calls.
@@ -114,7 +114,7 @@ class Excerpts
             );
 
             // Valid attributes supported.
-            $valid_attributes = Grav::instance()['config']->get('system.pages.markdown.valid_link_attributes');
+            $valid_attributes = $grav['config']->get('system.pages.markdown.valid_link_attributes') ?? [];
 
             $skip = [];
             // Unless told to not process, go through actions.
@@ -155,9 +155,11 @@ class Excerpts
         // If scheme isn't http(s)..
         if (!empty($url_parts['scheme']) && !in_array($url_parts['scheme'], ['http', 'https'])) {
             // Handle custom streams.
-            if ($type !== 'image' && !empty($url_parts['stream']) && !empty($url_parts['path'])) {
-                $grav = Grav::instance();
-                $url_parts['path'] = $grav['base_url_relative'] . '/' . $this->resolveStream("{$url_parts['scheme']}://{$url_parts['path']}");
+            /** @var UniformResourceLocator $locator */
+            $locator = $grav['locator'];
+            if ($locator->isStream($url)) {
+                $path = $locator->findResource($url, false) ?: $locator->findResource($url, false, true);
+                $url_parts['path'] = $grav['base_url_relative'] . '/' . $path;
                 unset($url_parts['stream'], $url_parts['scheme']);
             }
 
@@ -338,20 +340,4 @@ class Excerpts
 
         return $url_parts;
     }
-
-    /**
-     * @param string $url
-     * @return string
-     */
-    protected function resolveStream(string $url)
-    {
-        /** @var UniformResourceLocator $locator */
-        $locator = Grav::instance()['locator'];
-
-        if ($locator->isStream($url)) {
-            return $locator->findResource($url, false) ?: $locator->findResource($url, false, true);
-        }
-
-        return $url;
-    }
 }

+ 1 - 1
system/src/Grav/Common/Page/Page.php

@@ -2095,7 +2095,7 @@ class Page implements PageInterface
      */
     public function filePathClean()
     {
-        return str_replace(ROOT_DIR, '', $this->filePath());
+        return str_replace(GRAV_ROOT . DS, '', $this->filePath());
     }
 
     /**

+ 1 - 1
system/src/Grav/Common/Plugins.php

@@ -311,7 +311,7 @@ class Plugins extends Iterator
             }
         } else {
             $grav['log']->addWarning(
-                sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name)
+                sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clearcache`", $name)
             );
             return null;
         }

+ 8 - 8
system/src/Grav/Common/Processors/InitializeProcessor.php

@@ -101,7 +101,14 @@ class InitializeProcessor extends ProcessorBase
         // Load pages.
         $this->initializePages($config);
 
-        // Initialize URI.
+        // Load accounts (decides class to be used).
+        // TODO: remove in 2.0.
+        $this->container['accounts'];
+
+        // Initialize session.
+        $this->initializeSession($config);
+
+        // Initialize URI (uses session, see issue #3269).
         $this->initializeUri($config);
 
         // Grav may return redirect response right away.
@@ -115,13 +122,6 @@ class InitializeProcessor extends ProcessorBase
             }
         }
 
-        // Load accounts (decides class to be used).
-        // TODO: remove in 2.0.
-        $this->container['accounts'];
-
-        // Initialize session.
-        $this->initializeSession($config);
-
         $this->stopTimer('_init');
 
         // Wrap call to next handler so that debugger can profile it.

+ 1 - 1
system/src/Grav/Common/Security.php

@@ -210,7 +210,7 @@ class Security
             'on_events' => '#(<[^>]+[[a-z\x00-\x20\"\'\/])([\s\/]on|\sxmlns)[a-z].*=>?#iUu',
 
             // Match javascript:, livescript:, vbscript:, mocha:, feed: and data: protocols
-            'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . '):.*?#iUu',
+            'invalid_protocols' => '#(' . implode('|', array_map('preg_quote', $invalid_protocols, ['#'])) . '):\S.*?#iUu',
 
             // Match -moz-bindings
             'moz_binding' => '#-moz-binding[a-z\x00-\x20]*:#u',

+ 58 - 1
system/src/Grav/Common/Service/RequestServiceProvider.php

@@ -10,10 +10,18 @@
 namespace Grav\Common\Service;
 
 use Grav\Common\Uri;
+use JsonException;
 use Nyholm\Psr7\Factory\Psr17Factory;
 use Nyholm\Psr7Server\ServerRequestCreator;
 use Pimple\Container;
 use Pimple\ServiceProviderInterface;
+use function explode;
+use function fopen;
+use function function_exists;
+use function in_array;
+use function is_array;
+use function strtolower;
+use function trim;
 
 /**
  * Class RequestServiceProvider
@@ -36,7 +44,56 @@ class RequestServiceProvider implements ServiceProviderInterface
                 $psr17Factory  // StreamFactory
             );
 
-            return $creator->fromGlobals();
+            $server = $_SERVER;
+            if (false === isset($server['REQUEST_METHOD'])) {
+                $server['REQUEST_METHOD'] = 'GET';
+            }
+            $method = $server['REQUEST_METHOD'];
+
+            $headers = function_exists('getallheaders') ? getallheaders() : $creator::getHeadersFromServer($_SERVER);
+
+            $post = null;
+            if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
+                foreach ($headers as $headerName => $headerValue) {
+                    if ('content-type' !== strtolower($headerName)) {
+                        continue;
+                    }
+
+                    $contentType = strtolower(trim(explode(';', $headerValue, 2)[0]));
+                    switch ($contentType) {
+                        case 'application/x-www-form-urlencoded':
+                        case 'multipart/form-data':
+                            $post = $_POST;
+                            break 2;
+                        case 'application/json':
+                        case 'application/vnd.api+json':
+                            try {
+                                $json = file_get_contents('php://input');
+                                $post = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+                                if (!is_array($post)) {
+                                    $post = null;
+                                }
+                            } catch (JsonException $e) {
+                                $post = null;
+                            }
+                            break 2;
+                    }
+                }
+            }
+
+            // Remove _url from ngnix routes.
+            $get = $_GET;
+            unset($get['_url']);
+            if (isset($server['QUERY_STRING'])) {
+                $query = $server['QUERY_STRING'];
+                if (strpos($query, '_url=') !== false) {
+                    parse_str($query, $query);
+                    unset($query['_url']);
+                    $server['QUERY_STRING'] = http_build_query($query);
+                }
+            }
+
+            return $creator->fromArrays($server, $headers, $_COOKIE, $get, $post, $_FILES, fopen('php://input', 'rb') ?: null);
         };
 
         $container['route'] = $container->factory(function () {

+ 351 - 0
system/src/Grav/Common/Twig/Extension/FilesystemExtension.php

@@ -0,0 +1,351 @@
+<?php
+
+/**
+ * @package    Grav\Common\Twig
+ *
+ * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\Twig\Extension;
+
+use Grav\Common\Grav;
+use Grav\Common\Utils;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use Twig\Extension\AbstractExtension;
+use Twig\TwigFunction;
+
+/**
+ * Class FilesystemExtension
+ * @package Grav\Common\Twig\Extension
+ */
+class FilesystemExtension extends AbstractExtension
+{
+    /** @var UniformResourceLocator */
+    private $locator;
+
+    public function __construct()
+    {
+        $this->locator = Grav::instance()['locator'];
+    }
+
+    /**
+     * @return TwigFunction[]
+     */
+    public function getFilters()
+    {
+        return $this->getFunctions();
+    }
+
+    /**
+     * Return a list of all functions.
+     *
+     * @return TwigFunction[]
+     */
+    public function getFunctions()
+    {
+        return [
+            new TwigFunction('file_exists', [$this, 'file_exists']),
+            new TwigFunction('fileatime', [$this, 'fileatime']),
+            new TwigFunction('filectime', [$this, 'filectime']),
+            new TwigFunction('filemtime', [$this, 'filemtime']),
+            new TwigFunction('filesize', [$this, 'filesize']),
+            new TwigFunction('filetype', [$this, 'filetype']),
+            new TwigFunction('is_dir', [$this, 'is_dir']),
+            new TwigFunction('is_file', [$this, 'is_file']),
+            new TwigFunction('is_link', [$this, 'is_link']),
+            new TwigFunction('is_readable', [$this, 'is_readable']),
+            new TwigFunction('is_writable', [$this, 'is_writable']),
+            new TwigFunction('is_writeable', [$this, 'is_writable']),
+            new TwigFunction('lstat', [$this, 'lstat']),
+            new TwigFunction('getimagesize', [$this, 'getimagesize']),
+            new TwigFunction('exif_read_data', [$this, 'exif_read_data']),
+            new TwigFunction('read_exif_data', [$this, 'exif_read_data']),
+            new TwigFunction('exif_imagetype', [$this, 'exif_imagetype']),
+            new TwigFunction('hash_file', [$this, 'hash_file']),
+            new TwigFunction('hash_hmac_file', [$this, 'hash_hmac_file']),
+            new TwigFunction('md5_file', [$this, 'md5_file']),
+            new TwigFunction('sha1_file', [$this, 'sha1_file']),
+            new TwigFunction('get_meta_tags', [$this, 'get_meta_tags']),
+        ];
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function file_exists($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return file_exists($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return int|false
+     */
+    public function fileatime($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return fileatime($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return int|false
+     */
+    public function filectime($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return filectime($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return int|false
+     */
+    public function filemtime($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return filemtime($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return int|false
+     */
+    public function filesize($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return filesize($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return string|false
+     */
+    public function filetype($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return filetype($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function is_dir($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return is_dir($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function is_file($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return is_file($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function is_link($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return is_link($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function is_readable($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return is_readable($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    public function is_writable($filename): bool
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return is_writable($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return array|false
+     */
+    public function lstat($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return lstat($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return array|false
+     */
+    public function getimagesize($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return getimagesize($filename);
+    }
+
+    /**
+     * @param string $file
+     * @param string|null $required_sections
+     * @param bool $as_arrays
+     * @param bool $read_thumbnail
+     * @return array|false
+     */
+    public function exif_read_data($file, ?string $required_sections, bool $as_arrays = false, bool $read_thumbnail = false)
+    {
+        if (!Utils::functionExists('exif_read_data') || !$this->checkFilename($file)) {
+            return false;
+        }
+
+        return exif_read_data($file, $required_sections, $as_arrays, $read_thumbnail);
+    }
+
+    /**
+     * @param string $filename
+     * @return string|false
+     */
+    public function exif_imagetype($filename)
+    {
+        if (!Utils::functionExists('exif_imagetype') || !$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return @exif_imagetype($filename);
+    }
+
+    /**
+     * @param string $algo
+     * @param string $filename
+     * @param bool $binary
+     * @return string|false
+     */
+    public function hash_file(string $algo, string $filename, bool $binary = false)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return hash_file($algo, $filename, $binary);
+    }
+
+    /**
+     * @param string $algo
+     * @param string $data
+     * @param string $key
+     * @param bool $binary
+     * @return string|false
+     */
+    public function hash_hmac_file(string $algo, string $data, string $key, bool $binary = false)
+    {
+        if (!$this->checkFilename($data)) {
+            return false;
+        }
+
+        return hash_hmac_file($algo, $data, $key, $binary);
+    }
+
+    /**
+     * @param string $filename
+     * @param bool $binary
+     * @return string|false
+     */
+    public function md5_file($filename, bool $binary = false)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return md5_file($filename, $binary);
+    }
+
+    /**
+     * @param string $filename
+     * @param bool $binary
+     * @return string|false
+     */
+    public function sha1_file($filename, bool $binary = false)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return sha1_file($filename, $binary);
+    }
+
+    /**
+     * @param string $filename
+     * @return array|false
+     */
+    public function get_meta_tags($filename)
+    {
+        if (!$this->checkFilename($filename)) {
+            return false;
+        }
+
+        return get_meta_tags($filename);
+    }
+
+    /**
+     * @param string $filename
+     * @return bool
+     */
+    private function checkFilename($filename): bool
+    {
+        return is_string($filename) && (!str_contains($filename, '://') || $this->locator->isStream($filename));
+    }
+}

+ 1597 - 0
system/src/Grav/Common/Twig/Extension/GravExtension.php

@@ -0,0 +1,1597 @@
+<?php
+
+/**
+ * @package    Grav\Common\Twig
+ *
+ * @copyright  Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\Twig\Extension;
+
+use Cron\CronExpression;
+use Grav\Common\Config\Config;
+use Grav\Common\Data\Data;
+use Grav\Common\Debugger;
+use Grav\Common\Grav;
+use Grav\Common\Inflector;
+use Grav\Common\Language\Language;
+use Grav\Common\Page\Collection;
+use Grav\Common\Page\Interfaces\PageInterface;
+use Grav\Common\Page\Media;
+use Grav\Common\Scheduler\Cron;
+use Grav\Common\Security;
+use Grav\Common\Twig\TokenParser\TwigTokenParserCache;
+use Grav\Common\Twig\TokenParser\TwigTokenParserRender;
+use Grav\Common\Twig\TokenParser\TwigTokenParserScript;
+use Grav\Common\Twig\TokenParser\TwigTokenParserStyle;
+use Grav\Common\Twig\TokenParser\TwigTokenParserSwitch;
+use Grav\Common\Twig\TokenParser\TwigTokenParserThrow;
+use Grav\Common\Twig\TokenParser\TwigTokenParserTryCatch;
+use Grav\Common\Twig\TokenParser\TwigTokenParserMarkdown;
+use Grav\Common\User\Interfaces\UserInterface;
+use Grav\Common\Utils;
+use Grav\Common\Yaml;
+use Grav\Common\Helpers\Base32;
+use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
+use Grav\Framework\Psr7\Response;
+use Iterator;
+use JsonSerializable;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use Traversable;
+use Twig\Environment;
+use Twig\Extension\AbstractExtension;
+use Twig\Extension\GlobalsInterface;
+use Twig\Loader\FilesystemLoader;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use function array_slice;
+use function count;
+use function func_get_args;
+use function func_num_args;
+use function get_class;
+use function gettype;
+use function in_array;
+use function is_array;
+use function is_bool;
+use function is_float;
+use function is_int;
+use function is_numeric;
+use function is_object;
+use function is_scalar;
+use function is_string;
+use function strlen;
+
+/**
+ * Class GravExtension
+ * @package Grav\Common\Twig\Extension
+ */
+class GravExtension extends AbstractExtension implements GlobalsInterface
+{
+    /** @var Grav */
+    protected $grav;
+    /** @var Debugger|null */
+    protected $debugger;
+    /** @var Config */
+    protected $config;
+
+    /**
+     * GravExtension constructor.
+     */
+    public function __construct()
+    {
+        $this->grav     = Grav::instance();
+        $this->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 TwigFilter('*ize', [$this, 'inflectorFilter']),
+            new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']),
+            new TwigFilter('contains', [$this, 'containsFilter']),
+            new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']),
+            new TwigFilter('nicenumber', [$this, 'niceNumberFunc']),
+            new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']),
+            new TwigFilter('nicetime', [$this, 'nicetimeFunc']),
+            new TwigFilter('defined', [$this, 'definedDefaultFilter']),
+            new TwigFilter('ends_with', [$this, 'endsWithFilter']),
+            new TwigFilter('fieldName', [$this, 'fieldNameFilter']),
+            new TwigFilter('ksort', [$this, 'ksortFilter']),
+            new TwigFilter('ltrim', [$this, 'ltrimFilter']),
+            new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]),
+            new TwigFilter('md5', [$this, 'md5Filter']),
+            new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']),
+            new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']),
+            new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']),
+            new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']),
+            new TwigFilter('randomize', [$this, 'randomizeFilter']),
+            new TwigFilter('modulus', [$this, 'modulusFilter']),
+            new TwigFilter('rtrim', [$this, 'rtrimFilter']),
+            new TwigFilter('pad', [$this, 'padFilter']),
+            new TwigFilter('regex_replace', [$this, 'regexReplace']),
+            new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]),
+            new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']),
+            new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']),
+            new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']),
+            new TwigFilter('starts_with', [$this, 'startsWithFilter']),
+            new TwigFilter('truncate', [Utils::class, 'truncate']),
+            new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']),
+            new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
+            new TwigFilter('array_unique', 'array_unique'),
+            new TwigFilter('basename', 'basename'),
+            new TwigFilter('dirname', 'dirname'),
+            new TwigFilter('print_r', [$this, 'print_r']),
+            new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']),
+            new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']),
+            new TwigFilter('nicecron', [$this, 'niceCronFilter']),
+
+            // Translations
+            new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]),
+            new TwigFilter('tl', [$this, 'translateLanguage']),
+            new TwigFilter('ta', [$this, 'translateArray']),
+
+            // Casting values
+            new TwigFilter('string', [$this, 'stringFilter']),
+            new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]),
+            new TwigFilter('bool', [$this, 'boolFilter']),
+            new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]),
+            new TwigFilter('array', [$this, 'arrayFilter']),
+
+            // Object Types
+            new TwigFilter('get_type', [$this, 'getTypeFunc']),
+            new TwigFilter('of_type', [$this, 'ofTypeFunc'])
+        ];
+    }
+
+    /**
+     * Return a list of all functions.
+     *
+     * @return array
+     */
+    public function getFunctions()
+    {
+        return [
+            new TwigFunction('array', [$this, 'arrayFilter']),
+            new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']),
+            new TwigFunction('array_key_exists', 'array_key_exists'),
+            new TwigFunction('array_unique', 'array_unique'),
+            new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),
+            new TwigFunction('array_diff', 'array_diff'),
+            new TwigFunction('authorize', [$this, 'authorize']),
+            new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+            new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
+            new TwigFunction('vardump', [$this, 'vardumpFunc']),
+            new TwigFunction('print_r', [$this, 'print_r']),
+            new TwigFunction('http_response_code', 'http_response_code'),
+            new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]),
+            new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]),
+            new TwigFunction('gist', [$this, 'gistFunc']),
+            new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']),
+            new TwigFunction('pathinfo', 'pathinfo'),
+            new TwigFunction('random_string', [$this, 'randomStringFunc']),
+            new TwigFunction('repeat', [$this, 'repeatFunc']),
+            new TwigFunction('regex_replace', [$this, 'regexReplace']),
+            new TwigFunction('regex_filter', [$this, 'regexFilter']),
+            new TwigFunction('regex_match', [$this, 'regexMatch']),
+            new TwigFunction('regex_split', [$this, 'regexSplit']),
+            new TwigFunction('string', [$this, 'stringFilter']),
+            new TwigFunction('url', [$this, 'urlFunc']),
+            new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']),
+            new TwigFunction('get_cookie', [$this, 'getCookie']),
+            new TwigFunction('redirect_me', [$this, 'redirectFunc']),
+            new TwigFunction('range', [$this, 'rangeFunc']),
+            new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']),
+            new TwigFunction('exif', [$this, 'exifFunc']),
+            new TwigFunction('media_directory', [$this, 'mediaDirFunc']),
+            new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]),
+            new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]),
+            new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]),
+            new TwigFunction('read_file', [$this, 'readFileFunc']),
+            new TwigFunction('nicenumber', [$this, 'niceNumberFunc']),
+            new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']),
+            new TwigFunction('nicetime', [$this, 'nicetimeFunc']),
+            new TwigFunction('cron', [$this, 'cronFunc']),
+            new TwigFunction('svg_image', [$this, 'svgImageFunction']),
+            new TwigFunction('xss', [$this, 'xssFunc']),
+
+            // Translations
+            new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]),
+            new TwigFunction('tl', [$this, 'translateLanguage']),
+            new TwigFunction('ta', [$this, 'translateArray']),
+
+            // Object Types
+            new TwigFunction('get_type', [$this, 'getTypeFunc']),
+            new TwigFunction('of_type', [$this, 'ofTypeFunc'])
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public function getTokenParsers()
+    {
+        return [
+            new TwigTokenParserRender(),
+            new TwigTokenParserThrow(),
+            new TwigTokenParserTryCatch(),
+            new TwigTokenParserScript(),
+            new TwigTokenParserStyle(),
+            new TwigTokenParserMarkdown(),
+            new TwigTokenParserSwitch(),
+            new TwigTokenParserCache(),
+        ];
+    }
+
+    public function print_r($var)
+    {
+        return print_r($var, true);
+    }
+
+    /**
+     * 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)
+    {
+        static $list = [
+            '"' => '&#34;',
+            "'" => '&#39;',
+            '&' => '&amp;',
+            '<' => '&lt;',
+            '>' => '&gt;',
+            '@' => '&#64;'
+        ];
+
+        $characters = mb_str_split($str, 1, 'UTF-8');
+
+        $encoded = '';
+        foreach ($characters as $chr) {
+            $encoded .= $list[$chr] ?? (random_int(0, 1) ? '&#' . mb_ord($chr) . ';' : $chr);
+        }
+
+        return $encoded;
+    }
+
+    /**
+     * Returns array in a random order.
+     *
+     * @param  array|Traversable $original
+     * @param  int   $offset Can be used to return only slice of the array.
+     * @return array
+     */
+    public function randomizeFilter($original, $offset = 0)
+    {
+        if ($original instanceof Traversable) {
+            $original = iterator_to_array($original, false);
+        }
+
+        if (!is_array($original)) {
+            return $original;
+        }
+
+        $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|null   $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)) {
+            return $items[$remainder] ?? $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|null $count
+     * @return string
+     */
+    public function inflectorFilter($action, $data, $count = null)
+    {
+        $action .= 'ize';
+
+        /** @var Inflector $inflector */
+        $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)) {
+            return $count ? $inflector->{$action}($data, $count) : $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 string $str
+     * @return string
+     */
+    public function base32EncodeFilter($str)
+    {
+        return Base32::encode($str);
+    }
+
+    /**
+     * Return Base32 decoded string
+     *
+     * @param string $str
+     * @return string
+     */
+    public function base32DecodeFilter($str)
+    {
+        return Base32::decode($str);
+    }
+
+    /**
+     * Return Base64 encoded string
+     *
+     * @param string $str
+     * @return string
+     */
+    public function base64EncodeFilter($str)
+    {
+        return base64_encode($str);
+    }
+
+    /**
+     * Return Base64 decoded string
+     *
+     * @param string $str
+     * @return string|false
+     */
+    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|null $array
+     * @return array
+     */
+    public function ksortFilter($array)
+    {
+        if (null === $array) {
+            $array = [];
+        }
+        ksort($array);
+
+        return $array;
+    }
+
+    /**
+     * Wrapper for chunk_split() function
+     *
+     * @param string $value
+     * @param int $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 string|bool
+     * @todo returning $haystack here doesn't make much sense
+     */
+    public function containsFilter($haystack, $needle)
+    {
+        if (empty($needle)) {
+            return $haystack;
+        }
+
+        return (strpos($haystack, (string) $needle) !== false);
+    }
+
+    /**
+     * Gets a human readable output for cron syntax
+     *
+     * @param string $at
+     * @return string
+     */
+    public function niceCronFilter($at)
+    {
+        $cron = new Cron($at);
+        return $cron->getText('en');
+    }
+
+    /**
+     * Get Cron object for a crontab 'at' format
+     *
+     * @param string $at
+     * @return CronExpression
+     */
+    public function cronFunc($at)
+    {
+        return CronExpression::factory($at);
+    }
+
+    /**
+     * displays a facebook style 'time ago' formatted date/time
+     *
+     * @param string $date
+     * @param bool $long_strings
+     * @param bool $show_tense
+     * @return string
+     */
+    public function nicetimeFunc($date, $long_strings = true, $show_tense = true)
+    {
+        if (empty($date)) {
+            return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED');
+        }
+
+        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 === (string)$date) {
+            $unix_date = $date;
+        } else {
+            $unix_date = strtotime($date);
+        }
+
+        // check validity of date
+        if (empty($unix_date)) {
+            return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE');
+        }
+
+        // is it future date or past date
+        if ($now > $unix_date) {
+            $difference = $now - $unix_date;
+            $tense      = $this->grav['language']->translate('GRAV.NICETIME.AGO');
+        } elseif ($now == $unix_date) {
+            $difference = $now - $unix_date;
+            $tense      = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW');
+        } else {
+            $difference = $unix_date - $now;
+            $tense      = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW');
+        }
+
+        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('GRAV.'.$periods[$j]);
+
+        if ($now == $unix_date) {
+            return $tense;
+        }
+
+        $time = "{$difference} {$periods[$j]}";
+        $time .= $show_tense ? " {$tense}" : '';
+
+        return $time;
+    }
+
+    /**
+     * Allow quick check of a string for XSS Vulnerabilities
+     *
+     * @param string|array $data
+     * @return bool|string|array
+     */
+    public function xssFunc($data)
+    {
+        if (!is_array($data)) {
+            return Security::detectXss($data);
+        }
+
+        $results = Security::detectXssFromArray($data);
+        $results_parts = array_map(static function ($value, $key) {
+            return $key.': \''.$value . '\'';
+        }, array_values($results), array_keys($results));
+
+        return implode(', ', $results_parts);
+    }
+
+    /**
+     * @param string $string
+     * @return string
+     */
+    public function absoluteUrlFilter($string)
+    {
+        $url    = $this->grav['uri']->base();
+        $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string);
+
+        return $string;
+    }
+
+    /**
+     * @param array $context
+     * @param string $string
+     * @param bool $block  Block or Line processing
+     * @return string
+     */
+    public function markdownFunction($context, $string, $block = true)
+    {
+        $page = $context['page'] ?? null;
+        return Utils::processMarkdown($string, $block, $page);
+    }
+
+    /**
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    public function startsWithFilter($haystack, $needle)
+    {
+        return Utils::startsWith($haystack, $needle);
+    }
+
+    /**
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    public function endsWithFilter($haystack, $needle)
+    {
+        return Utils::endsWith($haystack, $needle);
+    }
+
+    /**
+     * @param mixed $value
+     * @param null $default
+     * @return mixed|null
+     */
+    public function definedDefaultFilter($value, $default = null)
+    {
+        return $value ?? $default;
+    }
+
+    /**
+     * @param string $value
+     * @param string|null $chars
+     * @return string
+     */
+    public function rtrimFilter($value, $chars = null)
+    {
+        return null !== $chars ? rtrim($value, $chars) : rtrim($value);
+    }
+
+    /**
+     * @param string $value
+     * @param string|null $chars
+     * @return string
+     */
+    public function ltrimFilter($value, $chars = null)
+    {
+        return  null !== $chars ? ltrim($value, $chars) : ltrim($value);
+    }
+
+    /**
+     * Returns a string from a value. If the value is array, return it json encoded
+     *
+     * @param mixed $value
+     * @return string
+     */
+    public function stringFilter($value)
+    {
+        // Format the array as a string
+        if (is_array($value)) {
+            return json_encode($value);
+        }
+
+        // Boolean becomes '1' or '0'
+        if (is_bool($value)) {
+            $value = (int)$value;
+        }
+
+        // Cast the other values to string.
+        return (string)$value;
+    }
+
+    /**
+     * Casts input to int.
+     *
+     * @param mixed $input
+     * @return int
+     */
+    public function intFilter($input)
+    {
+        return (int) $input;
+    }
+
+    /**
+     * Casts input to bool.
+     *
+     * @param mixed $input
+     * @return bool
+     */
+    public function boolFilter($input)
+    {
+        return (bool) $input;
+    }
+
+    /**
+     * Casts input to float.
+     *
+     * @param mixed $input
+     * @return float
+     */
+    public function floatFilter($input)
+    {
+        return (float) $input;
+    }
+
+    /**
+     * Casts input to array.
+     *
+     * @param mixed $input
+     * @return array
+     */
+    public function arrayFilter($input)
+    {
+        if (is_array($input)) {
+            return $input;
+        }
+
+        if (is_object($input)) {
+            if (method_exists($input, 'toArray')) {
+                return $input->toArray();
+            }
+
+            if ($input instanceof Iterator) {
+                return iterator_to_array($input);
+            }
+        }
+
+        return (array)$input;
+    }
+
+    /**
+     * @param Environment $twig
+     * @return string
+     */
+    public function translate(Environment $twig)
+    {
+        // shift off the environment
+        $args = func_get_args();
+        array_shift($args);
+
+        // If admin and tu filter provided, use it
+        if (isset($this->grav['admin'])) {
+            $numargs = count($args);
+            $lang = null;
+
+            if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) {
+                $lang = array_pop($args);
+            } elseif ($numargs === 2 && is_array($args[1])) {
+                $subs = array_pop($args);
+                $args = array_merge($args, $subs);
+            }
+
+            return $this->grav['admin']->translate($args, $lang);
+        }
+
+        // else use the default grav translate functionality
+        return $this->grav['language']->translate($args);
+    }
+
+    /**
+     * Translate Strings
+     *
+     * @param string|array $args
+     * @param array|null $languages
+     * @param bool $array_support
+     * @param bool $html_out
+     * @return string
+     */
+    public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false)
+    {
+        /** @var Language $language */
+        $language = $this->grav['language'];
+
+        return $language->translate($args, $languages, $array_support, $html_out);
+    }
+
+    /**
+     * @param string $key
+     * @param string $index
+     * @param array|null $lang
+     * @return string
+     */
+    public function translateArray($key, $index, $lang = null)
+    {
+        /** @var Language $language */
+        $language = $this->grav['language'];
+
+        return $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.
+     * @param  bool   $failGracefully If true, return URL even if the file does not exist.
+     * @return string|false      Returns url to the resource or null if resource was not found.
+     */
+    public function urlFunc($input, $domain = false, $failGracefully = false)
+    {
+        return Utils::url($input, $domain, $failGracefully);
+    }
+
+    /**
+     * 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 FilesystemLoader('.');
+        $env = new Environment($loader);
+        $env->addExtension($this);
+
+        $template = $env->createTemplate($twig);
+
+        return $template->render($context);
+    }
+
+    /**
+     * This function will evaluate a $string through the $environment, and return its results.
+     *
+     * @param array $context
+     * @param string $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 Environment $env
+     * @param array $context
+     */
+    public function dump(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++) {
+                $var = func_get_arg($i);
+                $this->debugger->addMessage($var, 'debug');
+            }
+        }
+    }
+
+    /**
+     * Output a Gist
+     *
+     * @param  string $id
+     * @param  string|false $file
+     * @return string
+     */
+    public function gistFunc($id, $file = false)
+    {
+        $url = 'https://gist.github.com/' . $id . '.js';
+        if ($file) {
+            $url .= '?file=' . $file;
+        }
+        return '<script src="' . $url . '"></script>';
+    }
+
+    /**
+     * 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 string $input
+     * @param int    $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);
+    }
+
+    /**
+     * 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|null $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 array|Collection $array1
+     * @param array|Collection $array2
+     * @return array|Collection
+     */
+    public function arrayIntersectFunc($array1, $array2)
+    {
+        if ($array1 instanceof Collection && $array2 instanceof Collection) {
+            return $array1->intersect($array2)->toArray();
+        }
+
+        return array_intersect($array1, $array2);
+    }
+
+    /**
+     * 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)
+    {
+        // Admin can use Flex users even if the site does not; make sure we use the right version of the user.
+        $admin = $this->grav['admin'] ?? null;
+        if ($admin) {
+            $user = $admin->user;
+        } else {
+            /** @var UserInterface|null $user */
+            $user = $this->grav['user'] ?? null;
+        }
+
+        if (!$user) {
+            return false;
+        }
+
+        if (is_array($action)) {
+            if (Utils::isAssoc($action)) {
+                // Handle nested access structure.
+                $actions = Utils::arrayFlattenDotNotation($action);
+            } else {
+                // Handle simple access list.
+                $actions = array_combine($action, array_fill(0, count($action), true));
+            }
+        } else {
+            // Handle single action.
+            $actions = [(string)$action => true];
+        }
+
+        $count = count($actions);
+        foreach ($actions as $act => $authenticated) {
+            // Ignore 'admin.super' if it's not the only value to be checked.
+            if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) {
+                continue;
+            }
+
+            $auth = $user->authorize($act) ?? false;
+            if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) {
+                return true;
+            }
+        }
+
+        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 = '<input type="hidden" name="' . $nonceParamName . '" value="' . Utils::getNonce($action) . '" />';
+
+        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, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
+    }
+
+    /**
+     * Used to retrieve a cookie value
+     *
+     * @param string $key     The cookie name to retrieve
+     * @return string
+     */
+    public function getCookie($key)
+    {
+        return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING);
+    }
+
+    /**
+     * Twig wrapper for PHP's preg_replace method
+     *
+     * @param string|string[] $subject the content to perform the replacement on
+     * @param string|string[] $pattern the regex pattern to use for matches
+     * @param string|string[] $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 string|string[]|null 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 $array
+     * @param string $regex
+     * @param int $flags
+     * @return array
+     */
+    public function regexFilter($array, $regex, $flags = 0)
+    {
+        return preg_grep($regex, $array, $flags);
+    }
+
+    /**
+     * Twig wrapper for PHP's preg_match method
+     *
+     * @param string $subject the content to perform the match on
+     * @param string $pattern the regex pattern to use for match
+     * @param int $flags
+     * @param int $offset
+     * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not.
+     */
+    public function regexMatch($subject, $pattern, $flags = 0, $offset = 0)
+    {
+        if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) {
+            return false;
+        }
+
+        return $matches;
+    }
+
+    /**
+     * Twig wrapper for PHP's preg_split method
+     *
+     * @param string $subject the content to perform the split on
+     * @param string $pattern the regex pattern to use for split
+     * @param int $limit the maximum possible splits for the given pattern
+     * @param int $flags
+     * @return array|false the resulting array after performing the split operation
+     */
+    public function regexSplit($subject, $pattern, $limit = -1, $flags = 0)
+    {
+        return preg_split($pattern, $subject, $limit, $flags);
+    }
+
+    /**
+     * redirect browser from twig
+     *
+     * @param string $url          the url to redirect to
+     * @param int $statusCode      statusCode, default 303
+     * @return void
+     */
+    public function redirectFunc($url, $statusCode = 303)
+    {
+        $response = new Response($statusCode, ['location' => $url]);
+
+        $this->grav->close($response);
+    }
+
+    /**
+     * 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 bool 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 the Exif data for a file
+     *
+     * @param string $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 ($image && 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 string $filepath
+     * @return bool|string
+     */
+    public function readFileFunc($filepath)
+    {
+        /** @var UniformResourceLocator $locator */
+        $locator = $this->grav['locator'];
+
+        if ($locator->isStream($filepath)) {
+            $filepath = $locator->findResource($filepath);
+        }
+
+        if ($filepath && file_exists($filepath)) {
+            return file_get_contents($filepath);
+        }
+
+        return false;
+    }
+
+    /**
+     * Process a folder as Media and return a media object
+     *
+     * @param string $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 ($media_dir && file_exists($media_dir)) {
+            return new Media($media_dir);
+        }
+
+        return null;
+    }
+
+    /**
+     * Dump a variable to the browser
+     *
+     * @param mixed $var
+     * @return void
+     */
+    public function vardumpFunc($var)
+    {
+        var_dump($var);
+    }
+
+    /**
+     * Returns a nicer more readable filesize based on bytes
+     *
+     * @param int $bytes
+     * @return string
+     */
+    public function niceFilesizeFunc($bytes)
+    {
+        return Utils::prettySize($bytes);
+    }
+
+    /**
+     * Returns a nicer more readable number
+     *
+     * @param int|float|string $n
+     * @return string|bool
+     */
+    public function niceNumberFunc($n)
+    {
+        if (!is_float($n) && !is_int($n)) {
+            if (!is_string($n) || $n === '') {
+                return false;
+            }
+
+            // Strip any thousand formatting and find the first number.
+            $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n)));
+            $n = reset($list);
+
+            if (!is_numeric($n)) {
+                return false;
+            }
+
+            $n = (float)$n;
+        }
+
+        // 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
+     * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root.
+     * If still not found, will use the theme's configuration value,
+     * If still not found, will use the $default value passed in
+     *
+     * @param array $context      Twig Context
+     * @param string $var variable to be found (using dot notation)
+     * @param null $default the default value to be used as last resort
+     * @param null $page an optional page to use for the current page
+     * @param bool $exists toggle to simply return the page where the variable is set, else null
+     * @return mixed
+     */
+    public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false)
+    {
+        $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null;
+
+        // Try to find var in the page headers
+        if ($page instanceof PageInterface && $page->exists()) {
+            // Loop over pages and look for header vars
+            while ($page && !$page->root()) {
+                $header = new Data((array)$page->header());
+                $value = $header->get($var);
+                if (isset($value)) {
+                    if ($exists) {
+                        return $page;
+                    }
+
+                    return $value;
+                }
+                $page = $page->parent();
+            }
+        }
+
+        if ($exists) {
+            return false;
+        }
+
+        return Grav::instance()['config']->get('theme.' . $var, $default);
+    }
+
+    /**
+     * Look for a page header variable in an array of pages working its way through until a value is found
+     *
+     * @param array $context
+     * @param string $var the variable to look for in the page header
+     * @param string|string[]|null $pages array of pages to check (current page upwards if not null)
+     * @return mixed
+     * @deprecated 1.7 Use themeVarFunc() instead
+     */
+    public function pageHeaderVarFunc($context, $var, $pages = null)
+    {
+        if (is_array($pages)) {
+            $page = array_shift($pages);
+        } else {
+            $page = null;
+        }
+        return $this->themeVarFunc($context, $var, null, $page);
+    }
+
+    /**
+     * 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 array $context
+     * @param string|string[] $classes
+     * @return string
+     */
+    public function bodyClassFunc($context, $classes)
+    {
+
+        $header = $context['page']->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;
+    }
+
+    /**
+     * Returns the content of an SVG image and adds extra classes as needed
+     *
+     * @param string $path
+     * @param string|null $classes
+     * @return string|string[]|null
+     */
+    public static function svgImageFunction($path, $classes = null, $strip_style = false)
+    {
+        $path = Utils::fullPath($path);
+
+        $classes = $classes ?: '';
+
+        if (file_exists($path) && !is_dir($path)) {
+            $svg = file_get_contents($path);
+            $classes = " inline-block $classes";
+            $matched = false;
+
+            //Remove xml tag if it exists
+            $svg = preg_replace('/^<\?xml.*\?>/','', $svg);
+
+            //Strip style if needed
+            if ($strip_style) {
+                $svg = preg_replace('/<style.*<\/style>/s', '', $svg);
+            }
+
+            //Look for existing class
+            $svg = preg_replace_callback('/^<svg[^>]*(class=\")([^"]*)(\")[^>]*>/', function($matches) use ($classes, &$matched) {
+                if (isset($matches[2])) {
+                    $new_classes = $matches[2] . $classes;
+                    $matched = true;
+                    return str_replace($matches[1], "class=\"$new_classes\"", $matches[0]);
+                }
+                return $matches[0];
+            }, $svg
+            );
+
+            // no matches found just add the class
+            if (!$matched) {
+                $classes = trim($classes);
+                $svg = str_replace('<svg ', "<svg class=\"$classes\" ", $svg);
+            }
+
+            return $svg;
+        }
+
+        return null;
+    }
+
+
+    /**
+     * Dump/Encode data into YAML format
+     *
+     * @param array|object $data
+     * @param int $inline integer number of levels of inline syntax
+     * @return string
+     */
+    public function yamlEncodeFilter($data, $inline = 10)
+    {
+        if (!is_array($data)) {
+            if ($data instanceof JsonSerializable) {
+                $data = $data->jsonSerialize();
+            } elseif (method_exists($data, 'toArray')) {
+                $data = $data->toArray();
+            } else {
+                $data = json_decode(json_encode($data), true);
+            }
+        }
+
+        return Yaml::dump($data, $inline);
+    }
+
+    /**
+     * Decode/Parse data from YAML format
+     *
+     * @param string $data
+     * @return array
+     */
+    public function yamlDecodeFilter($data)
+    {
+        return Yaml::parse($data);
+    }
+
+    /**
+     * Function/Filter to return the type of variable
+     *
+     * @param mixed $var
+     * @return string
+     */
+    public function getTypeFunc($var)
+    {
+        return gettype($var);
+    }
+
+    /**
+     * Function/Filter to test type of variable
+     *
+     * @param mixed $var
+     * @param string|null $typeTest
+     * @param string|null $className
+     * @return bool
+     */
+    public function ofTypeFunc($var, $typeTest = null, $className = null)
+    {
+
+        switch ($typeTest) {
+            default:
+                return false;
+
+            case 'array':
+                return is_array($var);
+
+            case 'bool':
+                return is_bool($var);
+
+            case 'class':
+                return is_object($var) === true && get_class($var) === $className;
+
+            case 'float':
+                return is_float($var);
+
+            case 'int':
+                return is_int($var);
+
+            case 'numeric':
+                return is_numeric($var);
+
+            case 'object':
+                return is_object($var);
+
+            case 'scalar':
+                return is_scalar($var);
+
+            case 'string':
+                return is_string($var);
+        }
+    }
+}

+ 1 - 1
system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php

@@ -47,6 +47,6 @@ class TwigNodeMarkdown extends Node implements NodeOutputInterface
             ->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($context, $content);' . PHP_EOL);
+            ->write('echo $this->env->getExtension(\'Grav\Common\Twig\Extension\GravExtension\')->markdownFunction($context, $content);' . PHP_EOL);
     }
 }

+ 48 - 16
system/src/Grav/Common/Twig/Twig.php

@@ -16,6 +16,9 @@ use Grav\Common\Language\Language;
 use Grav\Common\Language\LanguageCodes;
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Page\Pages;
+use Grav\Common\Twig\Extension\FilesystemExtension;
+use Grav\Common\Twig\Extension\GravExtension;
+use Grav\Common\Utils;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 use RocketTheme\Toolbox\Event\Event;
 use Phive\Twig\Extensions\Deferred\DeferredExtension;
@@ -34,6 +37,8 @@ use Twig\Profiler\Profile;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
 use function function_exists;
+use function in_array;
+use function is_array;
 
 /**
  * Class Twig
@@ -154,27 +159,53 @@ class Twig
 
             $this->twig = new TwigEnvironment($loader_chain, $params);
 
-            if ($config->get('system.twig.undefined_functions')) {
-                $this->twig->registerUndefinedFunctionCallback(function ($name) {
+            $this->twig->registerUndefinedFunctionCallback(function ($name) use ($config) {
+                $allowed = $config->get('system.twig.safe_functions');
+                if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {
+                    return new TwigFunction($name, $name);
+                }
+                if ($config->get('system.twig.undefined_functions')) {
                     if (function_exists($name)) {
-                        return new TwigFunction($name, $name);
+                        if (!Utils::isDangerousFunction($name)) {
+                            user_error("PHP function {$name}() was used as Twig function. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_functions`", E_USER_DEPRECATED);
+
+                            return new TwigFunction($name, $name);
+                        }
+
+                        /** @var Debugger $debugger */
+                        $debugger = $this->grav['debugger'];
+                        $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig function. If you really want to use it, please add it to system configuration: `system.twig.safe_functions`"));
                     }
 
-                    return new TwigFunction($name, static function () {
-                    });
-                });
-            }
+                    return new TwigFunction($name, static function () {});
+                }
+
+                return false;
+            });
 
-            if ($config->get('system.twig.undefined_filters')) {
-                $this->twig->registerUndefinedFilterCallback(function ($name) {
+            $this->twig->registerUndefinedFilterCallback(function ($name) use ($config) {
+                $allowed = $config->get('system.twig.safe_filters');
+                if (is_array($allowed) && in_array($name, $allowed, true) && function_exists($name)) {
+                    return new TwigFilter($name, $name);
+                }
+                if ($config->get('system.twig.undefined_filters')) {
                     if (function_exists($name)) {
-                        return new TwigFilter($name, $name);
+                        if (!Utils::isDangerousFunction($name)) {
+                            user_error("PHP function {$name}() used as Twig filter. This is deprecated in Grav 1.7. Please add it to system configuration: `system.twig.safe_filters`", E_USER_DEPRECATED);
+
+                            return new TwigFilter($name, $name);
+                        }
+
+                        /** @var Debugger $debugger */
+                        $debugger = $this->grav['debugger'];
+                        $debugger->addException(new RuntimeException("Blocked potentially dangerous PHP function {$name}() being used as Twig filter. If you really want to use it, please add it to system configuration: `system.twig.safe_filters`"));
                     }
 
-                    return new TwigFilter($name, static function () {
-                    });
-                });
-            }
+                    return new TwigFilter($name, static function () {});
+                }
+
+                return false;
+            });
 
             $this->grav->fireEvent('onTwigInitialized');
 
@@ -188,7 +219,8 @@ class Twig
             if ($config->get('system.twig.debug')) {
                 $this->twig->addExtension(new DebugExtension());
             }
-            $this->twig->addExtension(new TwigExtension());
+            $this->twig->addExtension(new GravExtension());
+            $this->twig->addExtension(new FilesystemExtension());
             $this->twig->addExtension(new DeferredExtension());
             $this->twig->addExtension(new StringLoaderExtension());
 
@@ -211,7 +243,7 @@ class Twig
                     'assets'            => $this->grav['assets'],
                     'taxonomy'          => $this->grav['taxonomy'],
                     'browser'           => $this->grav['browser'],
-                    'base_dir'          => rtrim(ROOT_DIR, '/'),
+                    'base_dir'          => GRAV_ROOT,
                     'home_url'          => $pages->homeUrl($active_language),
                     'base_url'          => $pages->baseUrl($active_language),
                     'base_url_absolute' => $pages->baseUrl($active_language, true),

+ 3 - 1581
system/src/Grav/Common/Twig/TwigExtension.php

@@ -9,1591 +9,13 @@
 
 namespace Grav\Common\Twig;
 
-use Cron\CronExpression;
-use Grav\Common\Config\Config;
-use Grav\Common\Data\Data;
-use Grav\Common\Debugger;
-use Grav\Common\Grav;
-use Grav\Common\Inflector;
-use Grav\Common\Language\Language;
-use Grav\Common\Page\Collection;
-use Grav\Common\Page\Interfaces\PageInterface;
-use Grav\Common\Page\Media;
-use Grav\Common\Scheduler\Cron;
-use Grav\Common\Security;
-use Grav\Common\Twig\TokenParser\TwigTokenParserCache;
-use Grav\Common\Twig\TokenParser\TwigTokenParserRender;
-use Grav\Common\Twig\TokenParser\TwigTokenParserScript;
-use Grav\Common\Twig\TokenParser\TwigTokenParserStyle;
-use Grav\Common\Twig\TokenParser\TwigTokenParserSwitch;
-use Grav\Common\Twig\TokenParser\TwigTokenParserThrow;
-use Grav\Common\Twig\TokenParser\TwigTokenParserTryCatch;
-use Grav\Common\Twig\TokenParser\TwigTokenParserMarkdown;
-use Grav\Common\User\Interfaces\UserInterface;
-use Grav\Common\Utils;
-use Grav\Common\Yaml;
-use Grav\Common\Helpers\Base32;
-use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
-use Grav\Framework\Psr7\Response;
-use Iterator;
-use JsonSerializable;
-use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
-use Traversable;
-use Twig\Environment;
-use Twig\Extension\AbstractExtension;
-use Twig\Extension\GlobalsInterface;
-use Twig\Loader\FilesystemLoader;
-use Twig\TwigFilter;
-use Twig\TwigFunction;
-use function array_slice;
-use function count;
-use function func_get_args;
-use function func_num_args;
-use function get_class;
-use function gettype;
-use function in_array;
-use function is_array;
-use function is_bool;
-use function is_float;
-use function is_int;
-use function is_numeric;
-use function is_object;
-use function is_scalar;
-use function is_string;
-use function ord;
-use function strlen;
+use Grav\Common\Twig\Extension\GravExtension;
 
 /**
  * Class TwigExtension
  * @package Grav\Common\Twig
+ * @deprecated 1.7 Use GravExtension instead
  */
-class TwigExtension extends AbstractExtension implements GlobalsInterface
+class TwigExtension extends GravExtension
 {
-    /** @var Grav */
-    protected $grav;
-    /** @var Debugger|null */
-    protected $debugger;
-    /** @var Config */
-    protected $config;
-
-    /**
-     * TwigExtension constructor.
-     */
-    public function __construct()
-    {
-        $this->grav     = Grav::instance();
-        $this->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 TwigFilter('*ize', [$this, 'inflectorFilter']),
-            new TwigFilter('absolute_url', [$this, 'absoluteUrlFilter']),
-            new TwigFilter('contains', [$this, 'containsFilter']),
-            new TwigFilter('chunk_split', [$this, 'chunkSplitFilter']),
-            new TwigFilter('nicenumber', [$this, 'niceNumberFunc']),
-            new TwigFilter('nicefilesize', [$this, 'niceFilesizeFunc']),
-            new TwigFilter('nicetime', [$this, 'nicetimeFunc']),
-            new TwigFilter('defined', [$this, 'definedDefaultFilter']),
-            new TwigFilter('ends_with', [$this, 'endsWithFilter']),
-            new TwigFilter('fieldName', [$this, 'fieldNameFilter']),
-            new TwigFilter('ksort', [$this, 'ksortFilter']),
-            new TwigFilter('ltrim', [$this, 'ltrimFilter']),
-            new TwigFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]),
-            new TwigFilter('md5', [$this, 'md5Filter']),
-            new TwigFilter('base32_encode', [$this, 'base32EncodeFilter']),
-            new TwigFilter('base32_decode', [$this, 'base32DecodeFilter']),
-            new TwigFilter('base64_encode', [$this, 'base64EncodeFilter']),
-            new TwigFilter('base64_decode', [$this, 'base64DecodeFilter']),
-            new TwigFilter('randomize', [$this, 'randomizeFilter']),
-            new TwigFilter('modulus', [$this, 'modulusFilter']),
-            new TwigFilter('rtrim', [$this, 'rtrimFilter']),
-            new TwigFilter('pad', [$this, 'padFilter']),
-            new TwigFilter('regex_replace', [$this, 'regexReplace']),
-            new TwigFilter('safe_email', [$this, 'safeEmailFilter'], ['is_safe' => ['html']]),
-            new TwigFilter('safe_truncate', [Utils::class, 'safeTruncate']),
-            new TwigFilter('safe_truncate_html', [Utils::class, 'safeTruncateHTML']),
-            new TwigFilter('sort_by_key', [$this, 'sortByKeyFilter']),
-            new TwigFilter('starts_with', [$this, 'startsWithFilter']),
-            new TwigFilter('truncate', [Utils::class, 'truncate']),
-            new TwigFilter('truncate_html', [Utils::class, 'truncateHTML']),
-            new TwigFilter('json_decode', [$this, 'jsonDecodeFilter']),
-            new TwigFilter('array_unique', 'array_unique'),
-            new TwigFilter('basename', 'basename'),
-            new TwigFilter('dirname', 'dirname'),
-            new TwigFilter('print_r', [$this, 'print_r']),
-            new TwigFilter('yaml_encode', [$this, 'yamlEncodeFilter']),
-            new TwigFilter('yaml_decode', [$this, 'yamlDecodeFilter']),
-            new TwigFilter('nicecron', [$this, 'niceCronFilter']),
-
-            // Translations
-            new TwigFilter('t', [$this, 'translate'], ['needs_environment' => true]),
-            new TwigFilter('tl', [$this, 'translateLanguage']),
-            new TwigFilter('ta', [$this, 'translateArray']),
-
-            // Casting values
-            new TwigFilter('string', [$this, 'stringFilter']),
-            new TwigFilter('int', [$this, 'intFilter'], ['is_safe' => ['all']]),
-            new TwigFilter('bool', [$this, 'boolFilter']),
-            new TwigFilter('float', [$this, 'floatFilter'], ['is_safe' => ['all']]),
-            new TwigFilter('array', [$this, 'arrayFilter']),
-
-            // Object Types
-            new TwigFilter('get_type', [$this, 'getTypeFunc']),
-            new TwigFilter('of_type', [$this, 'ofTypeFunc'])
-        ];
-    }
-
-    /**
-     * Return a list of all functions.
-     *
-     * @return array
-     */
-    public function getFunctions()
-    {
-        return [
-            new TwigFunction('array', [$this, 'arrayFilter']),
-            new TwigFunction('array_key_value', [$this, 'arrayKeyValueFunc']),
-            new TwigFunction('array_key_exists', 'array_key_exists'),
-            new TwigFunction('array_unique', 'array_unique'),
-            new TwigFunction('array_intersect', [$this, 'arrayIntersectFunc']),
-            new TwigFunction('array_diff', 'array_diff'),
-            new TwigFunction('authorize', [$this, 'authorize']),
-            new TwigFunction('debug', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
-            new TwigFunction('dump', [$this, 'dump'], ['needs_context' => true, 'needs_environment' => true]),
-            new TwigFunction('vardump', [$this, 'vardumpFunc']),
-            new TwigFunction('print_r', [$this, 'print_r']),
-            new TwigFunction('http_response_code', 'http_response_code'),
-            new TwigFunction('evaluate', [$this, 'evaluateStringFunc'], ['needs_context' => true]),
-            new TwigFunction('evaluate_twig', [$this, 'evaluateTwigFunc'], ['needs_context' => true]),
-            new TwigFunction('gist', [$this, 'gistFunc']),
-            new TwigFunction('nonce_field', [$this, 'nonceFieldFunc']),
-            new TwigFunction('pathinfo', 'pathinfo'),
-            new TwigFunction('random_string', [$this, 'randomStringFunc']),
-            new TwigFunction('repeat', [$this, 'repeatFunc']),
-            new TwigFunction('regex_replace', [$this, 'regexReplace']),
-            new TwigFunction('regex_filter', [$this, 'regexFilter']),
-            new TwigFunction('regex_match', [$this, 'regexMatch']),
-            new TwigFunction('regex_split', [$this, 'regexSplit']),
-            new TwigFunction('string', [$this, 'stringFilter']),
-            new TwigFunction('url', [$this, 'urlFunc']),
-            new TwigFunction('json_decode', [$this, 'jsonDecodeFilter']),
-            new TwigFunction('get_cookie', [$this, 'getCookie']),
-            new TwigFunction('redirect_me', [$this, 'redirectFunc']),
-            new TwigFunction('range', [$this, 'rangeFunc']),
-            new TwigFunction('isajaxrequest', [$this, 'isAjaxFunc']),
-            new TwigFunction('exif', [$this, 'exifFunc']),
-            new TwigFunction('media_directory', [$this, 'mediaDirFunc']),
-            new TwigFunction('body_class', [$this, 'bodyClassFunc'], ['needs_context' => true]),
-            new TwigFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]),
-            new TwigFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]),
-            new TwigFunction('read_file', [$this, 'readFileFunc']),
-            new TwigFunction('nicenumber', [$this, 'niceNumberFunc']),
-            new TwigFunction('nicefilesize', [$this, 'niceFilesizeFunc']),
-            new TwigFunction('nicetime', [$this, 'nicetimeFunc']),
-            new TwigFunction('cron', [$this, 'cronFunc']),
-            new TwigFunction('svg_image', [$this, 'svgImageFunction']),
-            new TwigFunction('xss', [$this, 'xssFunc']),
-
-
-            // Translations
-            new TwigFunction('t', [$this, 'translate'], ['needs_environment' => true]),
-            new TwigFunction('tl', [$this, 'translateLanguage']),
-            new TwigFunction('ta', [$this, 'translateArray']),
-
-            // Object Types
-            new TwigFunction('get_type', [$this, 'getTypeFunc']),
-            new TwigFunction('of_type', [$this, 'ofTypeFunc'])
-        ];
-    }
-
-    /**
-     * @return array
-     */
-    public function getTokenParsers()
-    {
-        return [
-            new TwigTokenParserRender(),
-            new TwigTokenParserThrow(),
-            new TwigTokenParserTryCatch(),
-            new TwigTokenParserScript(),
-            new TwigTokenParserStyle(),
-            new TwigTokenParserMarkdown(),
-            new TwigTokenParserSwitch(),
-            new TwigTokenParserCache(),
-        ];
-    }
-
-    public function print_r($var)
-    {
-        return print_r($var, true);
-    }
-
-    /**
-     * 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)
-    {
-        static $list = [
-            '"' => '&#34;',
-            "'" => '&#39;',
-            '&' => '&amp;',
-            '<' => '&lt;',
-            '>' => '&gt;',
-            '@' => '&#64;'
-        ];
-
-        $characters = mb_str_split($str, 1, 'UTF-8');
-
-        $encoded = '';
-        foreach ($characters as $chr) {
-            $encoded .= $list[$chr] ?? (random_int(0, 1) ? '&#' . mb_ord($chr) . ';' : $chr);
-        }
-
-        return $encoded;
-    }
-
-    /**
-     * Returns array in a random order.
-     *
-     * @param  array|Traversable $original
-     * @param  int   $offset Can be used to return only slice of the array.
-     * @return array
-     */
-    public function randomizeFilter($original, $offset = 0)
-    {
-        if ($original instanceof Traversable) {
-            $original = iterator_to_array($original, false);
-        }
-
-        if (!is_array($original)) {
-            return $original;
-        }
-
-        $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|null   $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)) {
-            return $items[$remainder] ?? $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|null $count
-     * @return string
-     */
-    public function inflectorFilter($action, $data, $count = null)
-    {
-        $action .= 'ize';
-
-        /** @var Inflector $inflector */
-        $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)) {
-            return $count ? $inflector->{$action}($data, $count) : $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 string $str
-     * @return string
-     */
-    public function base32EncodeFilter($str)
-    {
-        return Base32::encode($str);
-    }
-
-    /**
-     * Return Base32 decoded string
-     *
-     * @param string $str
-     * @return string
-     */
-    public function base32DecodeFilter($str)
-    {
-        return Base32::decode($str);
-    }
-
-    /**
-     * Return Base64 encoded string
-     *
-     * @param string $str
-     * @return string
-     */
-    public function base64EncodeFilter($str)
-    {
-        return base64_encode($str);
-    }
-
-    /**
-     * Return Base64 decoded string
-     *
-     * @param string $str
-     * @return string|false
-     */
-    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|null $array
-     * @return array
-     */
-    public function ksortFilter($array)
-    {
-        if (null === $array) {
-            $array = [];
-        }
-        ksort($array);
-
-        return $array;
-    }
-
-    /**
-     * Wrapper for chunk_split() function
-     *
-     * @param string $value
-     * @param int $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 string|bool
-     * @todo returning $haystack here doesn't make much sense
-     */
-    public function containsFilter($haystack, $needle)
-    {
-        if (empty($needle)) {
-            return $haystack;
-        }
-
-        return (strpos($haystack, (string) $needle) !== false);
-    }
-
-    /**
-     * Gets a human readable output for cron syntax
-     *
-     * @param string $at
-     * @return string
-     */
-    public function niceCronFilter($at)
-    {
-        $cron = new Cron($at);
-        return $cron->getText('en');
-    }
-
-    /**
-     * Get Cron object for a crontab 'at' format
-     *
-     * @param string $at
-     * @return CronExpression
-     */
-    public function cronFunc($at)
-    {
-        return CronExpression::factory($at);
-    }
-
-    /**
-     * displays a facebook style 'time ago' formatted date/time
-     *
-     * @param string $date
-     * @param bool $long_strings
-     * @param bool $show_tense
-     * @return string
-     */
-    public function nicetimeFunc($date, $long_strings = true, $show_tense = true)
-    {
-        if (empty($date)) {
-            return $this->grav['language']->translate('GRAV.NICETIME.NO_DATE_PROVIDED');
-        }
-
-        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 === (string)$date) {
-            $unix_date = $date;
-        } else {
-            $unix_date = strtotime($date);
-        }
-
-        // check validity of date
-        if (empty($unix_date)) {
-            return $this->grav['language']->translate('GRAV.NICETIME.BAD_DATE');
-        }
-
-        // is it future date or past date
-        if ($now > $unix_date) {
-            $difference = $now - $unix_date;
-            $tense      = $this->grav['language']->translate('GRAV.NICETIME.AGO');
-        } elseif ($now == $unix_date) {
-            $difference = $now - $unix_date;
-            $tense      = $this->grav['language']->translate('GRAV.NICETIME.JUST_NOW');
-        } else {
-            $difference = $unix_date - $now;
-            $tense      = $this->grav['language']->translate('GRAV.NICETIME.FROM_NOW');
-        }
-
-        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('GRAV.'.$periods[$j]);
-
-        if ($now == $unix_date) {
-            return $tense;
-        }
-
-        $time = "{$difference} {$periods[$j]}";
-        $time .= $show_tense ? " {$tense}" : '';
-
-        return $time;
-    }
-
-    /**
-     * Allow quick check of a string for XSS Vulnerabilities
-     *
-     * @param string|array $data
-     * @return bool|string|array
-     */
-    public function xssFunc($data)
-    {
-        if (!is_array($data)) {
-            return Security::detectXss($data);
-        }
-
-        $results = Security::detectXssFromArray($data);
-        $results_parts = array_map(static function ($value, $key) {
-            return $key.': \''.$value . '\'';
-        }, array_values($results), array_keys($results));
-
-        return implode(', ', $results_parts);
-    }
-
-    /**
-     * @param string $string
-     * @return string
-     */
-    public function absoluteUrlFilter($string)
-    {
-        $url    = $this->grav['uri']->base();
-        $string = preg_replace('/((?:href|src) *= *[\'"](?!(http|ftp)))/i', "$1$url", $string);
-
-        return $string;
-    }
-
-    /**
-     * @param array $context
-     * @param string $string
-     * @param bool $block  Block or Line processing
-     * @return string
-     */
-    public function markdownFunction($context, $string, $block = true)
-    {
-        $page = $context['page'] ?? null;
-        return Utils::processMarkdown($string, $block, $page);
-    }
-
-    /**
-     * @param string $haystack
-     * @param string $needle
-     * @return bool
-     */
-    public function startsWithFilter($haystack, $needle)
-    {
-        return Utils::startsWith($haystack, $needle);
-    }
-
-    /**
-     * @param string $haystack
-     * @param string $needle
-     * @return bool
-     */
-    public function endsWithFilter($haystack, $needle)
-    {
-        return Utils::endsWith($haystack, $needle);
-    }
-
-    /**
-     * @param mixed $value
-     * @param null $default
-     * @return mixed|null
-     */
-    public function definedDefaultFilter($value, $default = null)
-    {
-        return $value ?? $default;
-    }
-
-    /**
-     * @param string $value
-     * @param string|null $chars
-     * @return string
-     */
-    public function rtrimFilter($value, $chars = null)
-    {
-        return null !== $chars ? rtrim($value, $chars) : rtrim($value);
-    }
-
-    /**
-     * @param string $value
-     * @param string|null $chars
-     * @return string
-     */
-    public function ltrimFilter($value, $chars = null)
-    {
-        return  null !== $chars ? ltrim($value, $chars) : ltrim($value);
-    }
-
-    /**
-     * Returns a string from a value. If the value is array, return it json encoded
-     *
-     * @param mixed $value
-     * @return string
-     */
-    public function stringFilter($value)
-    {
-        // Format the array as a string
-        if (is_array($value)) {
-            return json_encode($value);
-        }
-
-        // Boolean becomes '1' or '0'
-        if (is_bool($value)) {
-            $value = (int)$value;
-        }
-
-        // Cast the other values to string.
-        return (string)$value;
-    }
-
-    /**
-     * Casts input to int.
-     *
-     * @param mixed $input
-     * @return int
-     */
-    public function intFilter($input)
-    {
-        return (int) $input;
-    }
-
-    /**
-     * Casts input to bool.
-     *
-     * @param mixed $input
-     * @return bool
-     */
-    public function boolFilter($input)
-    {
-        return (bool) $input;
-    }
-
-    /**
-     * Casts input to float.
-     *
-     * @param mixed $input
-     * @return float
-     */
-    public function floatFilter($input)
-    {
-        return (float) $input;
-    }
-
-    /**
-     * Casts input to array.
-     *
-     * @param mixed $input
-     * @return array
-     */
-    public function arrayFilter($input)
-    {
-        if (is_array($input)) {
-            return $input;
-        }
-
-        if (is_object($input)) {
-            if (method_exists($input, 'toArray')) {
-                return $input->toArray();
-            }
-
-            if ($input instanceof Iterator) {
-                return iterator_to_array($input);
-            }
-        }
-
-        return (array)$input;
-    }
-
-    /**
-     * @param Environment $twig
-     * @return string
-     */
-    public function translate(Environment $twig)
-    {
-        // shift off the environment
-        $args = func_get_args();
-        array_shift($args);
-
-        // If admin and tu filter provided, use it
-        if (isset($this->grav['admin'])) {
-            $numargs = count($args);
-            $lang = null;
-
-            if (($numargs === 3 && is_array($args[1])) || ($numargs === 2 && !is_array($args[1]))) {
-                $lang = array_pop($args);
-            } elseif ($numargs === 2 && is_array($args[1])) {
-                $subs = array_pop($args);
-                $args = array_merge($args, $subs);
-            }
-
-            return $this->grav['admin']->translate($args, $lang);
-        }
-
-        // else use the default grav translate functionality
-        return $this->grav['language']->translate($args);
-    }
-
-    /**
-     * Translate Strings
-     *
-     * @param string|array $args
-     * @param array|null $languages
-     * @param bool $array_support
-     * @param bool $html_out
-     * @return string
-     */
-    public function translateLanguage($args, array $languages = null, $array_support = false, $html_out = false)
-    {
-        /** @var Language $language */
-        $language = $this->grav['language'];
-
-        return $language->translate($args, $languages, $array_support, $html_out);
-    }
-
-    /**
-     * @param string $key
-     * @param string $index
-     * @param array|null $lang
-     * @return string
-     */
-    public function translateArray($key, $index, $lang = null)
-    {
-        /** @var Language $language */
-        $language = $this->grav['language'];
-
-        return $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.
-     * @param  bool   $failGracefully If true, return URL even if the file does not exist.
-     * @return string|false      Returns url to the resource or null if resource was not found.
-     */
-    public function urlFunc($input, $domain = false, $failGracefully = false)
-    {
-        return Utils::url($input, $domain, $failGracefully);
-    }
-
-    /**
-     * 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 FilesystemLoader('.');
-        $env = new Environment($loader);
-        $env->addExtension($this);
-
-        $template = $env->createTemplate($twig);
-
-        return $template->render($context);
-    }
-
-    /**
-     * This function will evaluate a $string through the $environment, and return its results.
-     *
-     * @param array $context
-     * @param string $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 Environment $env
-     * @param array $context
-     */
-    public function dump(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++) {
-                $var = func_get_arg($i);
-                $this->debugger->addMessage($var, 'debug');
-            }
-        }
-    }
-
-    /**
-     * Output a Gist
-     *
-     * @param  string $id
-     * @param  string|false $file
-     * @return string
-     */
-    public function gistFunc($id, $file = false)
-    {
-        $url = 'https://gist.github.com/' . $id . '.js';
-        if ($file) {
-            $url .= '?file=' . $file;
-        }
-        return '<script src="' . $url . '"></script>';
-    }
-
-    /**
-     * 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 string $input
-     * @param int    $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);
-    }
-
-    /**
-     * 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|null $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 array|Collection $array1
-     * @param array|Collection $array2
-     * @return array|Collection
-     */
-    public function arrayIntersectFunc($array1, $array2)
-    {
-        if ($array1 instanceof Collection && $array2 instanceof Collection) {
-            return $array1->intersect($array2)->toArray();
-        }
-
-        return array_intersect($array1, $array2);
-    }
-
-    /**
-     * 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)
-    {
-        // Admin can use Flex users even if the site does not; make sure we use the right version of the user.
-        $admin = $this->grav['admin'] ?? null;
-        if ($admin) {
-            $user = $admin->user;
-        } else {
-            /** @var UserInterface|null $user */
-            $user = $this->grav['user'] ?? null;
-        }
-
-        if (!$user) {
-            return false;
-        }
-
-        if (is_array($action)) {
-            if (Utils::isAssoc($action)) {
-                // Handle nested access structure.
-                $actions = Utils::arrayFlattenDotNotation($action);
-            } else {
-                // Handle simple access list.
-                $actions = array_combine($action, array_fill(0, count($action), true));
-            }
-        } else {
-            // Handle single action.
-            $actions = [(string)$action => true];
-        }
-
-        $count = count($actions);
-        foreach ($actions as $act => $authenticated) {
-            // Ignore 'admin.super' if it's not the only value to be checked.
-            if ($act === 'admin.super' && $count > 1 && $user instanceof FlexObjectInterface) {
-                continue;
-            }
-
-            $auth = $user->authorize($act) ?? false;
-            if (is_bool($auth) && $auth === Utils::isPositive($authenticated)) {
-                return true;
-            }
-        }
-
-        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 = '<input type="hidden" name="' . $nonceParamName . '" value="' . Utils::getNonce($action) . '" />';
-
-        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, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
-    }
-
-    /**
-     * Used to retrieve a cookie value
-     *
-     * @param string $key     The cookie name to retrieve
-     * @return string
-     */
-    public function getCookie($key)
-    {
-        return filter_input(INPUT_COOKIE, $key, FILTER_SANITIZE_STRING);
-    }
-
-    /**
-     * Twig wrapper for PHP's preg_replace method
-     *
-     * @param string|string[] $subject the content to perform the replacement on
-     * @param string|string[] $pattern the regex pattern to use for matches
-     * @param string|string[] $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 string|string[]|null 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 $array
-     * @param string $regex
-     * @param int $flags
-     * @return array
-     */
-    public function regexFilter($array, $regex, $flags = 0)
-    {
-        return preg_grep($regex, $array, $flags);
-    }
-
-    /**
-     * Twig wrapper for PHP's preg_match method
-     *
-     * @param string $subject the content to perform the match on
-     * @param string $pattern the regex pattern to use for match
-     * @param int $flags
-     * @param int $offset
-     * @return array|false returns the matches if there is at least one match in the subject for a given pattern or null if not.
-     */
-    public function regexMatch($subject, $pattern, $flags = 0, $offset = 0)
-    {
-        if (preg_match($pattern, $subject, $matches, $flags, $offset) === false) {
-            return false;
-        }
-
-        return $matches;
-    }
-
-    /**
-     * Twig wrapper for PHP's preg_split method
-     *
-     * @param string $subject the content to perform the split on
-     * @param string $pattern the regex pattern to use for split
-     * @param int $limit the maximum possible splits for the given pattern
-     * @param int $flags
-     * @return array|false the resulting array after performing the split operation
-     */
-    public function regexSplit($subject, $pattern, $limit = -1, $flags = 0)
-    {
-        return preg_split($pattern, $subject, $limit, $flags);
-    }
-
-    /**
-     * redirect browser from twig
-     *
-     * @param string $url          the url to redirect to
-     * @param int $statusCode      statusCode, default 303
-     * @return void
-     */
-    public function redirectFunc($url, $statusCode = 303)
-    {
-        $response = new Response($statusCode, ['location' => $url]);
-
-        $this->grav->close($response);
-    }
-
-    /**
-     * 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 bool 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 the Exif data for a file
-     *
-     * @param string $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 ($image && 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 string $filepath
-     * @return bool|string
-     */
-    public function readFileFunc($filepath)
-    {
-        /** @var UniformResourceLocator $locator */
-        $locator = $this->grav['locator'];
-
-        if ($locator->isStream($filepath)) {
-            $filepath = $locator->findResource($filepath);
-        }
-
-        if ($filepath && file_exists($filepath)) {
-            return file_get_contents($filepath);
-        }
-
-        return false;
-    }
-
-    /**
-     * Process a folder as Media and return a media object
-     *
-     * @param string $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 ($media_dir && file_exists($media_dir)) {
-            return new Media($media_dir);
-        }
-
-        return null;
-    }
-
-    /**
-     * Dump a variable to the browser
-     *
-     * @param mixed $var
-     * @return void
-     */
-    public function vardumpFunc($var)
-    {
-        var_dump($var);
-    }
-
-    /**
-     * Returns a nicer more readable filesize based on bytes
-     *
-     * @param int $bytes
-     * @return string
-     */
-    public function niceFilesizeFunc($bytes)
-    {
-        return Utils::prettySize($bytes);
-    }
-
-    /**
-     * Returns a nicer more readable number
-     *
-     * @param int|float|string $n
-     * @return string|bool
-     */
-    public function niceNumberFunc($n)
-    {
-        if (!is_float($n) && !is_int($n)) {
-            if (!is_string($n) || $n === '') {
-                return false;
-            }
-
-            // Strip any thousand formatting and find the first number.
-            $list = array_filter(preg_split("/\D+/", str_replace(',', '', $n)));
-            $n = reset($list);
-
-            if (!is_numeric($n)) {
-                return false;
-            }
-
-            $n = (float)$n;
-        }
-
-        // 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
-     * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root.
-     * If still not found, will use the theme's configuration value,
-     * If still not found, will use the $default value passed in
-     *
-     * @param array $context      Twig Context
-     * @param string $var variable to be found (using dot notation)
-     * @param null $default the default value to be used as last resort
-     * @param null $page an optional page to use for the current page
-     * @param bool $exists toggle to simply return the page where the variable is set, else null
-     * @return mixed
-     */
-    public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false)
-    {
-        $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null;
-
-        // Try to find var in the page headers
-        if ($page instanceof PageInterface && $page->exists()) {
-            // Loop over pages and look for header vars
-            while ($page && !$page->root()) {
-                $header = new Data((array)$page->header());
-                $value = $header->get($var);
-                if (isset($value)) {
-                    if ($exists) {
-                        return $page;
-                    }
-
-                    return $value;
-                }
-                $page = $page->parent();
-            }
-        }
-
-        if ($exists) {
-            return false;
-        }
-
-        return Grav::instance()['config']->get('theme.' . $var, $default);
-    }
-
-    /**
-     * Look for a page header variable in an array of pages working its way through until a value is found
-     *
-     * @param array $context
-     * @param string $var the variable to look for in the page header
-     * @param string|string[]|null $pages array of pages to check (current page upwards if not null)
-     * @return mixed
-     * @deprecated 1.7 Use themeVarFunc() instead
-     */
-    public function pageHeaderVarFunc($context, $var, $pages = null)
-    {
-        if (is_array($pages)) {
-            $page = array_shift($pages);
-        } else {
-            $page = null;
-        }
-        return $this->themeVarFunc($context, $var, null, $page);
-    }
-
-    /**
-     * 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 array $context
-     * @param string|string[] $classes
-     * @return string
-     */
-    public function bodyClassFunc($context, $classes)
-    {
-
-        $header = $context['page']->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;
-    }
-
-    /**
-     * Returns the content of an SVG image and adds extra classes as needed
-     *
-     * @param string $path
-     * @param string|null $classes
-     * @return string|string[]|null
-     */
-    public static function svgImageFunction($path, $classes = null, $strip_style = false)
-    {
-        $path = Utils::fullPath($path);
-
-        $classes = $classes ?: '';
-
-        if (file_exists($path) && !is_dir($path)) {
-            $svg = file_get_contents($path);
-            $classes = " inline-block $classes";
-            $matched = false;
-
-            //Remove xml tag if it exists
-            $svg = preg_replace('/^<\?xml.*\?>/','', $svg);
-
-            //Strip style if needed
-            if ($strip_style) {
-                $svg = preg_replace('/<style.*<\/style>/s', '', $svg);
-            }
-
-            //Look for existing class
-            $svg = preg_replace_callback('/^<svg[^>]*(class=\")([^"]*)(\")[^>]*>/', function($matches) use ($classes, &$matched) {
-                if (isset($matches[2])) {
-                    $new_classes = $matches[2] . $classes;
-                    $matched = true;
-                    return str_replace($matches[1], "class=\"$new_classes\"", $matches[0]);
-                }
-                return $matches[0];
-            }, $svg
-            );
-
-            // no matches found just add the class
-            if (!$matched) {
-                $classes = trim($classes);
-                $svg = str_replace('<svg ', "<svg class=\"$classes\" ", $svg);
-            }
-
-            return $svg;
-        }
-
-        return null;
-    }
-
-
-    /**
-     * Dump/Encode data into YAML format
-     *
-     * @param array|object $data
-     * @param int $inline integer number of levels of inline syntax
-     * @return string
-     */
-    public function yamlEncodeFilter($data, $inline = 10)
-    {
-        if (!is_array($data)) {
-            if ($data instanceof JsonSerializable) {
-                $data = $data->jsonSerialize();
-            } elseif (method_exists($data, 'toArray')) {
-                $data = $data->toArray();
-            } else {
-                $data = json_decode(json_encode($data), true);
-            }
-        }
-
-        return Yaml::dump($data, $inline);
-    }
-
-    /**
-     * Decode/Parse data from YAML format
-     *
-     * @param string $data
-     * @return array
-     */
-    public function yamlDecodeFilter($data)
-    {
-        return Yaml::parse($data);
-    }
-
-    /**
-     * Function/Filter to return the type of variable
-     *
-     * @param mixed $var
-     * @return string
-     */
-    public function getTypeFunc($var)
-    {
-        return gettype($var);
-    }
-
-    /**
-     * Function/Filter to test type of variable
-     *
-     * @param mixed $var
-     * @param string|null $typeTest
-     * @param string|null $className
-     * @return bool
-     */
-    public function ofTypeFunc($var, $typeTest = null, $className = null)
-    {
-
-        switch ($typeTest) {
-            default:
-                return false;
-
-            case 'array':
-                return is_array($var);
-
-            case 'bool':
-                return is_bool($var);
-
-            case 'class':
-                return is_object($var) === true && get_class($var) === $className;
-
-            case 'float':
-                return is_float($var);
-
-            case 'int':
-                return is_int($var);
-
-            case 'numeric':
-                return is_numeric($var);
-
-            case 'object':
-                return is_object($var);
-
-            case 'scalar':
-                return is_scalar($var);
-
-            case 'string':
-                return is_string($var);
-        }
-    }
 }

+ 309 - 72
system/src/Grav/Common/Utils.php

@@ -44,7 +44,7 @@ use function strlen;
  */
 abstract class Utils
 {
-    /** @var array  */
+    /** @var array */
     protected static $nonces = [];
 
     protected const ROOTURL_REGEX = '{^((?:http[s]?:\/\/[^\/]+)|(?:\/\/[^\/]+))(.*)}';
@@ -178,8 +178,8 @@ abstract class Utils
     /**
      * Check if the $haystack string starts with the substring $needle
      *
-     * @param  string $haystack
-     * @param  string|string[] $needle
+     * @param string $haystack
+     * @param string|string[] $needle
      * @param bool $case_sensitive
      * @return bool
      */
@@ -202,8 +202,8 @@ abstract class Utils
     /**
      * Check if the $haystack string ends with the substring $needle
      *
-     * @param  string $haystack
-     * @param  string|string[] $needle
+     * @param string $haystack
+     * @param string|string[] $needle
      * @param bool $case_sensitive
      * @return bool
      */
@@ -227,9 +227,9 @@ abstract class Utils
     /**
      * Check if the $haystack string contains the substring $needle
      *
-     * @param  string $haystack
-     * @param  string|string[] $needle
-     * @param  bool $case_sensitive
+     * @param string $haystack
+     * @param string|string[] $needle
+     * @param bool $case_sensitive
      * @return bool
      */
     public static function contains($haystack, $needle, $case_sensitive = true)
@@ -266,19 +266,19 @@ abstract class Utils
     {
         $regex = str_replace(
             array("\*", "\?"), // wildcard chars
-            array('.*','.'),   // regexp chars
+            array('.*', '.'),   // regexp chars
             preg_quote($wildcard_pattern, '/')
         );
 
-        return preg_match('/^'.$regex.'$/is', $haystack);
+        return preg_match('/^' . $regex . '$/is', $haystack);
     }
 
     /**
      * Render simple template filling up the variables in it. If value is not defined, leave it as it was.
      *
-     * @param string $template      Template string
-     * @param array $variables      Variables with values
-     * @param array $brackets       Optional array of opening and closing brackets or symbols
+     * @param string $template Template string
+     * @param array $variables Variables with values
+     * @param array $brackets Optional array of opening and closing brackets or symbols
      * @return string               Final string filled with values
      */
     public static function simpleTemplate(string $template, array $variables, array $brackets = ['{', '}']): string
@@ -376,8 +376,8 @@ abstract class Utils
     /**
      * Merge two objects into one.
      *
-     * @param  object $obj1
-     * @param  object $obj2
+     * @param object $obj1
+     * @param object $obj2
      *
      * @return object
      */
@@ -415,7 +415,7 @@ abstract class Utils
      */
     public static function arrayRemoveValue(array $search, $value)
     {
-        foreach ((array) $value as $val) {
+        foreach ((array)$value as $val) {
             $key = array_search($val, $search);
             if ($key !== false) {
                 unset($search[$key]);
@@ -481,8 +481,8 @@ abstract class Utils
     /**
      * Array combine but supports different array lengths
      *
-     * @param  array $arr1
-     * @param  array $arr2
+     * @param array $arr1
+     * @param array $arr2
      * @return array|false
      */
     public static function arrayCombine($arr1, $arr2)
@@ -495,7 +495,7 @@ abstract class Utils
     /**
      * Array is associative or not
      *
-     * @param  array $arr
+     * @param array $arr
      * @return bool
      */
     public static function arrayIsAssociative($arr)
@@ -517,15 +517,15 @@ abstract class Utils
         $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').')',
-            ];
+            '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);
+            $date_formats = array_merge([$default_format => $default_format . ' (e.g. ' . $now->format($default_format) . ')'], $date_formats);
         }
 
         return $date_formats;
@@ -552,11 +552,11 @@ abstract class Utils
     /**
      * 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.
+     * @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 = '&hellip;')
@@ -582,7 +582,7 @@ abstract class Utils
      * Truncate text by number of characters in a "word-safe" manor.
      *
      * @param string $string
-     * @param int    $limit
+     * @param int $limit
      * @return string
      */
     public static function safeTruncate($string, $limit = 150)
@@ -594,9 +594,9 @@ abstract class Utils
     /**
      * Truncate HTML by number of characters. not "word-safe"!
      *
-     * @param  string $text
-     * @param  int $length in characters
-     * @param  string $ellipsis
+     * @param string $text
+     * @param int $length in characters
+     * @param string $ellipsis
      * @return string
      */
     public static function truncateHtml($text, $length = 100, $ellipsis = '...')
@@ -607,9 +607,9 @@ abstract class Utils
     /**
      * Truncate HTML by number of characters in a "word-safe" manor.
      *
-     * @param  string $text
-     * @param  int    $length in words
-     * @param  string $ellipsis
+     * @param string $text
+     * @param int $length in words
+     * @param string $ellipsis
      * @return string
      */
     public static function safeTruncateHtml($text, $length = 25, $ellipsis = '...')
@@ -633,8 +633,8 @@ abstract class Utils
      *
      * @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
+     * @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)
@@ -645,7 +645,7 @@ abstract class Utils
 
             $file_parts = pathinfo($file);
             $mimetype = static::getMimeByExtension($file_parts['extension']);
-            $size   = filesize($file); // File size
+            $size = filesize($file); // File size
 
             // clean all buffers
             while (ob_get_level()) {
@@ -742,7 +742,7 @@ abstract class Utils
         // Set from uri extension
         $uri_extension = $uri->extension();
         if (is_string($uri_extension) && $uri->isValidExtension($uri_extension)) {
-            return($uri_extension);
+            return ($uri_extension);
         }
 
         // Use content negotiation via the `accept:` header
@@ -1060,7 +1060,7 @@ abstract class Utils
 
             $pretty_offset = "UTC${offset_prefix}${offset_formatted}";
 
-            $timezone_list[$timezone] = "(${pretty_offset}) ".str_replace('_', ' ', $timezone);
+            $timezone_list[$timezone] = "(${pretty_offset}) " . str_replace('_', ' ', $timezone);
         }
 
         return $timezone_list;
@@ -1069,11 +1069,11 @@ abstract class Utils
     /**
      * 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
+     * @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)
+    public static function arrayFilterRecursive(array $source, $fn)
     {
         $result = [];
         foreach ($source as $key => $value) {
@@ -1093,15 +1093,15 @@ abstract class Utils
     /**
      * Flatten a multi-dimensional associative array into query params.
      *
-     * @param  array   $array
-     * @param  string  $prepend
+     * @param array $array
+     * @param string $prepend
      * @return array
      */
     public static function arrayToQueryParams($array, $prepend = '')
     {
         $results = [];
         foreach ($array as $key => $value) {
-            $name = $prepend ? $prepend  . '[' . $key . ']' : $key;
+            $name = $prepend ? $prepend . '[' . $key . ']' : $key;
 
             if (is_array($value)) {
                 $results = array_merge($results, static::arrayToQueryParams($value, $name));
@@ -1138,8 +1138,8 @@ abstract class Utils
     /**
      * Flatten a multi-dimensional associative array into dot notation
      *
-     * @param  array   $array
-     * @param  string  $prepend
+     * @param array $array
+     * @param string $prepend
      * @return array
      */
     public static function arrayFlattenDotNotation($array, $prepend = '')
@@ -1147,9 +1147,9 @@ abstract class Utils
         $results = array();
         foreach ($array as $key => $value) {
             if (is_array($value)) {
-                $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend.$key.'.'));
+                $results = array_merge($results, static::arrayFlattenDotNotation($value, $prepend . $key . '.'));
             } else {
-                $results[$prepend.$key] = $value;
+                $results[$prepend . $key] = $value;
             }
         }
 
@@ -1297,7 +1297,7 @@ abstract class Utils
      * with reverse proxy setups.
      *
      * @param string $action
-     * @param bool   $previousTick if true, generates the token for the previous tick (the previous 12 hours)
+     * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)
      * @return string the nonce string
      */
     private static function generateNonceString($action, $previousTick = false)
@@ -1334,8 +1334,8 @@ abstract class Utils
      * 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   $previousTick if true, generates the token for the previous tick (the previous 12 hours)
+     * @param string $action the action the nonce is tied to (e.g. save-user-admin or move-page-homepage)
+     * @param bool $previousTick if true, generates the token for the previous tick (the previous 12 hours)
      * @return string the nonce
      */
     public static function getNonce($action, $previousTick = false)
@@ -1353,7 +1353,7 @@ abstract class Utils
     /**
      * Verify the passed nonce for the give action
      *
-     * @param string|string[] $nonce  the nonce to verify
+     * @param string|string[] $nonce the nonce to verify
      * @param string $action the action to verify the nonce to
      * @return boolean verified or not
      */
@@ -1370,9 +1370,7 @@ abstract class Utils
         }
 
         //Nonce generated 12-24 hours ago
-        $previousTick = true;
-
-        return $nonce === self::getNonce($action, $previousTick);
+        return $nonce === self::getNonce($action, true);
     }
 
     /**
@@ -1382,11 +1380,7 @@ abstract class Utils
      */
     public static function isAdminPlugin()
     {
-        if (isset(Grav::instance()['admin'])) {
-            return true;
-        }
-
-        return false;
+        return isset(Grav::instance()['admin']);
     }
 
     /**
@@ -1440,7 +1434,7 @@ abstract class Utils
         while (count($keys) > 1) {
             $key = array_shift($keys);
 
-            if (! isset($array[$key]) || ! is_array($array[$key])) {
+            if (!isset($array[$key]) || !is_array($array[$key])) {
                 $array[$key] = array();
             }
 
@@ -1731,7 +1725,7 @@ abstract class Utils
             $size *= 1024 ** stripos('bkmgtpezy', $unit[0]);
         }
 
-        return (int) abs(round($size));
+        return (int)abs(round($size));
     }
 
     /**
@@ -1776,7 +1770,7 @@ abstract class Utils
     public static function processMarkdown($string, $block = true, $page = null)
     {
         $grav = Grav::instance();
-        $page     = $page ?? $grav['page'] ?? null;
+        $page = $page ?? $grav['page'] ?? null;
         $defaults = [
             'markdown' => $grav['config']->get('system.pages.markdown', []),
             'images' => $grav['config']->get('system.images', [])
@@ -1818,12 +1812,12 @@ abstract class Utils
         $ip = (string)inet_pton($ip);
 
         // Maximum netmask length = same as packed address
-        $len = 8*strlen($ip);
+        $len = 8 * strlen($ip);
         if ($prefix > $len) {
             $prefix = $len;
         }
 
-        $mask  = str_repeat('f', $prefix>>2);
+        $mask = str_repeat('f', $prefix >> 2);
 
         switch ($prefix & 3) {
             case 3:
@@ -1836,7 +1830,7 @@ abstract class Utils
                 $mask .= '8';
                 break;
         }
-        $mask = str_pad($mask, $len>>2, '0');
+        $mask = str_pad($mask, $len >> 2, '0');
 
         // Packed representation of netmask
         $mask = pack('H*', $mask);
@@ -1850,11 +1844,14 @@ abstract class Utils
      * Wrapper to ensure html, htm in the front of the supported page types
      *
      * @param array|null $defaults
-     * @return array|mixed
+     * @return array
      */
     public static function getSupportPageTypes(array $defaults = null)
     {
         $types = Grav::instance()['config']->get('system.pages.types', $defaults);
+        if (!is_array($types)) {
+            return [];
+        }
 
         // remove html/htm
         $types = static::arrayRemoveValue($types, ['html', 'htm']);
@@ -1864,4 +1861,244 @@ abstract class Utils
 
         return $types;
     }
+
+    /**
+     * @param string $name
+     * @return bool
+     */
+    public static function isDangerousFunction(string $name): bool
+    {
+        static $commandExecutionFunctions = [
+            'exec',
+            'passthru',
+            'system',
+            'shell_exec',
+            'popen',
+            'proc_open',
+            'pcntl_exec',
+        ];
+
+        static $codeExecutionFunctions = [
+            'assert',
+            'preg_replace',
+            'create_function',
+            'include',
+            'include_once',
+            'require',
+            'require_once'
+        ];
+
+        static $callbackFunctions = [
+            'ob_start' => 0,
+            'array_diff_uassoc' => -1,
+            'array_diff_ukey' => -1,
+            'array_filter' => 1,
+            'array_intersect_uassoc' => -1,
+            'array_intersect_ukey' => -1,
+            'array_map' => 0,
+            'array_reduce' => 1,
+            'array_udiff_assoc' => -1,
+            'array_udiff_uassoc' => [-1, -2],
+            'array_udiff' => -1,
+            'array_uintersect_assoc' => -1,
+            'array_uintersect_uassoc' => [-1, -2],
+            'array_uintersect' => -1,
+            'array_walk_recursive' => 1,
+            'array_walk' => 1,
+            'assert_options' => 1,
+            'uasort' => 1,
+            'uksort' => 1,
+            'usort' => 1,
+            'preg_replace_callback' => 1,
+            'spl_autoload_register' => 0,
+            'iterator_apply' => 1,
+            'call_user_func' => 0,
+            'call_user_func_array' => 0,
+            'register_shutdown_function' => 0,
+            'register_tick_function' => 0,
+            'set_error_handler' => 0,
+            'set_exception_handler' => 0,
+            'session_set_save_handler' => [0, 1, 2, 3, 4, 5],
+            'sqlite_create_aggregate' => [2, 3],
+            'sqlite_create_function' => 2,
+        ];
+
+        static $informationDiscosureFunctions = [
+            'phpinfo',
+            'posix_mkfifo',
+            'posix_getlogin',
+            'posix_ttyname',
+            'getenv',
+            'get_current_user',
+            'proc_get_status',
+            'get_cfg_var',
+            'disk_free_space',
+            'disk_total_space',
+            'diskfreespace',
+            'getcwd',
+            'getlastmo',
+            'getmygid',
+            'getmyinode',
+            'getmypid',
+            'getmyuid'
+        ];
+
+        static $otherFunctions = [
+            'extract',
+            'parse_str',
+            'putenv',
+            'ini_set',
+            'mail',
+            'header',
+            'proc_nice',
+            'proc_terminate',
+            'proc_close',
+            'pfsockopen',
+            'fsockopen',
+            'apache_child_terminate',
+            'posix_kill',
+            'posix_mkfifo',
+            'posix_setpgid',
+            'posix_setsid',
+            'posix_setuid',
+        ];
+
+        if (in_array($name, $commandExecutionFunctions)) {
+            return true;
+        }
+
+        if (in_array($name, $codeExecutionFunctions)) {
+            return true;
+        }
+
+        if (isset($callbackFunctions[$name])) {
+            return true;
+        }
+
+        if (in_array($name, $informationDiscosureFunctions)) {
+            return true;
+        }
+
+        if (in_array($name, $otherFunctions)) {
+            return true;
+        }
+
+        return static::isFilesystemFunction($name);
+    }
+
+    /**
+     * @param string $name
+     * @return bool
+     */
+    public static function isFilesystemFunction(string $name): bool
+    {
+        static $fileWriteFunctions = [
+            'fopen',
+            'tmpfile',
+            'bzopen',
+            'gzopen',
+            // write to filesystem (partially in combination with reading)
+            'chgrp',
+            'chmod',
+            'chown',
+            'copy',
+            'file_put_contents',
+            'lchgrp',
+            'lchown',
+            'link',
+            'mkdir',
+            'move_uploaded_file',
+            'rename',
+            'rmdir',
+            'symlink',
+            'tempnam',
+            'touch',
+            'unlink',
+            'imagepng',
+            'imagewbmp',
+            'image2wbmp',
+            'imagejpeg',
+            'imagexbm',
+            'imagegif',
+            'imagegd',
+            'imagegd2',
+            'iptcembed',
+            'ftp_get',
+            'ftp_nb_get',
+        ];
+
+        static $fileContentFunctions = [
+            'file_get_contents',
+            'file',
+            'filegroup',
+            'fileinode',
+            'fileowner',
+            'fileperms',
+            'glob',
+            'is_executable',
+            'is_uploaded_file',
+            'parse_ini_file',
+            'readfile',
+            'readlink',
+            'realpath',
+            'gzfile',
+            'readgzfile',
+            'stat',
+            'imagecreatefromgif',
+            'imagecreatefromjpeg',
+            'imagecreatefrompng',
+            'imagecreatefromwbmp',
+            'imagecreatefromxbm',
+            'imagecreatefromxpm',
+            'ftp_put',
+            'ftp_nb_put',
+            'hash_update_file',
+            'highlight_file',
+            'show_source',
+            'php_strip_whitespace',
+        ];
+
+        static $filesystemFunctions = [
+            // read from filesystem
+            'file_exists',
+            'fileatime',
+            'filectime',
+            'filemtime',
+            'filesize',
+            'filetype',
+            'is_dir',
+            'is_file',
+            'is_link',
+            'is_readable',
+            'is_writable',
+            'is_writeable',
+            'linkinfo',
+            'lstat',
+            //'pathinfo',
+            'getimagesize',
+            'exif_read_data',
+            'read_exif_data',
+            'exif_thumbnail',
+            'exif_imagetype',
+            'hash_file',
+            'hash_hmac_file',
+            'md5_file',
+            'sha1_file',
+            'get_meta_tags',
+        ];
+
+        if (in_array($name, $fileWriteFunctions)) {
+            return true;
+        }
+
+        if (in_array($name, $fileContentFunctions)) {
+            return true;
+        }
+
+        if (in_array($name, $filesystemFunctions)) {
+            return true;
+        }
+
+        return false;
+    }
 }

+ 2 - 2
system/src/Grav/Console/Cli/InstallCommand.php

@@ -73,11 +73,11 @@ class InstallCommand extends GravCommand
         $io = $this->getIO();
 
         $dependencies_file = '.dependencies';
-        $this->destination = $input->getArgument('destination') ?: GRAV_ROOT;
+        $this->destination = $input->getArgument('destination') ?: GRAV_WEBROOT;
 
         // fix trailing slash
         $this->destination = rtrim($this->destination, DS) . DS;
-        $this->user_path = $this->destination . USER_PATH;
+        $this->user_path = $this->destination . GRAV_USER_PATH . DS;
         if ($local_config_file = $this->loadLocalConfig()) {
             $io->writeln('Read local config from <cyan>' . $local_config_file . '</cyan>');
         }

+ 7 - 1
system/src/Grav/Console/Gpm/InstallCommand.php

@@ -600,7 +600,13 @@ class InstallCommand extends GpmCommand
         try {
             $output = Response::get($package->zipball_url . $query, [], [$this, 'progress']);
         } catch (Exception $e) {
-            $error = str_replace("\n", "\n  |  '- ", $e->getMessage());
+            if (!empty($package->premium) && $e->getCode() === 401) {
+                $message = '<yellow>Unauthorized Premium License Key</yellow>';
+            } else {
+                $message = $e->getMessage();
+            }
+
+            $error = str_replace("\n", "\n  |  '- ", $message);
             $io->write("\x0D");
             // extra white spaces to clear out the buffer properly
             $io->writeln('  |- Downloading package...    <red>error</red>                             ');

+ 1 - 1
system/src/Grav/Console/Gpm/UninstallCommand.php

@@ -112,7 +112,7 @@ class UninstallCommand extends GpmCommand
 
         unset($this->data['not_found'], $this->data['total']);
 
-        // Plugins need to be initialized in order to make clear-cache to work.
+        // Plugins need to be initialized in order to make clearcache to work.
         try {
             $this->initializePlugins();
         } catch (Throwable $e) {

+ 1 - 0
system/src/Grav/Framework/Controller/Traits/ControllerResponseTrait.php

@@ -173,6 +173,7 @@ trait ControllerResponseTrait
             if ($method !== 'GET' && $method !== 'HEAD') {
                 $this->setMessage($message, 'error');
                 $referer = $request->getHeaderLine('Referer');
+
                 return $this->createRedirectResponse($referer, 303);
             }
 

+ 1 - 1
system/src/Grav/Framework/Flex/FlexForm.php

@@ -119,7 +119,7 @@ class FlexForm implements FlexObjectFormInterface, JsonSerializable
         }
         $this->setUniqueId($uniqueId);
         $directory = $object->getFlexDirectory();
-        $this->setFlashLookupFolder($directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');
+        $this->setFlashLookupFolder($options['flash_folder'] ?? $directory->getBlueprint()->get('form/flash_folder') ?? 'tmp://forms/[SESSIONID]');
         $this->form = $options['form'] ?? null;
 
         if (!empty($options['reset'])) {

+ 24 - 7
system/src/Grav/Framework/Flex/FlexObject.php

@@ -196,9 +196,10 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
     /**
      * Refresh object from the storage.
      *
+     * @param bool $keepMissing
      * @return bool True if the object was refreshed
      */
-    public function refresh(): bool
+    public function refresh(bool $keepMissing = false): bool
     {
         $key = $this->getStorageKey();
         if ('' === $key) {
@@ -216,20 +217,36 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             return false;
         }
 
+        // Get current elements (if requested).
+        $current = $keepMissing ? $this->getElements() : [];
+        // Get elements from the filesystem.
         $elements = $storage->readRows([$key => null])[$key] ?? null;
-        if (null !== $elements || isset($elements['__ERROR'])) {
-            $meta = $elements['_META'] ?? $meta;
+        if (null !== $elements) {
+            $meta = $elements['__META'] ?? $meta;
+            unset($elements['__META']);
             $this->filterElements($elements);
             $newKey = $meta['key'] ?? $this->getKey();
             if ($meta) {
                 $this->setMetaData($meta);
             }
             $this->objectConstruct($elements, $newKey);
-        }
 
-        /** @var Debugger $debugger */
-        $debugger = Grav::instance()['debugger'];
-        $debugger->addMessage("Refreshed {$this->getFlexType()} object {$this->getKey()}", 'debug');
+            if ($current) {
+                // Inject back elements which are missing in the filesystem.
+                $data = $this->getBlueprint()->flattenData($current);
+                foreach ($data as $property => $value) {
+                    if (strpos($property, '.') === false) {
+                        $this->defProperty($property, $value);
+                    } else {
+                        $this->defNestedProperty($property, $value);
+                    }
+                }
+            }
+
+            /** @var Debugger $debugger */
+            $debugger = Grav::instance()['debugger'];
+            $debugger->addMessage("Refreshed {$this->getFlexType()} object {$this->getKey()}", 'debug');
+        }
 
         return true;
     }

+ 15 - 8
system/src/Grav/Framework/Flex/Pages/Traits/PageLegacyTrait.php

@@ -320,14 +320,21 @@ trait PageLegacyTrait
 
         // Find non-existing key.
         $parentKey = $parent ? $parent->getKey() : '';
-        $key = trim($parentKey . '/' . basename($this->getKey()), '/');
-        $key = preg_replace('/-\d+$/', '', $key);
-        $i = 1;
-        do {
-            $i++;
-            $test = "{$key}-{$i}";
-        } while ($index->containsKey($test));
-        $key = $test;
+        if ($this instanceof FlexPageObject) {
+            $key = trim($parentKey . '/' . $this->folder(), '/');
+        } else {
+            $key = trim($parentKey . '/' . basename($this->getKey()), '/');
+        }
+
+        if ($index->containsKey($key)) {
+            $key = preg_replace('/\d+$/', '', $key);
+            $i = 1;
+            do {
+                $i++;
+                $test = "{$key}{$i}";
+            } while ($index->containsKey($test));
+            $key = $test;
+        }
         $folder = basename($key);
 
         // Get the folder name.

+ 3 - 0
user/config/themes/antimatter.yaml

@@ -0,0 +1,3 @@
+enabled: true
+dropdown:
+  enabled: true

+ 1 - 1
user/pages/01.home/01._accueil/accueil.md

@@ -2,6 +2,6 @@
 title: Accueil
 body_classes: modular
 visible: true
-debugger: true
+debugger: false
 ---
 

+ 2 - 2
user/pages/01.home/02._programmes/programmes.md

@@ -14,7 +14,7 @@ programmes:
         site: 'http://www.popsu.archi.fr/'
     -
         nom_du_programme: 'Programme Coubertin'
-        texte_de_presentation: "Le programme de recherche-action Coubertin construit le récit, au fil de l’eau, de la conception des ouvrages et des opérations d’aménagement des Jeux Olympiques et Paralympiques de Paris 2024. À travers une observation embarquée au sein de la SOLIDEO, l’équipe de chercheurs analyse la production architecturale et urbaine et sa capacité à transformer les pratiques d’aménagement.\r\n                        \r\n                    "
+        texte_de_presentation: "Le programme de recherche-action Coubertin construit le récit, au fil de l’eau, de la conception des ouvrages et des opérations d’aménagement des Jeux Olympiques et Paralympiques de Paris 2024. À travers une observation embarquée au sein de la SOLIDEO, l’équipe de chercheurs analyse la production architecturale et urbaine et sa capacité à transformer les pratiques d’aménagement.\n                        \n                    "
         logo: logo_coubertin.png
         site: 'http://www.urbanisme-puca.gouv.fr/programme-coubertin-un-programme-de-recherche-a2089.html#:~:text=Un%20centre%20de%20ressources%20sur%20les%20Jeux%20Olympiques%20et%20Paralympiques,Olympiques%20et%20Paralympiques%20de%20Paris%E2%80%A6'
     -
@@ -23,7 +23,7 @@ programmes:
         logo: forum_solution.png
         site: 'http://www.urbanisme-puca.gouv.fr/forums-des-solutions-en-videos-a1879.html'
 visible: true
-debugger: true
+debugger: false
 media_order: '14360_default_big.jpg,10361_web_01.jpg,hyperliens_marseille.PNG,popsu.png,forum_solution.png,europan_france.jpg,logo_coubertin.png,POPSU2018_LOGO_moinsde6cm.png'
 process:
     markdown: true

BIN
user/pages/01.home/03._ressources/Couv note orientation.PNG


BIN
user/pages/01.home/03._ressources/Couv rapport activités.PNG


BIN
user/pages/01.home/03._ressources/Note d'orientation EPAU 2021-2023.pdf


BIN
user/pages/01.home/03._ressources/Rapport d'activité EPAU 2020.pdf


+ 10 - 25
user/pages/01.home/03._ressources/ressources.md

@@ -1,35 +1,20 @@
 ---
 title: Ressources
 body_classes: modular
-media_order: 'rapport_activite_V1.pdf,rapport_activite_V1.png,rapport_activite_V1.pdf'
+media_order: 'Couv note orientation.PNG,Couv rapport activités.PNG,Note d''orientation EPAU 2021-2023.pdf,Rapport d''activité EPAU 2020.pdf'
 visible: true
-debugger: true
+debugger: false
 rapports:
     -
-        titre_du_rapport: 'rapport d''activité 2020'
-        texte_de_presentation: "Courte explicationVita estillis semper in fuga uxoresque mercenariae\r\n infugauxoresque mercenariae conductae ad tempus expacto atque, ut conductae ad tempus ex pacto atque, ut sit species matrimonii, dotis nomine futura coniunx hastam.\r\n"
-        couverture: rapport_activite_V1.png
-        pdf: rapport_activite_V1.pdf
+        titre_du_rapport: 'Rapport d''activité 2020'
+        texte_de_presentation: "Le rapport d’activité présente les principales actions réalisées par les programmes Europan, POPSU et Coubertin à l’occasion de l’année 2020. \n"
+        couverture: 'Couv rapport activités.PNG'
+        pdf: 'Rapport d''activité EPAU 2020.pdf'
     -
-        titre_du_rapport: 'rapport 3'
-        texte_de_presentation: "Courte explicationVita estillis semper in fuga uxoresque mercenariae\r\n infugauxoresque mercenariae conductae ad tempus expacto atque, ut conductae ad tempus ex pacto atque, ut sit species matrimonii, dotis nomine futura coniunx hastam.\r\n"
-        couverture: rapport_activite_V1.png
-        pdf: rapport_activite_V1.pdf
-    -
-        titre_du_rapport: 'exemple 3'
-        texte_de_presentation: "Courte explicationVita estillis semper in fuga uxoresque mercenariae\r\n infugauxoresque mercenariae conductae ad tempus expacto atque, ut conductae ad tempus ex pacto atque, ut sit species matrimonii, dotis nomine futura coniunx hastam.\r\n"
-        couverture: rapport_activite_V1.png
-        pdf: rapport_activite_V1.pdf
-    -
-        titre_du_rapport: 'exemple 4'
-        texte_de_presentation: "Courte explicationVita estillis semper in fuga uxoresque mercenariae\r\n infugauxoresque mercenariae conductae ad tempus expacto atque, ut conductae ad tempus ex pacto atque, ut sit species matrimonii, dotis nomine futura coniunx hastam.\r\n"
-        couverture: rapport_activite_V1.png
-        pdf: rapport_activite_V1.pdf
-    -
-        titre_du_rapport: 'exempl 5'
-        texte_de_presentation: "Courte explicationVita estillis semper in fuga uxoresque mercenariae\r\n infugauxoresque mercenariae conductae ad tempus expacto atque, ut conductae ad tempus ex pacto atque, ut sit species matrimonii, dotis nomine futura coniunx hastam.\r\n"
-        couverture: rapport_activite_V1.png
-        pdf: rapport_activite_V1.pdf
+        titre_du_rapport: 'Note d''orientation 2021-2023'
+        texte_de_presentation: "La note d’orientation présente la stratégie de l’Europe des projets architecturaux et urbains de 2021 à 2023. \n"
+        couverture: 'Couv note orientation.PNG'
+        pdf: 'Note d''orientation EPAU 2021-2023.pdf'
 process:
     markdown: true
     twig: false

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
user/pages/01.home/04._gouvernance/01._presidence/personnes.md


BIN
user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/Alain Maugard.jpg


BIN
user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/catherine chevillot 274 (1).jpg


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
user/pages/01.home/04._gouvernance/02._membres-du-conseil-dadministration/personnes.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
user/pages/01.home/04._gouvernance/03._equipe-epau/01._direction-generale-epau/personnes.md


BIN
user/pages/01.home/04._gouvernance/03._equipe-epau/02._direction-europan-france/Louis Vitalis.png


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
user/pages/01.home/04._gouvernance/03._equipe-epau/02._direction-europan-france/personnes.md


+ 1 - 1
user/pages/01.home/04._gouvernance/03._equipe-epau/04._direction-coubertin/personnes.md

@@ -5,7 +5,7 @@ personnes:
     -
         nom: 'Julien Moulard'
         fonction: 'Responsable de programme'
-        biographie: "  Photo et bio demandées\r\njulien.moulard@popsu.archi.fr\r\n+ 33 (0)1 40 81 70 72\r\n+ 33 (0)7 86 50 43 17"
+        biographie: "julien.moulard@popsu.archi.fr\n+ 33 (0)1 40 81 70 72\n+ 33 (0)7 86 50 43 17"
         portrait: null
 ---
 

+ 1 - 2
user/pages/01.home/04._gouvernance/gouvernance.md

@@ -15,7 +15,6 @@ content:
             - _equipe-epau
 admin:
     children_display_order: collection
-debugger: true
+debugger: false
 ---
 
-

+ 3 - 2
user/pages/01.home/05._contact/text.md

@@ -1,8 +1,9 @@
 ---
 title: Contact
 body_classes: modular
-media_order: 'puca.png,logo cité archi.jpg,logo ministère culture.png,europan_france.jpg,logo_gouvernment.png,logo MCTRCT.png,logo min logement.png'
+media_order: 'logo_gouvernment.png,logo MCTRCT.png,logo min logement.png,logo ministère culture.png,europan_france.jpg,puca.png,logo cité archi.jpg'
 published: true
+debugger: false
 ---
 
-![logo_gouvernment](logo_gouvernment.png "logo_gouvernment") ![logo%20MCTRCT](logo%20MCTRCT.png "logo%20MCTRCT") ![logo%20minist%C3%A8re%20culture](logo%20minist%C3%A8re%20culture.png "logo%20minist%C3%A8re%20culture") ![logo%20min%20logement](logo%20min%20logement.png "logo%20min%20logement") ![europan_france](europan_france.jpg "europan_france") ![puca](puca.png "puca") ![logo%20cit%C3%A9%20archi](logo%20cit%C3%A9%20archi.jpg "logo%20cit%C3%A9%20archi") 
+![logo_gouvernment](logo_gouvernment.png "logo_gouvernment")![logo%20MCTRCT](logo%20MCTRCT.png "logo%20MCTRCT")![logo%20minist%C3%A8re%20culture](logo%20minist%C3%A8re%20culture.png "logo%20minist%C3%A8re%20culture") ![logo%20min%20logement](logo%20min%20logement.png "logo%20min%20logement")![europan_france](europan_france.jpg "europan_france") ![puca](puca.png "puca")![logo%20cit%C3%A9%20archi](logo%20cit%C3%A9%20archi.jpg "logo%20cit%C3%A9%20archi")

+ 2 - 0
user/themes/antimatter/.gitignore

@@ -0,0 +1,2 @@
+.sass-cache
+.DS_Store

+ 81 - 1
user/themes/antimatter/CHANGELOG.md

@@ -1,3 +1,83 @@
+# v2.2.1
+## 01/15/2021
+
+1. [](#improved)
+    * Fixed autoescaping in preparation for Grav 1.7
+1. [](#bugfix)
+    * Fix deprecated messages from macros [#140](https://github.com/getgrav/grav-theme-antimatter/issues/140)
+
+# v2.2.0
+## 03/21/2019
+
+1. [](#new)
+    * Set Dependency of Grav 1.5.10+ which has support for new **Deferred Block** Twig extension
+    * Implement assets rendering using **Deferred Block** Twig extension 
+
+# v2.1.2
+## 9/28/2017
+
+1. [](#improved)
+    * Added polish and brazilian portuguese translations
+    * Allow overriding the slidebar by moving it to its own `sidebar_navigation` block [#110](https://github.com/getgrav/grav-theme-antimatter/pull/110)
+1. [](#bugfix)
+    * Fix showcase template overlapping when not at top [#99](https://github.com/getgrav/grav-theme-antimatter/pull/99)
+
+# v2.1.1
+## 02/26/2017
+
+1. [](#bugfix)
+    * Allow to add a `external` class to site.yaml links to work on modular pages [#95](https://github.com/getgrav/grav-theme-antimatter/pull/95)
+
+# v2.1.0
+## 01/24/2017
+
+1. [](#new)
+    * Updated to FontAwesome 4.7.0 with [Grav icon](http://fontawesome.io/icon/grav/)
+1. [](#improved)
+    * Added croatian
+    * Changed "SimpleSearch" string in the sidebar to "Search"
+1. [](#bugfix)
+    * Removed unreachable condition [#85](https://github.com/getgrav/grav-theme-antimatter/pull/85)
+    * Fixed a typo in the french translation
+
+# v2.0.0
+## 07/14/2016
+
+1. [](#new)
+    * Added microformats2 support [#64](https://github.com/getgrav/grav-theme-antimatter/pull/64)
+1. [](#improved)
+    * Updated to FontAwesome 4.6.3
+    * Added romanian, russian and ukranian
+
+# v2.0.0-beta.1
+## 05/23/2016
+
+1. [](#new)
+    * New and improved **dropdown** styling
+1. [](#improved)
+    * Removed templates from `form` + `snipcart` plugins
+    * Added support for search button
+    * Updated some translations
+    * Automatically add comments if configured
+    * Relative path for favicon
+    * Slightly modified the blockquote background color
+    * Removed unneeded streams from YAML
+    * Use common language strings in Blueprint
+
+# v1.8.0
+## 11/20/2015
+
+1. [](#new)
+    * Added logic to include site.menu items in modular pages
+    * Added a configurable lang field for HTML tag
+    * Added a `bottom` JS output call
+1. [](#improved)
+    * Updated to FontAwesome 4.4.0
+1. [](#bugfix)
+    * Fixed extra `/` in some tag URLs
+    * Better support for PECL Yaml parser
+    * Fixes for blog page blueprint
+
 # v1.7.6
 ## 10/07/2015
 
@@ -46,7 +126,7 @@
 1. [](#new)
     * Blueprints that work with new admin plugin!
 1. [](#bugfix)
-    * Favicon with full image URL   
+    * Favicon with full image URL
 
 # v1.6.1
 ## 07/24/2015

+ 0 - 7
user/themes/antimatter/antimatter.yaml

@@ -1,10 +1,3 @@
 enabled: true
 dropdown:
   enabled: false
-
-streams:
-  schemes:
-    theme:
-      type: ReadOnlyStream
-      paths:
-        - user/themes/antimatter

+ 6 - 3
user/themes/antimatter/blueprints.yaml

@@ -1,5 +1,5 @@
 name: Antimatter
-version: 1.7.6
+version: 2.2.1
 description: "Antimatter is the default theme included with **Grav**"
 icon: empire
 author:
@@ -12,6 +12,9 @@ keywords: antimatter, theme, core, modern, fast, responsive, html5, css3
 bugs: https://github.com/getgrav/grav-theme-antimatter/issues
 license: MIT
 
+dependencies:
+  - { name: grav, version: '>=1.5.10' }
+
 form:
   validation: loose
   fields:
@@ -21,7 +24,7 @@ form:
         highlight: 1
         default: 1
         options:
-          1: Enabled
-          0: Disabled
+          1: PLUGIN_ADMIN.ENABLED
+          0: PLUGIN_ADMIN.DISABLED
         validate:
           type: bool

+ 2 - 2
user/themes/antimatter/blueprints/asset/file.yaml

@@ -26,9 +26,9 @@ form:
       type: upload
       label: Upload
       allow:
-        @media.*.keys
+        '@media.*.keys'
       accept:
-        @media.*.values
+        '@media.*.values'
 
     filename:
       type: text

+ 20 - 7
user/themes/antimatter/blueprints/blog.yaml

@@ -1,5 +1,5 @@
 title: Blog
-@extends:
+'@extends':
     type: default
     context: blueprints://pages
 
@@ -33,11 +33,10 @@ form:
 
           fields:
             header.content.items:
-              type: select
+              type: textarea
+              yaml: true
               label: Items
-              default: @self.children
-              options:
-                @self.children: Children
+              default: '@self.children'
 
             header.content.limit:
               type: text
@@ -72,5 +71,19 @@ form:
               highlight: 1
               default: 1
               options:
-                1: Enabled
-                0: Disabled
+                1: PLUGIN_ADMIN.ENABLED
+                0: PLUGIN_ADMIN.DISABLED
+              validate:
+                type: bool
+
+
+            header.content.url_taxonomy_filters:
+              type: toggle
+              label: URL Taxonomy Filters
+              highlight: 1
+              default: 1
+              options:
+                1: PLUGIN_ADMIN.ENABLED
+                0: PLUGIN_ADMIN.DISABLED
+              validate:
+                type: bool

+ 1 - 1
user/themes/antimatter/blueprints/form.yaml

@@ -1,2 +1,2 @@
 title: Nopad
-@extends: default
+'@extends': default

+ 5 - 5
user/themes/antimatter/blueprints/item.yaml

@@ -1,5 +1,5 @@
 title: Item
-@extends:
+'@extends':
     type: default
     context: blueprints://pages
 
@@ -26,8 +26,8 @@ form:
               help: Enabled displaying of a header image
               highlight: 1
               options:
-                1: Enabled
-                0: Disabled
+                1: PLUGIN_ADMIN.ENABLED
+                0: PLUGIN_ADMIN.DISABLED
 
             header.header_image_file:
               type: text
@@ -73,8 +73,8 @@ form:
               label: Summary
               highlight: 1
               options:
-                1: Enabled
-                0: Disabled
+                1: PLUGIN_ADMIN.ENABLED
+                0: PLUGIN_ADMIN.DISABLED
 
             header.summary.format:
               type: select

+ 3 - 3
user/themes/antimatter/blueprints/modular/features.yaml

@@ -1,5 +1,5 @@
 title: Features
-@extends: default
+'@extends': default
 
 form:
   fields:
@@ -13,12 +13,12 @@ form:
                   fields:
                     name:
                       default: modular/features
-                      @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                      '@data-options': '\Grav\Common\Page\Pages::modularTypes'
             overrides:
               fields:
                 header.template:
                   default: modular/features
-                  @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                  '@data-options': '\Grav\Common\Page\Pages::modularTypes'
         features:
           type: tab
           title: Features

+ 6 - 5
user/themes/antimatter/blueprints/modular/showcase.yaml

@@ -1,5 +1,5 @@
 title: Showcase
-@extends: default
+'@extends': default
 
 form:
   fields:
@@ -13,12 +13,12 @@ form:
                   fields:
                     name:
                       default: modular/showcase
-                      @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                      '@data-options': '\Grav\Common\Page\Pages::modularTypes'
             overrides:
               fields:
                 header.template:
                   default: modular/showcase
-                  @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                  '@data-options': '\Grav\Common\Page\Pages::modularTypes'
         buttons:
           type: tab
           title: Buttons
@@ -38,8 +38,9 @@ form:
                   type: toggle
                   label: Primary
                   highlight: 1
+                  default: 1
                   options:
-                      1: Yes
-                      0: No
+                      1: 'Yes'
+                      0: 'No'
                   validate:
                       type: bool

+ 3 - 3
user/themes/antimatter/blueprints/modular/text.yaml

@@ -1,5 +1,5 @@
 title: Text
-@extends: default
+'@extends': default
 
 form:
   fields:
@@ -13,12 +13,12 @@ form:
                   fields:
                     name:
                       default: modular/text
-                      @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                      '@data-options': '\Grav\Common\Page\Pages::modularTypes'
             overrides:
               fields:
                 header.template:
                   default: modular/text
-                  @data-options: '\Grav\Common\Page\Pages::modularTypes'
+                  '@data-options': '\Grav\Common\Page\Pages::modularTypes'
         content:
           fields:
             uploads:

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 4
user/themes/antimatter/css-compiled/nucleus.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
user/themes/antimatter/css-compiled/nucleus.css.map


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 2
user/themes/antimatter/css-compiled/particles.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
user/themes/antimatter/css-compiled/particles.css.map


+ 86 - 31
user/themes/antimatter/css-compiled/template.css

@@ -1,5 +1,5 @@
 @import url(//fonts.googleapis.com/css?family=Montserrat:400|Raleway:300,400,600|Inconsolata);
-#header #logo h3, #header #navbar ul.navigation, #header #navbar .panel-activation, #footer p {
+#header #logo h3, #header #navbar .panel-activation, #footer p {
   position: relative;
   top: 50%;
   -webkit-transform: translateY(-50%);
@@ -51,7 +51,7 @@ b, strong, label, th {
 .right {
   float: right; }
 
-.default-animation, #body, #header, #header #logo h3, .modular .showcase .button {
+.default-animation, #body, #header, #header #logo h3, #header #navbar ul.navigation, .modular .showcase .button {
   -webkit-transition: all 0.5s ease;
   -moz-transition: all 0.5s ease;
   transition: all 0.5s ease; }
@@ -94,6 +94,8 @@ b, strong, label, th {
         color: #0e6e90 !important; }
     #header.scrolled #navbar a:before, #header.scrolled #navbar a:after {
       background-color: #1BB3E9 !important; }
+    #header.scrolled .navigation {
+      margin-top: 0.5rem !important; }
   #header > .grid, #header #logo, #header #navbar {
     height: 100%; }
   #header #logo {
@@ -106,21 +108,49 @@ b, strong, label, th {
       #header #logo h3 a {
         color: #444; }
   #header #navbar {
-    font-size: 0.9rem; }
+    font-size: 0.9rem;
+    /* Child Indicator */ }
+    #header #navbar .has-children > a > span {
+      display: inline-block;
+      padding-right: 8px; }
+      #header #navbar .has-children > a > span:after {
+        font-family: FontAwesome;
+        content: '\f107';
+        position: absolute;
+        display: inline-block;
+        right: 8px;
+        top: 4px; }
+    #header #navbar .has-children > a:after, #header #navbar .has-children > a:before {
+      display: none; }
+    #header #navbar .has-children .has-children > a > span:after {
+      content: '\f105'; }
+    #header #navbar .navigation > .has-children:hover > a {
+      background: #f6f6f6;
+      border: 1px solid #ececec;
+      border-bottom-color: #f6f6f6;
+      margin: -1px -1px 0 -1px;
+      z-index: 1000;
+      position: relative;
+      padding-bottom: 1px; }
     #header #navbar ul {
       margin: 0;
       padding: 0;
       list-style: none; }
       #header #navbar ul.navigation {
         display: inline-block;
-        float: right; }
+        float: right;
+        margin-top: 1.4rem; }
         #header #navbar ul.navigation li {
           float: left;
-          position: relative; }
+          position: relative;
+          /*Active dropdown nav item */
+          /* Dropdown CSS */
+          /* Active on Hover */ }
           #header #navbar ul.navigation li a {
             font-family: "Montserrat", "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif;
             display: inline-block;
-            padding: 0.3rem 0.8rem; }
+            padding: 0.3rem 0.8rem;
+            -webkit-backface-visibility: hidden; }
             #header #navbar ul.navigation li a:before, #header #navbar ul.navigation li a:after {
               content: "";
               position: absolute;
@@ -162,29 +192,35 @@ b, strong, label, th {
             -o-transform: scaleX(0.75);
             transform: scaleX(0.75); }
           #header #navbar ul.navigation li ul {
-            display: none;
-            padding: 0;
-            box-shadow: 0 0.05rem 1rem rgba(0, 0, 0, 0.15) !important; }
-          #header #navbar ul.navigation li ul ul {
-            left: 100%;
-            top: 0; }
-          #header #navbar ul.navigation li:hover > ul {
-            display: block;
             position: absolute;
-            background: rgba(255, 255, 255, 0.9);
-            width: 10rem; }
-          #header #navbar ul.navigation li:hover li {
+            background-color: #f6f6f6;
+            border: 1px solid #ececec;
+            border-top: 0;
+            min-width: 12rem;
+            text-align: left;
+            z-index: 999;
+            left: -1px;
+            display: none; }
+          #header #navbar ul.navigation li ul li {
+            display: block;
             float: none;
-            margin: 0;
-            padding: 0; }
-            #header #navbar ul.navigation li:hover li a {
-              padding: 0.5rem 0.8rem;
-              display: block; }
-              #header #navbar ul.navigation li:hover li a:before, #header #navbar ul.navigation li:hover li a:after {
-                display: none; }
-            #header #navbar ul.navigation li:hover li.active > a {
-              background: #1BB3E9;
+            /* Active Dropdown nav item */ }
+            #header #navbar ul.navigation li ul li.active > a {
+              background-color: #ececec;
+              color: #1BB3E9; }
+            #header #navbar ul.navigation li ul li:hover > a {
+              background-color: #1BB3E9;
               color: #fff; }
+            #header #navbar ul.navigation li ul li a {
+              display: block;
+              margin: 0 -1px; }
+              #header #navbar ul.navigation li ul li a:after, #header #navbar ul.navigation li ul li a:before {
+                display: none; }
+          #header #navbar ul.navigation li ul ul {
+            left: 100%;
+            top: 0px; }
+          #header #navbar ul.navigation li:hover > ul {
+            display: block; }
         @media only all and (max-width: 59.938em) {
           #header #navbar ul.navigation {
             display: none; } }
@@ -214,16 +250,14 @@ b, strong, label, th {
 .header-image #header {
   background-color: rgba(255, 255, 255, 0);
   box-shadow: none; }
+  .header-image #header .navigation .has-children:hover a {
+    color: #1BB3E9; }
   .header-image #header #logo h3, .header-image #header #logo a {
     color: #FFFFFF; }
   .header-image #header a, .header-image #header .menu-btn {
     color: #FFFFFF; }
   .header-image #header a:before, .header-image #header a:after {
     background-color: rgba(255, 255, 255, 0.7) !important; }
-  .header-image #header #navbar ul.navigation ul li a {
-    color: #1BB3E9; }
-    .header-image #header #navbar ul.navigation ul li a:hover {
-      color: #0e6e90; }
 
 #footer {
   position: absolute;
@@ -357,21 +391,37 @@ blockquote > blockquote > blockquote {
     border-left: 10px solid #F0AD4E;
     background: #FCF8F2;
     color: #df8a13; }
+    blockquote > blockquote > blockquote > p a {
+      color: #b06d0f; }
+      blockquote > blockquote > blockquote > p a:hover {
+        color: #f2b866; }
   blockquote > blockquote > blockquote > blockquote > p {
     margin-left: -94px;
     border-left: 10px solid #D9534F;
     background: #FDF7F7;
     color: #b52b27; }
+    blockquote > blockquote > blockquote > blockquote > p a {
+      color: #8b211e; }
+      blockquote > blockquote > blockquote > blockquote > p a:hover {
+        color: #de6764; }
   blockquote > blockquote > blockquote > blockquote > blockquote > p {
     margin-left: -118px;
     border-left: 10px solid #5BC0DE;
     background: #F4F8FA;
     color: #28a1c5; }
+    blockquote > blockquote > blockquote > blockquote > blockquote > p a {
+      color: #1f7e9a; }
+      blockquote > blockquote > blockquote > blockquote > blockquote > p a:hover {
+        color: #70c8e2; }
   blockquote > blockquote > blockquote > blockquote > blockquote > blockquote > p {
     margin-left: -142px;
     border-left: 10px solid #5CB85C;
     background: #F1F9F1;
     color: #3d8b3d; }
+    blockquote > blockquote > blockquote > blockquote > blockquote > blockquote > p a {
+      color: #2d672d; }
+      blockquote > blockquote > blockquote > blockquote > blockquote > blockquote > p a:hover {
+        color: #6ec06e; }
 
 code,
 kbd,
@@ -385,7 +435,7 @@ code {
 
 pre {
   padding: 2rem;
-  background: #f6f6f6;
+  background: #f0f0f0;
   border: 1px solid #ddd;
   border-radius: 3px; }
   pre code {
@@ -713,6 +763,11 @@ ul.pagination {
 @media only all and (max-width: 47.938em) {
   .simplesearch .search-item {
     margin-left: 0; } }
+.simplesearch .search-wrapper .search-submit {
+  height: 52px;
+  padding: 0 10px; }
+  .simplesearch .search-wrapper .search-submit img {
+    width: 30px; }
 .simplesearch .search-details {
   float: right;
   margin-top: -2.5rem;

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
user/themes/antimatter/css-compiled/template.css.map


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
user/themes/antimatter/css/font-awesome.min.css


BIN
user/themes/antimatter/fonts/fontawesome-webfont.eot


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 6 - 34
user/themes/antimatter/fonts/fontawesome-webfont.svg


BIN
user/themes/antimatter/fonts/fontawesome-webfont.ttf


BIN
user/themes/antimatter/fonts/fontawesome-webfont.woff


BIN
user/themes/antimatter/fonts/fontawesome-webfont.woff2


+ 15 - 0
user/themes/antimatter/hebe.json

@@ -0,0 +1,15 @@
+{
+   "project":"grav-theme-antimatter",
+   "platforms":{
+      "grav":{
+         "nodes":{
+            "theme":[
+               {
+                  "source":"/",
+                  "destination":"/user/themes/antimatter"
+               }
+            ]
+         }
+      }
+   }
+}

BIN
user/themes/antimatter/images/favicon.png


BIN
user/themes/antimatter/images/logo.png


+ 294 - 1
user/themes/antimatter/languages.yaml

@@ -1,2 +1,295 @@
 en:
-  TRANSLATION_TEST: Antimatter!
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Continue reading...
+      NEXT_POST: Next Post
+      PREV_POST: Previous Post
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Search
+    RELATED_POSTS:
+      HEADLINE: Related Posts
+    RANDOM_ARTICLE:
+      HEADLINE: Random Article
+      FEELING_LUCKY: I'm Feeling Lucky!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Some Text Widget
+    POPULAR_TAGS:
+      HEADLINE: Popular Tags
+    ARCHIVES:
+      HEADLINE: Archives
+    SYNDICATE:
+      HEADLINE: Syndicate
+  FORM_DATA:
+    SUMMARY: "Here is the summary of what you wrote to us:"
+  ERROR: Error
+
+de:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Weiterlesen...
+      NEXT_POST: Nächster Beitrag
+      PREV_POST: Vorheriger Beitrag
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: SimpleSearch
+    RELATED_POSTS:
+      HEADLINE: Ähnliche Beiträge
+    RANDOM_ARTICLE:
+      HEADLINE: Zufälliger Artikel
+      FEELING_LUCKY: Auf gut Glück!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Text Widget Beispiel
+    POPULAR_TAGS:
+      HEADLINE: Häufigste Tags
+    ARCHIVES:
+      HEADLINE: Archiv
+    SYNDICATE:
+      HEADLINE: Abonnements
+  FORM_DATA:
+    SUMMARY: "Folgendes haben Sie uns mitgeteilt:"
+  ERROR: Fehler
+
+es:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Continuar leyendo...
+      NEXT_POST: Siguiente Entrada
+      PREV_POST: Entrada Anterior
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Buscar
+    RELATED_POSTS:
+      HEADLINE: Entradas Relacionadas
+    RANDOM_ARTICLE:
+      HEADLINE: Artículo Aleatorio
+      FEELING_LUCKY: Voy a tener suerte!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Algunos Widget de Texto
+    POPULAR_TAGS:
+      HEADLINE: Etiquetas Populares
+    ARCHIVES:
+      HEADLINE: Archivos
+    SYNDICATE:
+      HEADLINE: Distribuir
+  FORM_DATA:
+    SUMMARY: "Este es un resumen de lo escrito:"
+  ERROR: Error
+
+fr:
+  TRANSLATION_TEST: Antimatter !
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Continuer la lecture...
+      NEXT_POST: Article suivant
+      PREV_POST: Article précédent
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Recherche simple
+    RELATED_POSTS:
+      HEADLINE: Articles en relation
+    RANDOM_ARTICLE:
+      HEADLINE: Article aléatoire
+      FEELING_LUCKY: J’ai de la chance !
+    SOME_TEXT_WIDGET:
+      HEADLINE: Du texte gadget
+    POPULAR_TAGS:
+      HEADLINE: Tags populaires
+    ARCHIVES:
+      HEADLINE: Archives
+    SYNDICATE:
+      HEADLINE: Syndication
+  FORM_DATA:
+    SUMMARY: "Voici le résumé de ce que vous avez écrit pour nous :"
+  ERROR: Erreur
+
+it:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Continua a leggere...
+      NEXT_POST: Prossimo articolo
+      PREV_POST: Articolo precedente
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: SimpleSearch
+    RELATED_POSTS:
+      HEADLINE: Articoli correlati
+    RANDOM_ARTICLE:
+      HEADLINE: Articolo a caso
+      FEELING_LUCKY: Mi sento fortunato!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Widget di testo
+    POPULAR_TAGS:
+      HEADLINE: Tag popolari
+    ARCHIVES:
+      HEADLINE: Archivio
+    SYNDICATE:
+      HEADLINE: Feed
+  FORM_DATA:
+    SUMMARY: "Questo è il riassunto di quanto ci hai scritto:"
+  ERROR: Errore
+
+ro:
+  BLOG:
+   ITEM:
+      CONTINUE_READING: Mai multe...
+      NEXT_POST: Următorul articol
+      PREV_POST: Articolul anterior
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Căutare
+    RELATED_POSTS:
+      HEADLINE: Articole corelate
+    RANDOM_ARTICLE:
+     HEADLINE: Articol aleator
+    FEELING_LUCKY: Mă simt norocos
+    SOME_TEXT_WIDGET:
+      HEADLINE: Text modular
+    POPULAR_TAGS:
+      HEADLINE: Etichete populare
+    ARCHIVES:
+      HEADLINE: Arhive
+    SYNDICATE:
+      HEADLINE: Abonați-vă
+  FORM_DATA:
+    SUMMARY: "Acesta este rezumatul mesajului Dvs:"
+  ERROR: Eroare
+
+ru:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Читать далее...
+      NEXT_POST: Следующая запись
+      PREV_POST: Предыдущая запись
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Поиск
+    RELATED_POSTS:
+      HEADLINE: Также читайте
+    RANDOM_ARTICLE:
+      HEADLINE: Случайная запись
+      FEELING_LUCKY: Мне повезёт!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Текстовой виджет
+    POPULAR_TAGS:
+      HEADLINE: Популярные теги
+    ARCHIVES:
+      HEADLINE: Архив
+    SYNDICATE:
+      HEADLINE: Синдикация
+  FORM_DATA:
+    SUMMARY: "Вы написали нам:"
+  ERROR: Ошибка
+
+uk:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Читати далі...
+      NEXT_POST: Наступний запис
+      PREV_POST: Попередній запис
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Пошук
+    RELATED_POSTS:
+      HEADLINE: Також читайте
+    RANDOM_ARTICLE:
+      HEADLINE: Випадковий запис
+      FEELING_LUCKY: Мені пощастить!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Текстовий віджет
+    POPULAR_TAGS:
+      HEADLINE: Популярні теги
+    ARCHIVES:
+      HEADLINE: Архів
+    SYNDICATE:
+      HEADLINE: Синдикація
+  FORM_DATA:
+    SUMMARY: "Ви написали нам:"
+  ERROR: Помилка
+
+hr:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Nastavi s čitanjem...
+      NEXT_POST: Slijedeća objava
+      PREV_POST: Prethodna objava
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Pretraživanje
+    RELATED_POSTS:
+      HEADLINE: Povezane objave
+    RANDOM_ARTICLE:
+      HEADLINE: Slučajni članak
+      FEELING_LUCKY: Osjećam se sretno!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Neki tekst widget
+    POPULAR_TAGS:
+      HEADLINE: Popularni tagovi
+    ARCHIVES:
+      HEADLINE: Arhiva
+    SYNDICATE:
+      HEADLINE: Kanali
+  FORM_DATA:
+    SUMMARY: "Ovo je sažetak onog što ste nam napisali:"
+  ERROR: Greška
+
+pl:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Czytaj dalej...
+      NEXT_POST: Następny wpis
+      PREV_POST: Poprzedni wpis
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Szukaj
+    RELATED_POSTS:
+      HEADLINE: Powiązane wpisy
+    RANDOM_ARTICLE:
+      HEADLINE: Losowy wpis
+      FEELING_LUCKY: Szczęśliwy traf!
+    SOME_TEXT_WIDGET:
+      HEADLINE: Jakiś tekst - widget
+    POPULAR_TAGS:
+      HEADLINE: Popularne tagi
+    ARCHIVES:
+      HEADLINE: Archiwum
+    SYNDICATE:
+      HEADLINE: Feed
+  FORM_DATA:
+    SUMMARY: "Podsumowanie tego co do nas napisałeś:"
+  ERROR: Błąd
+
+pt-BR:
+  TRANSLATION_TEST: Antimatter!
+  BLOG:
+    ITEM:
+      CONTINUE_READING: Continue lendo...
+      NEXT_POST: Próximo Post
+      PREV_POST: Post anterior
+  SIDEBAR:
+    SIMPLE_SEARCH:
+      HEADLINE: Buscar 
+    RELATED_POSTS:
+      HEADLINE: Posts relacionados
+    RANDOM_ARTICLE:
+      HEADLINE: Artigo aleatório
+      FEELING_LUCKY: Me sinto sortudo.
+    SOME_TEXT_WIDGET:
+      HEADLINE: Algum widget de texto
+    POPULAR_TAGS:
+      HEADLINE: Tags Populares
+    ARCHIVES:
+      HEADLINE: Arquivos
+    SYNDICATE:
+      HEADLINE: Feed
+  FORM_DATA:
+    SUMMARY: "Aqui está o resumo do que você nos escreveu:"
+  ERROR: Erro

+ 1 - 1
user/themes/antimatter/scss/configuration/template/_colors.scss

@@ -52,7 +52,7 @@ $rule-color: 					#F0F2F4;
 $code-text:						#c7254e;
 $code-bg:						#f9f2f4;
 $pre-text:						#237794;
-$pre-bg:						#f6f6f6;
+$pre-bg:						#f0f0f0;
 
 // Dark Contrast variation
 $dark-navbar-text:				#999;

+ 92 - 43
user/themes/antimatter/scss/template/_header.scss

@@ -1,4 +1,5 @@
 // Header styling
+$dropdown-color: #f6f6f6;
 
 #header {
 	@extend .default-animation;
@@ -32,6 +33,10 @@
 		#navbar a:before, #navbar a:after {
 			background-color: $core-accent !important;
 		}
+
+		.navigation {
+			margin-top: 0.5rem !important;
+		}
 	}
 
 	// set heights for vertical centering
@@ -56,6 +61,44 @@
 
 	#navbar {
 		font-size: $core-font-size - 0.1rem;
+
+		/* Child Indicator */
+		.has-children {
+
+			& > a {
+				& > span {
+					display: inline-block;
+					padding-right: 8px;
+
+					&:after {
+						font-family: FontAwesome;
+						content: '\f107';
+						position: absolute;
+						display: inline-block;
+						right: 8px;
+						top: 4px;
+					}
+				}
+				&:after, &:before {
+					display: none;
+				}
+			}
+
+			& .has-children > a > span:after {
+				content: '\f105';
+			}
+		}
+
+		.navigation > .has-children:hover > a {
+			background: $dropdown-color;
+			border: 1px solid darken($dropdown-color, 4%);
+			border-bottom-color: $dropdown-color;
+			margin: -1px -1px 0 -1px;
+			z-index: 1000;
+			position: relative;
+			padding-bottom: 1px;
+		}
+
 		ul {
 
 			margin: 0;
@@ -63,10 +106,12 @@
 			list-style: none;
 
 			&.navigation {
-				@extend %vertical-align;
 				display: inline-block;
-				
 				float: right;
+				@extend .default-animation;
+
+				margin-top: 1.4rem;
+
 				li {
 					float: left;
 					position: relative;
@@ -76,6 +121,9 @@
 						display: inline-block;
 						padding: 0.3rem 0.8rem;
 
+						-webkit-backface-visibility: hidden;
+
+
 						&:before, &:after {
 							content: "";
 							position: absolute;
@@ -87,6 +135,7 @@
 							visibility: hidden;
 							@include transform(scaleX(0));
 							@include transition(all 0.2s ease);
+
 						}
 
 						&:hover:before {
@@ -109,49 +158,55 @@
 						}
 					}
 
-					// Dropdown Menu Styles
+					/*Active dropdown nav item */
 					ul {
+						position: absolute;
+						background-color: $dropdown-color;
+						border: 1px solid darken($dropdown-color, 4%);
+						border-top: 0;
+						min-width: 12rem;
+						text-align: left;
+						z-index: 999;
+						left: -1px;
 						display: none;
-						padding: 0;
-						box-shadow: 0 0.05rem 1rem rgba(0,0,0, 0.15) !important;
 					}
 
-					ul ul {
-						left: 100%;
-						top: 0;
-					}	
-
+					ul li {
+						display: block;
+						float: none;
 
-					&:hover {
-						& > ul {
-							display: block;
-							position: absolute;
-							background: rgba($white, 0.9);
-							width: 10rem;
+						/* Active Dropdown nav item */
+						&.active > a {
+							background-color: darken($dropdown-color, 4%);
+							color: $core-accent;
 						}
 
-						li {
-							float: none;
-							margin: 0;
-							padding: 0;
-
-							a {
-								padding: 0.5rem 0.8rem;
-								display: block;
+						&:hover > a {
+							background-color: $core-accent;
+							color: $white;
+						}
 
-								&:before, &:after {
-									display: none;
-								}
-							}
+						a {
+							display: block;
+							margin: 0 -1px;
 
-							&.active {
-								& > a {
-									background: $core-accent;
-									color: $white;
-								}
+							&:after, &:before {
+								display: none;
 							}
 						}
 					}
+
+					/* Dropdown CSS */
+					ul ul {
+						left: 100%;
+						top: 0px;
+					}
+
+					/* Active on Hover */
+					&:hover > ul {
+						display: block;
+					}
+
 				}
 				@include breakpoint(desktop-only) {
 					display: none;
@@ -207,6 +262,10 @@
         background-color: rgba($header-text,0);
         box-shadow: none;
 
+		.navigation .has-children:hover a {
+			color: $core-accent;
+		}
+
         #logo h3, #logo a {
             color: $header-text;
         }
@@ -217,15 +276,5 @@
         	background-color: rgba($header-text,0.7) !important;
         }
 
-        #navbar ul.navigation  {
-
-            ul li a {
-
-                color: $core-accent;
-                &:hover {
-                    color: darken($core-accent, 20%);
-                }
-            }
-        }
     }
 }

+ 11 - 0
user/themes/antimatter/scss/template/_simplesearch.scss

@@ -32,6 +32,17 @@
         }
     }
 
+    .search-wrapper {
+        .search-submit {
+            height: 52px;
+            padding: 0 10px;
+            img {
+                width: 30px;
+            }
+        }
+    }
+
+
     .search-details {
         float: right;
         margin-top: -2.5rem;

+ 2 - 2
user/themes/antimatter/templates/blog.html.twig

@@ -10,7 +10,7 @@
 		{% else %}
 		<div class="blog-header">
 		{% endif %}
-			{{ page.content }}
+			{{ page.content|raw }}
 		</div>
 
 			{% if config.plugins.breadcrumbs.enabled %}
@@ -18,7 +18,7 @@
 			{% endif %}
 
 		<div class="content-wrapper blog-content-list grid pure-g">
-			<div id="listing" class="block pure-u-2-3">
+			<div id="listing" class="block pure-u-2-3 h-feed">
 				{% for child in collection %}
 			        {% include 'partials/blog_item.html.twig' with {'blog':page, 'page':child, 'truncate':true} %}
 			    {% endfor %}

+ 1 - 1
user/themes/antimatter/templates/default.html.twig

@@ -1,5 +1,5 @@
 {% extends 'partials/base.html.twig' %}
 
 {% block content %}
-	{{ page.content }}
+	{{ page.content|raw }}
 {% endblock %}

+ 2 - 2
user/themes/antimatter/templates/error.html.twig

@@ -3,9 +3,9 @@
 {% block content %}
 	<div id="error">
 		<div>
-			<h1>Error {{ page.header.http_response_code }}</h1>
+			<h1>{{ 'ERROR'|t }} {{ page.header.http_response_code }}</h1>
 			<p>
-				{{ page.content }}
+				{{ page.content|raw }}
 			</p>
 		</div>
 	</div>

+ 1 - 1
user/themes/antimatter/templates/form.html.twig

@@ -2,7 +2,7 @@
 
 {% block content %}
 
-    {{ content }}
+    {{ content|raw }}
     {% include "forms/form.html.twig" %}
 
 {% endblock %}

+ 1 - 1
user/themes/antimatter/templates/item.html.twig

@@ -6,7 +6,7 @@
 		{% endif %}
 		
 		<div class="blog-content-item grid pure-g-r">
-			<div id="item" class="block pure-u-2-3">
+			<div id="item" class="block pure-u-2-3 h-entry">
 			    {% include 'partials/blog_item.html.twig' with {'blog':page.parent, 'truncate':false} %}
 			</div>
 			<div id="sidebar" class="block size-1-3 pure-u-1-3">

+ 3 - 3
user/themes/antimatter/templates/modular.html.twig

@@ -35,7 +35,7 @@
         {% endfor %}
         {% for mitem in site.menu %}
             <li>
-                <a href="{{ mitem.url }}">
+                <a {% if mitem.class %}class="{{ mitem.class }}"{% endif %} href="{{ mitem.url }}">
                     {% if mitem.icon %}<i class="fa fa-{{ mitem.icon }}"></i>{% endif %}
                     {{ mitem.text }}
                 </a>
@@ -48,9 +48,9 @@
 {% endblock %}
 
 {% block content %}
-    {{ page.content }}
+    {{ page.content|raw }}
     {% for module in page.collection() %}
         <div id="{{ _self.pageLinkName(module.menu) }}"></div>
-        {{ module.content }}
+        {{ module.content|raw }}
     {% endfor %}
 {% endblock %}

+ 1 - 1
user/themes/antimatter/templates/modular/features.html.twig

@@ -1,5 +1,5 @@
 <div class="modular-row features {{ page.header.class}}">
-    {{ content }}
+    {{ content|raw }}
     <div class="feature-items">
     {% for feature in page.header.features %}
            <div class="feature">

+ 2 - 2
user/themes/antimatter/templates/modular/showcase.html.twig

@@ -1,10 +1,10 @@
 {% set showcase_image = page.media.images|first.grayscale().contrast(20).brightness(-125).colorize(-35,81,122) %}
 {% if showcase_image %}
-	<div class="modular-row showcase flush-top" style="background-image: url({{ showcase_image.url }});">
+	<div class="modular-row showcase{% if page == page.parent.collection.first %} flush-top{% endif %}" style="background-image: url({{ showcase_image.url }});">
 {% else %}
 <div class="modular-row showcase">
 {% endif %}
-    {{ content }}
+    {{ content|raw }}
 
     {% for button in page.header.buttons %}
         <a class="button{% if button.primary %} primary{% endif %}" href="{{ button.url }}">{{ button.text }}</a>

+ 2 - 2
user/themes/antimatter/templates/modular/text.html.twig

@@ -1,7 +1,7 @@
 <div class="modular-row callout">
     {% set image = page.media.images|first %}
     {% if image %}
-        {{ image.cropResize(400,400).html('','','align-'~page.header.image_align) }}
+        {{ image.cropResize(400,400).html('','','align-'~page.header.image_align)|raw }}
     {% endif %}
-{{ content }}
+{{ content|raw }}
 </div>

+ 31 - 27
user/themes/antimatter/templates/partials/base.html.twig

@@ -1,22 +1,21 @@
+{% set theme_config = attribute(config.themes, config.system.pages.theme) %}
 <!DOCTYPE html>
-<html lang="en">
+<html lang="{{ grav.language.getActive ?: grav.config.site.default_lang }}">
 <head>
-{% set theme_config = attribute(config.themes, config.system.pages.theme) %}
-
 {% block head %}
     <meta charset="utf-8" />
     <title>{% if header.title %}{{ header.title|e('html') }} | {% endif %}{{ site.title|e('html') }}</title>
     {% include 'partials/metadata.html.twig' %}
     <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
-    <link rel="icon" type="image/png" href="{{ url('theme://images/favicon.png', true) }}" />
+    <link rel="icon" type="image/png" href="{{ url('theme://images/favicon.png') }}" />
     <link rel="canonical" href="{{ page.url(true, true) }}" />
 
     {% block stylesheets %}
         {% do assets.addCss('theme://css/pure-0.5.0/grids-min.css', 103) %}
-        {% do assets.addCss('theme://css-compiled/nucleus.css',102) %}
-        {% do assets.addCss('theme://css-compiled/template.css',101) %}
-        {% do assets.addCss('theme://css/custom.css',100) %}
-        {% do assets.addCss('theme://css/font-awesome.min.css',100) %}
+        {% do assets.addCss('theme://css-compiled/nucleus.css', 102) %}
+        {% do assets.addCss('theme://css-compiled/template.css', 101) %}
+        {% do assets.addCss('theme://css/custom.css', 100) %}
+        {% do assets.addCss('theme://css/font-awesome.min.css', 100) %}
         {% do assets.addCss('theme://css/slidebars.min.css') %}
 
         {% if browser.getBrowser == 'msie' and browser.getVersion == 10 %}
@@ -27,17 +26,20 @@
             {% do assets.addJs('theme://js/html5shiv-printshiv.min.js') %}
         {% endif %}
     {% endblock %}
-    {{ assets.css() }}
 
     {% block javascripts %}
-        {% do assets.addJs('jquery',101) %}
-        {% do assets.addJs('theme://js/modernizr.custom.71422.js',100) %}
+        {% do assets.addJs('jquery', 101) %}
+        {% do assets.addJs('theme://js/modernizr.custom.71422.js', 100) %}
         {% do assets.addJs('theme://js/antimatter.js') %}
         {% do assets.addJs('theme://js/slidebars.min.js') %}
     {% endblock %}
-    {{ assets.js() }}
 
-{% endblock head%}
+    {% block assets deferred %}
+        {{ assets.css()|raw }}
+        {{ assets.js()|raw }}
+    {% endblock %}
+
+{% endblock head %}
 </head>
 <body id="top" class="{{ page.header.body_classes }}">
     <div id="sb-site">
@@ -76,23 +78,25 @@
         </footer>
         {% endblock %}
     </div>
-    <div class="sb-slidebar sb-left sb-width-thin">
-        <div id="panel">
-        {% include 'partials/navigation.html.twig' %}
+    {% block sidebar_navigation %}
+        <div class="sb-slidebar sb-left sb-width-thin">
+            <div id="panel">
+            {% include 'partials/navigation.html.twig' %}
+            </div>
         </div>
-    </div>
+    {% endblock %}
     {% block bottom %}
-
-    <script>
-    $(function () {
-        $(document).ready(function() {
-          $.slidebars({
-            hideControlClasses: true,
-            scrollLock: true
-          });
+         <script>
+        $(function () {
+            $(document).ready(function() {
+              $.slidebars({
+                hideControlClasses: true,
+                scrollLock: true
+              });
+            });
         });
-    });
-    </script>
+        </script>
+        {{ assets.js('bottom')|raw }}
     {% endblock %}
 </body>
 </html>

+ 34 - 23
user/themes/antimatter/templates/partials/blog_item.html.twig

@@ -1,4 +1,4 @@
-<div class="list-item">
+<div class="list-item h-entry">
 
     {% set header_image = page.header.header_image|defined(true) %}
     {% set header_image_width  = page.header.header_image_width|defined(900) %}
@@ -7,24 +7,26 @@
 
     <div class="list-blog-header">
         <span class="list-blog-date">
-            <span>{{ page.date|date("d") }}</span>
-            <em>{{ page.date|date("M") }}</em>
+            <time class="dt-published" datetime="{{ page.date|date("c") }}">
+                <span>{{ page.date|date("d") }}</span>
+                <em>{{ page.date|date("M") }}</em>
+            </time>
         </span>
         {% if page.header.link %}
-            <h4>
-                {% if page.header.continue_link is not sameas(false) %}
-                <a href="{{ page.url }}"><i class="fa fa-angle-double-right"></i></a>
+            <h4 class="p-name">
+                {% if page.header.continue_link is not same as(false) %}
+                <a href="{{ page.url }}"><i class="fa fa-angle-double-right u-url"></i></a>
                 {% endif %}
-                <a href="{{ page.header.link }}">{{ page.title }}</a>
+                <a href="{{ page.header.link }}" class="u-url">{{ page.title }}</a>
             </h4>
         {% else %}
-            <h4><a href="{{ page.url }}">{{ page.title }}</a></h4>
+            <h4 class="p-name"><a href="{{ page.url }}" class="u-url">{{ page.title }}</a></h4>
         {% endif %}
 
         {% if page.taxonomy.tag %}
         <span class="tags">
             {% for tag in page.taxonomy.tag %}
-            <a href="{{ blog.url }}/tag{{ config.system.param_sep }}{{ tag }}">{{ tag }}</a>
+            <a href="{{ blog.url|rtrim('/') }}/tag{{ config.system.param_sep }}{{ tag }}" class="p-category">{{ tag }}</a>
             {% endfor %}
         </span>
         {% endif %}
@@ -34,30 +36,39 @@
             {% else %}
                 {% set header_image_media = page.media.images|first %}
             {% endif %}
-            {{ header_image_media.cropZoom(header_image_width, header_image_height).html }}
+            {{ header_image_media.cropZoom(header_image_width, header_image_height).html|raw }}
         {% endif %}
 
     </div>
 
     <div class="list-blog-padding">
 
-    {% if page.header.continue_link is sameas(false) %}
-        {{ page.content }}
+    {% if page.header.continue_link is same as(false) %}
+        <div class="e-content">
+            {{ page.content|raw }}
+        </div>
         {% if not truncate %}
         {% set show_prev_next = true %}
         {% endif %}
     {% elseif truncate and page.summary != page.content %}
-        {{ page.summary }}
-        <p><a href="{{ page.url }}">Continue Reading...</a></p>
+        <div class="p-summary e-content">
+            {{ page.summary|raw }}
+            <p><a href="{{ page.url }}">{{ 'BLOG.ITEM.CONTINUE_READING'|t }}</a></p>
+        </div>
     {% elseif truncate %}
-        {% if page.summary != page.content %}
-            {{ page.content|truncate(550) }}
-        {% else %}
-            {{ page.content }}
-        {% endif %}
-        <p><a href="{{ page.url }}">Continue Reading...</a></p>
+        <div class="p-summary e-content">
+            {{ page.content|raw }}
+            <p><a href="{{ page.url }}">{{ 'BLOG.ITEM.CONTINUE_READING'|t }}</a></p>
+        </div>
     {% else %}
-        {{ page.content }}
+        <div class="e-content">
+            {{ page.content|raw }}
+        </div>
+
+        {% if config.plugins.comments.enabled %}
+            {% include 'partials/comments.html.twig' %}
+        {% endif %}
+
         {% set show_prev_next = true %}
     {% endif %}
 
@@ -65,11 +76,11 @@
 
         <p class="prev-next">
             {% if not page.isFirst %}
-                <a class="button" href="{{ page.nextSibling.url }}"><i class="fa fa-chevron-left"></i> Next Post</a>
+                <a class="button" href="{{ page.nextSibling.url }}"><i class="fa fa-chevron-left"></i> {{ 'BLOG.ITEM.NEXT_POST'|t }}</a>
             {% endif %}
 
             {% if not page.isLast %}
-                <a class="button" href="{{ page.prevSibling.url }}">Previous Post <i class="fa fa-chevron-right"></i></a>
+                <a class="button" href="{{ page.prevSibling.url }}">{{ 'BLOG.ITEM.PREV_POST'|t }} <i class="fa fa-chevron-right"></i></a>
             {% endif %}
         </p>
     {% endif %}

+ 21 - 10
user/themes/antimatter/templates/partials/navigation.html.twig

@@ -1,23 +1,34 @@
+{% import _self as macros %}
+
 {% macro loop(page) %}
+    {% import _self as macros %}
     {% for p in page.children.visible %}
         {% set current_page = (p.active or p.activeChild) ? 'active' : '' %}
-        <li class="{{ current_page }}">
-            <a href="{{ p.url }}">
-                {% if p.header.icon %}<i class="fa fa-{{ p.header.icon }}"></i>{% endif %}
-                {{ p.menu }}
-            </a>
-            {% if p.children.visible.count > 0 %}
+        {% if p.children.visible.count > 0 %}
+            <li class="has-children {{ current_page }}">
+                <a href="{{ p.url }}">
+                    {% if p.header.icon %}<i class="fa fa-{{ p.header.icon }}"></i>{% endif %}
+                    {{ p.menu }}
+                    <span></span>
+                </a>
                 <ul>
-                    {{ _self.loop(p) }}
+                    {{ macros.loop(p) }}
                 </ul>
-            {% endif %}
-        </li>
+            </li>
+        {% else %}
+            <li class="{{ current_page }}">
+                <a href="{{ p.url }}">
+                    {% if p.header.icon %}<i class="fa fa-{{ p.header.icon }}"></i>{% endif %}
+                    {{ p.menu }}
+                </a>
+            </li>
+        {% endif %}
     {% endfor %}
 {% endmacro %}
 
 <ul class="navigation">
     {% if theme_config.dropdown.enabled %}
-        {{ _self.loop(pages) }}
+        {{ macros.loop(pages) }}
     {% else %}
         {% for page in pages.children.visible %}
             {% set current_page = (page.active or page.activeChild) ? 'active' : '' %}

+ 8 - 8
user/themes/antimatter/templates/partials/sidebar.html.twig

@@ -3,39 +3,39 @@
 
 {% if config.plugins.simplesearch.enabled %}
 <div class="sidebar-content">
-    <h4>SimpleSearch</h4>
+    <h4>{{ 'SIDEBAR.SIMPLE_SEARCH.HEADLINE'|t }}</h4>
     {% include 'partials/simplesearch_searchbox.html.twig' %}
 </div>
 {% endif %}
 {% if config.plugins.relatedpages.enabled and related_pages|length > 0 %}
-    <h4>Related Posts</h4>
+    <h4>{{ 'SIDEBAR.RELATED_POSTS.HEADLINE'|t }}</h4>
     {% include 'partials/relatedpages.html.twig' %}
 {% endif %}
 {% if config.plugins.random.enabled %}
 <div class="sidebar-content">
-	<h4>Random Article</h4>
-	<a class="button" href="{{ base_url }}/random"><i class="fa fa-retweet"></i> I'm Feeling Lucky!</a>
+	<h4>{{ 'SIDEBAR.RANDOM_ARTICLE.HEADLINE'|t }}</h4>
+	<a class="button" href="{{ base_url }}/random"><i class="fa fa-retweet"></i> {{ 'SIDEBAR.RANDOM_ARTICLE.FEELING_LUCKY'|t }}</a>
 </div>
 {% endif %}
 <div class="sidebar-content">
-	<h4>Some Text Widget</h4>
+	<h4>{{ 'SIDEBAR.SOME_TEXT_WIDGET.HEADLINE'|t }}</h4>
 	<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna.</p>
 </div>
 {% if config.plugins.taxonomylist.enabled %}
 <div class="sidebar-content">
-    <h4>Popular Tags</h4>
+    <h4>{{ 'SIDEBAR.POPULAR_TAGS.HEADLINE'|t }}</h4>
     {% include 'partials/taxonomylist.html.twig' with {'base_url':new_base_url, 'taxonomy':'tag'} %}
 </div>
 {% endif %}
 {% if config.plugins.archives.enabled %}
 <div class="sidebar-content">
-    <h4>Archives</h4>
+    <h4>{{ 'SIDEBAR.ARCHIVES.HEADLINE'|t }}</h4>
 	{% include 'partials/archives.html.twig' with {'base_url':new_base_url} %}
 </div>
 {% endif %}
 {% if config.plugins.feed.enabled %}
 <div class="sidebar-content syndicate">
-    <h4>Syndicate</h4>
+    <h4>{{ 'SIDEBAR.SYNDICATE.HEADLINE'|t }}</h4>
     <a class="button" href="{{ feed_url }}.atom"><i class="fa fa-rss-square"></i> Atom 1.0</a>
     <a class="button" href="{{ feed_url }}.rss"><i class="fa fa-rss-square"></i> RSS</a>
 </div>

+ 15 - 10
user/themes/epau-antimatter/css-compiled/template.css

@@ -990,6 +990,9 @@ ul.pagination {
   @media (max-width: 1024px) {
     .texte-cache {
       max-height: 445px; } }
+  @media (max-width: 1024px) and (max-width: 442px) {
+    .texte-cache {
+      max-height: 400px; } }
 
 /*Style du module TEXTE lorsqu'il est ouvert*/
 .texte-cache.ouvert {
@@ -1008,9 +1011,15 @@ ul.pagination {
 @media (max-width: 1024px) {
   .modular .features {
     width: 80% !important;
-    margin-left: 8rem !important; }
+    margin: auto !important; } }
+
+@media (max-width: 442px) {
   .modular .features .feature {
-    width: 24% !important; } }
+    width: 80%; } }
+
+@media (max-width: 1024px) {
+  .modular .features .feature {
+    width: 24%; } }
 
 .bouton-ouverture {
   position: relative;
@@ -1120,10 +1129,10 @@ ul.pagination {
                 padding-left: 0.8rem;
                 font-size: 0.75rem;
                 padding-right: 0.6rem; } }
-        .mozaique_personnes .equipe .personne .mozaique:hover {
-          background-color: #000;
-          color: #fff;
-          transition: 0.2s ease-in-out; }
+          .mozaique_personnes .equipe .personne .mozaique .info:hover {
+            background-color: #000;
+            color: #fff;
+            transition: 0.2s ease-in-out; }
 
 .bouton {
   border: solid !important;
@@ -1450,7 +1459,3 @@ button:focus {
 
 .modular .modular-row:last-child {
   margin-bottom: 2rem; }
-
-@media (max-width: 442px) {
-  h1 {
-    font-size: 1.5rem; } }

+ 23 - 16
user/themes/epau-antimatter/scss/template/_custom.scss

@@ -359,6 +359,9 @@
   transition: max-height 1s ease; /* Transition CSS entre l'ouverture et la fermeture*/
   @media (max-width: 1024px) {
     max-height: 445px;
+  @media (max-width:442px) {
+    max-height: 400px;
+  }
 
   }
 }
@@ -381,19 +384,21 @@
 
 }
 
+.modular .features {
+  @media (max-width: 1024px) {
+      width: 80% !important;
+      margin: auto !important;
+  }
+}
+
+.modular .features .feature {
+    // margin-bottom: 10rem;
+    @media (max-width: 442px) {width: 80% ;}
+    @media (max-width: 1024px) {width: 24% ;}
+}
 
 
-@media (max-width: 1024px) {
-  .modular .features {
 
-    width: 80% !important;
-    margin-left: 8rem !important;
-  }
-  .modular .features .feature {
-      width: 24% !important;
-      // margin-bottom: 10rem;
-  }
-}
 
 .bouton-ouverture {
     position: relative;
@@ -543,14 +548,16 @@
               font-size: 0.75rem;
               padding-right: 0.6rem;
            }
-         }
+          }
+          &:hover {
+              background-color: #000;
+              color: #fff;
+              transition:0.2s ease-in-out;
+          }
+
         }
 
-         &:hover {
-             background-color: #000;
-             color: #fff;
-             transition:0.2s ease-in-out;
-         }
+
         }
       }
     }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است