Browse Source

maj && modif graphique

Tessier 3 years ago
parent
commit
b1e14f5f7b
100 changed files with 3364 additions and 564 deletions
  1. 2 0
      .gitignore
  2. 131 3
      CHANGELOG.md
  3. 8 1
      bin/gpm
  4. 11 4
      bin/grav
  5. 8 6
      bin/plugin
  6. 5 6
      composer.json
  7. 206 254
      composer.lock
  8. 6 0
      fixperms.sh
  9. 10 9
      index.php
  10. 5 0
      system/aliases.php
  11. 0 1
      system/assets/jquery/jquery-3.x.min.js
  12. 64 7
      system/blueprints/config/system.yaml
  13. 11 0
      system/config/system.yaml
  14. 1 1
      system/defines.php
  15. 1 0
      system/pages/notfound.md
  16. 5 1
      system/src/Grav/Common/Assets/Traits/LegacyAssetsTrait.php
  17. 2 0
      system/src/Grav/Common/Browser.php
  18. 16 5
      system/src/Grav/Common/Data/BlueprintSchema.php
  19. 30 2
      system/src/Grav/Common/Data/Data.php
  20. 10 1
      system/src/Grav/Common/Data/Validation.php
  21. 1 1
      system/src/Grav/Common/Debugger.php
  22. 40 11
      system/src/Grav/Common/Filesystem/Folder.php
  23. 2 1
      system/src/Grav/Common/GPM/Local/Package.php
  24. 4 2
      system/src/Grav/Common/GPM/Response.php
  25. 35 4
      system/src/Grav/Common/Grav.php
  26. 42 9
      system/src/Grav/Common/Helpers/Excerpts.php
  27. 2 1
      system/src/Grav/Common/Markdown/Parsedown.php
  28. 2 1
      system/src/Grav/Common/Markdown/ParsedownExtra.php
  29. 2 1
      system/src/Grav/Common/Page/Markdown/Excerpts.php
  30. 9 4
      system/src/Grav/Common/Page/Medium/ImageMedium.php
  31. 11 7
      system/src/Grav/Common/Page/Page.php
  32. 43 23
      system/src/Grav/Common/Page/Pages.php
  33. 1 1
      system/src/Grav/Common/Plugin.php
  34. 48 0
      system/src/Grav/Common/Processors/InitializeProcessor.php
  35. 2 2
      system/src/Grav/Common/Scheduler/Cron.php
  36. 13 0
      system/src/Grav/Common/Service/PagesServiceProvider.php
  37. 56 0
      system/src/Grav/Common/Twig/Node/TwigNodeCache.php
  38. 68 0
      system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php
  39. 99 45
      system/src/Grav/Common/Twig/TwigExtension.php
  40. 22 17
      system/src/Grav/Common/Uri.php
  41. 22 0
      system/src/Grav/Common/Utils.php
  42. 140 1
      system/src/Grav/Console/ConsoleCommand.php
  43. 1 1
      system/src/Grav/Console/Gpm/SelfupgradeCommand.php
  44. 1 1
      system/src/Grav/Framework/File/Formatter/CsvFormatter.php
  45. 1554 0
      system/src/Grav/Framework/Parsedown/Parsedown.php
  46. 532 0
      system/src/Grav/Framework/Parsedown/ParsedownExtra.php
  47. 3 4
      system/src/Grav/Framework/Route/Route.php
  48. 30 24
      system/src/Grav/Framework/Session/Session.php
  49. 5 0
      user/config/plugins/aboutme.yaml
  50. 2 0
      user/config/plugins/admin-media-actions.yaml
  51. 9 7
      user/config/plugins/simplesearch.yaml
  52. 5 0
      user/config/plugins/sitemap.yaml
  53. 3 5
      user/config/system.yaml
  54. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/at-large/events.md
  55. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/cicatrices/events.md
  56. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/exhibitions-jean-marais/events.md
  57. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/mr-majidi-and-the-electricity-box/events.md
  58. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/nour-el-saleh-exquisite-farces/events.md
  59. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/parfum-depines-phillips-x-vo-curations/events.md
  60. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/pas-de-corps-sonya-derviz/events.md
  61. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/sara-berman-matter-out-of-place/events.md
  62. 1 1
      user/pages/02.whats-on/01.exhibitions/current-upcoming/urban-waves-andrei-costache-richie-culver-an-d-morgan-ward/events.md
  63. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/Sola Olulode at V.O Curations.jpg
  64. 2 2
      user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/events.md
  65. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Dig #7 2019.jpeg
  66. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through (detail) 2020.jpeg
  67. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through 2020.jpeg
  68. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down (detail) 2020.jpeg
  69. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down 2020.jpeg
  70. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson 40 Elephants 2020.jpeg
  71. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson A Pound of Flower 2020.jpeg
  72. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Installation Frances Gibson Alexandre Canonico André Figueiredo da Silva.jpeg
  73. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast (detail) 2020.jpeg
  74. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast 2020.jpeg
  75. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall (detail) 2020.jpeg
  76. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall 2020.jpeg
  77. BIN
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker and Alexandré Canonico.jpeg
  78. 1 2
      user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/events.md
  79. 0 0
      user/pages/03.studio-programme/01.apply/form.md
  80. BIN
      user/pages/03.studio-programme/02.about/VO Curations - 12th Floor.jpg
  81. 1 1
      user/pages/03.studio-programme/02.about/default.md
  82. 1 1
      user/pages/04.residence/02.current-upcoming/blog.md
  83. BIN
      user/pages/04.residence/02.current-upcoming/emma-prempeh/20190611_090033694_iOS.jpg
  84. 1 2
      user/pages/04.residence/02.current-upcoming/emma-prempeh/artistes.md
  85. BIN
      user/pages/04.residence/02.current-upcoming/sola-olulode/Sola Olulode at V.O Curations.JPG
  86. 4 6
      user/pages/04.residence/02.current-upcoming/sola-olulode/artistes.md
  87. BIN
      user/pages/04.residence/02.current-upcoming/victor-man/Nour El Saleh, Detail 'Four Times as Big, Twice as Small' 2018.jpeg
  88. 0 11
      user/pages/04.residence/02.current-upcoming/victor-man/artistes.md
  89. 1 0
      user/pages/04.residence/blog.md
  90. 0 1
      user/pages/04.residence/past/archives.md
  91. 1 1
      user/pages/05.news/01.press/_'phillips-x-vo-curations-presents-perfume-of-thorns'-outset-contemporary/text.md
  92. 0 10
      user/pages/05.news/01.press/_sam-cornish-on-olivia-bax/text.md
  93. 0 9
      user/pages/05.news/01.press/paintings-by-sola-olulode-explore-the-honeymoon-phase-of-romance/default.md
  94. BIN
      user/pages/05.news/02.news/_adriano-amaral-et-al-euphoria-tick-tack-antwerpen-belgium/Adriano Amaral, Untitled, 2018.jpg
  95. 0 11
      user/pages/05.news/02.news/_adriano-amaral-et-al-euphoria-tick-tack-antwerpen-belgium/news.en.md
  96. 0 11
      user/pages/05.news/02.news/_daiga-grantina-what-eats-around-itself-new-museum-new-york-usa/news.en.md
  97. BIN
      user/pages/05.news/02.news/_isaac-lythgoe-railway-spine-super-dakota-brussels-belgium/Isaac Lythgoe 'Railway Spine'.jpg
  98. 1 1
      user/pages/05.news/02.news/_isaac-lythgoe-railway-spine-super-dakota-brussels-belgium/news.md
  99. 0 11
      user/pages/05.news/02.news/_jean-marie-appriou-biennale-de-lyon-lyon-france/news.en.md
  100. 1 1
      user/pages/05.news/02.news/_jean-marie-appriou-the-horses-central-park-new-york-usa/news.md

+ 2 - 0
.gitignore

@@ -27,6 +27,8 @@ user/themes/quark/*
 !user/themes/quark/.*
 user/localhost/config/security.yaml
 user/config/security.yaml
+user/pages/*
+user/pages/.*
 
 # OS Generated
 .DS_Store*

+ 131 - 3
CHANGELOG.md

@@ -1,3 +1,131 @@
+# v1.6.28
+## 10/07/2020
+
+1. [](#new)
+    * Back-ported twig `{% cache %}` tag from Grav 1.7
+    * Back-ported `Utils::fullPath()` helper function from Grav 1.7
+    * Back-ported `{{ svg_image() }}` Twig function from Grav 1.7
+    * Back-ported `Folder::countChildren()` function from Grav 1.7
+1. [](#improved)
+    * Use new `{{ theme_var() }}` enhanced logic from Grav 1.7
+    * Improved `Excerpts` class with fixes and functionality from Grav 1.7
+    * Ensure `onBlueprintCreated()` is initialized first
+    * Do not cache default `404` error page
+    * Composer update of vendor libraries
+    * Switched `Caddyfile` to use new Caddy2 syntax + improved usability
+1. [](#bugfix)
+    * Fixed Referer reference during GPM calls.
+    * Fixed fatal error with toggled lists
+
+# v1.6.27
+## 09/01/2020
+
+1. [](#improved)
+    * Right trim route for safety
+    * Use the proper ellipsis for summary [#2939](https://github.com/getgrav/grav/pull/2939)
+    * Left pad schedule times with zeros [#2921](https://github.com/getgrav/grav/pull/2921)
+
+# v1.6.26
+## 06/08/2020
+
+1. [](#improved)
+    * Added new configuration option to control the supported attributes in markdown links [#2882](https://github.com/getgrav/grav/issues/2882)
+1. [](#bugfix)
+    * Fixed blueprint for `system.pages.hide_empty_folders` [#1925](https://github.com/getgrav/grav/issues/2925)
+    * JSON Route of homepage with no ‘route’ set is valid
+    * Fix case-insensitive search of location header [form#425](https://github.com/getgrav/grav-plugin-form/issues/425)
+
+# v1.6.25
+## 05/14/2020
+
+1. [](#improved)
+    * Added system configuration support for `HTTP_X_Forwarded` headers (host disabled by default)
+    * Updated `PHPUserAgentParser` to 1.0.0
+    * Bump `Go` to version 1.13 in `travis.yaml`
+
+# v1.6.24
+## 04/27/2020
+
+1. [](#improved)
+    * Added support for `X-Forwarded-Host` [#2891](https://github.com/getgrav/grav/pull/2891)
+    * Disable XDebug in Travis builds
+
+# v1.6.23
+## 03/19/2020
+
+1. [](#new)
+    * Moved `Parsedown` 1.6 and `ParsedownExtra` 0.7 into `Grav\Framework\Parsedown` to allow fixes
+    * Added `aliases.php` with references to direct `\Parsedown` and `\ParsedownExtra` references
+1. [](#improved)
+    * Upgraded `jQuery` to latest 3.4.1 version [#2859](https://github.com/getgrav/grav/issues/2859)
+1. [](#bugfix)
+    * Fixed PHP 7.4 issue in ParsedownExtra [#2832](https://github.com/getgrav/grav/issues/2832)
+    * Fix for [user reported](https://twitter.com/OriginalSicksec) CVE path-based open redirect
+    * Fix for `stream_set_option` error with PHP 7.4 via Toolbox#28 [#2850](https://github.com/getgrav/grav/issues/2850)
+
+# v1.6.22
+## 03/05/2020
+
+1. [](#new)
+    * Added `Pages::reset()` method
+1. [](#improved)
+    * Updated Negotiation library to address issues [#2513](https://github.com/getgrav/grav/issues/2513)
+1. [](#bugfix)
+    * Fixed issue with search plugins not being able to switch between page translations
+    * Fixed issues with `Pages::baseRoute()` not picking up active language reliably
+    * Reverted `validation: strict` fix as it breaks sites, see [#1273](https://github.com/getgrav/grav/issues/1273)
+
+# v1.6.21
+## 02/11/2020
+
+1. [](#new)
+    * Added `ConsoleCommand::setLanguage()` method to set language to be used from CLI
+    * Added `ConsoleCommand::initializeGrav()` method to properly set up Grav instance to be used from CLI
+    * Added `ConsoleCommand::initializePlugins()`method to properly set up all plugins to be used from CLI
+    * Added `ConsoleCommand::initializeThemes()`method to properly set up current theme to be used from CLI
+    * Added `ConsoleCommand::initializePages()` method to properly set up pages to be used from CLI
+1. [](#improved)
+    * Vendor updates
+1. [](#bugfix)
+    * Fixed `bin/plugin` CLI calling `$themes->init()` way too early (removed it, use above methods instead)
+    * Fixed call to `$grav['page']` crashing CLI
+    * Fixed encoding problems when PHP INI setting `default_charset` is not `utf-8` [#2154](https://github.com/getgrav/grav/issues/2154)
+
+# v1.6.20
+## 02/03/2020
+
+1. [](#bugfix)
+    * Fixed incorrect routing caused by `str_replace()` in `Uri::init()` [#2754](https://github.com/getgrav/grav/issues/2754)
+    * Fixed session cookie is being set twice in the HTTP header [#2745](https://github.com/getgrav/grav/issues/2745)
+    * Fixed session not restarting if user was invalid (downgrading from Grav 1.7)
+    * Fixed filesystem iterator calls with non-existing folders
+    * Fixed `checkbox` field not being saved, requires also Form v4.0.2 [#1225](https://github.com/getgrav/grav/issues/1225)
+    * Fixed `validation: strict` not working in blueprints [#1273](https://github.com/getgrav/grav/issues/1273)
+    * Fixed `Data::filter()` removing empty fields (such as empty list) by default [#2805](https://github.com/getgrav/grav/issues/2805)
+    * Fixed fatal error with non-integer page param value [#2803](https://github.com/getgrav/grav/issues/2803)
+    * Fixed `Assets::addInlineJs()` parameter type mismatch between v1.5 and v1.6 [#2659](https://github.com/getgrav/grav/issues/2659)
+    * Fixed `site.metadata` saving issues [#2615](https://github.com/getgrav/grav/issues/2615)
+
+# v1.6.19
+## 12/04/2019
+
+1. [](#new)
+    * Catch PHP 7.4 deprecation messages and report them in debugbar instead of throwing fatal error
+1. [](#bugfix)
+    * Fixed fatal error when calling `{{ grav.undefined }}`
+    * Fixed multiple issues when there are no pages in the site
+    * PHP 7.4 fix for [#2750](https://github.com/getgrav/grav/issues/2750)
+
+# v1.6.18
+## 12/02/2019
+
+1. [](#bugfix)
+    * PHP 7.4 fix in `Pages::buildSort()`
+    * Updated vendor libraries for PHP 7.4 fixes in Twig and other libraries
+    * Fixed fatal error when `$page->id()` is null [#2731](https://github.com/getgrav/grav/pull/2731)
+    * Fixed cache conflicts on pages with no set id
+    * Fix rewrite rule for for `lighttpd` default config [#721](https://github.com/getgrav/grav/pull/2721)
+
 # v1.6.17
 ## 11/06/2019
 
@@ -50,7 +178,7 @@
     * 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)  
+    * 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)
@@ -69,7 +197,7 @@
     * 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
 
@@ -100,7 +228,7 @@
     * 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 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

+ 8 - 1
bin/gpm

@@ -28,6 +28,13 @@ if (!ini_get('date.timezone')) {
     date_default_timezone_set('UTC');
 }
 
+// Set internal encoding.
+if (!\extension_loaded('mbstring')) {
+    die("'mbstring' extension is not loaded.  This is required for Grav to run correctly");
+}
+@ini_set('default_charset', 'UTF-8');
+mb_internal_encoding('UTF-8');
+
 if (!file_exists(GRAV_ROOT . '/index.php')) {
     exit('FATAL: Must be run from ROOT directory of Grav!');
 }
@@ -55,7 +62,7 @@ $grav->setup($environment);
 
 $grav['config']->init();
 $grav['uri']->init();
-$grav['users'];
+$grav['accounts'];
 
 $app = new Application('Grav Package Manager', GRAV_VERSION);
 $app->addCommands(array(

+ 11 - 4
bin/grav

@@ -25,6 +25,17 @@ if (version_compare($ver = PHP_VERSION, $req = GRAV_PHP_MIN, '<')) {
     exit(sprintf("You are running PHP %s, but Grav needs at least PHP %s to run.\n", $ver, $req));
 }
 
+if (!ini_get('date.timezone')) {
+    date_default_timezone_set('UTC');
+}
+
+// Set internal encoding.
+if (!\extension_loaded('mbstring')) {
+    die("'mbstring' extension is not loaded.  This is required for Grav to run correctly");
+}
+@ini_set('default_charset', 'UTF-8');
+mb_internal_encoding('UTF-8');
+
 $climate = new League\CLImate\CLImate;
 $climate->arguments->add([
     'environment' => [
@@ -42,10 +53,6 @@ $environment = $climate->arguments->get('environment');
 $grav = Grav::instance(array('loader' => $autoload));
 $grav->setup($environment);
 
-if (!ini_get('date.timezone')) {
-    date_default_timezone_set('UTC');
-}
-
 if (!file_exists(GRAV_ROOT . '/index.php')) {
     exit('FATAL: Must be run from ROOT directory of Grav!');
 }

+ 8 - 6
bin/plugin

@@ -32,6 +32,13 @@ if (!ini_get('date.timezone')) {
     date_default_timezone_set('UTC');
 }
 
+// Set internal encoding.
+if (!\extension_loaded('mbstring')) {
+    die("'mbstring' extension is not loaded.  This is required for Grav to run correctly");
+}
+@ini_set('default_charset', 'UTF-8');
+mb_internal_encoding('UTF-8');
+
 if (!file_exists(GRAV_ROOT . '/index.php')) {
     exit('FATAL: Must be run from ROOT directory of Grav!');
 }
@@ -51,12 +58,7 @@ $environment = $climate->arguments->get('environment');
 
 $grav = Grav::instance(array('loader' => $autoload));
 $grav->setup($environment);
-
-$grav['config']->init();
-$grav['uri']->init();
-$grav['users'];
-$grav['plugins']->init();
-$grav['themes']->init();
+$grav->initializeCli();
 
 $app     = new Application('Grav Plugins Commands', GRAV_VERSION);
 $pattern = '([A-Z]\w+Command\.php)';

+ 5 - 6
composer.json

@@ -28,8 +28,6 @@
         "kodus/psr7-server": "*",
         "nyholm/psr7": "^1.0",
         "twig/twig": "~1.40",
-        "erusev/parsedown": "1.6.4",
-        "erusev/parsedown-extra": "~0.7",
         "symfony/yaml": "~4.2.0",
         "symfony/console": "~4.2.0",
         "symfony/event-dispatcher": "~4.2.0",
@@ -42,9 +40,9 @@
         "matthiasmullie/minify": "^1.3",
         "monolog/monolog": "~1.0",
         "gregwar/image": "2.*",
-        "donatj/phpuseragentparser": "~0.10",
+        "donatj/phpuseragentparser": "~1.0",
         "pimple/pimple": "~3.2",
-        "rockettheme/toolbox": "~1.4",
+        "rockettheme/toolbox": "~1.4.0",
         "maximebf/debugbar": "~1.15",
         "league/climate": "^3.4",
         "antoligy/dom-string-iterators": "^1.0",
@@ -52,7 +50,7 @@
         "composer/ca-bundle": "^1.0",
         "dragonmantank/cron-expression": "^1.2",
         "phive/twig-extensions-deferred": "^1.0",
-        "willdurand/negotiation": "^2.3"
+        "willdurand/negotiation": "2.x-dev"
     },
     "require-dev": {
         "codeception/codeception": "^2.4",
@@ -86,7 +84,8 @@
             "Grav\\": "system/src/Grav"
         },
         "files": [
-            "system/defines.php"
+            "system/defines.php",
+            "system/aliases.php"
         ]
     },
     "archive": {

File diff suppressed because it is too large
+ 206 - 254
composer.lock


+ 6 - 0
fixperms.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+chown -R kevin:http .
+find . -type f -exec chmod 664 {} \;
+find ./bin -type f -exec chmod 775 {} \;
+find . -type d -exec chmod 775 {} \;
+find . -type d -exec chmod +s {} \;

+ 10 - 9
index.php

@@ -20,6 +20,16 @@ if (PHP_SAPI === 'cli-server' && !isset($_SERVER['PHP_CLI_ROUTER'])) {
     die("PHP webserver requires a router to run Grav, please use: <pre>php -S {$_SERVER['SERVER_NAME']}:{$_SERVER['SERVER_PORT']} system/router.php</pre>");
 }
 
+// Set timezone to default, falls back to system if php.ini not set
+date_default_timezone_set(@date_default_timezone_get());
+
+// Set internal encoding.
+if (!\extension_loaded('mbstring')) {
+    die("'mbstring' extension is not loaded.  This is required for Grav to run correctly");
+}
+@ini_set('default_charset', 'UTF-8');
+mb_internal_encoding('UTF-8');
+
 // Ensure vendor libraries exist
 $autoload = __DIR__ . '/vendor/autoload.php';
 if (!is_file($autoload)) {
@@ -32,15 +42,6 @@ $loader = require $autoload;
 use Grav\Common\Grav;
 use RocketTheme\Toolbox\Event\Event;
 
-// Set timezone to default, falls back to system if php.ini not set
-date_default_timezone_set(@date_default_timezone_get());
-
-// Set internal encoding if mbstring loaded
-if (!\extension_loaded('mbstring')) {
-    die("'mbstring' extension is not loaded.  This is required for Grav to run correctly");
-}
-mb_internal_encoding('UTF-8');
-
 // Get the Grav instance
 $grav = Grav::instance(
     array(

+ 5 - 0
system/aliases.php

@@ -0,0 +1,5 @@
+<?php
+
+/** Moved from non-namespaced classes to Grav Framework */
+class_alias(Grav\Framework\Parsedown\Parsedown::class, '\Parsedown');
+class_alias(Grav\Framework\Parsedown\ParsedownExtra::class, '\ParsedownExtra');

