Browse Source

modif order

kevin tessier 4 years ago
parent
commit
c5b29bfdbe
100 changed files with 2214 additions and 940 deletions
  1. 8 0
      .github/FUNDING.yml
  2. 125 9
      CHANGELOG.md
  3. 3 2
      CONTRIBUTING.md
  4. 12 13
      bin/plugin
  5. 8 6
      composer.json
  6. 230 170
      composer.lock
  7. BIN
      default_site_backup--20190930153122.zip
  8. 4 0
      now.json
  9. 1 1
      system/blueprints/config/site.yaml
  10. 2 0
      system/blueprints/user/account_new.yaml
  11. 10 0
      system/blueprints/user/accounts.yaml
  12. 1 1
      system/defines.php
  13. 1 1
      system/languages/es.yaml
  14. 23 0
      system/languages/et.yaml
  15. 2 0
      system/languages/fr.yaml
  16. 11 0
      system/languages/is.yaml
  17. 9 0
      system/languages/pt.yaml
  18. 84 0
      system/languages/sk.yaml
  19. 11 0
      system/languages/sv.yaml
  20. 24 4
      system/languages/zh-tw.yaml
  21. 113 32
      system/languages/zh.yaml
  22. 14 4
      system/router.php
  23. 14 7
      system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php
  24. 5 3
      system/src/Grav/Common/Cache.php
  25. 6 8
      system/src/Grav/Common/Data/Blueprint.php
  26. 12 2
      system/src/Grav/Common/Data/Blueprints.php
  27. 12 1
      system/src/Grav/Common/Data/Validation.php
  28. 10 76
      system/src/Grav/Common/Form/FormFlash.php
  29. 8 5
      system/src/Grav/Common/GPM/Common/CachedCollection.php
  30. 2 2
      system/src/Grav/Common/GPM/Common/Package.php
  31. 13 12
      system/src/Grav/Common/GPM/GPM.php
  32. 1 1
      system/src/Grav/Common/GPM/Licenses.php
  33. 1 0
      system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php
  34. 1 0
      system/src/Grav/Common/GPM/Local/Plugins.php
  35. 1 1
      system/src/Grav/Common/GPM/Remote/AbstractPackageCollection.php
  36. 3 3
      system/src/Grav/Common/GPM/Remote/GravCore.php
  37. 17 240
      system/src/Grav/Common/Helpers/Excerpts.php
  38. 1 1
      system/src/Grav/Common/Helpers/Truncator.php
  39. 14 1
      system/src/Grav/Common/Helpers/YamlLinter.php
  40. 6 1
      system/src/Grav/Common/Language/Language.php
  41. 13 3
      system/src/Grav/Common/Markdown/Parsedown.php
  42. 13 3
      system/src/Grav/Common/Markdown/ParsedownExtra.php
  43. 47 28
      system/src/Grav/Common/Markdown/ParsedownGravTrait.php
  44. 329 0
      system/src/Grav/Common/Page/Markdown/Excerpts.php
  45. 3 0
      system/src/Grav/Common/Page/Medium/AbstractMedia.php
  46. 3 3
      system/src/Grav/Common/Page/Medium/ImageMedium.php
  47. 2 1
      system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php
  48. 22 14
      system/src/Grav/Common/Page/Page.php
  49. 21 3
      system/src/Grav/Common/Page/Pages.php
  50. 19 2
      system/src/Grav/Common/Plugin.php
  51. 34 14
      system/src/Grav/Common/Plugins.php
  52. 4 1
      system/src/Grav/Common/Processors/RequestProcessor.php
  53. 10 5
      system/src/Grav/Common/Service/AccountsServiceProvider.php
  54. 3 3
      system/src/Grav/Common/Service/RequestServiceProvider.php
  55. 7 3
      system/src/Grav/Common/Service/SessionServiceProvider.php
  56. 14 3
      system/src/Grav/Common/Themes.php
  57. 1 1
      system/src/Grav/Common/Twig/Node/TwigNodeMarkdown.php
  58. 11 9
      system/src/Grav/Common/Twig/TwigExtension.php
  59. 3 3
      system/src/Grav/Common/Uri.php
  60. 9 0
      system/src/Grav/Common/User/DataUser/UserCollection.php
  61. 50 0
      system/src/Grav/Common/User/FlexUser/User.php
  62. 1 1
      system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php
  63. 6 5
      system/src/Grav/Common/User/Traits/UserTrait.php
  64. 154 47
      system/src/Grav/Common/Utils.php
  65. 3 0
      system/src/Grav/Console/Cli/ClearCacheCommand.php
  66. 9 0
      system/src/Grav/Console/Cli/YamlLinterCommand.php
  67. 1 1
      system/src/Grav/Console/Gpm/InfoCommand.php
  68. 1 1
      system/src/Grav/Console/Gpm/InstallCommand.php
  69. 1 1
      system/src/Grav/Framework/Collection/AbstractIndexCollection.php
  70. 1 1
      system/src/Grav/Framework/Collection/ArrayCollection.php
  71. 21 2
      system/src/Grav/Framework/File/Formatter/CsvFormatter.php
  72. 2 2
      system/src/Grav/Framework/File/Formatter/YamlFormatter.php
  73. 1 1
      system/src/Grav/Framework/Flex/Flex.php
  74. 8 2
      system/src/Grav/Framework/Flex/FlexCollection.php
  75. 16 10
      system/src/Grav/Framework/Flex/FlexDirectory.php
  76. 4 3
      system/src/Grav/Framework/Flex/FlexForm.php
  77. 3 2
      system/src/Grav/Framework/Flex/FlexIndex.php
  78. 32 10
      system/src/Grav/Framework/Flex/FlexObject.php
  79. 1 1
      system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php
  80. 2 0
      system/src/Grav/Framework/Flex/Storage/FolderStorage.php
  81. 9 1
      system/src/Grav/Framework/Flex/Storage/SimpleStorage.php
  82. 9 3
      system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php
  83. 14 5
      system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php
  84. 183 96
      system/src/Grav/Framework/Form/FormFlash.php
  85. 165 0
      system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php
  86. 85 22
      system/src/Grav/Framework/Form/Traits/FormTrait.php
  87. 1 1
      system/src/Grav/Framework/Interfaces/RenderInterface.php
  88. 38 14
      system/src/Grav/Framework/Route/Route.php
  89. 2 2
      system/src/Grav/Framework/Uri/UriPartsFilter.php
  90. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_Cahiers01.jpg
  91. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_Cahiers10.jpg
  92. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers02.jpg
  93. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers03.jpg
  94. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers04.jpg
  95. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers05.jpg
  96. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers06.jpg
  97. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers07.jpg
  98. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers08.jpg
  99. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers09.jpg
  100. BIN
      user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers11.jpg

+ 8 - 0
.github/FUNDING.yml

@@ -0,0 +1,8 @@
+# These are supported funding model platforms
+
+github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: grav
+ko_fi: # Replace with a single Ko-fi username
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+custom: # Replace with a single custom sponsorship URL

+ 125 - 9
CHANGELOG.md