File diff suppressed because it is too large
+ 0 - 1
system/assets/jquery/jquery-3.x.min.js


+ 64 - 7
system/blueprints/config/system.yaml

@@ -241,13 +241,15 @@ form:
                                 type: commalist
 
                         pages.hide_empty_folders:
-                            type: selectize
-                            size: large
-                            label: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS
-                            help: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS_HELP
-                            classes: fancy
-                            validate:
-                                type: commalist
+                          type: toggle
+                          label: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS
+                          help: PLUGIN_ADMIN.HIDE_EMPTY_FOLDERS_HELP
+                          highlight: 0
+                          options:
+                            1: PLUGIN_ADMIN.YES
+                            0: PLUGIN_ADMIN.NO
+                          validate:
+                            type: bool
 
                         pages.url_taxonomy_filters:
                             type: toggle
@@ -513,6 +515,16 @@ form:
                             validate:
                                 type: bool
 
+                        pages.markdown.valid_link_attributes:
+                          type: selectize
+                          size: large
+                          label: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES
+                          help: PLUGIN_ADMIN.VALID_LINK_ATTRIBUTES_HELP
+                          placeholder: "rel, target, id, class, classes"
+                          classes: fancy
+                          validate:
+                            type: commalist
+
                 caching:
                     type: tab
                     title: PLUGIN_ADMIN.CACHING
@@ -1379,6 +1391,51 @@ form:
                             label: PLUGIN_ADMIN.CUSTOM_BASE_URL
                             help: PLUGIN_ADMIN.CUSTOM_BASE_URL_HELP
 
+                        http_x_forwarded.protocol:
+                          type: toggle
+                          label: HTTP_X_FORWARDED_PROTO Enabled
+                          highlight: 1
+                          default: 1
+                          options:
+                            1: PLUGIN_ADMIN.YES
+                            0: PLUGIN_ADMIN.NO
+                          validate:
+                            type: bool
+
+                        http_x_forwarded.host:
+                          type: toggle
+                          label: HTTP_X_FORWARDED_HOST Enabled
+                          highlight: 0
+                          default: 0
+                          options:
+                            1: PLUGIN_ADMIN.YES
+                            0: PLUGIN_ADMIN.NO
+                          validate:
+                            type: bool
+
+                        http_x_forwarded.port:
+                          type: toggle
+                          label: HTTP_X_FORWARDED_PORT Enabled
+                          highlight: 1
+                          default: 1
+                          options:
+                            1: PLUGIN_ADMIN.YES
+                            0: PLUGIN_ADMIN.NO
+                          validate:
+                            type: bool
+
+                        http_x_forwarded.ip:
+                          type: toggle
+                          label: HTTP_X_FORWARDED IP Enabled
+                          highlight: 1
+                          default: 1
+                          options:
+                            1: PLUGIN_ADMIN.YES
+                            0: PLUGIN_ADMIN.NO
+                          validate:
+                            type: bool
+
+
                         accounts.type:
                             type: hidden
 

+ 11 - 0
system/config/system.yaml

@@ -10,6 +10,11 @@ custom_base_url: ''                              # Set the base_url manually, e.
 username_regex: '^[a-z0-9_-]{3,16}$'             # Only lowercase chars, digits, dashes, underscores. 3 - 16 chars
 pwd_regex: '(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}' # At least one number, one uppercase and lowercase letter, and be at least 8+ chars
 intl_enabled: true                               # Special logic for PHP International Extension (mod_intl)
+http_x_forwarded:                                # Configuration options for the various HTTP_X_FORWARD headers
+  protocol: true
+  host: false
+  port: true
+  ip: true
 
 languages:
   supported: []                                  # List of languages supported. eg: [en, fr, de]
@@ -54,6 +59,12 @@ pages:
     special_chars:                               # List of special characters to automatically convert to entities
       '>': 'gt'
       '<': 'lt'
+    valid_link_attributes:                       # Valid attributes to pass through via markdown links
+      - rel
+      - target
+      - id
+      - class
+      - classes
   types: [html,htm,xml,txt,json,rss,atom]        # list of valid page types
   append_url_extension: ''                       # Append page's extension in Page urls (e.g. '.html' results in /path/page.html)
   expires: 604800                                # Page expires time in seconds (604800 seconds = 7 days)

+ 1 - 1
system/defines.php

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

+ 1 - 0
system/pages/notfound.md

@@ -2,4 +2,5 @@
 title: Not Found
 routable: false
 notfound: true
+expires: 0
 ---

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

@@ -51,7 +51,11 @@ trait LegacyAssetsTrait
                 // special case to handle old attributes being passed in
                 if (isset($arguments['attributes'])) {
                     $old_attributes = $arguments['attributes'];
-                    $arguments = array_merge($arguments, $old_attributes);
+                    if (is_array($old_attributes)) {
+                        $arguments = array_merge($arguments, $old_attributes);
+                    } else {
+                        $arguments['type'] = $old_attributes;
+                    }
                 }
                 unset($arguments['attributes']);
 

+ 2 - 0
system/src/Grav/Common/Browser.php

@@ -9,6 +9,8 @@
 
 namespace Grav\Common;
 
+use function donatj\UserAgent\parse_user_agent;
+
 /**
  * Internally uses the PhpUserAgent package https://github.com/donatj/PhpUserAgent
  */

+ 16 - 5
system/src/Grav/Common/Data/BlueprintSchema.php

@@ -136,7 +136,7 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
     {
         $messages = $this->checkRequired($data, $rules);
 
-        foreach ($data as $key => $field) {
+        foreach ($data as $key => $child) {
             $val = $rules[$key] ?? $rules['*'] ?? null;
             $rule = \is_string($val) ? $this->items[$val] : null;
 
@@ -147,10 +147,10 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
                     continue;
                 }
 
-                $messages += Validation::validate($field, $rule);
-            } elseif (\is_array($field) && \is_array($val)) {
+                $messages += Validation::validate($child, $rule);
+            } elseif (\is_array($child) && \is_array($val)) {
                 // Array has been defined in blueprints.
-                $messages += $this->validateArray($field, $val);
+                $messages += $this->validateArray($child, $val);
             } elseif (isset($rules['validation']) && $rules['validation'] === 'strict') {
                 // Undefined/extra item.
                 throw new \RuntimeException(sprintf('%s is not defined in blueprints', $key));
@@ -232,8 +232,19 @@ class BlueprintSchema extends BlueprintSchemaBase implements ExportInterface
                 continue;
             }
             if (is_array($value)) {
+                // Special toggle handling for all the nested data.
+                $toggle = $toggles[$key] ?? [];
+                if (!is_array($toggle)) {
+                    if (!$toggle) {
+                        $data[$key] = null;
+
+                        continue;
+                    }
+
+                    $toggle = [];
+                }
                 // Recursively fetch the items.
-                $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggles[$key] ?? [], $value);
+                $data[$key] = $this->processFormRecursive($data[$key] ?? null, $toggle, $value);
             } else {
                 $field = $this->get($value);
                 // Do not add the field if:

+ 30 - 2
system/src/Grav/Common/Data/Data.php

@@ -32,6 +32,12 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable
     /** @var File */
     protected $storage;
 
+    /** @var bool */
+    private $missingValuesAsNull = false;
+
+    /** @var bool */
+    private $keepEmptyValues = true;
+
     /**
      * @param array $items
      * @param Blueprint|callable $blueprints
@@ -42,6 +48,28 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable
         $this->blueprints = $blueprints;
     }
 
+    /**
+     * @param bool $value
+     * @return $this
+     */
+    public function setKeepEmptyValues(bool $value)
+    {
+        $this->keepEmptyValues = $value;
+
+        return $this;
+    }
+
+    /**
+     * @param bool $value
+     * @return $this
+     */
+    public function setMissingValuesAsNull(bool $value)
+    {
+        $this->missingValuesAsNull = $value;
+
+        return $this;
+    }
+
     /**
      * Get value by using dot notation for nested arrays/objects.
      *
@@ -202,8 +230,8 @@ class Data implements DataInterface, \ArrayAccess, \Countable, \JsonSerializable
     public function filter()
     {
         $args = func_get_args();
-        $missingValuesAsNull = (bool)(array_shift($args) ?: false);
-        $keepEmptyValues = (bool)(array_shift($args) ?: false);
+        $missingValuesAsNull = (bool)(array_shift($args) ?? $this->missingValuesAsNull);
+        $keepEmptyValues = (bool)(array_shift($args) ?? $this->keepEmptyValues);
 
         $this->items = $this->blueprints()->filter($this->items, $missingValuesAsNull, $keepEmptyValues);
 

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

@@ -166,9 +166,18 @@ class Validation
         return (string) $value;
     }
 
+    /**
+     * @param mixed $value
+     * @param array $params
+     * @param array $field
+     * @return string|null
+     */
     protected static function filterCheckbox($value, array $params, array $field)
     {
-        return (bool) $value;
+        $value = (string)$value;
+        $field_value = (string)($field['value'] ?? '1');
+
+        return $value === $field_value ? $value : null;
     }
 
     protected static function filterCommaList($value, array $params, array $field)

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

@@ -347,7 +347,7 @@ class Debugger
      */
     public function deprecatedErrorHandler($errno, $errstr, $errfile, $errline)
     {
-        if ($errno !== E_USER_DEPRECATED) {
+        if ($errno !== E_USER_DEPRECATED && $errno !== E_DEPRECATED) {
             if ($this->errorHandler) {
                 return \call_user_func($this->errorHandler, $errno, $errstr, $errfile, $errline);
             }

+ 40 - 11
system/src/Grav/Common/Filesystem/Folder.php

@@ -22,6 +22,10 @@ abstract class Folder
      */
     public static function lastModifiedFolder($path)
     {
+        if (!file_exists($path)) {
+            return 0;
+        }
+
         $last_modified = 0;
 
         /** @var UniformResourceLocator $locator */
@@ -56,6 +60,10 @@ abstract class Folder
      */
     public static function lastModifiedFile($path, $extensions = 'md|yaml')
     {
+        if (!file_exists($path)) {
+            return 0;
+        }
+
         $last_modified = 0;
 
         /** @var UniformResourceLocator $locator */
@@ -92,21 +100,24 @@ abstract class Folder
      */
     public static function hashAllFiles($path)
     {
-        $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
         $files = [];
 
-        /** @var UniformResourceLocator $locator */
-        $locator = Grav::instance()['locator'];
-        if ($locator->isStream($path)) {
-            $directory = $locator->getRecursiveIterator($path, $flags);
-        } else {
-            $directory = new \RecursiveDirectoryIterator($path, $flags);
-        }
+        if (file_exists($path)) {
+            $flags = \RecursiveDirectoryIterator::SKIP_DOTS;
+
+            /** @var UniformResourceLocator $locator */
+            $locator = Grav::instance()['locator'];
+            if ($locator->isStream($path)) {
+                $directory = $locator->getRecursiveIterator($path, $flags);
+            } else {
+                $directory = new \RecursiveDirectoryIterator($path, $flags);
+            }
 
-        $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST);
+            $iterator = new \RecursiveIteratorIterator($directory, \RecursiveIteratorIterator::SELF_FIRST);
 
-        foreach ($iterator as $file) {
-            $files[] = $file->getPathname() . '?'. $file->getMTime();
+            foreach ($iterator as $file) {
+                $files[] = $file->getPathname() . '?'. $file->getMTime();
+            }
         }
 
         return md5(serialize($files));
@@ -199,6 +210,9 @@ abstract class Folder
         if ($path === false) {
             throw new \RuntimeException("Path doesn't exist.");
         }
+        if (!file_exists($path)) {
+            return [];
+        }
 
         $compare = isset($params['compare']) ? 'get' . $params['compare'] : null;
         $pattern = $params['pattern'] ?? null;
@@ -494,4 +508,19 @@ abstract class Folder
 
         return $include_target ? @rmdir($folder) : true;
     }
+
+    /**
+     * Does a directory contain children
+     *
+     * @param string $directory
+     * @return int|false
+     */
+    public static function countChildren($directory) {
+        if (!is_dir($directory)) {
+            return false;
+        }
+        $directories = glob($directory . '/*', GLOB_ONLYDIR);
+
+        return count($directories);
+    }
 }

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

@@ -11,6 +11,7 @@ namespace Grav\Common\GPM\Local;
 
 use Grav\Common\Data\Data;
 use Grav\Common\GPM\Common\Package as BasePackage;
+use Grav\Framework\Parsedown\Parsedown;
 
 class Package extends BasePackage
 {
@@ -23,7 +24,7 @@ class Package extends BasePackage
 
         $this->settings = $package->toArray();
 
-        $html_description = \Parsedown::instance()->line($this->__get('description'));
+        $html_description = Parsedown::instance()->line($this->__get('description'));
         $this->data->set('slug', $package->__get('slug'));
         $this->data->set('description_html', $html_description);
         $this->data->set('description_plain', strip_tags($html_description));

+ 4 - 2
system/src/Grav/Common/GPM/Response.php

@@ -36,7 +36,6 @@ class Response
     private static $defaults = [
 
         'curl'  => [
-            CURLOPT_REFERER        => 'Grav GPM',
             CURLOPT_USERAGENT      => 'Grav GPM',
             CURLOPT_RETURNTRANSFER => true,
             CURLOPT_FOLLOWLOCATION => true,
@@ -285,6 +284,7 @@ class Response
             $options['fopen']['notification'] = ['self', 'progress'];
         }
 
+        $options['fopen']['header'] = 'Referer: ' . Grav::instance()['uri']->rootUrl(true);
         if (isset($options['fopen']['ssl'])) {
             $ssl = $options['fopen']['ssl'];
             unset($options['fopen']['ssl']);
@@ -367,6 +367,8 @@ class Response
      */
     private static function curlExecFollow($ch, $options, $callback)
     {
+        curl_setopt_array($ch, [ CURLOPT_REFERER => Grav::instance()['uri']->rootUrl(true) ]);
+
         if ($callback) {
             curl_setopt_array(
                 $ch,
@@ -409,7 +411,7 @@ class Response
             } else {
                 $code = (int)curl_getinfo($rch, CURLINFO_HTTP_CODE);
                 if ($code === 301 || $code === 302 || $code === 303) {
-                    preg_match('/Location:(.*?)\n/', $header, $matches);
+                    preg_match('/(?:^|\n)Location:(.*?)\n/i', $header, $matches);
                     $uri = trim(array_pop($matches));
                 } else {
                     $code = 0;

+ 35 - 4
system/src/Grav/Common/Grav.php

@@ -170,6 +170,29 @@ class Grav extends Container
         return $this;
     }
 
+    /**
+     * Initialize CLI environment.
+     *
+     * Call after `$grav->setup($environment)`
+     *
+     * - Load configuration
+     * - Disable debugger
+     * - Set timezone, locale
+     * - Load plugins
+     * - Set Users type to be used in the site
+     *
+     * This method WILL NOT initialize assets, twig or pages.
+     *
+     * @param string|null $environment
+     * @return $this
+     */
+    public function initializeCli()
+    {
+        InitializeProcessor::initializeCli($this);
+
+        return $this;
+    }
+
     /**
      * Process a request
      */
@@ -238,7 +261,7 @@ class Grav extends Container
         );
 
         $default = function (ServerRequestInterface $request) {
-            return new Response(404);
+            return new Response(404, ['Expires' => 0, 'Cache-Control' => 'no-cache, no-store, must-revalidate'], 'Not Found');
         };
 
         /** @var Debugger $debugger */
@@ -293,7 +316,10 @@ class Grav extends Container
         /** @var Uri $uri */
         $uri = $this['uri'];
 
-        //Check for code in route
+        // Clean route for redirect
+        $route = preg_replace("#^\/[\\\/]+\/#", '/', $route);
+
+         // Check for code in route
         $regex = '/.*(\[(30[1-7])\])$/';
         preg_match($regex, $route, $matches);
         if ($matches) {
@@ -439,11 +465,16 @@ class Grav extends Container
      * Used to call closures.
      *
      * Source: http://stackoverflow.com/questions/419804/closures-as-class-members
+     *
+     * @param string $method
+     * @param array $args
+     * @return
      */
     public function __call($method, $args)
     {
-        $closure = $this->{$method};
-        \call_user_func_array($closure, $args);
+        $closure = $this->{$method} ?? null;
+
+        return is_callable($closure) ? $closure(...$args) : null;
     }
 
     /**

+ 42 - 9
system/src/Grav/Common/Helpers/Excerpts.php

@@ -3,7 +3,7 @@
 /**
  * @package    Grav\Common\Helpers
  *
- * @copyright  Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @copyright  Copyright (C) 2015 - 2020 Trilby Media, LLC. All rights reserved.
  * @license    MIT License; see LICENSE file for details.
  */
 
@@ -32,7 +32,7 @@ class Excerpts
         $excerpt = static::processLinkExcerpt($excerpt, $page, 'image');
 
         $excerpt['element']['attributes']['src'] = $excerpt['element']['attributes']['href'];
-        unset ($excerpt['element']['attributes']['href']);
+        unset($excerpt['element']['attributes']['href']);
 
         $excerpt = static::processImageExcerpt($excerpt, $page);
 
@@ -43,6 +43,26 @@ class Excerpts
         return $html;
     }
 
+    /**
+     * Process Grav page link URL from HTML tag
+     *
+     * @param string $html              HTML tag e.g. `<a href="../foo">Page Link</a>`
+     * @param PageInterface|null $page  Page, defaults to the current page object
+     * @return string                   Returns final HTML string
+     */
+    public static function processLinkHtml($html, PageInterface $page = null)
+    {
+        $excerpt = static::getExcerptFromHtml($html, 'a');
+
+        $original_href = $excerpt['element']['attributes']['href'];
+        $excerpt = static::processLinkExcerpt($excerpt, $page, 'link');
+        $excerpt['element']['attributes']['data-href'] = $original_href;
+
+        $html = static::getHtmlFromExcerpt($excerpt);
+
+        return $html;
+    }
+
     /**
      * Get an Excerpt array from a chunk of HTML
      *
@@ -52,22 +72,35 @@ class Excerpts
      */
     public static function getExcerptFromHtml($html, $tag)
     {
-        $doc = new \DOMDocument();
-        $doc->loadHTML($html);
-        $images = $doc->getElementsByTagName($tag);
+        $doc = new \DOMDocument('1.0', 'UTF-8');
+        $internalErrors = libxml_use_internal_errors(true);
+        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
+        libxml_use_internal_errors($internalErrors);
+
+        $elements = $doc->getElementsByTagName($tag);
         $excerpt = null;
+        $inner = [];
 
-        foreach ($images as $image) {
+        /** @var \DOMElement $element */
+        foreach ($elements as $element) {
             $attributes = [];
-            foreach ($image->attributes as $name => $value) {
+            foreach ($element->attributes as $name => $value) {
                 $attributes[$name] = $value->value;
             }
             $excerpt = [
                 'element' => [
-                    'name'       => $image->tagName,
+                    'name'       => $element->tagName,
                     'attributes' => $attributes
                 ]
             ];
+
+            foreach ($element->childNodes as $node) {
+                $inner[] = $doc->saveHTML($node);
+            }
+
+            $excerpt = array_merge_recursive($excerpt, ['element' => ['text' => implode('', $inner)]]);
+
+
         }
 
         return $excerpt;
@@ -95,7 +128,7 @@ class Excerpts
 
         if (isset($element['text'])) {
             $html .= '>';
-            $html .= $element['text'];
+            $html .= is_array($element['text']) ? static::getHtmlFromExcerpt(['element' => $element['text']]) : $element['text'];
             $html .= '</'.$element['name'].'>';
         } else {
             $html .= ' />';

+ 2 - 1
system/src/Grav/Common/Markdown/Parsedown.php

@@ -11,8 +11,9 @@ namespace Grav\Common\Markdown;
 
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Page\Markdown\Excerpts;
+use Grav\Framework\Parsedown\Parsedown as ParsedownLib;
 
-class Parsedown extends \Parsedown
+class Parsedown extends ParsedownLib
 {
     use ParsedownGravTrait;
 

+ 2 - 1
system/src/Grav/Common/Markdown/ParsedownExtra.php

@@ -11,8 +11,9 @@ namespace Grav\Common\Markdown;
 
 use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Page\Markdown\Excerpts;
+use Grav\Framework\Parsedown\ParsedownExtra as ParsedownExtraLib;
 
-class ParsedownExtra extends \ParsedownExtra
+class ParsedownExtra extends ParsedownExtraLib
 {
     use ParsedownGravTrait;
 

+ 2 - 1
system/src/Grav/Common/Page/Markdown/Excerpts.php

@@ -87,7 +87,7 @@ class Excerpts
             );
 
             // Valid attributes supported.
-            $valid_attributes = ['rel', 'target', 'id', 'class', 'classes'];
+            $valid_attributes = Grav::instance()['config']->get('system.pages.markdown.valid_link_attributes');
 
             // Unless told to not process, go through actions.
             if (array_key_exists('noprocess', $actions)) {
@@ -232,6 +232,7 @@ class Excerpts
         $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(

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

@@ -516,6 +516,15 @@ class ImageMedium extends Medium
         return $this;
     }
 
+    /**
+     * Handle this commonly used variant
+     */
+    public function cropZoom()
+    {
+        $this->__call('zoomCrop', func_get_args());
+        return $this;
+    }
+
     /**
      * Forward the call to the image processing method.
      *
@@ -525,10 +534,6 @@ class ImageMedium extends Medium
      */
     public function __call($method, $args)
     {
-        if ($method === 'cropZoom') {
-            $method = 'zoomCrop';
-        }
-
         if (!\in_array($method, self::$magic_actions, true)) {
             return parent::__call($method, $args);
         }

+ 11 - 7
system/src/Grav/Common/Page/Page.php

@@ -608,12 +608,12 @@ class Page implements PageInterface
                 return $content;
             }
 
-            return mb_strimwidth($content, 0, $size, '...', 'utf-8');
+            return mb_strimwidth($content, 0, $size, '…', 'UTF-8');
         }
 
         $summary = Utils::truncateHtml($content, $size);
 
-        return html_entity_decode($summary);
+        return html_entity_decode($summary, ENT_COMPAT | ENT_HTML401, 'UTF-8');
     }
 
     /**
@@ -659,7 +659,7 @@ class Page implements PageInterface
             // Load cached content
             /** @var Cache $cache */
             $cache = Grav::instance()['cache'];
-            $cache_id = md5('page' . $this->id());
+            $cache_id = md5('page' . $this->getCacheKey());
             $content_obj = $cache->fetch($cache_id);
 
             if (is_array($content_obj)) {
@@ -865,7 +865,7 @@ class Page implements PageInterface
     public function cachePageContent()
     {
         $cache = Grav::instance()['cache'];
-        $cache_id = md5('page' . $this->id());
+        $cache_id = md5('page' . $this->getCacheKey());
         $cache->save($cache_id, ['content' => $this->content, 'content_meta' => $this->content_meta]);
     }
 
@@ -1200,7 +1200,7 @@ class Page implements PageInterface
     /**
      * @return string
      */
-    protected function getCacheKey(): string
+    public function getCacheKey(): string
     {
         return $this->id();
     }
@@ -1694,7 +1694,7 @@ class Page implements PageInterface
             $metadata['generator'] = 'GravCMS';
 
             // Get initial metadata for the page
-            $metadata = array_merge($metadata, Grav::instance()['config']->get('site.metadata'));
+            $metadata = array_merge($metadata, Grav::instance()['config']->get('site.metadata', []));
 
             if (isset($this->header->metadata) && is_array($this->header->metadata)) {
                 // Merge any site.metadata settings in with page metadata
@@ -2009,6 +2009,10 @@ class Page implements PageInterface
      */
     public function id($var = null)
     {
+        if (null === $this->id) {
+            // We need to set unique id to avoid potential cache conflicts between pages.
+            $var = time() . md5($this->filePath());
+        }
         if ($var !== null) {
             // store unique per language
             $active_lang = Grav::instance()['language']->getLanguage() ?: '';
@@ -2824,7 +2828,7 @@ class Page implements PageInterface
         if ($pagination) {
             $params = $collection->params();
 
-            $limit = $params['limit'] ?? 0;
+            $limit = (int)($params['limit'] ?? 0);
             $start = !empty($params['pagination']) ? ($uri->currentPage() - 1) * $limit : 0;
 
             if ($limit && $collection->count() > $limit) {

+ 43 - 23
system/src/Grav/Common/Page/Pages.php

@@ -95,6 +95,8 @@ class Pages
 
     protected $initialized = false;
 
+    protected $active_lang;
+
     /**
      * @var Types
      */
@@ -143,7 +145,7 @@ class Pages
      */
     public function baseRoute($lang = null)
     {
-        $key = $lang ?: 'default';
+        $key = $lang ?: $this->active_lang ?: 'default';
 
         if (!isset($this->baseRoute[$key])) {
             /** @var Language $language */
@@ -236,6 +238,16 @@ class Pages
         $this->check_method = strtolower($method);
     }
 
+    /**
+     * Reset pages (used in search indexing etc).
+     */
+    public function reset()
+    {
+        $this->initialized = false;
+
+        $this->init();
+    }
+
     /**
      * Class initialization. Must be called before using this class.
      */
@@ -598,8 +610,8 @@ class Pages
         }
 
         if (empty($blueprint->initialized)) {
-            $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
             $blueprint->initialized = true;
+            $this->grav->fireEvent('onBlueprintCreated', new Event(['blueprint' => $blueprint, 'type' => $type]));
         }
 
         return $blueprint;
@@ -958,6 +970,9 @@ class Pages
 
         $pages_dir = $locator->findResource('page://');
 
+        // Set active language
+        $this->active_lang = $language->getActive();
+
         if ($config->get('system.cache.enabled')) {
             /** @var Cache $cache */
             $cache = $this->grav['cache'];
@@ -982,17 +997,19 @@ class Pages
 
             $this->pages_cache_id = md5($pages_dir . $hash . $language->getActive() . $config->checksum());
 
-            list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cache->fetch($this->pages_cache_id);
-            if (!$this->instances) {
-                $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
+            $cached = $cache->fetch($this->pages_cache_id);
+            if ($cached) {
+                $this->grav['debugger']->addMessage('Page cache hit.');
 
-                // recurse pages and cache result
-                $this->resetPages($pages_dir);
+                list($this->instances, $this->routes, $this->children, $taxonomy_map, $this->sort) = $cached;
 
-            } else {
                 // If pages was found in cache, set the taxonomy
-                $this->grav['debugger']->addMessage('Page cache hit.');
                 $taxonomy->taxonomy($taxonomy_map);
+            } else {
+                $this->grav['debugger']->addMessage('Page cache missed, rebuilding pages..');
+
+                // recurse pages and cache result
+                $this->resetPages($pages_dir);
             }
         } else {
             $this->recurse($pages_dir);
@@ -1261,14 +1278,13 @@ class Pages
     {
         $list = [];
         $header_default = null;
-        $header_query = null;
+        $header_query = [];
 
         // do this header query work only once
         if (strpos($order_by, 'header.') === 0) {
-            $header_query = explode('|', str_replace('header.', '', $order_by));
-            if (isset($header_query[1])) {
-                $header_default = $header_query[1];
-            }
+            $query = explode('|', str_replace('header.', '', $order_by), 2);
+            $header_query = array_shift($query) ?? '';
+            $header_default = array_shift($query);
         }
 
         foreach ($pages as $key => $info) {
@@ -1306,11 +1322,17 @@ class Pages
                 case 'folder':
                     $list[$key] = $child->folder();
                     break;
-                case (is_string($header_query[0])):
-                    $child_header = new Header((array)$child->header());
-                    $header_value = $child_header->get($header_query[0]);
+                case 'manual':
+                case 'default':
+                default:
+                if (is_string($header_query)) {
+                    $child_header = $child->header();
+                    if (!$child_header instanceof Header) {
+                        $child_header = new Header((array)$child_header);
+                    }
+                    $header_value = $child_header->get($header_query);
                     if (is_array($header_value)) {
-                        $list[$key] = implode(',',$header_value);
+                        $list[$key] = implode(',', $header_value);
                     } elseif ($header_value) {
                         $list[$key] = $header_value;
                     } else {
@@ -1318,11 +1340,9 @@ class Pages
                     }
                     $sort_flags = $sort_flags ?: SORT_REGULAR;
                     break;
-                case 'manual':
-                case 'default':
-                default:
-                    $list[$key] = $key;
-                    $sort_flags = $sort_flags ?: SORT_REGULAR;
+                }
+                $list[$key] = $key;
+                $sort_flags = $sort_flags ?: SORT_REGULAR;
             }
         }
 

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

@@ -305,7 +305,7 @@ class Plugin implements EventSubscriberInterface, \ArrayAccess
 
             // Create new config object and set it on the page object so it's cached for next time
             $page->modifyHeader($class_name_merged, new Data($header));
-        } else if (isset($page_header->{$class_name_merged})) {
+        } elseif (isset($page_header->{$class_name_merged})) {
             $merged = $page_header->{$class_name_merged};
             $header = $merged->toArray();
         }

+ 48 - 0
system/src/Grav/Common/Processors/InitializeProcessor.php

@@ -10,6 +10,7 @@
 namespace Grav\Common\Processors;
 
 use Grav\Common\Config\Config;
+use Grav\Common\Grav;
 use Grav\Common\Uri;
 use Grav\Common\Utils;
 use Grav\Framework\Session\Exceptions\SessionException;
@@ -22,6 +23,22 @@ class InitializeProcessor extends ProcessorBase
     public $id = 'init';
     public $title = 'Initialize';
 
+    /** @var bool */
+    private static $cli_initialized = false;
+
+    /**
+     * @param Grav $grav
+     */
+    public static function initializeCli(Grav $grav)
+    {
+        if (!static::$cli_initialized) {
+            static::$cli_initialized = true;
+
+            $instance = new static($grav);
+            $instance->processCli();
+        }
+    }
+
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
     {
         $this->startTimer();
@@ -77,4 +94,35 @@ class InitializeProcessor extends ProcessorBase
 
         return $handler->handle($request);
     }
+
+    public function processCli(): void
+    {
+        // Load configuration.
+        $this->container['config']->init();
+        $this->container['plugins']->setup();
+
+        // Disable debugger.
+        $this->container['debugger']->enabled(false);
+
+        // Set timezone, locale.
+        /** @var Config $config */
+        $config = $this->container['config'];
+        $timezone = $config->get('system.timezone');
+        if ($timezone) {
+            date_default_timezone_set($timezone);
+        }
+        $this->container->setLocale();
+
+        // Load plugins.
+        $this->container['plugins']->init();
+
+        // Initialize URI.
+        /** @var Uri $uri */
+        $uri = $this->container['uri'];
+        $uri->init();
+
+        // Load accounts.
+        // TODO: remove in 2.0.
+        $this->container['accounts'];
+    }
 }

+ 2 - 2
system/src/Grav/Common/Scheduler/Cron.php

@@ -69,7 +69,7 @@ class Cron
             'name_year' => 'année',
             'text_period' => 'Chaque %s',
             'text_mins' => 'à %s minutes',
-            'text_time' => 'à %s:%s',
+            'text_time' => 'à %02s:%02s',
             'text_dow' => 'le %s',
             'text_month' => 'de %s',
             'text_dom' => 'le %s',
@@ -86,7 +86,7 @@ class Cron
             'name_year' => 'year',
             'text_period' => 'Every %s',
             'text_mins' => 'at %s minutes past the hour',
-            'text_time' => 'at %s:%s',
+            'text_time' => 'at %02s:%02s',
             'text_dow' => 'on %s',
             'text_month' => 'of %s',
             'text_dom' => 'on the %s',

+ 13 - 0
system/src/Grav/Common/Service/PagesServiceProvider.php

@@ -26,6 +26,19 @@ class PagesServiceProvider implements ServiceProviderInterface
             return new Pages($c);
         };
 
+        if (\defined('GRAV_CLI')) {
+            $container['page'] = static function ($c) {
+                $path = $c['locator']->findResource('system://pages/notfound.md');
+                $page = new Page();
+                $page->init(new \SplFileInfo($path));
+                $page->routable(false);
+
+                return $page;
+            };
+
+            return;
+        }
+
         $container['page'] = function ($c) {
             /** @var Grav $c */
 

+ 56 - 0
system/src/Grav/Common/Twig/Node/TwigNodeCache.php

@@ -0,0 +1,56 @@
+<?php
+
+/**
+ * @package    Grav\Common\Twig
+ *
+ * @copyright  Copyright (C) 2015 - 2020 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\Twig\Node;
+
+use Twig\Compiler;
+use Twig\Node\Expression\AbstractExpression;
+use Twig\Node\Node;
+
+class TwigNodeCache extends Node
+{
+    /**
+     * @param string    $key       unique name for key
+     * @param int       $lifetime  in seconds
+     * @param Node      $body
+     * @param integer   $lineno
+     * @param string    $tag
+     */
+    public function __construct(string $key, int $lifetime, Node $body, $lineno, $tag = null)
+    {
+        parent::__construct(array('body' => $body), array( 'key' => $key, 'lifetime' => $lifetime), $lineno, $tag);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function compile(Compiler $compiler)
+    {
+        $boo = $this->getAttribute('key');
+        $compiler
+            ->addDebugInfo($this)
+            ->write("\$cache = \\Grav\\Common\\Grav::instance()['cache'];\n")
+            ->write("\$key = \"twigcache-\" . \"" . $this->getAttribute('key') . "\";\n")
+            ->write("\$lifetime = " . $this->getAttribute('lifetime') . ";\n")
+            ->write("\$cache_body = \$cache->fetch(\$key);\n")
+            ->write("if (\$cache_body === false) {\n")
+            ->indent()
+                ->write("ob_start();\n")
+                    ->indent()
+                        ->subcompile($this->getNode('body'))
+                    ->outdent()
+                ->write("\n")
+                ->write("\$cache_body = ob_get_clean();\n")
+                ->write("\$cache->save(\$key, \$cache_body, \$lifetime);\n")
+            ->outdent()
+            ->write("}\n")
+            ->write("echo \$cache_body;\n")
+        ;
+    }
+}

+ 68 - 0
system/src/Grav/Common/Twig/TokenParser/TwigTokenParserCache.php

@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @package    Grav\Common\Twig
+ *
+ * @copyright  Copyright (C) 2015 - 2020 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Common\Twig\TokenParser;
+
+use Grav\Common\Grav;
+use Grav\Common\Twig\Node\TwigNodeCache;
+use Twig\Token;
+use Twig\TokenParser\AbstractTokenParser;
+
+/**
+ * Adds ability to cache Twig between tags.
+ *
+ * {% cache 600 %}
+ * {{ some_complex_work() }}
+ * {% endcache %}
+ *
+ * Where the `600` is an optional lifetime in seconds
+ */
+class TwigTokenParserCache extends AbstractTokenParser
+{
+    /**
+     * {@inheritDoc}
+     */
+    public function parse(Token $token)
+    {
+        $lineno = $token->getLine();
+        $stream = $this->parser->getStream();
+        $key = $this->parser->getVarName() . $lineno;
+        $lifetime = Grav::instance()['cache']->getLifetime();
+
+        // Check for optional lifetime override
+        if (!$stream->test(Token::BLOCK_END_TYPE)) {
+            $lifetime_expr = $this->parser->getExpressionParser()->parseExpression();
+            $lifetime = $lifetime_expr->getAttribute('value');
+        }
+
+        $stream->expect(Token::BLOCK_END_TYPE);
+        $body = $this->parser->subparse(array($this, 'decideCacheEnd'), true);
+        $stream->expect(Token::BLOCK_END_TYPE);
+
+        return new TwigNodeCache($key, $lifetime, $body, $lineno, $this->getTag());
+    }
+
+    /**
+     * Decide if current token marks end of cache block.
+     *
+     * @param Token $token
+     * @return bool
+     */
+    public function decideCacheEnd(Token $token)
+    {
+        return $token->test('endcache');
+    }
+    /**
+     * {@inheritDoc}
+     */
+    public function getTag()
+    {
+        return 'cache';
+    }
+}

+ 99 - 45
system/src/Grav/Common/Twig/TwigExtension.php

@@ -11,10 +11,12 @@ namespace Grav\Common\Twig;
 
 use Cron\CronExpression;
 use Grav\Common\Config\Config;
+use Grav\Common\Data\Data;
 use Grav\Common\Debugger;
 use Grav\Common\Grav;
 use Grav\Common\Language\Language;
 use Grav\Common\Page\Collection;
+use Grav\Common\Page\Interfaces\PageInterface;
 use Grav\Common\Page\Media;
 use Grav\Common\Scheduler\Cron;
 use Grav\Common\Security;
@@ -25,6 +27,7 @@ use Grav\Common\Twig\TokenParser\TwigTokenParserSwitch;
 use Grav\Common\Twig\TokenParser\TwigTokenParserThrow;
 use Grav\Common\Twig\TokenParser\TwigTokenParserTryCatch;
 use Grav\Common\Twig\TokenParser\TwigTokenParserMarkdown;
+use Grav\Common\Twig\TokenParser\TwigTokenParserCache;
 use Grav\Common\User\Interfaces\UserInterface;
 use Grav\Common\Utils;
 use Grav\Common\Yaml;
@@ -167,13 +170,14 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
             new \Twig_SimpleFunction('exif', [$this, 'exifFunc']),
             new \Twig_SimpleFunction('media_directory', [$this, 'mediaDirFunc']),
             new \Twig_SimpleFunction('body_class', [$this, 'bodyClassFunc']),
-            new \Twig_SimpleFunction('theme_var', [$this, 'themeVarFunc']),
-            new \Twig_SimpleFunction('header_var', [$this, 'pageHeaderVarFunc']),
+            new \Twig_SimpleFunction('theme_var', [$this, 'themeVarFunc'], ['needs_context' => true]),
+            new \Twig_SimpleFunction('header_var', [$this, 'pageHeaderVarFunc'], ['needs_context' => true]),
             new \Twig_SimpleFunction('read_file', [$this, 'readFileFunc']),
             new \Twig_SimpleFunction('nicenumber', [$this, 'niceNumberFunc']),
             new \Twig_SimpleFunction('nicefilesize', [$this, 'niceFilesizeFunc']),
             new \Twig_SimpleFunction('nicetime', [$this, 'nicetimeFunc']),
             new \Twig_SimpleFunction('cron', [$this, 'cronFunc']),
+            new \Twig_SimpleFunction('svg_image', [$this, 'svgImageFunction']),
             new \Twig_SimpleFunction('xss', [$this, 'xssFunc']),
 
 
@@ -201,6 +205,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
             new TwigTokenParserStyle(),
             new TwigTokenParserMarkdown(),
             new TwigTokenParserSwitch(),
+            new TwigTokenParserCache(),
         ];
     }
 
@@ -1055,7 +1060,7 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
      */
     public function jsonDecodeFilter($str, $assoc = false, $depth = 512, $options = 0)
     {
-        return json_decode(html_entity_decode($str), $assoc, $depth, $options);
+        return json_decode(html_entity_decode($str, ENT_COMPAT | ENT_HTML401, 'UTF-8'), $assoc, $depth, $options);
     }
 
     /**
@@ -1281,17 +1286,64 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
 
     /**
      * Get a theme variable
-     *
-     * @param string $var
-     * @param bool $default
+     * Will try to get the variable for the current page, if not found, it tries it's parent page on up to root.
+     * If still not found, will use the theme's configuration value,
+     * If still not found, will use the $default value passed in
+     *
+     * @param $context      Twig Context
+     * @param string $var variable to be found (using dot notation)
+     * @param null $default the default value to be used as last resort
+     * @param null $page an optional page to use for the current page
+     * @param bool $exists toggle to simply return the page where the variable is set, else null
      * @return string
      */
-    public function themeVarFunc($var, $default = null)
+    public function themeVarFunc($context, $var, $default = null, $page = null, $exists = false)
     {
-        $header = $this->grav['page']->header();
-        $header_classes = $header->{$var} ?? null;
+        $page = $page ?? $context['page'] ?? Grav::instance()['page'] ?? null;
+
+        // Try to find var in the page headers
+        if ($page instanceof PageInterface && $page->exists()) {
+            // Loop over pages and look for header vars
+            while ($page && !$page->root()) {
+                $header = new Data((array)$page->header());
+                $value = $header->get($var);
+                if (isset($value)) {
+                    if ($exists) {
+                        return $page;
+                    } else {
+                        return $value;
+                    }
+
+                }
+                $page = $page->parent();
+            }
+        }
+
+        if ($exists) {
+            return false;
+        } else {
+            return Grav::instance()['config']->get('theme.' . $var, $default);
+        }
+    }
 
-        return $header_classes ?: $this->config->get('theme.' . $var, $default);
+    /**
+     * Look for a page header variable in an array of pages working its way through until a value is found
+     *
+     * @param $context
+     * @param string $var the variable to look for in the page header
+     * @param string|string[]|null $pages array of pages to check (current page upwards if not null)
+     * @param bool $exists if true, return the page where the var is found, not the value
+     * @return mixed
+     * @deprecated 1.7 Use themeVarFunc() instead
+     */
+    public function pageHeaderVarFunc($context, $var, $pages = null)
+    {
+        if (is_array($pages)) {
+            $page = array_shift($pages);
+        } else {
+            $page = null;
+        }
+        return $this->themeVarFunc($context, $var, null, $page);
     }
 
     /**
@@ -1319,41 +1371,6 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
         return $body_classes;
     }
 
-    /**
-     * Look for a page header variable in an array of pages working its way through until a value is found
-     *
-     * @param string $var
-     * @param string|string[]|null $pages
-     * @return mixed
-     */
-    public function pageHeaderVarFunc($var, $pages = null)
-    {
-        if ($pages === null) {
-            $pages = $this->grav['page'];
-        }
-
-        // Make sure pages are an array
-        if (!\is_array($pages)) {
-            $pages = [$pages];
-        }
-
-        // Loop over pages and look for header vars
-        foreach ($pages as $page) {
-            if (\is_string($page)) {
-                $page = $this->grav['pages']->find($page);
-            }
-
-            if ($page) {
-                $header = $page->header();
-                if (isset($header->{$var})) {
-                    return $header->{$var};
-                }
-            }
-        }
-
-        return null;
-    }
-
     /**
      * Dump/Encode data into YAML format
      *
@@ -1442,4 +1459,41 @@ class TwigExtension extends \Twig_Extension implements \Twig_Extension_GlobalsIn
                 break;
         }
     }
+
+    /**
+     * Returns the content of an SVG image and adds extra classes as needed
+     *
+     * @param $path
+     * @param $classes
+     * @return string|string[]|null
+     */
+    public static function svgImageFunction($path, $classes)
+    {
+        $path = Utils::fullPath($path);
+
+        if (file_exists($path)) {
+            $svg = file_get_contents($path);
+            $classes = " inline-block $classes";
+            $matched = false;
+
+            //Look for existing class
+            $svg = preg_replace_callback('/^<svg.*?(class=\"(.*?)").*>/', function($matches) use ($classes, &$matched) {
+                if (isset($matches[2])) {
+                    $new_classes = $matches[2] . $classes;
+                    $matched = true;
+                    return str_replace($matches[1], "class=\"$new_classes\"", $matches[0]);
+                }
+            }, $svg
+            );
+
+            // no matches found just add the class
+            if (!$matched) {
+                $classes = trim($classes);
+                $svg = str_replace('<svg ', "<svg class=\"$classes\" ", $svg);
+            }
+
+            return $svg;
+        }
+    }
+
 }

+ 22 - 17
system/src/Grav/Common/Uri.php

@@ -151,7 +151,7 @@ class Uri
 
         $this->url = $this->base . $this->uri;
 
-        $uri = str_replace(static::filterPath($this->root), '', $this->url);
+        $uri = Utils::replaceFirstOccurrence(static::filterPath($this->root), '', $this->url);
 
         // remove the setup.php based base if set:
         $setup_base = $grav['pages']->base();
@@ -195,7 +195,7 @@ class Uri
         // set the new url
         $this->url = $this->root . $path;
         $this->path = static::cleanPath($path);
-        $this->content_path = trim(str_replace($this->base, '', $this->path), '/');
+        $this->content_path = trim(Utils::replaceFirstOccurrence($this->base, '', $this->path), '/');
         if ($this->content_path !== '') {
             $this->paths = explode('/', $this->content_path);
         }
@@ -306,7 +306,7 @@ class Uri
     public function param($id)
     {
         if (isset($this->params[$id])) {
-            return html_entity_decode(rawurldecode($this->params[$id]));
+            return html_entity_decode(rawurldecode($this->params[$id]), ENT_COMPAT | ENT_HTML401, 'UTF-8');
         }
 
         return false;
@@ -340,7 +340,7 @@ class Uri
             return $this->url;
         }
 
-        $url = str_replace($this->base, '', rtrim($this->url, '/'));
+        $url = Utils::replaceFirstOccurrence($this->base, '', rtrim($this->url, '/'));
 
         return $url ?: '/';
     }
@@ -489,7 +489,7 @@ class Uri
             return $this->uri;
         }
 
-        return str_replace($this->root_path, '', $this->uri);
+        return Utils::replaceFirstOccurrence($this->root_path, '', $this->uri);
     }
 
     /**
@@ -531,7 +531,7 @@ class Uri
             return $this->root;
         }
 
-        return str_replace($this->base, '', $this->root);
+        return Utils::replaceFirstOccurrence($this->base, '', $this->root);
     }
 
     /**
@@ -541,7 +541,9 @@ class Uri
      */
     public function currentPage()
     {
-        return $this->params['page'] ?? 1;
+        $page = (int)($this->params['page'] ?? 1);
+
+        return max(1, $page);
     }
 
     /**
@@ -629,9 +631,9 @@ class Uri
     {
         if (getenv('HTTP_CLIENT_IP')) {
             $ip = getenv('HTTP_CLIENT_IP');
-        } elseif (getenv('HTTP_X_FORWARDED_FOR')) {
+        } elseif (getenv('HTTP_X_FORWARDED_FOR') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) {
             $ip = getenv('HTTP_X_FORWARDED_FOR');
-        } elseif (getenv('HTTP_X_FORWARDED')) {
+        } elseif (getenv('HTTP_X_FORWARDED') && Grav::instance()['config']->get('system.http_x_forwarded.ip')) {
             $ip = getenv('HTTP_X_FORWARDED');
         } elseif (getenv('HTTP_FORWARDED_FOR')) {
             $ip = getenv('HTTP_FORWARDED_FOR');
@@ -783,7 +785,7 @@ class Uri
             }
 
             // special check to see if path checking is required.
-            $just_path = str_replace($normalized_url, '', $normalized_path);
+            $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path);
             if ($normalized_url === '/' || $just_path === $page->path()) {
                 $url_path = $normalized_url;
             } else {
@@ -852,7 +854,7 @@ class Uri
             }
 
             // strip base from this path
-            $target_path = str_replace($uri->rootUrl(), '', $target_path);
+            $target_path = Utils::replaceFirstOccurrence($uri->rootUrl(), '', $target_path);
 
             // set to / if root
             if (empty($target_path)) {
@@ -877,7 +879,7 @@ class Uri
 
         // Handle route only
         if ($route_only) {
-            $url_path = str_replace(static::filterPath($base_url), '', $url_path);
+            $url_path = Utils::replaceFirstOccurrence(static::filterPath($base_url), '', $url_path);
         }
 
         // transform back to string/array as needed
@@ -998,7 +1000,7 @@ class Uri
         }
 
         // special check to see if path checking is required.
-        $just_path = str_replace($normalized_url, '', $normalized_path);
+        $just_path = Utils::replaceFirstOccurrence($normalized_url, '', $normalized_path);
         if ($just_path === $page->path()) {
             return $normalized_url;
         }
@@ -1148,7 +1150,7 @@ class Uri
     protected function createFromEnvironment(array $env)
     {
         // Build scheme.
-        if (isset($env['HTTP_X_FORWARDED_PROTO'])) {
+        if (isset($env['HTTP_X_FORWARDED_PROTO']) && Grav::instance()['config']->get('system.http_x_forwarded.protocol')) {
             $this->scheme = $env['HTTP_X_FORWARDED_PROTO'];
         } elseif (isset($env['X-FORWARDED-PROTO'])) {
             $this->scheme = $env['X-FORWARDED-PROTO'];
@@ -1166,11 +1168,14 @@ class Uri
         $this->password = $env['PHP_AUTH_PW'] ?? null;
 
         // Build host.
-        $hostname = 'localhost';
-        if (isset($env['HTTP_HOST'])) {
+        if (isset($env['HTTP_X_FORWARDED_HOST']) && Grav::instance()['config']->get('system.http_x_forwarded.host')) {
+            $hostname = $env['HTTP_X_FORWARDED_HOST'];
+        } else if (isset($env['HTTP_HOST'])) {
             $hostname = $env['HTTP_HOST'];
         } elseif (isset($env['SERVER_NAME'])) {
             $hostname = $env['SERVER_NAME'];
+        } else {
+            $hostname = 'localhost';
         }
         // Remove port from HTTP_HOST generated $hostname
         $hostname = Utils::substrToString($hostname, ':');
@@ -1178,7 +1183,7 @@ class Uri
         $this->host = $this->validateHostname($hostname) ? $hostname : 'unknown';
 
         // Build port.
-        if (isset($env['HTTP_X_FORWARDED_PORT'])) {
+        if (isset($env['HTTP_X_FORWARDED_PORT']) && Grav::instance()['config']->get('system.http_x_forwarded.port')) {
            $this->port = (int)$env['HTTP_X_FORWARDED_PORT'];
         } elseif (isset($env['X-FORWARDED-PORT'])) {
            $this->port = (int)$env['X-FORWARDED-PORT'];

+ 22 - 0
system/src/Grav/Common/Utils.php

@@ -129,6 +129,28 @@ abstract class Utils
         return rtrim($uri->rootUrl($domain), '/') . '/' . ($resource ?? '');
     }
 
+    /**
+     * Helper method to find the full path to a file, be it a stream, a relative path, or
+     * already a full path
+     *
+     * @param $path
+     * @return string
+     */
+    public static function fullPath($path)
+    {
+        $locator = Grav::instance()['locator'];
+
+        if ($locator->isStream($path)) {
+            $path = $locator->findResource($path, true);
+        } elseif (!Utils::startsWith($path, GRAV_ROOT)) {
+            $base_url = Grav::instance()['base_url'];
+            $path = GRAV_ROOT . '/' . ltrim(Utils::replaceFirstOccurrence($base_url, '', $path), '/');
+        }
+
+        return $path;
+    }
+
+
     /**
      * Check if the $haystack string starts with the substring $needle
      *

+ 140 - 1
system/src/Grav/Console/ConsoleCommand.php

@@ -10,6 +10,10 @@
 namespace Grav\Console;
 
 use Grav\Common\Grav;
+use Grav\Common\Language\Language;
+use Grav\Common\Page\Page;
+use Grav\Common\Processors\InitializeProcessor;
+use RocketTheme\Toolbox\Event\Event;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -18,6 +22,13 @@ class ConsoleCommand extends Command
 {
     use ConsoleTrait;
 
+    /** @var bool */
+    private $plugins_initialized = false;
+    /** @var bool */
+    private $themes_initialized = false;
+    /** @var bool */
+    private $pages_initialized = false;
+
     /**
      * @param InputInterface  $input
      * @param OutputInterface $output
@@ -31,12 +42,140 @@ class ConsoleCommand extends Command
     }
 
     /**
-     *
+     * Override with your implementation.
      */
     protected function serve()
     {
     }
 
+    /**
+     * Initialize Grav.
+     *
+     * - Load configuration
+     * - Disable debugger
+     * - Set timezone, locale
+     * - Load plugins
+     * - Set Users type to be used in the site
+     *
+     * Safe to be called multiple times.
+     *
+     * @return $this
+     */
+    final protected function initializeGrav()
+    {
+        InitializeProcessor::initializeCli(Grav::instance());
+
+        return $this;
+    }
+
+    /**
+     * Set language to be used in CLI.
+     *
+     * @param string|null $code
+     */
+    final protected function setLanguage(string $code = null)
+    {
+        $this->initializeGrav();
+
+        $grav = Grav::instance();
+        /** @var Language $language */
+        $language = $grav['language'];
+        if ($language->enabled()) {
+            if ($code && $language->validate($code)) {
+                $language->setActive($code);
+            } else {
+                $language->setActive($language->getDefault());
+            }
+        }
+    }
+
+    /**
+     * Properly initialize plugins.
+     *
+     * - call $this->initializeGrav()
+     * - call onPluginsInitialized event
+     *
+     * Safe to be called multiple times.
+     *
+     * @return $this
+     */
+    final protected function initializePlugins()
+    {
+        if (!$this->plugins_initialized) {
+            $this->plugins_initialized = true;
+
+            $this->initializeGrav();
+
+            // Initialize plugins.
+            $grav = Grav::instance();
+            $grav->fireEvent('onPluginsInitialized');
+        }
+
+        return $this;
+    }
+
+    /**
+     * Properly initialize themes.
+     *
+     * - call $this->initializePlugins()
+     * - initialize theme (call onThemeInitialized event)
+     *
+     * Safe to be called multiple times.
+     *
+     * @return $this
+     */
+    final protected function initializeThemes()
+    {
+        if (!$this->themes_initialized) {
+            $this->themes_initialized = true;
+
+            $this->initializePlugins();
+
+            // Initialize themes.
+            $grav = Grav::instance();
+            $grav['themes']->init();
+        }
+
+        return $this;
+    }
+
+    /**
+     * Properly initialize pages.
+     *
+     * - call $this->initializeThemes()
+     * - initialize assets (call onAssetsInitialized event)
+     * - initialize twig (calls the twig events)
+     * - initialize pages (calls onPagesInitialized event)
+     *
+     * Safe to be called multiple times.
+     *
+     * @return $this
+     */
+    final protected function initializePages()
+    {
+        if (!$this->pages_initialized) {
+            $this->pages_initialized = true;
+
+            $this->initializeThemes();
+
+            $grav = Grav::instance();
+
+            // Initialize assets.
+            $grav['assets']->init();
+            $grav->fireEvent('onAssetsInitialized');
+
+            // Initialize twig.
+            $grav['twig']->init();
+
+            // Initialize pages.
+            $pages = $grav['pages'];
+            $pages->init();
+            $grav->fireEvent('onPagesInitialized', new Event(['pages' => $pages]));
+        }
+
+        return $this;
+    }
+
     protected function displayGPMRelease()
     {
         $this->output->writeln('');

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

@@ -177,7 +177,7 @@ class SelfupgradeCommand extends ConsoleCommand
     }
 
     /**
-     * @param Package $package
+     * @param array $package
      *
      * @return string
      */

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

@@ -92,7 +92,7 @@ class CsvFormatter extends AbstractFormatter
 
                     if ($null_replace) {
                         array_walk($csv_line, function(&$el) use ($null_replace) {
-                           $el = str_replace($null_replace, null, $el);
+                           $el = str_replace($null_replace, "\0", $el);
                         });
                     }
 

+ 1554 - 0
system/src/Grav/Framework/Parsedown/Parsedown.php

@@ -0,0 +1,1554 @@
+<?php
+
+/**
+ * @package    Grav\Framework\Parsedown
+ *
+ * @copyright  Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Framework\Parsedown;
+
+/*
+ * Parsedown
+ * http://parsedown.org
+ *
+ * (c) Emanuil Rusev
+ * http://erusev.com
+ *
+ * This file ported from officiall Parsedown repo and kept for compatibility.
+ */
+
+class Parsedown
+{
+    # ~
+
+    const version = '1.6.0';
+
+    # ~
+
+    function text($text)
+    {
+        # make sure no definitions are set
+        $this->DefinitionData = array();
+
+        # standardize line breaks
+        $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+        # remove surrounding line breaks
+        $text = trim($text, "\n");
+
+        # split text into lines
+        $lines = explode("\n", $text);
+
+        # iterate through lines to identify blocks
+        $markup = $this->lines($lines);
+
+        # trim line breaks
+        $markup = trim($markup, "\n");
+
+        return $markup;
+    }
+
+    #
+    # Setters
+    #
+
+    function setBreaksEnabled($breaksEnabled)
+    {
+        $this->breaksEnabled = $breaksEnabled;
+
+        return $this;
+    }
+
+    protected $breaksEnabled;
+
+    function setMarkupEscaped($markupEscaped)
+    {
+        $this->markupEscaped = $markupEscaped;
+
+        return $this;
+    }
+
+    protected $markupEscaped;
+
+    function setUrlsLinked($urlsLinked)
+    {
+        $this->urlsLinked = $urlsLinked;
+
+        return $this;
+    }
+
+    protected $urlsLinked = true;
+
+    #
+    # Lines
+    #
+
+    protected $BlockTypes = array(
+        '#' => array('Header'),
+        '*' => array('Rule', 'List'),
+        '+' => array('List'),
+        '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
+        '0' => array('List'),
+        '1' => array('List'),
+        '2' => array('List'),
+        '3' => array('List'),
+        '4' => array('List'),
+        '5' => array('List'),
+        '6' => array('List'),
+        '7' => array('List'),
+        '8' => array('List'),
+        '9' => array('List'),
+        ':' => array('Table'),
+        '<' => array('Comment', 'Markup'),
+        '=' => array('SetextHeader'),
+        '>' => array('Quote'),
+        '[' => array('Reference'),
+        '_' => array('Rule'),
+        '`' => array('FencedCode'),
+        '|' => array('Table'),
+        '~' => array('FencedCode'),
+    );
+
+    # ~
+
+    protected $unmarkedBlockTypes = array(
+        'Code',
+    );
+
+    #
+    # Blocks
+    #
+
+    protected function lines(array $lines)
+    {
+        $CurrentBlock = null;
+
+        foreach ($lines as $line)
+        {
+            if (chop($line) === '')
+            {
+                if (isset($CurrentBlock))
+                {
+                    $CurrentBlock['interrupted'] = true;
+                }
+
+                continue;
+            }
+
+            if (strpos($line, "\t") !== false)
+            {
+                $parts = explode("\t", $line);
+
+                $line = $parts[0];
+
+                unset($parts[0]);
+
+                foreach ($parts as $part)
+                {
+                    $shortage = 4 - mb_strlen($line, 'utf-8') % 4;
+
+                    $line .= str_repeat(' ', $shortage);
+                    $line .= $part;
+                }
+            }
+
+            $indent = 0;
+
+            while (isset($line[$indent]) and $line[$indent] === ' ')
+            {
+                $indent ++;
+            }
+
+            $text = $indent > 0 ? substr($line, $indent) : $line;
+
+            # ~
+
+            $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+            # ~
+
+            if (isset($CurrentBlock['continuable']))
+            {
+                $Block = $this->{'block'.$CurrentBlock['type'].'Continue'}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $CurrentBlock = $Block;
+
+                    continue;
+                }
+                else
+                {
+                    if ($this->isBlockCompletable($CurrentBlock['type']))
+                    {
+                        $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
+                    }
+                }
+            }
+
+            # ~
+
+            $marker = $text[0];
+
+            # ~
+
+            $blockTypes = $this->unmarkedBlockTypes;
+
+            if (isset($this->BlockTypes[$marker]))
+            {
+                foreach ($this->BlockTypes[$marker] as $blockType)
+                {
+                    $blockTypes []= $blockType;
+                }
+            }
+
+            #
+            # ~
+
+            foreach ($blockTypes as $blockType)
+            {
+                $Block = $this->{'block'.$blockType}($Line, $CurrentBlock);
+
+                if (isset($Block))
+                {
+                    $Block['type'] = $blockType;
+
+                    if ( ! isset($Block['identified']))
+                    {
+                        $Blocks []= $CurrentBlock;
+
+                        $Block['identified'] = true;
+                    }
+
+                    if ($this->isBlockContinuable($blockType))
+                    {
+                        $Block['continuable'] = true;
+                    }
+
+                    $CurrentBlock = $Block;
+
+                    continue 2;
+                }
+            }
+
+            # ~
+
+            if (isset($CurrentBlock) and ! isset($CurrentBlock['type']) and ! isset($CurrentBlock['interrupted']))
+            {
+                $CurrentBlock['element']['text'] .= "\n".$text;
+            }
+            else
+            {
+                $Blocks []= $CurrentBlock;
+
+                $CurrentBlock = $this->paragraph($Line);
+
+                $CurrentBlock['identified'] = true;
+            }
+        }
+
+        # ~
+
+        if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
+        {
+            $CurrentBlock = $this->{'block'.$CurrentBlock['type'].'Complete'}($CurrentBlock);
+        }
+
+        # ~
+
+        $Blocks []= $CurrentBlock;
+
+        unset($Blocks[0]);
+
+        # ~
+
+        $markup = '';
+
+        foreach ($Blocks as $Block)
+        {
+            if (isset($Block['hidden']))
+            {
+                continue;
+            }
+
+            $markup .= "\n";
+            $markup .= isset($Block['markup']) ? $Block['markup'] : $this->element($Block['element']);
+        }
+
+        $markup .= "\n";
+
+        # ~
+
+        return $markup;
+    }
+
+    protected function isBlockContinuable($Type)
+    {
+        return method_exists($this, 'block'.$Type.'Continue');
+    }
+
+    protected function isBlockCompletable($Type)
+    {
+        return method_exists($this, 'block'.$Type.'Complete');
+    }
+
+    #
+    # Code
+
+    protected function blockCode($Line, $Block = null)
+    {
+        if (isset($Block) and ! isset($Block['type']) and ! isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['indent'] >= 4)
+        {
+            $text = substr($Line['body'], 4);
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'pre',
+                    'handler' => 'element',
+                    'text' => array(
+                        'name' => 'code',
+                        'text' => $text,
+                    ),
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeContinue($Line, $Block)
+    {
+        if ($Line['indent'] >= 4)
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['text']['text'] .= "\n";
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['text']['text'] .= "\n";
+
+            $text = substr($Line['body'], 4);
+
+            $Block['element']['text']['text'] .= $text;
+
+            return $Block;
+        }
+    }
+
+    protected function blockCodeComplete($Block)
+    {
+        $text = $Block['element']['text']['text'];
+
+        $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
+
+        $Block['element']['text']['text'] = $text;
+
+        return $Block;
+    }
+
+    #
+    # Comment
+
+    protected function blockComment($Line)
+    {
+        if ($this->markupEscaped)
+        {
+            return;
+        }
+
+        if (isset($Line['text'][3]) and $Line['text'][3] === '-' and $Line['text'][2] === '-' and $Line['text'][1] === '!')
+        {
+            $Block = array(
+                'markup' => $Line['body'],
+            );
+
+            if (preg_match('/-->$/', $Line['text']))
+            {
+                $Block['closed'] = true;
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockCommentContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        $Block['markup'] .= "\n" . $Line['body'];
+
+        if (preg_match('/-->$/', $Line['text']))
+        {
+            $Block['closed'] = true;
+        }
+
+        return $Block;
+    }
+
+    #
+    # Fenced Code
+
+    protected function blockFencedCode($Line)
+    {
+        if (preg_match('/^['.$Line['text'][0].']{3,}[ ]*([\w-]+)?[ ]*$/', $Line['text'], $matches))
+        {
+            $Element = array(
+                'name' => 'code',
+                'text' => '',
+            );
+
+            if (isset($matches[1]))
+            {
+                $class = 'language-'.$matches[1];
+
+                $Element['attributes'] = array(
+                    'class' => $class,
+                );
+            }
+
+            $Block = array(
+                'char' => $Line['text'][0],
+                'element' => array(
+                    'name' => 'pre',
+                    'handler' => 'element',
+                    'text' => $Element,
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockFencedCodeContinue($Line, $Block)
+    {
+        if (isset($Block['complete']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['element']['text']['text'] .= "\n";
+
+            unset($Block['interrupted']);
+        }
+
+        if (preg_match('/^'.$Block['char'].'{3,}[ ]*$/', $Line['text']))
+        {
+            $Block['element']['text']['text'] = substr($Block['element']['text']['text'], 1);
+
+            $Block['complete'] = true;
+
+            return $Block;
+        }
+
+        $Block['element']['text']['text'] .= "\n".$Line['body'];
+
+        return $Block;
+    }
+
+    protected function blockFencedCodeComplete($Block)
+    {
+        $text = $Block['element']['text']['text'];
+
+        $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
+
+        $Block['element']['text']['text'] = $text;
+
+        return $Block;
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        if (isset($Line['text'][1]))
+        {
+            $level = 1;
+
+            while (isset($Line['text'][$level]) and $Line['text'][$level] === '#')
+            {
+                $level ++;
+            }
+
+            if ($level > 6)
+            {
+                return;
+            }
+
+            $text = trim($Line['text'], '# ');
+
+            $Block = array(
+                'element' => array(
+                    'name' => 'h' . min(6, $level),
+                    'text' => $text,
+                    'handler' => 'line',
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # List
+
+    protected function blockList($Line)
+    {
+        list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]+[.]');
+
+        if (preg_match('/^('.$pattern.'[ ]+)(.*)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'indent' => $Line['indent'],
+                'pattern' => $pattern,
+                'element' => array(
+                    'name' => $name,
+                    'handler' => 'elements',
+                ),
+            );
+
+            if($name === 'ol')
+            {
+                $listStart = stristr($matches[0], '.', true);
+
+                if($listStart !== '1')
+                {
+                    $Block['element']['attributes'] = array('start' => $listStart);
+                }
+            }
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => 'li',
+                'text' => array(
+                    $matches[2],
+                ),
+            );
+
+            $Block['element']['text'] []= & $Block['li'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockListContinue($Line, array $Block)
+    {
+        if ($Block['indent'] === $Line['indent'] and preg_match('/^'.$Block['pattern'].'(?:[ ]+(.*)|$)/', $Line['text'], $matches))
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['li']['text'] []= '';
+
+                unset($Block['interrupted']);
+            }
+
+            unset($Block['li']);
+
+            $text = isset($matches[1]) ? $matches[1] : '';
+
+            $Block['li'] = array(
+                'name' => 'li',
+                'handler' => 'li',
+                'text' => array(
+                    $text,
+                ),
+            );
+
+            $Block['element']['text'] []= & $Block['li'];
+
+            return $Block;
+        }
+
+        if ($Line['text'][0] === '[' and $this->blockReference($Line))
+        {
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
+
+            $Block['li']['text'] []= $text;
+
+            return $Block;
+        }
+
+        if ($Line['indent'] > 0)
+        {
+            $Block['li']['text'] []= '';
+
+            $text = preg_replace('/^[ ]{0,4}/', '', $Line['body']);
+
+            $Block['li']['text'] []= $text;
+
+            unset($Block['interrupted']);
+
+            return $Block;
+        }
+    }
+
+    #
+    # Quote
+
+    protected function blockQuote($Line)
+    {
+        if (preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'blockquote',
+                    'handler' => 'lines',
+                    'text' => (array) $matches[1],
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockQuoteContinue($Line, array $Block)
+    {
+        if ($Line['text'][0] === '>' and preg_match('/^>[ ]?(.*)/', $Line['text'], $matches))
+        {
+            if (isset($Block['interrupted']))
+            {
+                $Block['element']['text'] []= '';
+
+                unset($Block['interrupted']);
+            }
+
+            $Block['element']['text'] []= $matches[1];
+
+            return $Block;
+        }
+
+        if ( ! isset($Block['interrupted']))
+        {
+            $Block['element']['text'] []= $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    #
+    # Rule
+
+    protected function blockRule($Line)
+    {
+        if (preg_match('/^(['.$Line['text'][0].'])([ ]*\1){2,}[ ]*$/', $Line['text']))
+        {
+            $Block = array(
+                'element' => array(
+                    'name' => 'hr'
+                ),
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, array $Block = null)
+    {
+        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (chop($Line['text'], $Line['text'][0]) === '')
+        {
+            $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
+
+            return $Block;
+        }
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkup($Line)
+    {
+        if ($this->markupEscaped)
+        {
+            return;
+        }
+
+        if (preg_match('/^<(\w*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches))
+        {
+            $element = strtolower($matches[1]);
+
+            if (in_array($element, $this->textLevelElements))
+            {
+                return;
+            }
+
+            $Block = array(
+                'name' => $matches[1],
+                'depth' => 0,
+                'markup' => $Line['text'],
+            );
+
+            $length = strlen($matches[0]);
+
+            $remainder = substr($Line['text'], $length);
+
+            if (trim($remainder) === '')
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    $Block['closed'] = true;
+
+                    $Block['void'] = true;
+                }
+            }
+            else
+            {
+                if (isset($matches[2]) or in_array($matches[1], $this->voidElements))
+                {
+                    return;
+                }
+
+                if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder))
+                {
+                    $Block['closed'] = true;
+                }
+            }
+
+            return $Block;
+        }
+    }
+
+    protected function blockMarkupContinue($Line, array $Block)
+    {
+        if (isset($Block['closed']))
+        {
+            return;
+        }
+
+        if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) # open
+        {
+            $Block['depth'] ++;
+        }
+
+        if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) # close
+        {
+            if ($Block['depth'] > 0)
+            {
+                $Block['depth'] --;
+            }
+            else
+            {
+                $Block['closed'] = true;
+            }
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['markup'] .= "\n";
+
+            unset($Block['interrupted']);
+        }
+
+        $Block['markup'] .= "\n".$Line['body'];
+
+        return $Block;
+    }
+
+    #
+    # Reference
+
+    protected function blockReference($Line)
+    {
+        if (preg_match('/^\[(.+?)\]:[ ]*<?(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*$/', $Line['text'], $matches))
+        {
+            $id = strtolower($matches[1]);
+
+            $Data = array(
+                'url' => $matches[2],
+                'title' => null,
+            );
+
+            if (isset($matches[3]))
+            {
+                $Data['title'] = $matches[3];
+            }
+
+            $this->DefinitionData['Reference'][$id] = $Data;
+
+            $Block = array(
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Table
+
+    protected function blockTable($Line, array $Block = null)
+    {
+        if ( ! isset($Block) or isset($Block['type']) or isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if (strpos($Block['element']['text'], '|') !== false and chop($Line['text'], ' -:|') === '')
+        {
+            $alignments = array();
+
+            $divider = $Line['text'];
+
+            $divider = trim($divider);
+            $divider = trim($divider, '|');
+
+            $dividerCells = explode('|', $divider);
+
+            foreach ($dividerCells as $dividerCell)
+            {
+                $dividerCell = trim($dividerCell);
+
+                if ($dividerCell === '')
+                {
+                    continue;
+                }
+
+                $alignment = null;
+
+                if ($dividerCell[0] === ':')
+                {
+                    $alignment = 'left';
+                }
+
+                if (substr($dividerCell, - 1) === ':')
+                {
+                    $alignment = $alignment === 'left' ? 'center' : 'right';
+                }
+
+                $alignments []= $alignment;
+            }
+
+            # ~
+
+            $HeaderElements = array();
+
+            $header = $Block['element']['text'];
+
+            $header = trim($header);
+            $header = trim($header, '|');
+
+            $headerCells = explode('|', $header);
+
+            foreach ($headerCells as $index => $headerCell)
+            {
+                $headerCell = trim($headerCell);
+
+                $HeaderElement = array(
+                    'name' => 'th',
+                    'text' => $headerCell,
+                    'handler' => 'line',
+                );
+
+                if (isset($alignments[$index]))
+                {
+                    $alignment = $alignments[$index];
+
+                    $HeaderElement['attributes'] = array(
+                        'style' => 'text-align: '.$alignment.';',
+                    );
+                }
+
+                $HeaderElements []= $HeaderElement;
+            }
+
+            # ~
+
+            $Block = array(
+                'alignments' => $alignments,
+                'identified' => true,
+                'element' => array(
+                    'name' => 'table',
+                    'handler' => 'elements',
+                ),
+            );
+
+            $Block['element']['text'] []= array(
+                'name' => 'thead',
+                'handler' => 'elements',
+            );
+
+            $Block['element']['text'] []= array(
+                'name' => 'tbody',
+                'handler' => 'elements',
+                'text' => array(),
+            );
+
+            $Block['element']['text'][0]['text'] []= array(
+                'name' => 'tr',
+                'handler' => 'elements',
+                'text' => $HeaderElements,
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockTableContinue($Line, array $Block)
+    {
+        if (isset($Block['interrupted']))
+        {
+            return;
+        }
+
+        if ($Line['text'][0] === '|' or strpos($Line['text'], '|'))
+        {
+            $Elements = array();
+
+            $row = $Line['text'];
+
+            $row = trim($row);
+            $row = trim($row, '|');
+
+            preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]+`|`)+/', $row, $matches);
+
+            foreach ($matches[0] as $index => $cell)
+            {
+                $cell = trim($cell);
+
+                $Element = array(
+                    'name' => 'td',
+                    'handler' => 'line',
+                    'text' => $cell,
+                );
+
+                if (isset($Block['alignments'][$index]))
+                {
+                    $Element['attributes'] = array(
+                        'style' => 'text-align: '.$Block['alignments'][$index].';',
+                    );
+                }
+
+                $Elements []= $Element;
+            }
+
+            $Element = array(
+                'name' => 'tr',
+                'handler' => 'elements',
+                'text' => $Elements,
+            );
+
+            $Block['element']['text'][1]['text'] []= $Element;
+
+            return $Block;
+        }
+    }
+
+    #
+    # ~
+    #
+
+    protected function paragraph($Line)
+    {
+        $Block = array(
+            'element' => array(
+                'name' => 'p',
+                'text' => $Line['text'],
+                'handler' => 'line',
+            ),
+        );
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    protected $InlineTypes = array(
+        '"' => array('SpecialCharacter'),
+        '!' => array('Image'),
+        '&' => array('SpecialCharacter'),
+        '*' => array('Emphasis'),
+        ':' => array('Url'),
+        '<' => array('UrlTag', 'EmailTag', 'Markup', 'SpecialCharacter'),
+        '>' => array('SpecialCharacter'),
+        '[' => array('Link'),
+        '_' => array('Emphasis'),
+        '`' => array('Code'),
+        '~' => array('Strikethrough'),
+        '\\' => array('EscapeSequence'),
+    );
+
+    # ~
+
+    protected $inlineMarkerList = '!"*_&[:<>`~\\';
+
+    #
+    # ~
+    #
+
+    public function line($text)
+    {
+        $markup = '';
+
+        # $excerpt is based on the first occurrence of a marker
+
+        while ($excerpt = strpbrk($text, $this->inlineMarkerList))
+        {
+            $marker = $excerpt[0];
+
+            $markerPosition = strpos($text, $marker);
+
+            $Excerpt = array('text' => $excerpt, 'context' => $text);
+
+            foreach ($this->InlineTypes[$marker] as $inlineType)
+            {
+                $Inline = $this->{'inline'.$inlineType}($Excerpt);
+
+                if ( ! isset($Inline))
+                {
+                    continue;
+                }
+
+                # makes sure that the inline belongs to "our" marker
+
+                if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
+                {
+                    continue;
+                }
+
+                # sets a default inline position
+
+                if ( ! isset($Inline['position']))
+                {
+                    $Inline['position'] = $markerPosition;
+                }
+
+                # the text that comes before the inline
+                $unmarkedText = substr($text, 0, $Inline['position']);
+
+                # compile the unmarked text
+                $markup .= $this->unmarkedText($unmarkedText);
+
+                # compile the inline
+                $markup .= isset($Inline['markup']) ? $Inline['markup'] : $this->element($Inline['element']);
+
+                # remove the examined text
+                $text = substr($text, $Inline['position'] + $Inline['extent']);
+
+                continue 2;
+            }
+
+            # the marker does not belong to an inline
+
+            $unmarkedText = substr($text, 0, $markerPosition + 1);
+
+            $markup .= $this->unmarkedText($unmarkedText);
+
+            $text = substr($text, $markerPosition + 1);
+        }
+
+        $markup .= $this->unmarkedText($text);
+
+        return $markup;
+    }
+
+    #
+    # ~
+    #
+
+    protected function inlineCode($Excerpt)
+    {
+        $marker = $Excerpt['text'][0];
+
+        if (preg_match('/^('.$marker.'+)[ ]*(.+?)[ ]*(?<!'.$marker.')\1(?!'.$marker.')/s', $Excerpt['text'], $matches))
+        {
+            $text = $matches[2];
+            $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
+            $text = preg_replace("/[ ]*\n/", ' ', $text);
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'code',
+                    'text' => $text,
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmailTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<((mailto:)?\S+?@\S+?)>/i', $Excerpt['text'], $matches))
+        {
+            $url = $matches[1];
+
+            if ( ! isset($matches[2]))
+            {
+                $url = 'mailto:' . $url;
+            }
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $matches[1],
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    protected function inlineEmphasis($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        $marker = $Excerpt['text'][0];
+
+        if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'strong';
+        }
+        elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
+        {
+            $emphasis = 'em';
+        }
+        else
+        {
+            return;
+        }
+
+        return array(
+            'extent' => strlen($matches[0]),
+            'element' => array(
+                'name' => $emphasis,
+                'handler' => 'line',
+                'text' => $matches[1],
+            ),
+        );
+    }
+
+    protected function inlineEscapeSequence($Excerpt)
+    {
+        if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
+        {
+            return array(
+                'markup' => $Excerpt['text'][1],
+                'extent' => 2,
+            );
+        }
+    }
+
+    protected function inlineImage($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
+        {
+            return;
+        }
+
+        $Excerpt['text']= substr($Excerpt['text'], 1);
+
+        $Link = $this->inlineLink($Excerpt);
+
+        if ($Link === null)
+        {
+            return;
+        }
+
+        $Inline = array(
+            'extent' => $Link['extent'] + 1,
+            'element' => array(
+                'name' => 'img',
+                'attributes' => array(
+                    'src' => $Link['element']['attributes']['href'],
+                    'alt' => $Link['element']['text'],
+                ),
+            ),
+        );
+
+        $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+        unset($Inline['element']['attributes']['href']);
+
+        return $Inline;
+    }
+
+    protected function inlineLink($Excerpt)
+    {
+        $Element = array(
+            'name' => 'a',
+            'handler' => 'line',
+            'text' => null,
+            'attributes' => array(
+                'href' => null,
+                'title' => null,
+            ),
+        );
+
+        $extent = 0;
+
+        $remainder = $Excerpt['text'];
+
+        if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
+        {
+            $Element['text'] = $matches[1];
+
+            $extent += strlen($matches[0]);
+
+            $remainder = substr($remainder, $extent);
+        }
+        else
+        {
+            return;
+        }
+
+        if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*"|\'[^\']*\'))?\s*[)]/', $remainder, $matches))
+        {
+            $Element['attributes']['href'] = $matches[1];
+
+            if (isset($matches[2]))
+            {
+                $Element['attributes']['title'] = substr($matches[2], 1, - 1);
+            }
+
+            $extent += strlen($matches[0]);
+        }
+        else
+        {
+            if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
+            {
+                $definition = strlen($matches[1]) ? $matches[1] : $Element['text'];
+                $definition = strtolower($definition);
+
+                $extent += strlen($matches[0]);
+            }
+            else
+            {
+                $definition = strtolower($Element['text']);
+            }
+
+            if ( ! isset($this->DefinitionData['Reference'][$definition]))
+            {
+                return;
+            }
+
+            $Definition = $this->DefinitionData['Reference'][$definition];
+
+            $Element['attributes']['href'] = $Definition['url'];
+            $Element['attributes']['title'] = $Definition['title'];
+        }
+
+        $Element['attributes']['href'] = str_replace(array('&', '<'), array('&amp;', '&lt;'), $Element['attributes']['href']);
+
+        return array(
+            'extent' => $extent,
+            'element' => $Element,
+        );
+    }
+
+    protected function inlineMarkup($Excerpt)
+    {
+        if ($this->markupEscaped or strpos($Excerpt['text'], '>') === false)
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w*[ ]*>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] === '!' and preg_match('/^<!---?[^>-](?:-?[^-])*-->/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+
+        if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w*(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*\/?>/s', $Excerpt['text'], $matches))
+        {
+            return array(
+                'markup' => $matches[0],
+                'extent' => strlen($matches[0]),
+            );
+        }
+    }
+
+    protected function inlineSpecialCharacter($Excerpt)
+    {
+        if ($Excerpt['text'][0] === '&' and ! preg_match('/^&#?\w+;/', $Excerpt['text']))
+        {
+            return array(
+                'markup' => '&amp;',
+                'extent' => 1,
+            );
+        }
+
+        $SpecialCharacter = array('>' => 'gt', '<' => 'lt', '"' => 'quot');
+
+        if (isset($SpecialCharacter[$Excerpt['text'][0]]))
+        {
+            return array(
+                'markup' => '&'.$SpecialCharacter[$Excerpt['text'][0]].';',
+                'extent' => 1,
+            );
+        }
+    }
+
+    protected function inlineStrikethrough($Excerpt)
+    {
+        if ( ! isset($Excerpt['text'][1]))
+        {
+            return;
+        }
+
+        if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
+        {
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'del',
+                    'text' => $matches[1],
+                    'handler' => 'line',
+                ),
+            );
+        }
+    }
+
+    protected function inlineUrl($Excerpt)
+    {
+        if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
+        {
+            return;
+        }
+
+        if (preg_match('/\bhttps?:[\/]{2}[^\s<]+\b\/*/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $Inline = array(
+                'extent' => strlen($matches[0][0]),
+                'position' => $matches[0][1],
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $matches[0][0],
+                    'attributes' => array(
+                        'href' => $matches[0][0],
+                    ),
+                ),
+            );
+
+            return $Inline;
+        }
+    }
+
+    protected function inlineUrlTag($Excerpt)
+    {
+        if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w+:\/{2}[^ >]+)>/i', $Excerpt['text'], $matches))
+        {
+            $url = str_replace(array('&', '<'), array('&amp;', '&lt;'), $matches[1]);
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => array(
+                    'name' => 'a',
+                    'text' => $url,
+                    'attributes' => array(
+                        'href' => $url,
+                    ),
+                ),
+            );
+        }
+    }
+
+    # ~
+
+    protected function unmarkedText($text)
+    {
+        if ($this->breaksEnabled)
+        {
+            $text = preg_replace('/[ ]*\n/', "<br />\n", $text);
+        }
+        else
+        {
+            $text = preg_replace('/(?:[ ][ ]+|[ ]*\\\\)\n/', "<br />\n", $text);
+            $text = str_replace(" \n", "\n", $text);
+        }
+
+        return $text;
+    }
+
+    #
+    # Handlers
+    #
+
+    protected function element(array $Element)
+    {
+        $markup = '<'.$Element['name'];
+
+        if (isset($Element['attributes']))
+        {
+            foreach ($Element['attributes'] as $name => $value)
+            {
+                if ($value === null)
+                {
+                    continue;
+                }
+
+                $markup .= ' '.$name.'="'.$value.'"';
+            }
+        }
+
+        if (isset($Element['text']))
+        {
+            $markup .= '>';
+
+            if (isset($Element['handler']))
+            {
+                $markup .= $this->{$Element['handler']}($Element['text']);
+            }
+            else
+            {
+                $markup .= $Element['text'];
+            }
+
+            $markup .= '</'.$Element['name'].'>';
+        }
+        else
+        {
+            $markup .= ' />';
+        }
+
+        return $markup;
+    }
+
+    protected function elements(array $Elements)
+    {
+        $markup = '';
+
+        foreach ($Elements as $Element)
+        {
+            $markup .= "\n" . $this->element($Element);
+        }
+
+        $markup .= "\n";
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function li($lines)
+    {
+        $markup = $this->lines($lines);
+
+        $trimmedMarkup = trim($markup);
+
+        if ( ! in_array('', $lines) and substr($trimmedMarkup, 0, 3) === '<p>')
+        {
+            $markup = $trimmedMarkup;
+            $markup = substr($markup, 3);
+
+            $position = strpos($markup, "</p>");
+
+            $markup = substr_replace($markup, '', $position, 4);
+        }
+
+        return $markup;
+    }
+
+    #
+    # Deprecated Methods
+    #
+
+    function parse($text)
+    {
+        $markup = $this->text($text);
+
+        return $markup;
+    }
+
+    #
+    # Static Methods
+    #
+
+    static function instance($name = 'default')
+    {
+        if (isset(self::$instances[$name]))
+        {
+            return self::$instances[$name];
+        }
+
+        $instance = new static();
+
+        self::$instances[$name] = $instance;
+
+        return $instance;
+    }
+
+    private static $instances = array();
+
+    #
+    # Fields
+    #
+
+    protected $DefinitionData;
+
+    #
+    # Read-Only
+
+    protected $specialCharacters = array(
+        '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|',
+    );
+
+    protected $StrongRegex = array(
+        '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*[*])+?)[*]{2}(?![*])/s',
+        '_' => '/^__((?:\\\\_|[^_]|_[^_]*_)+?)__(?!_)/us',
+    );
+
+    protected $EmRegex = array(
+        '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
+        '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
+    );
+
+    protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:[^"\'=<>`\s]+|"[^"]*"|\'[^\']*\'))?';
+
+    protected $voidElements = array(
+        'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
+    );
+
+    protected $textLevelElements = array(
+        'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
+        'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
+        'i', 'rp', 'del', 'code',          'strike', 'marquee',
+        'q', 'rt', 'ins', 'font',          'strong',
+        's', 'tt', 'kbd', 'mark',
+        'u', 'xm', 'sub', 'nobr',
+        'sup', 'ruby',
+        'var', 'span',
+        'wbr', 'time',
+    );
+}

+ 532 - 0
system/src/Grav/Framework/Parsedown/ParsedownExtra.php

@@ -0,0 +1,532 @@
+<?php
+
+/**
+ * @package    Grav\Framework\Parsedown
+ *
+ * @copyright  Copyright (C) 2015 - 2019 Trilby Media, LLC. All rights reserved.
+ * @license    MIT License; see LICENSE file for details.
+ */
+
+namespace Grav\Framework\Parsedown;
+
+/*
+ * Parsedown Extra
+ * http://parsedown.org
+ *
+ * (c) Emanuil Rusev
+ * http://erusev.com
+ *
+ * This file ported from officiall ParsedownExtra repo and kept for compatibility.
+ */
+
+class ParsedownExtra extends Parsedown
+{
+    # ~
+
+    const version = '0.7.0';
+
+    # ~
+
+    function __construct()
+    {
+        if (parent::version < '1.5.0')
+        {
+            throw new Exception('ParsedownExtra requires a later version of Parsedown');
+        }
+
+        $this->BlockTypes[':'] []= 'DefinitionList';
+        $this->BlockTypes['*'] []= 'Abbreviation';
+
+        # identify footnote definitions before reference definitions
+        array_unshift($this->BlockTypes['['], 'Footnote');
+
+        # identify footnote markers before before links
+        array_unshift($this->InlineTypes['['], 'FootnoteMarker');
+    }
+
+    #
+    # ~
+
+    function text($text)
+    {
+        $markup = parent::text($text);
+
+        # merge consecutive dl elements
+
+        $markup = preg_replace('/<\/dl>\s+<dl>\s+/', '', $markup);
+
+        # add footnotes
+
+        if (isset($this->DefinitionData['Footnote']))
+        {
+            $Element = $this->buildFootnoteElement();
+
+            $markup .= "\n" . $this->element($Element);
+        }
+
+        return $markup;
+    }
+
+    #
+    # Blocks
+    #
+
+    #
+    # Abbreviation
+
+    protected function blockAbbreviation($Line)
+    {
+        if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches))
+        {
+            $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2];
+
+            $Block = array(
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    #
+    # Footnote
+
+    protected function blockFootnote($Line)
+    {
+        if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches))
+        {
+            $Block = array(
+                'label' => $matches[1],
+                'text' => $matches[2],
+                'hidden' => true,
+            );
+
+            return $Block;
+        }
+    }
+
+    protected function blockFootnoteContinue($Line, $Block)
+    {
+        if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text']))
+        {
+            return;
+        }
+
+        if (isset($Block['interrupted']))
+        {
+            if ($Line['indent'] >= 4)
+            {
+                $Block['text'] .= "\n\n" . $Line['text'];
+
+                return $Block;
+            }
+        }
+        else
+        {
+            $Block['text'] .= "\n" . $Line['text'];
+
+            return $Block;
+        }
+    }
+
+    protected function blockFootnoteComplete($Block)
+    {
+        $this->DefinitionData['Footnote'][$Block['label']] = array(
+            'text' => $Block['text'],
+            'count' => null,
+            'number' => null,
+        );
+
+        return $Block;
+    }
+
+    #
+    # Definition List
+
+    protected function blockDefinitionList($Line, $Block)
+    {
+        if ( ! isset($Block) or isset($Block['type']))
+        {
+            return;
+        }
+
+        $Element = array(
+            'name' => 'dl',
+            'handler' => 'elements',
+            'text' => array(),
+        );
+
+        $terms = explode("\n", $Block['element']['text']);
+
+        foreach ($terms as $term)
+        {
+            $Element['text'] []= array(
+                'name' => 'dt',
+                'handler' => 'line',
+                'text' => $term,
+            );
+        }
+
+        $Block['element'] = $Element;
+
+        $Block = $this->addDdElement($Line, $Block);
+
+        return $Block;
+    }
+
+    protected function blockDefinitionListContinue($Line, array $Block)
+    {
+        if ($Line['text'][0] === ':')
+        {
+            $Block = $this->addDdElement($Line, $Block);
+
+            return $Block;
+        }
+        else
+        {
+            if (isset($Block['interrupted']) and $Line['indent'] === 0)
+            {
+                return;
+            }
+
+            if (isset($Block['interrupted']))
+            {
+                $Block['dd']['handler'] = 'text';
+                $Block['dd']['text'] .= "\n\n";
+
+                unset($Block['interrupted']);
+            }
+
+            $text = substr($Line['body'], min($Line['indent'], 4));
+
+            $Block['dd']['text'] .= "\n" . $text;
+
+            return $Block;
+        }
+    }
+
+    #
+    # Header
+
+    protected function blockHeader($Line)
+    {
+        $Block = parent::blockHeader($Line);
+
+        if ($Block !== null && preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $attributeString = $matches[1][0];
+
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+            $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Markup
+
+    protected function blockMarkupComplete($Block)
+    {
+        if ( ! isset($Block['void']))
+        {
+            $Block['markup'] = $this->processTag($Block['markup']);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Setext
+
+    protected function blockSetextHeader($Line, array $Block = null)
+    {
+        $Block = parent::blockSetextHeader($Line, $Block);
+
+        if ($Block !== null && preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['text'], $matches, PREG_OFFSET_CAPTURE))
+        {
+            $attributeString = $matches[1][0];
+
+            $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+            $Block['element']['text'] = substr($Block['element']['text'], 0, $matches[0][1]);
+        }
+
+        return $Block;
+    }
+
+    #
+    # Inline Elements
+    #
+
+    #
+    # Footnote Marker
+
+    protected function inlineFootnoteMarker($Excerpt)
+    {
+        if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches))
+        {
+            $name = $matches[1];
+
+            if ( ! isset($this->DefinitionData['Footnote'][$name]))
+            {
+                return;
+            }
+
+            $this->DefinitionData['Footnote'][$name]['count'] ++;
+
+            if ( ! isset($this->DefinitionData['Footnote'][$name]['number']))
+            {
+                $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » &
+            }
+
+            $Element = array(
+                'name' => 'sup',
+                'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name),
+                'handler' => 'element',
+                'text' => array(
+                    'name' => 'a',
+                    'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'),
+                    'text' => $this->DefinitionData['Footnote'][$name]['number'],
+                ),
+            );
+
+            return array(
+                'extent' => strlen($matches[0]),
+                'element' => $Element,
+            );
+        }
+    }
+
+    private $footnoteCount = 0;
+
+    #
+    # Link
+
+    protected function inlineLink($Excerpt)
+    {
+        $Link = parent::inlineLink($Excerpt);
+
+        $remainder = $Link !== null ? substr($Excerpt['text'], $Link['extent']) : '';
+
+        if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches))
+        {
+            $Link['element']['attributes'] += $this->parseAttributeData($matches[1]);
+
+            $Link['extent'] += strlen($matches[0]);
+        }
+
+        return $Link;
+    }
+
+    #
+    # ~
+    #
+
+    protected function unmarkedText($text)
+    {
+        $text = parent::unmarkedText($text);
+
+        if (isset($this->DefinitionData['Abbreviation']))
+        {
+            foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning)
+            {
+                $pattern = '/\b'.preg_quote($abbreviation, '/').'\b/';
+
+                $text = preg_replace($pattern, '<abbr title="'.$meaning.'">'.$abbreviation.'</abbr>', $text);
+            }
+        }
+
+        return $text;
+    }
+
+    #
+    # Util Methods
+    #
+
+    protected function addDdElement(array $Line, array $Block)
+    {
+        $text = substr($Line['text'], 1);
+        $text = trim($text);
+
+        unset($Block['dd']);
+
+        $Block['dd'] = array(
+            'name' => 'dd',
+            'handler' => 'line',
+            'text' => $text,
+        );
+
+        if (isset($Block['interrupted']))
+        {
+            $Block['dd']['handler'] = 'text';
+
+            unset($Block['interrupted']);
+        }
+
+        $Block['element']['text'] []= & $Block['dd'];
+
+        return $Block;
+    }
+
+    protected function buildFootnoteElement()
+    {
+        $Element = array(
+            'name' => 'div',
+            'attributes' => array('class' => 'footnotes'),
+            'handler' => 'elements',
+            'text' => array(
+                array(
+                    'name' => 'hr',
+                ),
+                array(
+                    'name' => 'ol',
+                    'handler' => 'elements',
+                    'text' => array(),
+                ),
+            ),
+        );
+
+        uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes');
+
+        foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData)
+        {
+            if ( ! isset($DefinitionData['number']))
+            {
+                continue;
+            }
+
+            $text = $DefinitionData['text'];
+
+            $text = parent::text($text);
+
+            $numbers = range(1, $DefinitionData['count']);
+
+            $backLinksMarkup = '';
+
+            foreach ($numbers as $number)
+            {
+                $backLinksMarkup .= ' <a href="#fnref'.$number.':'.$definitionId.'" rev="footnote" class="footnote-backref">&#8617;</a>';
+            }
+
+            $backLinksMarkup = substr($backLinksMarkup, 1);
+
+            if (substr($text, - 4) === '</p>')
+            {
+                $backLinksMarkup = '&#160;'.$backLinksMarkup;
+
+                $text = substr_replace($text, $backLinksMarkup.'</p>', - 4);
+            }
+            else
+            {
+                $text .= "\n".'<p>'.$backLinksMarkup.'</p>';
+            }
+
+            $Element['text'][1]['text'] []= array(
+                'name' => 'li',
+                'attributes' => array('id' => 'fn:'.$definitionId),
+                'text' => "\n".$text."\n",
+            );
+        }
+
+        return $Element;
+    }
+
+    # ~
+
+    protected function parseAttributeData($attributeString)
+    {
+        $Data = array();
+
+        $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY);
+
+        foreach ($attributes as $attribute)
+        {
+            if ($attribute[0] === '#')
+            {
+                $Data['id'] = substr($attribute, 1);
+            }
+            else # "."
+            {
+                $classes []= substr($attribute, 1);
+            }
+        }
+
+        if (isset($classes))
+        {
+            $Data['class'] = implode(' ', $classes);
+        }
+
+        return $Data;
+    }
+
+    # ~
+
+    protected function processTag($elementMarkup) # recursive
+    {
+        # http://stackoverflow.com/q/1148928/200145
+        libxml_use_internal_errors(true);
+
+        $DOMDocument = new \DOMDocument;
+
+        # http://stackoverflow.com/q/11309194/200145
+        $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
+
+        # http://stackoverflow.com/q/4879946/200145
+        $DOMDocument->loadHTML($elementMarkup);
+        $DOMDocument->removeChild($DOMDocument->doctype);
+        $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild);
+
+        $elementText = '';
+
+        if ($DOMDocument->documentElement->getAttribute('markdown') === '1')
+        {
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
+            {
+                $elementText .= $DOMDocument->saveHTML($Node);
+            }
+
+            $DOMDocument->documentElement->removeAttribute('markdown');
+
+            $elementText = "\n".$this->text($elementText)."\n";
+        }
+        else
+        {
+            foreach ($DOMDocument->documentElement->childNodes as $Node)
+            {
+                $nodeMarkup = $DOMDocument->saveHTML($Node);
+
+                if ($Node instanceof \DOMElement and ! in_array($Node->nodeName, $this->textLevelElements))
+                {
+                    $elementText .= $this->processTag($nodeMarkup);
+                }
+                else
+                {
+                    $elementText .= $nodeMarkup;
+                }
+            }
+        }
+
+        # because we don't want for markup to get encoded
+        $DOMDocument->documentElement->nodeValue = 'placeholder\x1A';
+
+        $markup = $DOMDocument->saveHTML($DOMDocument->documentElement);
+        $markup = str_replace('placeholder\x1A', $elementText, $markup);
+
+        return $markup;
+    }
+
+    # ~
+
+    protected function sortFootnotes($A, $B) # callback
+    {
+        return $A['number'] - $B['number'];
+    }
+
+    #
+    # Fields
+    #
+
+    protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
+}

+ 3 - 4
system/src/Grav/Framework/Route/Route.php

@@ -286,7 +286,7 @@ class Route
             $url .= '?' . $this->getUriQuery();
         }
 
-        return $url;
+        return rtrim($url,'/');
     }
 
     /**
@@ -344,9 +344,8 @@ class Route
             $parts[] = $this->language;
         }
 
-        if ($this->route !== '') {
-            $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route;
-        }
+        $parts[] = $this->extension ? $this->route . '.' . $this->extension : $this->route;
+
 
         if ($this->gravParams) {
             $parts[] = RouteFactory::buildParams($this->gravParams);

+ 30 - 24
system/src/Grav/Framework/Session/Session.php

@@ -9,6 +9,7 @@
 
 namespace Grav\Framework\Session;
 
+use Grav\Common\User\Interfaces\UserInterface;
 use Grav\Framework\Session\Exceptions\SessionException;
 
 /**
@@ -17,16 +18,13 @@ use Grav\Framework\Session\Exceptions\SessionException;
  */
 class Session implements SessionInterface
 {
-    protected $options;
+    /** @var array */
+    protected $options = [];
 
-    /**
-     * @var bool
-     */
+    /** @var bool */
     protected $started = false;
 
-    /**
-     * @var Session
-     */
+    /** @var Session */
     protected static $instance;
 
     /**
@@ -178,9 +176,13 @@ class Session implements SessionInterface
             return $this;
         }
 
+        $sessionName = session_name();
+        $sessionExists = isset($_COOKIE[$sessionName]);
+
         // Protection against invalid session cookie names throwing exception: http://php.net/manual/en/function.session-id.php#116836
-        if (isset($_COOKIE[session_name()]) && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[session_name()])) {
-            unset($_COOKIE[session_name()]);
+        if ($sessionExists && !preg_match('/^[-,a-zA-Z0-9]{1,128}$/', $_COOKIE[$sessionName])) {
+            unset($_COOKIE[$sessionName]);
+            $sessionExists = false;
         }
 
         $options = $this->options;
@@ -197,24 +199,28 @@ class Session implements SessionInterface
             throw new SessionException('Failed to start session: ' . $error, 500);
         }
 
-        if ($user && !$user->isValid()) {
-            $this->clear();
-            throw new SessionException('User Invalid', 500);
-        }
+        $this->started = true;
 
-        $params = session_get_cookie_params();
+        if ($user && (!$user instanceof UserInterface || !$user->isValid())) {
+            $this->invalidate();
 
-        setcookie(
-            session_name(),
-            session_id(),
-            time() + $params['lifetime'],
-            $params['path'],
-            $params['domain'],
-            $params['secure'],
-            $params['httponly']
-        );
+            throw new SessionException('Invalid User object, session destroyed.', 500);
+        }
 
-        $this->started = true;
+        // Extend the lifetime of the session.
+        if ($sessionExists) {
+            $params = session_get_cookie_params();
+
+            setcookie(
+                $sessionName,
+                session_id(),
+                time() + $params['lifetime'],
+                $params['path'],
+                $params['domain'],
+                $params['secure'],
+                $params['httponly']
+            );
+        }
 
         return $this;
     }

File diff suppressed because it is too large
+ 5 - 0
user/config/plugins/aboutme.yaml


+ 2 - 0
user/config/plugins/admin-media-actions.yaml

@@ -0,0 +1,2 @@
+enabled: true
+show_samples: true

+ 9 - 7
user/config/plugins/simplesearch.yaml

@@ -1,13 +1,15 @@
-enabled: true
-built_in_css: true
-built_in_js: true
-display_button: true
-min_query_length: 3
+enabled: '1'
+built_in_css: '1'
+built_in_js: '1'
+display_button: '1'
+min_query_length: '3'
 route: /search
 search_content: raw
 template: simplesearch_results
+filters:
+  category: ''
 filter_combinator: or
-ignore_accented_characters: false
+ignore_accented_characters: '0'
 order:
   by: title
-  dir: desc
+  dir: asc

+ 5 - 0
user/config/plugins/sitemap.yaml

@@ -0,0 +1,5 @@
+enabled: true
+route: /sitemap
+changefreq: daily
+priority: !!float 1
+ignores: null

+ 3 - 5
user/config/system.yaml

@@ -1,5 +1,5 @@
 absolute_urls: false
-timezone: Europe/London
+timezone: Europe/Paris
 param_sep: ':'
 wrapped_site: false
 reverse_proxy_setup: false
@@ -9,15 +9,13 @@ username_regex: '^[a-z0-9_-]{3,16}$'
 pwd_regex: '(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}'
 intl_enabled: true
 languages:
-  supported:
-    - en
-  default_lang: en
+  default_lang: fr
   include_default_lang: false
   pages_fallback_only: false
   translations: false
   translations_fallback: false
   session_store_active: false
-  http_accept_language: true
+  http_accept_language: false
   override_locale: false
 home:
   alias: /home

+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/at-large/events.md

@@ -4,7 +4,7 @@ date: '08-08-2018 18:04'
 date_end: '24-08-2018 18:05'
 lieux: '93 Baker Street'
 artistes: ' Olivia Bax'
-title: 'at large, '
+title: 'at large'', '
 media_order: 'Olivia Bax, Boulder (with handle), 2015.jpg,Detail of Olivia Bax, Hot Spot, 2018.jpg,Olivia Bax, Love Handles, 2017.jpg,Olivia Bax, Pump, 2016.jpg,Olivia Bax, Slot _ Groove, 2015.jpg,Olivia Bax, Rumble, 2018.jpg,Installation Olivia Bax.jpg,Detail ''Footloose'', 2017.jpg,Olivia Bax, Footloose, 2017.jpg'
 show_sidebar: false
 aura:

File diff suppressed because it is too large
+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/cicatrices/events.md


File diff suppressed because it is too large
+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/exhibitions-jean-marais/events.md


File diff suppressed because it is too large
+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/mr-majidi-and-the-electricity-box/events.md


+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/nour-el-saleh-exquisite-farces/events.md

@@ -3,7 +3,7 @@ date: '05-09-2019 16:27'
 date_end: '11-10-2019 16:27'
 lieux: '12th floor'
 artistes: ' Nour El Saleh'
-title: 'Exquisite Farces, '
+title: 'Exquisite Farces'', '
 media_order: '(Not so) Musical Chairs, 2018_ Wrong Side of the Table, 2019_ The Puppeteer of Karakas, 2019.jpeg,Wrong Side of the Table, 2019.jpg,Detail of Wrong Side of the Table, 2019.jpg,A Grand Jester, 2019.jpeg,Dance Monkey, Dance!, 2019.jpg,Inhabited Props in a Box, 2018.jpeg,Detail of Inhabited Props in a Box, 2018.jpg,Four Times as Big, Twice as Small, 2018.jpg,Detail of Four Times as Big, Twice as Small, 2018.jpg'
 show_sidebar: false
 aura:

File diff suppressed because it is too large
+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/parfum-depines-phillips-x-vo-curations/events.md


+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/pas-de-corps-sonya-derviz/events.md

@@ -3,7 +3,7 @@ date: '05-08-2018 16:37'
 date_end: '18-08-2018 16:37'
 lieux: '93 Baker Street '
 artistes: ' Sonya Derviz'
-title: 'Pas de Corps, '
+title: 'Pas de Corps'', '
 media_order: 'Sonya Derviz, Dancers II, 2018.jpg'
 show_sidebar: false
 aura:

+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/sara-berman-matter-out-of-place/events.md

@@ -3,7 +3,7 @@ date: '03-10-2018 17:49'
 date_end: '19-10-2018 17:49'
 lieux: '93 Baker Street '
 artistes: ' Sara Berman'
-title: 'Matter out of Place,'
+title: 'Matter out of Place'','
 media_order: 'Sara Berman, Lint Sweater #40, 2018.jpg,Sara Berman, Lint Sweater #37, 2018.jpg,Installation 1 Sara Berman.jpg,Installation 2 Sara Berman.jpg,Sara Berman, Lint Sweater #38, 2018.jpg'
 show_sidebar: false
 aura:

+ 1 - 1
user/pages/02.whats-on/01.exhibitions/current-upcoming/urban-waves-andrei-costache-richie-culver-an-d-morgan-ward/events.md

@@ -2,8 +2,8 @@
 date: '03-07-2018 16:39'
 date_end: '13-07-2018 16:40'
 lieux: '93 Baker Street '
-title: 'URBAN WAVES, '
 artistes: ' Andrei Costache, Richie Culver an­d Morgan Ward'
+title: '‘URBAN WAVES'', '
 media_order: 'Andrei Costache, Tarantula, 2018.jpg'
 show_sidebar: false
 aura:

BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/Sola Olulode at V.O Curations.jpg


File diff suppressed because it is too large
+ 2 - 2
user/pages/02.whats-on/01.exhibitions/current-upcoming/where-the-ocean-meets-the-beach/events.md


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Dig #7 2019.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through (detail) 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Alexandre Canonico Through 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down (detail) 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/André Figueiredo da Silva Pinning Down 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson 40 Elephants 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Frances Gibson A Pound of Flower 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Installation Frances Gibson Alexandre Canonico André Figueiredo da Silva.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast (detail) 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Abrasion Coast 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall (detail) 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker Waterfall 2020.jpeg


BIN
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/Josephine Baker and Alexandré Canonico.jpeg


File diff suppressed because it is too large
+ 1 - 2
user/pages/02.whats-on/01.exhibitions/current-upcoming/wreck-or-ruin/events.md


File diff suppressed because it is too large
+ 0 - 0
user/pages/03.studio-programme/01.apply/form.md


BIN
user/pages/03.studio-programme/02.about/VO Curations - 12th Floor.jpg


File diff suppressed because it is too large
+ 1 - 1
user/pages/03.studio-programme/02.about/default.md


+ 1 - 1
user/pages/04.residence/02.current-upcoming/blog.md

@@ -5,7 +5,7 @@ show_sidebar: false
 content:
     items:
         - '@self.children'
-    limit: 5
+    limit: 999
     order:
         by: date
         dir: desc

BIN
user/pages/04.residence/02.current-upcoming/emma-prempeh/20190611_090033694_iOS.jpg


File diff suppressed because it is too large
+ 1 - 2
user/pages/04.residence/02.current-upcoming/emma-prempeh/artistes.md


BIN
user/pages/04.residence/02.current-upcoming/sola-olulode/Sola Olulode at V.O Curations.JPG


File diff suppressed because it is too large
+ 4 - 6
user/pages/04.residence/02.current-upcoming/sola-olulode/artistes.md


BIN
user/pages/04.residence/02.current-upcoming/victor-man/Nour El Saleh, Detail 'Four Times as Big, Twice as Small' 2018.jpeg


+ 0 - 11
user/pages/04.residence/02.current-upcoming/victor-man/artistes.md

@@ -1,11 +0,0 @@
----
-date_start: '05-06-2019 11:05'
-date_end: '05-09-2019 19:43'
-title: 'Nour El Saleh'
-media_order: 'Nour El Saleh, Detail ''Four Times as Big, Twice as Small'' 2018.jpeg'
-date: '05-06-2019 19:43'
-show_sidebar: false
-aura:
-    pagetype: website
----
-

+ 1 - 0
user/pages/04.residence/blog.md

@@ -1,5 +1,6 @@
 ---
 title: 'In residence'
+media_order: 'Emma Prempeh Studio Photo 2.JPG'
 redirect: /residence/about
 show_sidebar: false
 content:

+ 0 - 1
user/pages/04.residence/past/archives.md

@@ -14,4 +14,3 @@ content:
 aura:
     pagetype: website
 ---
-

+ 1 - 1
user/pages/05.news/01.press/_'phillips-x-vo-curations-presents-perfume-of-thorns'-outset-contemporary/text.md

@@ -1,9 +1,9 @@
 ---
 title: '‘Phillips x VO Curations Presents Perfume Of Thorns’'
-date_event: '23-05-2019 19:30'
 date: '23-05-2019 19:30'
 aura:
     pagetype: website
+date_event: '23-05-2019 19:30'
 ---
 
 [‘Phillips x VO Curations Presents Perfume Of Thorns’, Outset Contemporary](https://outset.org.uk/supported-projects/phillips-x-vo-curations-presents-perfume-of-thorns/)

+ 0 - 10
user/pages/05.news/01.press/_sam-cornish-on-olivia-bax/text.md

@@ -1,10 +0,0 @@
----
-title: 'Sam Cornish on Olivia Bax'
-date_event: '16-08-2018 17:31'
-date: '16-08-2018 17:31'
-aura:
-    pagetype: website
-class: small
----
-
-['Sam Cornish on Olivia Bax', Instantloveland](https://instantloveland.com/wp/2018/08/16/sam-cornish-on-olivia-bax/)

+ 0 - 9
user/pages/05.news/01.press/paintings-by-sola-olulode-explore-the-honeymoon-phase-of-romance/default.md

@@ -1,9 +0,0 @@
----
-title: 'Paintings by Sola Olulode explore the ''honeymoon'' phase of romance'
-date_event: '16-01-2020 15:38'
-date: '16-01-2020 15:38'
-aura:
-    pagetype: website
----
-
-['Paintings by Sola Olulode explore the ''honeymoon'' phase of romance', Creative Boom](https://www.creativeboom.com/inspiration/paintings-by-sola-olulode-explore-the-honeymoon-phase-of-romance/)

BIN
user/pages/05.news/02.news/_adriano-amaral-et-al-euphoria-tick-tack-antwerpen-belgium/Adriano Amaral, Untitled, 2018.jpg


+ 0 - 11
user/pages/05.news/02.news/_adriano-amaral-et-al-euphoria-tick-tack-antwerpen-belgium/news.en.md

@@ -1,11 +0,0 @@
----
-title: 'Adriano Amaral, Et Al., ''Euphoria'', Tick Tack, Antwerpen, Belgium'
-media_order: 'Adriano Amaral, Untitled, 2018.jpg'
-date: '09-11-2019 14:49'
-date_end: '21-12-2019 14:50'
-url_news: 'https://www.ticktack.be/exhibitions/euphoria-curated-by-domenico-de-chirico'
-published: true
-aura:
-    pagetype: website
----
-

+ 0 - 11
user/pages/05.news/02.news/_daiga-grantina-what-eats-around-itself-new-museum-new-york-usa/news.en.md

@@ -1,11 +0,0 @@
----
-title: 'Daiga Grantina, ''What Eats Around Itself'', New Museum, New York, USA'
-media_order: 'Daiga Grantina, ''What Eats Around Itself'', New Museum.png'
-date: '21-01-2020 14:41'
-date_end: '05-09-2020 14:41'
-url_news: 'https://www.newmuseum.org/exhibitions/view/daiga-grantina-what-eats-around-itself'
-published: true
-aura:
-    pagetype: website
----
-

BIN
user/pages/05.news/02.news/_isaac-lythgoe-railway-spine-super-dakota-brussels-belgium/Isaac Lythgoe 'Railway Spine'.jpg


+ 1 - 1
user/pages/05.news/02.news/_isaac-lythgoe-railway-spine-super-dakota-brussels-belgium/news.md

@@ -1,6 +1,6 @@
 ---
 title: 'Isaac Lythgoe, ''Railway Spine'', Super Dakota, Brussels, Belgium'
-media_order: 'Isaac Lythgoe ''Railway Spine''.jpg'
+media_order: 'Screenshot 2020-01-23 at 16.50.04.png'
 date: '09-01-2020 14:20'
 date_end: '25-02-2020 14:21'
 url_news: 'http://www.superdakota.com/exhibitions/isaac-lythgoe-railway-spine'

+ 0 - 11
user/pages/05.news/02.news/_jean-marie-appriou-biennale-de-lyon-lyon-france/news.en.md

@@ -1,11 +0,0 @@
----
-title: 'Jean-Marie Appriou, Biennale de Lyon, Lyon, France'
-date: '18-09-2019 19:52'
-date_end: '05-01-2020 19:52'
-url_news: 'https://www.biennaledelyon.com/en/artistes/jean-marie-appriou-2/'
-published: true
-aura:
-    pagetype: website
-show_sidebar: false
----
-

+ 1 - 1
user/pages/05.news/02.news/_jean-marie-appriou-the-horses-central-park-new-york-usa/news.md

@@ -2,7 +2,7 @@
 title: 'Jean-Marie Appriou, ''The Horses'', Central Park, New York, USA'
 media_order: 'Jean-Marie Appriou _The Horses_, Central Park, New York, USA .jpg'
 date: '11-09-2019 19:55'
-date_end: '15-12-2020 19:55'
+date_end: '30-08-2020 19:55'
 url_news: 'https://www.publicartfund.org/view/exhibitions/6708_jean-marie_appriou_the_horses'
 published: false
 aura:

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