@@ -1,3 +1,119 @@
+# v1.6.16
+## 09/19/2019
+
+1. [](#bugfix)
+    * Fixed Flex user creation if file storage is being used [#2444](https://github.com/getgrav/grav/issues/2444)
+    * Fixed `Badly encoded JSON data` warning when uploading files [#2663](https://github.com/getgrav/grav/issues/2663)
+
+# v1.6.15
+## 08/20/2019
+
+1. [](#improved)
+    * Improved robots.txt [#2632](https://github.com/getgrav/grav/issues/2632)
+1. [](#bugfix)
+    * Fixed broken markdown Twig tag [#2635](https://github.com/getgrav/grav/issues/2635)
+    * Force Symfony 4.2 in Grav 1.6 to remove a bunch of deprecated messages
+
+# v1.6.14
+## 08/18/2019
+
+1. [](#bugfix)
+    * Actually include fix for `system\router.php` [#2627](https://github.com/getgrav/grav/issues/2627)
+
+# v1.6.13
+## 08/16/2019
+
+1. [](#bugfix)
+    * Regression fix for `system\router.php` [#2627](https://github.com/getgrav/grav/issues/2627)
+
+# v1.6.12
+## 08/14/2019
+
+1. [](#new)
+    * Added support for custom `FormFlash` save locations
+    * Added a new `Utils::arrayLower()` method for lowercasing arrays
+    * Support new GRAV_BASEDIR environment variable [#2541](https://github.com/getgrav/grav/pull/2541)
+    * Allow users to override plugin handler priorities [#2165](https://github.com/getgrav/grav/pull/2165)
+1. [](#improved)
+    * Use new `Utils::getSupportedPageTypes()` to enforce `html,htm` at the front of the list [#2531](https://github.com/getgrav/grav/issues/2531)  
+    * Updated vendor libraries
+    * Markdown filter is now page-aware so that it works with modular references [admin#1731](https://github.com/getgrav/grav-plugin-admin/issues/1731)
+    * Check of `GRAV_USER_INSTANCE` constant is already defined [#2621](https://github.com/getgrav/grav/pull/2621)
+1. [](#bugfix)
+    * Fixed some potential issues when `$grav['user']` is not set
+    * Fixed error when calling `Media::add($name, null)`
+    * Fixed `url()` returning wrong path if using stream with grav root path in it, eg: `user-data://shop` when Grav is in `/shop`
+    * Fixed `url()` not returning a path to non-existing file (`user-data://shop` => `/user/data/shop`) if it is set to fail gracefully
+    * Fixed `url()` returning false on unknown streams, such as `ftp://domain.com`, they should be treated as external URL
+    * Fixed Flex User to have permissions to save and delete his own user
+    * Fixed new Flex User creation not being possible because of username could not be given
+    * Fixed fatal error 'Expiration date must be an integer, a DateInterval or null, "double" given' [#2529](https://github.com/getgrav/grav/issues/2529)
+    * Fixed non-existing Flex object having a bad media folder
+    * Fixed collections using `page@.self:` should allow modular pages if requested
+    * Fixed an error when trying to delete a file from non-existing Flex Object
+    * Fixed `FlexObject::exists()` failing sometimes just after the object has been saved
+    * Fixed CSV formatter not encoding strings with `"` and `,` properly
+    * Fixed var order in `Validation.php` [#2610](https://github.com/getgrav/grav/issues/2610)
+    
+# v1.6.11
+## 06/21/2019
+
+1. [](#new)
+    * Added `FormTrait::getAllFlashes()` method to get all the available form flash objects for the form
+    * Added creation and update timestamps to `FormFlash` objects
+1. [](#improved)
+    * Added `FormFlashInterface`, changed constructor to take `$config` array
+1. [](#bugfix)
+    * Fixed error in `ImageMedium::url()` if the image cache folder does not exist
+    * Fixed empty form flash name after file upload or form state update
+    * Fixed a bug in `Route::withParam()` method
+    * Fixed issue with `FormFlash` objects when there is no session initialized
+
+# v1.6.10
+## 06/14/2019
+
+1. [](#improved)
+    * Added **page blueprints** to `YamlLinter` CLI and Admin reports
+    * Removed `Gitter` and `Slack` [#2502](https://github.com/getgrav/grav/issues/2502)
+    * Optimizations for Plugin/Theme loading
+    * Generalized markdown classes so they can be used outside of `Page` scope with a custom `Excerpts` class instance
+    * Change minimal port number to 0 (unix socket) [#2452](https://github.com/getgrav/grav/issues/2452)
+1. [](#bugfix)
+    * Force question to install demo content in theme update [#2493](https://github.com/getgrav/grav/issues/2493)
+    * Fixed GPM errors from blueprints not being logged [#2505](https://github.com/getgrav/grav/issues/2505)
+    * Don't error when IP is invalid [#2507](https://github.com/getgrav/grav/issues/2507)
+    * Fixed regression with `bin/plugin` not listing the plugins available (1c725c0)
+    * Fixed bitwise operator in `TwigExtension::exifFunc()` [#2518](https://github.com/getgrav/grav/issues/2518)
+    * Fixed issue with lang prefix incorrectly identifying as admin [#2511](https://github.com/getgrav/grav/issues/2511)
+    * Fixed issue with `U0ils::pathPrefixedBYLanguageCode()` and trailing slash [#2510](https://github.com/getgrav/grav/issues/2511) 
+    * Fixed regresssion issue of `Utils::Url()` not returning `false` on failure. Added new optional `fail_gracefully` 3rd attribute to return string that caused failure [#2524](https://github.com/getgrav/grav/issues/2524)
+
+# v1.6.9
+## 05/09/2019
+
+1. [](#new)
+    * Added `Route::withoutParams()` methods
+    * Added `Pages::setCheckMethod()` method to override page configuration in Admin Plugin
+    * Added `Cache::clearCache('invalidate')` parameter for just invalidating the cache without deleting any cached files
+    * Made `UserCollectionInderface` to extend `Countable` to get the count of existing users
+1. [](#improved)
+    * Flex admin: added default search options for flex objects
+    * Flex collection and object now fall back to the default template if template file doesn't exist
+    * Updated Vendor libraries including Twig 1.40.1
+    * Updated language files from `https://crowdin.com/project/grav-core`
+1. [](#bugfix)
+    * Fixed `$grav['route']` from being modified when the route instance gets modified
+    * Fixed Assets options array mixed with standalone priority [#2477](https://github.com/getgrav/grav/issues/2477)
+    * Fix for `avatar_url` provided by 3rd party providers
+    * Fixed non standard `lang` code lengths in `Utils` and `Session` detection
+    * Fixed saving a new object in Flex `SimpleStorage`
+    * Fixed exception in `Flex::getDirectories()` if the first parameter is set
+    * Output correct "Last Updated" in `bin/gpm info` command
+    * Checkbox getting interpreted as string, so created new `Validation::filterCheckbox()`
+    * Fixed backwards compatibility to `select` field with `selectize.create` set to true [git-sync#141](https://github.com/trilbymedia/grav-plugin-git-sync/issues/141)
+    * Fixed `YamlFormatter::decode()` to always return array [#2494](https://github.com/getgrav/grav/pull/2494)
+    * Fixed empty `$grav['request']->getAttribute('route')->getExtension()`
+
 # v1.6.8
 ## 04/23/2019
 
@@ -25,18 +141,18 @@
 ## 04/17/2019
 
 1. [](#new)
-    * `FormInterface` now implements `RenderInterface` 
-    * Added new `FormInterface::getTask()` method which reads the task from `form.task` in the blueprint 
+    * `FormInterface` now implements `RenderInterface`
+    * Added new `FormInterface::getTask()` method which reads the task from `form.task` in the blueprint
 1. [](#improved)
     * Updated vendor libraries to latest
 1. [](#bugfix)
     * Rollback `redirect_default_route` logic as it has issues with multi-lang [#2459](https://github.com/getgrav/grav/issues/2459)
-    * Fix potential issue with `|contains` Twig filter on PHP 7.3 
+    * Fix potential issue with `|contains` Twig filter on PHP 7.3
     * Fixed bug in text field filtering: return empty string if value isn't a string or number [#2460](https://github.com/getgrav/grav/issues/2460)
     * Force Asset `priority` to be an integer and not throw error if invalid string passed [#2461](https://github.com/getgrav/grav/issues/2461)
     * Fixed bug in text field filtering: return empty string if value isn't a string or number
     * Fixed `FlexForm` missing getter methods for defining form variables
-    
+
 # v1.6.5
 ## 04/15/2019
 
@@ -97,7 +213,7 @@
     * Added `Grav\Framework\Object\ObjectIndex` class
     * Added `Grav\Framework\Flex` classes
     * Added support for hiding form fields in blueprints by using dynamic property like `security@: admin.foobar`, `scope@: object` or `scope-ignore@: object` to any field
-    * New experimental **FlexObjects** powered `Users` for increased performance and capability (**disabled** by default)    
+    * New experimental **FlexObjects** powered `Users` for increased performance and capability (**disabled** by default)
     * Added PSR-7 and PSR-15 classes
     * Added `Grav\Framework\DI\Container` class
     * Added `Grav\Framework\RequestHandler\RequestHandler` class
@@ -173,7 +289,7 @@
     * Added ability to reset `Page::metadata` to allow rebuilding from automatically generated values
     * Added back missing `page.types` field in system content configuration [admin#1612](https://github.com/getgrav/grav-plugin-admin/issues/1612)
     * Console commands: add method for invalidating cache
-    * Updated languages    
+    * Updated languages
     * Improved `$page->forms()` call, added `$page->addForms()`
     * Updated languages from crowdin
     * Fixed `ImageMedium` constructor warning when file does not exist
@@ -194,7 +310,7 @@
     * Added apcu autoloader optimization
     * Additional helper methods in `Language`, `Languages`, and `LanguageCodes` classes
     * Call `onFatalException` event also on internal PHP errors
-    * Built-in PHP Webserver: log requests before handling them 
+    * Built-in PHP Webserver: log requests before handling them
     * Added support for syslog and syslog facility logging (default: 'file')
     * Improved usability of `System` configuration blueprint with side-tabs
  1. [](#bugfix)
@@ -219,7 +335,7 @@
     * Fixed failed login if user attempts to log in with upper case non-english letters
     * Removed extra authenticated/authorized fields when saving existing user from a form
     * Fixed `Grav\Framework\Route::__toString()` returning relative URL, not relative route
-    * Fixed handling of `append_url_extension` inside of `Page::templateFormat()` [#2264](https://github.com/getgrav/grav/issues/2264) 
+    * Fixed handling of `append_url_extension` inside of `Page::templateFormat()` [#2264](https://github.com/getgrav/grav/issues/2264)
     * Fixed a broken language string [#2261](https://github.com/getgrav/grav/issues/2261)
     * Fixed clearing cache having no effect on Doctrine cache
     * Fixed `Medium::relativePath()` for streams
@@ -272,7 +388,7 @@
     * Updated vendor libraries
 1. [](#bugfix)
     * Support spaces with filenames in responsive images [#2300](https://github.com/getgrav/grav/pull/2300)
-    
+
 # v1.5.6
 ## 12/14/2018
 

+ 3 - 2
CONTRIBUTING.md

@@ -29,7 +29,7 @@ The issue tracker is the preferred channel for [bug reports](#bugs),
 requests](#pull-requests), but please respect the following restrictions:
 
 * Please **do not** use the issue tracker for support requests. Use
-  [the Forum](http://getgrav.org/forum) or [the Gitter chat](https://gitter.im/getgrav/grav).
+  [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/).
 
 
 <a name="bugs"></a>
@@ -110,7 +110,8 @@ Good pull requests - patches, improvements, new features - are a fantastic
 help. They should remain focused in scope and avoid containing unrelated
 commits.
 
-**Please ask first** in [Slack](https://getgrav.org/slack) or in the Forum before embarking on any significant pull request (e.g.
+**Please ask first** in [the Forum](http://getgrav.org/forum) or [the Chat](https://chat.getgrav.org/) 
+before embarking on any significant pull request (e.g.
 implementing features, refactoring code..),
 otherwise you risk spending a lot of time working on something that the
 project's developers might not want to merge into the project.

+ 12 - 13
bin/plugin

@@ -79,19 +79,6 @@ $output = new ConsoleOutput();
 $output->getFormatter()->setStyle('red', new OutputFormatterStyle('red', null, array('bold')));
 $output->getFormatter()->setStyle('white', new OutputFormatterStyle('white', null, array('bold')));
 
-if (is_null($plugin)) {
-    $output->writeln('');
-    $output->writeln("<red>$name plugin not found</red>");
-    die;
-}
-
-if (!$plugin->enabled) {
-    $output->writeln('');
-    $output->writeln("<red>$name not enabled</red>");
-    die;
-}
-
-
 if (!$name) {
     $output->writeln('');
     $output->writeln('<red>Usage:</red>');
@@ -123,6 +110,18 @@ if (!$name) {
     }
 
     exit;
+} else {
+    if (is_null($plugin)) {
+        $output->writeln('');
+        $output->writeln("<red>$name plugin not found</red>");
+        die;
+    }
+
+    if (!$plugin->enabled) {
+        $output->writeln('');
+        $output->writeln("<red>$name not enabled</red>");
+        die;
+    }
 }
 
 if ($plugin === null) {

+ 8 - 6
composer.json

@@ -24,14 +24,14 @@
         "kodus/psr7-server": "*",
         "nyholm/psr7": "^1.0",
 
-        "twig/twig": "~1.35",
+        "twig/twig": "~1.40",
         "erusev/parsedown": "1.6.4",
         "erusev/parsedown-extra": "~0.7",
-        "symfony/yaml": "~4.2",
-        "symfony/console": "~4.2",
-        "symfony/event-dispatcher": "~4.2",
-        "symfony/var-dumper": "~4.2",
-        "symfony/process": "~4.2",
+        "symfony/yaml": "~4.2.0",
+        "symfony/console": "~4.2.0",
+        "symfony/event-dispatcher": "~4.2.0",
+        "symfony/var-dumper": "~4.2.0",
+        "symfony/process": "~4.2.0",
         "doctrine/cache": "^1.8",
         "doctrine/collections": "^1.5",
         "guzzlehttp/psr7": "^1.4",
@@ -89,6 +89,8 @@
         "exclude": ["VERSION"]
     },
     "scripts": {
+        "api-16": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.16.md",
+        "api-15": "vendor/bin/phpdoc-md generate system/src > user/pages/14.api/default.md",
         "post-create-project-cmd": "bin/grav install",
         "phpstan": "vendor/bin/phpstan analyse -l 2 -c ./tests/phpstan/phpstan.neon system/src --memory-limit=256M",
         "phpstan-framework": "vendor/bin/phpstan analyse -l 5 -c ./tests/phpstan/phpstan.neon system/src/Grav/Framework --memory-limit=256M",

File diff suppressed because it is too large
+ 230 - 170
composer.lock


BIN
default_site_backup--20190930153122.zip


+ 4 - 0
now.json

@@ -0,0 +1,4 @@
+    {
+  "version": 2,
+  "builds": [{ "src": "*.php", "use": "@now/php" }]
+}

+ 1 - 1
system/blueprints/config/site.yaml

@@ -65,7 +65,7 @@ form:
 
                 summary.size:
                     type: text
-                    size: x-small
+                    size: small
                     append: PLUGIN_ADMIN.CHARACTERS
                     label: PLUGIN_ADMIN.SUMMARY_SIZE
                     help: PLUGIN_ADMIN.SUMMARY_SIZE_HELP

+ 2 - 0
system/blueprints/user/account_new.yaml

@@ -12,5 +12,7 @@ form:
       type: text
       label: PLUGIN_ADMIN.USERNAME
       help: PLUGIN_ADMIN.USERNAME_HELP
+      unset-disabled@: true
+      unset-readonly@: true
       validate:
         required: true

+ 10 - 0
system/blueprints/user/accounts.yaml

@@ -27,3 +27,13 @@ config:
         title: Accounts
         icon: fa-users
         authorize: ['admin.users', 'admin.accounts', 'admin.super']
+
+form:
+  fields:
+    username:
+      flex-disabled@: exists
+      disabled: false
+      flex-readonly@: exists
+      readonly: false
+      validate:
+        required: true

+ 1 - 1
system/defines.php

@@ -8,7 +8,7 @@
 
 // Some standard defines
 define('GRAV', true);
-define('GRAV_VERSION', '1.6.8');
+define('GRAV_VERSION', '1.6.16');
 define('GRAV_TESTING', false);
 define('DS', '/');
 

+ 1 - 1
system/languages/es.yaml

@@ -23,7 +23,7 @@ GRAV:
     BAD_DATE: Fecha errónea
     AGO: antes
     FROM_NOW: desde ahora
-    JUST_NOW: justo ahora
+    JUST_NOW: hace un momento
     SECOND: segundo
     MINUTE: minuto
     HOUR: hora

+ 23 - 0
system/languages/et.yaml

@@ -1,11 +1,30 @@
 ---
 GRAV:
   FRONTMATTER_ERROR_PAGE: "---\npealkiri: %1$s\n---\n\n# Viga: vigane Frontmatter'i\n\nasukoht: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
+  INFLECTOR_UNCOUNTABLE:
+    - 'equipment'
+    - 'informatsioon'
+    - 'rice'
+    - 'money'
+    - 'species'
+    - 'series'
+    - 'kala'
+    - 'lammas'
+  INFLECTOR_IRREGULAR:
+    'person': 'inimesed'
+    'man': 'mees'
+    'child': 'lapsed'
+  INFLECTOR_ORDINALS:
+    'default': '.'
+    'first': '.'
+    'second': '.'
+    'third': '.'
   NICETIME:
     NO_DATE_PROVIDED: Kuupäev määramata
     BAD_DATE: Vigane kuupäev
     AGO: tagasi
     FROM_NOW: praegusest
+    JUST_NOW: just nüüd
     SECOND: sekund
     MINUTE: minut
     HOUR: tundi
@@ -60,3 +79,7 @@ GRAV:
     - 'reede'
     - 'laupäev'
     - 'pühapäev'
+  CRON:
+    EVERY: iga
+    EVERY_MONTH: iga kuu
+    TEXT_PERIOD: Iga <b />

+ 2 - 0
system/languages/fr.yaml

@@ -14,6 +14,8 @@ GRAV:
     '/sis$/i': 'ses'
     '/([ti])um$/i': '\1a'
     '/(buffal|tomat)o$/i': '\1es'
+    '/(bu)s$/i': 'Bus'
+    '/(alias|status)/i': 'alias|status'
     '/(ax|test)is$/i': '\1s'
     '/s$/i': 's'
     '/$/': 's'

+ 11 - 0
system/languages/is.yaml

@@ -11,6 +11,8 @@ GRAV:
     - 'fish'
     - 'sheep'
   NICETIME:
+    NO_DATE_PROVIDED: Engin dagsetning gefin
+    BAD_DATE: Röng dagsetning
     AGO: síðan
     JUST_NOW: í þessu
     SECOND: sekúndu
@@ -45,6 +47,7 @@ GRAV:
     DEC_PLURAL: árat
   FORM:
     VALIDATION_FAIL: <b>Sannvottun mistókst:</b>
+    INVALID_INPUT: Ógilt inntak í
     MISSING_REQUIRED_FIELD: 'Vantar nauðsynlegan reit:'
   MONTHS_OF_THE_YEAR:
     - 'janúar'
@@ -67,3 +70,11 @@ GRAV:
     - 'Föstudagur'
     - 'Laugardagur'
     - 'Sunnudagur'
+  CRON:
+    TEXT_TIME: ' á <b />:<b />'
+    TEXT_DOW: ' á <b />'
+    TEXT_MONTH: ' af <b />'
+    TEXT_DOM: ' á <b />'
+    ERROR1: Merkið %s er ekki stutt!
+    ERROR3: Það ætti að setja jquery_element inn í stillingar jqCron
+    ERROR4: Óþekkt segð

+ 9 - 0
system/languages/pt.yaml

@@ -35,3 +35,12 @@ GRAV:
     - 'Outubro'
     - 'Novembro'
     - 'Dezembro'
+  INFLECTOR_UNCOUNTABLE:
+    - 'equipment'
+    - 'information'
+    - 'arroz'
+    - 'money'
+    - 'species'
+    - 'series'
+    - 'fish'
+    - 'sheep'

+ 84 - 0
system/languages/sk.yaml

@@ -1,10 +1,75 @@
 ---
 GRAV:
+  FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# Chyba: Chybný frontmatter\n\nPath: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
+  INFLECTOR_PLURALS:
+    '/(quiz)$/i': '\1zes'
+    '/^(ox)$/i': '\1en'
+    '/([m|l])ouse$/i': '\1ice'
+    '/(matr|vert|ind)ix|ex$/i': '\1ices'
+    '/(x|ch|ss|sh)$/i': '\1es'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([^aeiouy]|qu)y$/i': '\1ies'
+    '/(hive)$/i': '\1s'
+    '/(?:([^f])fe|([lr])f)$/i': '\1\2ves'
+    '/sis$/i': 'ses'
+    '/([ti])um$/i': '\1a'
+    '/(buffal|tomat)o$/i': '\1oes'
+    '/(bu)s$/i': '\1ses'
+    '/(alias|status)/i': '\1es'
+    '/(octop|vir)us$/i': '\1i'
+    '/(ax|test)is$/i': '\1es'
+    '/s$/i': 's'
+    '/$/': 's'
+  INFLECTOR_SINGULAR:
+    '/(quiz)zes$/i': '\1'
+    '/(matr)ices$/i': '\1ix'
+    '/(vert|ind)ices$/i': '\1ex'
+    '/^(ox)en/i': '\1'
+    '/(alias|status)es$/i': '\1'
+    '/([octop|vir])i$/i': '\1us'
+    '/(cris|ax|test)es$/i': '\1is'
+    '/(shoe)s$/i': '\1'
+    '/(o)es$/i': '\1'
+    '/(bus)es$/i': '\1'
+    '/([m|l])ice$/i': '\1ouse'
+    '/(x|ch|ss|sh)es$/i': '\1'
+    '/(m)ovies$/i': '\1ovie'
+    '/(s)eries$/i': '\1eries'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([lr])ves$/i': '\1f'
+    '/(tive)s$/i': '\1'
+    '/(hive)s$/i': '\1'
+    '/([^f])ves$/i': '\1fe'
+    '/(^analy)ses$/i': '\1sis'
+    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis'
+    '/([ti])a$/i': '\1um'
+    '/(n)ews$/i': '\1ews'
+  INFLECTOR_UNCOUNTABLE:
+    - 'vybavenie'
+    - 'informácie'
+    - 'ryža'
+    - 'peniaze'
+    - 'druhy'
+    - 'séria'
+    - 'ryba'
+    - 'ovce'
+  INFLECTOR_IRREGULAR:
+    'person': 'ľudia'
+    'man': 'muži'
+    'child': 'deti'
+    'sex': 'pohlavia'
+    'move': 'pohyby'
+  INFLECTOR_ORDINALS:
+    'default': '.'
+    'first': '.'
+    'second': '.'
+    'third': '.'
   NICETIME:
     NO_DATE_PROVIDED: Neposkytnutý žiaden dátum
     BAD_DATE: Nesprávny dátum
     AGO: pred
     FROM_NOW: odteraz
+    JUST_NOW: práve teraz
     SECOND: sekunda
     MINUTE: minúta
     HOUR: hodina
@@ -14,10 +79,12 @@ GRAV:
     YEAR: rok
     DECADE: desaťročie
     SEC: sek
+    MIN: min
     HR: hod
     WK: t
     MO: m
     YR: r
+    DEC: dec
     SECOND_PLURAL: sekúnd
     MINUTE_PLURAL: minút
     HOUR_PLURAL: hodín
@@ -58,3 +125,20 @@ GRAV:
     - 'Piatok'
     - 'Sobota'
     - 'Nedeľa'
+  CRON:
+    EVERY: každý
+    EVERY_HOUR: každú hodinu
+    EVERY_MINUTE: každú minútu
+    EVERY_DAY_OF_WEEK: každý deň v týždni
+    EVERY_DAY_OF_MONTH: každý deň v mesiaci
+    EVERY_MONTH: každý mesiac
+    TEXT_PERIOD: Každý <b />
+    TEXT_MINS: ' at <b /> minute(s) past the hour'
+    TEXT_TIME: ' at <b />:<b />'
+    TEXT_DOW: ' on <b />'
+    TEXT_MONTH: ' of <b />'
+    TEXT_DOM: ' on <b />'
+    ERROR1: Tag %s nieje podporovaný!
+    ERROR2: Chybný počet položiek
+    ERROR3: jquery_element musí byť nastavený v nastaveniach pre jqCron
+    ERROR4: Neznámy výraz

+ 11 - 0
system/languages/sv.yaml

@@ -1,6 +1,17 @@
 ---
 GRAV:
   FRONTMATTER_ERROR_PAGE: "--- titel: %1$s --- # Fel: Ogiltig Frontmatter-sökväg: `%2$s` **%3$s** ``` %4$s ```"
+  INFLECTOR_UNCOUNTABLE:
+    - 'utrustning'
+    - 'information'
+    - 'ris'
+    - 'pengar'
+    - 'arter'
+    - 'serier'
+    - 'fisk'
+    - 'får'
+  INFLECTOR_IRREGULAR:
+    'person': 'personer'
   NICETIME:
     NO_DATE_PROVIDED: Inget datum har angivits
     BAD_DATE: Ogiltigt datum

+ 24 - 4
system/languages/zh-tw.yaml

@@ -1,24 +1,44 @@
 ---
 GRAV:
+  FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# 錯誤: 不正確的 Frontmatter\n\n路徑: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
   NICETIME:
+    NO_DATE_PROVIDED: 沒有提供日期
+    BAD_DATE: 錯誤日期
+    AGO: 之前
+    FROM_NOW: 之後
+    JUST_NOW: 剛剛
     SECOND: 秒
     MINUTE: 分
     HOUR: 小時
+    DAY: 天
+    WEEK: 週
+    MONTH: 月
+    YEAR: 年
+    DECADE: 十年
+    SEC: 秒
+    MIN: 分
+    HR: 小時
+    WK: 週
+    MO: 月
+    YR: 年
+    DEC: 十年
     SECOND_PLURAL: 秒
     MINUTE_PLURAL: 分
-    HOUR_PLURAL: 時
-    DAY_PLURAL: 日
-    WEEK_PLURAL: 周
+    HOUR_PLURAL: 
+    DAY_PLURAL: 
+    WEEK_PLURAL: 
     MONTH_PLURAL: 月
     YEAR_PLURAL: 年
     DECADE_PLURAL: 十年
     SEC_PLURAL: 秒
     MIN_PLURAL: 分
     HR_PLURAL: 時
-    WK_PLURAL: 周
+    WK_PLURAL: 
     MO_PLURAL: 月
     YR_PLURAL: 年
     DEC_PLURAL: 十年
+  FORM:
+    MISSING_REQUIRED_FIELD: 遺漏必填欄位:
   MONTHS_OF_THE_YEAR:
     - '一月'
     - '二月'

+ 113 - 32
system/languages/zh.yaml

@@ -1,57 +1,122 @@
 ---
 GRAV:
-  FRONTMATTER_ERROR_PAGE: "---\ntitle: %1$s\n---\n\n# 錯誤: 不正確的 Frontmatter\n\n路徑: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
+  FRONTMATTER_ERROR_PAGE: "---\n标题: %1$s\n---\n\n# 错误:无效参数\n\n位置: `%2$s`\n\n**%3$s**\n\n```\n%4$s\n```"
+  INFLECTOR_PLURALS:
+    '/(quiz)$/i': '\1zes'
+    '/^(ox)$/i': '\1en'
+    '/([m|l])ouse$/i': '\1ice'
+    '/(matr|vert|ind)ix|ex$/i': '\1ices'
+    '/(x|ch|ss|sh)$/i': '\1es'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([^aeiouy]|qu)y$/i': '\1ies'
+    '/(hive)$/i': '\1s'
+    '/(?:([^f])fe|([lr])f)$/i': '\1\2ves'
+    '/sis$/i': 'ses'
+    '/([ti])um$/i': '\1a'
+    '/(buffal|tomat)o$/i': '\1oes'
+    '/(bu)s$/i': '\1ses'
+    '/(alias|status)/i': '\1es'
+    '/(octop|vir)us$/i': '\1i'
+    '/(ax|test)is$/i': '\1es'
+    '/s$/i': 's'
+    '/$/': 's'
+  INFLECTOR_SINGULAR:
+    '/(quiz)zes$/i': '\1'
+    '/(matr)ices$/i': '\1ix'
+    '/(vert|ind)ices$/i': '\1ex'
+    '/^(ox)en/i': '\1'
+    '/(alias|status)es$/i': '\1'
+    '/([octop|vir])i$/i': '\1us'
+    '/(cris|ax|test)es$/i': '\1is'
+    '/(shoe)s$/i': '\1'
+    '/(o)es$/i': '\1'
+    '/(bus)es$/i': '\1'
+    '/([m|l])ice$/i': '\1ouse'
+    '/(x|ch|ss|sh)es$/i': '\1'
+    '/(m)ovies$/i': '\1ovie'
+    '/(s)eries$/i': '\1eries'
+    '/([^aeiouy]|qu)ies$/i': '\1y'
+    '/([lr])ves$/i': '\1f'
+    '/(tive)s$/i': '\1'
+    '/(hive)s$/i': '\1'
+    '/([^f])ves$/i': '\1fe'
+    '/(^analy)ses$/i': '\1sis'
+    '/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i': '\1\2sis'
+    '/([ti])a$/i': '\1um'
+    '/(n)ews$/i': '\1ews'
+  INFLECTOR_UNCOUNTABLE:
+    - '装备'
+    - '信息'
+    - '大米'
+    - '钱'
+    - '物种'
+    - '系列'
+    - '鱼'
+    - '羊'
+  INFLECTOR_IRREGULAR:
+    'person': '人员'
+    'man': '男人'
+    'child': '儿童'
+    'sex': '性别'
+    'move': '移动'
+  INFLECTOR_ORDINALS:
+    'default': 'th'
+    'first': 'st'
+    'second': 'md'
+    'third': 'rd'
   NICETIME:
-    NO_DATE_PROVIDED: 沒有提供日期
-    BAD_DATE: 錯誤日期
-    AGO: 之前
-    FROM_NOW: 之後
-    JUST_NOW: 剛剛
+    NO_DATE_PROVIDED: 无日期信息
+    BAD_DATE: 无效日期
+    AGO: 前
+    FROM_NOW: 距今
+    JUST_NOW: 刚刚
     SECOND: 秒
-    MINUTE: 分
-    HOUR: 小時
+    MINUTE: 分
+    HOUR: 小
     DAY: 天
-    WEEK: 週
+    WEEK: 
     MONTH: 月
     YEAR: 年
     DECADE: 十年
     SEC: 秒
-    MIN: 分
-    HR: 小時
-    WK: 週
+    MIN: 分
+    HR: 小
+    WK: 
     MO: 月
     YR: 年
-    DEC: 十年
+    DEC: 年
     SECOND_PLURAL: 秒
     MINUTE_PLURAL: 分
-    HOUR_PLURAL: 小時
+    HOUR_PLURAL: 小
     DAY_PLURAL: 天
-    WEEK_PLURAL: 週
+    WEEK_PLURAL: 
     MONTH_PLURAL: 月
     YEAR_PLURAL: 年
     DECADE_PLURAL: 十年
     SEC_PLURAL: 秒
     MIN_PLURAL: 分
-    HR_PLURAL: 時
-    WK_PLURAL: 週
+    HR_PLURAL: 
+    WK_PLURAL: 
     MO_PLURAL: 月
     YR_PLURAL: 年
-    DEC_PLURAL: 十年
+    DEC_PLURAL: 年
   FORM:
-    MISSING_REQUIRED_FIELD: 遺漏必填欄位:
+    VALIDATION_FAIL: <b>验证失败:</b>
+    INVALID_INPUT: 无效输入
+    MISSING_REQUIRED_FIELD: 必填字段缺失:
   MONTHS_OF_THE_YEAR:
-    - '一月'
-    - '二月'
-    - '三月'
-    - '四月'
-    - '五月'
-    - '六月'
-    - '七月'
-    - '八月'
-    - '九月'
-    - '十月'
-    - '十一月'
-    - '十二月'
+    - '1月'
+    - '2月'
+    - '3月'
+    - '4月'
+    - '5月'
+    - '6月'
+    - '7月'
+    - '8月'
+    - '9月'
+    - '10月'
+    - '11月'
+    - '12月'
   DAYS_OF_THE_WEEK:
     - '星期一'
     - '星期二'
@@ -60,4 +125,20 @@ GRAV:
     - '星期五'
     - '星期六'
     - '星期日'
-
+  CRON:
+    EVERY: 每隔
+    EVERY_HOUR: 每小时
+    EVERY_MINUTE: 每分钟
+    EVERY_DAY_OF_WEEK: 一周中的每一天
+    EVERY_DAY_OF_MONTH: 月份中的每一天
+    EVERY_MONTH: 每月
+    TEXT_PERIOD: 所有 <b />
+    TEXT_MINS: ' 在 <b /> 小时过后的分钟'
+    TEXT_TIME: ' 在 <b />:<b />'
+    TEXT_DOW: ' on <b />'
+    TEXT_MONTH: ' of <b />'
+    TEXT_DOM: ' on <b />'
+    ERROR1: 不支持分享类型 %s
+    ERROR2: 无效数字
+    ERROR3: 请在 jqCron 设置中设定 jquery_element
+    ERROR4: 无法识别表达式

+ 14 - 4
system/router.php

@@ -17,11 +17,21 @@ if (is_file($_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . $_SERVER['SCRIPT_N
     return false;
 }
 
+$grav_index = 'index.php';
+
+/* Check the GRAV_BASEDIR environment variable and use if set */
+$grav_basedir = getenv('GRAV_BASEDIR') ?: '';
+if ($grav_basedir) {
+    $grav_index = ltrim($grav_basedir, '/') . DIRECTORY_SEPARATOR . $grav_index;
+    $grav_basedir = DIRECTORY_SEPARATOR . trim($grav_basedir, DIRECTORY_SEPARATOR);
+    define('GRAV_ROOT', str_replace(DIRECTORY_SEPARATOR, '/', getcwd()) . $grav_basedir);
+}
+
 $_SERVER = array_merge($_SERVER, $_ENV);
-$_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT'] . DIRECTORY_SEPARATOR . 'index.php';
-$_SERVER['SCRIPT_NAME'] = DIRECTORY_SEPARATOR . 'index.php';
-$_SERVER['PHP_SELF'] = DIRECTORY_SEPARATOR . 'index.php';
+$_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT'] . $grav_basedir .DIRECTORY_SEPARATOR . 'index.php';
+$_SERVER['SCRIPT_NAME'] = $grav_basedir . DIRECTORY_SEPARATOR . 'index.php';
+$_SERVER['PHP_SELF'] = $grav_basedir . DIRECTORY_SEPARATOR . 'index.php';
 
 error_log(sprintf('%s:%d [%d]: %s', $_SERVER['REMOTE_ADDR'], $_SERVER['REMOTE_PORT'], http_response_code(), $_SERVER['REQUEST_URI']), 4);
 
-require 'index.php';
+require $grav_index;

+ 14 - 7
system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php

@@ -24,19 +24,21 @@ trait LegacyAssetsTrait
         // First argument is always the asset
         array_shift($args);
 
-        if (\count($args) === 0) {
+        if (count($args) === 0) {
             return [];
         }
-        if (\count($args) === 1 && \is_array($args[0])) {
+        // New options array format
+        if (count($args) === 1 && is_array($args[0])) {
             return $args[0];
         }
+        // Handle obscure case where options array is mixed with a priority
+        if (count($args) === 2 && is_array($args[0]) && is_int($args[1])) {
+            $arguments = $args[0];
+            $arguments['priority'] = $args[1];
+            return $arguments;
+        }
 
         switch ($type) {
-            case(Assets::INLINE_CSS_TYPE):
-                $defaults = ['priority' => null, 'group' => null];
-                $arguments = $this->createArgumentsFromLegacy($args, $defaults);
-                break;
-
             case(Assets::JS_TYPE):
                 $defaults = ['priority' => null, 'pipeline' => true, 'loading' => null, 'group' => null];
                 $arguments = $this->createArgumentsFromLegacy($args, $defaults);
@@ -55,6 +57,11 @@ trait LegacyAssetsTrait
 
                 break;
 
+            case(Assets::INLINE_CSS_TYPE):
+                $defaults = ['priority' => null, 'group' => null];
+                $arguments = $this->createArgumentsFromLegacy($args, $defaults);
+                break;
+
             default:
             case(Assets::CSS_TYPE):
                 $defaults = ['priority' => null, 'pipeline' => true, 'group' => null, 'loading' => null];

+ 5 - 3
system/src/Grav/Common/Cache.php

@@ -437,6 +437,9 @@ class Cache extends Getters
             case 'tmp-only':
                 $remove_paths = self::$tmp_remove;
                 break;
+            case 'invalidate':
+                $remove_paths = [];
+                break;
             default:
                 if (Grav::instance()['config']->get('system.cache.clear_images_by_default')) {
                     $remove_paths = self::$standard_remove;
@@ -528,7 +531,6 @@ class Cache extends Getters
 
     }
 
-
     /**
      * Set the cache lifetime programmatically
      *
@@ -540,7 +542,7 @@ class Cache extends Getters
             return;
         }
 
-        $interval = $future - $this->now;
+        $interval = (int)($future - $this->now);
         if ($interval > 0 && $interval < $this->getLifetime()) {
             $this->lifetime = $interval;
         }
@@ -555,7 +557,7 @@ class Cache extends Getters
     public function getLifetime()
     {
         if ($this->lifetime === null) {
-            $this->lifetime = $this->config->get('system.cache.lifetime') ?: 604800; // 1 week default
+            $this->lifetime = (int)($this->config->get('system.cache.lifetime') ?: 604800); // 1 week default
         }
 
         return $this->lifetime;

+ 6 - 8
system/src/Grav/Common/Data/Blueprint.php

@@ -378,14 +378,12 @@ class Blueprint extends BlueprintForm
         $grav = Grav::instance();
         $actions = (array)$call['params'];
 
-        /** @var UserInterface $user */
-        if (isset($grav['user'])) {
-            $user = Grav::instance()['user'];
-            foreach ($actions as $action) {
-                if (!$user->authorize($action)) {
-                    $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
-                    return;
-                }
+        /** @var UserInterface|null $user */
+        $user = $grav['user'] ?? null;
+        foreach ($actions as $action) {
+            if (!$user || !$user->authorize($action)) {
+                $this->addPropertyRecursive($field, 'validate', ['ignore' => true]);
+                return;
             }
         }
     }

+ 12 - 2
system/src/Grav/Common/Data/Blueprints.php

@@ -39,7 +39,8 @@ class Blueprints
     public function get($type)
     {
         if (!isset($this->instances[$type])) {
-            $this->instances[$type] = $this->loadFile($type);
+            $blueprint = $this->loadFile($type);
+            $this->instances[$type] = $blueprint;
         }
 
         return $this->instances[$type];
@@ -99,6 +100,15 @@ class Blueprints
             $blueprint->setContext($this->search);
         }
 
-        return $blueprint->load()->init();
+        try {
+            $blueprint->load()->init();
+        } catch (\RuntimeException $e) {
+            $log = Grav::instance()['log'];
+            $log->error(sprintf('Blueprint %s cannot be loaded: %s', $name, $e->getMessage()));
+
+            throw $e;
+        }
+
+        return $blueprint;
     }
 }

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

@@ -27,8 +27,9 @@ class Validation
         if (!isset($field['type'])) {
             $field['type'] = 'text';
         }
-        $type = $validate['type'] ?? $field['type'];
+
         $validate = (array)($field['validate'] ?? null);
+        $type = $validate['type'] ?? $field['type'];
         $required = $validate['required'] ?? false;
 
         // If value isn't required, we will stop validation if empty value is given.
@@ -165,6 +166,11 @@ class Validation
         return (string) $value;
     }
 
+    protected static function filterCheckbox($value, array $params, array $field)
+    {
+        return (bool) $value;
+    }
+
     protected static function filterCommaList($value, array $params, array $field)
     {
         return \is_array($value) ? $value : preg_split('/\s*,\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
@@ -571,6 +577,11 @@ class Validation
             }
         }
 
+        // If creating new values is allowed, no further checks are needed.
+        if (!empty($field['selectize']['create'])) {
+            return true;
+        }
+
         $options = $field['options'] ?? [];
         $use = $field['use'] ?? 'values';
 

+ 10 - 76
system/src/Grav/Common/Form/FormFlash.php

@@ -10,54 +10,10 @@
 namespace Grav\Common\Form;
 
 use Grav\Common\Filesystem\Folder;
-use Grav\Common\Grav;
-use Grav\Common\User\Interfaces\UserInterface;
-use RocketTheme\Toolbox\File\YamlFile;
-use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+use Grav\Framework\Form\FormFlash as FrameworkFormFlash;
 
-class FormFlash extends \Grav\Framework\Form\FormFlash
+class FormFlash extends FrameworkFormFlash
 {
-    /**
-     * @param string $sessionId
-     */
-    public static function clearSession(string $sessionId): void
-    {
-        $folder = static::getSessionTmpDir($sessionId);
-        if (is_dir($folder)) {
-            Folder::delete($folder);
-        }
-    }
-
-    /**
-     * @param string $sessionId
-     * @return string
-     */
-    public static function getSessionTmpDir(string $sessionId): string
-    {
-        /** @var UniformResourceLocator $locator */
-        $locator = Grav::instance()['locator'];
-
-        return $locator->findResource("tmp://forms/{$sessionId}", true, true);
-    }
-
-    /**
-     * @param UserInterface|null $user
-     * @return $this
-     */
-    public function setUser(UserInterface $user = null)
-    {
-        if ($user && $user->username) {
-            $this->user = [
-                'username' => $user->username,
-                'email' => $user->email ?? ''
-            ];
-        } else {
-            $this->user = null;
-        }
-
-        return $this;
-    }
-
     /**
      * @return array
      * @deprecated 1.6 For backwards compatibility only, do not use
@@ -89,8 +45,11 @@ class FormFlash extends \Grav\Framework\Form\FormFlash
      */
     public function uploadFile(string $field, string $filename, array $upload): bool
     {
-        $tmp_dir = $this->getTmpDir();
+        if (!$this->uniqueId) {
+            return false;
+        }
 
+        $tmp_dir = $this->getTmpDir();
         Folder::create($tmp_dir);
 
         $tmp_file = $upload['file']['tmp_name'];
@@ -118,8 +77,11 @@ class FormFlash extends \Grav\Framework\Form\FormFlash
      */
     public function cropFile(string $field, string $filename, array $upload, array $crop): bool
     {
-        $tmp_dir = $this->getTmpDir();
+        if (!$this->uniqueId) {
+            return false;
+        }
 
+        $tmp_dir = $this->getTmpDir();
         Folder::create($tmp_dir);
 
         $tmp_file = $upload['file']['tmp_name'];
@@ -136,32 +98,4 @@ class FormFlash extends \Grav\Framework\Form\FormFlash
 
         return true;
     }
-
-    /**
-     * @return YamlFile
-     */
-    protected function getTmpIndex(): YamlFile
-    {
-        // Do not use CompiledYamlFile as the file can change multiple times per second.
-        return YamlFile::instance($this->getTmpDir() . '/index.yaml');
-    }
-
-    /**
-     * @param string $name
-     */
-    protected function removeTmpFile(string $name): void
-    {
-        $filename = $this->getTmpDir() . '/' . $name;
-        if ($name && is_file($filename)) {
-            unlink($filename);
-        }
-    }
-
-    protected function removeTmpDir(): void
-    {
-        $tmpDir = $this->getTmpDir();
-        if (file_exists($tmpDir)) {
-            Folder::delete($tmpDir);
-        }
-    }
 }

+ 8 - 5
system/src/Grav/Common/GPM/Common/CachedCollection.php

@@ -11,19 +11,22 @@ namespace Grav\Common\GPM\Common;
 
 use Grav\Common\Iterator;
 
-class CachedCollection extends Iterator {
-
+class CachedCollection extends Iterator
+{
     protected static $cache;
 
     public function __construct($items)
     {
         parent::__construct();
+
+        $method = static::class . __METHOD__;
+
         // local cache to speed things up
-        if (!isset(self::$cache[get_called_class() . __METHOD__])) {
-            self::$cache[get_called_class() . __METHOD__] = $items;
+        if (!isset(self::$cache[$method])) {
+            self::$cache[$method] = $items;
         }
 
-        foreach (self::$cache[get_called_class() . __METHOD__] as $name => $item) {
+        foreach (self::$cache[$method] as $name => $item) {
             $this->append([$name => $item]);
         }
     }

+ 2 - 2
system/src/Grav/Common/GPM/Common/Package.php

@@ -11,8 +11,8 @@ namespace Grav\Common\GPM\Common;
 
 use Grav\Common\Data\Data;
 
-class Package {
-
+class Package
+{
     /**
      * @var Data
      */

+ 13 - 12
system/src/Grav/Common/GPM/GPM.php

@@ -770,7 +770,7 @@ class GPM extends Iterator
      * @param array $ignore_packages_list
      *
      * @return bool
-     * @throws \Exception
+     * @throws \RuntimeException
      */
     public function checkNoOtherPackageNeedsThisDependencyInALowerVersion(
         $slug,
@@ -793,8 +793,8 @@ class GPM extends Iterator
                     $compatible = $this->checkNextSignificantReleasesAreCompatible($version,
                         $other_dependency_version);
                     if (!$compatible) {
-                        if (!in_array($dependent_package, $ignore_packages_list)) {
-                            throw new \Exception("Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.",
+                        if (!in_array($dependent_package, $ignore_packages_list, true)) {
+                            throw new \RuntimeException("Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.",
                                 2);
                         }
                     }
@@ -850,10 +850,10 @@ class GPM extends Iterator
                 ) {
                     //Needs a Grav update first
                     throw new \RuntimeException("<red>One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this");
-                } else {
-                    unset($dependencies[$dependency_slug]);
-                    continue;
                 }
+
+                unset($dependencies[$dependency_slug]);
+                continue;
             }
 
             //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.
@@ -863,10 +863,10 @@ class GPM extends Iterator
                 ) {
                     //Needs a Grav update first
                     throw new \RuntimeException("<red>One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.");
-                } else {
-                    unset($dependencies[$dependency_slug]);
-                    continue;
                 }
+
+                unset($dependencies[$dependency_slug]);
+                continue;
             }
 
             if ($this->isPluginInstalled($dependency_slug)) {
@@ -1092,6 +1092,7 @@ class GPM extends Iterator
         if ($this->versionFormatIsEqualOrHigher($version)) {
             return trim(substr($version, 2));
         }
+
         return $version;
     }
 
@@ -1104,7 +1105,7 @@ class GPM extends Iterator
      *
      * @return bool
      */
-    public function versionFormatIsNextSignificantRelease($version)
+    public function versionFormatIsNextSignificantRelease($version): bool
     {
         return strpos($version, '~') === 0;
     }
@@ -1118,7 +1119,7 @@ class GPM extends Iterator
      *
      * @return bool
      */
-    public function versionFormatIsEqualOrHigher($version)
+    public function versionFormatIsEqualOrHigher($version): bool
     {
         return strpos($version, '>=') === 0;
     }
@@ -1136,7 +1137,7 @@ class GPM extends Iterator
      *
      * @return bool
      */
-    public function checkNextSignificantReleasesAreCompatible($version1, $version2)
+    public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool
     {
         $version1array = explode('.', $version1);
         $version2array = explode('.', $version2);

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

@@ -103,7 +103,7 @@ class Licenses
     }
 
     /**
-     * Get's the License File object
+     * Get the License File object
      *
      * @return \RocketTheme\Toolbox\File\FileInterface
      */

+ 1 - 0
system/src/Grav/Common/GPM/Local/AbstractPackageCollection.php

@@ -16,6 +16,7 @@ abstract class AbstractPackageCollection extends BaseCollection
     public function __construct($items)
     {
         parent::__construct();
+
         foreach ($items as $name => $data) {
             $data->set('slug', $name);
             $this->items[$name] = new Package($data, $this->type);

+ 1 - 0
system/src/Grav/Common/GPM/Local/Plugins.php

@@ -25,6 +25,7 @@ class Plugins extends AbstractPackageCollection
     {
         /** @var \Grav\Common\Plugins $plugins */
         $plugins = Grav::instance()['plugins'];
+
         parent::__construct($plugins->all());
     }
 }

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

@@ -43,7 +43,7 @@ class AbstractPackageCollection extends BaseCollection
     {
         parent::__construct();
         if ($repository === null) {
-            throw new \RuntimeException("A repository is required to indicate the origin of the remote collection");
+            throw new \RuntimeException('A repository is required to indicate the origin of the remote collection');
         }
 
         $channel = Grav::instance()['config']->get('system.gpm.releases', 'stable');

+ 3 - 3
system/src/Grav/Common/GPM/Remote/GravCore.php

@@ -37,9 +37,9 @@ class GravCore extends AbstractPackageCollection
         $this->fetch($refresh, $callback);
 
         $this->data    = json_decode($this->raw, true);
-        $this->version = isset($this->data['version']) ? $this->data['version'] : '-';
-        $this->date    = isset($this->data['date']) ? $this->data['date'] : '-';
-        $this->min_php = isset($this->data['min_php']) ? $this->data['min_php'] : null;
+        $this->version = $this->data['version'] ?? '-';
+        $this->date    = $this->data['date'] ?? '-';
+        $this->min_php = $this->data['min_php'] ?? null;
 
         if (isset($this->data['assets'])) {
             foreach ((array)$this->data['assets'] as $slug => $data) {

+ 17 - 240
system/src/Grav/Common/Helpers/Excerpts.php

@@ -9,24 +9,20 @@
 
 namespace Grav\Common\Helpers;
 
-use Grav\Common\Grav;
 use Grav\Common\Page\Interfaces\PageInterface;
-use Grav\Common\Uri;
+use Grav\Common\Page\Markdown\Excerpts as ExcerptsObject;
 use Grav\Common\Page\Medium\Medium;
-use Grav\Common\Utils;
-use RocketTheme\Toolbox\Event\Event;
-use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 
 class Excerpts
 {
     /**
      * Process Grav image media URL from HTML tag
      *
-     * @param string $html         HTML tag e.g. `<img src="image.jpg" />`
-     * @param PageInterface $page  The current page object
-     * @return string              Returns final HTML string
+     * @param string $html              HTML tag e.g. `<img src="image.jpg" />`
+     * @param PageInterface|null $page  Page, defaults to the current page object
+     * @return string                   Returns final HTML string
      */
-    public static function processImageHtml($html, PageInterface $page)
+    public static function processImageHtml($html, PageInterface $page = null)
     {
         $excerpt = static::getExcerptFromHtml($html, 'img');
 
@@ -112,157 +108,29 @@ class Excerpts
      * Process a Link excerpt
      *
      * @param array $excerpt
-     * @param PageInterface $page
+     * @param PageInterface|null $page  Page, defaults to the current page object
      * @param string $type
      * @return mixed
      */
-    public static function processLinkExcerpt($excerpt, PageInterface $page, $type = 'link')
+    public static function processLinkExcerpt($excerpt, PageInterface $page = null, $type = 'link')
     {
-        $url = htmlspecialchars_decode(rawurldecode($excerpt['element']['attributes']['href']));
+        $excerpts = new ExcerptsObject($page);
 
-        $url_parts = static::parseUrl($url);
-
-        // If there is a query, then parse it and build action calls.
-        if (isset($url_parts['query'])) {
-            $actions = array_reduce(explode('&', $url_parts['query']), function ($carry, $item) {
-                $parts = explode('=', $item, 2);
-                $value = isset($parts[1]) ? rawurldecode($parts[1]) : true;
-                $carry[$parts[0]] = $value;
-
-                return $carry;
-            }, []);
-
-            // Valid attributes supported.
-            $valid_attributes = ['rel', 'target', 'id', 'class', 'classes'];
-
-            // Unless told to not process, go through actions.
-            if (array_key_exists('noprocess', $actions)) {
-                unset($actions['noprocess']);
-            } else {
-                // Loop through actions for the image and call them.
-                foreach ($actions as $attrib => $value) {
-                    $key = $attrib;
-
-                    if (in_array($attrib, $valid_attributes, true)) {
-                        // support both class and classes.
-                        if ($attrib === 'classes') {
-                            $attrib = 'class';
-                        }
-                        $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value);
-                        unset($actions[$key]);
-                    }
-                }
-            }
-
-            $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986);
-        }
-
-        // If no query elements left, unset query.
-        if (empty($url_parts['query'])) {
-            unset ($url_parts['query']);
-        }
-
-        // Set path to / if not set.
-        if (empty($url_parts['path'])) {
-            $url_parts['path'] = '';
-        }
-
-        // 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'])) {
-                $url_parts['path'] = Grav::instance()['base_url_relative'] . '/' . static::resolveStream("{$url_parts['scheme']}://{$url_parts['path']}");
-                unset($url_parts['stream'], $url_parts['scheme']);
-            }
-
-            $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);
-            return $excerpt;
-        }
-
-        // Handle paths and such.
-        $url_parts = Uri::convertUrl($page, $url_parts, $type);
-
-        // Build the URL from the component parts and set it on the element.
-        $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);
-
-        return $excerpt;
+        return $excerpts->processLinkExcerpt($excerpt, $type);
     }
 
     /**
      * Process an image excerpt
      *
      * @param array $excerpt
-     * @param PageInterface $page
+     * @param PageInterface|null $page  Page, defaults to the current page object
      * @return array
      */
-    public static function processImageExcerpt(array $excerpt, PageInterface $page)
+    public static function processImageExcerpt(array $excerpt, PageInterface $page = null)
     {
-        $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src']));
-        $url_parts = static::parseUrl($url);
-
-        $media = null;
-        $filename = null;
-
-        if (!empty($url_parts['stream'])) {
-            $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? '');
-
-            $media = $page->getMedia();
-
-        } else {
-            $grav = Grav::instance();
-
-            // File is also local if scheme is http(s) and host matches.
-            $local_file = isset($url_parts['path'])
-                && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true))
-                && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host());
-
-            if ($local_file) {
-                $filename = basename($url_parts['path']);
-                $folder = dirname($url_parts['path']);
-
-                // Get the local path to page media if possible.
-                if ($folder === $page->url(false, false, false)) {
-                    // Get the media objects for this page.
-                    $media = $page->getMedia();
-                } else {
-                    // see if this is an external page to this one
-                    $base_url = rtrim($grav['base_url_relative'] . $grav['pages']->base(), '/');
-                    $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/');
-
-                    /** @var PageInterface $ext_page */
-                    $ext_page = $grav['pages']->dispatch($page_route, true);
-                    if ($ext_page) {
-                        $media = $ext_page->getMedia();
-                    } else {
-                        $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media]));
-                    }
-                }
-            }
-        }
-
-        // If there is a media file that matches the path referenced..
-        if ($media && $filename && isset($media[$filename])) {
-            // Get the medium object.
-            /** @var Medium $medium */
-            $medium = $media[$filename];
-
-            // Process operations
-            $medium = static::processMediaActions($medium, $url_parts);
-            $element_excerpt = $excerpt['element']['attributes'];
-
-            $alt = $element_excerpt['alt'] ?? '';
-            $title = $element_excerpt['title'] ?? '';
-            $class = $element_excerpt['class'] ?? '';
-            $id = $element_excerpt['id'] ?? '';
-
-            $excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true);
-
-        } else {
-            // Not a current page media file, see if it needs converting to relative.
-            $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts);
-        }
+        $excerpts = new ExcerptsObject($page);
 
-        return $excerpt;
+        return $excerpts->processImageExcerpt($excerpt);
     }
 
     /**
@@ -270,104 +138,13 @@ class Excerpts
      *
      * @param Medium $medium
      * @param string|array $url
+     * @param PageInterface|null $page  Page, defaults to the current page object
      * @return Medium
      */
-    public static function processMediaActions($medium, $url)
+    public static function processMediaActions($medium, $url, PageInterface $page = null)
     {
-        if (!is_array($url)) {
-            $url_parts = parse_url($url);
-        } else {
-            $url_parts = $url;
-        }
-
-        $actions = [];
-
-        // if there is a query, then parse it and build action calls
-        if (isset($url_parts['query'])) {
-            $actions = array_reduce(explode('&', $url_parts['query']), function ($carry, $item) {
-                $parts = explode('=', $item, 2);
-                $value = $parts[1] ?? null;
-                $carry[] = ['method' => $parts[0], 'params' => $value];
-
-                return $carry;
-            }, []);
-        }
-
-        if (Grav::instance()['config']->get('system.images.auto_fix_orientation')) {
-            $actions[] = ['method' => 'fixOrientation', 'params' => ''];
-        }
-        $defaults = Grav::instance()['config']->get('system.images.defaults');
-        if (is_array($defaults) && count($defaults)) {
-            foreach ($defaults as $method => $params) {
-                $actions[] = [
-                    'method' => $method,
-                    'params' => $params,
-                ];
-            }
-        }
-
-        // loop through actions for the image and call them
-        foreach ($actions as $action) {
-            $matches = [];
-
-            if (preg_match('/\[(.*)\]/', $action['params'], $matches)) {
-                $args = [explode(',', $matches[1])];
-            } else {
-                $args = explode(',', $action['params']);
-            }
-
-            $medium = call_user_func_array([$medium, $action['method']], $args);
-        }
-
-        if (isset($url_parts['fragment'])) {
-            $medium->urlHash($url_parts['fragment']);
-        }
-
-        return $medium;
-    }
-
-    /**
-     * Variation of parse_url() which works also with local streams.
-     *
-     * @param string $url
-     * @return array|bool
-     */
-    protected static function parseUrl($url)
-    {
-        $url_parts = Utils::multibyteParseUrl($url);
-
-        if (isset($url_parts['scheme'])) {
-            /** @var UniformResourceLocator $locator */
-            $locator = Grav::instance()['locator'];
-
-            // Special handling for the streams.
-            if ($locator->schemeExists($url_parts['scheme'])) {
-                if (isset($url_parts['host'])) {
-                    // Merge host and path into a path.
-                    $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : '');
-                    unset($url_parts['host']);
-                }
-
-                $url_parts['stream'] = true;
-            }
-        }
-
-        return $url_parts;
-    }
-
-    /**
-     * @param string $url
-     * @return bool|string
-     */
-    protected static function resolveStream($url)
-    {
-        /** @var UniformResourceLocator $locator */
-        $locator = Grav::instance()['locator'];
-
-        if ($locator->isStream($url)) {
-            return $locator->findResource($url, false) ?: $locator->findResource($url, false, true);
-        }
+        $excerpts = new ExcerptsObject($page);
 
-        return $url;
+        return $excerpts->processMediaActions($medium, $url);
     }
 }

+ 1 - 1
system/src/Grav/Common/Helpers/Truncator.php

@@ -234,7 +234,7 @@ class Truncator {
     }
 
     /**
-     * @inheritDoc
+     *
      */
     public function truncate(
         $text,

+ 14 - 1
system/src/Grav/Common/Helpers/YamlLinter.php

@@ -20,7 +20,8 @@ class YamlLinter
     {
         $errors = static::lintConfig();
         $errors = $errors + static::lintPages();
-
+        $errors = $errors + static::lintBlueprints();
+        
         return $errors;
     }
 
@@ -34,6 +35,18 @@ class YamlLinter
         return static::recurseFolder('config://');
     }
 
+    public static function lintBlueprints()
+    {
+        /** @var UniformResourceLocator $locator */
+        $locator = Grav::instance()['locator'];
+
+        $current_theme = Grav::instance()['config']->get('system.pages.theme');
+        $theme_path = 'themes://' . $current_theme . '/blueprints';
+
+        $locator->addPath('blueprints', '', [$theme_path]);
+        return static::recurseFolder('blueprints://');
+    }
+
     public static function recurseFolder($path, $extensions = 'md|yaml')
     {
         $lint_errors = [];

+ 6 - 1
system/src/Grav/Common/Language/Language.php

@@ -104,6 +104,11 @@ class Language
     public function getAvailable()
     {
         $languagesArray = $this->languages; //Make local copy
+
+        $languagesArray = array_map(function($value) {
+            return preg_quote($value);
+        }, $languagesArray);
+
         sort($languagesArray);
 
         return implode('|', array_reverse($languagesArray));
@@ -229,7 +234,7 @@ class Language
     }
 
     /**
-     * Get's a URL prefix based on configuration
+     * Get a URL prefix based on configuration
      *
      * @param string|null $lang
      * @return string

+ 13 - 3
system/src/Grav/Common/Markdown/Parsedown.php

@@ -10,6 +10,7 @@
 namespace Grav\Common\Markdown;
 
 use Grav\Common\Page\Interfaces\PageInterface;
+use Grav\Common\Page\Markdown\Excerpts;
 
 class Parsedown extends \Parsedown
 {
@@ -18,12 +19,21 @@ class Parsedown extends \Parsedown
     /**
      * Parsedown constructor.
      *
-     * @param PageInterface $page
+     * @param Excerpts|null $excerpts
      * @param array|null $defaults
      */
-    public function __construct($page, $defaults)
+    public function __construct($excerpts = null, $defaults = null)
     {
-        $this->init($page, $defaults);
+        if (!$excerpts || $excerpts instanceof PageInterface || null !== $defaults) {
+            // Deprecated in Grav 1.6.10
+            if ($defaults) {
+                $defaults = ['markdown' => $defaults];
+            }
+            $excerpts = new Excerpts($excerpts, $defaults);
+            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED);
+        }
+
+        $this->init($excerpts, $defaults);
     }
 
 }

+ 13 - 3
system/src/Grav/Common/Markdown/ParsedownExtra.php

@@ -10,6 +10,7 @@
 namespace Grav\Common\Markdown;
 
 use Grav\Common\Page\Interfaces\PageInterface;
+use Grav\Common\Page\Markdown\Excerpts;
 
 class ParsedownExtra extends \ParsedownExtra
 {
@@ -18,14 +19,23 @@ class ParsedownExtra extends \ParsedownExtra
     /**
      * ParsedownExtra constructor.
      *
-     * @param PageInterface $page
+     * @param Excerpts|null $excerpts
      * @param array|null $defaults
      * @throws \Exception
      */
-    public function __construct($page, $defaults)
+    public function __construct($excerpts = null, $defaults = null)
     {
+        if (!$excerpts || $excerpts instanceof PageInterface || null !== $defaults) {
+            // Deprecated in Grav 1.6.10
+            if ($defaults) {
+                $defaults = ['markdown' => $defaults];
+            }
+            $excerpts = new Excerpts($excerpts, $defaults);
+            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use new ' . __CLASS__ . '(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED);
+        }
+
         parent::__construct();
 
-        $this->init($page, $defaults);
+        $this->init($excerpts, $defaults);
     }
 }

+ 47 - 28
system/src/Grav/Common/Markdown/ParsedownGravTrait.php

@@ -9,15 +9,13 @@
 
 namespace Grav\Common\Markdown;
 
-use Grav\Common\Grav;
-use Grav\Common\Helpers\Excerpts;
+use Grav\Common\Page\Markdown\Excerpts;
 use Grav\Common\Page\Interfaces\PageInterface;
-use RocketTheme\Toolbox\Event\Event;
 
 trait ParsedownGravTrait
 {
-    /** @var PageInterface $page */
-    protected $page;
+    /** @var Excerpts */
+    protected $excerpts;
 
     protected $special_chars;
     protected $twig_link_regex = '/\!*\[(?:.*)\]\((\{([\{%#])\s*(.*?)\s*(?:\2|\})\})\)/';
@@ -28,28 +26,49 @@ trait ParsedownGravTrait
     /**
      * Initialization function to setup key variables needed by the MarkdownGravLinkTrait
      *
-     * @param PageInterface $page
+     * @param PageInterface|Excerpts|null $excerpts
      * @param array|null $defaults
      */
-    protected function init($page, $defaults)
+    protected function init($excerpts = null, $defaults = null)
     {
-        $grav = Grav::instance();
+        if (!$excerpts || $excerpts instanceof PageInterface) {
+            // Deprecated in Grav 1.6.10
+            if ($defaults) {
+                $defaults = ['markdown' => $defaults];
+            }
+            $this->excerpts = new Excerpts($excerpts, $defaults);
+            user_error(__CLASS__ . '::' . __FUNCTION__ . '($page, $defaults) is deprecated since Grav 1.6.10, use ->init(new ' . Excerpts::class . '($page, [\'markdown\' => $defaults])) instead.', E_USER_DEPRECATED);
+        } else {
+            $this->excerpts = $excerpts;
+        }
 
-        $this->page = $page;
-        $this->BlockTypes['{'] [] = 'TwigTag';
+        $this->BlockTypes['{'][] = 'TwigTag';
         $this->special_chars = ['>' => 'gt', '<' => 'lt', '"' => 'quot'];
 
-        if ($defaults === null) {
-            $defaults = (array)Grav::instance()['config']->get('system.pages.markdown');
-        }
+        $defaults = $this->excerpts->getConfig();
 
-        $this->setBreaksEnabled($defaults['auto_line_breaks']);
-        $this->setUrlsLinked($defaults['auto_url_links']);
-        $this->setMarkupEscaped($defaults['escape_markup']);
-        $this->setSpecialChars($defaults['special_chars']);
+        if (isset($defaults['markdown']['auto_line_breaks'])) {
+            $this->setBreaksEnabled($defaults['markdown']['auto_line_breaks']);
+        }
+        if (isset($defaults['markdown']['auto_url_links'])) {
+            $this->setUrlsLinked($defaults['markdown']['auto_url_links']);
+        }
+        if (isset($defaults['markdown']['escape_markup'])) {
+                $this->setMarkupEscaped($defaults['markdown']['escape_markup']);
+        }
+        if (isset($defaults['markdown']['special_chars'])) {
+            $this->setSpecialChars($defaults['markdown']['special_chars']);
+        }
 
-        $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $this, 'page' => $page]));
+        $this->excerpts->fireInitializedEvent($this);
+    }
 
+    /**
+     * @return Excerpts
+     */
+    public function getExcerpts()
+    {
+        return $this->excerpts;
     }
 
     /**
@@ -114,7 +133,8 @@ trait ParsedownGravTrait
      */
     protected function isBlockContinuable($Type)
     {
-        $continuable = \in_array($Type, $this->continuable_blocks) || method_exists($this, 'block' . $Type . 'Continue');
+        $continuable = \in_array($Type, $this->continuable_blocks, true)
+            || method_exists($this, 'block' . $Type . 'Continue');
 
         return $continuable;
     }
@@ -128,7 +148,8 @@ trait ParsedownGravTrait
      */
     protected function isBlockCompletable($Type)
     {
-        $completable = \in_array($Type, $this->completable_blocks) || method_exists($this, 'block' . $Type . 'Complete');
+        $completable = \in_array($Type, $this->completable_blocks, true)
+            || method_exists($this, 'block' . $Type . 'Complete');
 
         return $completable;
     }
@@ -210,7 +231,7 @@ trait ParsedownGravTrait
 
         // if this is an image process it
         if (isset($excerpt['element']['attributes']['src'])) {
-            $excerpt = Excerpts::processImageExcerpt($excerpt, $this->page);
+            $excerpt = $this->excerpts->processImageExcerpt($excerpt);
         }
 
         return $excerpt;
@@ -218,11 +239,7 @@ trait ParsedownGravTrait
 
     protected function inlineLink($excerpt)
     {
-        if (isset($excerpt['type'])) {
-            $type = $excerpt['type'];
-        } else {
-            $type = 'link';
-        }
+        $type = $excerpt['type'] ?? 'link';
 
         // do some trickery to get around Parsedown requirement for valid URL if its Twig in there
         if (preg_match($this->twig_link_regex, $excerpt['text'], $matches)) {
@@ -238,13 +255,15 @@ trait ParsedownGravTrait
 
         // if this is a link
         if (isset($excerpt['element']['attributes']['href'])) {
-            $excerpt = Excerpts::processLinkExcerpt($excerpt, $this->page, $type);
+            $excerpt = $this->excerpts->processLinkExcerpt($excerpt, $type);
         }
 
         return $excerpt;
     }
 
-    // For extending this class via plugins
+    /**
+     * For extending this class via plugins
+     */
     public function __call($method, $args)
     {
         if (isset($this->{$method}) === true) {

+ 329 - 0
system/src/Grav/Common/Page/Markdown/Excerpts.php

@@ -0,0 +1,329 @@
+<?php
+
+/**
+ * @package    Grav\Common\Page
+ *
+ * @copyright  Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\Page\Markdown;
+
+use Grav\Common\Grav;
+use Grav\Common\Page\Interfaces\PageInterface;
+use Grav\Common\Page\Medium\Link;
+use Grav\Common\Uri;
+use Grav\Common\Page\Medium\Medium;
+use Grav\Common\Utils;
+use RocketTheme\Toolbox\Event\Event;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
+
+class Excerpts
+{
+    /** @var PageInterface */
+    protected $page;
+    /** @var array */
+    protected $config;
+
+    public function __construct(PageInterface $page = null, array $config = null)
+    {
+        $this->page = $page ?? Grav::instance()['page'] ?? null;
+
+        // Add defaults to the configuration.
+        if (null === $config || !isset($config['markdown'], $config['images'])) {
+            $c = Grav::instance()['config'];
+            $config = $config ?? [];
+            $config += [
+                'markdown' => $c->get('system.pages.markdown', []),
+                'images' => $c->get('system.images', [])
+            ];
+        }
+
+        $this->config = $config;
+    }
+
+    public function getPage(): PageInterface
+    {
+        return $this->page;
+    }
+
+    public function getConfig(): array
+    {
+        return $this->config;
+    }
+
+    public function fireInitializedEvent($markdown): void
+    {
+        $grav = Grav::instance();
+
+        $grav->fireEvent('onMarkdownInitialized', new Event(['markdown' => $markdown, 'page' => $this->page]));
+    }
+
+    /**
+     * Process a Link excerpt
+     *
+     * @param array $excerpt
+     * @param string $type
+     * @return array
+     */
+    public function processLinkExcerpt(array $excerpt, string $type = 'link'): array
+    {
+        $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.
+        if (isset($url_parts['query'])) {
+            $actions = array_reduce(
+                explode('&', $url_parts['query']),
+                static function ($carry, $item) {
+                    $parts = explode('=', $item, 2);
+                    $value = isset($parts[1]) ? rawurldecode($parts[1]) : true;
+                    $carry[$parts[0]] = $value;
+
+                    return $carry;
+                },
+                []
+            );
+
+            // Valid attributes supported.
+            $valid_attributes = ['rel', 'target', 'id', 'class', 'classes'];
+
+            // Unless told to not process, go through actions.
+            if (array_key_exists('noprocess', $actions)) {
+                unset($actions['noprocess']);
+            } else {
+                // Loop through actions for the image and call them.
+                foreach ($actions as $attrib => $value) {
+                    $key = $attrib;
+
+                    if (in_array($attrib, $valid_attributes, true)) {
+                        // support both class and classes.
+                        if ($attrib === 'classes') {
+                            $attrib = 'class';
+                        }
+                        $excerpt['element']['attributes'][$attrib] = str_replace(',', ' ', $value);
+                        unset($actions[$key]);
+                    }
+                }
+            }
+
+            $url_parts['query'] = http_build_query($actions, null, '&', PHP_QUERY_RFC3986);
+        }
+
+        // If no query elements left, unset query.
+        if (empty($url_parts['query'])) {
+            unset ($url_parts['query']);
+        }
+
+        // Set path to / if not set.
+        if (empty($url_parts['path'])) {
+            $url_parts['path'] = '';
+        }
+
+        // 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']}");
+                unset($url_parts['stream'], $url_parts['scheme']);
+            }
+
+            $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);
+
+            return $excerpt;
+        }
+
+        // Handle paths and such.
+        $url_parts = Uri::convertUrl($this->page, $url_parts, $type);
+
+        // Build the URL from the component parts and set it on the element.
+        $excerpt['element']['attributes']['href'] = Uri::buildUrl($url_parts);
+
+        return $excerpt;
+    }
+
+    /**
+     * Process an image excerpt
+     *
+     * @param array $excerpt
+     * @return array
+     */
+    public function processImageExcerpt(array $excerpt): array
+    {
+        $url = htmlspecialchars_decode(urldecode($excerpt['element']['attributes']['src']));
+        $url_parts = $this->parseUrl($url);
+
+        $media = null;
+        $filename = null;
+
+        if (!empty($url_parts['stream'])) {
+            $filename = $url_parts['scheme'] . '://' . ($url_parts['path'] ?? '');
+
+            $media = $this->page->getMedia();
+
+        } else {
+            $grav = Grav::instance();
+
+            // File is also local if scheme is http(s) and host matches.
+            $local_file = isset($url_parts['path'])
+                && (empty($url_parts['scheme']) || in_array($url_parts['scheme'], ['http', 'https'], true))
+                && (empty($url_parts['host']) || $url_parts['host'] === $grav['uri']->host());
+
+            if ($local_file) {
+                $filename = basename($url_parts['path']);
+                $folder = dirname($url_parts['path']);
+
+                // Get the local path to page media if possible.
+                if ($this->page && $folder === $this->page->url(false, false, false)) {
+                    // Get the media objects for this page.
+                    $media = $this->page->getMedia();
+                } else {
+                    // see if this is an external page to this one
+                    $base_url = rtrim($grav['base_url_relative'] . $grav['pages']->base(), '/');
+                    $page_route = '/' . ltrim(str_replace($base_url, '', $folder), '/');
+
+                    /** @var PageInterface $ext_page */
+                    $ext_page = $grav['pages']->dispatch($page_route, true);
+                    if ($ext_page) {
+                        $media = $ext_page->getMedia();
+                    } else {
+                        $grav->fireEvent('onMediaLocate', new Event(['route' => $page_route, 'media' => &$media]));
+                    }
+                }
+            }
+        }
+
+        // If there is a media file that matches the path referenced..
+        if ($media && $filename && isset($media[$filename])) {
+            // Get the medium object.
+            /** @var Medium $medium */
+            $medium = $media[$filename];
+
+            // Process operations
+            $medium = $this->processMediaActions($medium, $url_parts);
+            $element_excerpt = $excerpt['element']['attributes'];
+
+            $alt = $element_excerpt['alt'] ?? '';
+            $title = $element_excerpt['title'] ?? '';
+            $class = $element_excerpt['class'] ?? '';
+            $id = $element_excerpt['id'] ?? '';
+
+            $excerpt['element'] = $medium->parsedownElement($title, $alt, $class, $id, true);
+
+        } else {
+            // Not a current page media file, see if it needs converting to relative.
+            $excerpt['element']['attributes']['src'] = Uri::buildUrl($url_parts);
+        }
+
+        return $excerpt;
+    }
+
+    /**
+     * Process media actions
+     *
+     * @param Medium $medium
+     * @param string|array $url
+     * @return Medium|Link
+     */
+    public function processMediaActions($medium, $url)
+    {
+        $url_parts = is_string($url) ? $this->parseUrl($url) : $url;
+        $actions = [];
+
+        // if there is a query, then parse it and build action calls
+        if (isset($url_parts['query'])) {
+            $actions = array_reduce(
+                explode('&', $url_parts['query']),
+                static function ($carry, $item) {
+                    $parts = explode('=', $item, 2);
+                    $value = $parts[1] ?? null;
+                    $carry[] = ['method' => $parts[0], 'params' => $value];
+
+                    return $carry;
+                },
+                []
+            );
+        }
+
+        $config = $this->getConfig();
+        if (!empty($config['images']['auto_fix_orientation'])) {
+            $actions[] = ['method' => 'fixOrientation', 'params' => ''];
+        }
+
+        $defaults = $config['images']['defaults'] ?? [];
+        if (count($defaults)) {
+            foreach ($defaults as $method => $params) {
+                $actions[] = [
+                    'method' => $method,
+                    'params' => $params,
+                ];
+            }
+        }
+
+        // loop through actions for the image and call them
+        foreach ($actions as $action) {
+            $matches = [];
+
+            if (preg_match('/\[(.*)\]/', $action['params'], $matches)) {
+                $args = [explode(',', $matches[1])];
+            } else {
+                $args = explode(',', $action['params']);
+            }
+
+            $medium = call_user_func_array([$medium, $action['method']], $args);
+        }
+
+        if (isset($url_parts['fragment'])) {
+            $medium->urlHash($url_parts['fragment']);
+        }
+
+        return $medium;
+    }
+
+    /**
+     * Variation of parse_url() which works also with local streams.
+     *
+     * @param string $url
+     * @return array|bool
+     */
+    protected function parseUrl(string $url)
+    {
+        $url_parts = Utils::multibyteParseUrl($url);
+
+        if (isset($url_parts['scheme'])) {
+            /** @var UniformResourceLocator $locator */
+            $locator = Grav::instance()['locator'];
+
+            // Special handling for the streams.
+            if ($locator->schemeExists($url_parts['scheme'])) {
+                if (isset($url_parts['host'])) {
+                    // Merge host and path into a path.
+                    $url_parts['path'] = $url_parts['host'] . (isset($url_parts['path']) ? '/' . $url_parts['path'] : '');
+                    unset($url_parts['host']);
+                }
+
+                $url_parts['stream'] = true;
+            }
+        }
+
+        return $url_parts;
+    }
+
+    /**
+     * @param string $url
+     * @return bool|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;
+    }
+}

+ 3 - 0
system/src/Grav/Common/Page/Medium/AbstractMedia.php

@@ -154,6 +154,9 @@ abstract class AbstractMedia implements ExportInterface, MediaCollectionInterfac
      */
     public function add($name, $file)
     {
+        if (!$file) {
+            return;
+        }
         $this->offsetSet($name, $file);
         switch ($file->type) {
             case 'image':

+ 3 - 3
system/src/Grav/Common/Page/Medium/ImageMedium.php

@@ -170,8 +170,7 @@ class ImageMedium extends Medium
     {
         /** @var UniformResourceLocator $locator */
         $locator = Grav::instance()['locator'];
-        $image_path = $locator->findResource('cache://images', true);
-        $image_dir = $locator->findResource('cache://images', false);
+        $image_path = $locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true);
         $saved_image_path = $this->saveImage();
 
         $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path);
@@ -181,6 +180,7 @@ class ImageMedium extends Medium
         }
 
         if (Utils::startsWith($output, $image_path)) {
+            $image_dir = $locator->findResource('cache://images', false);
             $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output);
         }
 
@@ -232,7 +232,7 @@ class ImageMedium extends Medium
     }
 
     /**
-     * Allows the ability to override the Inmage's Pretty name stored in cache
+     * Allows the ability to override the image's pretty name stored in cache
      *
      * @param string $name
      */

+ 2 - 1
system/src/Grav/Common/Page/Medium/ParsedownHtmlTrait.php

@@ -10,6 +10,7 @@
 namespace Grav\Common\Page\Medium;
 
 use Grav\Common\Markdown\Parsedown;
+use Grav\Common\Page\Markdown\Excerpts;
 
 trait ParsedownHtmlTrait
 {
@@ -33,7 +34,7 @@ trait ParsedownHtmlTrait
         $element = $this->parsedownElement($title, $alt, $class, $id, $reset);
 
         if (!$this->parsedown) {
-            $this->parsedown = new Parsedown(null, null);
+            $this->parsedown = new Parsedown(new Excerpts());
         }
 
         return $this->parsedown->elementToHtml($element);

+ 22 - 14
system/src/Grav/Common/Page/Page.php

@@ -19,6 +19,7 @@ use Grav\Common\Markdown\Parsedown;
 use Grav\Common\Markdown\ParsedownExtra;
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Media\Traits\MediaTrait;
+use Grav\Common\Page\Markdown\Excerpts;
 use Grav\Common\Taxonomy;
 use Grav\Common\Uri;
 use Grav\Common\Utils;
@@ -27,7 +28,6 @@ use Negotiation\Accept;
 use Negotiation\Negotiator;
 use RocketTheme\Toolbox\Event\Event;
 use RocketTheme\Toolbox\File\MarkdownFile;
-use Symfony\Component\Yaml\Exception\ParseException;
 
 define('PAGE_ORDER_PREFIX_REGEX', '/^[0-9]+\./u');
 
@@ -819,23 +819,31 @@ class Page implements PageInterface
         /** @var Config $config */
         $config = Grav::instance()['config'];
 
-        $defaults = (array)$config->get('system.pages.markdown');
+        $markdownDefaults = (array)$config->get('system.pages.markdown');
         if (isset($this->header()->markdown)) {
-            $defaults = array_merge($defaults, $this->header()->markdown);
+            $markdownDefaults = array_merge($markdownDefaults, $this->header()->markdown);
         }
 
         // pages.markdown_extra is deprecated, but still check it...
-        if (!isset($defaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) {
+        if (!isset($markdownDefaults['extra']) && (isset($this->markdown_extra) || $config->get('system.pages.markdown_extra') !== null)) {
             user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED);
 
-            $defaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra');
+            $markdownDefaults['extra'] = $this->markdown_extra ?: $config->get('system.pages.markdown_extra');
         }
 
+        $extra = $markdownDefaults['extra'] ?? false;
+        $defaults = [
+            'markdown' => $markdownDefaults,
+            'images' => $config->get('system.images', [])
+        ];
+
+        $excerpts = new Excerpts($this, $defaults);
+
         // Initialize the preferred variant of Parsedown
-        if ($defaults['extra']) {
-            $parsedown = new ParsedownExtra($this, $defaults);
+        if ($extra) {
+            $parsedown = new ParsedownExtra($excerpts);
         } else {
-            $parsedown = new Parsedown($this, $defaults);
+            $parsedown = new Parsedown($excerpts);
         }
 
         $this->content = $parsedown->text($this->content);
@@ -1397,12 +1405,12 @@ class Page implements PageInterface
             return $this->template_format;
         }
 
-        // Use content negotitation via the `accept:` header
-        $http_accept = $_SERVER['HTTP_ACCEPT'] ?? false;
+        // Use content negotiation via the `accept:` header
+        $http_accept = $_SERVER['HTTP_ACCEPT'] ?? null;
         if (is_string($http_accept)) {
             $negotiator = new Negotiator();
 
-            $supported_types = Grav::instance()['config']->get('system.pages.types', ['html', 'json']);
+            $supported_types = Utils::getSupportPageTypes(['html', 'json']);
             $priorities = Utils::getMimeTypes($supported_types);
 
             $media_type = $negotiator->getBest($http_accept, $priorities);
@@ -2847,9 +2855,9 @@ class Page implements PageInterface
             $result = [];
             foreach ((array)$value as $key => $val) {
                 if (is_int($key)) {
-                    $result = $result + $this->evaluate($val)->toArray();
+                    $result = $result + $this->evaluate($val, $only_published)->toArray();
                 } else {
-                    $result = $result + $this->evaluate([$key => $val])->toArray();
+                    $result = $result + $this->evaluate([$key => $val], $only_published)->toArray();
                 }
 
             }
@@ -2930,7 +2938,7 @@ class Page implements PageInterface
                         case 'page':
                         case 'self':
                             $results = new Collection();
-                            $results = $results->addPage($page)->nonModular();
+                            $results = $results->addPage($page);
                             break;
 
                         case 'descendants':

+ 21 - 3
system/src/Grav/Common/Page/Pages.php

@@ -88,6 +88,13 @@ class Pages
      */
     protected $ignore_hidden;
 
+    /** @var string */
+    protected $check_method;
+
+    protected $pages_cache_id;
+
+    protected $initialized = false;
+
     /**
      * @var Types
      */
@@ -98,8 +105,6 @@ class Pages
      */
     static protected $home_route;
 
-    protected $pages_cache_id;
-
     /**
      * Constructor
      *
@@ -226,11 +231,20 @@ class Pages
         return $this->baseUrl($lang, $absolute) . Uri::filterPath($route);
     }
 
+    public function setCheckMethod($method)
+    {
+        $this->check_method = strtolower($method);
+    }
+
     /**
      * Class initialization. Must be called before using this class.
      */
     public function init()
     {
+        if ($this->initialized) {
+            return;
+        }
+
         $config = $this->grav['config'];
         $this->ignore_files = $config->get('system.pages.ignore_files');
         $this->ignore_folders = $config->get('system.pages.ignore_folders');
@@ -240,6 +254,10 @@ class Pages
         $this->children = [];
         $this->routes = [];
 
+        if (!$this->check_method) {
+            $this->setCheckMethod($config->get('system.cache.check.method', 'file'));
+        }
+
         $this->buildPages();
     }
 
@@ -947,7 +965,7 @@ class Pages
             $taxonomy = $this->grav['taxonomy'];
 
             // how should we check for last modified? Default is by file
-            switch (strtolower($config->get('system.cache.check.method', 'file'))) {
+            switch ($this->check_method) {
                 case 'none':
                 case 'off':
                     $hash = 0;

+ 19 - 2
system/src/Grav/Common/Plugin.php

@@ -151,15 +151,32 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess
             if (\is_string($params)) {
                 $dispatcher->addListener($eventName, [$this, $params]);
             } elseif (\is_string($params[0])) {
-                $dispatcher->addListener($eventName, [$this, $params[0]], $params[1] ?? 0);
+                $dispatcher->addListener($eventName, [$this, $params[0]], $this->getPriority($params, $eventName));
             } else {
                 foreach ($params as $listener) {
-                    $dispatcher->addListener($eventName, [$this, $listener[0]], $listener[1] ?? 0);
+                    $dispatcher->addListener($eventName, [$this, $listener[0]], $this->getPriority($listener, $eventName));
                 }
             }
         }
     }
 
+    /**
+     * @param array  $params
+     * @param string $eventName
+     */
+    private function getPriority($params, $eventName)
+    {
+        $grav = Grav::instance();
+        $override = implode('.', ["priorities", $this->name, $eventName, $params[0]]);
+        if ($grav['config']->get($override) !== null)
+        {
+            return $grav['config']->get($override);
+        } elseif (isset($params[1])) {
+            return $params[1];
+        }
+        return 0;
+    }
+
     /**
      * @param array $events
      */

+ 34 - 14
system/src/Grav/Common/Plugins.php

@@ -133,12 +133,25 @@ class Plugins extends Iterator
      */
     public static function all()
     {
-        $plugins = Grav::instance()['plugins'];
+        $grav = Grav::instance();
+        $plugins = $grav['plugins'];
         $list = [];
 
         foreach ($plugins as $instance) {
             $name = $instance->name;
-            $result = self::get($name);
+
+            try {
+                $result = self::get($name);
+            } catch (\Exception $e) {
+                $exception = new \RuntimeException(sprintf('Plugin %s: %s', $name, $e->getMessage()), $e->getCode(), $e);
+
+                /** @var Debugger $debugger */
+                $debugger = $grav['debugger'];
+                $debugger->addMessage("Plugin {$name} cannot be loaded, please check Exceptions tab", 'error');
+                $debugger->addException($exception);
+
+                continue;
+            }
 
             if ($result) {
                 $list[$name] = $result;
@@ -185,24 +198,31 @@ class Plugins extends Iterator
         $grav = Grav::instance();
         $locator = $grav['locator'];
 
-        $filePath = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT);
-        if (!is_file($filePath)) {
+        $file = $locator->findResource('plugins://' . $name . DS . $name . PLUGIN_EXT);
+
+        if (is_file($file)) {
+            // Local variables available in the file: $grav, $config, $name, $file
+            $class = include_once $file;
+
+            $pluginClassFormat = [
+                'Grav\\Plugin\\' . ucfirst($name). 'Plugin',
+                'Grav\\Plugin\\' . Inflector::camelize($name) . 'Plugin'
+            ];
+
+            foreach ($pluginClassFormat as $pluginClass) {
+                if (class_exists($pluginClass)) {
+                    $class = new $pluginClass($name, $grav);
+                    break;
+                }
+            }
+        } else {
             $grav['log']->addWarning(
                 sprintf("Plugin '%s' enabled but not found! Try clearing cache with `bin/grav clear-cache`", $name)
             );
             return null;
         }
 
-        require_once $filePath;
-
-        $pluginClassName = 'Grav\\Plugin\\' . ucfirst($name) . 'Plugin';
-        if (!class_exists($pluginClassName)) {
-            $pluginClassName = 'Grav\\Plugin\\' . $grav['inflector']->camelize($name) . 'Plugin';
-            if (!class_exists($pluginClassName)) {
-                throw new \RuntimeException(sprintf("Plugin '%s' class not found! Try reinstalling this plugin.", $name));
-            }
-        }
-        return new $pluginClassName($name, $grav);
+        return $class;
     }
 
 }

+ 4 - 1
system/src/Grav/Common/Processors/RequestProcessor.php

@@ -30,10 +30,13 @@ class RequestProcessor extends ProcessorBase
             $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true));
         }
 
+        $uri = $request->getUri();
+        $ext = mb_strtolower(pathinfo($uri->getPath(), PATHINFO_EXTENSION));
+
         $request = $request
             ->withAttribute('grav', $this->container)
             ->withAttribute('time', $_SERVER['REQUEST_TIME_FLOAT'] ?? GRAV_REQUEST_TIME)
-            ->withAttribute('route', Uri::getCurrentRoute())
+            ->withAttribute('route', Uri::getCurrentRoute()->withExtension($ext))
             ->withAttribute('referrer', $this->container['uri']->referrer());
 
         $event = new RequestHandlerEvent(['request' => $request, 'handler' => $handler]);

+ 10 - 5
system/src/Grav/Common/Service/AccountsServiceProvider.php

@@ -27,9 +27,10 @@ class AccountsServiceProvider implements ServiceProviderInterface
     public function register(Container $container)
     {
         $container['accounts'] = function (Container $container) {
-            /** @var Debugger $debugger */
-            $debugger = $container['debugger'];
-            if ($container['config']->get('system.accounts.type') === 'flex') {
+            $type = strtolower(defined('GRAV_USER_INSTANCE') ? GRAV_USER_INSTANCE : $container['config']->get('system.accounts.type', 'data'));
+            if ($type === 'flex') {
+                /** @var Debugger $debugger */
+                $debugger = $container['debugger'];
                 $debugger->addMessage('User Accounts: Flex Directory');
                 return $this->flexAccounts($container);
             }
@@ -46,7 +47,9 @@ class AccountsServiceProvider implements ServiceProviderInterface
 
     protected function dataAccounts(Container $container)
     {
-        define('GRAV_USER_INSTANCE', 'DATA');
+        if (!defined('GRAV_USER_INSTANCE')) {
+            define('GRAV_USER_INSTANCE', 'DATA');
+        }
 
         // Use User class for backwards compatibility.
         return new DataUser\UserCollection(User::class);
@@ -54,7 +57,9 @@ class AccountsServiceProvider implements ServiceProviderInterface
 
     protected function flexAccounts(Container $container)
     {
-        define('GRAV_USER_INSTANCE', 'FLEX');
+        if (!defined('GRAV_USER_INSTANCE')) {
+            define('GRAV_USER_INSTANCE', 'FLEX');
+        }
 
         /** @var Config $config */
         $config = $container['config'];

+ 3 - 3
system/src/Grav/Common/Service/RequestServiceProvider.php

@@ -31,8 +31,8 @@ class RequestServiceProvider implements ServiceProviderInterface
             return $creator->fromGlobals();
         };
 
-        $container['route'] = function() {
-            return Uri::getCurrentRoute();
-        };
+        $container['route'] = $container->factory(function() {
+            return clone Uri::getCurrentRoute();
+        });
     }
 }

+ 7 - 3
system/src/Grav/Common/Service/SessionServiceProvider.php

@@ -13,6 +13,7 @@ use Grav\Common\Config\Config;
 use Grav\Common\Debugger;
 use Grav\Common\Session;
 use Grav\Common\Uri;
+use Grav\Common\Utils;
 use Pimple\Container;
 use Pimple\ServiceProviderInterface;
 use RocketTheme\Toolbox\Session\Message;
@@ -49,14 +50,17 @@ class SessionServiceProvider implements ServiceProviderInterface
             // Activate admin if we're inside the admin path.
             $is_admin = false;
             if ($config->get('plugins.admin.enabled')) {
-                $base = '/' . trim($config->get('plugins.admin.route'), '/');
+                $admin_base = '/' . trim($config->get('plugins.admin.route'), '/');
 
                 // Uri::route() is not processed yet, let's quickly get what we need.
                 $current_route = str_replace(Uri::filterPath($uri->rootUrl(false)), '', parse_url($uri->url(true), PHP_URL_PATH));
 
+                // Test to see if path starts with a supported language + admin base
+                $lang = Utils::pathPrefixedByLangCode($current_route);
+                $lang_admin_base = '/' . $lang . $admin_base;
+
                 // Check no language, simple language prefix (en) and region specific language prefix (en-US).
-                $pos = strpos($current_route, $base);
-                if ($pos === 0 || $pos === 3 || $pos === 6) {
+                if (Utils::startsWith($current_route, $admin_base) || Utils::startsWith($current_route, $lang_admin_base)) {
                     $cookie_lifetime = $config->get('plugins.admin.session.timeout', 1800);
                     $enabled = $is_admin = true;
                 }

+ 14 - 3
system/src/Grav/Common/Themes.php

@@ -100,7 +100,19 @@ class Themes extends Iterator
             }
 
             $theme = $directory->getFilename();
-            $result = $this->get($theme);
+
+            try {
+                $result = $this->get($theme);
+            } catch (\Exception $e) {
+                $exception = new \RuntimeException(sprintf('Theme %s: %s', $theme, $e->getMessage()), $e->getCode(), $e);
+
+                /** @var Debugger $debugger */
+                $debugger = $this->grav['debugger'];
+                $debugger->addMessage("Theme {$theme} cannot be loaded, please check Exceptions tab", 'error');
+                $debugger->addException($exception);
+
+                continue;
+            }
 
             if ($result) {
                 $list[$theme] = $result;
@@ -196,8 +208,7 @@ class Themes extends Iterator
 
                 foreach ($themeClassFormat as $themeClass) {
                     if (class_exists($themeClass)) {
-                        $themeClassName = $themeClass;
-                        $class = new $themeClassName($grav, $config, $name);
+                        $class = new $themeClass($grav, $config, $name);
                         break;
                     }
                 }

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

@@ -41,6 +41,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($content);' . PHP_EOL);
+            ->write('echo $this->env->getExtension(\'Grav\Common\Twig\TwigExtension\')->markdownFunction($context, $content);' . PHP_EOL);
     }
 }

+ 11 - 9
system/src/Grav/Common/Twig/TwigExtension.php

@@ -84,7 +84,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
             new \Twig_SimpleFilter('fieldName', [$this, 'fieldNameFilter']),
             new \Twig_SimpleFilter('ksort', [$this, 'ksortFilter']),
             new \Twig_SimpleFilter('ltrim', [$this, 'ltrimFilter']),
-            new \Twig_SimpleFilter('markdown', [$this, 'markdownFunction'], ['is_safe' => ['html']]),
+            new \Twig_SimpleFilter('markdown', [$this, 'markdownFunction'], ['needs_context' => true, 'is_safe' => ['html']]),
             new \Twig_SimpleFilter('md5', [$this, 'md5Filter']),
             new \Twig_SimpleFilter('base32_encode', [$this, 'base32EncodeFilter']),
             new \Twig_SimpleFilter('base32_decode', [$this, 'base32DecodeFilter']),
@@ -455,7 +455,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
     }
 
     /**
-     * Gets a human readable output for cron sytnax
+     * Gets a human readable output for cron syntax
      *
      * @param $at
      * @return string
@@ -613,12 +613,14 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
     /**
      * @param string $string
      *
+     * @param array $context
      * @param bool $block  Block or Line processing
      * @return mixed|string
      */
-    public function markdownFunction($string, $block = true)
+    public function markdownFunction($context, $string, $block = true)
     {
-        return Utils::processMarkdown($string, $block);
+        $page = $context['page'] ?? null;
+        return Utils::processMarkdown($string, $block, $page);
     }
 
     /**
@@ -1004,10 +1006,10 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
      */
     public function authorize($action)
     {
-        /** @var UserInterface $user */
-        $user = $this->grav['user'];
+        /** @var UserInterface|null $user */
+        $user = $this->grav['user'] ?? null;
 
-        if (!$user->authenticated || (isset($user->authorized) && !$user->authorized)) {
+        if (!$user || !$user->authenticated || (isset($user->authorized) && !$user->authorized)) {
             return false;
         }
 
@@ -1136,7 +1138,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
     }
 
     /**
-     * Get's the Exif data for a file
+     * Get the Exif data for a file
      *
      * @param string $image
      * @param bool $raw
@@ -1154,7 +1156,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
 
             $exif_reader = $this->grav['exif']->getReader();
 
-            if ($image & file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) {
+            if ($image && file_exists($image) && $this->config->get('system.media.auto_metadata_exif') && $exif_reader) {
 
                 $exif_data = $exif_reader->read($image);
 

+ 3 - 3
system/src/Grav/Common/Uri.php

@@ -1154,7 +1154,7 @@ class Uri
             $this->scheme = $env['X-FORWARDED-PROTO'];
         } elseif (isset($env['HTTP_CLOUDFRONT_FORWARDED_PROTO'])) {
             $this->scheme = $env['HTTP_CLOUDFRONT_FORWARDED_PROTO'];
-        } elseif (isset($env['REQUEST_SCHEME'])) {
+        } elseif (isset($env['REQUEST_SCHEME']) && empty($env['HTTPS'])) {
            $this->scheme = $env['REQUEST_SCHEME'];
         } else {
             $https = $env['HTTPS'] ?? '';
@@ -1286,7 +1286,7 @@ class Uri
     }
 
     /**
-     * Get's post from either $_POST or JSON response object
+     * Get post from either $_POST or JSON response object
      * By default returns all data, or can return a single item
      *
      * @param string $element
@@ -1345,7 +1345,7 @@ class Uri
      */
     public function isValidExtension($extension)
     {
-        $valid_page_types = implode('|', Grav::instance()['config']->get('system.pages.types'));
+        $valid_page_types = implode('|', Utils::getSupportPageTypes());
 
         // Strip the file extension for valid page types
         if (preg_match('/(' . $valid_page_types . ')/', $extension)) {

+ 9 - 0
system/src/Grav/Common/User/DataUser/UserCollection.php

@@ -118,4 +118,13 @@ class UserCollection implements UserCollectionInterface
 
         return $file_path && unlink($file_path);
     }
+
+    public function count(): int
+    {
+        // check for existence of a user account
+        $account_dir = $file_path = Grav::instance()['locator']->findResource('account://');
+        $accounts = glob($account_dir . '/*.yaml') ?: [];
+
+        return count($accounts);
+    }
 }

+ 50 - 0
system/src/Grav/Common/User/FlexUser/User.php

@@ -9,6 +9,7 @@
 
 namespace Grav\Common\User\FlexUser;
 
+use Grav\Common\Data\Blueprint;
 use Grav\Common\Grav;
 use Grav\Common\Media\Interfaces\MediaCollectionInterface;
 use Grav\Common\Page\Media;
@@ -21,6 +22,7 @@ use Grav\Framework\File\Formatter\JsonFormatter;
 use Grav\Framework\File\Formatter\YamlFormatter;
 use Grav\Framework\Flex\FlexDirectory;
 use Grav\Framework\Flex\FlexObject;
+use Grav\Framework\Flex\Storage\FileStorage;
 use Grav\Framework\Flex\Traits\FlexAuthorizeTrait;
 use Grav\Framework\Flex\Traits\FlexMediaTrait;
 use Grav\Framework\Form\FormFlashFile;
@@ -381,6 +383,31 @@ class User extends FlexObject implements UserInterface, MediaManipulationInterfa
         return $this->getBlueprint()->extra($this->toArray());
     }
 
+    /**
+     * @param string $name
+     * @return Blueprint
+     */
+    public function getBlueprint(string $name = '')
+    {
+        $blueprint = clone parent::getBlueprint($name);
+
+        $blueprint->addDynamicHandler('flex', function (array &$field, $property, array &$call) {
+            $params = (array)$call['params'];
+            $method = array_shift($params);
+
+            if (method_exists($this, $method)) {
+                $value = $this->{$method}(...$params);
+                if (\is_array($value) && isset($field[$property]) && \is_array($field[$property])) {
+                    $field[$property] = array_merge_recursive($field[$property], $value);
+                } else {
+                    $field[$property] = $value;
+                }
+            }
+        });
+
+        return $blueprint->init();
+    }
+
     /**
      * Return unmodified data as raw string.
      *
@@ -420,6 +447,15 @@ class User extends FlexObject implements UserInterface, MediaManipulationInterfa
      */
     public function save()
     {
+        // TODO: We may want to handle this in the storage layer in the future.
+        $key = $this->getStorageKey();
+        if (!$key || strpos($key, '@@')) {
+            $storage = $this->getFlexDirectory()->getStorage();
+            if ($storage instanceof FileStorage) {
+                $this->setStorageKey($this->getKey());
+            }
+        }
+
         $password = $this->getProperty('password');
         if (null !== $password) {
             $this->unsetProperty('password');
@@ -431,6 +467,20 @@ class User extends FlexObject implements UserInterface, MediaManipulationInterfa
         return parent::save();
     }
 
+    public function isAuthorized(string $action, string $scope = null, UserInterface $user = null): bool
+    {
+        if (null === $user) {
+            /** @var UserInterface $user */
+            $user = Grav::instance()['user'] ?? null;
+        }
+
+        if ($user instanceof User && $user->getStorageKey() === $this->getStorageKey()) {
+            return true;
+        }
+
+        return parent::isAuthorized($action, $scope, $user);
+    }
+
     /**
      * @return array
      */

+ 1 - 1
system/src/Grav/Common/User/Interfaces/UserCollectionInterface.php

@@ -9,7 +9,7 @@
 
 namespace Grav\Common\User\Interfaces;
 
-interface UserCollectionInterface
+interface UserCollectionInterface extends \Countable
 {
     /**
      * Load user account.

+ 6 - 5
system/src/Grav/Common/User/Traits/UserTrait.php

@@ -148,12 +148,13 @@ trait UserTrait
 
         // Try looking for provider.
         $provider = $this->get('provider');
-        if (\is_array($provider)) {
-            if (isset($provider['avatar_url']) && \is_string($provider['avatar_url'])) {
-                return $provider['avatar_url'];
+        $provider_options = $this->get($provider);
+        if (\is_array($provider_options)) {
+            if (isset($provider_options['avatar_url']) && \is_string($provider_options['avatar_url'])) {
+                return $provider_options['avatar_url'];
             }
-            if (isset($provider['avatar']) && \is_string($provider['avatar'])) {
-                return $provider['avatar'];
+            if (isset($provider_options['avatar']) && \is_string($provider_options['avatar'])) {
+                return $provider_options['avatar'];
             }
         }
 

+ 154 - 47
system/src/Grav/Common/Utils.php

@@ -13,6 +13,7 @@ use Grav\Common\Helpers\Truncator;
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Markdown\Parsedown;
 use Grav\Common\Markdown\ParsedownExtra;
+use Grav\Common\Page\Markdown\Excerpts;
 use RocketTheme\Toolbox\Event\Event;
 use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 
@@ -27,55 +28,103 @@ abstract class Utils
     /**
      * Simple helper method to make getting a Grav URL easier
      *
-     * @param string $input
+     * @param string|object $input
      * @param bool $domain
+     * @param bool $fail_gracefully
      * @return bool|null|string
      */
-    public static function url($input, $domain = false)
+    public static function url($input, $domain = false, $fail_gracefully = false)
     {
-        if (!trim((string)$input)) {
-            $input = '/';
+        if ((!is_string($input) && !method_exists($input, '__toString')) || !trim($input)) {
+            if ($fail_gracefully) {
+                $input = '/';
+            } else {
+                return false;
+            }
         }
 
-        if (Grav::instance()['config']->get('system.absolute_urls', false)) {
-            $domain = true;
-        }
+        $input = (string)$input;
 
-        if (Grav::instance()['uri']->isExternal($input)) {
+        if (Uri::isExternal($input)) {
             return $input;
         }
 
-        /** @var Uri $uri */
-        $uri = Grav::instance()['uri'];
-
-        $root = $uri->rootUrl();
-        $input = Utils::replaceFirstOccurrence($root, '', $input);
+        $grav = Grav::instance();
 
-        $input = ltrim((string)$input, '/');
+        /** @var Uri $uri */
+        $uri = $grav['uri'];
 
-        if (Utils::contains((string)$input, '://')) {
+        if (static::contains((string)$input, '://')) {
             /** @var UniformResourceLocator $locator */
-            $locator = Grav::instance()['locator'];
+            $locator = $grav['locator'];
 
             $parts = Uri::parseUrl($input);
 
-            if ($parts) {
-                $resource = $locator->findResource("{$parts['scheme']}://{$parts['host']}{$parts['path']}", false);
+            if (is_array($parts)) {
+                // Make sure we always have scheme, host, port and path.
+                $scheme = $parts['scheme'] ?? '';
+                $host = $parts['host'] ?? '';
+                $port = $parts['port'] ?? '';
+                $path = $parts['path'] ?? '';
+
+                if ($scheme && !$port) {
+                    // If URL has a scheme, we need to check if it's one of Grav streams.
+                    if (!$locator->schemeExists($scheme)) {
+                        // If scheme does not exists as a stream, assume it's external.
+                        return str_replace(' ', '%20', $input);
+                    }
+
+                    // Attempt to find the resource (because of parse_url() we need to put host back to path).
+                    $resource = $locator->findResource("{$scheme}://{$host}{$path}", false);
 
-                if (isset($parts['query'])) {
-                    $resource = $resource . '?' . $parts['query'];
+                    if ($resource === false) {
+                        if (!$fail_gracefully) {
+                            return false;
+                        }
+
+                        // Return location where the file would be if it was saved.
+                        $resource = $locator->findResource("{$scheme}://{$host}{$path}", false, true);
+                    }
+
+                } elseif ($host || $port) {
+                    // If URL doesn't have scheme but has host or port, it is external.
+                    return str_replace(' ', '%20', $input);
+                }
+
+                if (!empty($resource)) {
+                    // Add query string back.
+                    if (isset($parts['query'])) {
+                        $resource .= '?' . $parts['query'];
+                    }
+
+                    // Add fragment back.
+                    if (isset($parts['fragment'])) {
+                        $resource .= '#' . $parts['fragment'];
+                    }
                 }
+
             } else {
                 // Not a valid URL (can still be a stream).
                 $resource = $locator->findResource($input, false);
             }
 
-
         } else {
+            $root = $uri->rootUrl();
+
+            if (static::startsWith($input, $root)) {
+                $input = static::replaceFirstOccurrence($root, '', $input);
+            }
+
+            $input = ltrim($input, '/');
+
             $resource = $input;
         }
 
+        if (!$fail_gracefully && $resource === false) {
+            return false;
+        }
 
+        $domain = $domain ?: $grav['config']->get('system.absolute_urls', false);
 
         return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? '');
     }
@@ -274,6 +323,35 @@ abstract class Utils
         return (object)array_merge((array)$obj1, (array)$obj2);
     }
 
+    /**
+     * Lowercase an entire array. Useful when combined with `in_array()`
+     *
+     * @param array $a
+     * @return array|false
+     */
+    public static function arrayLower(Array $a)
+    {
+        return array_map('mb_strtolower', $a);
+    }
+
+    /**
+     * Simple function to remove item/s in an array by value
+     *
+     * @param $search array
+     * @param $value string|array
+     * @return array
+     */
+    public static function arrayRemoveValue(Array $search, $value)
+    {
+        foreach ((array) $value as $val) {
+            $key = array_search($val, $search);
+            if ($key !== false) {
+                unset($search[$key]);
+            }
+        }
+        return $search;
+    }
+
     /**
      * Recursive Merge with uniqueness
      *
@@ -974,17 +1052,19 @@ abstract class Utils
      *
      * @param string $string The path
      *
-     * @return bool
+     * @return bool|string Either false or the language
+     *
      */
     public static function pathPrefixedByLangCode($string)
     {
-        if (strlen($string) <= 3) {
-            return false;
-        }
-
         $languages_enabled = Grav::instance()['config']->get('system.languages.supported', []);
+        $parts = explode('/', trim($string, '/'));
+
+        if (count($parts) > 0 && in_array($parts[0], $languages_enabled)) {
+            return $parts[0];
+        }
 
-        return $string[0] === '/' && $string[3] === '/' && \in_array(substr($string, 1, 2), $languages_enabled, true);
+        return false;
     }
 
     /**
@@ -1054,12 +1134,9 @@ abstract class Utils
      */
     private static function generateNonceString($action, $previousTick = false)
     {
-        $username = '';
-        if (isset(Grav::instance()['user'])) {
-            $user = Grav::instance()['user'];
-            $username = $user->username;
-        }
+        $grav = Grav::instance();
 
+        $username = isset($grav['user']) ? $grav['user']->username : '';
         $token = session_id();
         $i = self::nonceTick();
 
@@ -1067,7 +1144,7 @@ abstract class Utils
             $i--;
         }
 
-        return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . Grav::instance()['config']->get('security.salt'));
+        return ($i . '|' . $action . '|' . $username . '|' . $token . '|' . $grav['config']->get('security.salt'));
     }
 
     /**
@@ -1281,7 +1358,7 @@ abstract class Utils
     }
 
     /**
-     * Get's path based on a token
+     * Get path based on a token
      *
      * @param string $path
      * @param PageInterface|null $page
@@ -1341,6 +1418,8 @@ abstract class Utils
             $post_max_size = static::parseSize(ini_get('post_max_size'));
             if ($post_max_size > 0) {
                 $max_size = $post_max_size;
+            } else {
+                $max_size = 0;
             }
 
             $upload_max = static::parseSize(ini_get('upload_max_filesize'));
@@ -1388,7 +1467,7 @@ abstract class Utils
         $pow = min($pow, count($units) - 1);
 
         // Uncomment one of the following alternatives
-         $bytes /= pow(1024, $pow);
+        $bytes /= 1024 ** $pow;
         // $bytes /= (1 << (10 * $pow));
 
         return round($bytes, $precision) . ' ' . $units[$pow];
@@ -1404,11 +1483,12 @@ abstract class Utils
     {
         $unit = preg_replace('/[^bkmgtpezy]/i', '', $size);
         $size = preg_replace('/[^0-9\.]/', '', $size);
+
         if ($unit) {
-            return round($size * pow(1024, stripos('bkmgtpezy', $unit[0])));
-        } else {
-            return round($size);
+            $size = $size * pow(1024, stripos('bkmgtpezy', $unit[0]));
         }
+
+        return (int) abs(round($size));
     }
 
     /**
@@ -1446,19 +1526,28 @@ abstract class Utils
      *
      * @param string $string
      *
-     * @param bool $block  Block or Line processing
+     * @param bool $block Block or Line processing
+     * @param null $page
      * @return string
+     * @throws \Exception
      */
-    public static function processMarkdown($string, $block = true)
+    public static function processMarkdown($string, $block = true, $page = null)
     {
-        $page     = Grav::instance()['page'] ?? null;
-        $defaults = Grav::instance()['config']->get('system.pages.markdown');
+        $grav = Grav::instance();
+        $page     = $page ?? $grav['page'] ?? null;
+        $defaults = [
+            'markdown' => $grav['config']->get('system.pages.markdown', []),
+            'images' => $grav['config']->get('system.images', [])
+        ];
+        $extra = $defaults['markdown']['extra'] ?? false;
+
+        $excerpts = new Excerpts($page, $defaults);
 
         // Initialize the preferred variant of Parsedown
-        if ($defaults['extra']) {
-            $parsedown = new ParsedownExtra($page, $defaults);
+        if ($extra) {
+            $parsedown = new ParsedownExtra($excerpts);
         } else {
-            $parsedown = new Parsedown($page, $defaults);
+            $parsedown = new Parsedown($excerpts);
         }
 
         if ($block) {
@@ -1477,12 +1566,11 @@ abstract class Utils
      * @param int $prefix
      *
      * @return string
-     * @throws \InvalidArgumentException if provided an invalid IP
      */
     public static function getSubnet($ip, $prefix = 64)
     {
         if (!filter_var($ip, FILTER_VALIDATE_IP)) {
-            throw new \InvalidArgumentException('Invalid IP: ' . $ip);
+            return $ip;
         }
 
         // Packed representation of IP
@@ -1509,4 +1597,23 @@ abstract class Utils
 
         return $subnet;
     }
+
+    /**
+     * Wrapper to ensure html, htm in the front of the supported page types
+     *
+     * @param array|null $defaults
+     * @return array|mixed
+     */
+    public static function getSupportPageTypes(array $defaults = null)
+    {
+        $types = Grav::instance()['config']->get('system.pages.types', $defaults);
+
+        // remove html/htm
+        $types = static::arrayRemoveValue($types, ['html', 'htm']);
+
+        // put them back at the front
+        $types = array_merge(['html', 'htm'], $types);
+
+        return $types;
+    }
 }

+ 3 - 0
system/src/Grav/Console/Cli/ClearCacheCommand.php

@@ -21,6 +21,7 @@ class ClearCacheCommand extends ConsoleCommand
             ->setName('cache')
             ->setAliases(['clearcache', 'cache-clear'])
             ->setDescription('Clears Grav cache')
+            ->addOption('invalidate', null, InputOption::VALUE_NONE, 'Invalidate cache, but do not remove any files')
             ->addOption('purge', null, InputOption::VALUE_NONE, 'If set purge old caches')
             ->addOption('all', null, InputOption::VALUE_NONE, 'If set will remove all including compiled, twig, doctrine caches')
             ->addOption('assets-only', null, InputOption::VALUE_NONE, 'If set will remove only assets/*')
@@ -64,6 +65,8 @@ class ClearCacheCommand extends ConsoleCommand
                 $remove = 'cache-only';
             } elseif ($this->input->getOption('tmp-only')) {
                 $remove = 'tmp-only';
+            } elseif ($this->input->getOption('invalidate')) {
+                $remove = 'invalidate';
             } else {
                 $remove = 'standard';
             }

+ 9 - 0
system/src/Grav/Console/Cli/YamlLinterCommand.php

@@ -61,6 +61,15 @@ class YamlLinterCommand extends ConsoleCommand
             $this->displayErrors($errors, $io);
         }
 
+        $io->section('Page Blueprints');
+        $errors = YamlLinter::lintBlueprints();
+
+        if (empty($errors)) {
+            $io->success('No YAML Linting issues with blueprints');
+        } else {
+            $this->displayErrors($errors, $io);
+        }
+
     }
 
     protected function displayErrors($errors, $io)

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

@@ -114,7 +114,7 @@ class InfoCommand extends ConsoleCommand
 
                 if ($info === 'date') {
                     $name = 'Last Update';
-                    $data = date('D, j M Y, H:i:s, P ', strtotime('2014-09-16T00:07:16Z'));
+                    $data = date('D, j M Y, H:i:s, P ', strtotime($data));
                 }
 
                 $name = str_pad($name, 12);

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

@@ -372,7 +372,7 @@ class InstallCommand extends ConsoleCommand
             $helper = $this->getHelper('question');
             $question = new ConfirmationQuestion('Do you wish to install this demo content? [y|N] ', false);
 
-            $answer = $this->all_yes ? true : $helper->ask($this->input, $this->output, $question);
+            $answer = $helper->ask($this->input, $this->output, $question);
 
             if (!$answer) {
                 $this->output->writeln("  '- <red>Skipped!</red>  ");

+ 1 - 1
system/src/Grav/Framework/Collection/AbstractIndexCollection.php

@@ -437,7 +437,7 @@ abstract class AbstractIndexCollection implements CollectionInterface
     }
 
     /**
-     * Implementes JsonSerializable interface.
+     * Implements JsonSerializable interface.
      *
      * @return array
      */

+ 1 - 1
system/src/Grav/Framework/Collection/ArrayCollection.php

@@ -84,7 +84,7 @@ class ArrayCollection extends BaseArrayCollection implements CollectionInterface
     }
 
     /**
-     * Implementes JsonSerializable interface.
+     * Implements JsonSerializable interface.
      *
      * @return array
      */

+ 21 - 2
system/src/Grav/Framework/File/Formatter/CsvFormatter.php

@@ -53,11 +53,11 @@ class CsvFormatter extends AbstractFormatter
         $header = array_keys(reset($data));
 
         // Encode the field names
-        $string = implode($delimiter, $header). "\n";
+        $string = $this->encodeLine($header, $delimiter);
 
         // Encode the data
         foreach ($data as $row) {
-            $string .=  implode($delimiter, $row). "\n";
+            $string .= $this->encodeLine($row, $delimiter);
         }
 
         return $string;
@@ -87,4 +87,23 @@ class CsvFormatter extends AbstractFormatter
 
         return $list;
     }
+
+    protected function encodeLine(array $line, $delimiter = null): string
+    {
+        foreach ($line as $key => &$value) {
+            $value = $this->escape((string)$value);
+        }
+        unset($value);
+
+        return implode($delimiter, $line). "\n";
+    }
+
+    protected function escape(string $value)
+    {
+        if (preg_match('/[,"\r\n]/u', $value)) {
+            $value = '"' . preg_replace('/"/', '""', $value) . '"';
+        }
+
+        return $value;
+    }
 }

+ 2 - 2
system/src/Grav/Framework/File/Formatter/YamlFormatter.php

@@ -97,12 +97,12 @@ class YamlFormatter extends AbstractFormatter
             @ini_set('yaml.decode_php', $saved);
 
             if ($decoded !== false) {
-                return $decoded;
+                return (array) $decoded;
             }
         }
 
         try {
-            return YamlParser::parse($data);
+            return (array) YamlParser::parse($data);
         } catch (ParseException $e) {
             if ($this->useCompatibleDecoder()) {
                 return (array) FallbackYamlParser::parse($data);

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

@@ -91,7 +91,7 @@ class Flex implements \Countable
         // Return the directories in the given order.
         $directories = [];
         foreach ($types as $type) {
-            $directories = $this->types[$type] ?? null;
+            $directories[$type] = $this->types[$type] ?? null;
         }
 
         return $keepMissing ? $directories : array_filter($directories);

+ 8 - 2
system/src/Grav/Framework/Flex/FlexCollection.php

@@ -9,6 +9,7 @@
 
 namespace Grav\Framework\Flex;
 
+use Doctrine\Common\Collections\Collection;
 use Doctrine\Common\Collections\Criteria;
 use Grav\Common\Debugger;
 use Grav\Common\Grav;
@@ -125,7 +126,7 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface
 
     /**
      * @param array $filters
-     * @return FlexCollectionInterface
+     * @return FlexCollectionInterface|Collection
      */
     public function filterBy(array $filters)
     {
@@ -490,7 +491,12 @@ class FlexCollection extends ObjectCollection implements FlexCollectionInterface
         $twig = $grav['twig'];
 
         try {
-            return $twig->twig()->resolveTemplate(["flex-objects/layouts/{$this->getFlexType()}/collection/{$layout}.html.twig"]);
+            return $twig->twig()->resolveTemplate(
+                [
+                    "flex-objects/layouts/{$this->getFlexType()}/collection/{$layout}.html.twig",
+                    "flex-objects/layouts/_default/collection/{$layout}.html.twig"
+                ]
+            );
         } catch (LoaderError $e) {
             /** @var Debugger $debugger */
             $debugger = Grav::instance()['debugger'];

+ 16 - 10
system/src/Grav/Framework/Flex/FlexDirectory.php

@@ -315,15 +315,16 @@ class FlexDirectory implements FlexAuthorizeInterface
                 $gravCache = $grav['cache'];
                 $config = $this->getConfig('cache.' . $namespace);
                 if (empty($config['enabled'])) {
-                    throw new \RuntimeException(sprintf('Flex: %s %s cache not enabled', $this->type, $namespace));
+                    $cache = new MemoryCache('flex-objects-' . $this->getFlexType());
+                } else {
+                    $timeout = $config['timeout'] ?? 60;
+
+                    $key = $gravCache->getKey();
+                    if (Utils::isAdminPlugin()) {
+                        $key = substr($key, 0, -1);
+                    }
+                    $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $timeout);
                 }
-                $timeout = $config['timeout'] ?? 60;
-
-                $key = $gravCache->getKey();
-                if (Utils::isAdminPlugin()) {
-                    $key = substr($key, 0, -1);
-                }
-                $cache = new DoctrineCache($gravCache->getCacheDriver(), 'flex-objects-' . $this->getFlexType() . $key, $timeout);
             } catch (\Exception $e) {
                 /** @var Debugger $debugger */
                 $debugger = Grav::instance()['debugger'];
@@ -519,7 +520,9 @@ class FlexDirectory implements FlexAuthorizeInterface
         // Store updated rows to the cache.
         if ($updated) {
             try {
-                $debugger->addMessage(sprintf('Flex: Caching %d %s: %s', \count($updated), $this->type, implode(', ', array_keys($updated))), 'debug');
+                if (!$cache instanceof MemoryCache) {
+                    $debugger->addMessage(sprintf('Flex: Caching %d %s: %s', \count($updated), $this->type, implode(', ', array_keys($updated))), 'debug');
+                }
                 $cache->setMultiple($updated);
             } catch (InvalidArgumentException $e) {
                 $debugger->addException($e);
@@ -640,7 +643,10 @@ class FlexDirectory implements FlexAuthorizeInterface
                 /** @var string|FlexIndexInterface $className */
                 $className = $this->getIndexClass();
                 $keys = $className::loadEntriesFromStorage($storage);
-                $debugger->addMessage(sprintf('Flex: Caching %s index of %d objects', $this->type, \count($keys)), 'debug');
+                if (!$cache instanceof MemoryCache) {
+                    $debugger->addMessage(sprintf('Flex: Caching %s index of %d objects', $this->type, \count($keys)),
+                        'debug');
+                }
                 try {
                     $cache->set('__keys', $keys);
                 } catch (InvalidArgumentException $e) {

+ 4 - 3
system/src/Grav/Framework/Flex/FlexForm.php

@@ -20,6 +20,7 @@ use Grav\Framework\Form\Traits\FormTrait;
 use Grav\Framework\Route\Route;
 use Twig\Error\LoaderError;
 use Twig\Error\SyntaxError;
+use Twig\Template;
 use Twig\TemplateWrapper;
 
 /**
@@ -82,7 +83,7 @@ class FlexForm implements FlexFormInterface
     }
 
     /**
-     * @return Data|FlexObjectInterface
+     * @return Data|FlexObjectInterface|object
      */
     public function getData()
     {
@@ -103,7 +104,7 @@ class FlexForm implements FlexFormInterface
         $value = $this->data ? $this->data[$name] : null;
 
         // Return the form data or fall back to the object property.
-        return $value ?? $this->getObject()->value($name);
+        return $value ?? $this->getObject()->getFormValue($name);
     }
 
     public function getDefaultValue(string $name)
@@ -277,7 +278,7 @@ class FlexForm implements FlexFormInterface
 
     /**
      * @param string $layout
-     * @return TemplateWrapper
+     * @return Template|TemplateWrapper
      * @throws LoaderError
      * @throws SyntaxError
      */

+ 3 - 2
system/src/Grav/Framework/Flex/FlexIndex.php

@@ -312,9 +312,10 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde
         // Ordering can be done by using index only.
         $previous = null;
         foreach (array_reverse($orderings) as $field => $ordering) {
+            $field = (string)$field;
             if ($this->getKeyField() === $field) {
                 $keys = $this->getKeys();
-                $search = array_combine($keys, $keys);
+                $search = array_combine($keys, $keys) ?: [];
             } elseif ($field === 'flex_key') {
                 $search = $this->getFlexKeys();
             } else {
@@ -462,7 +463,7 @@ class FlexIndex extends ObjectIndex implements FlexCollectionInterface, FlexInde
             $first = reset($entries);
             if ($first) {
                 $keys = array_keys($first);
-                $keys = array_combine($keys, $keys);
+                $keys = array_combine($keys, $keys) ?: [];
             } else {
                 $keys = [];
             }

+ 32 - 10
system/src/Grav/Framework/Flex/FlexObject.php

@@ -31,6 +31,7 @@ use Psr\SimpleCache\InvalidArgumentException;
 use RocketTheme\Toolbox\Event\Event;
 use Twig\Error\LoaderError;
 use Twig\Error\SyntaxError;
+use Twig\Template;
 use Twig\TemplateWrapper;
 
 /**
@@ -162,6 +163,13 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
     {
         $options = $options ?? $this->getFlexDirectory()->getConfig('data.search.options', []);
         $properties = $properties ?? $this->getFlexDirectory()->getConfig('data.search.fields', []);
+        if (!$properties) {
+            foreach ($this->getFlexDirectory()->getConfig('admin.list.fields', []) as $property => $value) {
+                if (!empty($value['link'])) {
+                    $properties[] = $property;
+                }
+            }
+        }
 
         $weight = 0;
         foreach ((array)$properties as $property) {
@@ -273,7 +281,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             return (float)$options['ends_with'];
         }
         if ((!$tested || !empty($options['contains'])) && Utils::contains($value, $search, $options['case_sensitive'] ?? false)) {
-            return (float)$options['contains'];
+            return (float)($options['contains'] ?? 1);
         }
 
         return 0;
@@ -384,7 +392,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
         }
 
         try {
-            $data = $cache ? $cache->get($key) : null;
+            $data = $cache && $key ? $cache->get($key) : null;
 
             $block = $data ? HtmlBlock::fromArray($data) : null;
         } catch (InvalidArgumentException $e) {
@@ -403,7 +411,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
         }
 
         if (!$block) {
-            $block = HtmlBlock::create($key);
+            $block = HtmlBlock::create($key ?: null);
             $block->setChecksum($checksum);
             if ($key === false) {
                 $block->disableCache();
@@ -427,7 +435,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             $block->setContent($output);
 
             try {
-                $cache && $block->isCached() && $cache->set($key, $block->toArray());
+                $cache && $key && $block->isCached() && $cache->set($key, $block->toArray());
             } catch (InvalidArgumentException $e) {
                 $debugger->addException($e);
             }
@@ -534,7 +542,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
         $result = $this->getFlexDirectory()->getStorage()->replaceRows([$this->getStorageKey() => $this->prepareStorage()]);
 
         $value = reset($result);
-        $storageKey = key($result);
+        $storageKey = (string)key($result);
         if ($value && $storageKey) {
             $this->setStorageKey($storageKey);
             if (!$this->hasKey()) {
@@ -542,6 +550,15 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             }
         }
 
+        // FIXME: For some reason locator caching isn't cleared for the file, investigate!
+        $locator = Grav::instance()['locator'];
+        $locator->clearCache();
+
+        // Make sure that the object exists before continuing (just in case).
+        if (!$this->exists()) {
+            throw new \RuntimeException('Saving failed: Object does not exist!');
+        }
+
         if (method_exists($this, 'saveUpdatedMedia')) {
             $this->saveUpdatedMedia();
         }
@@ -551,7 +568,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             if (method_exists($this, 'clearMediaCache')) {
                 $this->clearMediaCache();
             }
-        } catch (InvalidArgumentException $e) {
+        } catch (\Exception $e) {
             /** @var Debugger $debugger */
             $debugger = Grav::instance()['debugger'];
             $debugger->addException($e);
@@ -579,7 +596,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
             if (method_exists($this, 'clearMediaCache')) {
                 $this->clearMediaCache();
             }
-        } catch (InvalidArgumentException $e) {
+        } catch (\Exception $e) {
             /** @var Debugger $debugger */
             $debugger = Grav::instance()['debugger'];
             $debugger->addException($e);
@@ -729,7 +746,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
         }
 
         $grav = Grav::instance();
-        /** @var Flex $flex */
+        /** @var Flex|null $flex */
         $flex = $grav['flex_objects'] ?? null;
         $directory = $flex ? $flex->getDirectory($type) : null;
         if (!$directory) {
@@ -800,7 +817,7 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
 
     /**
      * @param string $layout
-     * @return TemplateWrapper
+     * @return Template|TemplateWrapper
      * @throws LoaderError
      * @throws SyntaxError
      */
@@ -812,7 +829,12 @@ class FlexObject implements FlexObjectInterface, FlexAuthorizeInterface
         $twig = $grav['twig'];
 
         try {
-            return $twig->twig()->resolveTemplate(["flex-objects/layouts/{$this->getFlexType()}/object/{$layout}.html.twig"]);
+            return $twig->twig()->resolveTemplate(
+                [
+                    "flex-objects/layouts/{$this->getFlexType()}/object/{$layout}.html.twig",
+                    "flex-objects/layouts/_default/object/{$layout}.html.twig"
+                ]
+            );
         } catch (LoaderError $e) {
             /** @var Debugger $debugger */
             $debugger = Grav::instance()['debugger'];

+ 1 - 1
system/src/Grav/Framework/Flex/Storage/AbstractFilesystemStorage.php

@@ -128,7 +128,7 @@ abstract class AbstractFilesystemStorage implements FlexStorageInterface
             return $path;
         }
 
-        return (string) $locator->findResource($path) ?: $locator->findResource($path, true, true);
+        return (string)($locator->findResource($path) ?: $locator->findResource($path, true, true));
     }
 
     /**

+ 2 - 0
system/src/Grav/Framework/Flex/Storage/FolderStorage.php

@@ -455,6 +455,8 @@ class FolderStorage extends AbstractFilesystemStorage
     protected function initOptions(array $options): void
     {
         $extension = $this->dataFormatter->getDefaultFileExtension();
+
+        /** @var string $pattern */
         $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern;
 
         $this->dataFolder = $options['folder'];

+ 9 - 1
system/src/Grav/Framework/Flex/Storage/SimpleStorage.php

@@ -192,6 +192,9 @@ class SimpleStorage extends AbstractFilesystemStorage
 
         $list = [];
         foreach ($rows as $key => $row) {
+            if (strpos($key, '@@')) {
+                $key = $this->getNewKey();
+            }
             $this->data[$key] = $list[$key] = $row;
         }
 
@@ -224,7 +227,12 @@ class SimpleStorage extends AbstractFilesystemStorage
         $keys = array_keys($this->data);
         $keys[array_search($src, $keys, true)] = $dst;
 
-        $this->data = array_combine($keys, $this->data);
+        $data = array_combine($keys, $this->data);
+        if (false === $data) {
+            throw new \LogicException('Bad data');
+        }
+
+        $this->data = $data;
 
         return true;
     }

+ 9 - 3
system/src/Grav/Framework/Flex/Traits/FlexAuthorizeTrait.php

@@ -13,6 +13,7 @@ namespace Grav\Framework\Flex\Traits;
 
 use Grav\Common\Grav;
 use Grav\Common\User\Interfaces\UserInterface;
+use Grav\Framework\Flex\FlexDirectory;
 use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
 
 /**
@@ -25,10 +26,11 @@ trait FlexAuthorizeTrait
     public function isAuthorized(string $action, string $scope = null, UserInterface $user = null) : bool
     {
         if (null === $user) {
-            $user = Grav::instance()['user'];
+            /** @var UserInterface $user */
+            $user = Grav::instance()['user'] ?? null;
         }
 
-        return $this->isAuthorizedAction($user, $action, $scope) || $this->isAuthorizedSuperAdmin($user);
+        return $user && ($this->isAuthorizedAction($user, $action, $scope) || $this->isAuthorizedSuperAdmin($user));
     }
 
     protected function isAuthorizedSuperAdmin(UserInterface $user): bool
@@ -44,7 +46,11 @@ trait FlexAuthorizeTrait
             $action = $this->exists() ? 'update' : 'create';
         }
 
-        return $user->authorize(sprintf($this->_authorize, $scope, $action));
+        $directory = $this instanceof FlexDirectory ? $this : $this->getFlexDirectory();
+        $config = $directory->getConfig();
+        $allowed = $config->get("{$scope}.actions.{$action}") ?? $config->get("actions.{$action}") ?? true;
+
+        return $allowed && $user->authorize(sprintf($this->_authorize, $scope, $action));
     }
 
     protected function setAuthorizeRule(string $authorize) : void

+ 14 - 5
system/src/Grav/Framework/Flex/Traits/FlexMediaTrait.php

@@ -49,7 +49,7 @@ trait FlexMediaTrait
      */
     public function getStorageFolder()
     {
-        return $this->getFlexDirectory()->getStorageFolder($this->getStorageKey());
+        return $this->exists() ? $this->getFlexDirectory()->getStorageFolder($this->getStorageKey()) : '';
     }
 
     /**
@@ -57,7 +57,7 @@ trait FlexMediaTrait
      */
     public function getMediaFolder()
     {
-        return $this->getFlexDirectory()->getMediaFolder($this->getStorageKey());
+        return $this->exists() ? $this->getFlexDirectory()->getMediaFolder($this->getStorageKey()) : '';
     }
 
     /**
@@ -153,6 +153,12 @@ trait FlexMediaTrait
         /** @var UniformResourceLocator $locator */
         $locator = $grav['locator'];
         $path = $media->getPath();
+        if (!$path) {
+            $language = $grav['language'];
+
+            throw new RuntimeException($language->translate('PLUGIN_ADMIN.FAILED_TO_MOVE_UPLOADED_FILE'), 400);
+        }
+
         if ($locator->isStream($path)) {
             $path = $locator->findResource($path, true, true);
             $locator->clearCache($path);
@@ -202,12 +208,16 @@ trait FlexMediaTrait
         }
 
         $media = $this->getMedia();
+        $path = $media->getPath();
+        if (!$path) {
+            return;
+        }
 
         /** @var UniformResourceLocator $locator */
         $locator = $grav['locator'];
 
-        $targetPath = $media->getPath() . '/' . $dirname;
-        $targetFile = $media->getPath() . '/' . $filename;
+        $targetPath = $path . '/' . $dirname;
+        $targetFile = $path . '/' . $filename;
         if ($locator->isStream($targetFile)) {
             $targetPath = $locator->findResource($targetPath, true, true);
             $targetFile = $locator->findResource($targetFile, true, true);
@@ -229,7 +239,6 @@ trait FlexMediaTrait
         }
 
         // Remove Extra Files
-
         foreach (scandir($targetPath, SCANDIR_SORT_NONE) as $file) {
             $preg_name = preg_quote($fileParts['filename'], '`');
             $preg_ext =preg_quote($fileParts['extension'], '`');

+ 183 - 96
system/src/Grav/Framework/Form/FormFlash.php

@@ -10,12 +10,18 @@
 namespace Grav\Framework\Form;
 
 use Grav\Common\Filesystem\Folder;
+use Grav\Common\Grav;
+use Grav\Common\User\Interfaces\UserInterface;
 use Grav\Common\Utils;
+use Grav\Framework\Form\Interfaces\FormFlashInterface;
 use Psr\Http\Message\UploadedFileInterface;
 use RocketTheme\Toolbox\File\YamlFile;
+use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
 
-class FormFlash implements \JsonSerializable
+class FormFlash implements FormFlashInterface
 {
+    /** @var bool */
+    protected $exists;
     /** @var string */
     protected $sessionId;
     /** @var string */
@@ -26,6 +32,10 @@ class FormFlash implements \JsonSerializable
     protected $url;
     /** @var array */
     protected $user;
+    /** @var int */
+    protected $createdTimestamp;
+    /** @var int */
+    protected $updatedTimestamp;
     /** @var array */
     protected $data;
     /** @var array */
@@ -34,29 +44,35 @@ class FormFlash implements \JsonSerializable
     protected $uploadedFiles;
     /** @var string[] */
     protected $uploadObjects;
-    /** @var bool */
-    protected $exists;
+    /** @var string */
+    protected $folder;
 
     /**
-     * @param string $sessionId
-     * @return string
+     * @inheritDoc
      */
-    public static function getSessionTmpDir(string $sessionId): string
+    public function __construct($config)
     {
-        return "tmp://forms/{$sessionId}";
-    }
+        // Backwards compatibility with Grav 1.6 plugins.
+        if (!is_array($config)) {
+            user_error(__CLASS__ . '::' . __FUNCTION__ . '($sessionId, $uniqueId, $formName) is deprecated since Grav 1.6.11, use $config parameter instead', E_USER_DEPRECATED);
+
+            $args = func_get_args();
+            $config = [
+                'session_id' => $args[0],
+                'unique_id' => $args[1] ?? null,
+                'form_name' => $args[2] ?? null,
+            ];
+        }
 
-    /**
-     * FormFlashObject constructor.
-     * @param string $sessionId
-     * @param string $uniqueId
-     * @param string|null $formName
-     */
-    public function __construct(string $sessionId, string $uniqueId, string $formName = null)
-    {
-        $this->sessionId = $sessionId;
-        $this->uniqueId = $uniqueId;
+        $this->sessionId = $config['session_id'] ?? 'no-session';
+        $this->uniqueId = $config['unique_id'] ?? '';
+
+        $folder = $config['folder'] ?? ($this->sessionId ? 'tmp://forms/' . $this->sessionId : '');
+
+        /** @var UniformResourceLocator $locator */
+        $locator = Grav::instance()['locator'];
 
+        $this->folder = $folder && $locator->isStream($folder) ? $locator->findResource($folder, true, true) : $folder;
         $file = $this->getTmpIndex();
         $this->exists = $file->exists();
 
@@ -66,65 +82,58 @@ class FormFlash implements \JsonSerializable
             } catch (\Exception $e) {
                 $data = [];
             }
-            $this->formName = null !== $formName ? $content['form'] ?? '' : '';
+            $this->formName = $content['form'] ?? $config['form_name'] ?? '';
             $this->url = $data['url'] ?? '';
             $this->user = $data['user'] ?? null;
+            $this->updatedTimestamp = $data['timestamps']['updated'] ?? time();
+            $this->createdTimestamp = $data['timestamps']['created'] ?? $this->updatedTimestamp;
             $this->data = $data['data'] ?? null;
             $this->files = $data['files'] ?? [];
         } else {
-            $this->formName = $formName;
+            $this->formName = $config['form_name'] ?? '';
             $this->url = '';
+            $this->createdTimestamp = $this->updatedTimestamp = time();
             $this->files = [];
         }
     }
 
     /**
-     * @return string
+     * @inheritDoc
      */
-    public function getFormName(): string
+    public function getSessionId(): string
     {
-        return $this->formName;
+        return $this->sessionId;
     }
 
     /**
-     * @return string
+     * @inheritDoc
      */
-    public function getUniqieId(): string
+    public function getUniqueId(): string
     {
         return $this->uniqueId;
     }
 
     /**
-     * @return bool
+     * @deprecated 1.6.11 Use '->getUniqueId()' method instead.
      */
-    public function exists(): bool
+    public function getUniqieId(): string
     {
-        return $this->exists;
+        user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6.11, use ->getUniqueId() method instead', E_USER_DEPRECATED);
+
+        return $this->getUniqueId();
     }
 
     /**
-     * @return $this
+     * @inheritDoc
      */
-    public function save(): self
+    public function getFormName(): string
     {
-        $file = $this->getTmpIndex();
-        $file->save($this->jsonSerialize());
-        $this->exists = true;
-
-        return $this;
+        return $this->formName;
     }
 
-    public function delete(): self
-    {
-        $this->removeTmpDir();
-        $this->files = [];
-        $this->exists = false;
-
-        return $this;
-    }
 
     /**
-     * @return string
+     * @inheritDoc
      */
     public function getUrl(): string
     {
@@ -132,18 +141,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * @param string $url
-     * @return $this
-     */
-    public function setUrl(string $url): self
-    {
-        $this->url = $url;
-
-        return $this;
-    }
-
-    /**
-     * @return string
+     * @inheritDoc
      */
     public function getUsername(): string
     {
@@ -151,7 +149,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * @return string
+     * @inheritDoc
      */
     public function getUserEmail(): string
     {
@@ -159,40 +157,84 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * @param string|null $username
-     * @return $this
+     * @inheritDoc
      */
-    public function setUserName(string $username = null): self
+    public function getCreatedTimestamp(): int
     {
-        $this->user['username'] = $username;
-
-        return $this;
+        return $this->createdTimestamp;
     }
 
     /**
-     * @param string|null $email
-     * @return $this
+     * @inheritDoc
      */
-    public function setUserEmail(string $email = null): self
+    public function getUpdatedTimestamp(): int
     {
-        $this->user['email'] = $email;
-
-        return $this;
+        return $this->updatedTimestamp;
     }
 
+
+    /**
+     * @inheritDoc
+     */
     public function getData(): ?array
     {
         return $this->data;
     }
 
+    /**
+     * @inheritDoc
+     */
     public function setData(?array $data): void
     {
         $this->data = $data;
     }
 
     /**
-     * @param string $field
-     * @return array
+     * @inheritDoc
+     */
+    public function exists(): bool
+    {
+        return $this->exists;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function save(): self
+    {
+        if (!($this->folder && $this->uniqueId)) {
+            return $this;
+        }
+
+        if ($this->data || $this->files) {
+            // Only save if there is data or files to be saved.
+            $file = $this->getTmpIndex();
+            $file->save($this->jsonSerialize());
+            $this->exists = true;
+        } elseif ($this->exists) {
+            // Delete empty form flash if it exists (it carries no information).
+            return $this->delete();
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function delete(): self
+    {
+        if ($this->folder && $this->uniqueId) {
+            $this->removeTmpDir();
+            $this->files = [];
+            $this->exists = false;
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
      */
     public function getFilesByField(string $field): array
     {
@@ -208,8 +250,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * @param bool $includeOriginal
-     * @return array
+     * @inheritDoc
      */
     public function getFilesByFields($includeOriginal = false): array
     {
@@ -225,12 +266,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * Add uploaded file to the form flash.
-     *
-     * @param UploadedFileInterface $upload
-     * @param string|null $field
-     * @param array|null $crop
-     * @return string Return name of the file
+     * @inheritDoc
      */
     public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string
     {
@@ -256,12 +292,7 @@ class FormFlash implements \JsonSerializable
 
 
     /**
-     * Add existing file to the form flash.
-     *
-     * @param string $filename
-     * @param string $field
-     * @param array $crop
-     * @return bool
+     * @inheritDoc
      */
     public function addFile(string $filename, string $field, array $crop = null): bool
     {
@@ -282,11 +313,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * Remove any file from form flash.
-     *
-     * @param string $name
-     * @param string $field
-     * @return bool
+     * @inheritDoc
      */
     public function removeFile(string $name, string $field = null): bool
     {
@@ -318,7 +345,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * Clear form flash from all uploaded files.
+     * @inheritDoc
      */
     public function clearFiles()
     {
@@ -332,7 +359,7 @@ class FormFlash implements \JsonSerializable
     }
 
     /**
-     * @return array
+     * @inheritDoc
      */
     public function jsonSerialize(): array
     {
@@ -341,17 +368,72 @@ class FormFlash implements \JsonSerializable
             'unique_id' => $this->uniqueId,
             'url' => $this->url,
             'user' => $this->user,
+            'timestamps' => [
+                'created' => $this->createdTimestamp,
+                'updated' => time(),
+            ],
             'data' => $this->data,
             'files' => $this->files
         ];
     }
 
+    /**
+     * @param string $url
+     * @return $this
+     */
+    public function setUrl(string $url): self
+    {
+        $this->url = $url;
+
+        return $this;
+    }
+
+    /**
+     * @param UserInterface|null $user
+     * @return $this
+     */
+    public function setUser(UserInterface $user = null)
+    {
+        if ($user && $user->username) {
+            $this->user = [
+                'username' => $user->username,
+                'email' => $user->email ?? ''
+            ];
+        } else {
+            $this->user = null;
+        }
+
+        return $this;
+    }
+
+    /**
+     * @param string|null $username
+     * @return $this
+     */
+    public function setUserName(string $username = null): self
+    {
+        $this->user['username'] = $username;
+
+        return $this;
+    }
+
+    /**
+     * @param string|null $email
+     * @return $this
+     */
+    public function setUserEmail(string $email = null): self
+    {
+        $this->user['email'] = $email;
+
+        return $this;
+    }
+
     /**
      * @return string
      */
     public function getTmpDir(): string
     {
-        return static::getSessionTmpDir($this->sessionId) . '/' . $this->uniqueId;
+        return $this->folder && $this->uniqueId ? "{$this->folder}/{$this->uniqueId}" : '';
     }
 
     /**
@@ -368,8 +450,9 @@ class FormFlash implements \JsonSerializable
      */
     protected function removeTmpFile(string $name): void
     {
-        $filename = $this->getTmpDir() . '/' . $name;
-        if ($name && is_file($filename)) {
+        $tmpDir = $this->getTmpDir();
+        $filename =  $tmpDir ? $tmpDir . '/' . $name : '';
+        if ($name && $filename && is_file($filename)) {
             unlink($filename);
         }
     }
@@ -377,7 +460,7 @@ class FormFlash implements \JsonSerializable
     protected function removeTmpDir(): void
     {
         $tmpDir = $this->getTmpDir();
-        if (file_exists($tmpDir)) {
+        if ($tmpDir && file_exists($tmpDir)) {
             Folder::delete($tmpDir);
         }
     }
@@ -390,6 +473,10 @@ class FormFlash implements \JsonSerializable
      */
     protected function addFileInternal(?string $field, string $name, array $data, array $crop = null): void
     {
+        if (!($this->folder && $this->uniqueId)) {
+            throw new \RuntimeException('Cannot upload files: form flash folder not defined');
+        }
+
         $field = $field ?: 'undefined';
         if (!isset($this->files[$field])) {
             $this->files[$field] = [];

+ 165 - 0
system/src/Grav/Framework/Form/Interfaces/FormFlashInterface.php

@@ -0,0 +1,165 @@
+<?php
+
+/**
+ * @package    Grav\Framework\Form
+ *
+ * @copyright  Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Framework\Form\Interfaces;
+
+use Psr\Http\Message\UploadedFileInterface;
+
+interface FormFlashInterface extends \JsonSerializable
+{
+    /**
+     * @param array $config     Available configuration keys: session_id, unique_id, form_name
+     */
+    public function __construct($config);
+
+    /**
+     * Get session Id associated to this form instance.
+     *
+     * @return string
+     */
+    public function getSessionId(): string;
+
+    /**
+     * Get unique identifier associated to this form instance.
+     *
+     * @return string
+     */
+    public function getUniqueId(): string;
+
+    /**
+     * Get form name associated to this form instance.
+     *
+     * @return string
+     */
+    public function getFormName(): string;
+
+    /**
+     * Get URL associated to this form instance.
+     *
+     * @return string
+     */
+    public function getUrl(): string;
+
+    /**
+     * Get username from the user who was associated to this form instance.
+     *
+     * @return string
+     */
+    public function getUsername(): string;
+
+    /**
+     * Get email from the user who was associated to this form instance.
+     *
+     * @return string
+     */
+    public function getUserEmail(): string;
+
+
+    /**
+     * Get creation timestamp for this form flash.
+     *
+     * @return int
+     */
+    public function getCreatedTimestamp(): int;
+
+    /**
+     * Get last updated timestamp for this form flash.
+     *
+     * @return int
+     */
+    public function getUpdatedTimestamp(): int;
+
+    /**
+     * Get raw form data.
+     *
+     * @return array|null
+     */
+    public function getData(): ?array;
+
+    /**
+     * Set raw form data.
+     *
+     * @param array|null $data
+     */
+    public function setData(?array $data): void;
+
+    /**
+     * Check if this form flash exists.
+     *
+     * @return bool
+     */
+    public function exists(): bool;
+
+    /**
+     * Save this form flash.
+     *
+     * @return $this
+     */
+    public function save();
+
+    /**
+     * Delete this form flash.
+     */
+    public function delete();
+
+    /**
+     * Get all files associated to a form field.
+     *
+     * @param string $field
+     * @return array
+     */
+    public function getFilesByField(string $field): array;
+
+    /**
+     * Get all files grouped by the associated form fields.
+     *
+     * @param bool $includeOriginal
+     * @return array
+     */
+    public function getFilesByFields($includeOriginal = false): array;
+
+    /**
+     * Add uploaded file to the form flash.
+     *
+     * @param UploadedFileInterface $upload
+     * @param string|null $field
+     * @param array|null $crop
+     * @return string Return name of the file
+     */
+    public function addUploadedFile(UploadedFileInterface $upload, string $field = null, array $crop = null): string;
+
+    /**
+     * Add existing file to the form flash.
+     *
+     * @param string $filename
+     * @param string $field
+     * @param array $crop
+     * @return bool
+     */
+    public function addFile(string $filename, string $field, array $crop = null): bool;
+
+    /**
+     * Remove any file from form flash.
+     *
+     * @param string $name
+     * @param string $field
+     * @return bool
+     */
+    public function removeFile(string $name, string $field = null): bool;
+
+    /**
+     * Clear form flash from all uploaded files.
+     */
+    public function clearFiles();
+
+    /**
+     * @return array
+     */
+    public function jsonSerialize(): array;
+}

+ 85 - 22
system/src/Grav/Framework/Form/Traits/FormTrait.php

@@ -15,9 +15,11 @@ use Grav\Common\Data\ValidationException;
 use Grav\Common\Form\FormFlash;
 use Grav\Common\Grav;
 use Grav\Common\Twig\Twig;
+use Grav\Common\User\Interfaces\UserInterface;
 use Grav\Common\Utils;
 use Grav\Framework\ContentBlock\HtmlBlock;
 use Grav\Framework\Form\Interfaces\FormInterface;
+use Grav\Framework\Session\SessionInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Message\UploadedFileInterface;
 use Twig\Error\LoaderError;
@@ -296,12 +298,12 @@ trait FormTrait
 
     public function getButtons(): array
     {
-        return $this->getBlueprint()['form']['buttons'] ?? [];
+        return $this->getBlueprint()->get('form/buttons') ?? [];
     }
 
     public function getTasks(): array
     {
-        return $this->getBlueprint()['form']['tasks'] ?? [];
+        return $this->getBlueprint()->get('form/tasks') ?? [];
     }
 
     abstract public function getBlueprint(): Blueprint;
@@ -336,33 +338,54 @@ trait FormTrait
     public function getFlash(): FormFlash
     {
         if (null === $this->flash) {
-            /** @var Grav $grav */
             $grav = Grav::instance();
-            $id = null;
+            $config = [
+                'session_id' => $this->getSessionId(),
+                'unique_id' => $this->getUniqueId(),
+                'form_name' => $this->getName(),
+                'folder' => $this->getFlashFolder()
+            ];
 
-            $user = $grav['user'] ?? null;
-            if (isset($user)) {
-                $rememberState = $this->getBlueprint()->get('form/remember_state');
-                if ($rememberState === 'user') {
-                    $id = $user->username;
-                }
-            }
 
-            // Session Required for flash form
-            $session = $grav['session'] ?? null;
-            if (isset($session)) {
-                // By default store flash by the session id.
-                if (null === $id) {
-                    $id = $session->getId();
-                }
+            $this->flash = new FormFlash($config);
+            $this->flash->setUrl($grav['uri']->url)->setUser($grav['user'] ?? null);
+        }
 
+        return $this->flash;
+    }
 
-                $this->flash = new FormFlash($id, $this->getUniqueId(), $this->getName());
-                $this->flash->setUrl($grav['uri']->url)->setUser($user);
+    /**
+     * Get all available form flash objects for this form.
+     *
+     * @return FormFlash[]
+     */
+    public function getAllFlashes(): array
+    {
+        $folder = $this->getFlashFolder();
+        if (!$folder || !is_dir($folder)) {
+            return [];
+        }
+
+        $name = $this->getName();
+
+        $list = [];
+        /** @var \SplFileInfo $file */
+        foreach (new \FilesystemIterator($folder) as $file) {
+            $uniqueId = $file->getFilename();
+            $config = [
+                'session_id' => $this->getSessionId(),
+                'unique_id' => $uniqueId,
+                'form_name' => $name,
+                'folder' => $this->getFlashFolder()
+            ];
+            $flash = new FormFlash($config);
+            if ($flash->exists() && $flash->getFormName() === $name) {
+                $list[] = $flash;
             }
         }
 
-        return $this->flash;
+        return $list;
+
     }
 
     /**
@@ -389,11 +412,51 @@ trait FormTrait
         return $block;
     }
 
+    protected function getSessionId(): string
+    {
+        /** @var Grav $grav */
+        $grav = Grav::instance();
+
+        /** @var SessionInterface $session */
+        $session = $grav['session'] ?? null;
+
+        return $session ? ($session->getId() ?? '') : '';
+    }
+
     protected function unsetFlash(): void
     {
         $this->flash = null;
     }
 
+    protected function getFlashFolder(): ?string
+    {
+        $grav = Grav::instance();
+
+        /** @var UserInterface $user */
+        $user = $grav['user'] ?? null;
+        $userExists = $user && $user->exists();
+        $username = $userExists ? $user->username : null;
+        $mediaFolder = $userExists ? $user->getMediaFolder() : null;
+        $session = $grav['session'] ?? null;
+        $sessionId = $session ? $session->getId() : null;
+
+        // Fill template token keys/value pairs.
+        $dataMap = [
+            '[FORM_NAME]' => $this->getName(),
+            '[SESSIONID]' => $sessionId ?? '!!',
+            '[USERNAME]' => $username ?? '!!',
+            '[USERNAME_OR_SESSIONID]' => $username ?? $sessionId ?? '!!',
+            '[ACCOUNT]' => $mediaFolder ?? '!!'
+        ];
+
+        $flashFolder = $this->getBlueprint()->get('form/flash_folder', 'tmp://forms/[SESSIONID]');
+
+        $path = str_replace(array_keys($dataMap), array_values($dataMap), $flashFolder);
+
+        // Make sure we only return valid paths.
+        return strpos($path, '!!') === false ? rtrim($path, '/') : null;
+    }
+
     /**
      * Set a single error.
      *
@@ -577,7 +640,7 @@ trait FormTrait
         foreach ($data as $key => &$value) {
             if (\is_array($value)) {
                 $value = $this->jsonDecode($value);
-            } elseif ($value === '') {
+            } elseif (trim($value) === '') {
                 unset($data[$key]);
             } else {
                 $value = json_decode($value, true);

+ 1 - 1
system/src/Grav/Framework/Interfaces/RenderInterface.php

@@ -29,7 +29,7 @@ interface RenderInterface
      * @example {% render object layout 'custom' with { variable: 'value' } %}
      *
      * @param string|null $layout  Layout to be used.
-     * @param array|null  $context Extra context given to the renderer.
+     * @param array       $context Extra context given to the renderer.
      *
      * @return ContentBlockInterface|HtmlBlock Returns `HtmlBlock` containing the rendered output.
      * @api

+ 38 - 14
system/src/Grav/Framework/Route/Route.php

@@ -154,16 +154,11 @@ class Route
      * If the parameter exists in both, return Grav parameter.
      *
      * @param string $param
-     * @return string|null
+     * @return string|array|null
      */
     public function getParam($param)
     {
-        $value = $this->getGravParam($param);
-        if ($value === null) {
-            $value = $this->getQueryParam($param);
-        }
-
-        return $value;
+        return $this->getGravParam($param) ?? $this->getQueryParam($param);
     }
 
     /**
@@ -177,7 +172,7 @@ class Route
 
     /**
      * @param string $param
-     * @return string|null
+     * @return string|array|null
      */
     public function getQueryParam($param)
     {
@@ -193,6 +188,7 @@ class Route
     public function withRoute($route)
     {
         $this->route = $route;
+
         return $this;
     }
 
@@ -205,6 +201,7 @@ class Route
     public function withRoot($root)
     {
         $this->root = $root;
+
         return $this;
     }
 
@@ -250,6 +247,25 @@ class Route
         return $this->withParam('queryParams', $param, $value);
     }
 
+    public function withoutParams()
+    {
+        return $this->withoutGravParams()->withoutQueryParams();
+    }
+
+    public function withoutGravParams()
+    {
+        $this->gravParams = [];
+
+        return $this;
+    }
+
+    public function withoutQueryParams()
+    {
+        $this->queryParams = [];
+
+        return $this;
+    }
+
     /**
      * @return \Grav\Framework\Uri\Uri
      */
@@ -292,22 +308,30 @@ class Route
      */
     protected function withParam($type, $param, $value)
     {
-        $oldValue = $this->{$type}[$param] ?? null;
+        $values = $this->{$type} ?? [];
+        $oldValue = $values[$param] ?? null;
 
         if ($oldValue === $value) {
             return $this;
         }
 
-        $new = clone $this;
+        $new = $this->copy();
         if ($value === null) {
-            unset($new->{$type}[$param]);
+            unset($values[$param]);
         } else {
-            $new->{$type}[$param] = $value;
+            $values[$param] = $value;
         }
 
+        $new->{$type} = $values;
+
         return $new;
     }
 
+    protected function copy()
+    {
+        return clone $this;
+    }
+
     /**
      * @param bool $includeRoot
      * @return string
@@ -350,8 +374,8 @@ class Route
             $this->language = $gravParts['language'];
             $this->route = $gravParts['route'];
             $this->extension = $gravParts['extension'] ?? '';
-            $this->gravParams = $gravParts['params'];
-            $this->queryParams = $parts['query_params'];
+            $this->gravParams = $gravParts['params'] ?: [];
+            $this->queryParams = $parts['query_params'] ?: [];
 
         } else {
             $this->root = RouteFactory::getRoot();

+ 2 - 2
system/src/Grav/Framework/Uri/UriPartsFilter.php

@@ -84,11 +84,11 @@ class UriPartsFilter
      */
     public static function filterPort($port = null)
     {
-        if (null === $port || (\is_int($port) && ($port >= 1 && $port <= 65535))) {
+        if (null === $port || (\is_int($port) && ($port >= 0 && $port <= 65535))) {
             return $port;
         }
 
-        throw new \InvalidArgumentException('Uri port must be null or an integer between 1 and 65535');
+        throw new \InvalidArgumentException('Uri port must be null or an integer between 0 and 65535');
     }
 
     /**

BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_Cahiers01.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_Cahiers10.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers02.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers03.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers04.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers05.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers06.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers07.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers08.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers09.jpg


BIN
user/pages/01.projets/01.cahier-du-muse-des-beaux-arts-de-caen/A_MBA_cahiers11.jpg


Some files were not shown because too many files changed in this diff